From 65a1424255074b7dee6d576680ee0c7af756271e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Rudge?= Date: Thu, 24 Oct 2019 16:58:51 -0300 Subject: [PATCH] Improved OIDC compliance (#248) * idtv-1 * max_age * finish idtv * Update src/jwt.ts Co-Authored-By: Luciano Balmaceda * fix1 * nonce * azp clientid * idclient * client_id * nonce * date types * fixed auth_time test * auth_time date --- __tests__/index.test.ts | 36 +++++-- __tests__/jwt.test.ts | 209 +++++++++++++++++++++++++++++++++------- package-lock.json | 30 +++--- package.json | 4 +- src/Auth0Client.ts | 3 +- src/global.ts | 1 + src/jwt.ts | 132 ++++++++++++++++++++++--- 7 files changed, 339 insertions(+), 76 deletions(-) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index bf15c0984..e5a8ab8c0 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -283,7 +283,9 @@ describe('Auth0', () => { id_token: TEST_ID_TOKEN, nonce: TEST_RANDOM_STRING, aud: 'test-client-id', - iss: 'https://test.auth0.com/' + iss: 'https://test.auth0.com/', + leeway: undefined, + max_age: undefined }); }); it('calls `tokenVerifier.verify` with the `issuer` from in the oauth/token response', async () => { @@ -296,7 +298,9 @@ describe('Auth0', () => { aud: 'test-client-id', id_token: TEST_ID_TOKEN, nonce: TEST_RANDOM_STRING, - iss: 'https://test-123.auth0.com/' + iss: 'https://test-123.auth0.com/', + leeway: undefined, + max_age: undefined }); }); it('calls `tokenVerifier.verify` with the `leeway` from constructor', async () => { @@ -308,7 +312,21 @@ describe('Auth0', () => { nonce: TEST_RANDOM_STRING, aud: 'test-client-id', iss: 'https://test.auth0.com/', - leeway: 10 + leeway: 10, + max_age: undefined + }); + }); + it('calls `tokenVerifier.verify` with the `max_age` from constructor', async () => { + const { auth0, tokenVerifier } = await setup({ max_age: '10' }); + + await auth0.loginWithPopup({}); + expect(tokenVerifier).toHaveBeenCalledWith({ + id_token: TEST_ID_TOKEN, + nonce: TEST_RANDOM_STRING, + aud: 'test-client-id', + iss: 'https://test.auth0.com/', + leeway: undefined, + max_age: '10' }); }); it('saves cache', async () => { @@ -616,7 +634,9 @@ describe('Auth0', () => { id_token: TEST_ID_TOKEN, nonce: TEST_RANDOM_STRING, aud: 'test-client-id', - iss: 'https://test.auth0.com/' + iss: 'https://test.auth0.com/', + leeway: undefined, + max_age: undefined }); }); it('saves cache', async () => { @@ -773,7 +793,9 @@ describe('Auth0', () => { id_token: TEST_ID_TOKEN, nonce: TEST_RANDOM_STRING, aud: 'test-client-id', - iss: 'https://test.auth0.com/' + iss: 'https://test.auth0.com/', + leeway: undefined, + max_age: undefined }); }); it('saves cache', async () => { @@ -1118,7 +1140,9 @@ describe('Auth0', () => { id_token: TEST_ID_TOKEN, nonce: TEST_RANDOM_STRING, aud: 'test-client-id', - iss: 'https://test.auth0.com/' + iss: 'https://test.auth0.com/', + leeway: undefined, + max_age: undefined }); }); it('saves cache', async () => { diff --git a/__tests__/jwt.test.ts b/__tests__/jwt.test.ts index 04fae17d8..666ca65d8 100644 --- a/__tests__/jwt.test.ts +++ b/__tests__/jwt.test.ts @@ -11,7 +11,8 @@ interface Certificate { const verifyOptions = { iss: 'https://brucke.auth0.com/', aud: 'k5u3o2fiAA8XweXEEX604KCwCjzjtMU6', - nonce: 'omcw.ptjx3~.8VBm3OuMziLdn5PB0uXG' + nonce: 'omcw.ptjx3~.8VBm3OuMziLdn5PB0uXG', + client_id: 'the_client_id' }; const createCertificate = (): Promise => @@ -33,7 +34,12 @@ const createCertificate = (): Promise => }); }); -const DEFAULT_PAYLOAD = { payload: true, nonce: verifyOptions.nonce }; +const DEFAULT_PAYLOAD = { + sub: 'id|123', + payload: true, + nonce: verifyOptions.nonce, + azp: verifyOptions.aud +}; const createJWT = async (payload = DEFAULT_PAYLOAD, options = {}) => { const cert = await createCertificate(); return jwt.sign(payload, cert.serviceKey, { @@ -82,6 +88,26 @@ describe('jwt', async () => { } }); }); + describe('validates id_token', () => { + const IDTOKEN_ERROR_MESSAGE = 'ID token could not be decoded'; + it('throws when there is less than 3 parts', () => { + expect(() => decode('test')).toThrow(IDTOKEN_ERROR_MESSAGE); + expect(() => decode('test.')).toThrow(IDTOKEN_ERROR_MESSAGE); + expect(() => decode('test.test')).toThrow(IDTOKEN_ERROR_MESSAGE); + expect(() => decode('test.test.test.test')).toThrow( + IDTOKEN_ERROR_MESSAGE + ); + }); + it('throws when there is no header', () => { + expect(() => decode('.test.test')).toThrow(IDTOKEN_ERROR_MESSAGE); + }); + it('throws when there is no payload', () => { + expect(() => decode('test..test')).toThrow(IDTOKEN_ERROR_MESSAGE); + }); + it('throws when there is no signature', () => { + expect(() => decode('test.test.')).toThrow(IDTOKEN_ERROR_MESSAGE); + }); + }); it('verifies correctly', async done => { const id_token = await createJWT(); const { encoded, header, claims } = verify({ @@ -97,72 +123,183 @@ describe('jwt', async () => { done(); }); }); + it('verifies correctly with multiple audiences and azp', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { + audience: ['item 1', verifyOptions.aud] + }); + + const { encoded, header, claims } = verify({ + ...verifyOptions, + id_token + }); + expect({ encoded, header, payload: claims }).toMatchObject( + verifier.decode(id_token) + ); + }); + it('validates id_token is present', async () => { + expect(() => verify({ ...verifyOptions, id_token: '' })).toThrow( + 'ID token is required but missing' + ); + }); + + it('validates algorithm', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { + algorithm: 'HS256' + }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + `Signature algorithm of "HS256" is not supported. Expected the ID token to be signed with "RS256".` + ); + }); + it('validates issuer is present', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { issuer: '' }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + 'Issuer (iss) claim must be a string present in the ID token' + ); + }); it('validates issuer', async () => { const id_token = await createJWT(); expect(() => verify({ ...verifyOptions, id_token, iss: 'wrong' })).toThrow( - 'Invalid issuer' + `Issuer (iss) claim mismatch in the ID token; expected "wrong", found "${verifyOptions.iss}"` + ); + }); + it('validates `sub` is present', async () => { + const id_token = await createJWT({ ...DEFAULT_PAYLOAD, sub: undefined }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + 'Subject (sub) claim must be a string present in the ID token' + ); + }); + it('validates aud is present', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { audience: '' }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + 'Audience (aud) claim must be a string or array of strings present in the ID token' ); }); - it('validates audience', async () => { + it('validates audience with `aud` is an array', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { + audience: ['client_id'] + }); + expect(() => verify({ ...verifyOptions, id_token, aud: 'wrong' })).toThrow( + `Audience (aud) claim mismatch in the ID token; expected "wrong" but was not one of "client_id"` + ); + }); + it('validates audience with `aud` is a string', async () => { const id_token = await createJWT(); expect(() => verify({ ...verifyOptions, id_token, aud: 'wrong' })).toThrow( - 'Invalid audience' + `Audience (aud) claim mismatch in the ID token; expected "wrong" but found "${verifyOptions.aud}"` ); }); - it('validates algorithm', async () => { - const id_token = await createJWT( - { - nonce: verifyOptions.nonce - }, - { - algorithm: 'HS256' - } + it('validates exp', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { + expiresIn: '-1h' + }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + `Expiration Time (exp) claim error in the ID token` ); + }); + it('validates nbf', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { + notBefore: '1h' + }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + `Not Before time (nbf) claim in the ID token indicates that this token can't be used just yet.` + ); + }); + it('validates iat is present', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { noTimestamp: true }); expect(() => verify({ ...verifyOptions, id_token })).toThrow( - 'Invalid algorithm' + 'Issued At (iat) claim must be a number present in the ID token' + ); + }); + it('validates iat', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const id_token = await createJWT({ + ...DEFAULT_PAYLOAD, + iat: tomorrow.getTime() + }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + 'Issued At (iat) claim error in the ID token' + ); + }); + it('does not validate nonce is present when options.nonce is undefined', async () => { + const id_token = await createJWT({ ...DEFAULT_PAYLOAD, nonce: undefined }); + expect(() => + verify({ ...verifyOptions, nonce: undefined, id_token }) + ).not.toThrow(); + }); + it('validates nonce is present', async () => { + const id_token = await createJWT({ ...DEFAULT_PAYLOAD, nonce: undefined }); + expect(() => verify({ ...verifyOptions, id_token })).toThrow( + 'Nonce (nonce) claim must be a string present in the ID token' ); }); it('validates nonce', async () => { const id_token = await createJWT(); expect(() => verify({ ...verifyOptions, id_token, nonce: 'wrong' }) - ).toThrow('Invalid nonce'); + ).toThrow( + `Nonce (nonce) claim mismatch in the ID token; expected "wrong", found "${verifyOptions.nonce}"` + ); }); - it('validates exp', async () => { + it('does not validate azp is present when `aud` is a string', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { + audience: 'aud' + }); + expect(() => + verify({ ...verifyOptions, id_token, aud: 'aud' }) + ).not.toThrow(); + }); + it('does not validate azp is present when `aud` is an array with a single item', async () => { + const id_token = await createJWT(DEFAULT_PAYLOAD, { + audience: ['item 1'] + }); + expect(() => + verify({ ...verifyOptions, id_token, aud: 'item 1' }) + ).not.toThrow(); + }); + it('validates azp is present when `aud` is an array with more than one item', async () => { const id_token = await createJWT( + { ...DEFAULT_PAYLOAD, azp: undefined }, { - nonce: verifyOptions.nonce - }, - { - expiresIn: '-1h' + audience: ['item 1', 'other_value'] } ); - expect(() => verify({ ...verifyOptions, id_token })).toThrow( - 'id_token expired' + expect(() => + verify({ ...verifyOptions, id_token, aud: 'other_value' }) + ).toThrow( + 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values' ); }); - it('validates nbf', async () => { + it('validates azp when `aud` is an array with more than one item', async () => { const id_token = await createJWT( + { ...DEFAULT_PAYLOAD, azp: 'not_the_client_id' }, { - nonce: verifyOptions.nonce - }, - { - notBefore: '1h' + audience: ['item 1', 'other_value'] } ); - expect(() => verify({ ...verifyOptions, id_token })).toThrow( - 'token is not yet valid (invalid notBefore)' + expect(() => + verify({ ...verifyOptions, id_token, aud: 'other_value' }) + ).toThrow( + `Authorized Party (azp) claim mismatch in the ID token; expected "other_value", found "not_the_client_id"` ); }); - it('validates iat', async () => { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); + it('validate auth_time is present when max_age is provided', async () => { + const id_token = await createJWT({ ...DEFAULT_PAYLOAD }); + expect(() => + verify({ ...verifyOptions, id_token, max_age: '123' }) + ).toThrow( + 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified' + ); + }); + it('validate auth_time + max_age is in the future', async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); const id_token = await createJWT({ - nonce: verifyOptions.nonce, - iat: tomorrow.getTime() + ...DEFAULT_PAYLOAD, + auth_time: yesterday.getTime() }); - expect(() => verify({ ...verifyOptions, id_token })).toThrow( - 'id_token was issued in the future (invalid iat)' + expect(() => verify({ ...verifyOptions, id_token, max_age: '1' })).toThrow( + 'Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication.' ); }); }); diff --git a/package-lock.json b/package-lock.json index 305ab7ecb..bcc1397ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5178,12 +5178,6 @@ "wrap-ansi": "^5.1.0" } }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -5949,12 +5943,6 @@ "wrap-ansi": "^5.1.0" } }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, "find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -9876,6 +9864,12 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz", "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==", "dev": true + }, + "typescript": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz", + "integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==", + "dev": true } } }, @@ -9892,15 +9886,15 @@ } }, "typescript": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz", - "integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==", + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz", + "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==", "dev": true }, "uglify-js": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.1.tgz", - "integrity": "sha512-+dSJLJpXBb6oMHP+Yvw8hUgElz4gLTh82XuX68QiJVTXaE5ibl6buzhNkQdYhBlIhozWOC9ge16wyRmjG4TwVQ==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.2.tgz", + "integrity": "sha512-+gh/xFte41GPrgSMJ/oJVq15zYmqr74pY9VoM69UzMzq9NFk4YDylclb1/bhEzZSaUQjbW5RvniHeq1cdtRYjw==", "dev": true, "optional": true, "requires": { diff --git a/package.json b/package.json index 8905941d3..a53ca9888 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "gzip-size": "^5.1.1", "husky": "^3.0.0", "idtoken-verifier": "^1.4.1", - "jest": "^24.8.0", + "jest": "^24.9.0", "jsonwebtoken": "^8.5.1", "pem": "^1.14.2", "prettier": "^1.18.2", @@ -65,7 +65,7 @@ "tslint": "^5.18.0", "tslint-config-security": "^1.16.0", "typedoc": "^0.15.0", - "typescript": "^3.5.3", + "typescript": "^3.6.4", "wait-on": "^3.3.0" }, "dependencies": { diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts index c009e1cd9..27ec28931 100644 --- a/src/Auth0Client.ts +++ b/src/Auth0Client.ts @@ -88,7 +88,8 @@ export default class Auth0Client { aud: this.options.client_id, id_token, nonce, - leeway: this.options.leeway + leeway: this.options.leeway, + max_age: this.options.max_age }); } diff --git a/src/global.ts b/src/global.ts index f2bc7a1e3..4c0e5dcd6 100644 --- a/src/global.ts +++ b/src/global.ts @@ -240,6 +240,7 @@ interface JWTVerifyOptions { id_token: string; nonce?: string; leeway?: number; + max_age?: string; } /** diff --git a/src/jwt.ts b/src/jwt.ts index 1cce8fd0b..af249ca88 100644 --- a/src/jwt.ts +++ b/src/jwt.ts @@ -1,5 +1,7 @@ import { urlDecodeB64 } from './utils'; +const isNumber = n => typeof n === 'number'; + const idTokendecoded = [ 'iss', 'aud', @@ -34,10 +36,15 @@ const idTokendecoded = [ ]; export const decode = (token: string) => { - const [header, payload, signature] = token.split('.'); + const parts = token.split('.'); + const [header, payload, signature] = parts; + + if (parts.length !== 3 || !header || !payload || !signature) { + throw new Error('ID token could not be decoded'); + } const payloadJSON = JSON.parse(urlDecodeB64(payload)); const claims: IdToken = { __raw: token }; - const user = {}; + const user: any = {}; Object.keys(payloadJSON).forEach(k => { claims[k] = payloadJSON[k]; if (!idTokendecoded.includes(k)) { @@ -53,37 +60,136 @@ export const decode = (token: string) => { }; export const verify = (options: JWTVerifyOptions) => { + if (!options.id_token) { + throw new Error('ID token is required but missing'); + } + const decoded = decode(options.id_token); + if (!decoded.claims.iss) { + throw new Error( + 'Issuer (iss) claim must be a string present in the ID token' + ); + } + if (decoded.claims.iss !== options.iss) { - throw new Error('Invalid issuer'); + throw new Error( + `Issuer (iss) claim mismatch in the ID token; expected "${options.iss}", found "${decoded.claims.iss}"` + ); } - if (decoded.claims.aud !== options.aud) { - throw new Error('Invalid audience'); + + if (!decoded.user.sub) { + throw new Error( + 'Subject (sub) claim must be a string present in the ID token' + ); } + if (decoded.header.alg !== 'RS256') { - throw new Error('Invalid algorithm'); + throw new Error( + `Signature algorithm of "${decoded.header.alg}" is not supported. Expected the ID token to be signed with "RS256".` + ); + } + + if ( + !decoded.claims.aud || + !( + typeof decoded.claims.aud === 'string' || + Array.isArray(decoded.claims.aud) + ) + ) { + throw new Error( + 'Audience (aud) claim must be a string or array of strings present in the ID token' + ); } - if (decoded.claims.nonce !== options.nonce) { - throw new Error('Invalid nonce'); + if (Array.isArray(decoded.claims.aud)) { + if (!decoded.claims.aud.includes(options.aud)) { + throw new Error( + `Audience (aud) claim mismatch in the ID token; expected "${ + options.aud + }" but was not one of "${decoded.claims.aud.join(', ')}"` + ); + } + if (decoded.claims.aud.length > 1) { + if (!decoded.claims.azp) { + throw new Error( + 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values' + ); + } + if (decoded.claims.azp !== options.aud) { + throw new Error( + `Authorized Party (azp) claim mismatch in the ID token; expected "${options.aud}", found "${decoded.claims.azp}"` + ); + } + } + } else if (decoded.claims.aud !== options.aud) { + throw new Error( + `Audience (aud) claim mismatch in the ID token; expected "${options.aud}" but found "${decoded.claims.aud}"` + ); + } + if (options.nonce) { + if (!decoded.claims.nonce) { + throw new Error( + 'Nonce (nonce) claim must be a string present in the ID token' + ); + } + if (decoded.claims.nonce !== options.nonce) { + throw new Error( + `Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${decoded.claims.nonce}"` + ); + } + } + + if (options.max_age && !isNumber(decoded.claims.auth_time)) { + throw new Error( + 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified' + ); + } + + /* istanbul ignore next */ + if (!isNumber(decoded.claims.exp)) { + throw new Error( + 'Expiration Time (exp) claim must be a number present in the ID token' + ); + } + if (!isNumber(decoded.claims.iat)) { + throw new Error( + 'Issued At (iat) claim must be a number present in the ID token' + ); } + const leeway = options.leeway || 60; const now = new Date(); const expDate = new Date(0); const iatDate = new Date(0); const nbfDate = new Date(0); - const leeway = options.leeway || 60; + const authTimeDate = new Date(0); + authTimeDate.setUTCSeconds( + (parseInt(decoded.claims.auth_time) + parseInt(options.max_age)) / 1000 + + leeway + ); expDate.setUTCSeconds(decoded.claims.exp + leeway); iatDate.setUTCSeconds(decoded.claims.iat - leeway); nbfDate.setUTCSeconds(decoded.claims.nbf - leeway); + if (now > expDate) { - throw new Error('id_token expired'); + throw new Error( + `Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expDate})` + ); } if (now < iatDate) { - throw new Error('id_token was issued in the future (invalid iat)'); + throw new Error( + `Issued At (iat) claim error in the ID token; current time (${now}) is before issued at time (${iatDate})` + ); + } + if (isNumber(decoded.claims.nbf) && now < nbfDate) { + throw new Error( + `Not Before time (nbf) claim in the ID token indicates that this token can't be used just yet. Currrent time (${now}) is before ${nbfDate}` + ); } - if (typeof decoded.claims.nbf !== 'undefined' && now < nbfDate) { - throw new Error('token is not yet valid (invalid notBefore)'); + if (isNumber(decoded.claims.auth_time) && now > authTimeDate) { + throw new Error( + `Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Currrent time (${now}) is after last auth at ${authTimeDate}` + ); } return decoded; };