import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { request as httpsRequest } from "node:https"; import { afterEach, describe, expect, test } from "vitest"; import { startFakeOidcProvider } from "./fixtures/oidc-provider.mjs"; const registration = Object.freeze({ clientId: "fixture-client", clientSecret: "fixture-client-secret-not-production", redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback", }); const apiToken = "fixture-api-token-not-production"; const verifier = "fixture-pkce-verifier-0123456789-abcdefghijklmnopqrstuvwxyz"; const challenge = createHash("sha256").update(verifier).digest("base64url"); let provider; afterEach(async () => { await provider?.close(); provider = undefined; }); async function start(options = {}) { provider = await startFakeOidcProvider({ registration, apiToken, ...options }); return provider; } function exchange(target, options = {}) { return new Promise((resolve, reject) => { const body = options.body ?? ""; const request = httpsRequest(target, { method: options.method ?? "GET", ca: readFileSync(provider.caFile), headers: { accept: "application/json", ...(body.length === 0 ? {} : { "content-length": String(Buffer.byteLength(body)), "content-type": "application/x-www-form-urlencoded", }), ...options.headers, }, }, (response) => { const chunks = []; response.on("data", (chunk) => chunks.push(chunk)); response.once("error", reject); response.once("end", () => { const text = Buffer.concat(chunks).toString("utf8"); const parsed = text.length === 0 ? {} : JSON.parse(text); if (parsed && typeof parsed === "object") { if (Object.hasOwn(parsed, "access_token")) parsed.access_token = "[redacted]"; if (Object.hasOwn(parsed, "id_token")) parsed.id_token = "[redacted]"; } resolve({ status: response.statusCode ?? 0, location: response.headers.location, body: parsed, }); }); }); request.once("error", reject); request.end(body); }); } function form(entries) { return new URLSearchParams(entries).toString(); } async function authorize(overrides = {}) { const target = new URL(`${provider.issuer}authorize`); const values = { response_type: "code", client_id: registration.clientId, redirect_uri: registration.redirectUri, state: "fixture-state", nonce: "fixture-nonce", code_challenge: challenge, code_challenge_method: "S256", ...overrides, }; for (const [name, value] of Object.entries(values)) target.searchParams.set(name, value); return exchange(target); } function codeFrom(response) { return new URL(response.location).searchParams.get("code"); } function tokenBody(code, overrides = {}) { return form({ grant_type: "authorization_code", client_id: registration.clientId, client_secret: registration.clientSecret, code, redirect_uri: registration.redirectUri, code_verifier: verifier, ...overrides, }); } function deviceAuthorizationBody(overrides = {}) { return form({ client_id: registration.clientId, client_secret: registration.clientSecret, ...overrides, }); } function deviceTokenBody(deviceCode, overrides = {}) { return form({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", client_id: registration.clientId, client_secret: registration.clientSecret, device_code: deviceCode, ...overrides, }); } describe("loopback OIDC fixture security contract", () => { test("advertises only production client_secret_post and rejects missing, wrong, basic, or duplicate client credentials", async () => { await start(); const discovery = await exchange(`${provider.issuer}.well-known/openid-configuration`); expect(discovery.body.token_endpoint_auth_methods_supported).toEqual(["client_secret_post"]); const endpoint = `${provider.issuer}token`; const requests = [ form({ grant_type: "authorization_code", code: "unknown" }), form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: "wrong" }), `${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_secret=duplicate`, `${form({ grant_type: "authorization_code", code: "unknown", client_id: registration.clientId, client_secret: registration.clientSecret })}&client_id=duplicate`, form({ grant_type: "authorization_code", code: "unknown", client_id: "wrong", client_secret: registration.clientSecret }), ]; for (const body of requests) { const response = await exchange(endpoint, { method: "POST", body }); expect(response).toMatchObject({ status: 401, body: { error: "invalid_client" } }); } const basic = Buffer.from(`${registration.clientId}:${registration.clientSecret}`).toString("base64"); await expect(exchange(endpoint, { method: "POST", body: form({ grant_type: "authorization_code", code: "unknown" }), headers: { authorization: `Basic ${basic}` }, })).resolves.toMatchObject({ status: 401, body: { error: "invalid_client" } }); await expect(exchange(endpoint, { method: "POST", body: tokenBody("unknown"), })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); }); test("requires the exact Authentik bearer token", async () => { await start(); const target = `${provider.baseUrl}/api/v3/core/groups/?name=fixture-users`; await expect(exchange(target)).resolves.toMatchObject({ status: 401 }); await expect(exchange(target, { headers: { authorization: "Bearer wrong" } })) .resolves.toMatchObject({ status: 401 }); await expect(exchange(target, { headers: { authorization: `Bearer ${apiToken}` } })) .resolves.toMatchObject({ status: 200, body: { results: [{ name: "fixture-users" }] } }); }); test("binds authorization codes to registration, redirect URI, and PKCE and consumes terminal attempts", async () => { await start(); await expect(authorize({ client_id: "wrong" })).resolves.toMatchObject({ status: 400 }); await expect(authorize({ redirect_uri: "http://127.0.0.1:8787/wrong" })).resolves.toMatchObject({ status: 400 }); const redirectCode = codeFrom(await authorize()); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(redirectCode, { redirect_uri: "http://127.0.0.1:8787/wrong" }), })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(redirectCode) })) .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); const pkceCode = codeFrom(await authorize()); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(pkceCode, { code_verifier: "wrong-verifier" }), })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(pkceCode) })) .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); const successfulCode = codeFrom(await authorize()); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) })) .resolves.toMatchObject({ status: 200 }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(successfulCode) })) .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); }); test("prunes expired authorization state before enforcing a fail-closed cardinality cap", async () => { let now = 1_000; await start({ now: () => now, authorizationStateTtlMs: 1_000, authorizationStateLimit: 1 }); const first = await authorize(); expect(first.status).toBe(302); await expect(authorize({ state: "capacity" })) .resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } }); now += 1_001; await expect(authorize({ state: "after-expiry" })).resolves.toMatchObject({ status: 302 }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: tokenBody(codeFrom(first)) })) .resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); }); test("bounds device state, polling, expiry, and replay", async () => { let now = 5_000; await start({ now: () => now, deviceStateTtlMs: 1_000, deviceStateLimit: 1, devicePendingPolls: 1, devicePollLimit: 3, }); const device = await exchange(`${provider.issuer}device_authorization`, { method: "POST", body: deviceAuthorizationBody(), }); expect(device.status).toBe(200); await expect(exchange(`${provider.issuer}device_authorization`, { method: "POST", body: deviceAuthorizationBody(), })).resolves.toMatchObject({ status: 503, body: { error: "temporarily_unavailable" } }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: deviceTokenBody(device.body.device_code), })).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: deviceTokenBody(device.body.device_code), })).resolves.toMatchObject({ status: 200 }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: deviceTokenBody(device.body.device_code), })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); const expired = await exchange(`${provider.issuer}device_authorization`, { method: "POST", body: deviceAuthorizationBody(), }); now += 1_001; await expect(exchange(`${provider.issuer}device_authorization`, { method: "POST", body: deviceAuthorizationBody(), })).resolves.toMatchObject({ status: 200 }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: deviceTokenBody(expired.body.device_code), })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); }); test("deletes a device grant when its polling limit is exhausted", async () => { await start({ devicePendingPolls: 10, devicePollLimit: 2 }); const device = await exchange(`${provider.issuer}device_authorization`, { method: "POST", body: deviceAuthorizationBody(), }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: deviceTokenBody(device.body.device_code), })).resolves.toMatchObject({ status: 400, body: { error: "authorization_pending" } }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: deviceTokenBody(device.body.device_code), })).resolves.toMatchObject({ status: 400, body: { error: "expired_token" } }); await expect(exchange(`${provider.issuer}token`, { method: "POST", body: deviceTokenBody(device.body.device_code), })).resolves.toMatchObject({ status: 400, body: { error: "invalid_grant" } }); }); });