import { createSign, generateKeyPairSync } from "node:crypto"; import { expect, test } from "vitest"; import { createOidcProtocol, OidcIssuerMismatchError, OidcJwksUnavailableError, OidcProtocolError, OidcProviderUnavailableError, } from "../src/auth/oidc-client.js"; const issuer = "https://issuer.example.test"; const clientId = "thothii"; const callbackUrl = "https://thothii.example.test/api/auth/oidc/callback"; const verifier = "v".repeat(43); const nonce = "n".repeat(43); const state = "s".repeat(43); const keys = generateKeyPairSync("rsa", { modulusLength: 2048 }); const jwk = { ...keys.publicKey.export({ format: "jwk" }), kid: "test-key", use: "sig", alg: "RS256" }; function token(claims: Record, invalidSignature = false): string { const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); const input = `${encode({ alg: "RS256", kid: "test-key", typ: "JWT" })}.${encode(claims)}`; const signer = createSign("RSA-SHA256"); signer.update(input); signer.end(); const signature = signer.sign(keys.privateKey).toString("base64url"); const corruptedSignature = signature.startsWith("A") ? `B${signature.slice(1)}` : `A${signature.slice(1)}`; return `${input}.${invalidSignature ? corruptedSignature : signature}`; } function protocol(options: { claims?: Record; 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; } = {}) { const now = Math.floor(Date.now() / 1000); const claims = { iss: issuer, sub: "user-123", aud: clientId, exp: now + 300, iat: now, nonce, name: "Ada Lovelace", groups: ["TOT Users", "Unmapped group"], ...options.claims, }; const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { 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`, 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"], ...options.discoveryMetadata, }); } 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", refresh_token: "refresh-token-must-not-be-persisted", id_token: token(claims, options.invalidSignature), }); } return new Response(null, { status: 404 }); }; const protocolOptions = { issuer, clientId, clientSecret: "client-secret-must-not-be-persisted", callbackUrl, scopes: ["openid", "profile"], groupsClaim: "groups", fetch, ...(options.httpTimeoutMs === undefined ? {} : { httpTimeoutMs: options.httpTimeoutMs }), ...(options.jwksTimeoutMs === undefined ? {} : { jwksTimeoutMs: options.jwksTimeoutMs }), } as Parameters[0]; return createOidcProtocol(protocolOptions); } async function callback(subject = protocol()) { return subject.callback({ currentUrl: new URL(`${callbackUrl}?code=good&state=${state}`), state, nonce, codeVerifier: verifier, }); } 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 }); const authorization = await subject.authorizationUrl({ state, nonce, codeVerifier: verifier }); expect(authorization.origin).toBe(issuer); expect(authorization.pathname).toBe("/authorize"); expect(Object.fromEntries(authorization.searchParams)).toMatchObject({ response_type: "code", client_id: clientId, redirect_uri: callbackUrl, state, nonce, code_challenge_method: "S256", scope: "openid profile", }); expect(authorization.searchParams.get("code_challenge")).not.toBe(verifier); await expect(callback(subject)).resolves.toEqual({ issuer, subject: "user-123", displayName: "Ada Lovelace", groups: ["TOT Users", "Unmapped group"], tokenExpiresAt: expect.any(Date), }); 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, scopes: ["openid"], groupsClaim: "groups", })).toThrow(OidcProtocolError); try { await protocol({ discoveryIssuer: "https://other.example.test" }) .authorizationUrl({ state, nonce, codeVerifier: verifier }); throw new Error("issuer mismatch unexpectedly accepted"); } catch (error) { expect(error).toBeInstanceOf(OidcIssuerMismatchError); expect((error as Error).message).toBe("oidc_issuer_mismatch"); } }); test.each([ ["authorization", { authorization_endpoint: "http://127.0.0.1/authorize" }], ["token", { token_endpoint: "http://127.0.0.1/token" }], ["JWKS", { jwks_uri: "http://127.0.0.1/jwks" }], ])("keeps the discovered %s endpoint HTTPS-only", async (_label, discoveryMetadata) => { const subject = protocol({ discoveryMetadata }); await expect(subject.authorizationUrl({ state, nonce, codeVerifier: verifier })) .rejects.toThrow(OidcProtocolError); }); test.each([ "http://127.0.0.1:3000/api/auth/oidc/callback", "http://127.255.255.254/api/auth/oidc/callback", "http://[::1]:3000/api/auth/oidc/callback", "https://thothii.example.test/api/auth/oidc/callback", ])("accepts the configured callback URL %s", (configuredCallbackUrl) => { expect(() => createOidcProtocol({ issuer, clientId, clientSecret: "secret", callbackUrl: configuredCallbackUrl, scopes: ["openid"], groupsClaim: "groups", })).not.toThrow(); }); test.each([ "http://localhost/api/auth/oidc/callback", "http://loopback.example.test/api/auth/oidc/callback", "http://user@127.0.0.1/api/auth/oidc/callback", "http://127.1/api/auth/oidc/callback", "http://127.0.0.01/api/auth/oidc/callback", "http://0177.0.0.1/api/auth/oidc/callback", "http://0x7f000001/api/auth/oidc/callback", "http://2130706433/api/auth/oidc/callback", "http://[::ffff:127.0.0.1]/api/auth/oidc/callback", "http://128.0.0.1/api/auth/oidc/callback", "http://192.168.1.1/api/auth/oidc/callback", ])("rejects the non-canonical or non-loopback HTTP callback URL %s", (configuredCallbackUrl) => { expect(() => createOidcProtocol({ issuer, clientId, clientSecret: "secret", callbackUrl: configuredCallbackUrl, scopes: ["openid"], groupsClaim: "groups", })).toThrow(OidcProtocolError); }); test.each([ ["state", new URL(`${callbackUrl}?code=good&state=wrong`), {}], ["nonce", new URL(`${callbackUrl}?code=good&state=${state}`), { nonce: "wrong" }], ["audience", new URL(`${callbackUrl}?code=good&state=${state}`), { aud: "someone-else" }], ["issuer", new URL(`${callbackUrl}?code=good&state=${state}`), { iss: "https://other.example.test" }], ["expiry", new URL(`${callbackUrl}?code=good&state=${state}`), { exp: Math.floor(Date.now() / 1000) - 1 }], ["subject", new URL(`${callbackUrl}?code=good&state=${state}`), { sub: undefined }], ])("rejects invalid %s claims or callback bindings", async (_label, currentUrl, claims) => { const subject = protocol({ claims }); await expect(subject.callback({ currentUrl, state, nonce, codeVerifier: verifier })).rejects.toThrow(OidcProtocolError); }); test("rejects invalid ID-token signatures", async () => { await expect(callback(protocol({ invalidSignature: true }))).rejects.toThrow(OidcProtocolError); }); test("aborts a hanging JWKS request at the configured timeout", async () => { let aborted = false; const subject = protocol({ jwksTimeoutMs: 20, jwksResponse: (init) => new Promise((_resolve, reject) => { const fallback = setTimeout(() => reject(new Error("JWKS fixture was not aborted")), 200); init?.signal?.addEventListener("abort", () => { aborted = true; clearTimeout(fallback); reject(new DOMException("aborted", "AbortError")); }, { once: true }); }), }); await expect(callback(subject)).rejects.toThrow(OidcProtocolError); expect(aborted).toBe(true); }); test("diagnose validates the bounded JWKS endpoint after discovery", async () => { const subject = protocol({ jwksResponse: () => new Response("upstream JWKS body", { status: 503 }) }); await expect(subject.diagnose(new AbortController().signal)).rejects.toBeInstanceOf(OidcJwksUnavailableError); }); test("rejects an oversized JWKS Content-Length before reading the 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({ jwksResponse: () => new Response(body, { headers: { "content-length": String(1024 * 1024 + 1) } }), }); await expect(callback(subject)).rejects.toThrow(OidcProtocolError); expect(pulls).toBe(0); expect(cancelled).toBe(true); expect(body.locked).toBe(false); }); test("cancels and releases a JWKS stream with an invalid Content-Length", async () => { let cancelled = false; const body = new ReadableStream({ pull() { /* remains pending until the response is rejected and cancelled */ }, cancel() { cancelled = true; }, }); const subject = protocol({ jwksResponse: () => new Response(body, { headers: { "content-length": "not-a-number" } }), }); await expect(callback(subject)).rejects.toThrow(OidcProtocolError); expect(cancelled).toBe(true); expect(body.locked).toBe(false); }); test("cancels and releases a non-success JWKS response body", async () => { let cancelled = false; const body = new ReadableStream({ pull() { /* remains pending until the response is rejected and cancelled */ }, cancel() { cancelled = true; }, }); const subject = protocol({ jwksResponse: () => new Response(body, { status: 503 }) }); await expect(callback(subject)).rejects.toThrow(OidcProtocolError); expect(cancelled).toBe(true); expect(body.locked).toBe(false); }); test("rejects promptly and releases the JWKS reader when stream cancellation never settles", async () => { let cancelled = false; const body = new ReadableStream({ pull() { /* a non-success response is rejected before any body read */ }, cancel() { cancelled = true; return new Promise(() => { /* deliberately never settles */ }); }, }); const subject = protocol({ jwksResponse: () => new Response(body, { status: 503 }) }); const completion = callback(subject).then(() => "resolved" as const, () => "rejected" as const); const outcome = await Promise.race([ completion, new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 100)), ]); expect(outcome).toBe("rejected"); expect(cancelled).toBe(true); expect(body.locked).toBe(false); }); test("preserves the JWKS body timeout when cancellation never settles", async () => { let cancelled = false; const body = new ReadableStream({ pull() { return new Promise(() => { /* body read deliberately hangs */ }); }, cancel() { cancelled = true; return new Promise(() => { /* deliberately never settles */ }); }, }); const subject = protocol({ jwksTimeoutMs: 20, jwksResponse: () => new Response(body), }); const completion = callback(subject).then(() => "resolved" as const, () => "rejected" as const); const outcome = await Promise.race([ completion, new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 100)), ]); expect(outcome).toBe("rejected"); expect(cancelled).toBe(true); expect(body.locked).toBe(false); }); test("stops streaming a JWKS response as soon as the byte limit is exceeded", 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({ jwksResponse: () => new Response(body) }); await expect(callback(subject)).rejects.toThrow(OidcProtocolError); expect(pulls).toBe(2); expect(cancelled).toBe(true); expect(body.locked).toBe(false); }); test.each([ ["absent", { groups: undefined }], ["non-array", { groups: "TOT Users" }], ["empty array", { groups: [] }], ["empty", { groups: [""] }], ["duplicate", { groups: ["TOT Users", "TOT Users"] }], ["control", { groups: ["TOT\u0000Users"] }], ["oversized", { groups: ["x".repeat(257)] }], ["distributed", { _claim_names: { groups: "source" }, _claim_sources: { source: { endpoint: "https://issuer.example.test/claims" } } }], ["overage", { hasgroups: true }], ])("rejects %s mandatory groups claims", async (_label, claims) => { await expect(callback(protocol({ claims }))).rejects.toThrow(OidcProtocolError); });