import { createHash, randomBytes } from "node:crypto"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { exportJWK, generateKeyPair, SignJWT, type JWK } from "jose"; /** * Ein Identity Provider für Tests: Discovery, JWKS, Autorisierung (leitet sofort mit Code zurück, * als hätte sich jemand angemeldet) und Token-Endpunkt mit PKCE-Prüfung und RS256-ID-Token. * `naechsteAnsprueche` legt fest, wer sich „anmeldet“; `manipulieren` verfälscht Antworten. */ export class OidcMock { readonly clientId = "proxdeploy"; readonly clientSecret = "geheim-fuer-tests"; naechsteAnsprueche: Record = { sub: "u-1", preferred_username: "anna" }; manipulieren: { nonce?: string; aud?: string } = {}; private server: Server | undefined; private codes = new Map< string, { nonce: string; challenge: string; redirect: string; ansprueche: Record } >(); private schluessel: Awaited> | undefined; private jwk: JWK | undefined; url = ""; async starten(): Promise { this.schluessel = await generateKeyPair("RS256"); this.jwk = { ...(await exportJWK(this.schluessel.publicKey)), kid: "k1", alg: "RS256", use: "sig" }; this.server = createServer((anfrage, antwort) => void this.bearbeiten(anfrage, antwort)); await new Promise((fertig) => this.server!.listen(0, "127.0.0.1", fertig)); this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`; return this; } async stoppen(): Promise { await new Promise((fertig) => this.server?.close(() => fertig())); } private async bearbeiten( anfrage: import("node:http").IncomingMessage, antwort: import("node:http").ServerResponse, ): Promise { const url = new URL(anfrage.url ?? "/", this.url); const json = (status: number, daten: unknown) => { antwort.writeHead(status, { "content-type": "application/json" }); antwort.end(JSON.stringify(daten)); }; if (url.pathname === "/.well-known/openid-configuration") { return json(200, { issuer: this.url, authorization_endpoint: `${this.url}/authorize`, token_endpoint: `${this.url}/token`, jwks_uri: `${this.url}/jwks`, response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], }); } if (url.pathname === "/jwks") return json(200, { keys: [this.jwk] }); if (url.pathname === "/authorize") { const p = url.searchParams; if (p.get("client_id") !== this.clientId || p.get("code_challenge_method") !== "S256") { return json(400, { error: "invalid_request" }); } const code = randomBytes(16).toString("hex"); this.codes.set(code, { nonce: p.get("nonce") ?? "", challenge: p.get("code_challenge") ?? "", redirect: p.get("redirect_uri") ?? "", ansprueche: this.naechsteAnsprueche, }); const zurueck = new URL(p.get("redirect_uri")!); zurueck.searchParams.set("code", code); zurueck.searchParams.set("state", p.get("state") ?? ""); antwort.writeHead(302, { location: zurueck.href }); antwort.end(); return; } if (url.pathname === "/token" && anfrage.method === "POST") { const rumpf = new URLSearchParams( await new Promise((fertig) => { let text = ""; anfrage.on("data", (stueck) => (text += stueck)); anfrage.on("end", () => fertig(text)); }), ); const erwartet = `Basic ${Buffer.from(`${encodeURIComponent(this.clientId)}:${encodeURIComponent(this.clientSecret)}`).toString("base64")}`; // Beide gängigen Arten der Client-Anmeldung, wie authentik und Keycloak auch. const perPost = rumpf.get("client_id") === this.clientId && rumpf.get("client_secret") === this.clientSecret; if (anfrage.headers.authorization !== erwartet && !perPost) return json(401, { error: "invalid_client" }); const eintrag = this.codes.get(rumpf.get("code") ?? ""); this.codes.delete(rumpf.get("code") ?? ""); const challenge = createHash("sha256") .update(rumpf.get("code_verifier") ?? "") .digest("base64url"); if (!eintrag || eintrag.challenge !== challenge || eintrag.redirect !== rumpf.get("redirect_uri")) { return json(400, { error: "invalid_grant" }); } const idToken = await new SignJWT({ ...eintrag.ansprueche, nonce: this.manipulieren.nonce ?? eintrag.nonce, }) .setProtectedHeader({ alg: "RS256", kid: "k1" }) .setIssuer(this.url) .setAudience(this.manipulieren.aud ?? this.clientId) .setSubject(String(eintrag.ansprueche["sub"])) .setIssuedAt() .setExpirationTime("5m") .sign(this.schluessel!.privateKey); return json(200, { access_token: "zugang", token_type: "Bearer", expires_in: 300, id_token: idToken }); } json(404, { error: "not_found" }); } }