| @@ -11,10 +11,18 @@ · 10 unmodified lines |
| 11 | 11 | // |
| 12 | 12 | // * Caches the JSON Web Key Set in module-scope memory for a configurable |
| 13 | 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 | 20 | // * Single-flights concurrent fetches so a stampede of verifies during a |
| 15 | 21 | // cache miss only fires one upstream request. |
| 16 | 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 | 27 | // The returned function is a `JWTVerifyGetKey` and slots directly into |
| 20 | 28 | // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can |
| @@ -32,6 +40,21 @@ import { · 11 unmodified lines |
| 32 | 40 | import { Schema } from "effect"; |
| 33 | 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 | 58 | export interface CachedRemoteJWKSetOptions { |
| 36 | 59 | /** |
| 37 | 60 | * How long a successful fetch is considered fresh. Defaults to 1 hour — |
| @@ -43,6 +66,12 @@ export interface CachedRemoteJWKSetOptions { · 5 unmodified lines |
| 43 | 66 | readonly fetch?: typeof globalThis.fetch; |
| 44 | 67 | /** HTTP request timeout. Defaults to 5s, matching jose. */ |
| 45 | 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 | 77 | export interface CachedRemoteJWKSet extends JWTVerifyGetKey { |
| @@ -122,31 +151,92 @@ export const createCachedRemoteJWKSet = ( · 73 unmodified lines |
| 122 | 151 | // (tests do this) without us snapshotting a stale reference. |
| 123 | 152 | const fetchImpl = (): typeof globalThis.fetch => |
| 124 | 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; |
| 127 | | − let inflight: Promise<CacheEntry> | null = null; |
| 128 | | − |
| 129 | | − const refresh = (): Promise<CacheEntry> => { |
| 130 | | − if (inflight) return inflight; |
| 131 | | − inflight = (async () => { |
| 132 | | − const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| 133 | | − const next: CacheEntry = { |
| 134 | | − jwks, |
| 135 | | − fetchedAt: Date.now(), |
| 136 | | − resolver: createLocalJWKSet(jwks), |
| 137 | | − }; |
| 138 | | − entry = next; |
| 139 | | − return next; |
| 140 | | − })().finally(() => { |
| 141 | | − inflight = null; |
| 164 | + // The edge cache is best-effort: a failed read is a miss, a failed write is |
| 165 | + // dropped. Verification correctness never depends on it — the upstream |
| 166 | + // fetch remains the source of truth. |
| 167 | + const readEdgeCache = async (): Promise<JSONWebKeySet | null> => { |
| 168 | + if (!edgeCache) return null; |
| 169 | + // oxlint-disable-next-line executor/no-promise-catch -- boundary: Cache API failure degrades to a cache miss inside a jose Promise resolver |
| 170 | + const cached = await edgeCache.match(url.toString()).catch(() => undefined); |
| 171 | + if (!cached) return null; |
| 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 |
| 173 | + const body: unknown = await cached.json().catch(() => null); |
| 174 | + if (body === null) return null; |
| 175 | + return decodeJsonWebKeySetPayload(body).then( |
| 176 | + () => body as JSONWebKeySet, |
| 177 | + () => null, |
| 178 | + ); |
| 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 | 236 | const ensureFresh = async (forceRefresh: boolean): Promise<CacheEntry> => { |
| 147 | | − if (forceRefresh) return refresh(); |
| 237 | + if (forceRefresh) return refresh(true); |
| 148 | 238 | if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; |
| 149 | | − return refresh(); |
| 239 | + return refresh(false); |
| 150 | 240 | }; |
| 151 | 241 | |
| 152 | 242 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { |
| @@ -171,6 +261,7 @@ export const createCachedRemoteJWKSet = ( · 18 unmodified lines |
| 171 | 261 | Object.defineProperty(result, "forceRefresh", { |
| 172 | 262 | value: () => { |
| 173 | 263 | entry = null; |
| 264 | + bypassEdgeOnNextRefresh = true; |
| 174 | 265 | }, |
| 175 | 266 | }); |
| 176 | 267 | Object.defineProperty(result, "inspect", { |