test(auth): gate local and OIDC authentication release

This commit is contained in:
2026-08-18 06:02:25 +02:00
parent 7cf7d9db6b
commit 8a3fa5031d
23 changed files with 1711 additions and 168 deletions
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env node
/**
* Hermetic loopback OIDC/AuthentiK-shaped provider for browser smoke tests.
*
* It deliberately has no network dependency and binds only to 127.0.0.1. Its
* ephemeral TLS and signing keys are test-scoped; callers receive the CA path
* 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 { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:https";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
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 VALID_IDENTITIES = new Set([
"ordinary",
"admin",
"missing-groups",
"malformed-groups",
"unmapped",
"expired",
]);
const identityClaims = Object.freeze({
ordinary: { subject: "fixture-ordinary", displayName: "Fixture ordinary", groups: ["fixture-users"] },
admin: { subject: "fixture-admin", displayName: "Fixture administrator", groups: ["fixture-admin"] },
"missing-groups": { subject: "fixture-missing-groups", displayName: "Fixture missing groups" },
"malformed-groups": { subject: "fixture-malformed-groups", displayName: "Fixture malformed groups", groups: ["fixture-users", 7] },
unmapped: { subject: "fixture-unmapped", displayName: "Fixture unmapped", groups: ["fixture-unmapped"] },
expired: { subject: "fixture-expired", displayName: "Fixture expired", groups: ["fixture-users"], expired: true },
});
function safeError(code) {
return new Error(code);
}
function ensurePrivateDirectory(directory) {
mkdirSync(directory, { recursive: true, mode: 0o700 });
chmodSync(directory, 0o700);
}
function createCertificate(directory) {
const keyFile = join(directory, "provider-key.pem");
const certificateFile = join(directory, "provider-ca.pem");
const result = spawnSync("openssl", [
"req", "-x509", "-newkey", "rsa:2048", "-sha256", "-nodes",
"-keyout", keyFile,
"-out", certificateFile,
"-subj", "/CN=127.0.0.1",
"-addext", "subjectAltName=IP:127.0.0.1",
"-days", "1",
], { stdio: "ignore" });
if (result.status !== 0) throw safeError("oidc_fixture_certificate_generation_failed");
chmodSync(keyFile, 0o600);
chmodSync(certificateFile, 0o600);
return { key: readFileSync(keyFile), cert: readFileSync(certificateFile), certificateFile };
}
function sendJson(reply, status, value) {
reply.writeHead(status, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
});
reply.end(JSON.stringify(value));
}
function redirect(reply, location) {
reply.writeHead(302, { "cache-control": "no-store", location });
reply.end();
}
async function requestBody(request) {
let size = 0;
const chunks = [];
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw safeError("oidc_fixture_request_too_large");
chunks.push(chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
function jwt(privateKey, issuer, audience, nonce, identity) {
const claims = identityClaims[identity];
const now = Math.floor(Date.now() / 1_000);
const payload = {
iss: issuer,
sub: claims.subject,
aud: audience,
exp: now + (claims.expired ? -30 : 60),
iat: now - 1,
nonce,
name: claims.displayName,
...(Object.hasOwn(claims, "groups") ? { groups: claims.groups } : {}),
};
const header = { alg: "RS256", typ: "JWT", kid: "fixture-rs256" };
const protectedPart = Buffer.from(JSON.stringify(header)).toString("base64url");
const payloadPart = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${protectedPart}.${payloadPart}`;
const signature = signRsa("RSA-SHA256", Buffer.from(signingInput), privateKey).toString("base64url");
return `${signingInput}.${signature}`;
}
function authorizationIdentity(identity) {
if (!VALID_IDENTITIES.has(identity)) throw safeError("oidc_fixture_identity_invalid");
return identity;
}
/**
* Start an HTTPS test provider. The returned telemetry intentionally excludes
* transient authorization codes, browser state, nonces, and token material.
*/
export async function startFakeOidcProvider(options = {}) {
const host = options.host ?? LOOPBACK_HOST;
if (host !== LOOPBACK_HOST) throw safeError("oidc_fixture_loopback_required");
const ownsDirectory = options.directory === undefined;
const directory = options.directory ?? mkdtempSync(join(tmpdir(), "thothii-oidc-fixture-"));
ensurePrivateDirectory(directory);
const certificate = createCertificate(directory);
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const publicJwk = publicKey.export({ format: "jwk" });
const jwks = {
keys: [{ ...publicJwk, alg: "RS256", kid: "fixture-rs256", use: "sig" }],
};
const authorizations = new Map();
const deviceCodes = new Map();
let activeIdentity = authorizationIdentity(options.identity ?? "ordinary");
let lastAuthorization = undefined;
let issuer = undefined;
let baseUrl = undefined;
const server = createServer({ key: certificate.key, cert: certificate.cert }, async (request, reply) => {
try {
if (!issuer || !baseUrl || !request.url) {
sendJson(reply, 503, { error: "temporarily_unavailable" });
return;
}
const url = new URL(request.url, baseUrl);
const path = url.pathname;
const discoveryPath = `${ISSUER_PATH}/.well-known/openid-configuration`;
const rfc8414Path = `/.well-known/openid-configuration${ISSUER_PATH}`;
if (request.method === "GET" && (path === discoveryPath || path === rfc8414Path)) {
sendJson(reply, 200, {
issuer,
authorization_endpoint: `${issuer}authorize`,
token_endpoint: `${issuer}token`,
jwks_uri: `${issuer}jwks`,
device_authorization_endpoint: `${issuer}device_authorization`,
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"],
code_challenge_methods_supported: ["S256"],
id_token_signing_alg_values_supported: ["RS256"],
});
return;
}
if (request.method === "GET" && path === `${ISSUER_PATH}/jwks`) {
sendJson(reply, 200, jwks);
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") {
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" });
return;
}
const code = randomBytes(32).toString("base64url");
authorizations.set(code, {
challenge,
clientId,
identity: activeIdentity,
nonce,
used: false,
});
lastAuthorization = { identity: activeIdentity, codeChallengeMethod: "S256", pkceVerified: false };
callback.searchParams.set("code", code);
callback.searchParams.set("state", state);
redirect(reply, callback.href);
return;
}
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" });
return;
}
const deviceCode = randomBytes(32).toString("base64url");
const userCode = "FIXTURE-CODE";
deviceCodes.set(deviceCode, { clientId, identity: activeIdentity, nonce: "device", used: false });
sendJson(reply, 200, {
device_code: deviceCode,
user_code: userCode,
verification_uri: `${issuer}device`,
verification_uri_complete: `${issuer}device?user_code=${userCode}`,
expires_in: 60,
interval: 1,
});
return;
}
if (request.method === "POST" && path === `${ISSUER_PATH}/token`) {
const values = new URLSearchParams(await requestBody(request));
const grantType = values.get("grant_type");
let record;
if (grantType === "authorization_code") {
const code = values.get("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 (!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") ?? "";
record = deviceCodes.get(deviceCode);
if (record === undefined || record.used) {
sendJson(reply, 400, { error: "invalid_grant" });
return;
}
record.used = true;
} else {
sendJson(reply, 400, { error: "unsupported_grant_type" });
return;
}
sendJson(reply, 200, {
access_token: randomBytes(32).toString("base64url"),
token_type: "Bearer",
expires_in: 60,
id_token: jwt(privateKey, issuer, record.clientId, record.nonce, record.identity),
});
return;
}
if (request.method === "GET" && path === "/api/v3/core/groups/") {
if (!request.headers.authorization?.startsWith("Bearer ")) {
sendJson(reply, 401, { detail: "authentication required" });
return;
}
const name = url.searchParams.get("name") ?? "";
const present = name === "fixture-users" || name === "fixture-admin";
sendJson(reply, 200, {
pagination: { next: null },
results: present ? [{ name }] : [],
});
return;
}
sendJson(reply, 404, { error: "not_found" });
} catch {
if (!reply.headersSent) sendJson(reply, 400, { error: "invalid_request" });
else reply.end();
}
});
try {
await new Promise((resolveListen, rejectListen) => {
const onError = (error) => rejectListen(error);
server.once("error", onError);
server.listen({ host, port: options.port ?? 0 }, () => {
server.off("error", onError);
resolveListen();
});
});
} catch (error) {
server.close();
if (ownsDirectory) rmSync(directory, { recursive: true, force: true });
throw error;
}
const address = server.address();
if (!address || typeof address === "string") {
await new Promise((resolveClose) => server.close(resolveClose));
if (ownsDirectory) rmSync(directory, { recursive: true, force: true });
throw safeError("oidc_fixture_listen_failed");
}
baseUrl = `https://${LOOPBACK_HOST}:${address.port}`;
issuer = `${baseUrl}${ISSUER_PATH}/`;
return {
baseUrl,
issuer,
caFile: certificate.certificateFile,
setIdentity(identity) {
activeIdentity = authorizationIdentity(identity);
},
lastAuthorization() {
return lastAuthorization === undefined ? undefined : { ...lastAuthorization };
},
async close() {
await new Promise((resolveClose) => server.close(resolveClose));
if (ownsDirectory) rmSync(directory, { recursive: true, force: true });
},
};
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && resolve(process.argv[1]) === currentFile) {
const provider = await startFakeOidcProvider();
process.stdout.write('{"status":"ready"}\n');
const close = async () => {
await provider.close();
process.exit(0);
};
process.once("SIGINT", () => { void close(); });
process.once("SIGTERM", () => { void close(); });
}