Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add options for jwks-rsa to enable cache and rate limiting #6

Merged
merged 11 commits into from
Feb 25, 2019
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
language: node_js
node_js:
- '10'
- '8'
- '7'
- '6'
cdwills marked this conversation as resolved.
Show resolved Hide resolved
before_install:
- npm install -g yarn@1.3.2
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ Initialize `authentic` with an options object containing an `issWhitelist` array

**Note:** The urls in the list need to be **exact matches** of the `payload.iss` values in your JWT's.

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
Expand All @@ -39,3 +37,17 @@ const handler = req =>
authentic(req.cookies.token)
.then(/* the JWT has been validated */)
```

## Options

`authentic` accepts a JSON object with the following options:

* `jwks` Object: options to forward to [`node-jwks-rsa`](https://github.com/auth0/node-jwks-rsa) with the following defaults:

| option | default |
| ----------- | ------- |
| `cache` | `true` |
| `rateLimit` | `true` |

* `verify` Object: options to forward to `jwt.verify` from [`jsonwebtoken`](https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback)
* `issWhitelist` Array: list of trusted OIDC issuers
25 changes: 16 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ const jwks = require('jwks-rsa')
const jwt = require('jsonwebtoken')

const {
applyTo: thrush, composeP, curryN, dissoc, partialRight, prop, replace
applyTo: thrush, compose, composeP, curryN, merge,
mergeDeepRight, partialRight, prop, replace
} = require('ramda')

const { promisify, rename, tapP } = require('@articulate/funky')
Expand All @@ -14,11 +15,11 @@ const wellKnown = '/.well-known/openid-configuration'
const bindFunction = client =>
promisify(client.getSigningKey, client)

const buildClient = url =>
const buildClient = (jwksOpts, url) =>
gimme({ url })
.then(prop('body'))
.then(rename('jwks_uri', 'jwksUri'))
.then(jwks)
.then(compose(jwks, merge(jwksOpts)))
.then(bindFunction)

const chooseKey = key =>
Expand All @@ -29,15 +30,21 @@ const decode = partialRight(jwt.decode, [{ complete: true }])
const enforce = token =>
token || Promise.reject(Boom.unauthorized('null token not allowed'))

const stripBearer =
const stripBearer =
replace(/^Bearer /i, '')

const unauthorized = err =>
Promise.reject(Boom.wrap(err, 401))
Promise.reject(Boom.unauthorized(err))

const factory = opts => {
const clients = {}
const verifyOpts = dissoc('issWhitelist', opts)
const jwksOptsDefaults = { jwks: { cache: true, rateLimit: true } }

const factory = options => {
const clients = {}
const opts = mergeDeepRight(jwksOptsDefaults, options)
const {
verify: verifyOpts = {},
jwks: jwksOpts
} = opts

const cacheClient = iss => client =>
clients[iss] = client
Expand All @@ -49,7 +56,7 @@ const factory = opts => {
const getSigningKey = ({ header: { kid }, payload: { iss } }) =>
clients[iss]
? clients[iss](kid)
: buildClient(iss.replace(/\/$/, '') + wellKnown)
: buildClient(jwksOpts, iss.replace(/\/$/, '') + wellKnown)
.then(cacheClient(iss))
.then(thrush(kid))

Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@articulate/authentic",
"version": "0.2.0",
"version": "1.0.0",
"description": "Proper validation of JWT's against JWK's",
"main": "index.js",
"repository": "git@github.com:articulate/authentic.git",
Expand All @@ -25,18 +25,18 @@
"dependencies": {
"@articulate/funky": "^1.2.0",
"@articulate/gimme": "^1.0.0",
"boom": "5.2.0",
"boom": "7.3.x",
"jsonwebtoken": "^8.1.1",
"jwks-rsa": "^1.2.1",
"ramda": "^0.25.0"
},
"devDependencies": {
"chai": "^4.1.2",
"coveralls": "^3.0.0",
"eslint": "^4.16.0",
"mocha": "^5.0.0",
"nock": "^9.1.6",
"nyc": "^11.4.1",
"eslint": "5.14.x",
"mocha": "6.0.x",
"nock": "10.x.x",
"nyc": "13.x.x",
"prop-factory": "^1.0.0"
}
}
170 changes: 102 additions & 68 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ const lowerBearerToken = 'bearer ' + token

const { issuer } = oidc

const authentic = require('..')({
ignoreExpiration: true,
issWhitelist: [ issuer ],
})

const badIss = jwt.decode(bad, { complete: true }).payload.iss

Expand All @@ -35,86 +31,124 @@ describe('authentic', () => {
nock.cleanAll()
)

describe('with a valid jwt', () => {
beforeEach(() =>
authentic(token).then(res)
)
describe('setup with minimal valid configuration options', () => {
const authentic = require('..')({
issWhitelist: [ issuer ],
})

it('validates the jwt against the jwks', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)
describe('with an expired jwt', () => {
beforeEach(() =>
authentic(token).catch(res)
)

it('caches the jwks client', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)
it('booms with a 401', () => {
expect(res().isBoom).to.be.true
expect(res().output.statusCode).to.equal(401)
})
})
})

describe('with a valid jwt that starts with Bearer', () => {
beforeEach(() =>
authentic(capitalBearerToken).then(res)
)
describe('setup with valid configuration options', () => {
const authentic = require('..')({
verify: { ignoreExpiration: true },
issWhitelist: [ issuer ],
})

it('validates the jwt against the jwks', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)
describe('with a valid jwt', () => {
beforeEach(() =>
authentic(token).then(res)
)

it('caches the jwks client', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)
})

describe('with a valid jwt that starts with bearer', () => {
beforeEach(() =>
authentic(lowerBearerToken).then(res)
)

it('validates the jwt against the jwks', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)

it('caches the jwks client', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)
})
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', () => {
beforeEach(() =>
authentic('invalid').catch(res)
)
describe('with a valid jwt that starts with Bearer', () => {
beforeEach(() =>
authentic(capitalBearerToken).then(res)
)

it('booms with a 401', () => {
expect(res().isBoom).to.be.true
expect(res().output.statusCode).to.equal(401)
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 iss', () => {
beforeEach(() =>
authentic(bad).catch(res)
)
describe('with a valid jwt that starts with bearer', () => {
beforeEach(() =>
authentic(lowerBearerToken).then(res)
)

it('booms with a 401', () => {
expect(res().isBoom).to.be.true
expect(res().output.statusCode).to.equal(401)
it('validates the jwt against the jwks', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)

it('caches the jwks client', () =>
expect(res().sub).to.equal('00udjyjssbt2S1QVr0h7')
)
})

it('includes the invalid iss in the error message', () =>
expect(res().output.payload.message).to.contain(badIss)
)
})
describe('with an invalid jwt', () => {
beforeEach(() =>
authentic('invalid').catch(res)
)

it('booms with a 401', () => {
expect(res().isBoom).to.be.true
expect(res().output.statusCode).to.equal(401)
})
})

describe('with an expired jwt', () => {
beforeEach(() => {
const auth = require('..')({
issWhitelist: [ issuer ],
})
auth(token).catch(res)
})

it('booms with a 401', () => {
expect(res().isBoom).to.be.true
expect(res().output.statusCode).to.equal(401)
})
})

describe('with an invalid iss', () => {
beforeEach(() =>
authentic(bad).catch(res)
)

describe('with a null token', () => {
beforeEach(() =>
authentic(null).catch(res)
)
it('booms with a 401', () => {
expect(res().isBoom).to.be.true
expect(res().output.statusCode).to.equal(401)
})

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)
)
})

it('mentions that the token was null', () =>
expect(res().output.payload.message).to.contain('null token')
)
describe('with a null token', () => {
beforeEach(() =>
authentic(null).catch(res)
)

it('booms with a 401', () => {
expect(res().isBoom).to.be.true
expect(res().output.statusCode).to.equal(401)
})

it('mentions that the token was null', () =>
expect(res().output.payload.message).to.contain('null token')
)
})
})
})