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
+172 -37
View File
@@ -7,7 +7,13 @@
* needed to trust the provider from a spawned backend process.
*/
import { spawnSync } from "node:child_process";
import { createHash, generateKeyPairSync, randomBytes, sign as signRsa } from "node:crypto";
import {
createHash,
generateKeyPairSync,
randomBytes,
sign as signRsa,
timingSafeEqual,
} from "node:crypto";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:https";
import { tmpdir } from "node:os";
@@ -17,6 +23,9 @@ import { fileURLToPath } from "node:url";
const LOOPBACK_HOST = "127.0.0.1";
const ISSUER_PATH = "/application/o/thothii";
const MAX_BODY_BYTES = 32 * 1024;
const MAX_STATE_TTL_MS = 5 * 60 * 1_000;
const MAX_STATE_LIMIT = 1_024;
const MAX_DEVICE_POLLS = 32;
const VALID_IDENTITIES = new Set([
"ordinary",
"admin",
@@ -39,6 +48,43 @@ function safeError(code) {
return new Error(code);
}
function boundedInteger(value, fallback, maximum, code) {
const selected = value ?? fallback;
if (!Number.isSafeInteger(selected) || selected < 1 || selected > maximum) throw safeError(code);
return selected;
}
function boundedNonNegativeInteger(value, fallback, maximum, code) {
const selected = value ?? fallback;
if (!Number.isSafeInteger(selected) || selected < 0 || selected > maximum) throw safeError(code);
return selected;
}
function controlledString(value, code) {
if (typeof value !== "string" || value.length < 1 || value.length > 512 || /[\r\n]/u.test(value)) {
throw safeError(code);
}
return value;
}
function exactParameter(values, name) {
const matches = values.getAll(name);
return matches.length === 1 && matches[0].length > 0 ? matches[0] : undefined;
}
function secretMatches(actual, expected) {
if (typeof actual !== "string") return false;
const actualDigest = createHash("sha256").update(actual).digest();
const expectedDigest = createHash("sha256").update(expected).digest();
return timingSafeEqual(actualDigest, expectedDigest);
}
function pruneExpired(records, currentTime) {
for (const [key, record] of records) {
if (record.expiresAt <= currentTime) records.delete(key);
}
}
function ensurePrivateDirectory(directory) {
mkdirSync(directory, { recursive: true, mode: 0o700 });
chmodSync(directory, 0o700);
@@ -85,9 +131,9 @@ async function requestBody(request) {
return Buffer.concat(chunks).toString("utf8");
}
function jwt(privateKey, issuer, audience, nonce, identity) {
function jwt(privateKey, issuer, audience, nonce, identity, currentTime) {
const claims = identityClaims[identity];
const now = Math.floor(Date.now() / 1_000);
const now = Math.floor(currentTime / 1_000);
const payload = {
iss: issuer,
sub: claims.subject,
@@ -118,6 +164,42 @@ function authorizationIdentity(identity) {
export async function startFakeOidcProvider(options = {}) {
const host = options.host ?? LOOPBACK_HOST;
if (host !== LOOPBACK_HOST) throw safeError("oidc_fixture_loopback_required");
const registration = options.registration;
if (!registration || typeof registration !== "object") throw safeError("oidc_fixture_registration_required");
const clientId = controlledString(registration.clientId, "oidc_fixture_client_id_invalid");
const clientSecret = controlledString(registration.clientSecret, "oidc_fixture_client_secret_invalid");
const redirectUri = controlledString(registration.redirectUri, "oidc_fixture_redirect_invalid");
let parsedRedirect;
try {
parsedRedirect = new URL(redirectUri);
} catch {
throw safeError("oidc_fixture_redirect_invalid");
}
if (parsedRedirect.protocol !== "http:" || parsedRedirect.hostname !== LOOPBACK_HOST
|| parsedRedirect.username || parsedRedirect.password || parsedRedirect.hash) {
throw safeError("oidc_fixture_redirect_invalid");
}
const apiToken = controlledString(options.apiToken, "oidc_fixture_api_token_required");
const now = options.now ?? Date.now;
if (typeof now !== "function") throw safeError("oidc_fixture_clock_invalid");
const authorizationStateTtlMs = boundedInteger(
options.authorizationStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_authorization_ttl_invalid",
);
const authorizationStateLimit = boundedInteger(
options.authorizationStateLimit, 64, MAX_STATE_LIMIT, "oidc_fixture_authorization_limit_invalid",
);
const deviceStateTtlMs = boundedInteger(
options.deviceStateTtlMs, 60_000, MAX_STATE_TTL_MS, "oidc_fixture_device_ttl_invalid",
);
const deviceStateLimit = boundedInteger(
options.deviceStateLimit, 32, MAX_STATE_LIMIT, "oidc_fixture_device_limit_invalid",
);
const devicePendingPolls = boundedNonNegativeInteger(
options.devicePendingPolls, 0, MAX_DEVICE_POLLS, "oidc_fixture_device_pending_polls_invalid",
);
const devicePollLimit = boundedInteger(
options.devicePollLimit, 5, MAX_DEVICE_POLLS, "oidc_fixture_device_poll_limit_invalid",
);
const ownsDirectory = options.directory === undefined;
const directory = options.directory ?? mkdtempSync(join(tmpdir(), "thothii-oidc-fixture-"));
ensurePrivateDirectory(directory);
@@ -134,6 +216,19 @@ export async function startFakeOidcProvider(options = {}) {
let issuer = undefined;
let baseUrl = undefined;
function currentTime() {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) throw safeError("oidc_fixture_clock_invalid");
return value;
}
function authenticateClient(request, values) {
if (request.headers.authorization !== undefined) return false;
const suppliedClientId = exactParameter(values, "client_id");
const suppliedClientSecret = exactParameter(values, "client_secret");
return secretMatches(suppliedClientId, clientId) && secretMatches(suppliedClientSecret, clientSecret);
}
const server = createServer({ key: certificate.key, cert: certificate.cert }, async (request, reply) => {
try {
if (!issuer || !baseUrl || !request.url) {
@@ -154,7 +249,7 @@ export async function startFakeOidcProvider(options = {}) {
response_types_supported: ["code"],
subject_types_supported: ["public"],
grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
code_challenge_methods_supported: ["S256"],
id_token_signing_alg_values_supported: ["RS256"],
});
@@ -165,33 +260,33 @@ export async function startFakeOidcProvider(options = {}) {
return;
}
if (request.method === "GET" && path === `${ISSUER_PATH}/authorize`) {
const redirectUri = url.searchParams.get("redirect_uri");
const clientId = url.searchParams.get("client_id");
const state = url.searchParams.get("state");
const nonce = url.searchParams.get("nonce");
const challenge = url.searchParams.get("code_challenge");
if (url.searchParams.get("response_type") !== "code" || !redirectUri || !clientId || !state || !nonce
|| !challenge || url.searchParams.get("code_challenge_method") !== "S256") {
const suppliedRedirectUri = exactParameter(url.searchParams, "redirect_uri");
const suppliedClientId = exactParameter(url.searchParams, "client_id");
const state = exactParameter(url.searchParams, "state");
const nonce = exactParameter(url.searchParams, "nonce");
const challenge = exactParameter(url.searchParams, "code_challenge");
if (exactParameter(url.searchParams, "response_type") !== "code"
|| !secretMatches(suppliedRedirectUri, redirectUri)
|| !secretMatches(suppliedClientId, clientId) || !state || !nonce || !challenge
|| exactParameter(url.searchParams, "code_challenge_method") !== "S256") {
sendJson(reply, 400, { error: "invalid_request" });
return;
}
let callback;
try {
callback = new URL(redirectUri);
if (callback.protocol !== "http:" || callback.hostname !== LOOPBACK_HOST || callback.username || callback.password) {
throw safeError("oidc_fixture_redirect_invalid");
}
} catch {
sendJson(reply, 400, { error: "invalid_request" });
const reservationTime = currentTime();
pruneExpired(authorizations, reservationTime);
if (authorizations.size >= authorizationStateLimit) {
sendJson(reply, 503, { error: "temporarily_unavailable" });
return;
}
const callback = new URL(redirectUri);
const code = randomBytes(32).toString("base64url");
authorizations.set(code, {
challenge,
clientId,
redirectUri,
identity: activeIdentity,
nonce,
used: false,
expiresAt: reservationTime + authorizationStateTtlMs,
});
lastAuthorization = { identity: activeIdentity, codeChallengeMethod: "S256", pkceVerified: false };
callback.searchParams.set("code", code);
@@ -201,48 +296,80 @@ export async function startFakeOidcProvider(options = {}) {
}
if (request.method === "POST" && path === `${ISSUER_PATH}/device_authorization`) {
const values = new URLSearchParams(await requestBody(request));
const clientId = values.get("client_id");
if (!clientId) {
sendJson(reply, 400, { error: "invalid_request" });
if (!authenticateClient(request, values)) {
sendJson(reply, 401, { error: "invalid_client" });
return;
}
const reservationTime = currentTime();
pruneExpired(deviceCodes, reservationTime);
if (deviceCodes.size >= deviceStateLimit) {
sendJson(reply, 503, { error: "temporarily_unavailable" });
return;
}
const deviceCode = randomBytes(32).toString("base64url");
const userCode = "FIXTURE-CODE";
deviceCodes.set(deviceCode, { clientId, identity: activeIdentity, nonce: "device", used: false });
deviceCodes.set(deviceCode, {
clientId,
identity: activeIdentity,
nonce: "device",
expiresAt: reservationTime + deviceStateTtlMs,
polls: 0,
});
sendJson(reply, 200, {
device_code: deviceCode,
user_code: userCode,
verification_uri: `${issuer}device`,
verification_uri_complete: `${issuer}device?user_code=${userCode}`,
expires_in: 60,
expires_in: Math.max(1, Math.floor(deviceStateTtlMs / 1_000)),
interval: 1,
});
return;
}
if (request.method === "POST" && path === `${ISSUER_PATH}/token`) {
const values = new URLSearchParams(await requestBody(request));
const grantType = values.get("grant_type");
if (!authenticateClient(request, values)) {
sendJson(reply, 401, { error: "invalid_client" });
return;
}
const tokenTime = currentTime();
pruneExpired(authorizations, tokenTime);
pruneExpired(deviceCodes, tokenTime);
const grantType = exactParameter(values, "grant_type");
let record;
if (grantType === "authorization_code") {
const code = values.get("code") ?? "";
const code = exactParameter(values, "code") ?? "";
record = authorizations.get(code);
const verifier = values.get("code_verifier") ?? "";
const verified = record !== undefined && !record.used
&& createHash("sha256").update(verifier).digest("base64url") === record.challenge;
if (record !== undefined) authorizations.delete(code);
const verifier = exactParameter(values, "code_verifier") ?? "";
const suppliedRedirectUri = exactParameter(values, "redirect_uri");
const suppliedClientId = exactParameter(values, "client_id");
const verified = record !== undefined
&& secretMatches(suppliedClientId, record.clientId)
&& secretMatches(suppliedRedirectUri, record.redirectUri)
&& secretMatches(createHash("sha256").update(verifier).digest("base64url"), record.challenge);
if (!verified) {
sendJson(reply, 400, { error: "invalid_grant" });
return;
}
record.used = true;
if (lastAuthorization) lastAuthorization = { ...lastAuthorization, pkceVerified: true };
} else if (grantType === "urn:ietf:params:oauth:grant-type:device_code") {
const deviceCode = values.get("device_code") ?? "";
const deviceCode = exactParameter(values, "device_code") ?? "";
record = deviceCodes.get(deviceCode);
if (record === undefined || record.used) {
if (record === undefined) {
sendJson(reply, 400, { error: "invalid_grant" });
return;
}
record.used = true;
record.polls += 1;
if (record.polls >= devicePollLimit && record.polls <= devicePendingPolls) {
deviceCodes.delete(deviceCode);
sendJson(reply, 400, { error: "expired_token" });
return;
}
if (record.polls <= devicePendingPolls) {
sendJson(reply, 400, { error: "authorization_pending" });
return;
}
deviceCodes.delete(deviceCode);
} else {
sendJson(reply, 400, { error: "unsupported_grant_type" });
return;
@@ -251,12 +378,13 @@ export async function startFakeOidcProvider(options = {}) {
access_token: randomBytes(32).toString("base64url"),
token_type: "Bearer",
expires_in: 60,
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity),
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity, tokenTime),
});
return;
}
if (request.method === "GET" && path === "/api/v3/core/groups/") {
if (!request.headers.authorization?.startsWith("Bearer ")) {
const authorization = request.headers.authorization;
if (!secretMatches(authorization, `Bearer ${apiToken}`)) {
sendJson(reply, 401, { detail: "authentication required" });
return;
}
@@ -317,7 +445,14 @@ export async function startFakeOidcProvider(options = {}) {
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && resolve(process.argv[1]) === currentFile) {
const provider = await startFakeOidcProvider();
const provider = await startFakeOidcProvider({
registration: {
clientId: "fixture-standalone-client",
clientSecret: "fixture-standalone-secret-not-production",
redirectUri: "http://127.0.0.1:8787/api/auth/oidc/callback",
},
apiToken: "fixture-standalone-api-token-not-production",
});
process.stdout.write('{"status":"ready"}\n');
const close = async () => {
await provider.close();