import Fastify from "fastify"; import cookie from "@fastify/cookie"; 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 { createOidcProtocol, 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 transactionCookieName = "__Host-thothii_oidc_tx"; const loopbackTransactionCookieName = "thothii_oidc_tx"; const createdApps: Array> = []; function setCookieHeaders(response: { headers: Record }): string[] { const header = response.headers["set-cookie"]; return header === undefined ? [] : Array.isArray(header) ? header : [header]; } function transactionCookie( response: { headers: Record }, name = transactionCookieName, ): string | undefined { return setCookieHeaders(response) .find((header) => header.startsWith(`${name}=`) && !header.includes("Max-Age=0")) ?.split(";", 1)[0]; } function expectTransactionCleared( response: { headers: Record }, name = transactionCookieName, secure = true, ): void { const header = setCookieHeaders(response).find((candidate) => candidate.startsWith(`${name}=`) && candidate.includes("Max-Age=0")); expect(header).toBeDefined(); expect(header).toContain("HttpOnly"); expect(header).toContain("SameSite=Lax"); expect(header).toContain("Path=/"); expect(header).not.toContain("Domain="); if (secure) expect(header).toContain("Secure"); else expect(header).not.toContain("Secure"); } function expectBothTransactionVariantsCleared( response: { headers: Record }, activeName: string, activeSecure: boolean, ): void { expectTransactionCleared(response, activeName, activeSecure); expectTransactionCleared( response, activeName === transactionCookieName ? loopbackTransactionCookieName : transactionCookieName, activeName !== transactionCookieName, ); } 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, browserTransactionDigest: "b".repeat(64), browserTransactionTransport: "https", createdAt: "2030-01-01T00:00:00.000Z", expiresAt: "2030-01-01T00:10:00.000Z", ...extra, } as OidcStateRecord; } function fixture(options: { loaded?: LoadedAuthConfig; identity?: Awaited>; protocol?: OidcProtocol; callbackFailure?: boolean; stateReturnTo?: string; } = {}) { let loaded = options.loaded ?? config(); let protocolAvailable = true; let storedState: OidcStateRecord | undefined; const stateInputs: Array> = []; const creates: Array> = []; const createTimes: Array = []; const callbacks: URL[] = []; let authorizationRequests = 0; const defaultProtocol: OidcProtocol = { authorizationUrl: async ({ state: received, nonce: receivedNonce, codeVerifier }) => { authorizationRequests += 1; 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 protocol = options.protocol ?? defaultProtocol; 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, }); (record as OidcStateRecord & { browserTransactionDigest: string }).browserTransactionDigest = input.browserTransactionDigest as string; (record as OidcStateRecord & { browserTransactionTransport?: string }).browserTransactionTransport = input.browserTransactionTransport as string | undefined ?? "https"; 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, now?: Date) => { creates.push(input); createTimes.push(now); 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, createTimes, callbacks, stateInputs, authorizationRequests: () => authorizationRequests, 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())); }); async function beginOidcLogin(subject: ReturnType) { const response = await subject.app.inject({ method: "GET", url: "/auth/oidc/login" }); return { response, cookie: transactionCookie(response) ?? transactionCookie(response, loopbackTransactionCookieName), }; } async function finishOidcLogin( subject: ReturnType, cookie: string | undefined, query = `code=good&state=${state}`, ) { return await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?${query}`, ...(cookie ? { headers: { cookie } } : {}), }); } 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(); for (let attempt = 0; attempt < 20; attempt += 1) { expect((await beginOidcLogin(subject)).response.statusCode).toBe(302); } const limited = await beginOidcLogin(subject); expect(limited.response.statusCode).toBe(429); expect(limited.response.json()).toEqual({ code: "login_rate_limited", error: "Too many login attempts", }); expect(subject.stateInputs).toHaveLength(20); expect(subject.authorizationRequests()).toBe(20); }); test("OIDC initiation rate-limit capacity expires after ten minutes", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2030-01-02T03:04:05.000Z")); const subject = fixture(); try { for (let attempt = 0; attempt < 20; attempt += 1) { expect((await beginOidcLogin(subject)).response.statusCode).toBe(302); } expect((await beginOidcLogin(subject)).response.statusCode).toBe(429); vi.advanceTimersByTime(10 * 60_000 + 1); expect((await beginOidcLogin(subject)).response.statusCode).toBe(302); expect(subject.authorizationRequests()).toBe(21); } finally { vi.useRealTimers(); } }); test("creates digest-only bound state, maps exact groups, creates a cookie session, and redirects safely", async () => { const subject = fixture(); const { response: start, cookie } = await beginOidcLogin(subject); 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, browserTransactionDigest: expect.stringMatching(/^[a-f0-9]{64}$/), browserTransactionTransport: "https", }); expect(cookie).toMatch(new RegExp(`^${transactionCookieName}=[A-Za-z0-9_-]{43}$`)); const issued = setCookieHeaders(start) .find((header) => header.startsWith(`${transactionCookieName}=`) && !header.includes("Max-Age=0")); expect(issued).toContain("HttpOnly"); expect(issued).toContain("SameSite=Lax"); expect(issued).toContain("Secure"); expect(issued).toContain("Path=/"); expect(issued).not.toContain("Domain="); expect(JSON.stringify(subject.stateInputs)).not.toContain(cookie?.split("=", 2)[1] ?? "missing-cookie"); const callback = await subject.app.inject({ method: "GET", url: `/auth/oidc/callback?code=good&state=${state}`, headers: { host: "attacker.example.test", cookie: cookie ?? "" }, }); expect(callback.statusCode).toBe(302); expect(callback.headers.location).toBe("/"); expect(setCookieHeaders(callback).join("\n")).toContain("HttpOnly"); expect(setCookieHeaders(callback).join("\n")).toContain("SameSite=Lax"); expect(setCookieHeaders(callback).join("\n")).toContain("Secure"); expectBothTransactionVariantsCleared(callback, transactionCookieName, true); 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.each([ "http://127.42.0.1:8787", "http://[::1]:8787", ])("uses the non-prefixed, non-Secure transaction cookie for literal loopback OIDC %s", async (publicUrl) => { const subject = fixture({ loaded: config({ publicUrl }) }); const { response: start, cookie } = await beginOidcLogin(subject); expect(start.statusCode).toBe(302); expect(subject.stateInputs[0]).toMatchObject({ browserTransactionTransport: "loopback_http" }); expect(cookie).toMatch(new RegExp(`^${loopbackTransactionCookieName}=[A-Za-z0-9_-]{43}$`)); const issued = setCookieHeaders(start).find((header) => header.startsWith(`${loopbackTransactionCookieName}=`)); expect(issued).toContain("HttpOnly"); expect(issued).toContain("SameSite=Lax"); expect(issued).toContain("Path=/"); expect(issued).not.toContain("Secure"); expect(issued).not.toContain("Domain="); const callback = await finishOidcLogin(subject, cookie); expect(callback.statusCode).toBe(302); expect(subject.callbacks[0]?.href).toBe(`${publicUrl}/api/auth/oidc/callback?code=good&state=${state}`); expectBothTransactionVariantsCleared(callback, loopbackTransactionCookieName, false); }); test("uses the consumed state transport to reject cross-mode and shadowed transaction cookies", async () => { const subject = fixture({ loaded: config({ publicUrl: "http://127.0.0.1:8787" }) }); const { cookie } = await beginOidcLogin(subject); expect(cookie).toBeDefined(); const failed = await finishOidcLogin(subject, [ cookie, `${transactionCookieName}=${"x".repeat(43)}`, ].join("; ")); expect(failed.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false); }); test("rejects a transaction presented only under the wrong transport cookie name", async () => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); const wrongTransportCookie = cookie?.replace(transactionCookieName, loopbackTransactionCookieName); const failed = await finishOidcLogin(subject, wrongTransportCookie); expect(failed.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); }); test("rejects duplicate same-mode transaction cookies instead of trusting a parser-selected value", async () => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); expect(cookie).toBeDefined(); const failed = await finishOidcLogin(subject, [ cookie, `${transactionCookieName}=${"x".repeat(43)}`, ].join("; ")); expect(failed.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); expect(subject.stateWasConsumed()).toBe(true); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); }); test("pins the callback transaction transport to its captured configuration snapshot", async () => { const subject = fixture({ loaded: config({ publicUrl: "http://127.42.0.1:8787" }) }); const { cookie } = await beginOidcLogin(subject); subject.setConfig(config({ publicUrl: "https://thothii.example.test" })); const failed = await finishOidcLogin(subject, cookie); expect(failed.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); expect(subject.stateWasConsumed()).toBe(true); expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false); }); test("clears the state-pinned loopback transaction variant after a terminal callback failure", async () => { const subject = fixture({ loaded: config({ publicUrl: "http://127.0.0.1:8787" }), callbackFailure: true, }); const { cookie } = await beginOidcLogin(subject); const failed = await finishOidcLogin(subject, cookie); expect(failed.statusCode).toBe(401); expectBothTransactionVariantsCleared(failed, loopbackTransactionCookieName, false); }); test("consumes state on callback failure and refuses replay", async () => { const subject = fixture({ callbackFailure: true }); const { cookie } = await beginOidcLogin(subject); 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); subject.setProtocolAvailable(false); const failed = await finishOidcLogin(subject, cookie); expect(failed.statusCode).toBe(401); expect(subject.stateWasConsumed()).toBe(true); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); }); test("rejects a consumed state with a non-root return target", async () => { const subject = fixture({ stateReturnTo: "https://attacker.example.test" }); const { cookie } = await beginOidcLogin(subject); const callback = await finishOidcLogin(subject, cookie); expect(callback.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); expect(subject.creates).toEqual([]); expectBothTransactionVariantsCleared(callback, transactionCookieName, true); }); test("rejects an OIDC state when its configuration revision changes before callback", async () => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); subject.setConfig({ ...config(), revision: "b".repeat(64) }); const callback = await finishOidcLogin(subject, cookie); expect(callback.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); expectBothTransactionVariantsCleared(callback, transactionCookieName, true); }); test("rejects an OIDC state when its issuer changes before callback", async () => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); const previous = config(); subject.setConfig({ ...previous, value: { ...previous.value, oidc: { ...previous.value.oidc, issuer: "https://other.example.test" } }, } as LoadedAuthConfig); const callback = await finishOidcLogin(subject, cookie); 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), } }); const { cookie } = await beginOidcLogin(subject); const callback = await finishOidcLogin(subject, cookie); expect(callback.statusCode).toBe(302); expect(subject.creates[0]).toMatchObject({ principal: { roles: [], permissions: [], isAdmin: false } }); }); test("rejects a callback from a different browser and clears the transaction cookie", async () => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); expect(cookie).toBeDefined(); const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`); expect(failed.statusCode).toBe(401); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); expect(subject.stateWasConsumed()).toBe(true); expect(subject.creates).toEqual([]); const replay = await finishOidcLogin(subject, cookie); expect(replay.statusCode).toBe(401); expect(subject.creates).toEqual([]); }); test.each([ ["unknown", `code=good&state=${state}&unexpected=value`], ["duplicate", `code=good&code=other&state=${state}`], ["malformed", `code=good&state=${state}&error_description=%00bad`], ])("burns canonical state before rejecting %s callback parameters", async (_label, query) => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); const failed = await finishOidcLogin(subject, cookie, query); expect(failed.statusCode).toBe(401); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); expect(subject.stateWasConsumed()).toBe(true); expect(subject.callbacks).toEqual([]); const replay = await finishOidcLogin(subject, cookie); expect(replay.statusCode).toBe(401); expect(subject.callbacks).toEqual([]); }); test("boundedly burns a canonical state from an oversized callback URL", async () => { const subject = fixture(); const { cookie } = await beginOidcLogin(subject); const oversized = `state=${state}&code=good&padding=${"x".repeat(4096)}`; const failed = await finishOidcLogin(subject, cookie, oversized); expect(failed.statusCode).toBe(401); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); expect(subject.stateWasConsumed()).toBe(true); expect(subject.callbacks).toEqual([]); expect((await finishOidcLogin(subject, cookie)).statusCode).toBe(401); }); test("rejects an empty direct groups claim without creating a session cookie", async () => { const subject = fixture({ identity: { issuer, subject: "user-123", groups: [], tokenExpiresAt: new Date(Date.now() + 60_000), } }); const { cookie } = await beginOidcLogin(subject); const callback = await finishOidcLogin(subject, cookie); expect(callback.statusCode).toBe(401); expectBothTransactionVariantsCleared(callback, transactionCookieName, true); expect(subject.creates).toEqual([]); expect(setCookieHeaders(callback).join("\n")).not.toContain("opaque-session-token"); }); test("clears both transaction variants when the callback state cannot be consumed", async () => { const subject = fixture(); const failed = await finishOidcLogin(subject, `${transactionCookieName}=${"x".repeat(43)}`); expect(failed.statusCode).toBe(401); expectBothTransactionVariantsCleared(failed, transactionCookieName, true); }); test("uses one captured instant for token TTL derivation and session creation", async () => { const tokenExpiresAt = new Date(Date.now() + 120_000); const subject = fixture({ identity: { issuer, subject: "user-123", groups: ["Users"], tokenExpiresAt, } }); const { cookie } = await beginOidcLogin(subject); const callback = await finishOidcLogin(subject, cookie); expect(callback.statusCode).toBe(302); expect(subject.createTimes[0]).toBeInstanceOf(Date); expect((subject.createTimes[0] as Date).getTime() + (subject.creates[0]?.absoluteTtlMs as number)) .toBe(tokenExpiresAt.getTime()); });