diff --git a/README.md b/README.md index 5088717..56901d4 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Proper validation of JWT's against JWK's. ## Motivation -The process of validating JWT's against JWK's is [quite involved](https://auth0.com/blog/navigating-rs256-and-jwks/), but at the end of the day, you've probably got an auth server with a `/.well-known/openid-configuration` endpoint, and you just want to know if an incoming JWT is valid. End of story. You don't want to fumble with parsing the JWT, matching `kid` values, converting certs, and caching JWK's. +The process of validating JWT's against JWK's is [quite involved](https://auth0.com/blog/navigating-rs256-and-jwks/), but at the end of the day, you probably have an auth server with a `/.well-known/openid-configuration` endpoint, and you just want to know if an incoming JWT is valid. End of story. You don't want to fumble with parsing the JWT, matching `kid` values, converting certs, or caching JWK's. -Now you don't need to. Initialize `authentic` with your base `oidcURI`, and you'll receive a function that accepts a JWT and validates it. The rest is handled for you. +Now you don't need to. Initialize `authentic` with an `issWhitelist`, and you'll receive a function that accepts a JWT and validates it. The rest is handled for you. ## Usage @@ -18,21 +18,23 @@ Now you don't need to. Initialize `authentic` with your base `oidcURI`, and you authentic :: { k: v } -> String -> Promise Boom { k: v } ``` -Initialize `authentic` with an options object containing your base `oidcURI`. For example: +Initialize `authentic` with an options object containing an `issWhitelist` array listing the `token.payload.iss` values you will accept. For example: -| Provider | Suggested `oidcURI` | +| Provider | Sample `issWhitelist` | | -------- | ------------------- | -| [Auth0](https://auth0.com/) | `https://${tenant}.auth0.com` | -| [Okta](https://www.okta.com/) | `https://${tenant}.oktapreview.com/oauth2/${appName}` | +| [Auth0](https://auth0.com/) | `[ 'https://${tenant}.auth0.com' ]` | +| [Okta](https://www.okta.com/) | `[ 'https://${tenant}.oktapreview.com/oauth2/${appName}' ]` | -**Note:** Don't include the `/.well-known/openid-configuration` in your `oidcURI`, as `authentic` will add that for you. +**Note:** Don't include the `/.well-known/openid-configuration` in your `issWhitelist` values, as `authentic` will add that for you. Any other options passed to `authentic` will be forwarded to `jwt.verify()` for validation and parsing. [See the list of available options here.](https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback) You'll receive a unary function which takes a JWT and returns a `Promise` that resolves with the parsed JWT payload if it is valid, or rejects with a `401` [Boom](https://github.com/hapijs/boom) error if it is invalid. ```js -const authentic = require('@articulate/authentic')({ oidcURI: process.env.OIDC_URI }) +const authentic = require('@articulate/authentic')({ + issWhitelist: JSON.parse(process.env.ISS_WHITELIST) +}) const handler = req => authentic(req.cookies.token) diff --git a/index.js b/index.js index c92a4d8..d0fec2f 100644 --- a/index.js +++ b/index.js @@ -2,10 +2,9 @@ const Boom = require('boom') const gimme = require('@articulate/gimme') const jwks = require('jwks-rsa') const jwt = require('jsonwebtoken') -const property = require('prop-factory') const { - applyTo: thrush, curryN, dissoc, partialRight, path, prop + applyTo: thrush, curryN, dissoc, partialRight, prop } = require('ramda') const { promisify, rename } = require('@articulate/funky') @@ -31,15 +30,22 @@ const unauthorized = err => Promise.reject(Boom.wrap(err, 401)) const factory = opts => { - const verifyOpts = dissoc('oidcURI', opts) + const clients = {} + const verifyOpts = dissoc('issWhitelist', opts) - const getKey = property() + const cacheClient = iss => client => + clients[iss] = client - const getSigningKey = kid => - getKey() - ? getKey()(kid) - : buildClient(opts.oidcURI + wellKnown) - .then(getKey) + const checkIss = token => + opts.issWhitelist.indexOf(token.payload.iss) > -1 + ? Promise.resolve(token) + : Promise.reject(new Error(`iss '${token.payload.iss}' not in issWhitelist`)) + + const getSigningKey = ({ header: { kid }, payload: { iss } }) => + clients[iss] + ? clients[iss](kid) + : buildClient(iss + wellKnown) + .then(cacheClient(iss)) .then(thrush(kid)) const verify = curryN(2, partialRight(promisify(jwt.verify), [ verifyOpts ])) @@ -47,7 +53,7 @@ const factory = opts => { const authentic = token => Promise.resolve(token) .then(decode) - .then(path(['header', 'kid'])) + .then(checkIss) .then(getSigningKey) .then(chooseKey) .then(verify(token)) diff --git a/package.json b/package.json index 508cdfd..7cc7661 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,11 @@ "test:coverage": "nyc yarn test" }, "dependencies": { - "@articulate/funky": "^0.1.0", + "@articulate/funky": "^1.2.0", "@articulate/gimme": "^1.0.0", "boom": "5.2.0", "jsonwebtoken": "^8.1.1", "jwks-rsa": "^1.2.1", - "prop-factory": "^1.0.0", "ramda": "^0.25.0" }, "devDependencies": { @@ -37,6 +36,7 @@ "eslint": "^4.16.0", "mocha": "^5.0.0", "nock": "^9.1.6", - "nyc": "^11.4.1" + "nyc": "^11.4.1", + "prop-factory": "^1.0.0" } } diff --git a/test/fixtures/bad-iss.js b/test/fixtures/bad-iss.js new file mode 100644 index 0000000..6a0a04e --- /dev/null +++ b/test/fixtures/bad-iss.js @@ -0,0 +1 @@ +module.exports = 'eyJraWQiOiJEYVgxMWdBcldRZWJOSE83RU1QTUw1VnRUNEV3cmZrd2M1U2xHaVd2VXdBIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHVkanlqc3NidDJTMVFWcjBoNyIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9iYWQtaXNzLmNvbSIsImF1ZCI6IjBvYWRqeWs1MjNobFpmeWIxMGg3IiwiaWF0IjoxNTE2NjM3MDkxLCJleHAiOjE1MTY2NDA2OTEsImp0aSI6IklELmM4amh6b2t5MGZGTlByOExfU0NycnBnVFRVeUFvY3RIdjY5T0tTbWY1R0EiLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwb2NnNHRidTZGSzJEaDVHMGg3Iiwibm9uY2UiOiIyIiwiYXV0aF90aW1lIjoxNTE2NjM3MDkxLCJ0ZW5hbnRJZCI6ImQ0MmUzM2ZkLWYwNWUtNGE0ZS05MDUwLTViN2IyZTgwMDgzNCJ9.Senilj3Z8Z99b-UVnnxwWKjYIn4jNrE-BmZAuR7Qb3nkxS7N-r7WnAQ-4vuqtD5Fyy-1zOFUxoO6jyMvhWbhNlPmYaBQk7InKZU6ABayrijfv7OJSQKzs0Q7EQbgtW4T27Gqp6G4Rp9l7O472lgwapTV_L2IUqYNP7aC3FAFcqmpP_KFyeKj-zcwil6aszPgxzMA3Rp33BqQfuhIJKSYqWQT6pkDXkjM3pLxaHRfrRahQ2F0M190iCvBJMc4b82TVoQQu5uJbb1mD97wwlSvMFYCHN_51g9IY5BabZcOv4h0T3-XqFxPNbS8PZVfBikumkhqD5b4zjA-3ddgPw2GkA' diff --git a/test/fixtures/keys.json b/test/fixtures/keys.json index fda854b..8bfd07e 100644 --- a/test/fixtures/keys.json +++ b/test/fixtures/keys.json @@ -11,7 +11,7 @@ { "alg": "RS256", "e": "AQAB", - "n": "gRuHPh4qGuJweXLgwDdOoTL9vCYTn4_TgCa88KArGb0IulDnRolPCycM99_2EseGbkCuuss4O1r7W6vqfs2AXmFetFU3GOxobc8fG1z_qxB7JYU5bRmWjN6UCK43O7HIbRuvdGqblX98DRlx4NVTFFLoJlFGGu87-Fn2unbR8WbgZwel9saZVeVm2SYkba5hYOAwu3_M3ulilyTOo-kwgg9mU-YW10FD_XXevNcT6j0CCqN3HsRYlDr_7-JVG1hlow6As8WaRrSjXb6JQJoFcvEnPmSauSsgvdkuxtRKc59_T_vOESaL70DX9b2hmkDvHau5p7kO7IIMyUgoPK13FQ", + "n": "AJc7FAIHQeLMoKyR70lJTfby1D_4uW6gAuhR0SrdrCHO-Hou9lRroAyJv6Bsa8E9kW460c8SbPOoaXj-qHFCLbAv7L46ZRYkW7hXqSdx3f35gEQshg7amiZVBExj07ziW4_aljXMiRsRu4PG22YahSM6jr106SzblHOepeCpKSFPOED_CrhV3Ky17vAKfv_YhtSNfnnjk9TyIMr7XFSn5NJntiMbwyVOmVlW1U_HE39aQXo8kBrkM11puFexgs6bWeLzu_jT2-M81B0hbo-OGWxiakZYxKBB80W-ybUpcDqVsRRjo1QluJkfDALj7vAn5iJiC1lhN0y1hn3ohnXzpmk", "kid": "DaX11gArWQebNHO7EMPML5VtT4Ewrfkwc5SlGiWvUwA", "kty": "RSA", "use": "sig" diff --git a/test/fixtures/token.js b/test/fixtures/token.js index 00798f4..8497356 100644 --- a/test/fixtures/token.js +++ b/test/fixtures/token.js @@ -1 +1 @@ -module.exports = 'eyJraWQiOiJEYVgxMWdBcldRZWJOSE83RU1QTUw1VnRUNEV3cmZrd2M1U2xHaVd2VXdBIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHVkanlqc3NidDJTMVFWcjBoNyIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9kZXYtOTM5NDgwLm9rdGFwcmV2aWV3LmNvbS9vYXV0aDIvZGVmYXVsdCIsImF1ZCI6IjBvYWRqeWs1MjNobFpmeWIxMGg3IiwiaWF0IjoxNTE2NjM3MDkxLCJleHAiOjE1MTY2NDA2OTEsImp0aSI6IklELmM4amh6b2t5MGZGTlByOExfU0NycnBnVFRVeUFvY3RIdjY5T0tTbWY1R0EiLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwb2NnNHRidTZGSzJEaDVHMGg3Iiwibm9uY2UiOiIyIiwiYXV0aF90aW1lIjoxNTE2NjM3MDkxLCJ0ZW5hbnRJZCI6ImQ0MmUzM2ZkLWYwNWUtNGE0ZS05MDUwLTViN2IyZTgwMDgzNCJ9.dgdnXNnd0k6HInkjZSlPoF_FFbPHWuzNtvuK7Z0IpKhV-RC--uFyIGkEfzqstAw0LfLuuVxYD4DOuqJeeSX4cvByCUreniRrk4_8sDospiYdmZkpicx1GpVz_CIL2NB86-r-NrJO7nIxYzGITS3zQMI4HgZxISnQgOkscAH_QWgHH6cPKAmzaXnuOdcJE2S3XHp_2lDtqRti9Kf7GRSOuQS3ffY2w2fd1eiYRYVzlP5K9RMgHfM3-JF3aYk-ndJIKzzNb9PjVKNXeTy3FDsYL43wyKOFzV74vfeJDKuKqJ0jhvQihkXWfecOmJIaPcTbnB9ZEehFRhZtV52lvRyHgg' +module.exports = 'eyJraWQiOiJEYVgxMWdBcldRZWJOSE83RU1QTUw1VnRUNEV3cmZrd2M1U2xHaVd2VXdBIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHVkanlqc3NidDJTMVFWcjBoNyIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9hdXRoZW50aWMuYXJ0aWN1bGF0ZS5jb20iLCJhdWQiOiIwb2FkanlrNTIzaGxaZnliMTBoNyIsImlhdCI6MTUxNjYzNzA5MSwiZXhwIjoxNTE2NjQwNjkxLCJqdGkiOiJJRC5jOGpoem9reTBmRk5QcjhMX1NDcnJwZ1RUVXlBb2N0SHY2OU9LU21mNUdBIiwiYW1yIjpbInB3ZCJdLCJpZHAiOiIwMG9jZzR0YnU2RksyRGg1RzBoNyIsIm5vbmNlIjoiMiIsImF1dGhfdGltZSI6MTUxNjYzNzA5MSwidGVuYW50SWQiOiJkNDJlMzNmZC1mMDVlLTRhNGUtOTA1MC01YjdiMmU4MDA4MzQifQ.HfQHsdLFluWFrP6cHX_w9iBEZ3NW-aQ_HUSAXJyecNx213p7Yk4LSB6MmtPPTS3-Ig0EdjnpwrObFNEtTLEXv2KRTN7G7S6HD3uuHZWtiA_b43e2LnZfsWgCeiLq_MbjeumayF8n5uOOrqYxerWBdWmjQt5G4pJc9XhvfxWUEJg1E2ST4Za2eRFooRjxWFshUBHtV81iJ37URfTm2qFtyz-ENz7fQfPeT0KnJ4OIGXunNDQwnm1iUM7CZar2XJlvUU0kCNLCSewlWz1YghTpd6mLrcou2tVgFt7qZ1UP7YIpdtOth3DdYWpyWFEQOEAMAg3EHjer_RXYaeOYqP_wVg' diff --git a/test/index.js b/test/index.js index fdd0417..5c50f86 100644 --- a/test/index.js +++ b/test/index.js @@ -1,21 +1,30 @@ const { expect } = require('chai') +const jwt = require('jsonwebtoken') const nock = require('nock') const property = require('prop-factory') +const bad = require('./fixtures/bad-iss') const keys = require('./fixtures/keys') const oidc = require('./fixtures/oidc') const token = require('./fixtures/token') -const { issuer: oidcURI } = oidc +const { issuer } = oidc -const authentic = require('..')({ oidcURI, ignoreExpiration: true }) +const authentic = require('..')({ + ignoreExpiration: true, + issWhitelist: [ issuer ], +}) + +const badIss = jwt.decode(bad, { complete: true }).payload.iss + +const wellKnown = '/.well-known/openid-configuration' describe('authentic', () => { const res = property() beforeEach(() => { - nock(oidcURI).get('/.well-known/openid-configuration').reply(200, oidc) - nock(oidcURI).get('/v1/keys').reply(200, keys) + nock(issuer).get(wellKnown).once().reply(200, oidc) + nock(issuer).get('/v1/keys').once().reply(200, keys) }) nock.disableNetConnect() @@ -32,6 +41,10 @@ describe('authentic', () => { it('validates the jwt against the jwks', () => expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7') ) + + it('caches the jwks client', () => + expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7') + ) }) describe('with an invalid jwt', () => { @@ -44,4 +57,19 @@ describe('authentic', () => { expect(res().output.statusCode).to.equal(401) }) }) + + describe('with an invalid iss', () => { + beforeEach(() => + authentic(bad).catch(res) + ) + + it('booms with a 401', () => { + expect(res().isBoom).to.be.true + expect(res().output.statusCode).to.equal(401) + }) + + it('includes the invalid iss in the error message', () => + expect(res().output.payload.message).to.contain(badIss) + ) + }) }) diff --git a/yarn.lock b/yarn.lock index 041eaad..7770fa9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,12 +9,11 @@ joi "^10.6.0" ramda "^0.24.1" -"@articulate/funky@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@articulate/funky/-/funky-0.1.0.tgz#d7dc756f62b1f66ba703d13e101e7ae510b625f8" +"@articulate/funky@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@articulate/funky/-/funky-1.2.0.tgz#681776d0f5b0045f9bb00cd623cf398be9fff985" dependencies: - joi "^10.6.0" - ramda "^0.24.1" + ramda "^0.25.0" "@articulate/gimme@^1.0.0": version "1.0.0"