mirror of
https://github.com/hanzoai/node-jwks-rsa.git
synced 2026-09-24 17:00:54 +00:00
feat: add cacheMaxAgeFallback and onStaleCacheFallback for graceful JWKS degradation during outages (#502)
This commit is contained in:
+29
@@ -3,6 +3,7 @@
|
||||
- [Integrations](#integrations)
|
||||
- [Configuration](#configuration)
|
||||
- [Caching](#caching)
|
||||
- [Graceful Degradation during JWKS Endpoint Downtime](#graceful-degradation-during-jwks-endpoint-downtime)
|
||||
- [Rate Limiting](#rate-limiting)
|
||||
- [Using Request Agent for TLS/SSL Configuration](#using-request-agent-for-tlsssl-configuration)
|
||||
- [Proxy Configuration](#proxy-configuration)
|
||||
@@ -29,6 +30,8 @@ This repository holds a number of example integrations found in the [examples](.
|
||||
- `requestAgent`: (_optional_) a Node `http.Agent` to be passed to the http(s) request
|
||||
- `getKeysInterceptor`: (_optional_) a promise returning function hook [(details)](#loading-keys-from-local-file-environment-variable-or-other-externals)
|
||||
- `cacheMaxAge`: (_optional_) the duration for which to store a cached JWKS in ms (default 600,000 or 10 minutes)
|
||||
- `cacheMaxAgeFallback`: (_optional_) additional duration in ms to serve a stale signing key when the JWKS endpoint is unreachable and `cacheMaxAge` has expired. Not set by default — see [(details)](#graceful-degradation-during-jwks-endpoint-downtime)
|
||||
- `onStaleCacheFallback`: (_optional_) callback invoked when a stale key is successfully served during a JWKS endpoint outage. Receives `(err, kid, staleKey)` — useful for metrics and alerting
|
||||
- `jwksRequestsPerMinute`: (_optional_) max number of requests allowed to the JWKS URI per minute (defaults to 10)
|
||||
|
||||
## Caching
|
||||
@@ -50,6 +53,32 @@ const key = await client.getSigningKey(kid);
|
||||
const signingKey = key.getPublicKey();
|
||||
```
|
||||
|
||||
## Graceful Degradation during JWKS Endpoint Downtime
|
||||
|
||||
By default, when `cacheMaxAge` expires and the JWKS endpoint is unreachable, every `getSigningKey` call will fail until the endpoint recovers. With the default `cacheMaxAge` of 10 minutes, this means a service can tolerate only ~5 minutes of JWKS endpoint downtime on average before all signing key lookups start failing.
|
||||
|
||||
Setting `cacheMaxAgeFallback` tells the lib to continue serving the last known good signing key for that additional duration when a refresh attempt fails. Once both `cacheMaxAge` and `cacheMaxAgeFallback` have elapsed since the last successful fetch, the lib stops serving the stale key and throws as normal.
|
||||
|
||||
```js
|
||||
const jwksClient = require('jwks-rsa');
|
||||
|
||||
const client = jwksClient({
|
||||
cache: true,
|
||||
cacheMaxAge: 600000, // 10 minutes — normal freshness TTL
|
||||
cacheMaxAgeFallback: 3600000, // 1 hour — serve stale key if JWKS endpoint is unreachable
|
||||
jwksUri: 'https://sandrino.auth0.com/.well-known/jwks.json',
|
||||
onStaleCacheFallback: (err, kid, staleKey) => {
|
||||
console.warn(`JWKS endpoint unavailable, serving stale key for kid '${kid}': ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const kid = 'RkI5MjI5OUY5ODc1N0Q4QzM0OUYzNkVGMTJDOUEzQkFCOTU3NjE2Rg';
|
||||
const key = await client.getSigningKey(kid);
|
||||
const signingKey = key.getPublicKey();
|
||||
```
|
||||
|
||||
> **Note:** This is an availability vs. security tradeoff. If the JWKS endpoint goes down at the same time keys are being rotated due to a compromise, stale keys will continue to be trusted for the duration of the fallback window. Set `cacheMaxAgeFallback` to a value that reflects your expected JWKS endpoint recovery time.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Even if caching is enabled the library will call the JWKS endpoint if the `kid` is not available in the cache, because a key rotation could have taken place. To prevent attackers to send many random `kid`s you can also configure rate limiting. This will allow you to limit the number of calls that are made to the JWKS endpoint per minute (because it would be highly unlikely that signing keys are rotated multiple times per minute).
|
||||
|
||||
Vendored
+29
@@ -23,6 +23,35 @@ declare namespace JwksRsa {
|
||||
cache?: boolean;
|
||||
cacheMaxEntries?: number;
|
||||
cacheMaxAge?: number;
|
||||
/**
|
||||
* Additional duration in milliseconds to continue serving a stale signing key when
|
||||
* the JWKS endpoint is unreachable and `cacheMaxAge` has expired.
|
||||
*
|
||||
* The fallback window is measured from the last successful fetch:
|
||||
* stale key is served while `Date.now() - lastFetchedAt < cacheMaxAge + cacheMaxAgeFallback`.
|
||||
*
|
||||
* Not set by default — must be opted into explicitly. This is an availability vs. security
|
||||
* tradeoff: a compromised key will continue to be trusted for this window if the endpoint
|
||||
* goes down simultaneously with a key rotation. Set this to your expected endpoint recovery time.
|
||||
*
|
||||
* Has no effect unless `cache` is enabled.
|
||||
*/
|
||||
cacheMaxAgeFallback?: number;
|
||||
/**
|
||||
* Callback invoked when a stale signing key is successfully served because the JWKS
|
||||
* endpoint was unreachable and the entry was still within the `cacheMaxAgeFallback` window.
|
||||
*
|
||||
* Use this to emit metrics, trigger alerts, or log warnings so your observability tooling
|
||||
* can detect JWKS endpoint outages even when requests are succeeding via the stale cache.
|
||||
*
|
||||
* @param err - The endpoint error that caused the fallback (network failure or HTTP error)
|
||||
* @param kid - The key ID that was requested
|
||||
* @param staleKey - The stale signing key being served
|
||||
*
|
||||
* Only called on successful stale fallback. Not called when the fallback window has expired
|
||||
* or when the kid was never fetched before.
|
||||
*/
|
||||
onStaleCacheFallback?(err: Error, kid: string | null | undefined, staleKey: SigningKey): void;
|
||||
jwksRequestsPerMinute?: number;
|
||||
proxy?: string;
|
||||
requestHeaders?: Headers;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"debug": "^4.3.4",
|
||||
"jose": "^6.1.3",
|
||||
"limiter": "^1.1.5",
|
||||
"lru-cache": "^11.0.0",
|
||||
"lru-memoizer": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+3
-1
@@ -45,7 +45,9 @@ class JwksClient {
|
||||
} catch (err) {
|
||||
const { errorMsg } = err;
|
||||
logger('Failure:', errorMsg || err);
|
||||
throw (errorMsg ? new JwksError(errorMsg) : err);
|
||||
const error = errorMsg ? new JwksError(errorMsg) : err;
|
||||
error.isEndpointUnavailable = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
-3
@@ -1,12 +1,53 @@
|
||||
const logger = require('debug')('jwks');
|
||||
const memoizer = require('lru-memoizer');
|
||||
const { LRUCache } = require('lru-cache');
|
||||
const { promisify, callbackify } = require('util');
|
||||
|
||||
function cacheWrapper(client, { cacheMaxEntries = 5, cacheMaxAge = 600000 }) {
|
||||
logger(`Configured caching of signing keys. Max: ${cacheMaxEntries} / Age: ${cacheMaxAge}`);
|
||||
function cacheWrapper(client, { cacheMaxEntries = 5, cacheMaxAge = 600000, cacheMaxAgeFallback, onStaleCacheFallback }) {
|
||||
logger(`Configured caching of signing keys. Max: ${cacheMaxEntries} / Age: ${cacheMaxAge}${cacheMaxAgeFallback ? ` / Fallback: ${cacheMaxAgeFallback}` : ''}`);
|
||||
|
||||
/**
|
||||
* cacheMaxAgeFallback: when the JWKS endpoint is unreachable and cacheMaxAge has expired,
|
||||
* rather than immediately failing to return a signing key, the lib can continue serving
|
||||
* the last known good key for an additional window (cacheMaxAgeFallback ms). This prevents
|
||||
* callers from failing on every getSigningKey call during a transient JWKS endpoint downtime.
|
||||
*
|
||||
* Two load variants: with cacheMaxAgeFallback, maintain a stale-key store so a failed
|
||||
* refresh can fall back to the last known good key within the window. Without it, skip
|
||||
* the extra cache entirely to avoid any overhead.
|
||||
*/
|
||||
let load;
|
||||
if (cacheMaxAgeFallback) {
|
||||
const staleCache = new LRUCache({ max: cacheMaxEntries });
|
||||
const getSigningKey = client.getSigningKey.bind(client);
|
||||
|
||||
load = callbackify(async (kid) => {
|
||||
try {
|
||||
const key = await getSigningKey(kid);
|
||||
staleCache.set(kid, { key, fetchedAt: Date.now() });
|
||||
return key;
|
||||
} catch (err) {
|
||||
if (err.isEndpointUnavailable) {
|
||||
const stale = staleCache.get(kid);
|
||||
if (stale && (Date.now() - stale.fetchedAt) < (cacheMaxAge + cacheMaxAgeFallback)) {
|
||||
logger(`JWKS endpoint unavailable, serving stale signing key for '${kid}': ${err.message}`);
|
||||
if (onStaleCacheFallback) {
|
||||
onStaleCacheFallback(err, kid, stale.key);
|
||||
}
|
||||
return stale.key;
|
||||
}
|
||||
logger(`JWKS endpoint unavailable and no valid stale entry for '${kid}', fallback window expired or key never fetched`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
load = callbackify(client.getSigningKey.bind(client));
|
||||
}
|
||||
|
||||
return promisify(memoizer({
|
||||
hash: (kid) => kid,
|
||||
load: callbackify(client.getSigningKey.bind(client)),
|
||||
load,
|
||||
ttl: cacheMaxAge,
|
||||
max: cacheMaxEntries
|
||||
}));
|
||||
|
||||
@@ -48,6 +48,178 @@ describe('JwksClient (cache)', () => {
|
||||
expect(scope.isDone()).not.ok;
|
||||
});
|
||||
});
|
||||
describe('cacheMaxAgeFallback', () => {
|
||||
const kid = 'NkFCNEE1NDFDNTQ5RTQ5OTE1QzRBMjYyMzY0NEJCQTJBMjJBQkZCMA';
|
||||
const cacheMaxAge = 100;
|
||||
const cacheMaxAgeFallback = 500;
|
||||
|
||||
it('should serve stale key when AS is down and within fallback window', async () => {
|
||||
const scope = nock(jwksHost)
|
||||
.get('/.well-known/jwks.json')
|
||||
.once()
|
||||
.reply(200, x5cSingle);
|
||||
|
||||
const client = new JwksClient({
|
||||
cache: true,
|
||||
cacheMaxAge,
|
||||
cacheMaxAgeFallback,
|
||||
jwksUri: `${jwksHost}/.well-known/jwks.json`
|
||||
});
|
||||
|
||||
// First request - caches the key
|
||||
const key1 = await client.getSigningKey(kid);
|
||||
expect(key1.kid).to.equal(kid);
|
||||
|
||||
// AS goes down
|
||||
nock(jwksHost).get('/.well-known/jwks.json').reply(500, 'Service Unavailable');
|
||||
|
||||
// Wait for cacheMaxAge to expire
|
||||
await new Promise(resolve => setTimeout(resolve, cacheMaxAge + 10));
|
||||
|
||||
// Should serve stale key instead of throwing
|
||||
const key2 = await client.getSigningKey(kid);
|
||||
expect(key2.kid).to.equal(kid);
|
||||
expect(scope.isDone()).ok;
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should call onStaleCacheFallback with err, kid and stale key when serving stale key', async () => {
|
||||
nock(jwksHost)
|
||||
.get('/.well-known/jwks.json')
|
||||
.once()
|
||||
.reply(200, x5cSingle);
|
||||
|
||||
let callbackErr, callbackKid, callbackStaleKey;
|
||||
const client = new JwksClient({
|
||||
cache: true,
|
||||
cacheMaxAge,
|
||||
cacheMaxAgeFallback,
|
||||
jwksUri: `${jwksHost}/.well-known/jwks.json`,
|
||||
onStaleCacheFallback: (err, k, staleKey) => {
|
||||
callbackErr = err;
|
||||
callbackKid = k;
|
||||
callbackStaleKey = staleKey;
|
||||
}
|
||||
});
|
||||
|
||||
await client.getSigningKey(kid);
|
||||
|
||||
nock(jwksHost).get('/.well-known/jwks.json').reply(500, 'Service Unavailable');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, cacheMaxAge + 10));
|
||||
|
||||
const key = await client.getSigningKey(kid);
|
||||
expect(key.kid).to.equal(kid);
|
||||
expect(callbackErr).to.be.instanceOf(Error);
|
||||
expect(callbackKid).to.equal(kid);
|
||||
expect(callbackStaleKey.kid).to.equal(kid);
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should not call onStaleCacheFallback when AS returns a key successfully', async () => {
|
||||
nock(jwksHost)
|
||||
.get('/.well-known/jwks.json')
|
||||
.twice()
|
||||
.reply(200, x5cSingle);
|
||||
|
||||
let callbackCalled = false;
|
||||
const client = new JwksClient({
|
||||
cache: true,
|
||||
cacheMaxAge,
|
||||
cacheMaxAgeFallback,
|
||||
jwksUri: `${jwksHost}/.well-known/jwks.json`,
|
||||
onStaleCacheFallback: () => { callbackCalled = true; }
|
||||
});
|
||||
|
||||
await client.getSigningKey(kid);
|
||||
await new Promise(resolve => setTimeout(resolve, cacheMaxAge + 10));
|
||||
await client.getSigningKey(kid);
|
||||
|
||||
expect(callbackCalled).to.equal(false);
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should throw when AS is down and fallback window has also expired', async () => {
|
||||
nock(jwksHost)
|
||||
.get('/.well-known/jwks.json')
|
||||
.once()
|
||||
.reply(200, x5cSingle);
|
||||
|
||||
const client = new JwksClient({
|
||||
cache: true,
|
||||
cacheMaxAge,
|
||||
cacheMaxAgeFallback: 50,
|
||||
jwksUri: `${jwksHost}/.well-known/jwks.json`
|
||||
});
|
||||
|
||||
// First request - caches the key
|
||||
await client.getSigningKey(kid);
|
||||
|
||||
// AS goes down
|
||||
nock(jwksHost).get('/.well-known/jwks.json').reply(500, 'Service Unavailable');
|
||||
|
||||
// Wait for both cacheMaxAge + cacheMaxAgeFallback to expire
|
||||
await new Promise(resolve => setTimeout(resolve, cacheMaxAge + 50 + 20));
|
||||
|
||||
await expect(client.getSigningKey(kid)).to.eventually.be.rejected;
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should throw for an unknown kid even when cacheMaxAgeFallback is set and within fallback window', async () => {
|
||||
nock(jwksHost)
|
||||
.get('/.well-known/jwks.json')
|
||||
.once()
|
||||
.reply(200, x5cSingle);
|
||||
|
||||
const client = new JwksClient({
|
||||
cache: true,
|
||||
cacheMaxAge,
|
||||
cacheMaxAgeFallback,
|
||||
jwksUri: `${jwksHost}/.well-known/jwks.json`
|
||||
});
|
||||
|
||||
// Populate staleCache for the known kid
|
||||
await client.getSigningKey(kid);
|
||||
|
||||
// AS goes down
|
||||
nock(jwksHost).get('/.well-known/jwks.json').reply(500, 'Service Unavailable');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, cacheMaxAge + 10));
|
||||
|
||||
// An unknown kid has no stale entry — fallback cannot help
|
||||
await expect(client.getSigningKey('unknown-kid')).to.eventually.be.rejected;
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should not serve stale key when cacheMaxAgeFallback is not set', async () => {
|
||||
nock(jwksHost)
|
||||
.get('/.well-known/jwks.json')
|
||||
.once()
|
||||
.reply(200, x5cSingle);
|
||||
|
||||
const client = new JwksClient({
|
||||
cache: true,
|
||||
cacheMaxAge,
|
||||
jwksUri: `${jwksHost}/.well-known/jwks.json`
|
||||
});
|
||||
|
||||
await client.getSigningKey(kid);
|
||||
|
||||
nock(jwksHost).get('/.well-known/jwks.json').reply(500, 'Service Unavailable');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, cacheMaxAge + 10));
|
||||
|
||||
await expect(client.getSigningKey(kid)).to.eventually.be.rejected;
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should respect cacheMaxAge', () => {
|
||||
const kid = 'NkFCNEE1NDFDNTQ5RTQ5OTE1QzRBMjYyMzY0NEJCQTJBMjJBQkZCMA';
|
||||
const cacheMaxAge = 100;
|
||||
|
||||
Reference in New Issue
Block a user