test(auth): harden Task 15 OIDC smoke evidence

This commit is contained in:
2026-08-18 06:33:48 +02:00
parent 8a3fa5031d
commit 2b618c5a0f
5 changed files with 758 additions and 82 deletions
+254
View File
@@ -0,0 +1,254 @@
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" } });
});
});