import { JWTClaimValidationFailed, JWTExpired, JWTInvalid } from '../util/errors.js'; import { encoder, decoder } from './buffer_utils.js'; import { isObject } from './is_object.js'; const epoch = (date) => Math.floor(date.getTime() / 1000); const minute = 60; const hour = minute * 60; const day = hour * 24; const week = day * 7; const year = day * 365.25; const REGEX = /^(\+|\-)? ?(\d+|\d+\.\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i; export function secs(str) { const matched = REGEX.exec(str); if (!matched || (matched[4] && matched[1])) { throw new TypeError('Invalid time period format'); } const value = parseFloat(matched[2]); const unit = matched[3].toLowerCase(); let numericDate; switch (unit) { case 'sec': case 'secs': case 'second': case 'seconds': case 's': numericDate = Math.round(value); break; case 'minute': case 'minutes': case 'min': case 'mins': case 'm': numericDate = Math.round(value * minute); break; case 'hour': case 'hours': case 'hr': case 'hrs': case 'h': numericDate = Math.round(value * hour); break; case 'day': case 'days': case 'd': numericDate = Math.round(value * day); break; case 'week': case 'weeks': case 'w': numericDate = Math.round(value * week); break; default: numericDate = Math.round(value * year); break; } if (matched[1] === '-' || matched[4] === 'ago') { return -numericDate; } return numericDate; } function validateInput(label, input) { if (!Number.isFinite(input)) { throw new TypeError(`Invalid ${label} input`); } return input; } const normalizeTyp = (value) => { if (value.includes('/')) { return value.toLowerCase(); } return `application/${value.toLowerCase()}`; }; const checkAudiencePresence = (audPayload, audOption) => { if (typeof audPayload === 'string') { return audOption.includes(audPayload); } if (Array.isArray(audPayload)) { return audOption.some(Set.prototype.has.bind(new Set(audPayload))); } return false; }; export function validateClaimsSet(protectedHeader, encodedPayload, options = {}) { let payload; try { payload = JSON.parse(decoder.decode(encodedPayload)); } catch { } if (!isObject(payload)) { throw new JWTInvalid('JWT Claims Set must be a top-level JSON object'); } const { typ } = options; if (typ && (typeof protectedHeader.typ !== 'string' || normalizeTyp(protectedHeader.typ) !== normalizeTyp(typ))) { throw new JWTClaimValidationFailed('unexpected "typ" JWT header value', payload, 'typ', 'check_failed'); } const { requiredClaims = [], issuer, subject, audience, maxTokenAge } = options; const presenceCheck = [...requiredClaims]; if (maxTokenAge !== undefined) presenceCheck.push('iat'); if (audience !== undefined) presenceCheck.push('aud'); if (subject !== undefined) presenceCheck.push('sub'); if (issuer !== undefined) presenceCheck.push('iss'); for (const claim of new Set(presenceCheck.reverse())) { if (!(claim in payload)) { throw new JWTClaimValidationFailed(`missing required "${claim}" claim`, payload, claim, 'missing'); } } if (issuer && !(Array.isArray(issuer) ? issuer : [issuer]).includes(payload.iss)) { throw new JWTClaimValidationFailed('unexpected "iss" claim value', payload, 'iss', 'check_failed'); } if (subject && payload.sub !== subject) { throw new JWTClaimValidationFailed('unexpected "sub" claim value', payload, 'sub', 'check_failed'); } if (audience && !checkAudiencePresence(payload.aud, typeof audience === 'string' ? [audience] : audience)) { throw new JWTClaimValidationFailed('unexpected "aud" claim value', payload, 'aud', 'check_failed'); } let tolerance; switch (typeof options.clockTolerance) { case 'string': tolerance = secs(options.clockTolerance); break; case 'number': tolerance = options.clockTolerance; break; case 'undefined': tolerance = 0; break; default: throw new TypeError('Invalid clockTolerance option type'); } const { currentDate } = options; const now = epoch(currentDate || new Date()); if ((payload.iat !== undefined || maxTokenAge) && typeof payload.iat !== 'number') { throw new JWTClaimValidationFailed('"iat" claim must be a number', payload, 'iat', 'invalid'); } if (payload.nbf !== undefined) { if (typeof payload.nbf !== 'number') { throw new JWTClaimValidationFailed('"nbf" claim must be a number', payload, 'nbf', 'invalid'); } if (payload.nbf > now + tolerance) { throw new JWTClaimValidationFailed('"nbf" claim timestamp check failed', payload, 'nbf', 'check_failed'); } } if (payload.exp !== undefined) { if (typeof payload.exp !== 'number') { throw new JWTClaimValidationFailed('"exp" claim must be a number', payload, 'exp', 'invalid'); } if (payload.exp <= now - tolerance) { throw new JWTExpired('"exp" claim timestamp check failed', payload, 'exp', 'check_failed'); } } if (maxTokenAge) { const age = now - payload.iat; const max = typeof maxTokenAge === 'number' ? maxTokenAge : secs(maxTokenAge); if (age - tolerance > max) { throw new JWTExpired('"iat" claim timestamp check failed (too far in the past)', payload, 'iat', 'check_failed'); } if (age < 0 - tolerance) { throw new JWTClaimValidationFailed('"iat" claim timestamp check failed (it should be in the past)', payload, 'iat', 'check_failed'); } } return payload; } export class JWTClaimsBuilder { #payload; constructor(payload) { if (!isObject(payload)) { throw new TypeError('JWT Claims Set MUST be an object'); } this.#payload = structuredClone(payload); } data() { return encoder.encode(JSON.stringify(this.#payload)); } get iss() { return this.#payload.iss; } set iss(value) { this.#payload.iss = value; } get sub() { return this.#payload.sub; } set sub(value) { this.#payload.sub = value; } get aud() { return this.#payload.aud; } set aud(value) { this.#payload.aud = value; } set jti(value) { this.#payload.jti = value; } set nbf(value) { if (typeof value === 'number') { this.#payload.nbf = validateInput('setNotBefore', value); } else if (value instanceof Date) { this.#payload.nbf = validateInput('setNotBefore', epoch(value)); } else { this.#payload.nbf = epoch(new Date()) + secs(value); } } set exp(value) { if (typeof value === 'number') { this.#payload.exp = validateInput('setExpirationTime', value); } else if (value instanceof Date) { this.#payload.exp = validateInput('setExpirationTime', epoch(value)); } else { this.#payload.exp = epoch(new Date()) + secs(value); } } set iat(value) { if (value === undefined) { this.#payload.iat = epoch(new Date()); } else if (value instanceof Date) { this.#payload.iat = validateInput('setIssuedAt', epoch(value)); } else if (typeof value === 'string') { this.#payload.iat = validateInput('setIssuedAt', epoch(new Date()) + secs(value)); } else { this.#payload.iat = validateInput('setIssuedAt', value); } } }