239 lines
8.0 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|