Skip to content

Commit

Permalink
feat: add createRemoteJWKSet cacheMaxAge option
Browse files Browse the repository at this point in the history
resolves #394

Co-authored-by: Filip Skokan <[email protected]>
  • Loading branch information
ghdoergeloh and panva authored Apr 21, 2022
1 parent 0849d0e commit 5017d95
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 18 deletions.
53 changes: 35 additions & 18 deletions src/jwks/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,35 @@ import { isJWKSLike, LocalJWKSet } from './local.js'
*/
export interface RemoteJWKSetOptions {
/**
* Timeout (in milliseconds) for the HTTP request. When reached the request will be
* aborted and the verification will fail. Default is 5000.
* Timeout (in milliseconds) for the HTTP request. When reached the request
* will be aborted and the verification will fail. Default is 5000 (5
* seconds).
*/
timeoutDuration?: number

/**
* Duration (in milliseconds) for which no more HTTP requests will be triggered
* after a previous successful fetch. Default is 30000.
* Duration (in milliseconds) for which no more HTTP requests will be
* triggered after a previous successful fetch. Default is 30000 (30 seconds).
*/
cooldownDuration?: number

/**
* An instance of [http.Agent](https://nodejs.org/api/http.html#http_class_http_agent)
* or [https.Agent](https://nodejs.org/api/https.html#https_class_https_agent) to pass
* to the [http.get](https://nodejs.org/api/http.html#http_http_get_options_callback)
* or [https.get](https://nodejs.org/api/https.html#https_https_get_options_callback)
* method's options. Use when behind an http(s) proxy.
* This is a Node.js runtime specific option, it is ignored
* when used outside of Node.js runtime.
* Maximum time (in milliseconds) between successful HTTP requests. Default is
* 600000 (10 minutes).
*/
cacheMaxAge?: number | typeof Infinity

/**
* An instance of
* [http.Agent](https://nodejs.org/api/http.html#http_class_http_agent) or
* [https.Agent](https://nodejs.org/api/https.html#https_class_https_agent) to
* pass to the
* [http.get](https://nodejs.org/api/http.html#http_http_get_options_callback)
* or
* [https.get](https://nodejs.org/api/https.html#https_https_get_options_callback)
* method's options. Use when behind an http(s) proxy. This is a Node.js
* runtime specific option, it is ignored when used outside of Node.js
* runtime.
*/
agent?: any
}
Expand All @@ -41,7 +51,9 @@ class RemoteJWKSet extends LocalJWKSet {

private _cooldownDuration: number

private _cooldownStarted?: number
private _cacheMaxAge: number

private _jwksTimestamp?: number

private _pendingFetch?: Promise<unknown>

Expand All @@ -61,18 +73,23 @@ class RemoteJWKSet extends LocalJWKSet {
typeof options?.timeoutDuration === 'number' ? options?.timeoutDuration : 5000
this._cooldownDuration =
typeof options?.cooldownDuration === 'number' ? options?.cooldownDuration : 30000
this._cacheMaxAge = typeof options?.cacheMaxAge === 'number' ? options?.cacheMaxAge : 600000
}

coolingDown() {
if (!this._cooldownStarted) {
return false
}
return typeof this._jwksTimestamp === 'number'
? Date.now() < this._jwksTimestamp + this._cooldownDuration
: false
}

return Date.now() < this._cooldownStarted + this._cooldownDuration
fresh() {
return typeof this._jwksTimestamp === 'number'
? Date.now() < this._jwksTimestamp + this._cacheMaxAge
: false
}

async getKey(protectedHeader: JWSHeaderParameters, token: FlattenedJWSInput): Promise<KeyLike> {
if (!this._jwks) {
if (!this._jwks || !this.fresh()) {
await this.reload()
}

Expand Down Expand Up @@ -112,7 +129,7 @@ class RemoteJWKSet extends LocalJWKSet {
}

this._jwks = { keys: json.keys }
this._cooldownStarted = Date.now()
this._jwksTimestamp = Date.now()
this._pendingFetch = undefined
})
.catch((err: Error) => {
Expand Down
70 changes: 70 additions & 0 deletions test/jwks/remote.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,76 @@ skipOnUndiciTestSerial('refreshes the JWKS once off cooldown', async (t) => {
}
})

skipOnUndiciTestSerial('refreshes the JWKS once stale', async (t) => {
timekeeper.freeze(now * 1000)
let jwk = {
crv: 'P-256',
x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E',
kty: 'EC',
}
const jwks = {
keys: [
{
crv: 'P-256',
x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
kty: 'EC',
kid: 'one',
},
],
}

nock('https://as.example.com').get('/jwks').twice().reply(200, jwks)

const url = new URL('https://as.example.com/jwks')
const JWKS = createRemoteJWKSet(url, { cacheMaxAge: 60 * 10 * 1000 })
const key = await importJWK({ ...jwk, alg: 'ES256' })
{
const jwt = await new SignJWT({}).setProtectedHeader({ alg: 'ES256', kid: 'one' }).sign(key)
await t.notThrowsAsync(jwtVerify(jwt, JWKS))
await t.notThrowsAsync(jwtVerify(jwt, JWKS))
timekeeper.travel((now + 60 * 10) * 1000)
await t.notThrowsAsync(jwtVerify(jwt, JWKS))
}
})

skipOnUndiciTestSerial('can be configured to never be stale', async (t) => {
timekeeper.freeze(now * 1000)
let jwk = {
crv: 'P-256',
x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E',
kty: 'EC',
}
const jwks = {
keys: [
{
crv: 'P-256',
x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
kty: 'EC',
kid: 'one',
},
],
}

nock('https://as.example.com').get('/jwks').once().reply(200, jwks)

const url = new URL('https://as.example.com/jwks')
const JWKS = createRemoteJWKSet(url, { cacheMaxAge: Infinity })
const key = await importJWK({ ...jwk, alg: 'ES256' })
{
const jwt = await new SignJWT({}).setProtectedHeader({ alg: 'ES256', kid: 'one' }).sign(key)
await t.notThrowsAsync(jwtVerify(jwt, JWKS))
await t.notThrowsAsync(jwtVerify(jwt, JWKS))
timekeeper.travel((now + 60 * 10) * 1000)
await t.notThrowsAsync(jwtVerify(jwt, JWKS))
}
})

skipOnUndiciTestSerial('throws on invalid JWKSet', async (t) => {
const scope = nock('https://as.example.com').get('/jwks').once().reply(200, 'null')

Expand Down

0 comments on commit 5017d95

Please sign in to comment.