import Fastify from "fastify"; import cookie from "@fastify/cookie"; import { afterEach, expect, test } 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"; const revision = "a".repeat(64); const issuer = "https://issuer.example.test"; const state = "s".repeat(43); const nonce = "n".repeat(43); const verifier = "v".repeat(43); const createdApps: Array> = []; function config(overrides: Partial = {}): LoadedAuthConfig { return { revision, sourcePath: "/private/auth.yaml", value: { version: 1, mode: "oidc", publicUrl: "https://thothii.example.test", session: { regularTtlSeconds: 3600, regularIdleSeconds: 300, rememberTtlSeconds: 3600, rememberIdleSeconds: 300, oidcTtlSeconds: 3600, }, oidc: { issuer, clientId: "thothii", clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid", "profile"], groupsClaim: "groups", }, groupCatalog: { driver: "authentik", baseUrl: issuer, apiTokenRef: "THT_AUTHENTIK_API_TOKEN" }, authorization: { groupRoles: { Users: ["user"], Admins: ["admin"] } }, ...overrides, }, } as LoadedAuthConfig; } function stateRecord(extra: Partial = {}): OidcStateRecord { return { version: 1, nonce, codeVerifier: verifier, returnTo: "/", authConfigRevision: revision, issuer, createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z", ...extra, } as OidcStateRecord; } function fixture(options: { loaded?: LoadedAuthConfig; identity?: Awaited>; callbackFailure?: boolean; stateReturnTo?: string; } = {}) { let loaded = options.loaded ?? config(); let protocolAvailable = true; let storedState: OidcStateRecord | undefined; const stateInputs: Array> = []; const creates: Array> = []; const callbacks: URL[] = []; const protocol: OidcProtocol = { authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => { expect(received).toBe(state); expect(receivedNonce).toHaveLength(43); expect(codeVerifier).toHaveLength(43); return new URL(`https://issuer.example.test/authorize?state=${received}`); }, callback: async ({ currentUrl }) => { callbacks.push(currentUrl); if (options.callbackFailure) throw new Error("provider failure with access-token-must-not-leak"); return options.identity ?? { issuer, subject: "user-123", displayName: "Ada", groups: ["Users", "Admins", "Unmapped"], tokenExpiresAt: new Date(Date.now() + 120_000), }; }, diagnose: async () => undefined, }; const store = { createOidcState: async (input: Record) => { stateInputs.push(input); const record = stateRecord({ nonce: input.nonce as string, codeVerifier: input.codeVerifier as string, authConfigRevision: input.authConfigRevision as string, issuer: input.issuer as string, }); if (options.stateReturnTo) (record as { returnTo: string }).returnTo = options.stateReturnTo; storedState = record; return { state, record: storedState }; }, consumeOidcState: async (received: string) => { if (received !== state) return undefined; const consumed = storedState; storedState = undefined; return consumed; }, create: async (input: Record) => { creates.push(input); return { token: "opaque-session-token", csrfToken: "c".repeat(43), record: {} }; }, } as unknown as AuthSessionStore; const app = Fastify(); app.decorateRequest("authConfigSnapshot", undefined); app.decorateRequest("authConfigSnapshotCaptured", false); app.decorateRequest("authConfigSnapshotUnavailable", false); app.register(cookie); registerAuthRoutes(app, { authMode: "oidc", authentication: { current: () => loaded }, sessionStore: store, resolveOidcProtocol: () => protocolAvailable ? protocol : undefined, }); createdApps.push(app); return { app, creates, callbacks, stateInputs, setConfig(next: LoadedAuthConfig) { loaded = next; }, setProtocolAvailable(available: boolean) { protocolAvailable = available; }, stateWasConsumed: () => storedState === undefined, }; } afterEach(async () => { await Promise.all(createdApps.splice(0).map((app) => app.close())); }); test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => { const subject = fixture(); const start = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); expect(start.statusCode).toBe(302); expect(new URL(start.headers.location ?? "").searchParams.get("state")).toBe(state); expect(subject.stateInputs[0]).toMatchObject({ returnTo: "/", authConfigRevision: revision, issuer }); const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}`, headers: { host: "attacker.example.test" }, }); expect(callback.statusCode).toBe(302); expect(callback.headers.location).toBe("/"); expect(callback.headers["set-cookie"]).toContain("HttpOnly"); expect(callback.headers["set-cookie"]).toContain("SameSite=Lax"); expect(callback.headers["set-cookie"]).toContain("Secure"); expect(subject.callbacks[0]?.href).toBe(`https://thothii.example.test/api/auth/oidc/callback?code=good&state=${state}`); expect(subject.creates).toHaveLength(1); expect(subject.creates[0]).toMatchObject({ method: "oidc", remembered: false, authConfigRevision: revision, principal: { issuer, subject: "user-123", roles: ["user", "admin"] }, idleTtlMs: 300_000, }); const absoluteTtlMs = subject.creates[0]?.absoluteTtlMs; expect(typeof absoluteTtlMs).toBe("number"); expect(absoluteTtlMs as number).toBeGreaterThan(0); expect(absoluteTtlMs as number).toBeLessThanOrEqual(120_000); expect(JSON.stringify(subject.creates)).not.toContain("access-token-must-not-leak"); expect(JSON.stringify(subject.creates)).not.toContain("refresh-token-must-not-leak"); }); test("consumes state on callback failure and refuses replay", async () => { const subject = fixture({ callbackFailure: true }); await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` }); expect(failed.statusCode).toBe(401); const replay = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` }); expect(replay.statusCode).toBe(401); expect(subject.callbacks).toHaveLength(1); }); test("consumes state when the protocol becomes unavailable before callback", async () => { const subject = fixture(); await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); subject.setProtocolAvailable(false); const failed = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` }); expect(failed.statusCode).toBe(401); expect(subject.stateWasConsumed()).toBe(true); }); test("rejects a consumed state with a non-root return target", async () => { const subject = fixture({ stateReturnTo: "https://attacker.example.test" }); await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` }); expect(callback.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); expect(subject.creates).toEqual([]); }); test("rejects an OIDC state when its configuration revision changes before callback", async () => { const subject = fixture(); await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); subject.setConfig({ ...config(), revision: "b".repeat(64) }); const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` }); expect(callback.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); }); test("rejects an OIDC state when its issuer changes before callback", async () => { const subject = fixture(); await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); const previous = config(); subject.setConfig({ ...previous, value: { ...previous.value, oidc: { ...previous.value.oidc, issuer: "https://other.example.test" } }, } as LoadedAuthConfig); const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` }); expect(callback.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); }); test("creates an authenticated but forbidden principal for extra unmapped groups", async () => { const subject = fixture({ identity: { issuer, subject: "user-123", groups: ["Unmapped"], tokenExpiresAt: new Date(Date.now() + 60_000), } }); await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}` }); expect(callback.statusCode).toBe(302); expect(subject.creates[0]).toMatchObject({ principal: { roles: [], permissions: [], isAdmin: false } }); });