2025-12-03 20:54:35 +01:00

239 lines
8.0 KiB
JavaScript

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);
}
}
}