import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describeFetchFailure, extractOidcGroups, extractOidcGroupsFromSources, parseOidcRoleMap, resolveOidcMappedRoles, validateLogoutTokenClaims, verifyOIDCToken, } from "../../src/backend/oidc-protocol.js"; import { getOidcConfigFromEnv as getOIDCConfigFromEnv, isEnvOverrideEnabled as isOIDCEnvOverrideEnabled, } from "../../src/backend/providers.js"; const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; afterEach(() => { vi.restoreAllMocks(); }); describe("describeFetchFailure", () => { it("unwraps the undici cause, which carries the reason that matters", () => { // Every transport failure surfaces as this same outer message. const error = new TypeError("fetch failed", { cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), { code: "ENOTFOUND", }), }); expect(describeFetchFailure(error)).toBe( "fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)", ); }); it("falls back to the outer message when there is no cause", () => { expect(describeFetchFailure(new Error("boom"))).toBe("boom"); }); it("handles a non-Error throw", () => { expect(describeFetchFailure("nope")).toBe("nope"); }); }); describe("verifyOIDCToken JWKS diagnostics", () => { const issuer = "https://login.microsoftonline.com/example/v2.0"; const token = "header.payload.signature"; it("reports every attempted URL and why it failed", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("not found", { status: 404 }), ); const error = await verifyOIDCToken(token, issuer, "client").catch( (e) => e as Error, ); expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/); expect(error.message).toContain( `${issuer}/.well-known/openid-configuration: HTTP 404`, ); expect(error.message).toContain( `${issuer}/.well-known/jwks.json: HTTP 404`, ); expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`); }); it("reports a transport failure with its underlying cause", async () => { vi.spyOn(globalThis, "fetch").mockRejectedValue( new TypeError("fetch failed", { cause: Object.assign(new Error("self-signed certificate"), { code: "SELF_SIGNED_CERT_IN_CHAIN", }), }), ); const error = await verifyOIDCToken(token, issuer, "client").catch( (e) => e as Error, ); expect(error.message).toContain( "self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)", ); }); it("says so when discovery succeeds but advertises no jwks_uri", async () => { vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce( new Response(JSON.stringify({ issuer }), { status: 200 }), ) .mockResolvedValue(new Response("not found", { status: 404 })); const error = await verifyOIDCToken(token, issuer, "client").catch( (e) => e as Error, ); expect(error.message).toContain("no jwks_uri in the discovery document"); }); it("says so when a JWKS response carries no keys array", async () => { vi.spyOn(globalThis, "fetch") .mockResolvedValueOnce( new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), { status: 200, }), ) .mockResolvedValue( new Response(JSON.stringify({ error: "unauthorized" }), { status: 200, }), ); const error = await verifyOIDCToken(token, issuer, "client").catch( (e) => e as Error, ); expect(error.message).toContain( 'https://idp.example/keys: response contains no "keys" array', ); }); }); describe("verifyOIDCToken", () => { it("accepts a discovery document URL as the configured issuer", async () => { const { exportJWK, generateKeyPair, SignJWT } = await import("jose"); const { publicKey, privateKey } = await generateKeyPair("RS256"); const jwk = await exportJWK(publicKey); jwk.kid = "google-key"; const issuer = "https://accounts.google.com"; const clientId = "termix-client"; const token = await new SignJWT({ sub: "user-1" }) .setProtectedHeader({ alg: "RS256", kid: jwk.kid }) .setIssuer(issuer) .setAudience(clientId) .setExpirationTime("5m") .sign(privateKey); const fetchMock = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce( new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), { status: 200, }), ) .mockResolvedValueOnce( new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }), ); const payload = await verifyOIDCToken( token, `${issuer}/.well-known/openid-configuration`, clientId, ); expect(payload.sub).toBe("user-1"); expect(fetchMock).toHaveBeenNthCalledWith( 1, `${issuer}/.well-known/openid-configuration`, {}, ); }); it("uses the protected-header algorithm when the provider JWK omits alg", async () => { const { exportJWK, generateKeyPair, SignJWT } = await import("jose"); const { publicKey, privateKey } = await generateKeyPair("RS256"); const jwk = await exportJWK(publicKey); jwk.kid = "entra-key"; const issuer = "https://login.microsoftonline.com/example/v2.0"; const clientId = "termix-client"; const token = await new SignJWT({ sub: "user-1" }) .setProtectedHeader({ alg: "RS256", kid: jwk.kid }) .setIssuer(issuer) .setAudience(clientId) .setExpirationTime("5m") .sign(privateKey); const fetchMock = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce( new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), { status: 200, }), ) .mockResolvedValueOnce( new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }), ); const payload = await verifyOIDCToken(token, issuer, clientId); expect(payload.sub).toBe("user-1"); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe("getOIDCConfigFromEnv", () => { const REQUIRED = [ "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_ISSUER_URL", "OIDC_AUTHORIZATION_URL", "OIDC_TOKEN_URL", ]; const OPTIONAL = [ "OIDC_USERINFO_URL", "OIDC_IDENTIFIER_PATH", "OIDC_NAME_PATH", "OIDC_SCOPES", "OIDC_ALLOWED_USERS", "OIDC_ADMIN_GROUP", "OIDC_ENV_OVERRIDE", ]; const saved: Record = {}; beforeEach(() => { for (const key of [...REQUIRED, ...OPTIONAL]) { saved[key] = process.env[key]; delete process.env[key]; } }); afterEach(() => { for (const key of [...REQUIRED, ...OPTIONAL]) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } }); it("returns null when any required variable is missing", () => { process.env.OIDC_CLIENT_ID = "id"; process.env.OIDC_CLIENT_SECRET = "secret"; // issuer/authorization/token urls intentionally missing expect(getOIDCConfigFromEnv()).toBeNull(); }); it("builds a config with defaults when all required vars are present", () => { process.env.OIDC_CLIENT_ID = "id"; process.env.OIDC_CLIENT_SECRET = "secret"; process.env.OIDC_ISSUER_URL = "https://idp.example"; process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth"; process.env.OIDC_TOKEN_URL = "https://idp.example/token"; const config = getOIDCConfigFromEnv(); expect(config).not.toBeNull(); expect(config?.client_id).toBe("id"); expect(config?.identifier_path).toBe("sub"); expect(config?.name_path).toBe("name"); expect(config?.scopes).toBe("openid email profile"); expect(config?.userinfo_url).toBe(""); }); it("honors overrides for optional vars", () => { process.env.OIDC_CLIENT_ID = "id"; process.env.OIDC_CLIENT_SECRET = "secret"; process.env.OIDC_ISSUER_URL = "https://idp.example"; process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth"; process.env.OIDC_TOKEN_URL = "https://idp.example/token"; process.env.OIDC_IDENTIFIER_PATH = "email"; process.env.OIDC_SCOPES = "openid"; const config = getOIDCConfigFromEnv(); expect(config?.identifier_path).toBe("email"); expect(config?.scopes).toBe("openid"); }); it("only enables database recovery override when explicitly requested", () => { expect(isOIDCEnvOverrideEnabled()).toBe(false); process.env.OIDC_ENV_OVERRIDE = "true"; expect(isOIDCEnvOverrideEnabled()).toBe(true); }); }); describe("extractOidcGroups", () => { it("reads the standard groups claim as an array", () => { expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([ "admin", "user", ]); }); it("splits a comma-separated string claim", () => { expect(extractOidcGroups({ roles: "admin, user" })).toEqual([ "admin", "user", ]); }); it("falls back through groups, roles, then group", () => { expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]); }); it("reads a custom claim path when provided", () => { const userInfo = { "zitadel:grants:groups:123": ["user", "admin"], groups: ["ignored"], }; expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([ "user", "admin", ]); }); it("uses object keys as group names (Zitadel roles object)", () => { const userInfo = { "urn:zitadel:iam:org:project:roles": { admin: {}, user: {} }, }; expect( extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"), ).toEqual(["admin", "user"]); }); it("falls back to defaults when the custom claim is absent", () => { expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([ "admin", ]); }); it("returns an empty array when no groups are present", () => { expect(extractOidcGroups({})).toEqual([]); }); }); describe("extractOidcGroupsFromSources", () => { it("preserves ID token groups when userinfo omits them", () => { expect( extractOidcGroupsFromSources([ { groups: ["admins", "users"] }, { sub: "user-1", name: "Example User" }, ]), ).toEqual(["admins", "users"]); }); it("combines and deduplicates groups from both verified sources", () => { expect( extractOidcGroupsFromSources([ { roles: ["users", "operators"] }, { roles: ["operators", "admins"] }, ]), ).toEqual(["users", "operators", "admins"]); }); it("supports a configured group claim across sources", () => { expect( extractOidcGroupsFromSources( [{ custom_groups: ["admins"] }, { custom_groups: ["users"] }], "custom_groups", ), ).toEqual(["admins", "users"]); }); }); describe("validateLogoutTokenClaims", () => { const validClaims = { sub: "subject-1", sid: "session-1", iat: 1_783_641_600, jti: "logout-1", events: { [BACKCHANNEL_LOGOUT_EVENT]: {} }, }; it("accepts a spec-compliant back-channel logout payload", () => { expect(validateLogoutTokenClaims(validClaims)).toEqual({ sub: "subject-1", sid: "session-1", jti: "logout-1", }); }); it("requires the logout event to contain an object", () => { expect(() => validateLogoutTokenClaims({ ...validClaims, events: { [BACKCHANNEL_LOGOUT_EVENT]: true }, }), ).toThrow("missing back-channel logout event"); }); it("requires iat and jti claims", () => { expect(() => validateLogoutTokenClaims({ ...validClaims, iat: undefined }), ).toThrow("missing iat claim"); expect(() => validateLogoutTokenClaims({ ...validClaims, jti: "" }), ).toThrow("missing jti claim"); }); it("rejects nonce and requires sub or sid", () => { expect(() => validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }), ).toThrow("must not contain a nonce"); expect(() => validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }), ).toThrow("must contain sub and/or sid"); }); }); describe("parseOidcRoleMap", () => { it("returns an empty map for blank input", () => { expect(parseOidcRoleMap(undefined).size).toBe(0); expect(parseOidcRoleMap(null).size).toBe(0); expect(parseOidcRoleMap(" ").size).toBe(0); }); it("parses comma-separated group:role pairs", () => { const map = parseOidcRoleMap( "devops-interns:devops-intern,devops-seniors:devops-senior", ); expect(map.get("devops-interns")).toBe("devops-intern"); expect(map.get("devops-seniors")).toBe("devops-senior"); expect(map.size).toBe(2); }); it("parses newline-separated pairs and trims whitespace", () => { const map = parseOidcRoleMap(" a : role-a \n b:role-b \n"); expect(map.get("a")).toBe("role-a"); expect(map.get("b")).toBe("role-b"); }); it("normalizes leading slashes and case in group names", () => { const map = parseOidcRoleMap("/DevOps-Interns:devops-intern"); expect(map.get("devops-interns")).toBe("devops-intern"); }); it("skips malformed entries instead of throwing", () => { const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r"); expect(map.size).toBe(1); expect(map.get("ok")).toBe("r"); }); it("splits on the last colon so group names may contain colons", () => { const map = parseOidcRoleMap("ns:team:role-x"); expect(map.get("ns:team")).toBe("role-x"); }); it("preserves role-name case verbatim", () => { // Role names must match roles.name exactly, so they are not lowercased. expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior"); }); }); describe("resolveOidcMappedRoles", () => { const roleMap = parseOidcRoleMap( "devops-interns:devops-intern,devops-seniors:devops-senior", ); it("reports every mapped role as managed regardless of membership", () => { const { managed } = resolveOidcMappedRoles([], roleMap); expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]); }); it("desires only the roles whose groups the user is in", () => { const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap); expect([...desired]).toEqual(["devops-intern"]); }); it("matches full group paths emitted by Keycloak", () => { const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap); expect([...desired]).toEqual(["devops-senior"]); }); it("ignores groups that are not mapped", () => { const { desired } = resolveOidcMappedRoles( ["finance", "devops-interns"], roleMap, ); expect([...desired]).toEqual(["devops-intern"]); }); it("supports a user in multiple mapped groups", () => { const { desired } = resolveOidcMappedRoles( ["devops-interns", "devops-seniors"], roleMap, ); expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]); }); it("desires nothing when the map is empty", () => { const { desired, managed } = resolveOidcMappedRoles( ["devops-interns"], new Map(), ); expect(desired.size).toBe(0); expect(managed.size).toBe(0); }); }); // Imported as a namespace rather than destructured into the shared block at the // top of the file, so this suite stays independent of what that block binds. const oidcUtils = await import("../../src/backend/oidc-protocol.js"); describe("verifyOIDCToken token shape", () => { const issuer = "https://idp.example.com/application/o/termix"; // The shape check runs before any network call, so no fetch stub is needed. const fetchSpy = vi.fn(); beforeEach(() => { vi.stubGlobal("fetch", fetchSpy); }); afterEach(() => { vi.unstubAllGlobals(); fetchSpy.mockReset(); }); it("reports an encrypted (JWE) token as a format error", async () => { const jwe = ["header", "key", "iv", "ciphertext", "tag"].join("."); await expect( oidcUtils.verifyOIDCToken(jwe, issuer, "client"), ).rejects.toThrow(oidcUtils.OIDCTokenFormatError); await expect( oidcUtils.verifyOIDCToken(jwe, issuer, "client"), ).rejects.toThrow(/JWE \(encrypted\)/); expect(fetchSpy).not.toHaveBeenCalled(); }); it("reports any other non-JWS segment count as a format error", async () => { await expect( oidcUtils.verifyOIDCToken("header.payload", issuer, "client"), ).rejects.toThrow(/expected 3 segments, got 2/); await expect( oidcUtils.verifyOIDCToken("opaque", issuer, "client"), ).rejects.toThrow(/expected 3 segments, got 1/); expect(fetchSpy).not.toHaveBeenCalled(); }); it("lets a three-segment token through to key resolution", async () => { fetchSpy.mockResolvedValue({ ok: false }); // Reaches JWKS fetching, so it fails on the key lookup rather than the shape. await expect( oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"), ).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError); expect(fetchSpy).toHaveBeenCalled(); }); });