From a0c0c7ad70f42d9b23b3e71de43599a8ac6fe1ff Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 16 Jan 2020 08:49:37 +0100 Subject: [PATCH] feat: add JWTExpired error and JWTClaimInvalid claim and reason props Resolves #62 --- docs/README.md | 53 +++++++--- lib/errors.js | 11 +- lib/jwt/shared_validations.js | 6 +- lib/jwt/verify.js | 66 ++++++------ test/jwt/verify.test.js | 189 +++++++++++++++++++++++++--------- types/index.d.ts | 40 ++++--- 6 files changed, 253 insertions(+), 112 deletions(-) diff --git a/docs/README.md b/docs/README.md index 5f60da5aa2..b16698c5ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1403,6 +1403,7 @@ Verifies the provided JWE in either serialization with a given `` or `< - [Class: <JOSEAlgNotWhitelisted>](#class-josealgnotwhitelisted) - [Class: <JOSECritNotUnderstood>](#class-josecritnotunderstood) - [Class: <JOSEMultiError>](#class-josemultierror) +- [Class: <JOSEInvalidEncoding>](#class-joseinvalidencoding) - [Class: <JOSENotSupported>](#class-josenotsupported) - [Class: <JWEDecryptionFailed>](#class-jwedecryptionfailed) - [Class: <JWEInvalid>](#class-jweinvalid) @@ -1413,6 +1414,7 @@ Verifies the provided JWE in either serialization with a given `` or `< - [Class: <JWSInvalid>](#class-jwsinvalid) - [Class: <JWSVerificationFailed>](#class-jwsverificationfailed) - [Class: <JWTClaimInvalid>](#class-jwtclaiminvalid) +- [Class: <JWTExpired>](#class-jwtexpired) - [Class: <JWTMalformed>](#class-jwtmalformed) @@ -1435,7 +1437,7 @@ Base Error the others inherit from. Thrown when an algorithm whitelist is provided but the validated JWE/JWS does not use one from it. ```js -if (err.code === 'ERR_JOSE_ALG_NOT_WHITELISTED') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_ALG_NOT_WHITELISTED') { // ... } ``` @@ -1446,7 +1448,7 @@ Thrown when a Critical member is encountered that's not acknowledged. The only b handler is for "b64", it must still be acknowledged though. ```js -if (err.code === 'ERR_JOSE_CRIT_NOT_UNDERSTOOD') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_CRIT_NOT_UNDERSTOOD') { // ... } ``` @@ -1464,7 +1466,7 @@ The error is an [Iterable](https://developer.mozilla.org/en-US/docs/Web/JavaScri and yields every single one of the encountered errors. ```js -if (err.code === 'ERR_JOSE_MULTIPLE_ERRORS') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_MULTIPLE_ERRORS') { for (const e of err) { console.log(e) // ... @@ -1472,12 +1474,22 @@ if (err.code === 'ERR_JOSE_MULTIPLE_ERRORS') { } ``` +#### Class: `JOSEInvalidEncoding` + +Thrown when invalid base64url encoding is detected. + +```js +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_INVALID_ENCODING') { + // ... +} +``` + #### Class: `JOSENotSupported` Thrown when an unsupported "alg", "kty" or specific header value like "zip" is encountered. ```js -if (err.code === 'ERR_JOSE_NOT_SUPPORTED') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JOSE_NOT_SUPPORTED') { // ... } ``` @@ -1488,7 +1500,7 @@ Thrown when JWE decrypt operations are started but fail to decrypt. Only generic provided. ```js -if (err.code === 'ERR_JWE_DECRYPTION_FAILED') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWE_DECRYPTION_FAILED') { // ... } ``` @@ -1498,7 +1510,7 @@ if (err.code === 'ERR_JWE_DECRYPTION_FAILED') { Thrown when syntactically incorrect JWE is either requested to be encrypted or decrypted ```js -if (err.code === 'ERR_JWE_INVALID') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWE_INVALID') { // ... } ``` @@ -1508,7 +1520,7 @@ if (err.code === 'ERR_JWE_INVALID') { Thrown when a key failed to import as `` ```js -if (err.code === 'ERR_JWK_IMPORT_FAILED') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWK_IMPORT_FAILED') { // ... } ``` @@ -1518,7 +1530,7 @@ if (err.code === 'ERR_JWK_IMPORT_FAILED') { Thrown when key's parameters are invalid, e.g. key_ops and use values are inconsistent. ```js -if (err.code === 'ERR_JWK_INVALID') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWK_INVALID') { // ... } ``` @@ -1528,7 +1540,7 @@ if (err.code === 'ERR_JWK_INVALID') { Thrown when a key does not support the request algorithm. ```js -if (err.code === 'ERR_JWK_KEY_SUPPORT') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWK_KEY_SUPPORT') { // ... } ``` @@ -1539,7 +1551,7 @@ Thrown when `` is used as argument for decrypt / verify operation for the crypto operation is found in it ```js -if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWKS_NO_MATCHING_KEY') { // ... } ``` @@ -1550,7 +1562,7 @@ Thrown when syntactically incorrect JWS is either requested to be signed or verified ```js -if (err.code === 'ERR_JWS_INVALID') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWS_INVALID') { // ... } ``` @@ -1561,7 +1573,7 @@ Thrown when JWS verify operations are started but fail to verify. Only generic e provided. ```js -if (err.code === 'ERR_JWS_VERIFICATION_FAILED') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWS_VERIFICATION_FAILED') { // ... } ``` @@ -1569,9 +1581,22 @@ if (err.code === 'ERR_JWS_VERIFICATION_FAILED') { #### Class: `JWTClaimInvalid` Thrown when JWT Claim is either of incorrect type or fails to validate by the provided options. +Instances of `` have a `claim` property with the name of the claim as well as +`reason` property with one of the following values `'prohibited' | 'missing' | 'invalid' | 'check_failed' | 'unspecified'`. + +```js +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWT_CLAIM_INVALID') { + // ... +} +``` + +#### Class: `JWTExpired` + +Thrown when the JWT Claims indicate the JWT is expired by the provided options. `` +is a descendant of `` with a unique `code` property. ```js -if (err.code === 'ERR_JWT_CLAIM_INVALID') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWT_EXPIRED') { // ... } ``` @@ -1581,7 +1606,7 @@ if (err.code === 'ERR_JWT_CLAIM_INVALID') { Thrown when malformed JWT is either being decoded or verified. ```js -if (err.code === 'ERR_JWT_MALFORMED') { +if (err instanceof jose.errors.JOSEError && err.code === 'ERR_JWT_MALFORMED') { // ... } ``` diff --git a/lib/errors.js b/lib/errors.js index 7c7fa68506..bc997cdd78 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -13,6 +13,7 @@ const CODES = { JWSInvalid: 'ERR_JWS_INVALID', JWSVerificationFailed: 'ERR_JWS_VERIFICATION_FAILED', JWTClaimInvalid: 'ERR_JWT_CLAIM_INVALID', + JWTExpired: 'ERR_JWT_EXPIRED', JWTMalformed: 'ERR_JWT_MALFORMED' } @@ -73,5 +74,13 @@ module.exports.JWKSNoMatchingKey = class JWKSNoMatchingKey extends JOSEError {} module.exports.JWSInvalid = class JWSInvalid extends JOSEError {} module.exports.JWSVerificationFailed = class JWSVerificationFailed extends JOSEError {} -module.exports.JWTClaimInvalid = class JWTClaimInvalid extends JOSEError {} +class JWTClaimInvalid extends JOSEError { + constructor (message, claim = 'unspecified', reason = 'unspecified') { + super(message) + this.claim = claim + this.reason = reason + } +} +module.exports.JWTClaimInvalid = JWTClaimInvalid +module.exports.JWTExpired = class JWTExpired extends JWTClaimInvalid {} module.exports.JWTMalformed = class JWTMalformed extends JOSEError {} diff --git a/lib/jwt/shared_validations.js b/lib/jwt/shared_validations.js index 099a64b19a..276d187024 100644 --- a/lib/jwt/shared_validations.js +++ b/lib/jwt/shared_validations.js @@ -1,12 +1,12 @@ const isNotString = val => typeof val !== 'string' || val.length === 0 module.exports.isNotString = isNotString -module.exports.isString = function isString (Err, value, label, required = false) { +module.exports.isString = function isString (Err, value, label, claim, required = false) { if (required && value === undefined) { - throw new Err(`${label} is missing`) + throw new Err(`${label} is missing`, claim, 'missing') } if (value !== undefined && isNotString(value)) { - throw new Err(`${label} must be a string`) + throw new Err(`${label} must be a string`, claim, 'invalid') } } diff --git a/lib/jwt/verify.js b/lib/jwt/verify.js index 6e07a4223c..364c0a31c1 100644 --- a/lib/jwt/verify.js +++ b/lib/jwt/verify.js @@ -4,7 +4,7 @@ const secs = require('../help/secs') const getKey = require('../help/get_key') const JWS = require('../jws') const { KeyStore } = require('../jwks') -const { JWTClaimInvalid } = require('../errors') +const { JWTClaimInvalid, JWTExpired } = require('../errors') const { isString, isNotString } = require('./shared_validations') const decode = require('./decode') @@ -18,21 +18,21 @@ const ATJWT = 'at+JWT' const isTimestamp = (value, label, required = false) => { if (required && value === undefined) { - throw new JWTClaimInvalid(`"${label}" claim is missing`) + throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing') } if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value))) { - throw new JWTClaimInvalid(`"${label}" claim must be a unix timestamp`) + throw new JWTClaimInvalid(`"${label}" claim must be a unix timestamp`, label, 'invalid') } } const isStringOrArrayOfStrings = (value, label, required = false) => { if (required && value === undefined) { - throw new JWTClaimInvalid(`"${label}" claim is missing`) + throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing') } if (value !== undefined && (isNotString(value) && isNotArrayOfStrings(value))) { - throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`) + throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`, label, 'invalid') } } @@ -148,51 +148,51 @@ const validateOptions = ({ } const validateTypes = ({ header, payload }, profile, options) => { - isPayloadString(header.alg, '"alg" header parameter', true) + isPayloadString(header.alg, '"alg" header parameter', 'alg', true) isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN || !!options.maxTokenAge) isTimestamp(payload.exp, 'exp', profile === IDTOKEN || profile === ATJWT) isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge) isTimestamp(payload.nbf, 'nbf') - isPayloadString(payload.jti, '"jti" claim', profile === LOGOUTTOKEN || !!options.jti) - isPayloadString(payload.acr, '"acr" claim') - isPayloadString(payload.nonce, '"nonce" claim', !!options.nonce) - isPayloadString(payload.iss, '"iss" claim', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.issuer) - isPayloadString(payload.sub, '"sub" claim', profile === IDTOKEN || profile === ATJWT || !!options.subject) + 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.sub, '"sub" claim', 'sub', profile === IDTOKEN || profile === ATJWT || !!options.subject) isStringOrArrayOfStrings(payload.aud, 'aud', profile === IDTOKEN || profile === ATJWT || profile === LOGOUTTOKEN || !!options.audience) - isPayloadString(payload.azp, '"azp" claim', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1) + isPayloadString(payload.azp, '"azp" claim', 'azp', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1) isStringOrArrayOfStrings(payload.amr, 'amr') if (profile === ATJWT) { - isPayloadString(payload.client_id, '"client_id" claim', true) - isPayloadString(header.typ, '"typ" header parameter', true) + isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true) + isPayloadString(header.typ, '"typ" header parameter', 'typ', true) } if (profile === LOGOUTTOKEN) { - isPayloadString(payload.sid, '"sid" claim') + isPayloadString(payload.sid, '"sid" claim', 'sid') if (!('sid' in payload) && !('sub' in payload)) { throw new JWTClaimInvalid('either "sid" or "sub" (or both) claims must be present') } if ('nonce' in payload) { - throw new JWTClaimInvalid('"nonce" claim is prohibited') + throw new JWTClaimInvalid('"nonce" claim is prohibited', 'nonce', 'prohibited') } if (!('events' in payload)) { - throw new JWTClaimInvalid('"events" claim is missing') + throw new JWTClaimInvalid('"events" claim is missing', 'events', 'missing') } if (!isObject(payload.events)) { - throw new JWTClaimInvalid('"events" claim must be an object') + throw new JWTClaimInvalid('"events" claim must be an object', 'events', 'invalid') } if (!('http://schemas.openid.net/event/backchannel-logout' in payload.events)) { - throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim') + throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim', 'events', 'invalid') } if (!isObject(payload.events['http://schemas.openid.net/event/backchannel-logout'])) { - throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object') + throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object', 'events', 'invalid') } } } @@ -231,23 +231,23 @@ module.exports = (token, key, options = {}) => { validateTypes(decoded, profile, options) if (issuer && decoded.payload.iss !== issuer) { - throw new JWTClaimInvalid('unexpected "iss" claim value') + throw new JWTClaimInvalid('unexpected "iss" claim value', 'iss', 'check_failed') } if (nonce && decoded.payload.nonce !== nonce) { - throw new JWTClaimInvalid('unexpected "nonce" claim value') + throw new JWTClaimInvalid('unexpected "nonce" claim value', 'nonce', 'check_failed') } if (subject && decoded.payload.sub !== subject) { - throw new JWTClaimInvalid('unexpected "sub" claim value') + throw new JWTClaimInvalid('unexpected "sub" claim value', 'sub', 'check_failed') } if (jti && decoded.payload.jti !== jti) { - throw new JWTClaimInvalid('unexpected "jti" claim value') + throw new JWTClaimInvalid('unexpected "jti" claim value', 'jti', 'check_failed') } if (audience && !checkAudiencePresence(decoded.payload.aud, typeof audience === 'string' ? [audience] : audience, profile)) { - throw new JWTClaimInvalid('unexpected "aud" claim value') + throw new JWTClaimInvalid('unexpected "aud" claim value', 'aud', 'check_failed') } const tolerance = clockTolerance ? secs(clockTolerance) : 0 @@ -255,20 +255,20 @@ module.exports = (token, key, options = {}) => { if (maxAuthAge) { const maxAuthAgeSeconds = secs(maxAuthAge) if (decoded.payload.auth_time + maxAuthAgeSeconds < unix - tolerance) { - throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)') + throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)', 'auth_time', 'check_failed') } } if (!ignoreIat && !('exp' in decoded.payload) && 'iat' in decoded.payload && decoded.payload.iat > unix + tolerance) { - throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)') + throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed') } if (!ignoreNbf && 'nbf' in decoded.payload && decoded.payload.nbf > unix + tolerance) { - throw new JWTClaimInvalid('"nbf" claim timestamp check failed') + throw new JWTClaimInvalid('"nbf" claim timestamp check failed', 'nbf', 'check_failed') } if (!ignoreExp && 'exp' in decoded.payload && decoded.payload.exp <= unix - tolerance) { - throw new JWTClaimInvalid('"exp" claim timestamp check failed') + throw new JWTExpired('"exp" claim timestamp check failed', 'exp', 'check_failed') } if (maxTokenAge) { @@ -276,20 +276,20 @@ module.exports = (token, key, options = {}) => { const max = secs(maxTokenAge) if (age - tolerance > max) { - throw new JWTClaimInvalid('"iat" claim timestamp check failed (too far in the past)') + throw new JWTExpired('"iat" claim timestamp check failed (too far in the past)', 'iat', 'check_failed') } if (age < 0 - tolerance) { - throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)') + throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed') } } if (profile === IDTOKEN && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) { - throw new JWTClaimInvalid('unexpected "azp" claim value') + 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') + throw new JWTClaimInvalid('invalid JWT typ header value for the used validation profile', 'typ', 'check_failed') } key = getKey(key, true) diff --git a/test/jwt/verify.test.js b/test/jwt/verify.test.js index f7b0e77b29..8a8f228620 100644 --- a/test/jwt/verify.test.js +++ b/test/jwt/verify.test.js @@ -106,10 +106,13 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { ;['iat', 'exp', 'auth_time', 'nbf'].forEach((claim) => { test(`"${claim} must be a timestamp when provided"`, t => { ;['', 'foo', true, null, [], {}].forEach((val) => { - t.throws(() => { + const err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: val })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a unix timestamp` }) + + t.is(err.claim, claim) + t.is(err.reason, 'invalid') }) }) }) @@ -117,10 +120,13 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { ;['jti', 'acr', 'iss', 'nonce', 'sub', 'azp'].forEach((claim) => { test(`"${claim} must be a string when provided"`, t => { ;['', 0, 1, true, null, [], {}].forEach((val) => { - t.throws(() => { + const err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: val })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a string` }) + + t.is(err.claim, claim) + t.is(err.reason, 'invalid') }) }) }) @@ -128,14 +134,20 @@ test('options.ignoreIat & options.maxTokenAge may not be used together', t => { ;['aud', 'amr'].forEach((claim) => { test(`"${claim} must be a string when provided"`, t => { ;['', 0, 1, true, null, [], {}].forEach((val) => { - t.throws(() => { + let err + err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: val })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a string or array of strings` }) - t.throws(() => { + t.is(err.claim, claim) + t.is(err.reason, 'invalid') + + err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: [val] })}.` JWT.verify(invalid, key) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim must be a string or array of strings` }) + t.is(err.claim, claim) + t.is(err.reason, 'invalid') }) }) }) @@ -147,14 +159,20 @@ Object.entries({ jti: 'jti' }).forEach(([option, claim]) => { test(`option.${option} validation fails`, t => { - t.throws(() => { + let err + err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: 'foo' })}.` JWT.verify(invalid, key, { [option]: 'bar' }) }, { instanceOf: errors.JWTClaimInvalid, message: `unexpected "${claim}" claim value` }) - t.throws(() => { + t.is(err.claim, claim) + t.is(err.reason, 'check_failed') + + err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ [claim]: undefined })}.` JWT.verify(invalid, key, { [option]: 'bar' }) }, { instanceOf: errors.JWTClaimInvalid, message: `"${claim}" claim is missing` }) + t.is(err.claim, claim) + t.is(err.reason, 'missing') }) test(`option.${option} validation success`, t => { @@ -165,14 +183,20 @@ Object.entries({ }) test('option.audience validation fails', t => { - t.throws(() => { + let err + err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ aud: 'foo' })}.` JWT.verify(invalid, key, { audience: 'bar' }) }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "aud" claim value' }) - t.throws(() => { + t.is(err.claim, 'aud') + t.is(err.reason, 'check_failed') + + err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ aud: ['foo'] })}.` JWT.verify(invalid, key, { audience: 'bar' }) }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "aud" claim value' }) + t.is(err.claim, 'aud') + t.is(err.reason, 'check_failed') }) test('option.audience validation success', t => { @@ -193,20 +217,24 @@ test('option.audience validation success', t => { }) test('option.maxAuthAge requires iat to be in the payload', t => { - t.throws(() => { + const err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({})}.` JWT.verify(invalid, key, { maxAuthAge: '30s' }) }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim is missing' }) + t.is(err.claim, 'auth_time') + t.is(err.reason, 'missing') }) const epoch = 1265328501 const now = new Date(epoch * 1000) test('option.maxAuthAge checks auth_time', t => { - t.throws(() => { + const err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ auth_time: epoch - 31 })}.` JWT.verify(invalid, key, { maxAuthAge: '30s', now }) }, { instanceOf: errors.JWTClaimInvalid, message: '"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)' }) + t.is(err.claim, 'auth_time') + t.is(err.reason, 'check_failed') }) test('option.maxAuthAge checks auth_time (with tolerance)', t => { @@ -216,17 +244,22 @@ test('option.maxAuthAge checks auth_time (with tolerance)', t => { }) test('option.maxTokenAge requires iat to be in the payload', t => { - t.throws(() => { + const err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({})}.` JWT.verify(invalid, key, { maxTokenAge: '30s' }) }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) + t.is(err.claim, 'iat') + t.is(err.reason, 'missing') }) test('option.maxTokenAge checks iat elapsed time', t => { - t.throws(() => { + const err = t.throws(() => { const invalid = `eyJhbGciOiJub25lIn0.${base64url.JSON.encode({ iat: epoch - 31 })}.` JWT.verify(invalid, key, { maxTokenAge: '30s', now }) - }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim timestamp check failed (too far in the past)' }) + }, { instanceOf: errors.JWTExpired, code: 'ERR_JWT_EXPIRED', message: '"iat" claim timestamp check failed (too far in the past)' }) + t.true(err instanceof errors.JWTClaimInvalid) + t.is(err.claim, 'iat') + t.is(err.reason, 'check_failed') }) test('option.maxTokenAge checks iat (with tolerance)', t => { @@ -255,9 +288,11 @@ test('iat check (pass with tolerance)', t => { test('iat check (failed)', t => { const token = JWT.sign({}, key, { now: new Date((epoch + 1) * 1000) }) - t.throws(() => { + const err = t.throws(() => { JWT.verify(token, key, { now }) }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim timestamp check failed (it should be in the past)' }) + t.is(err.claim, 'iat') + t.is(err.reason, 'check_failed') }) test('iat future check (ignored since exp is also present)', t => { @@ -268,9 +303,11 @@ test('iat future check (ignored since exp is also present)', t => { test('iat future check (part of maxTokenAge)', t => { const token = JWT.sign({}, key, { now: new Date((epoch + 1) * 1000), expiresIn: '2h' }) - t.throws(() => { + const err = t.throws(() => { JWT.verify(token, key, { now, maxTokenAge: '30s' }) }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim timestamp check failed (it should be in the past)' }) + t.is(err.claim, 'iat') + t.is(err.reason, 'check_failed') }) test('iat future check with tolerance (part of maxTokenAge)', t => { @@ -305,16 +342,22 @@ test('exp check (pass with tolerance)', t => { test('exp check (failed equal)', t => { const token = JWT.sign({ exp: epoch }, key, { iat: false }) - t.throws(() => { + const err = t.throws(() => { JWT.verify(token, key, { now }) - }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim timestamp check failed' }) + }, { instanceOf: errors.JWTExpired, code: 'ERR_JWT_EXPIRED', message: '"exp" claim timestamp check failed' }) + t.true(err instanceof errors.JWTClaimInvalid) + t.is(err.claim, 'exp') + t.is(err.reason, 'check_failed') }) test('exp check (failed normal)', t => { const token = JWT.sign({ exp: epoch - 1 }, key, { iat: false }) - t.throws(() => { + const err = t.throws(() => { JWT.verify(token, key, { now }) - }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim timestamp check failed' }) + }, { instanceOf: errors.JWTExpired, code: 'ERR_JWT_EXPIRED', message: '"exp" claim timestamp check failed' }) + t.true(err instanceof errors.JWTClaimInvalid) + t.is(err.claim, 'exp') + t.is(err.reason, 'check_failed') }) test('exp check (passed because of ignoreExp)', t => { @@ -343,9 +386,11 @@ test('nbf check (pass with tolerance)', t => { test('nbf check (failed)', t => { const token = JWT.sign({ nbf: epoch + 10 }, key, { iat: false }) - t.throws(() => { + const err = t.throws(() => { JWT.verify(token, key, { now }) }, { instanceOf: errors.JWTClaimInvalid, message: '"nbf" claim timestamp check failed' }) + t.is(err.claim, 'nbf') + t.is(err.reason, 'check_failed') }) test('nbf check (passed because of ignoreIat)', t => { @@ -389,73 +434,87 @@ test('must be a supported value', t => { }) test('profile=id_token mandates exp to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim is missing' }) + t.is(err.claim, 'exp') + t.is(err.reason, 'missing') }) test('profile=id_token mandates iat to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { expiresIn: '10m', iat: false, subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) + t.is(err.claim, 'iat') + t.is(err.reason, 'missing') }) test('profile=id_token mandates sub to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { expiresIn: '10m', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"sub" claim is missing' }) + t.is(err.claim, 'sub') + t.is(err.reason, 'missing') }) test('profile=id_token mandates iss to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', audience: 'client_id' }), key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) + t.is(err.claim, 'iss') + t.is(err.reason, 'missing') }) test('profile=id_token mandates aud to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer' }), key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) + t.is(err.claim, 'aud') + t.is(err.reason, 'missing') }) test('profile=id_token mandates azp to be present when multiple audiences are used', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['client_id', 'another audience'] }), key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"azp" claim is missing' }) + t.is(err.claim, 'azp') + t.is(err.reason, 'missing') }) test('profile=id_token mandates azp to match the audience when required', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ azp: 'mismatched' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['client_id', 'another audience'] }), key, { profile: 'id_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "azp" claim value' }) + t.is(err.claim, 'azp') + t.is(err.reason, 'check_failed') }) test('profile=id_token validates full id tokens', t => { @@ -501,27 +560,31 @@ test('must be a supported value', t => { }) test('profile=logout_token mandates jti to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"jti" claim is missing' }) + t.is(err.claim, 'jti') + t.is(err.reason, 'missing') }) test('profile=logout_token mandates events to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"events" claim is missing' }) + t.is(err.claim, 'events') + t.is(err.reason, 'missing') }) test('profile=logout_token mandates events to be an object', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ events: [] @@ -530,10 +593,12 @@ test('must be a supported value', t => { { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"events" claim must be an object' }) + t.is(err.claim, 'events') + t.is(err.reason, 'invalid') }) test('profile=logout_token mandates events to have the backchannel logout member', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ events: {} @@ -542,10 +607,12 @@ test('must be a supported value', t => { { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim' }) + t.is(err.claim, 'events') + t.is(err.reason, 'invalid') }) test('profile=logout_token mandates events to have the backchannel logout member thats an object', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ events: { @@ -556,66 +623,80 @@ test('must be a supported value', t => { { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object' }) + t.is(err.claim, 'events') + t.is(err.reason, 'invalid') }) test('profile=logout_token mandates iat to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { jti: 'foo', iat: false, subject: 'subject', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iat" claim is missing' }) + t.is(err.claim, 'iat') + t.is(err.reason, 'missing') }) test('profile=logout_token mandates sub or sid to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { jti: 'foo', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: 'either "sid" or "sub" (or both) claims must be present' }) + t.is(err.claim, 'unspecified') + t.is(err.reason, 'unspecified') }) test('profile=logout_token mandates sid to be a string when present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ sid: true }, key, { jti: 'foo', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"sid" claim must be a string' }) + t.is(err.claim, 'sid') + t.is(err.reason, 'invalid') }) test('profile=logout_token prohibits nonce', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ nonce: 'foo' }, key, { subject: 'subject', jti: 'foo', issuer: 'issuer', audience: 'client_id' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"nonce" claim is prohibited' }) + t.is(err.claim, 'nonce') + t.is(err.reason, 'prohibited') }) test('profile=logout_token mandates iss to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { jti: 'foo', subject: 'subject', audience: 'client_id' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) + t.is(err.claim, 'iss') + t.is(err.reason, 'missing') }) test('profile=logout_token mandates aud to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { jti: 'foo', subject: 'subject', issuer: 'issuer' }), key, { profile: 'logout_token', issuer: 'issuer', audience: 'client_id' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) + t.is(err.claim, 'aud') + t.is(err.reason, 'missing') }) } @@ -647,23 +728,27 @@ test('must be a supported value', t => { }) test('profile=at+JWT mandates exp to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"exp" claim is missing' }) + t.is(err.claim, 'exp') + t.is(err.reason, 'missing') }) test('profile=at+JWT mandates that all known aliases of the current RS are provided as the audience option', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['RS-alias1', 'RS-alias2'], header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: ['RS-alias1'] } ) }, { instanceOf: errors.JWTClaimInvalid, message: 'unexpected "aud" claim value' }) + t.is(err.claim, 'aud') + t.is(err.reason, 'check_failed') JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: ['RS-alias1', 'RS-alias2'], header: { typ: 'at+JWT' } }), key, @@ -672,63 +757,75 @@ test('must be a supported value', t => { }) test('profile=at+JWT mandates client_id to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"client_id" claim is missing' }) + t.is(err.claim, 'client_id') + t.is(err.reason, 'missing') }) test('profile=at+JWT mandates sub to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', issuer: 'issuer', audience: 'RS', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"sub" claim is missing' }) + t.is(err.claim, 'sub') + t.is(err.reason, 'missing') }) test('profile=at+JWT mandates iss to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"iss" claim is missing' }) + t.is(err.claim, 'iss') + t.is(err.reason, 'missing') }) test('profile=at+JWT mandates aud to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', issuer: 'issuer', header: { typ: 'at+JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"aud" claim is missing' }) + t.is(err.claim, 'aud') + t.is(err.reason, 'missing') }) test('profile=at+JWT mandates header typ to be present', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', issuer: 'issuer' }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: '"typ" header parameter is missing' }) + t.is(err.claim, 'typ') + t.is(err.reason, 'missing') }) test('profile=at+JWT mandates header typ to be present and of the right value', t => { - t.throws(() => { + const err = t.throws(() => { JWT.verify( JWT.sign({ client_id: 'client_id' }, key, { expiresIn: '10m', subject: 'subject', audience: 'RS', issuer: 'issuer', header: { typ: 'JWT' } }), key, { profile: 'at+JWT', issuer: 'issuer', audience: 'RS' } ) }, { instanceOf: errors.JWTClaimInvalid, message: 'invalid JWT typ header value for the used validation profile' }) + t.is(err.claim, 'typ') + t.is(err.reason, 'check_failed') }) } diff --git a/types/index.d.ts b/types/index.d.ts index 6768428df6..7adc32354f 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -407,25 +407,35 @@ export namespace JWT { } export namespace errors { - class JOSEError extends Error {} - class JOSEMultiError extends JOSEError {} + class JOSEError extends Error { + code: T; + } + + class JOSEInvalidEncoding extends JOSEError<'ERR_JOSE_INVALID_ENCODING'> {} + class JOSEMultiError extends JOSEError<'ERR_JOSE_MULTIPLE_ERRORS'> {} - class JOSEAlgNotWhitelisted extends JOSEError {} - class JOSECritNotUnderstood extends JOSEError {} - class JOSENotSupported extends JOSEError {} + class JOSEAlgNotWhitelisted extends JOSEError<'ERR_JOSE_ALG_NOT_WHITELISTED'> {} + class JOSECritNotUnderstood extends JOSEError<'ERR_JOSE_CRIT_NOT_UNDERSTOOD'> {} + class JOSENotSupported extends JOSEError<'ERR_JOSE_NOT_SUPPORTED'> {} - class JWEDecryptionFailed extends JOSEError {} - class JWEInvalid extends JOSEError {} + class JWEDecryptionFailed extends JOSEError<'ERR_JWE_DECRYPTION_FAILED'> {} + class JWEInvalid extends JOSEError<'ERR_JWE_INVALID'> {} - class JWKImportFailed extends JOSEError {} - class JWKInvalid extends JOSEError {} - class JWKKeySupport extends JOSEError {} + class JWKImportFailed extends JOSEError<'ERR_JWK_IMPORT_FAILED'> {} + class JWKInvalid extends JOSEError<'ERR_JWK_INVALID'> {} + class JWKKeySupport extends JOSEError<'ERR_JWK_KEY_SUPPORT'> {} - class JWKSNoMatchingKey extends JOSEError {} + class JWKSNoMatchingKey extends JOSEError<'ERR_JWKS_NO_MATCHING_KEY'> {} - class JWSInvalid extends JOSEError {} - class JWSVerificationFailed extends JOSEError {} + class JWSInvalid extends JOSEError<'ERR_JWS_INVALID'> {} + class JWSVerificationFailed extends JOSEError<'ERR_JWS_VERIFICATION_FAILED'> {} - class JWTClaimInvalid extends JOSEError {} - class JWTMalformed extends JOSEError {} + class JWTClaimInvalid extends JOSEError { + constructor(message?: string, claim?: string, reason?: string); + + claim: string; + reason: 'prohibited' | 'missing' | 'invalid' | 'check_failed' | 'unspecified'; + } + class JWTExpired extends JWTClaimInvalid<'ERR_JWT_EXPIRED'> {} + class JWTMalformed extends JOSEError<'ERR_JWT_MALFORMED'> {} }