From eeea91df48cadda84e4fdce6bbba7251ca7af83f Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 14 Apr 2023 12:06:59 +0200 Subject: [PATCH] feat: add requiredClaims JWT validation option --- src/lib/jwt_claims_set.ts | 23 ++++++++++++++++------- src/types.d.ts | 10 ++++++++++ test/jwt/verify.test.mjs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/lib/jwt_claims_set.ts b/src/lib/jwt_claims_set.ts index 62b45f9f79..205d37b6d4 100644 --- a/src/lib/jwt_claims_set.ts +++ b/src/lib/jwt_claims_set.ts @@ -51,17 +51,27 @@ export default ( throw new JWTInvalid('JWT Claims Set must be a top-level JSON object') } - const { issuer } = options + const { requiredClaims = [], issuer, subject, audience, maxTokenAge } = options + + if (maxTokenAge !== undefined) requiredClaims.push('iat') + if (audience !== undefined) requiredClaims.push('aud') + if (subject !== undefined) requiredClaims.push('sub') + if (issuer !== undefined) requiredClaims.push('iss') + + for (const claim of new Set(requiredClaims.reverse())) { + if (!(claim in payload)) { + throw new JWTClaimValidationFailed(`missing required "${claim}" claim`, claim, 'missing') + } + } + if (issuer && !((Array.isArray(issuer) ? issuer : [issuer])).includes(payload.iss!)) { throw new JWTClaimValidationFailed('unexpected "iss" claim value', 'iss', 'check_failed') } - const { subject } = options if (subject && payload.sub !== subject) { throw new JWTClaimValidationFailed('unexpected "sub" claim value', 'sub', 'check_failed') } - const { audience } = options if ( audience && !checkAudiencePresence(payload.aud, typeof audience === 'string' ? [audience] : audience) @@ -87,7 +97,7 @@ export default ( const { currentDate } = options const now = epoch(currentDate || new Date()) - if ((payload.iat !== undefined || options.maxTokenAge) && typeof payload.iat !== 'number') { + if ((payload.iat !== undefined || maxTokenAge) && typeof payload.iat !== 'number') { throw new JWTClaimValidationFailed('"iat" claim must be a number', 'iat', 'invalid') } @@ -113,10 +123,9 @@ export default ( } } - if (options.maxTokenAge) { + if (maxTokenAge) { const age = now - payload.iat! - const max = - typeof options.maxTokenAge === 'number' ? options.maxTokenAge : secs(options.maxTokenAge) + const max = typeof maxTokenAge === 'number' ? maxTokenAge : secs(maxTokenAge) if (age - tolerance > max) { throw new JWTExpired( diff --git a/src/types.d.ts b/src/types.d.ts index 3499fc2240..55b3f523b0 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -457,6 +457,16 @@ export interface JWTClaimVerificationOptions { /** Date to use when comparing NumericDate claims, defaults to `new Date()`. */ currentDate?: Date + + /** + * Array of required Claim Names that must be present in the JWT Claims Set. Default is that: if + * the {@link JWTClaimVerificationOptions.issuer issuer option} is set, then "iss" must be present; + * if the {@link JWTClaimVerificationOptions.audience audience option} is set, then "aud" must be + * present; if the {@link JWTClaimVerificationOptions.subject subject option} is set, then "sub" + * must be present; if the {@link JWTClaimVerificationOptions.maxTokenAge maxTokenAge option} is + * set, then "iat" must be present. + */ + requiredClaims?: string[] } /** JWS Verification options. */ diff --git a/test/jwt/verify.test.mjs b/test/jwt/verify.test.mjs index d07dbc5f5e..a9b412705d 100644 --- a/test/jwt/verify.test.mjs +++ b/test/jwt/verify.test.mjs @@ -410,3 +410,34 @@ test('signatures are compared before claim set', async (t) => { code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', }) }) + +test('requiredClaims claims check', async (t) => { + const jwt = await new SignJWT({ + ...t.context.payload, + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(t.context.secret) + + for (const [claim, option] of [ + ['iss', 'issuer'], + ['aud', 'audience'], + ['iat', 'maxTokenAge'], + ['sub', 'subject'], + ]) { + await t.throwsAsync(jwtVerify(jwt, t.context.secret, { [option]: 'foo' }), { + code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', + message: `missing required "${claim}" claim`, + }) + await t.throwsAsync( + jwtVerify(jwt, t.context.secret, { [option]: 'foo', requiredClaims: ['nbf'] }), + { + code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', + message: `missing required "${claim}" claim`, + }, + ) + } + await t.throwsAsync(jwtVerify(jwt, t.context.secret, { requiredClaims: ['nbf'] }), { + code: 'ERR_JWT_CLAIM_VALIDATION_FAILED', + message: `missing required "nbf" claim`, + }) +})