Files
proxdeploy/src/server/auth/oidc.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

301 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}