From 311642a6caa12bddf0c6e25b3c2a5b15f57f952d Mon Sep 17 00:00:00 2001 From: davidpatrick Date: Sun, 12 Jan 2020 21:07:19 -0800 Subject: [PATCH 1/6] ID Token Validation --- package.json | 1 + src/auth/OAUthWithIDTokenValidation.js | 43 ++- src/auth/idToken.js | 151 +++++++++ .../oauth-with-idtoken-validation.tests.js | 12 +- test/idToken.tests.js | 308 ++++++++++++++++++ 5 files changed, 498 insertions(+), 17 deletions(-) create mode 100644 src/auth/idToken.js create mode 100644 test/idToken.tests.js diff --git a/package.json b/package.json index a3c23cd91..ae3fa1323 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "husky": "^3.0.1", "jsdoc": "^3.6.3", "json-loader": "^0.5.7", + "jws": "^3.2.2", "minami": "^1.2.3", "mocha": "^6.2.0", "mocha-junit-reporter": "^1.23.1", diff --git a/src/auth/OAUthWithIDTokenValidation.js b/src/auth/OAUthWithIDTokenValidation.js index d294fd10f..aee4ec045 100644 --- a/src/auth/OAUthWithIDTokenValidation.js +++ b/src/auth/OAUthWithIDTokenValidation.js @@ -3,6 +3,7 @@ var jwksClient = require('jwks-rsa'); var Promise = require('bluebird'); var ArgumentError = require('rest-facade').ArgumentError; +var validateIdToken = require('./idToken').validate; var HS256_IGNORE_VALIDATION_MESSAGE = 'Validation of `id_token` requires a `clientSecret` when using the HS256 algorithm. To ensure tokens are validated, please switch the signing algorithm to RS256 or provide a `clientSecret` in the constructor.'; @@ -80,25 +81,37 @@ OAUthWithIDTokenValidation.prototype.create = function(params, data, cb) { }); } return new Promise((res, rej) => { - jwt.verify( - r.id_token, - getKey, - { - algorithms: this.supportedAlgorithms, - audience: this.clientId, - issuer: 'https://' + this.domain + '/' - }, - function(err) { - if (!err) { - return res(r); - } + var options = { + algorithms: this.supportedAlgorithms, + audience: this.clientId, + issuer: 'https://' + this.domain + '/' + }; + + if (data.nonce) { + options.nonce = data.nonce; + } + + if (data.maxAge) { + options.maxAge = data.maxAge; + } + + jwt.verify(r.id_token, getKey, options, function(err) { + if (err) { if (err.message && err.message.includes(HS256_IGNORE_VALIDATION_MESSAGE)) { console.warn(HS256_IGNORE_VALIDATION_MESSAGE); - return res(r); + } else { + return rej(err); } - return rej(err); } - ); + + try { + validateIdToken(r.id_token, options); + } catch (idTokenError) { + return rej(idTokenError); + } + + return res(r); + }); }); } return r; diff --git a/src/auth/idToken.js b/src/auth/idToken.js new file mode 100644 index 000000000..10bf5302a --- /dev/null +++ b/src/auth/idToken.js @@ -0,0 +1,151 @@ +var urlDecodeB64 = function(data) { + return Buffer.from(data, 'base64').toString('utf8'); +}; + +/** + * Decodes a string token into the 3 parts, throws if the format is invalid + * @param token + */ +var decode = function(token) { + var parts = token.split('.'); + + if (parts.length !== 3) { + throw new Error('ID token could not be decoded'); + } + + return { + _raw: token, + header: JSON.parse(urlDecodeB64(parts[0])), + payload: JSON.parse(urlDecodeB64(parts[1])), + signature: parts[2] + }; +}; + +var DEFAULT_LEEWAY = 60; //default clock-skew, in seconds + +/** + * Validator for ID Tokens following OIDC spec. + * @param token the string token to verify + * @param options the options required to run this verification + * @returns A promise containing the decoded token payload, or throws an exception if validation failed + */ +var validate = function(token, options) { + if (!token) { + throw new Error('ID token is required but missing'); + } + + var decodedToken = decode(token); + + // Check algorithm + var header = decodedToken.header; + if (header.alg !== 'RS256' && header.alg !== 'HS256') { + throw new Error( + `Signature algorithm of "${header.alg}" is not supported. Expected the ID token to be signed with "RS256" or "HS256".` + ); + } + + var payload = decodedToken.payload; + + // Issuer + if (!payload.iss || typeof payload.iss !== 'string') { + throw new Error('Issuer (iss) claim must be a string present in the ID token'); + } + if (payload.iss !== options.issuer) { + throw new Error( + `Issuer (iss) claim mismatch in the ID token; expected "${options.issuer}", found "${payload.iss}"` + ); + } + + // Subject + if (!payload.sub || typeof payload.sub !== 'string') { + throw new Error('Subject (sub) claim must be a string present in the ID token'); + } + + // Audience + if (!payload.aud || !(typeof payload.aud === 'string' || Array.isArray(payload.aud))) { + throw new Error( + 'Audience (aud) claim must be a string or array of strings present in the ID token' + ); + } + if (Array.isArray(payload.aud) && !payload.aud.includes(options.audience)) { + throw new Error( + `Audience (aud) claim mismatch in the ID token; expected "${ + options.audience + }" but was not one of "${payload.aud.join(', ')}"` + ); + } else if (typeof payload.aud === 'string' && payload.aud !== options.audience) { + throw new Error( + `Audience (aud) claim mismatch in the ID token; expected "${options.audience}" but found "${payload.aud}"` + ); + } + + // --Time validation (epoch)-- + var now = Math.floor(Date.now() / 1000); + var leeway = options.leeway || DEFAULT_LEEWAY; + + // Expires at + if (!payload.exp || typeof payload.exp !== 'number') { + throw new Error('Expiration Time (exp) claim must be a number present in the ID token'); + } + var expTime = payload.exp + leeway; + + if (now > expTime) { + throw new Error( + `Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expTime})` + ); + } + + // Issued at + if (!payload.iat || typeof payload.iat !== 'number') { + throw new Error('Issued At (iat) claim must be a number present in the ID token'); + } + + // Nonce + if (options.nonce) { + if (!payload.nonce || typeof payload.nonce !== 'string') { + throw new Error('Nonce (nonce) claim must be a string present in the ID token'); + } + if (payload.nonce !== options.nonce) { + throw new Error( + `Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${payload.nonce}"` + ); + } + } + + // Authorized party + if (Array.isArray(payload.aud) && payload.aud.length > 1) { + if (!payload.azp || typeof payload.azp !== 'string') { + throw new Error( + 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values' + ); + } + if (payload.azp !== options.audience) { + throw new Error( + `Authorized Party (azp) claim mismatch in the ID token; expected "${options.audience}", found "${payload.azp}"` + ); + } + } + + // Authentication time + if (options.maxAge) { + if (!payload.auth_time || typeof payload.auth_time !== 'number') { + throw new Error( + 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified' + ); + } + + var authValidUntil = payload.auth_time + options.maxAge + leeway; + if (now > authValidUntil) { + 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 ${authValidUntil}` + ); + } + } + + return decodedToken; +}; + +module.exports = { + decode: decode, + validate: validate +}; diff --git a/test/auth/oauth-with-idtoken-validation.tests.js b/test/auth/oauth-with-idtoken-validation.tests.js index c9b8ab2b7..0c8b28bc5 100644 --- a/test/auth/oauth-with-idtoken-validation.tests.js +++ b/test/auth/oauth-with-idtoken-validation.tests.js @@ -118,7 +118,11 @@ describe('OAUthWithIDTokenValidation', function() { sinon.stub(jwt, 'verify').callsFake(function(idtoken, getKey, options, callback) { callback(null, { verification: 'result' }); }); - var oauthWithValidation = new OAUthWithIDTokenValidation(oauth, {}); + OAUthWithIDTokenValidationProxy = proxyquire('../../src/auth/OAUthWithIDTokenValidation', { + './idToken': { validate: token => token } + }); + + var oauthWithValidation = new OAUthWithIDTokenValidationProxy(oauth, {}); oauthWithValidation.create(PARAMS, DATA).then(function(r) { expect(r).to.be.eql({ id_token: 'foobar' }); done(); @@ -162,6 +166,10 @@ describe('OAUthWithIDTokenValidation', function() { return new Promise(res => res({ id_token: 'foobar' })); } }; + OAUthWithIDTokenValidationProxy = proxyquire('../../src/auth/OAUthWithIDTokenValidation', { + './idToken': { validate: token => token } + }); + sinon.stub(jwt, 'verify').callsFake(function(idtoken, getKey, options, callback) { getKey({ alg: 'HS256' }, function(err, key) { expect(err.message).to.contain( @@ -170,7 +178,7 @@ describe('OAUthWithIDTokenValidation', function() { callback(err, key); }); }); - var oauthWithValidation = new OAUthWithIDTokenValidation(oauth, {}); + var oauthWithValidation = new OAUthWithIDTokenValidationProxy(oauth, {}); oauthWithValidation.create(PARAMS, DATA, function(err, response) { expect(err).to.be.null; expect(response).to.be.eql({ id_token: 'foobar' }); diff --git a/test/idToken.tests.js b/test/idToken.tests.js new file mode 100644 index 000000000..eedb436a8 --- /dev/null +++ b/test/idToken.tests.js @@ -0,0 +1,308 @@ +var assert = require('assert'); +var jws = require('jws'); +var idToken = require('../src/auth/idToken'); + +var secretHMAC = 'secret'; +//openssl genrsa -out private.pem 2048 +//openssl rsa -in private.pem -pubout -out public.pem +var privateKeyRSA = `-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC4ZtdaIrd1BPIJ +tfnF0TjIK5inQAXZ3XlCrUlJdP+XHwIRxdv1FsN12XyMYO/6ymLmo9ryoQeIrsXB +XYqlET3zfAY+diwCb0HEsVvhisthwMU4gZQu6TYW2s9LnXZB5rVtcBK69hcSlA2k +ZudMZWxZcj0L7KMfO2rIvaHw/qaVOE9j0T257Z8Kp2CLF9MUgX0ObhIsdumFRLaL +DvDUmBPr2zuh/34j2XmWwn1yjN/WvGtdfhXW79Ki1S40HcWnygHgLV8sESFKUxxQ +mKvPUTwDOIwLFL5WtE8Mz7N++kgmDcmWMCHc8kcOIu73Ta/3D4imW7VbKgHZo9+K +3ESFE3RjAgMBAAECggEBAJTEIyjMqUT24G2FKiS1TiHvShBkTlQdoR5xvpZMlYbN +tVWxUmrAGqCQ/TIjYnfpnzCDMLhdwT48Ab6mQJw69MfiXwc1PvwX1e9hRscGul36 +ryGPKIVQEBsQG/zc4/L2tZe8ut+qeaK7XuYrPp8bk/X1e9qK5m7j+JpKosNSLgJj +NIbYsBkG2Mlq671irKYj2hVZeaBQmWmZxK4fw0Istz2WfN5nUKUeJhTwpR+JLUg4 +ELYYoB7EO0Cej9UBG30hbgu4RyXA+VbptJ+H042K5QJROUbtnLWuuWosZ5ATldwO +u03dIXL0SH0ao5NcWBzxU4F2sBXZRGP2x/jiSLHcqoECgYEA4qD7mXQpu1b8XO8U +6abpKloJCatSAHzjgdR2eRDRx5PMvloipfwqA77pnbjTUFajqWQgOXsDTCjcdQui +wf5XAaWu+TeAVTytLQbSiTsBhrnoqVrr3RoyDQmdnwHT8aCMouOgcC5thP9vQ8Us +rVdjvRRbnJpg3BeSNimH+u9AHgsCgYEA0EzcbOltCWPHRAY7B3Ge/AKBjBQr86Kv +TdpTlxePBDVIlH+BM6oct2gaSZZoHbqPjbq5v7yf0fKVcXE4bSVgqfDJ/sZQu9Lp +PTeV7wkk0OsAMKk7QukEpPno5q6tOTNnFecpUhVLLlqbfqkB2baYYwLJR3IRzboJ +FQbLY93E8gkCgYB+zlC5VlQbbNqcLXJoImqItgQkkuW5PCgYdwcrSov2ve5r/Acz +FNt1aRdSlx4176R3nXyibQA1Vw+ztiUFowiP9WLoM3PtPZwwe4bGHmwGNHPIfwVG +m+exf9XgKKespYbLhc45tuC08DATnXoYK7O1EnUINSFJRS8cezSI5eHcbQKBgQDC +PgqHXZ2aVftqCc1eAaxaIRQhRmY+CgUjumaczRFGwVFveP9I6Gdi+Kca3DE3F9Pq +PKgejo0SwP5vDT+rOGHN14bmGJUMsX9i4MTmZUZ5s8s3lXh3ysfT+GAhTd6nKrIE +kM3Nh6HWFhROptfc6BNusRh1kX/cspDplK5x8EpJ0QKBgQDWFg6S2je0KtbV5PYe +RultUEe2C0jYMDQx+JYxbPmtcopvZQrFEur3WKVuLy5UAy7EBvwMnZwIG7OOohJb +vkSpADK6VPn9lbqq7O8cTedEHttm6otmLt8ZyEl3hZMaL3hbuRj6ysjmoFKx6CrX +rK0/Ikt5ybqUzKCMJZg2VKGTxg== +-----END PRIVATE KEY-----`; + +//base date expressed in MS +//expected values for a good id token payload +var expectations = { + clientId: 'tokens-test-123', + clientIdAlt: 'external-test-999', + issuer: 'https://tokens-test.auth0.com/', + nonce: 'a1b2c3d4e5', + clock: Date.now() +}; + +//date helpers +var TODAY_IN_SECONDS = Math.floor(expectations.clock / 1000); +var ONE_DAY_IN_SECONDS = 3600 * 24; +function yesterday() { + return TODAY_IN_SECONDS - ONE_DAY_IN_SECONDS; +} +function tomorrow() { + return TODAY_IN_SECONDS + ONE_DAY_IN_SECONDS; +} +//good id token payload +var payload = { + iss: expectations.issuer, + sub: 'auth0|123456789', + aud: [expectations.clientId, expectations.clientIdAlt], + exp: tomorrow(), + iat: yesterday(), + nonce: expectations.nonce, + azp: expectations.clientId, + auth_time: TODAY_IN_SECONDS +}; + +var defaultOptions = { + issuer: expectations.issuer, + audience: [expectations.clientId, expectations.clientIdAlt], + nonce: expectations.nonce +}; + +function generateJWT(bodyOverrides, alg) { + var body = Object.assign({}, payload, bodyOverrides || {}); + alg = alg || 'RS256'; + var options = { + header: { alg: alg }, + payload: body + }; + if (alg === 'RS256') { + options.privateKey = privateKeyRSA; + } else if (alg === 'HS256') { + options.secret = secretHMAC; + } + return jws.sign(options); +} + +describe('idToken.decode', function() { + it('should decode a valid token', function() { + var alg = 'RS256'; + var token = generateJWT({ name: 'ÁÁutf8' }, alg); + var decoded = idToken.decode(token); + + assert.equal(decoded._raw, token); + assert.equal(decoded.header.alg, alg); + + Object.keys(payload).forEach(function(key) { + assert.deepEqual(payload[key], decoded.payload[key]); + }); + }); + + it('throws errors on invalid tokens', function() { + var IDTOKEN_ERROR_MESSAGE = 'ID token could not be decoded'; + + it('throws when there is more or less than 3 parts', function() { + assert.throws(idToken.decode('test'), IDTOKEN_ERROR_MESSAGE); + assert.throws(idToken.decode('test.'), IDTOKEN_ERROR_MESSAGE); + assert.throws(idToken.decode('test.test'), IDTOKEN_ERROR_MESSAGE); + assert.throws(idToken.decode('test.test.test.test'), IDTOKEN_ERROR_MESSAGE); + }); + it('throws when there is no header', function() { + assert.throws(idToken.decode('.test.test'), IDTOKEN_ERROR_MESSAGE); + }); + it('throws when there is no payload', function() { + assert.throws(idToken.decode('test..test'), IDTOKEN_ERROR_MESSAGE); + }); + it('throws when there is no signature', function() { + assert.throws(idToken.decode('test.test.'), IDTOKEN_ERROR_MESSAGE); + }); + }); +}); + +describe('idToken.validate', function() { + var expectedOptions; + + beforeEach(function() { + expectedOptions = Object.assign({}, defaultOptions); + expectedOptions.audience = expectations.clientId; + expectedOptions.maxAge = 123; + }); + + it('should throw when no id token is present', function(done) { + var EXPECTED_ERROR_MESSAGE = 'ID token is required but missing'; + try { + idToken.validate(); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when no Issuer is present in the claim', function(done) { + var EXPECTED_ERROR_MESSAGE = 'Issuer (iss) claim must be a string present in the ID token'; + try { + idToken.validate(generateJWT({ iss: undefined })); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when the expected issuer is not in the claim', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Issuer (iss) claim mismatch in the ID token; expected "ExpectedIssuer", found "https://tokens-test.auth0.com/"'; + try { + idToken.validate(generateJWT({}), { issuer: 'ExpectedIssuer' }); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when the claim has no Subject', function(done) { + var EXPECTED_ERROR_MESSAGE = 'Subject (sub) claim must be a string present in the ID token'; + try { + idToken.validate(generateJWT({ sub: undefined }), defaultOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when the alg is neither rs256 or hs256', function(done) { + var token = + 'eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMifQ.cZ4qDwoKdKQx8DtD-F-xVKCxd3rz58wSJh3k28z5qnpzm4x3xRiyHCuUvtxmL2aPdBQ37Zt8Mt5drd9hZhNzFQ'; + var EXPECTED_ERROR_MESSAGE = + 'Signature algorithm of "HS512" is not supported. Expected the ID token to be signed with "RS256" or "HS256".'; + try { + idToken.validate(token, defaultOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when the audience is not a string or array', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Audience (aud) claim must be a string or array of strings present in the ID token'; + try { + idToken.validate(generateJWT({ aud: undefined }), defaultOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when expected audience doesnt match claim audience', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Audience (aud) claim mismatch in the ID token; expected "expectedAudience" but was not one of "tokens-test-123, external-test-999"'; + try { + expectedOptions.audience = 'expectedAudience'; + idToken.validate(generateJWT({}), expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when azp claim not found when aud has multiple values', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values'; + try { + var token = generateJWT({ azp: undefined }); + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when azp claim doesnt match the expected aud', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Authorized Party (azp) claim mismatch in the ID token; expected "external-test-999", found "tokens-test-123"'; + try { + var token = generateJWT({}); + expectedOptions.audience = expectations.clientIdAlt; + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + + it('should throw when nonce is in options, but missing from claim', function(done) { + var EXPECTED_ERROR_MESSAGE = 'Nonce (nonce) claim must be a string present in the ID token'; + try { + var token = generateJWT({ nonce: undefined }); + + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when nonce claim doesnt match nonce expected', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Nonce (nonce) claim mismatch in the ID token; expected "noncey", found "notExpectedNonce"'; + try { + var token = generateJWT({ nonce: 'notExpectedNonce' }); + expectedOptions.nonce = 'noncey'; + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when auth_time is not a number', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'; + try { + var token = generateJWT({ auth_time: undefined }); + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when exp is not a number', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Expiration Time (exp) claim must be a number present in the ID token'; + try { + var token = generateJWT({ exp: 'not a number' }); + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert.equal(error.message, EXPECTED_ERROR_MESSAGE); + done(); + } + }); + it('should throw when exp has passed', function(done) { + var EXPECTED_ERROR_MESSAGE = 'is after expiration time'; + try { + var token = generateJWT({ exp: yesterday() }); + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert(error.message.includes(EXPECTED_ERROR_MESSAGE)); + done(); + } + }); + it('should throw when idtoken indicates too much time has passed', function(done) { + var EXPECTED_ERROR_MESSAGE = + 'Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication.'; + try { + var token = generateJWT({ auth_time: yesterday() }); + idToken.validate(token, expectedOptions); + done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); + } catch (error) { + assert(error.message.includes(EXPECTED_ERROR_MESSAGE)); + done(); + } + }); +}); From 5974c64f68b64b28335cd27bd201655734505e54 Mon Sep 17 00:00:00 2001 From: davidpatrick Date: Mon, 13 Jan 2020 14:35:20 -0800 Subject: [PATCH 2/6] Updated tests --- test/idToken.tests.js | 241 +++++++++++++++--------------------------- 1 file changed, 85 insertions(+), 156 deletions(-) diff --git a/test/idToken.tests.js b/test/idToken.tests.js index eedb436a8..6f0ea3eed 100644 --- a/test/idToken.tests.js +++ b/test/idToken.tests.js @@ -1,4 +1,5 @@ var assert = require('assert'); +var expect = require('chai').expect; var jws = require('jws'); var idToken = require('../src/auth/idToken'); @@ -130,179 +131,107 @@ describe('idToken.validate', function() { expectedOptions.maxAge = 123; }); - it('should throw when no id token is present', function(done) { - var EXPECTED_ERROR_MESSAGE = 'ID token is required but missing'; - try { + it('should throw when no id token is present', function() { + expect(function() { idToken.validate(); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } + }).to.throw('ID token is required but missing'); }); - it('should throw when no Issuer is present in the claim', function(done) { - var EXPECTED_ERROR_MESSAGE = 'Issuer (iss) claim must be a string present in the ID token'; - try { + it('should throw when no Issuer is present in the claim', function() { + expect(function() { idToken.validate(generateJWT({ iss: undefined })); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } + }).to.throw('Issuer (iss) claim must be a string present in the ID token'); }); - it('should throw when the expected issuer is not in the claim', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Issuer (iss) claim mismatch in the ID token; expected "ExpectedIssuer", found "https://tokens-test.auth0.com/"'; - try { + it('should throw when the expected issuer is not in the claim', function() { + expect(function() { idToken.validate(generateJWT({}), { issuer: 'ExpectedIssuer' }); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } + }).to.throw( + 'Issuer (iss) claim mismatch in the ID token; expected "ExpectedIssuer", found "https://tokens-test.auth0.com/"' + ); }); - it('should throw when the claim has no Subject', function(done) { - var EXPECTED_ERROR_MESSAGE = 'Subject (sub) claim must be a string present in the ID token'; - try { + it('should throw when the claim has no Subject', function() { + expect(function() { idToken.validate(generateJWT({ sub: undefined }), defaultOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } + }).to.throw('Subject (sub) claim must be a string present in the ID token'); }); - it('should throw when the alg is neither rs256 or hs256', function(done) { + it('should throw when the alg is neither rs256 or hs256', function() { var token = 'eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOiJ0b2tlbnMtdGVzdC0xMjMifQ.cZ4qDwoKdKQx8DtD-F-xVKCxd3rz58wSJh3k28z5qnpzm4x3xRiyHCuUvtxmL2aPdBQ37Zt8Mt5drd9hZhNzFQ'; - var EXPECTED_ERROR_MESSAGE = - 'Signature algorithm of "HS512" is not supported. Expected the ID token to be signed with "RS256" or "HS256".'; - try { + + expect(function() { idToken.validate(token, defaultOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } + }).to.throw( + 'Signature algorithm of "HS512" is not supported. Expected the ID token to be signed with "RS256" or "HS256".' + ); }); - it('should throw when the audience is not a string or array', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Audience (aud) claim must be a string or array of strings present in the ID token'; - try { + it('should throw when the audience is not a string or array', function() { + expect(function() { idToken.validate(generateJWT({ aud: undefined }), defaultOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } - }); - it('should throw when expected audience doesnt match claim audience', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Audience (aud) claim mismatch in the ID token; expected "expectedAudience" but was not one of "tokens-test-123, external-test-999"'; - try { - expectedOptions.audience = 'expectedAudience'; - idToken.validate(generateJWT({}), expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } - }); - it('should throw when azp claim not found when aud has multiple values', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values'; - try { - var token = generateJWT({ azp: undefined }); - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } - }); - it('should throw when azp claim doesnt match the expected aud', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Authorized Party (azp) claim mismatch in the ID token; expected "external-test-999", found "tokens-test-123"'; - try { - var token = generateJWT({}); - expectedOptions.audience = expectations.clientIdAlt; - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } + }).to.throw( + 'Audience (aud) claim must be a string or array of strings present in the ID token' + ); }); + it('should throw when expected audience doesnt match claim audience', function() { + expectedOptions.audience = 'expectedAudience'; - it('should throw when nonce is in options, but missing from claim', function(done) { - var EXPECTED_ERROR_MESSAGE = 'Nonce (nonce) claim must be a string present in the ID token'; - try { - var token = generateJWT({ nonce: undefined }); + expect(function() { + idToken.validate(generateJWT({}), expectedOptions); + }).to.throw( + 'Audience (aud) claim mismatch in the ID token; expected "expectedAudience" but was not one of "tokens-test-123, external-test-999"' + ); + }); + it('should throw when azp claim not found when aud has multiple values', function() { + expect(function() { + idToken.validate(generateJWT({ azp: undefined }), expectedOptions); + }).to.throw( + 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values' + ); + }); + it('should throw when azp claim doesnt match the expected aud', function() { + expectedOptions.audience = expectations.clientIdAlt; - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } - }); - it('should throw when nonce claim doesnt match nonce expected', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Nonce (nonce) claim mismatch in the ID token; expected "noncey", found "notExpectedNonce"'; - try { - var token = generateJWT({ nonce: 'notExpectedNonce' }); - expectedOptions.nonce = 'noncey'; - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } - }); - it('should throw when auth_time is not a number', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'; - try { - var token = generateJWT({ auth_time: undefined }); - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } - }); - it('should throw when exp is not a number', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Expiration Time (exp) claim must be a number present in the ID token'; - try { - var token = generateJWT({ exp: 'not a number' }); - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert.equal(error.message, EXPECTED_ERROR_MESSAGE); - done(); - } + expect(function() { + idToken.validate(generateJWT({}), expectedOptions); + }).to.throw( + 'Authorized Party (azp) claim mismatch in the ID token; expected "external-test-999", found "tokens-test-123"' + ); }); - it('should throw when exp has passed', function(done) { - var EXPECTED_ERROR_MESSAGE = 'is after expiration time'; - try { - var token = generateJWT({ exp: yesterday() }); - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert(error.message.includes(EXPECTED_ERROR_MESSAGE)); - done(); - } + + it('should throw when nonce is in options, but missing from claim', function() { + expect(function() { + idToken.validate(generateJWT({ nonce: undefined }), expectedOptions); + }).to.throw('Nonce (nonce) claim must be a string present in the ID token'); }); - it('should throw when idtoken indicates too much time has passed', function(done) { - var EXPECTED_ERROR_MESSAGE = - 'Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication.'; - try { - var token = generateJWT({ auth_time: yesterday() }); - idToken.validate(token, expectedOptions); - done(new Error('Should have thrown error: ' + EXPECTED_ERROR_MESSAGE)); - } catch (error) { - assert(error.message.includes(EXPECTED_ERROR_MESSAGE)); - done(); - } + it('should throw when nonce claim doesnt match nonce expected', function() { + expectedOptions.nonce = 'noncey'; + + expect(function() { + idToken.validate(generateJWT({ nonce: 'notExpectedNonce' }), expectedOptions); + }).to.throw( + 'Nonce (nonce) claim mismatch in the ID token; expected "noncey", found "notExpectedNonce"' + ); + }); + it('should throw when auth_time is not a number', function() { + expect(function() { + idToken.validate(generateJWT({ auth_time: undefined }), expectedOptions); + }).to.throw( + 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified' + ); + }); + it('should throw when exp is not a number', function() { + expect(function() { + idToken.validate(generateJWT({ exp: 'not a number' }), expectedOptions); + }).to.throw('Expiration Time (exp) claim must be a number present in the ID token'); + }); + it('should throw when exp has passed', function() { + expect(function() { + idToken.validate(generateJWT({ exp: yesterday() }), expectedOptions); + }).to.throw('is after expiration time'); + }); + it('should throw when idtoken indicates too much time has passed', function() { + expect(function() { + idToken.validate(generateJWT({ auth_time: yesterday() }), expectedOptions); + }).to.throw( + 'Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication.' + ); }); }); From 6effccb8f5bdd6c30f265ea0c814542ba757ab34 Mon Sep 17 00:00:00 2001 From: davidpatrick Date: Mon, 13 Jan 2020 14:41:37 -0800 Subject: [PATCH 3/6] Add nonce string teest --- test/idToken.tests.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/idToken.tests.js b/test/idToken.tests.js index 6f0ea3eed..26e2e34c7 100644 --- a/test/idToken.tests.js +++ b/test/idToken.tests.js @@ -195,7 +195,6 @@ describe('idToken.validate', function() { 'Authorized Party (azp) claim mismatch in the ID token; expected "external-test-999", found "tokens-test-123"' ); }); - it('should throw when nonce is in options, but missing from claim', function() { expect(function() { idToken.validate(generateJWT({ nonce: undefined }), expectedOptions); @@ -210,6 +209,11 @@ describe('idToken.validate', function() { 'Nonce (nonce) claim mismatch in the ID token; expected "noncey", found "notExpectedNonce"' ); }); + it('should throw when nonce claim is not a string', function() { + expect(function() { + idToken.validate(generateJWT({ nonce: 10000 }), expectedOptions); + }).to.throw('Nonce (nonce) claim must be a string present in the ID token'); + }); it('should throw when auth_time is not a number', function() { expect(function() { idToken.validate(generateJWT({ auth_time: undefined }), expectedOptions); From f19a34449481450635bd99e8b9ff37534c1bfc9a Mon Sep 17 00:00:00 2001 From: davidpatrick Date: Tue, 14 Jan 2020 10:25:53 -0800 Subject: [PATCH 4/6] validate aud payload --- test/idToken.tests.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/idToken.tests.js b/test/idToken.tests.js index 26e2e34c7..8c2b5fd96 100644 --- a/test/idToken.tests.js +++ b/test/idToken.tests.js @@ -179,6 +179,14 @@ describe('idToken.validate', function() { 'Audience (aud) claim mismatch in the ID token; expected "expectedAudience" but was not one of "tokens-test-123, external-test-999"' ); }); + it('should throw when expected audience is not a String or Array', function() { + expectedOptions.audience = 'expectedAudience'; + expect(function() { + idToken.validate(generateJWT({ aud: 10000 }), expectedOptions); + }).to.throw( + 'Audience (aud) claim must be a string or array of strings present in the ID token' + ); + }); it('should throw when azp claim not found when aud has multiple values', function() { expect(function() { idToken.validate(generateJWT({ azp: undefined }), expectedOptions); From 76da1c1bb515ed2f17e7f13711d9a29954700922 Mon Sep 17 00:00:00 2001 From: davidpatrick Date: Tue, 14 Jan 2020 10:39:59 -0800 Subject: [PATCH 5/6] validate data type --- test/idToken.tests.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/idToken.tests.js b/test/idToken.tests.js index 8c2b5fd96..4d328a385 100644 --- a/test/idToken.tests.js +++ b/test/idToken.tests.js @@ -186,6 +186,11 @@ describe('idToken.validate', function() { }).to.throw( 'Audience (aud) claim must be a string or array of strings present in the ID token' ); + expect(function() { + idToken.validate(generateJWT({ aud: {} }), expectedOptions); + }).to.throw( + 'Audience (aud) claim must be a string or array of strings present in the ID token' + ); }); it('should throw when azp claim not found when aud has multiple values', function() { expect(function() { @@ -221,6 +226,9 @@ describe('idToken.validate', function() { expect(function() { idToken.validate(generateJWT({ nonce: 10000 }), expectedOptions); }).to.throw('Nonce (nonce) claim must be a string present in the ID token'); + expect(function() { + idToken.validate(generateJWT({ nonce: {} }), expectedOptions); + }).to.throw('Nonce (nonce) claim must be a string present in the ID token'); }); it('should throw when auth_time is not a number', function() { expect(function() { From aebd41f66030481a4011a35156b0a83420b25c18 Mon Sep 17 00:00:00 2001 From: davidpatrick Date: Tue, 14 Jan 2020 11:21:01 -0800 Subject: [PATCH 6/6] Better audience mismatch tests --- test/idToken.tests.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/idToken.tests.js b/test/idToken.tests.js index 4d328a385..6a99ad571 100644 --- a/test/idToken.tests.js +++ b/test/idToken.tests.js @@ -170,17 +170,21 @@ describe('idToken.validate', function() { 'Audience (aud) claim must be a string or array of strings present in the ID token' ); }); - it('should throw when expected audience doesnt match claim audience', function() { - expectedOptions.audience = 'expectedAudience'; - + it('should throw when claim audience is a String and not expected audience', function() { expect(function() { - idToken.validate(generateJWT({}), expectedOptions); + idToken.validate(generateJWT({ aud: 'notExpected' }), expectedOptions); + }).to.throw( + 'Audience (aud) claim mismatch in the ID token; expected "tokens-test-123" but found "notExpected"' + ); + }); + it('should throw when claim audience is an Array and not expected audience', function() { + expect(function() { + idToken.validate(generateJWT({ aud: ['notExpected'] }), expectedOptions); }).to.throw( - 'Audience (aud) claim mismatch in the ID token; expected "expectedAudience" but was not one of "tokens-test-123, external-test-999"' + 'Audience (aud) claim mismatch in the ID token; expected "tokens-test-123" but was not one of "notExpected"' ); }); it('should throw when expected audience is not a String or Array', function() { - expectedOptions.audience = 'expectedAudience'; expect(function() { idToken.validate(generateJWT({ aud: 10000 }), expectedOptions); }).to.throw(