1111//
1212// * Caches the JSON Web Key Set in module-scope memory for a configurable
1313// 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.
2014// * Single-flights concurrent fetches so a stampede of verifies during a
2115// cache miss only fires one upstream request.
2216// * Force-refreshes once when verification fails with a cached key, so
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.
17+ // genuine key rotation isn't blocked by the TTL.
2618//
2719// The returned function is a `JWTVerifyGetKey` and slots directly into
2820// `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can
@@ -40,21 +32,6 @@ import {
4032import { Schema } from "effect" ;
4133import { JWKSNoMatchingKey } from "jose/errors" ;
4234
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-
5835export interface CachedRemoteJWKSetOptions {
5936 /**
6037 * How long a successful fetch is considered fresh. Defaults to 1 hour —
@@ -66,12 +43,6 @@ export interface CachedRemoteJWKSetOptions {
6643 readonly fetch ?: typeof globalThis . fetch ;
6744 /** HTTP request timeout. Defaults to 5s, matching jose. */
6845 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 ;
7546}
7647
7748export interface CachedRemoteJWKSet extends JWTVerifyGetKey {
@@ -151,92 +122,31 @@ export const createCachedRemoteJWKSet = (
151122 // (tests do this) without us snapshotting a stale reference.
152123 const fetchImpl = ( ) : typeof globalThis . fetch =>
153124 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 ;
163-
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- } ,
191- } ) ;
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- } ;
201125
202126 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 ;
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 ;
142+ } ) ;
143+ return inflight ;
234144 } ;
235145
236146 const ensureFresh = async ( forceRefresh : boolean ) : Promise < CacheEntry > => {
237- if ( forceRefresh ) return refresh ( true ) ;
147+ if ( forceRefresh ) return refresh ( ) ;
238148 if ( entry && Date . now ( ) - entry . fetchedAt < ttlMs ) return entry ;
239- return refresh ( false ) ;
149+ return refresh ( ) ;
240150 } ;
241151
242152 const get : JWTVerifyGetKey = async ( protectedHeader , token ) => {
@@ -261,7 +171,6 @@ export const createCachedRemoteJWKSet = (
261171 Object . defineProperty ( result , "forceRefresh" , {
262172 value : ( ) => {
263173 entry = null ;
264- bypassEdgeOnNextRefresh = true ;
265174 } ,
266175 } ) ;
267176 Object . defineProperty ( result , "inspect" , {
0 commit comments