255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
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" } });
|
|
});
|
|
});
|