import { createSign, generateKeyPairSync } from "node:crypto"; import { expect, test } from "vitest"; import { createOidcProtocol, OidcProtocolError } 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[]; } = {}) { 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) => { 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/")) { 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"], }); } if (url.pathname === "/jwks") return Response.json({ keys: [jwk] }); if (url.pathname === "/token") { 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 }); }; return createOidcProtocol({ issuer, clientId, clientSecret: "client-secret-must-not-be-persisted", callbackUrl, scopes: ["openid", "profile"], groupsClaim: "groups", fetch, }); } async function callback(subject = protocol()) { return subject.callback({ currentUrl: new URL(`${callbackUrl}?code=good&state=${state}`), state, nonce, codeVerifier: verifier, }); } 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 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); await expect(protocol({ discoveryIssuer: "https://other.example.test" }).authorizationUrl({ state, nonce, codeVerifier: verifier })) .rejects.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.each([ ["absent", { groups: undefined }], ["non-array", { groups: "TOT Users" }], ["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); });