| @@ -29,6 +29,7 @@ import { · 28 unmodified lines |
| 29 | type JWTVerifyGetKey, | 29 | type JWTVerifyGetKey, |
| 30 | type KeyLike, | 30 | type KeyLike, |
| 31 | } from "jose"; | 31 | } from "jose"; |
| | 32 | import { Schema } from "effect"; |
| 32 | import { JWKSNoMatchingKey } from "jose/errors"; | 33 | import { JWKSNoMatchingKey } from "jose/errors"; |
| 33 | | 34 | |
| 34 | export interface CachedRemoteJWKSetOptions { | 35 | export interface CachedRemoteJWKSetOptions { |
| @@ -54,13 +55,19 @@ export interface CachedRemoteJWKSet extends JWTVerifyGetKey { · 19 unmodified lines |
| 54 | const DEFAULT_TTL_MS = 60 * 60 * 1000; | 55 | const DEFAULT_TTL_MS = 60 * 60 * 1000; |
| 55 | const DEFAULT_TIMEOUT_MS = 5000; | 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 | interface CacheEntry { | 67 | interface CacheEntry { |
| 58 | jwks: JSONWebKeySet; | 68 | jwks: JSONWebKeySet; |
| 59 | fetchedAt: number; | 69 | fetchedAt: number; |
| 60 | resolver: ( | 70 | resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>; |
| 61 | protectedHeader: JWTHeaderParameters, | | |
| 62 | token?: FlattenedJWSInput, | | |
| 63 | ) => Promise<KeyLike>; | | |
| 64 | } | 71 | } |
| 65 | | 72 | |
| 66 | const fetchJwksOnce = async ( | 73 | const fetchJwksOnce = async ( |
| @@ -70,20 +77,28 @@ const fetchJwksOnce = async ( · 3 unmodified lines |
| 70 | ): Promise<JSONWebKeySet> => { | 77 | ): Promise<JSONWebKeySet> => { |
| 71 | const controller = new AbortController(); | 78 | const controller = new AbortController(); |
| 72 | const timer = setTimeout(() => controller.abort(), timeoutMs); | 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 | try { | 81 | try { |
| 74 | const response = await fetchImpl(url.toString(), { | 82 | const response = await fetchImpl(url.toString(), { |
| 75 | method: "GET", | 83 | method: "GET", |
| 76 | headers: { accept: "application/json" }, | 84 | headers: { accept: "application/json" }, |
| 77 | signal: controller.signal, | 85 | signal: controller.signal, |
| 78 | }); | 86 | }); |
| | 87 | |
| 79 | if (!response.ok) { | 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 | throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`); | 90 | throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`); |
| 81 | } | 91 | } |
| 82 | const body = (await response.json()) as JSONWebKeySet; | 92 | |
| 83 | if (!body || !Array.isArray((body as JSONWebKeySet).keys)) { | 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 | throw new Error("JWKS fetch returned malformed payload"); | 100 | throw new Error("JWKS fetch returned malformed payload"); |
| 85 | } | 101 | } |
| 86 | return body; | | |
| 87 | } finally { | 102 | } finally { |
| 88 | clearTimeout(timer); | 103 | clearTimeout(timer); |
| 89 | } | 104 | } |
| @@ -111,19 +126,17 @@ export const createCachedRemoteJWKSet = ( · 21 unmodified lines |
| 111 | const refresh = (): Promise<CacheEntry> => { | 126 | const refresh = (): Promise<CacheEntry> => { |
| 112 | if (inflight) return inflight; | 127 | if (inflight) return inflight; |
| 113 | inflight = (async () => { | 128 | inflight = (async () => { |
| 114 | try { | 129 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); |
| 115 | const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); | 130 | const next: CacheEntry = { |
| 116 | const next: CacheEntry = { | 131 | jwks, |
| 117 | jwks, | 132 | fetchedAt: Date.now(), |
| 118 | fetchedAt: Date.now(), | 133 | resolver: createLocalJWKSet(jwks), |
| 119 | resolver: createLocalJWKSet(jwks), | 134 | }; |
| 120 | }; | 135 | entry = next; |
| 121 | entry = next; | 136 | return next; |
| 122 | return next; | 137 | })().finally(() => { |
| 123 | } finally { | 138 | inflight = null; |
| 124 | inflight = null; | 139 | }); |
| 125 | } | | |
| 126 | })(); | | |
| 127 | return inflight; | 140 | return inflight; |
| 128 | }; | 141 | }; |
| 129 | | 142 | |
| @@ -135,15 +148,19 @@ export const createCachedRemoteJWKSet = ( · 5 unmodified lines |
| 135 | | 148 | |
| 136 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { | 149 | const get: JWTVerifyGetKey = async (protectedHeader, token) => { |
| 137 | const current = await ensureFresh(false); | 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 | try { | 152 | try { |
| 139 | return await current.resolver(protectedHeader, token); | 153 | return await current.resolver(protectedHeader, token); |
| 140 | } catch (error) { | 154 | } catch (error) { |
| 141 | // Likely cause: keys rotated upstream after our TTL window started. | 155 | // Likely cause: keys rotated upstream after our TTL window started. |
| 142 | // Refetch once and try again. Anything still failing bubbles up so | 156 | // Refetch once and try again. Anything still failing bubbles up so |
| 143 | // jose can classify it (we do not silently swallow real failures). | 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 | const refreshed = await ensureFresh(true); | 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 | |