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:
2026-10-12 16:57:50 +02:00
co-authored by Claude Opus 5.5
parent 99357d59bd
commit 6d6b0ff121
15 changed files with 871 additions and 8 deletions
+300
View File
@@ -0,0 +1,300 @@
import { jwtVerify, SignJWT } from "jose";
import * as client from "openid-client";
import type { Benutzer, Rolle } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { protokollieren } from "@/server/services/protokoll";
/**
* Anmeldung per OpenID Connect (z. B. authentik): Authorization Code Flow mit PKCE, State und
* Nonce. Die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim – der IdP ist maßgeblich.
*
* Konten: per `oidcSubjekt` (sub) zugeordnet. Ein lokales Konto wird **nie automatisch** über den
* Benutzernamen übernommen – sonst könnte, wer beim IdP „admin“ heißt, den lokalen Admin
* übernehmen. Verknüpfen geht nur angemeldet, aus den Einstellungen heraus.
*/
export type OidcEinstellungen = {
issuer: string;
clientId: string;
clientSecret: string;
anzeigename: string;
gruppenClaim: string;
gruppeAdmin: string;
gruppeOperator: string;
};
export function oidcEinstellungen(
umgebung: Record<string, string | undefined> = process.env,
): OidcEinstellungen | null {
if (umgebung["OIDC_AKTIV"] !== "true") return null;
const issuer = umgebung["OIDC_ISSUER"]?.trim();
const clientId = umgebung["OIDC_CLIENT_ID"]?.trim();
if (!issuer || !clientId) {
logger.warn("OIDC_AKTIV=true, aber OIDC_ISSUER oder OIDC_CLIENT_ID fehlt – OIDC bleibt aus.");
return null;
}
return {
issuer,
clientId,
clientSecret: umgebung["OIDC_CLIENT_SECRET"] ?? "",
anzeigename: umgebung["OIDC_ANZEIGENAME"]?.trim() || "SSO",
gruppenClaim: umgebung["OIDC_GRUPPEN_CLAIM"]?.trim() || "groups",
gruppeAdmin: umgebung["OIDC_GRUPPE_ADMIN"]?.trim() ?? "",
gruppeOperator: umgebung["OIDC_GRUPPE_OPERATOR"]?.trim() ?? "",
};
}
let konfiguration: { schluessel: string; wert: Promise<client.Configuration> } | undefined;
/** Discovery einmal je Issuer; schlägt sie fehl, wird beim nächsten Mal neu versucht. */
async function konfigurationHolen(e: OidcEinstellungen): Promise<client.Configuration> {
const schluessel = `${e.issuer}|${e.clientId}`;
if (konfiguration?.schluessel !== schluessel) {
const url = new URL(e.issuer);
const wert = client.discovery(
url,
e.clientId,
e.clientSecret || undefined,
e.clientSecret ? undefined : client.None(),
// http nur für Tests und Labore auf localhost – sonst verlangt openid-client zu Recht https.
url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)
? { execute: [client.allowInsecureRequests] }
: undefined,
);
konfiguration = { schluessel, wert };
wert.catch(() => {
if (konfiguration?.wert === wert) konfiguration = undefined;
});
}
return await konfiguration.wert;
}
/** Nur für Tests. */
export function oidcZwischenspeicherLeeren(): void {
konfiguration = undefined;
}
// ── Zustand zwischen Start und Rückruf: kurzlebiges, signiertes Cookie ───────────────────────────
export const OIDC_COOKIE = "pd_oidc";
const OIDC_LAUFZEIT_S = 600;
type Ablauf = { verifier: string; state: string; nonce: string; ziel: string; verknuepfen: string | null };
function geheimnis(): Uint8Array {
return new TextEncoder().encode(process.env["SITZUNG_GEHEIMNIS"] ?? "");
}
export const RUECKRUF_PFAD = "/api/v1/auth/oidc/rueckruf";
/** Startet die Anmeldung: Weiterleitungsziel beim IdP und das Cookie für den Rückruf. */
export async function oidcStarten(
e: OidcEinstellungen,
rueckruf: URL,
ziel: string,
verknuepfen: string | null,
): Promise<{ weiter: URL; cookie: string }> {
const config = await konfigurationHolen(e);
const verifier = client.randomPKCECodeVerifier();
const ablauf: Ablauf = {
verifier,
state: client.randomState(),
nonce: client.randomNonce(),
ziel,
verknuepfen,
};
const weiter = client.buildAuthorizationUrl(config, {
redirect_uri: rueckruf.href,
scope: "openid profile email",
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: "S256",
state: ablauf.state,
nonce: ablauf.nonce,
});
const cookie = await new SignJWT(ablauf)
.setProtectedHeader({ alg: "HS256" })
.setAudience("proxdeploy-oidc")
.setIssuedAt()
.setExpirationTime(`${OIDC_LAUFZEIT_S}s`)
.sign(geheimnis());
return { weiter, cookie };
}
export class OidcFehler extends Error {
constructor(
readonly code: "abgelaufen" | "idp" | "keine_gruppe" | "gesperrt" | "name_vergeben" | "schon_verknuepft",
nachricht: string,
) {
super(nachricht);
this.name = "OidcFehler";
}
}
type Ansprueche = Record<string, unknown> & { sub: string };
export function rolleAusGruppen(e: OidcEinstellungen, ansprueche: Ansprueche): Rolle | null {
const roh = ansprueche[e.gruppenClaim];
const gruppen = Array.isArray(roh) ? roh.map(String) : typeof roh === "string" ? [roh] : [];
if (e.gruppeAdmin && gruppen.includes(e.gruppeAdmin)) return "ADMIN";
if (e.gruppeOperator && gruppen.includes(e.gruppeOperator)) return "OPERATOR";
// Ohne konfigurierte Gruppen genügt die Anmeldung beim IdP für „Operator“.
if (!e.gruppeAdmin && !e.gruppeOperator) return "OPERATOR";
return null;
}
/** Aus preferred_username/E-Mail einen gültigen, freien Benutzernamen machen. */
async function freierName(ansprueche: Ansprueche): Promise<string> {
const quelle = String(
ansprueche["preferred_username"] ?? String(ansprueche["email"] ?? "").split("@")[0] ?? "",
);
let basis = quelle
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9._-]/g, "-")
.replace(/^[^a-z]+/, "")
.slice(0, 28);
if (basis.length < 2) basis = "sso-benutzer";
for (let n = 0; n < 50; n++) {
const name = n === 0 ? basis : `${basis}-${n + 1}`;
if (!(await prisma.benutzer.findUnique({ where: { benutzername: name } }))) return name;
}
throw new OidcFehler("name_vergeben", "Kein freier Benutzername gefunden.");
}
/**
* Rückruf vom IdP: Code tauschen, ID-Token prüfen (Signatur, Issuer, Audience, Nonce), Konto
* finden, anlegen oder verknüpfen, Rolle aus den Gruppen setzen.
*/
export async function oidcAbschliessen(
e: OidcEinstellungen,
aktuelleUrl: URL,
cookie: string | undefined,
ip: string | null,
): Promise<{ benutzer: Benutzer; ziel: string }> {
let ablauf: Ablauf;
try {
const { payload } = await jwtVerify(cookie ?? "", geheimnis(), {
algorithms: ["HS256"],
audience: "proxdeploy-oidc",
});
ablauf = payload as unknown as Ablauf;
} catch {
throw new OidcFehler(
"abgelaufen",
"Die Anmeldung hat zu lange gedauert oder kam nicht von hier. Bitte neu starten.",
);
}
const config = await konfigurationHolen(e);
let ansprueche: Ansprueche;
try {
const antwort = await client.authorizationCodeGrant(config, aktuelleUrl, {
pkceCodeVerifier: ablauf.verifier,
expectedState: ablauf.state,
expectedNonce: ablauf.nonce,
idTokenExpected: true,
});
ansprueche = antwort.claims() as Ansprueche;
} catch (fehler) {
logger.warn({ err: fehler }, "OIDC-Rückruf abgelehnt");
throw new OidcFehler("idp", "Der Identity Provider hat die Anmeldung nicht bestätigt.");
}
const rolle = rolleAusGruppen(e, ansprueche);
const anzeigename = String(ansprueche["name"] ?? ansprueche["preferred_username"] ?? "SSO-Benutzer").slice(
0,
80,
);
const email = typeof ansprueche["email"] === "string" ? ansprueche["email"] : null;
// Verknüpfen: ein angemeldeter lokaler Benutzer hängt sein IdP-Konto an.
if (ablauf.verknuepfen) {
const vorhanden = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
if (vorhanden && vorhanden.id !== ablauf.verknuepfen) {
throw new OidcFehler(
"schon_verknuepft",
`Dieses SSO-Konto gehört schon zu „${vorhanden.benutzername}“.`,
);
}
const benutzer = await prisma.$transaction(async (tx) => {
const b = await tx.benutzer.update({
where: { id: ablauf.verknuepfen! },
data: { oidcSubjekt: ansprueche.sub },
});
await protokollieren(
{
benutzerId: b.id,
quelle: "WEB",
aktion: "SSO-Konto verknüpft",
ziel: `benutzer:${b.benutzername}`,
ip,
},
tx,
);
return b;
});
return { benutzer, ziel: "/einstellungen" };
}
if (!rolle) {
await protokollieren({
benutzerId: null,
quelle: "WEB",
aktion: "SSO-Anmeldung ohne passende Gruppe",
ziel: `oidc:${ansprueche.sub}`,
ip,
});
throw new OidcFehler(
"keine_gruppe",
"Dein SSO-Konto ist für ProxDeploy nicht freigegeben (Gruppe fehlt).",
);
}
const bekannt = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } });
if (bekannt) {
if (!bekannt.aktiv) throw new OidcFehler("gesperrt", "Dein Konto ist gesperrt.");
const benutzer = await prisma.$transaction(async (tx) => {
const b = await tx.benutzer.update({
where: { id: bekannt.id },
data: { rolle, anzeigename, ...(email ? { email } : {}) },
});
if (bekannt.rolle !== rolle) {
await protokollieren(
{
benutzerId: b.id,
quelle: "WEB",
aktion: "Rolle aus SSO-Gruppen übernommen",
ziel: `benutzer:${b.benutzername}`,
details: { rolle: [bekannt.rolle, rolle] },
ip,
},
tx,
);
}
await protokollieren({ benutzerId: b.id, quelle: "WEB", aktion: "Angemeldet (SSO)", ip }, tx);
return b;
});
return { benutzer, ziel: ablauf.ziel };
}
const benutzername = await freierName(ansprueche);
const benutzer = await prisma.$transaction(async (tx) => {
const b = await tx.benutzer.create({
data: { benutzername, anzeigename, email, rolle, oidcSubjekt: ansprueche.sub },
});
await protokollieren(
{
benutzerId: b.id,
quelle: "WEB",
aktion: "Benutzer per SSO angelegt",
ziel: `benutzer:${b.benutzername}`,
details: { rolle },
ip,
},
tx,
);
return b;
});
return { benutzer, ziel: ablauf.ziel };
}