464 lines
18 KiB
JavaScript
464 lines
18 KiB
JavaScript
#!/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,
|
|
timingSafeEqual,
|
|
} 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 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",
|
|
"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 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);
|
|
}
|
|
|
|
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, currentTime) {
|
|
const claims = identityClaims[identity];
|
|
const now = Math.floor(currentTime / 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 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);
|
|
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;
|
|
|
|
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) {
|
|
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_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 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;
|
|
}
|
|
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,
|
|
expiresAt: reservationTime + authorizationStateTtlMs,
|
|
});
|
|
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));
|
|
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",
|
|
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: 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));
|
|
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 = exactParameter(values, "code") ?? "";
|
|
record = authorizations.get(code);
|
|
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;
|
|
}
|
|
if (lastAuthorization) lastAuthorization = { ...lastAuthorization, pkceVerified: true };
|
|
} else if (grantType === "urn:ietf:params:oauth:grant-type:device_code") {
|
|
const deviceCode = exactParameter(values, "device_code") ?? "";
|
|
record = deviceCodes.get(deviceCode);
|
|
if (record === undefined) {
|
|
sendJson(reply, 400, { error: "invalid_grant" });
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
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, tokenTime),
|
|
});
|
|
return;
|
|
}
|
|
if (request.method === "GET" && path === "/api/v3/core/groups/") {
|
|
const authorization = request.headers.authorization;
|
|
if (!secretMatches(authorization, `Bearer ${apiToken}`)) {
|
|
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({
|
|
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();
|
|
process.exit(0);
|
|
};
|
|
process.once("SIGINT", () => { void close(); });
|
|
process.once("SIGTERM", () => { void close(); });
|
|
}
|