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