| @@ -29,6 +29,7 @@ import { · 28 unmodified lines |
| 29 | 29 | type JWTVerifyGetKey, |
| 30 | 30 | type KeyLike, |
| 31 | 31 | } from "jose"; |
| 32 | +import { Schema } from "effect"; |
| 32 | 33 | import { JWKSNoMatchingKey } from "jose/errors"; |
| 33 | 34 | |
| 34 | 35 | export interface CachedRemoteJWKSetOptions { |
| @@ -54,13 +55,19 @@ export interface CachedRemoteJWKSet extends JWTVerifyGetKey { · 19 unmodified lines |
| 54 | 55 | const DEFAULT_TTL_MS = 60 * 60 * 1000; |
| 55 | 56 | const DEFAULT_TIMEOUT_MS = 5000; |
| 56 | 57 | |
| 58 | +const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); |
| 59 | +const JsonWebKeySetPayload = Schema.Struct({ |
| 60 | + keys: Schema.Array(JsonWebKey), |
| 61 | +}); |
| 62 | +const decodeJsonWebKeySetPayload = Schema.decodeUnknownPromise(JsonWebKeySetPayload); |
| 63 | + |
| 64 | +const isJwksNoMatchingKey = (cause: unknown): boolean => |
| 65 | + Schema.is(Schema.Struct({ code: Schema.String }))(cause) && cause.code === JWKSNoMatchingKey.code; |
| 66 | + |
| 57 | 67 | interface CacheEntry { |
| 58 | 68 | jwks: JSONWebKeySet; |
| 59 | 69 | fetchedAt: number; |
| 60 | | − resolver: ( |
| 61 | | − protectedHeader: JWTHeaderParameters, |
| 62 | | − token?: FlattenedJWSInput, |
| 63 | | − ) => Promise<KeyLike>; |
| 70 | + resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>; |
| 64 | 71 | } |
| 65 | 72 | |
| 66 | 73 | const fetchJwksOnce = async ( |
| @@ -70,20 +77,28 @@ const fetchJwksOnce = async ( · 3 unmodified lines |
| 70 | 77 | ): Promise<JSONWebKeySet> => { |
| 71 | 78 | const controller = new AbortController(); |
| 72 | 79 | const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 80 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch adapter must clear abort timer while preserving promise rejection behavior |
| 73 | 81 | try { |
| 74 | 82 | const response = await fetchImpl(url.toString(), { |
| 75 | 83 | method: "GET", |
| 76 | 84 | headers: { accept: "application/json" }, |
| 77 | 85 | signal: controller.signal, |
| 78 | 86 | }); |
| 87 | + |
| 79 | 88 | if (!response.ok) { |
| 89 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: fetch-backed JWT key resolver must reject with the existing Error cause shape |
| 80 | 90 | throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`); |
| 81 | 91 | } |
| 82 | | − const body = (await response.json()) as JSONWebKeySet; |
| 83 | | − if (!body || !Array.isArray((body as JSONWebKeySet).keys)) { |
| 92 | + |
| 93 | + const body = await response.json(); |
| 94 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch JSON validation maps Schema failures to the existing malformed JWKS rejection |
| 95 | + try { |
| 96 | + await decodeJsonWebKeySetPayload(body); |
| 97 | + return body as JSONWebKeySet; |
| 98 | + } catch { |
| 99 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: fetch JSON validation preserves the existing malformed JWKS rejection |
| 84 | 100 | throw new Error("JWKS fetch returned malformed payload"); |
| 85 | 101 | } |
| 86 | | − return body; |
| 87 | 102 | } finally { |
| 88 | 103 | clearTimeout(timer); |
| 89 | 104 | } |
| @@ -111,19 +126,17 @@ export const createCachedRemoteJWKSet = ( · 21 unmodified lines |
| 111 | 126 | const refresh = (): Promise<CacheEntry> => { |
| 112 | 127 | if (inflight) return inflight; |
| 113 | 128 | inflight = (async () => { |
| 114 | | − try { |
| 115 | | − const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| 116 | | − const next: CacheEntry = { |
| 117 | | − jwks, |
| 118 | | − fetchedAt: Date.now(), |
| 119 | | − resolver: createLocalJWKSet(jwks), |
| 120 | | − }; |
| 121 | | − entry = next; |
| 122 | | − return next; |
| 123 | | − } finally { |
| 124 | | − inflight = null; |
| 125 | | − } |
| 126 | | − })(); |
| 129 | + const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| 130 | + const next: CacheEntry = { |
| 131 | + jwks, |
| 132 | + fetchedAt: Date.now(), |
| 133 | + resolver: createLocalJWKSet(jwks), |
| 134 | + }; |
| 135 | + entry = next; |
| 136 | + return next; |
| 137 | + })().finally(() => { |
| 138 | + inflight = null; |
| 139 | + }); |
| 127 | 140 | return inflight; |
| 128 | 141 | }; |
| 129 | 142 | |
| @@ -135,15 +148,19 @@ export const createCachedRemoteJWKSet = ( · 5 unmodified lines |
| 135 | 148 | |
| 136 | 149 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { |
| 137 | 150 | const current = await ensureFresh(false); |
| 151 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures |
| 138 | 152 | try { |
| 139 | 153 | return await current.resolver(protectedHeader, token); |
| 140 | 154 | } catch (error) { |
| 141 | 155 | // Likely cause: keys rotated upstream after our TTL window started. |
| 142 | 156 | // Refetch once and try again. Anything still failing bubbles up so |
| 143 | 157 | // jose can classify it (we do not silently swallow real failures). |
| 144 | | − if (!(error instanceof JWKSNoMatchingKey)) throw error; |
| 158 | + if (!isJwksNoMatchingKey(error)) { |
| 159 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection |
| 160 | + throw error; |
| 161 | + } |
| 145 | 162 | const refreshed = await ensureFresh(true); |
| 146 | | − return await refreshed.resolver(protectedHeader, token); |
| 163 | + return refreshed.resolver(protectedHeader, token); |
| 147 | 164 | } |
| 148 | 165 | }; |
| 149 | 166 | |