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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
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" });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user