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