| @@ -11,10 +11,18 @@ · 10 unmodified lines |
| 11 | // | 11 | // |
| 12 | // * Caches the JSON Web Key Set in module-scope memory for a configurable | 12 | // * Caches the JSON Web Key Set in module-scope memory for a configurable |
| 13 | // TTL (default 1 hour). | 13 | // TTL (default 1 hour). |
| | 14 | // * Backs the in-memory layer with the Workers Cache API (`caches.default`) |
| | 15 | // so a COLD isolate reads the JWKS from its datacenter's shared cache |
| | 16 | // instead of round-tripping to the upstream endpoint. The in-memory layer |
| | 17 | // alone left every fresh isolate paying that full fetch, which is exactly |
| | 18 | // when users notice: an app-open burst fans out across many new isolates |
| | 19 | // and each one stalled ~1-2s inside session verification. |
| 14 | // * Single-flights concurrent fetches so a stampede of verifies during a | 20 | // * Single-flights concurrent fetches so a stampede of verifies during a |
| 15 | // cache miss only fires one upstream request. | 21 | // cache miss only fires one upstream request. |
| 16 | // * Force-refreshes once when verification fails with a cached key, so | 22 | // * Force-refreshes once when verification fails with a cached key, so |
| 17 | // genuine key rotation isn't blocked by the TTL. | 23 | // genuine key rotation isn't blocked by the TTL. A forced refresh also |
| | 24 | // bypasses the shared edge copy (it is just as stale as ours) and |
| | 25 | // overwrites it with the freshly fetched set. |
| 18 | // | 26 | // |
| 19 | // The returned function is a `JWTVerifyGetKey` and slots directly into | 27 | // The returned function is a `JWTVerifyGetKey` and slots directly into |
| 20 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can | 28 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can |
| @@ -32,6 +40,21 @@ import { · 11 unmodified lines |
| 32 | import { Schema } from "effect"; | 40 | import { Schema } from "effect"; |
| 33 | import { JWKSNoMatchingKey } from "jose/errors"; | 41 | import { JWKSNoMatchingKey } from "jose/errors"; |
| 34 | | 42 | |
| | 43 | /** The slice of the Workers Cache API this module uses, expressed |
| | 44 | * structurally: `Cache` from workers-types satisfies it, and node tests can |
| | 45 | * supply a conforming fake without the full `Cache` surface. */ |
| | 46 | export interface JwksEdgeCache { |
| | 47 | match(url: string): Promise<Response | undefined>; |
| | 48 | put(url: string, response: Response): Promise<void>; |
| | 49 | } |
| | 50 | |
| | 51 | // The `caches` global exists in the Workers runtime but not under node (the |
| | 52 | // test environment). Typed structurally (the ASSETS precedent in |
| | 53 | // env-augment.d.ts) rather than via workers-types: its `Cache` type drags in |
| | 54 | // a second `Response` that intersects with the ambient one and rejects |
| | 55 | // responses this module constructs. |
| | 56 | declare const caches: { readonly default: JwksEdgeCache } | undefined; |
| | 57 | |
| 35 | export interface CachedRemoteJWKSetOptions { | 58 | export interface CachedRemoteJWKSetOptions { |
| 36 | /** | 59 | /** |
| 37 | * How long a successful fetch is considered fresh. Defaults to 1 hour — | 60 | * How long a successful fetch is considered fresh. Defaults to 1 hour — |
| @@ -43,6 +66,12 @@ export interface CachedRemoteJWKSetOptions { · 5 unmodified lines |
| 43 | readonly fetch?: typeof globalThis.fetch; | 66 | readonly fetch?: typeof globalThis.fetch; |
| 44 | /** HTTP request timeout. Defaults to 5s, matching jose. */ | 67 | /** HTTP request timeout. Defaults to 5s, matching jose. */ |
| 45 | readonly timeoutMs?: number; | 68 | readonly timeoutMs?: number; |
| | 69 | /** |
| | 70 | * Datacenter-shared cache behind the in-memory layer. Defaults to |
| | 71 | * `caches.default` when the runtime provides it (Workers) and to disabled |
| | 72 | * when it does not (node tests). Pass explicitly to fake it in tests. |
| | 73 | */ |
| | 74 | readonly edgeCache?: JwksEdgeCache | null; |
| 46 | } | 75 | } |
| 47 | | 76 | |
| 48 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { | 77 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { |
| @@ -122,31 +151,92 @@ export const createCachedRemoteJWKSet = ( · 73 unmodified lines |
| 122 | // (tests do this) without us snapshotting a stale reference. | 151 | // (tests do this) without us snapshotting a stale reference. |
| 123 | const fetchImpl = (): typeof globalThis.fetch => | 152 | const fetchImpl = (): typeof globalThis.fetch => |
| 124 | options.fetch ?? globalThis.fetch.bind(globalThis); | 153 | options.fetch ?? globalThis.fetch.bind(globalThis); |
| | 154 | // `undefined` = not specified, use the runtime default; `null` = disabled. |
| | 155 | // Node (tests) has no `caches` global, so the default degrades to disabled |
| | 156 | // there without configuration. |
| | 157 | const edgeCache = |
| | 158 | options.edgeCache !== undefined |
| | 159 | ? options.edgeCache |
| | 160 | : typeof caches !== "undefined" |
| | 161 | ? caches.default |
| | 162 | : null; |
| 125 | | 163 | |
| 126 | let entry: CacheEntry | null = null; | 164 | // The edge cache is best-effort: a failed read is a miss, a failed write is |
| 127 | let inflight: Promise<CacheEntry> | null = null; | 165 | // dropped. Verification correctness never depends on it — the upstream |
| 128 | | 166 | // fetch remains the source of truth. |
| 129 | const refresh = (): Promise<CacheEntry> => { | 167 | const readEdgeCache = async (): Promise<JSONWebKeySet | null> => { |
| 130 | if (inflight) return inflight; | 168 | if (!edgeCache) return null; |
| 131 | inflight = (async () => { | 169 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: Cache API failure degrades to a cache miss inside a jose Promise resolver |
| 132 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); | 170 | const cached = await edgeCache.match(url.toString()).catch(() => undefined); |
| 133 | const next: CacheEntry = { | 171 | if (!cached) return null; |
| 134 | jwks, | 172 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: a non-JSON cached body degrades to a cache miss inside a jose Promise resolver |
| 135 | fetchedAt: Date.now(), | 173 | const body: unknown = await cached.json().catch(() => null); |
| 136 | resolver: createLocalJWKSet(jwks), | 174 | if (body === null) return null; |
| 137 | }; | 175 | return decodeJsonWebKeySetPayload(body).then( |
| 138 | entry = next; | 176 | () => body as JSONWebKeySet, |
| 139 | return next; | 177 | () => null, |
| 140 | })().finally(() => { | 178 | ); |
| 141 | inflight = null; | 179 | }; |
| | 180 | |
| | 181 | const writeEdgeCache = async (jwks: JSONWebKeySet): Promise<void> => { |
| | 182 | if (!edgeCache) return; |
| | 183 | // The Cache API owns expiry: `match` stops returning the entry once |
| | 184 | // `max-age` elapses, so the shared copy has the same freshness window as |
| | 185 | // the in-memory layer. |
| | 186 | const response = new Response(JSON.stringify(jwks), { |
| | 187 | headers: { |
| | 188 | "content-type": "application/json", |
| | 189 | "cache-control": `public, max-age=${Math.floor(ttlMs / 1000)}`, |
| | 190 | }, |
| 142 | }); | 191 | }); |
| 143 | return inflight; | 192 | // oxlint-disable-next-line executor/no-promise-catch -- boundary: a failed shared-cache write is dropped; the fetched JWKS is already in hand |
| | 193 | await edgeCache.put(url.toString(), response).catch(() => undefined); |
| | 194 | }; |
| | 195 | |
| | 196 | const fetchUpstream = async (): Promise<JSONWebKeySet> => { |
| | 197 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| | 198 | await writeEdgeCache(jwks); |
| | 199 | return jwks; |
| | 200 | }; |
| | 201 | |
| | 202 | let entry: CacheEntry | null = null; |
| | 203 | let inflight: { promise: Promise<CacheEntry>; bypassedEdge: boolean } | null = null; |
| | 204 | // Set by `forceRefresh()`: the shared edge copy is as suspect as our |
| | 205 | // in-memory one (same staleness), so the next refresh must go upstream and |
| | 206 | // overwrite it rather than read it back. |
| | 207 | let bypassEdgeOnNextRefresh = false; |
| | 208 | |
| | 209 | const refresh = (bypassEdge: boolean): Promise<CacheEntry> => { |
| | 210 | const effectiveBypass = bypassEdge || bypassEdgeOnNextRefresh; |
| | 211 | // Join an inflight refresh only when it satisfies this request: a |
| | 212 | // bypassing caller must not receive the result of an edge-cache read. |
| | 213 | if (inflight && (inflight.bypassedEdge || !effectiveBypass)) return inflight.promise; |
| | 214 | bypassEdgeOnNextRefresh = false; |
| | 215 | const record: { promise: Promise<CacheEntry>; bypassedEdge: boolean } = { |
| | 216 | bypassedEdge: effectiveBypass, |
| | 217 | promise: (async () => { |
| | 218 | const jwks = effectiveBypass |
| | 219 | ? await fetchUpstream() |
| | 220 | : ((await readEdgeCache()) ?? (await fetchUpstream())); |
| | 221 | const next: CacheEntry = { |
| | 222 | jwks, |
| | 223 | fetchedAt: Date.now(), |
| | 224 | resolver: createLocalJWKSet(jwks), |
| | 225 | }; |
| | 226 | entry = next; |
| | 227 | return next; |
| | 228 | })().finally(() => { |
| | 229 | if (inflight === record) inflight = null; |
| | 230 | }), |
| | 231 | }; |
| | 232 | inflight = record; |
| | 233 | return record.promise; |
| 144 | }; | 234 | }; |
| 145 | | 235 | |
| 146 | const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { | 236 | const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { |
| 147 | if (forceRefresh) return refresh(); | 237 | if (forceRefresh) return refresh(true); |
| 148 | if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; | 238 | if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; |
| 149 | return refresh(); | 239 | return refresh(false); |
| 150 | }; | 240 | }; |
| 151 | | 241 | |
| 152 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { | 242 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { |
| @@ -171,6 +261,7 @@ export const createCachedRemoteJWKSet = ( · 18 unmodified lines |
| 171 | Object.defineProperty(result, "forceRefresh", { | 261 | Object.defineProperty(result, "forceRefresh", { |
| 172 | value: () => { | 262 | value: () => { |
| 173 | entry = null; | 263 | entry = null; |
| | 264 | bypassEdgeOnNextRefresh = true; |
| 174 | }, | 265 | }, |
| 175 | }); | 266 | }); |
| 176 | Object.defineProperty(result, "inspect", { | 267 | Object.defineProperty(result, "inspect", { |