From fc08426466233709b442ba21232768ddeeb94e56 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 20 Feb 2020 13:42:26 +0100 Subject: [PATCH] feat: add JWT.verify "typ" option for checking JWT Type Header parameter --- docs/README.md | 3 +++ lib/jwt/verify.js | 24 ++++++++++++++---------- test/jwt/verify.test.js | 29 ++++++++++++++++++++++++++--- types/index.d.ts | 1 + 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/docs/README.md b/docs/README.md index 72a38d5eb0..ca716b79a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -893,6 +893,9 @@ Verifies the claims and signature of a JSON Web Token. `subject` depending on the use-case. - `audience`: `` | `string[]` Expected audience value(s). When string an exact match must be found in the payload, when array at least one must be matched. + - `typ`: `` Expected JWT "typ" Header Parameter value. An exact match must be found in the + JWT header. **Default:** 'undefined' unless a `profile` with a specific value is used, in which + case this option will be ignored. - `clockTolerance`: `` Clock Tolerance for comparing timestamps, provided as timespan string e.g. `120s`, `2 minutes`, etc. **Default:** no clock tolerance - `complete`: `` When false only the parsed payload is returned, otherwise an object with diff --git a/lib/jwt/verify.js b/lib/jwt/verify.js index dd8af88f4e..78e55c03f9 100644 --- a/lib/jwt/verify.js +++ b/lib/jwt/verify.js @@ -40,7 +40,7 @@ const isNotArrayOfStrings = val => !Array.isArray(val) || val.length === 0 || va const validateOptions = ({ algorithms, audience, clockTolerance, complete = false, crit, ignoreExp = false, ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(), - profile, subject + profile, subject, typ }) => { isOptionString(profile, 'options.profile') @@ -66,6 +66,7 @@ const validateOptions = ({ isOptionString(maxAuthAge, 'options.maxAuthAge') isOptionString(jti, 'options.jti') isOptionString(clockTolerance, 'options.clockTolerance') + isOptionString(typ, 'options.typ') if (audience !== undefined && (isNotString(audience) && isNotArrayOfStrings(audience))) { throw new TypeError('options.audience must be a string or an array of strings') @@ -109,6 +110,8 @@ const validateOptions = ({ throw new TypeError('"audience" option is required to validate a JWT Access Token') } + typ = ATJWT + break case LOGOUTTOKEN: if (!issuer) { @@ -142,7 +145,8 @@ const validateOptions = ({ nonce, now, profile, - subject + subject, + typ } } @@ -156,15 +160,15 @@ const validateTypes = ({ header, payload }, profile, options) => { isPayloadString(payload.jti, '"jti" claim', 'jti', profile === LOGOUTTOKEN || !!options.jti) isPayloadString(payload.acr, '"acr" claim', 'acr') isPayloadString(payload.nonce, '"nonce" claim', 'nonce', !!options.nonce) - isPayloadString(payload.iss, '"iss" claim', 'iss', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.issuer) + isPayloadString(payload.iss, '"iss" claim', 'iss', !!options.issuer) isPayloadString(payload.sub, '"sub" claim', 'sub', profile === IDTOKEN || profile === ATJWT || !!options.subject) - isStringOrArrayOfStrings(payload.aud, 'aud', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.audience) + isStringOrArrayOfStrings(payload.aud, 'aud', !!options.audience) isPayloadString(payload.azp, '"azp" claim', 'azp', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1) isStringOrArrayOfStrings(payload.amr, 'amr') + isPayloadString(header.typ, '"typ" header parameter', 'typ', !!options.typ) if (profile === ATJWT) { isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true) - isPayloadString(header.typ, '"typ" header parameter', 'typ', true) } if (profile === LOGOUTTOKEN) { @@ -221,7 +225,7 @@ module.exports = (token, key, options = {}) => { const { algorithms, audience, clockTolerance, complete, crit, ignoreExp, ignoreIat, ignoreNbf, issuer, - jti, maxAuthAge, maxTokenAge, nonce, now, profile, subject + jti, maxAuthAge, maxTokenAge, nonce, now, profile, subject, typ } = options = validateOptions(options) const decoded = decode(token, { complete: true }) @@ -257,6 +261,10 @@ module.exports = (token, key, options = {}) => { throw new JWTClaimInvalid('unexpected "aud" claim value', 'aud', 'check_failed') } + if (typ && decoded.header.typ !== typ) { + throw new JWTClaimInvalid('unexpected "typ" JWT header value', 'typ', 'check_failed') + } + const tolerance = clockTolerance ? secs(clockTolerance) : 0 if (maxAuthAge) { @@ -295,9 +303,5 @@ module.exports = (token, key, options = {}) => { throw new JWTClaimInvalid('unexpected "azp" claim value', 'azp', 'check_failed') } - if (profile === ATJWT && decoded.header.typ !== ATJWT) { - throw new JWTClaimInvalid('invalid JWT typ header value for the used validation profile', 'typ', 'check_failed') - } - return complete ? decoded : decoded.payload } diff --git a/test/jwt/verify.test.js b/test/jwt/verify.test.js index 844c51a4dd..fdeb1a2021 100644 --- a/test/jwt/verify.test.js +++ b/test/jwt/verify.test.js @@ -153,9 +153,9 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { Object.entries({ issuer: 'iss', + jti: 'jti', nonce: 'nonce', - subject: 'sub', - jti: 'jti' + subject: 'sub' }).forEach(([option, claim]) => { test(`option.${option} validation fails`, t => { let err @@ -181,6 +181,29 @@ Object.entries({ }) }) +test('option.typ validation fails', t => { + let err + err = t.throws(() => { + const invalid = JWT.sign({}, key, { header: { typ: 'foo' } }) + JWT.verify(invalid, key, { typ: 'bar' }) + }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "typ" JWT header value' }) + t.is(err.claim, 'typ') + t.is(err.reason, 'check_failed') + + err = t.throws(() => { + const invalid = JWS.sign({}, key, { header: { typ: undefined } }) + JWT.verify(invalid, key, { typ: 'bar' }) + }, { instanceOf: errors.JWTClaimInvalid, message: '"typ" header parameter is missing' }) + t.is(err.claim, 'typ') + t.is(err.reason, 'missing') +}) + +test('option.typ validation success', t => { + const token = JWT.sign({}, key, { header: { typ: 'foo' } }) + JWT.verify(token, key, { typ: 'foo' }) + t.pass() +}) + test('option.audience validation fails', t => { let err err = t.throws(() => { @@ -822,7 +845,7 @@ test('must be a supported value', t => { key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) - }, { instanceOf: errors.JWTClaimInvalid, message: 'invalid JWT typ header value for the used validation profile' }) + }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "typ" JWT header value' }) t.is(err.claim, 'typ') t.is(err.reason, 'check_failed') }) diff --git a/types/index.d.ts b/types/index.d.ts index d6843234bd..ff8ca38a32 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -431,6 +431,7 @@ export namespace JWT { audience?: string | string[]; algorithms?: string[]; nonce?: string; + typ?: string; now?: Date; crit?: string[]; profile?: JWTProfiles;