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" });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user