test(auth): harden Task 15 OIDC smoke evidence
This commit is contained in:
@@ -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" } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user