Files
florian.wildeandClaude Opus 5.5 6d6b0ff121 feat(auth): Anmeldung per OIDC mit Rollen aus Gruppen
Authorization Code mit PKCE, State und Nonce über openid-client 6.8.8; die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim. Lokale Konten werden nie über den Namen übernommen, verknüpfen geht nur angemeldet. Getestet gegen einen Mock-IdP inklusive gefälschtem State, falscher Nonce und fremder Audience.

Closes #016

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:57:50 +02:00

118 lines
5.1 KiB
TypeScript

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<string, unknown> = { 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<string, unknown> }
>();
private schluessel: Awaited<ReturnType<typeof generateKeyPair>> | undefined;
private jwk: JWK | undefined;
url = "";
async starten(): Promise<this> {
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<void>((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<void> {
await new Promise<void>((fertig) => this.server?.close(() => fertig()));
}
private async bearbeiten(
anfrage: import("node:http").IncomingMessage,
antwort: import("node:http").ServerResponse,
): Promise<void> {
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<string>((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" });
}
}