Skip to content

Commit 81d4cf9

Browse files
authored
Cache JWKS across isolates and serve stale keys on refresh failure (#1665)
* Cache JWKS across isolates and serve stale keys on refresh failure The module-scope JWKS cache only helps while an isolate stays warm. When workos.session.local_verify started missing ~92% of the time, every miss paid a fresh upstream fetch (p50 3s) and the tail crossed the 5s timeout, so a slow key server surfaced as AbortError -> 500 on authenticated API routes and 503 on /mcp. Add a cross-isolate store (Workers Cache API by default, injectable, null to disable) so a cold isolate reads keys colo-locally instead of going upstream, and make the resolver stale-while-revalidate: past ttlMs a usable key set is served immediately and refreshed in the background, and a failed refresh keeps serving the last good keys until staleMaxMs rather than failing the verify. Only a fully cold path blocks on the network. Bounded by staleMaxMs (24h) and unchanged forced-refresh on unknown kid, so a retired key cannot be honoured indefinitely. * Keep JWKS latency attribution honest under stale-while-revalidate Background revalidation moves fetchCount without the caller waiting on it, so jwks.fetched_during_verify would report true for verifies that paid nothing. Track blockingFetchCount (fetches a verify awaited) and storeHitCount (cold-isolate reads served by the cross-isolate store), and attribute the span off the blocking counter.
1 parent 5897721 commit 81d4cf9

3 files changed

Lines changed: 396 additions & 22 deletions

File tree

apps/cloud/src/auth/jwks-cache.node.test.ts

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
type KeyLike,
1010
} from "jose";
1111

12-
import { createCachedRemoteJWKSet } from "./jwks-cache";
12+
import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache";
1313

1414
const issuer = "https://test-authkit.example.com";
1515
const audience = "client_test_fixture";
@@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray<JWK>): FetchHarness => {
6565
};
6666
};
6767

68+
interface StoreHarness extends JwksStore {
69+
readonly seed: (stored: StoredJwks) => void;
70+
readonly reads: () => number;
71+
readonly writes: () => number;
72+
}
73+
74+
/** Stands in for the Workers Cache API: shared across "isolates", in memory. */
75+
const makeStoreHarness = (): StoreHarness => {
76+
const entries = new Map<string, StoredJwks>();
77+
let reads = 0;
78+
let writes = 0;
79+
return {
80+
get: async (url) => {
81+
reads++;
82+
return entries.get(url.toString()) ?? null;
83+
},
84+
put: async (url, stored) => {
85+
writes++;
86+
entries.set(url.toString(), stored);
87+
},
88+
seed: (stored) => {
89+
entries.set(jwksUrl.toString(), stored);
90+
},
91+
reads: () => reads,
92+
writes: () => writes,
93+
};
94+
};
95+
96+
/** A fetch that always fails, standing in for a slow/down key server. */
97+
const failingFetch: typeof globalThis.fetch = async () => {
98+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test double: `fetch` signals an unreachable key server by rejecting
99+
throw new Error("JWKS endpoint unreachable");
100+
};
101+
68102
describe("createCachedRemoteJWKSet", () => {
69103
it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => {
70104
const kp = await generateRotatableKeypair("k1");
@@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => {
168202
await jwtVerify(t1, jwks, { issuer, audience });
169203
expect(harness.callCount()).toBe(2);
170204
});
205+
206+
// -------------------------------------------------------------------------
207+
// Cross-isolate store — the cold-isolate path that caused the 2026-08-18
208+
// 500s: no module-scope entry, so every verify paid an upstream fetch.
209+
// -------------------------------------------------------------------------
210+
211+
it("a cold isolate serves from the store instead of going upstream", async () => {
212+
const kp = await generateRotatableKeypair("k1");
213+
const store = makeStoreHarness();
214+
215+
// First isolate: cold everywhere, so it fetches and populates the store.
216+
const warm = makeFetchHarness([kp.publicJwk]);
217+
const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store });
218+
const t1 = await sign(kp);
219+
await jwtVerify(t1, first, { issuer, audience });
220+
expect(warm.callCount()).toBe(1);
221+
expect(store.writes()).toBe(1);
222+
223+
// A brand-new isolate (fresh module scope). If it reaches upstream at all
224+
// the fetch fails, so verifying proves the store answered.
225+
const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store });
226+
const { payload } = await jwtVerify(t1, second, { issuer, audience });
227+
expect(payload.sub).toBe("user_test");
228+
expect(store.reads()).toBeGreaterThan(0);
229+
});
230+
231+
it("keeps serving the last good keys when the key server is down", async () => {
232+
const kp = await generateRotatableKeypair("k1");
233+
const harness = makeFetchHarness([kp.publicJwk]);
234+
let upstreamUp = true;
235+
const flaky: typeof globalThis.fetch = (...args) =>
236+
upstreamUp ? harness.fetch(...args) : failingFetch(...args);
237+
238+
// ttl short enough that the next call is past it, stale window generous.
239+
const jwks = createCachedRemoteJWKSet(jwksUrl, {
240+
fetch: flaky,
241+
store: null,
242+
ttlMs: 10,
243+
staleMaxMs: 60_000,
244+
});
245+
246+
const token = await sign(kp);
247+
await jwtVerify(token, jwks, { issuer, audience });
248+
249+
upstreamUp = false;
250+
await new Promise((r) => setTimeout(r, 20));
251+
252+
// Past the TTL with a dead upstream: the cached keys still verify.
253+
const { payload } = await jwtVerify(token, jwks, { issuer, audience });
254+
expect(payload.sub).toBe("user_test");
255+
});
256+
257+
it("stops serving stale keys once the stale window closes", async () => {
258+
const kp = await generateRotatableKeypair("k1");
259+
const harness = makeFetchHarness([kp.publicJwk]);
260+
let upstreamUp = true;
261+
const flaky: typeof globalThis.fetch = (...args) =>
262+
upstreamUp ? harness.fetch(...args) : failingFetch(...args);
263+
264+
const jwks = createCachedRemoteJWKSet(jwksUrl, {
265+
fetch: flaky,
266+
store: null,
267+
ttlMs: 5,
268+
staleMaxMs: 10,
269+
});
270+
271+
const token = await sign(kp);
272+
await jwtVerify(token, jwks, { issuer, audience });
273+
274+
upstreamUp = false;
275+
await new Promise((r) => setTimeout(r, 30));
276+
277+
// Beyond staleMaxMs the keys are no longer trustworthy — fail, don't
278+
// silently honour a key set we can no longer confirm.
279+
await expect(jwtVerify(token, jwks, { issuer, audience })).rejects.toThrow();
280+
});
281+
282+
it("attributes background revalidation to fetchCount but not blockingFetchCount", async () => {
283+
const kp = await generateRotatableKeypair("k1");
284+
const harness = makeFetchHarness([kp.publicJwk]);
285+
const jwks = createCachedRemoteJWKSet(jwksUrl, {
286+
fetch: harness.fetch,
287+
store: null,
288+
ttlMs: 10,
289+
staleMaxMs: 60_000,
290+
});
291+
292+
const token = await sign(kp);
293+
await jwtVerify(token, jwks, { issuer, audience });
294+
expect(jwks.inspect().blockingFetchCount).toBe(1);
295+
296+
await new Promise((r) => setTimeout(r, 20));
297+
298+
// Past the TTL: served from the stale entry, revalidated behind it. The
299+
// caller waited on nothing, so latency must not be attributed to it.
300+
const before = jwks.inspect();
301+
await jwtVerify(token, jwks, { issuer, audience });
302+
const after = jwks.inspect();
303+
304+
expect(after.blockingFetchCount).toBe(before.blockingFetchCount);
305+
expect(after.fetchCount).toBeGreaterThan(before.fetchCount);
306+
});
307+
308+
it("records a cold-isolate store read as a store hit, not a fetch", async () => {
309+
const kp = await generateRotatableKeypair("k1");
310+
const store = makeStoreHarness();
311+
const warm = makeFetchHarness([kp.publicJwk]);
312+
313+
const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store });
314+
const token = await sign(kp);
315+
await jwtVerify(token, first, { issuer, audience });
316+
317+
const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store });
318+
await jwtVerify(token, second, { issuer, audience });
319+
320+
const stats = second.inspect();
321+
expect(stats.storeHitCount).toBe(1);
322+
expect(stats.blockingFetchCount).toBe(0);
323+
});
324+
325+
it("does not block a request on revalidation once keys are cached", async () => {
326+
const kp = await generateRotatableKeypair("k1");
327+
const harness = makeFetchHarness([kp.publicJwk]);
328+
let hang = false;
329+
const slowAfterFirst: typeof globalThis.fetch = async (...args) => {
330+
if (hang) await new Promise((r) => setTimeout(r, 5_000));
331+
return harness.fetch(...args);
332+
};
333+
334+
const jwks = createCachedRemoteJWKSet(jwksUrl, {
335+
fetch: slowAfterFirst,
336+
store: null,
337+
ttlMs: 10,
338+
staleMaxMs: 60_000,
339+
});
340+
341+
const token = await sign(kp);
342+
await jwtVerify(token, jwks, { issuer, audience });
343+
344+
hang = true;
345+
await new Promise((r) => setTimeout(r, 20));
346+
347+
// The revalidation behind this call hangs for 5s; the verify must not.
348+
const startedAt = Date.now();
349+
await jwtVerify(token, jwks, { issuer, audience });
350+
expect(Date.now() - startedAt).toBeLessThan(1_000);
351+
});
171352
});

0 commit comments

Comments
 (0)