diff --git a/backend/src/auth/oidc-client.ts b/backend/src/auth/oidc-client.ts index 00f60608..b331efc2 100644 --- a/backend/src/auth/oidc-client.ts +++ b/backend/src/auth/oidc-client.ts @@ -5,6 +5,7 @@ import { customFetch, discovery, type Configuration, + type CustomFetch, } from "openid-client"; import { constants, createPublicKey, verify as verifySignature } from "node:crypto"; import { parseConfiguredTransportUrl } from "./url-policy.js"; @@ -25,8 +26,17 @@ export interface OidcProtocol { } export class OidcProtocolError extends Error { + constructor(message = "oidc_protocol_invalid") { + super(message); + this.name = "OidcProtocolError"; + } +} + +/** A safe operational distinction for callers and diagnostics; provider details never cross this boundary. */ +export class OidcProviderUnavailableError extends OidcProtocolError { constructor() { - super("oidc_protocol_invalid"); + super("oidc_provider_unavailable"); + this.name = "OidcProviderUnavailableError"; } } @@ -38,13 +48,16 @@ export interface OidcProtocolOptions { scopes: readonly string[]; groupsClaim: string; fetch?: typeof globalThis.fetch; + httpTimeoutMs?: number; jwksTimeoutMs?: number; } const MAX_GROUPS = 128; const MAX_GROUP_LENGTH = 256; const MAX_ID_TOKEN_LENGTH = 16 * 1024; -const MAX_JWKS_BYTES = 1024 * 1024; +const MAX_OIDC_RESPONSE_BYTES = 1024 * 1024; +const DEFAULT_HTTP_TIMEOUT_MS = 5_000; +const MAX_HTTP_TIMEOUT_MS = 30_000; const DEFAULT_JWKS_TIMEOUT_MS = 5_000; const MAX_JWKS_TIMEOUT_MS = 30_000; const text = (value: unknown, maximum = 2048): value is string => @@ -166,70 +179,201 @@ function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]); } -async function readWithAbort( - reader: ReadableStreamDefaultReader, - signal: AbortSignal, -): Promise> { - signal.throwIfAborted(); - return await new Promise((resolve, reject) => { - const aborted = () => { - cancelReaderBestEffort(reader); - reject(signal.reason); - }; - signal.addEventListener("abort", aborted, { once: true }); - reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted)); - }); +class OidcTransportError extends Error { + constructor(readonly availability: boolean) { + super(availability ? "oidc_provider_unavailable" : "oidc_provider_response_invalid"); + this.name = "OidcTransportError"; + } +} + +interface BoundedOidcTransport { + customFetch: CustomFetch; + request( + input: RequestInfo | URL, + init?: RequestInit, + options?: { timeoutMs?: number; requireSuccess?: boolean }, + ): Promise; } function cancelReaderBestEffort(reader: ReadableStreamDefaultReader): void { try { - void reader.cancel().catch(() => undefined); + void Promise.resolve(reader.cancel()).catch(() => undefined); } catch { - // Cancellation is advisory; the bounded fetch timeout/rejection remains authoritative. + // Cancellation is advisory; the deadline and rejection remain authoritative. } } -async function boundedJwksBody(response: Response, signal: AbortSignal): Promise { - if (!response.body) throw new OidcProtocolError(); - const reader = response.body.getReader(); +function cancelResponseBestEffort(response: Response): void { + if (!response.body) return; + try { + void Promise.resolve(response.body.cancel()).catch(() => undefined); + } catch { + // A late response is never allowed to turn an already-bounded request into an unhandled rejection. + } +} + +function abortedReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); +} + +async function awaitWithAbort( + operation: Promise, + signal: AbortSignal, + onLateResolution?: (value: T) => void, +): Promise { + return await new Promise((resolve, reject) => { + let settled = signal.aborted; + const cleanup = () => signal.removeEventListener("abort", aborted); + const aborted = () => { + if (settled) return; + settled = true; + cleanup(); + reject(abortedReason(signal)); + }; + if (signal.aborted) reject(abortedReason(signal)); + else signal.addEventListener("abort", aborted, { once: true }); + operation.then( + (value) => { + if (settled) { + try { onLateResolution?.(value); } catch { /* best-effort late cleanup only */ } + return; + } + settled = true; + cleanup(); + resolve(value); + }, + (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }, + ); + }); +} + +function providerRequestUrl(input: RequestInfo | URL): URL { + const value = input instanceof URL ? input.href : input instanceof Request ? input.url : input; + return httpsEndpoint(value); +} + +function declaredResponseLength(response: Response): number | undefined { + const declared = response.headers.get("content-length"); + if (declared === null) return undefined; + if (!/^\d+$/.test(declared)) throw new OidcTransportError(false); + const length = Number(declared); + if (!Number.isSafeInteger(length) || length > MAX_OIDC_RESPONSE_BYTES) throw new OidcTransportError(false); + return length; +} + +function safeBufferedResponse(response: Response, body: Buffer): Response { + const noBodyStatus = response.status === 204 || response.status === 205 || response.status === 304; + // Node accepts Buffer as a fetch body; the DOM declaration in this project does not model it. + return new Response(noBodyStatus ? null : body as unknown as BodyInit, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +async function boundedResponse( + response: Response, + signal: AbortSignal, + requireSuccess: boolean, +): Promise { + const reader = response.body?.getReader(); const chunks: Buffer[] = []; let total = 0; let completed = false; try { - if (!response.ok) throw new OidcProtocolError(); - const declaredLength = response.headers.get("content-length"); - if (declaredLength !== null) { - if (!/^\d+$/.test(declaredLength)) throw new OidcProtocolError(); - const length = Number(declaredLength); - if (!Number.isSafeInteger(length) || length > MAX_JWKS_BYTES) throw new OidcProtocolError(); + if (response.redirected || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) { + throw new OidcTransportError(false); + } + if (requireSuccess && !response.ok) throw new OidcTransportError(false); + declaredResponseLength(response); + if (!reader) { + completed = true; + return safeBufferedResponse(response, Buffer.alloc(0)); } while (true) { - const { done, value } = await readWithAbort(reader, signal); + const { done, value } = await awaitWithAbort(reader.read(), signal); if (done) break; - if (value.byteLength > MAX_JWKS_BYTES - total) throw new OidcProtocolError(); + if (value.byteLength > MAX_OIDC_RESPONSE_BYTES - total) throw new OidcTransportError(false); total += value.byteLength; chunks.push(Buffer.from(value)); } completed = true; + return safeBufferedResponse(response, Buffer.concat(chunks, total)); } finally { try { - if (!completed) cancelReaderBestEffort(reader); + if (!completed && reader) cancelReaderBestEffort(reader); } finally { - try { reader.releaseLock(); } catch { /* cancellation already made the response unusable */ } + try { reader?.releaseLock(); } catch { /* cancellation already made the response unusable */ } } } - signal.throwIfAborted(); - try { - return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total)); - } catch { - throw new OidcProtocolError(); +} + +function createBoundedOidcTransport( + fetchImplementation: typeof globalThis.fetch, + defaultTimeoutMs: number, +): BoundedOidcTransport { + const request: BoundedOidcTransport["request"] = async (input, init, requestOptions) => { + let target: URL; + try { + target = providerRequestUrl(input); + } catch { + throw new OidcTransportError(false); + } + const timeoutMs = requestOptions?.timeoutMs ?? defaultTimeoutMs; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + timeout.unref(); + const signal = init?.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; + try { + const requestInit: RequestInit = { ...init, redirect: "manual", signal }; + const response = await awaitWithAbort( + Promise.resolve().then(() => fetchImplementation(target, requestInit)), + signal, + cancelResponseBestEffort, + ); + return await boundedResponse(response, signal, requestOptions?.requireSuccess === true); + } catch (error) { + if (error instanceof OidcTransportError) throw error; + if (signal.aborted) throw new OidcTransportError(true); + throw new OidcTransportError(true); + } finally { + clearTimeout(timeout); + } + }; + return { + request, + // openid-client's FetchBody is Fetch-compatible, but comes from a distinct declaration graph. + customFetch: (url, options) => request(url, options as unknown as RequestInit), + }; +} + +function availabilityFailure(error: unknown): boolean { + let current = error; + const seen = new Set(); + for (let depth = 0; depth < 8; depth += 1) { + if (current instanceof OidcTransportError) return current.availability; + if (!current || typeof current !== "object" || seen.has(current)) return false; + seen.add(current); + current = (current as { cause?: unknown }).cause; } + return false; +} + +function protocolFailure(error: unknown): OidcProtocolError { + if (error instanceof OidcProtocolError) return error; + return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError(); } async function verifyIdTokenSignature( idToken: unknown, config: Configuration, - options: OidcProtocolOptions, + transport: BoundedOidcTransport, + jwksTimeoutMs: number, ): Promise { if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError(); const [protectedPart, payloadPart, signaturePart, extra] = idToken.split("."); @@ -241,15 +385,13 @@ async function verifyIdTokenSignature( || !metadata.id_token_signing_alg_values_supported.includes(header.alg) || !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError(); const jwksUrl = httpsEndpoint(metadata.jwks_uri); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS); - timeout.unref(); - let response: Response; try { - response = await (options.fetch ?? globalThis.fetch)(jwksUrl, { - headers: { accept: "application/json" }, redirect: "error", signal: controller.signal, - }); - const body = await boundedJwksBody(response, controller.signal); + const response = await transport.request( + jwksUrl, + { headers: { accept: "application/json" }, redirect: "manual" }, + { timeoutMs: jwksTimeoutMs, requireSuccess: true }, + ); + const body = new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer()); const parsed = JSON.parse(body) as { keys?: unknown }; if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError(); const matching = parsed.keys.filter((key): key is Record => @@ -273,10 +415,7 @@ async function verifyIdTokenSignature( : verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature); if (!verified) throw new OidcProtocolError(); } catch (error) { - if (error instanceof OidcProtocolError) throw error; - throw new OidcProtocolError(); - } finally { - clearTimeout(timeout); + throw protocolFailure(error); } } @@ -286,8 +425,13 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { if (!text(options.clientId, 512) || !text(options.clientSecret, 4096) || !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16 || options.scopes.some((scope) => !text(scope, 128)) + || (options.httpTimeoutMs !== undefined && (!Number.isSafeInteger(options.httpTimeoutMs) + || options.httpTimeoutMs < 1 || options.httpTimeoutMs > MAX_HTTP_TIMEOUT_MS)) || (options.jwksTimeoutMs !== undefined && (!Number.isSafeInteger(options.jwksTimeoutMs) || options.jwksTimeoutMs < 1 || options.jwksTimeoutMs > MAX_JWKS_TIMEOUT_MS))) throw new OidcProtocolError(); + const httpTimeoutMs = options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS; + const jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS; + const transport = createBoundedOidcTransport(options.fetch ?? globalThis.fetch, httpTimeoutMs); let discovered: Promise | undefined; const configuration = async (): Promise => { @@ -299,7 +443,7 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { options.clientId, { client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] }, undefined, - options.fetch ? { [customFetch]: options.fetch as never } : undefined, + { [customFetch]: transport.customFetch, timeout: httpTimeoutMs / 1000 }, ); const metadata = config.serverMetadata(); if (metadata.issuer !== options.issuer) throw new OidcProtocolError(); @@ -308,8 +452,7 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { httpsEndpoint(metadata.jwks_uri); return config; } catch (error) { - if (error instanceof OidcProtocolError) throw error; - throw new OidcProtocolError(); + throw protocolFailure(error); } })(); } @@ -330,8 +473,7 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { code_challenge_method: "S256", }); } catch (error) { - if (error instanceof OidcProtocolError) throw error; - throw new OidcProtocolError(); + throw protocolFailure(error); } }, async callback(input) { @@ -346,13 +488,12 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { pkceCodeVerifier: input.codeVerifier, idTokenExpected: true, }); - await verifyIdTokenSignature(tokens.id_token, config, options); + await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs); const claims = tokens.claims(); if (!claims || Array.isArray(claims)) throw new OidcProtocolError(); return identityFromClaims(claims as Record, options); } catch (error) { - if (error instanceof OidcProtocolError) throw error; - throw new OidcProtocolError(); + throw protocolFailure(error); } }, async diagnose(signal) { diff --git a/backend/test/auth-routes-oidc.test.ts b/backend/test/auth-routes-oidc.test.ts index cd169633..8e4fe4c7 100644 --- a/backend/test/auth-routes-oidc.test.ts +++ b/backend/test/auth-routes-oidc.test.ts @@ -4,7 +4,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { registerAuthRoutes } from "../src/auth/routes.js"; import type { LoadedAuthConfig, OidcStateRecord } from "../src/auth/types.js"; import type { AuthSessionStore } from "../src/auth/session-store.js"; -import type { OidcProtocol } from "../src/auth/oidc-client.js"; +import { createOidcProtocol, type OidcProtocol } from "../src/auth/oidc-client.js"; const revision = "a".repeat(64); const issuer = "https://issuer.example.test"; @@ -95,6 +95,7 @@ function stateRecord(extra: Partial = {}): OidcStateRecord { function fixture(options: { loaded?: LoadedAuthConfig; identity?: Awaited>; + protocol?: OidcProtocol; callbackFailure?: boolean; stateReturnTo?: string; } = {}) { @@ -106,7 +107,7 @@ function fixture(options: { const createTimes: Array = []; const callbacks: URL[] = []; let authorizationRequests = 0; - const protocol: OidcProtocol = { + const defaultProtocol: OidcProtocol = { authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => { authorizationRequests += 1; expect(received).toBe(state); @@ -124,6 +125,7 @@ function fixture(options: { }, diagnose: async () => undefined, }; + const protocol = options.protocol ?? defaultProtocol; const store = { createOidcState: async (input: Record) => { stateInputs.push(input); @@ -198,6 +200,16 @@ async function finishOidcLogin( }); } +async function completesWithin(operation: Promise, timeoutMs = 150): Promise { + return await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(undefined), timeoutMs); + operation.then( + (value) => { clearTimeout(timeout); resolve(value); }, + () => { clearTimeout(timeout); resolve(undefined); }, + ); + }); +} + test("rate limits OIDC initiation before state creation and provider discovery", async () => { const subject = fixture(); @@ -377,11 +389,66 @@ test("consumes state on callback failure and refuses replay", async () => { const failed = await finishOidcLogin(subject, cookie); expect(failed.statusCode).toBe(401); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); + expect(subject.creates).toEqual([]); + const failedOutput = JSON.stringify({ body: failed.json(), cookies: setCookieHeaders(failed) }); + expect(failedOutput).not.toContain("access-token-must-not-leak"); + expect(failedOutput).not.toContain("opaque-session-token"); const replay = await finishOidcLogin(subject, cookie); expect(replay.statusCode).toBe(401); expect(subject.callbacks).toHaveLength(1); }); +test("fails a bounded concrete-provider callback without a session, cookie, or token persistence", async () => { + let tokenAborted = false; + const protocol = createOidcProtocol({ + issuer, + clientId: "thothii", + clientSecret: "client-secret-must-not-persist", + callbackUrl: "https://thothii.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile"], + groupsClaim: "groups", + httpTimeoutMs: 20, + fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.pathname.includes(".well-known/")) { + return Response.json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/jwks`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + }); + } + if (url.pathname === "/token") { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + tokenAborted = true; + reject(new Error("provider access-token-must-not-persist")); + }, { once: true }); + }); + } + return new Response(null, { status: 404 }); + }, + }); + const subject = fixture({ protocol }); + const { cookie } = await beginOidcLogin(subject); + expect(cookie).toBeDefined(); + + const failed = await completesWithin(finishOidcLogin(subject, cookie)); + + expect(failed?.statusCode).toBe(401); + expect(tokenAborted).toBe(true); + expect(subject.creates).toEqual([]); + if (!failed) return; + expectBothTransactionVariantsCleared(failed, transactionCookieName, true); + const failedOutput = JSON.stringify({ body: failed.json(), cookies: setCookieHeaders(failed) }); + expect(failedOutput).not.toContain("access-token-must-not-persist"); + expect(failedOutput).not.toContain("thothii_session"); +}); + test("consumes state when the protocol becomes unavailable before callback", async () => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); diff --git a/backend/test/oidc-client.test.ts b/backend/test/oidc-client.test.ts index 12cec828..4fbd0a03 100644 --- a/backend/test/oidc-client.test.ts +++ b/backend/test/oidc-client.test.ts @@ -1,6 +1,6 @@ import { createSign, generateKeyPairSync } from "node:crypto"; import { expect, test } from "vitest"; -import { createOidcProtocol, OidcProtocolError } from "../src/auth/oidc-client.js"; +import { createOidcProtocol, OidcProtocolError, OidcProviderUnavailableError } from "../src/auth/oidc-client.js"; const issuer = "https://issuer.example.test"; const clientId = "thothii"; @@ -28,7 +28,10 @@ function protocol(options: { discoveryIssuer?: string; invalidSignature?: boolean; seen?: URL[]; + discoveryResponse?: (init?: RequestInit) => Response | Promise; + tokenResponse?: (init?: RequestInit) => Response | Promise; jwksResponse?: (init?: RequestInit) => Response | Promise; + httpTimeoutMs?: number; jwksTimeoutMs?: number; discoveryMetadata?: Record; } = {}) { @@ -48,6 +51,7 @@ function protocol(options: { const url = new URL(input instanceof Request ? input.url : typeof input === "string" ? input : input.toString()); options.seen?.push(url); if (url.pathname.includes(".well-known/")) { + if (options.discoveryResponse) return await options.discoveryResponse(init); return Response.json({ issuer: options.discoveryIssuer ?? issuer, authorization_endpoint: `${issuer}/authorize`, @@ -62,6 +66,7 @@ function protocol(options: { } if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] }); if (url.pathname === "/token") { + if (options.tokenResponse) return await options.tokenResponse(init); return Response.json({ token_type: "Bearer", access_token: "access-token-must-not-be-persisted", @@ -79,6 +84,7 @@ function protocol(options: { scopes: ["openid", "profile"], groupsClaim: "groups", fetch, + ...(options.httpTimeoutMs === undefined ? {} : { httpTimeoutMs: options.httpTimeoutMs }), ...(options.jwksTimeoutMs === undefined ? {} : { jwksTimeoutMs: options.jwksTimeoutMs }), } as Parameters[0]; return createOidcProtocol(protocolOptions); @@ -90,6 +96,16 @@ async function callback(subject = protocol()) { }); } +async function settlesWithin(operation: Promise, timeoutMs = 150): Promise<"resolved" | "rejected" | "timed-out"> { + return await new Promise((resolve) => { + const timeout = setTimeout(() => resolve("timed-out"), timeoutMs); + operation.then( + () => { clearTimeout(timeout); resolve("resolved"); }, + () => { clearTimeout(timeout); resolve("rejected"); }, + ); + }); +} + test("uses HTTPS discovery, Authorization Code, and PKCE S256 without external network", async () => { const seen: URL[] = []; const subject = protocol({ seen }); @@ -109,6 +125,160 @@ test("uses HTTPS discovery, Authorization Code, and PKCE S256 without external n expect(seen.map((url) => url.origin)).toEqual([issuer, issuer, issuer]); }); +test("rejects a hanging discovery request at the provider transport deadline", async () => { + let aborted = false; + const subject = protocol({ + httpTimeoutMs: 20, + discoveryResponse: (init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + reject(new DOMException("aborted", "AbortError")); + }, { once: true }); + }), + }); + + const completion = subject.authorizationUrl({ state, nonce, codeVerifier: verifier }); + expect(await settlesWithin(completion)).toBe("rejected"); + const error = await completion.catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(OidcProviderUnavailableError); + expect((error as Error).message).toBe("oidc_provider_unavailable"); + expect(aborted).toBe(true); +}); + +test("rejects oversized discovery Content-Length before reading or buffering its body", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + type: "bytes", + pull(controller) { + pulls += 1; + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + }, + cancel() { cancelled = true; }, + }); + const subject = protocol({ + discoveryResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }), + }); + + await expect(subject.authorizationUrl({ state, nonce, codeVerifier: verifier })).rejects.toThrow(OidcProtocolError); + expect(pulls).toBe(0); + expect(cancelled).toBe(true); + expect(body.locked).toBe(false); +}); + +test("stops chunked discovery streaming at the provider response limit", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + type: "bytes", + pull(controller) { + pulls += 1; + if (pulls === 1) controller.enqueue(new Uint8Array(700_000)); + else if (pulls === 2) controller.enqueue(new Uint8Array(400_000)); + else { + controller.enqueue(new Uint8Array([1])); + controller.close(); + } + }, + cancel() { cancelled = true; }, + }); + const subject = protocol({ discoveryResponse: () => new Response(body) }); + + await expect(subject.authorizationUrl({ state, nonce, codeVerifier: verifier })).rejects.toThrow(OidcProtocolError); + expect(pulls).toBe(2); + expect(cancelled).toBe(true); + expect(body.locked).toBe(false); +}); + +test("rejects a hanging token exchange at the provider transport deadline", async () => { + let aborted = false; + const subject = protocol({ + httpTimeoutMs: 20, + tokenResponse: (init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + reject(new DOMException("aborted", "AbortError")); + }, { once: true }); + }), + }); + await subject.authorizationUrl({ state, nonce, codeVerifier: verifier }); + + const completion = callback(subject); + expect(await settlesWithin(completion)).toBe("rejected"); + const error = await completion.catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(OidcProviderUnavailableError); + expect((error as Error).message).toBe("oidc_provider_unavailable"); + expect(aborted).toBe(true); +}); + +test("rejects oversized token Content-Length before reading or buffering its body", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + type: "bytes", + pull(controller) { + pulls += 1; + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + }, + cancel() { cancelled = true; }, + }); + const subject = protocol({ + tokenResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }), + }); + await subject.authorizationUrl({ state, nonce, codeVerifier: verifier }); + + await expect(callback(subject)).rejects.toThrow(OidcProtocolError); + expect(pulls).toBe(0); + expect(cancelled).toBe(true); + expect(body.locked).toBe(false); +}); + +test("stops chunked token streaming at the provider response limit", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + type: "bytes", + pull(controller) { + pulls += 1; + if (pulls === 1) controller.enqueue(new Uint8Array(700_000)); + else if (pulls === 2) controller.enqueue(new Uint8Array(400_000)); + else { + controller.enqueue(new Uint8Array([1])); + controller.close(); + } + }, + cancel() { cancelled = true; }, + }); + const subject = protocol({ tokenResponse: () => new Response(body) }); + await subject.authorizationUrl({ state, nonce, codeVerifier: verifier }); + + await expect(callback(subject)).rejects.toThrow(OidcProtocolError); + expect(pulls).toBe(2); + expect(cancelled).toBe(true); + expect(body.locked).toBe(false); +}); + +test("fails promptly and releases a provider response whose cancellation never settles", async () => { + let cancelled = false; + const body = new ReadableStream({ + pull() { return new Promise(() => { /* no body bytes are ever delivered */ }); }, + cancel() { + cancelled = true; + return new Promise(() => { /* cancellation remains advisory */ }); + }, + }); + const subject = protocol({ + discoveryResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }), + }); + + const completion = subject.authorizationUrl({ state, nonce, codeVerifier: verifier }); + expect(await settlesWithin(completion)).toBe("rejected"); + expect(cancelled).toBe(true); + expect(body.locked).toBe(false); +}); + test("rejects non-HTTPS issuer configuration and a discovery issuer mismatch", async () => { expect(() => createOidcProtocol({ issuer: "http://issuer.example.test", clientId, clientSecret: "secret", callbackUrl,