Files
proxdeploy/tests/oidc.test.ts
T
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

211 lines
9.1 KiB
TypeScript

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);
});
});