/** * Wrap `fetch` so that AWS API Gateway authorizer rejections (i.e. expired * bearer JWTs) automatically trigger a refresh + retry. * * Use case: bearer (JWT) auth only. Clerk session tokens default to ~60s * validity, so any single SDK call that outlives that window — typically a * `.wait()` poll on a long job, or a slow `uploadFile()` — risks getting a * 403 mid-flight even though the user is still authenticated. This wrapper * intercepts those 403s, calls a caller-provided refresh callback to mint a * fresh JWT, and retries the same request transparently. * * Not applicable to API-key auth: API keys are long-lived credentials and * don't expire mid-session. * * Detection: AWS API Gateway sets `x-amzn-errortype: AccessDeniedException` * on authorizer rejections. Real downstream permission denials return 403 * *without* that header — those are propagated unchanged so we don't refresh * pointlessly on a genuine "you don't have access" response. * * @example * ```ts * import { Scenario, withBearerRefresh } from '@scenario-labs/sdk'; * * const client = new Scenario({ * bearerAuth: initialJwt, * fetch: withBearerRefresh(async () => { * // app-specific: re-mint a JWT from Clerk, your IdP, etc. * return await mintFreshJwt(); * }), * }); * * // Long-running poll — JWT can expire mid-flight, wrapper handles it: * const job = await client.workflows.run(wfId, { body }).then(r => r.job); * const completed = await job.wait({ timeoutMs: 600_000 }); * ``` */ export function withBearerRefresh( refresh: () => Promise, options?: BearerRefreshOptions, ): typeof fetch { const inner = options?.fetch ?? globalThis.fetch; const maxRefreshes = options?.maxRefreshes ?? 2; let refreshCount = 0; // Concurrent requests that all hit a gateway 403 must share a single // refresh — otherwise each call mints its own JWT, wastes the refresh // budget, and risks token-version races. We coalesce them onto one // in-flight promise (single-flight). let inflightRefresh: Promise | null = null; return async (input, init) => { const response = await inner(input, init); const isAuthorizerReject = response.status === 403 && response.headers.get('x-amzn-errortype') === 'AccessDeniedException'; if (!isAuthorizerReject) return response; // Budget only blocks NEW refreshes — followers awaiting an in-flight // refresh aren't paying for it, so let them through. if (refreshCount >= maxRefreshes && inflightRefresh === null) return response; if (inflightRefresh === null) { // Leader for this round: charge the budget now (not on result) so // followers awaiting the same promise don't each increment. refreshCount += 1; inflightRefresh = refresh().finally(() => { inflightRefresh = null; }); } const freshJwt = await inflightRefresh; if (!freshJwt) { // Refresh failed — propagate the original 403 with its body intact so // the SDK / caller can still read the error message. Draining here // would leave them with an empty/closed stream. return response; } // Refresh succeeded; we're about to retry. Drain the rejected response's // body so we don't leak the underlying socket. response.body?.cancel().catch(() => undefined); const newHeaders = new Headers(init?.headers); newHeaders.set('Authorization', `Bearer ${freshJwt}`); return inner(input, { ...init, headers: newHeaders }); }; } export interface BearerRefreshOptions { /** * Maximum number of refresh attempts per wrapper instance. After this many * refreshes, subsequent authorizer 403s are propagated unchanged. * @default 2 */ maxRefreshes?: number; /** * Underlying fetch to wrap. Use this to compose with other fetch wrappers * (logging, retries, etc.) or to inject a mock in tests. * @default globalThis.fetch */ fetch?: typeof fetch; }