|
9 | 9 | type KeyLike, |
10 | 10 | } from "jose"; |
11 | 11 |
|
12 | | -import { createCachedRemoteJWKSet } from "./jwks-cache"; |
| 12 | +import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache"; |
13 | 13 |
|
14 | 14 | const issuer = "https://test-authkit.example.com"; |
15 | 15 | const audience = "client_test_fixture"; |
@@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray<JWK>): FetchHarness => { |
65 | 65 | }; |
66 | 66 | }; |
67 | 67 |
|
| 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 | + |
68 | 102 | describe("createCachedRemoteJWKSet", () => { |
69 | 103 | it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { |
70 | 104 | const kp = await generateRotatableKeypair("k1"); |
@@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { |
168 | 202 | await jwtVerify(t1, jwks, { issuer, audience }); |
169 | 203 | expect(harness.callCount()).toBe(2); |
170 | 204 | }); |
| 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 | + }); |
171 | 352 | }); |
0 commit comments