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:
2026-10-12 16:57:50 +02:00
co-authored by Claude Opus 5.5
parent 99357d59bd
commit 6d6b0ff121
15 changed files with 871 additions and 8 deletions
+117
View File
@@ -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" });
}
}
+210
View File
@@ -0,0 +1,210 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import * as ich from "@/app/api/v1/ich/route";
import * as rueckruf from "@/app/api/v1/auth/oidc/rueckruf/route";
import * as start from "@/app/api/v1/auth/oidc/start/route";
import { oidcEinstellungen, oidcZwischenspeicherLeeren, rolleAusGruppen } from "@/server/auth/oidc";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet } from "./helpers/anfragen";
import { OidcMock } from "./oidc-mock";
const basis = {
issuer: "https://idp",
clientId: "x",
clientSecret: "",
anzeigename: "SSO",
gruppenClaim: "groups",
gruppeAdmin: "pd-admins",
gruppeOperator: "pd-ops",
};
describe("Rollen aus Gruppen", () => {
it("Admin vor Operator, ohne passende Gruppe kein Zugang, ohne konfigurierte Gruppen Operator", () => {
expect(rolleAusGruppen(basis, { sub: "a", groups: ["pd-ops", "pd-admins"] })).toBe("ADMIN");
expect(rolleAusGruppen(basis, { sub: "a", groups: "pd-ops" })).toBe("OPERATOR");
expect(rolleAusGruppen(basis, { sub: "a", groups: ["andere"] })).toBeNull();
expect(rolleAusGruppen(basis, { sub: "a" })).toBeNull();
expect(rolleAusGruppen({ ...basis, gruppeAdmin: "", gruppeOperator: "" }, { sub: "a" })).toBe("OPERATOR");
});
it("OIDC nur mit OIDC_AKTIV=true und Issuer + Client-ID", () => {
expect(
oidcEinstellungen({ OIDC_AKTIV: "false", OIDC_ISSUER: "https://i", OIDC_CLIENT_ID: "c" }),
).toBeNull();
expect(oidcEinstellungen({ OIDC_AKTIV: "true", OIDC_ISSUER: "https://i" })).toBeNull();
expect(
oidcEinstellungen({ OIDC_AKTIV: "true", OIDC_ISSUER: "https://i", OIDC_CLIENT_ID: "c" }),
).toMatchObject({
anzeigename: "SSO",
gruppenClaim: "groups",
});
});
});
describe.runIf(datenbankVerfuegbar)("Anmeldung per OIDC gegen einen Mock-IdP", () => {
const idp = new OidcMock();
const umgebung = { ...process.env };
beforeAll(async () => {
await idp.starten();
Object.assign(process.env, {
OIDC_AKTIV: "true",
OIDC_ISSUER: idp.url,
OIDC_CLIENT_ID: idp.clientId,
OIDC_CLIENT_SECRET: idp.clientSecret,
OIDC_GRUPPEN_CLAIM: "groups",
OIDC_GRUPPE_ADMIN: "pd-admins",
OIDC_GRUPPE_OPERATOR: "pd-ops",
APP_URL: "http://proxdeploy.test",
});
oidcZwischenspeicherLeeren();
});
afterAll(async () => {
process.env = umgebung;
oidcZwischenspeicherLeeren();
await idp.stoppen();
});
beforeEach(async () => {
await datenbankLeeren();
idp.manipulieren = {};
});
const cookieAus = (antwort: Response, name: string) =>
antwort.headers
.getSetCookie()
.map((c) => c.split(";")[0]!)
.find((c) => c.startsWith(`${name}=`) && c.length > name.length + 1);
/** Der ganze Weg: Start → IdP → Rückruf. Liefert die Antwort des Rückrufs. */
async function durchlaufen(
ansprueche: Record<string, unknown>,
o: { sitzung?: string; verknuepfen?: boolean; stateFaelschen?: boolean; ohneCookie?: boolean } = {},
) {
idp.naechsteAnsprueche = ansprueche;
const anfang = await start.GET(
new Request(
`http://intern:3000/api/v1/auth/oidc/start?ziel=/server${o.verknuepfen ? "&verknuepfen=1" : ""}`,
{
headers: o.sitzung ? { cookie: o.sitzung } : {},
},
),
);
expect(anfang.status).toBe(302);
const weiter = new URL(anfang.headers.get("location")!);
expect(weiter.origin).toBe(idp.url);
// Die redirect_uri ist die öffentliche Adresse (APP_URL), nicht der interne Host.
expect(weiter.searchParams.get("redirect_uri")).toBe("http://proxdeploy.test/api/v1/auth/oidc/rueckruf");
expect(weiter.searchParams.get("code_challenge_method")).toBe("S256");
const ablauf = cookieAus(anfang, "pd_oidc")!;
const beimIdp = await fetch(weiter, { redirect: "manual" });
const zurueck = new URL(beimIdp.headers.get("location")!);
if (o.stateFaelschen) zurueck.searchParams.set("state", "gefaelscht");
// Der Reverse-Proxy reicht die Anfrage mit internem Host durch.
return await rueckruf.GET(
new Request(`http://intern:3000${zurueck.pathname}${zurueck.search}`, {
headers: o.ohneCookie ? {} : { cookie: [ablauf, o.sitzung].filter(Boolean).join("; ") },
}),
);
}
it("legt beim ersten Mal ein Konto mit Rolle aus den Gruppen an und meldet an", async () => {
const antwort = await durchlaufen({
sub: "u-1",
preferred_username: "Anna.Ä",
name: "Anna Ä",
groups: ["pd-admins"],
});
expect(antwort.status).toBe(302);
expect(antwort.headers.get("location")).toBe("http://proxdeploy.test/server");
const sitzung = cookieAus(antwort, "pd_sitzung")!;
const wer = await (
await ich.GET(new Request("http://x/api/v1/ich", { headers: { cookie: sitzung } }))
).json();
expect(wer).toMatchObject({ benutzername: "anna.a", rolle: "ADMIN" });
const b = await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-1" } });
expect(b.passwortHash).toBeNull();
});
it("übernimmt bei jeder Anmeldung die Rolle aus den Gruppen und protokolliert den Wechsel", async () => {
await durchlaufen({ sub: "u-1", preferred_username: "anna", groups: ["pd-admins"] });
await durchlaufen({ sub: "u-1", preferred_username: "anna", groups: ["pd-ops"] });
expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-1" } })).rolle).toBe(
"OPERATOR",
);
const eintrag = await testPrisma.protokoll.findFirstOrThrow({
where: { aktion: "Rolle aus SSO-Gruppen übernommen" },
});
expect(eintrag.details).toEqual({ rolle: ["ADMIN", "OPERATOR"] });
expect(await testPrisma.benutzer.count()).toBe(1);
});
it("ohne passende Gruppe: kein Konto, Hinweis auf der Anmeldeseite", async () => {
const antwort = await durchlaufen({ sub: "u-2", preferred_username: "gast", groups: ["andere"] });
expect(antwort.headers.get("location")).toBe("http://proxdeploy.test/anmelden?sso=keine_gruppe");
expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined();
expect(await testPrisma.benutzer.count()).toBe(0);
});
it("übernimmt nie ein lokales Konto über den Namen", async () => {
const lokal = await angemeldet("ADMIN", "admin");
await durchlaufen({ sub: "u-3", preferred_username: "admin", groups: ["pd-ops"] });
const neu = await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-3" } });
expect(neu.benutzername).toBe("admin-2");
expect(neu.rolle).toBe("OPERATOR");
expect(
(await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).oidcSubjekt,
).toBeNull();
});
it("verknüpfen nur angemeldet; danach landet die SSO-Anmeldung im lokalen Konto", async () => {
const lokal = await angemeldet("OPERATOR", "florian");
const ohne = await start.GET(new Request("http://intern:3000/api/v1/auth/oidc/start?verknuepfen=1"));
expect(ohne.status).toBe(401);
const verknuepft = await durchlaufen(
{ sub: "u-4", preferred_username: "fw", groups: ["pd-admins"] },
{ sitzung: lokal.cookie, verknuepfen: true },
);
expect(verknuepft.headers.get("location")).toBe("http://proxdeploy.test/einstellungen");
expect(
(await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).oidcSubjekt,
).toBe("u-4");
await durchlaufen({ sub: "u-4", preferred_username: "fw", groups: ["pd-admins"] });
expect(await testPrisma.benutzer.count()).toBe(1);
expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).rolle).toBe(
"ADMIN",
);
const anderer = await angemeldet("OPERATOR", "zweiter");
const doppelt = await durchlaufen(
{ sub: "u-4", groups: ["pd-ops"] },
{ sitzung: anderer.cookie, verknuepfen: true },
);
expect(doppelt.headers.get("location")).toBe("http://proxdeploy.test/einstellungen?sso=schon_verknuepft");
});
it("gesperrt bleibt gesperrt", async () => {
await durchlaufen({ sub: "u-5", preferred_username: "max", groups: ["pd-ops"] });
await testPrisma.benutzer.update({ where: { oidcSubjekt: "u-5" }, data: { aktiv: false } });
const antwort = await durchlaufen({ sub: "u-5", preferred_username: "max", groups: ["pd-ops"] });
expect(antwort.headers.get("location")).toContain("sso=gesperrt");
expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined();
});
it.each([
["gefälschter State", { stateFaelschen: true }, {}, "idp"],
["fehlendes Ablauf-Cookie", { ohneCookie: true }, {}, "abgelaufen"],
["falsche Nonce im ID-Token", {}, { nonce: "fremd" }, "idp"],
["ID-Token für einen anderen Client", {}, { aud: "anderer-client" }, "idp"],
] as const)("%s wird abgewiesen", async (_name, optionen, manipulation, code) => {
idp.manipulieren = { ...manipulation };
const antwort = await durchlaufen(
{ sub: "u-6", preferred_username: "eve", groups: ["pd-admins"] },
optionen,
);
expect(antwort.headers.get("location")).toBe(`http://proxdeploy.test/anmelden?sso=${code}`);
expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined();
expect(await testPrisma.benutzer.count()).toBe(0);
});
});
+4
View File
@@ -13,6 +13,10 @@ const AUSNAHMEN: Record<string, string> = {
"src/app/api/v1/auth/token/route.ts":
"proxdeploy login: Passwort gegen Token – prüft das Passwort selbst (mit Sperre)",
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
"src/app/api/v1/auth/oidc/start/route.ts":
"SSO-Start ist der Weg zur Anmeldung; Verknüpfen prüft die Sitzung selbst",
"src/app/api/v1/auth/oidc/rueckruf/route.ts":
"Rückkehr vom IdP; prüft State, PKCE, Nonce und ID-Token selbst",
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
"src/app/anmelden/page.tsx": "Anmeldeseite",
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",