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>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
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 };
|
||
}
|