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>
211 lines
9.1 KiB
TypeScript
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);
|
|
});
|
|
});
|