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
+36 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Status } from "@/components/status";
import { Symbol, Wortmarke } from "@/components/wortmarke";
import { oidcEinstellungen } from "@/server/auth/oidc";
import { aktuellerBenutzer } from "@/server/auth/zugang";
import { Anmeldeformular } from "./anmeldeformular";
@@ -11,12 +13,25 @@ function sicheresZiel(ziel: string | string[] | undefined): string {
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
}
const SSO_MELDUNGEN: Record<string, string> = {
abgelaufen: "Die SSO-Anmeldung hat zu lange gedauert. Bitte noch einmal.",
idp: "Der Identity Provider hat die Anmeldung nicht bestätigt.",
keine_gruppe: "Dein SSO-Konto ist für ProxDeploy nicht freigegeben – es fehlt die Gruppe.",
gesperrt: "Dein Konto ist gesperrt.",
name_vergeben: "Für dein SSO-Konto ließ sich kein Benutzername vergeben.",
aus: "Die Anmeldung per SSO ist nicht eingerichtet.",
};
export default async function Anmelden({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const ziel = sicheresZiel((await searchParams)["ziel"]);
const parameter = await searchParams;
const ziel = sicheresZiel(parameter["ziel"]);
const sso = oidcEinstellungen();
const ssoFehler =
typeof parameter["sso"] === "string" ? (SSO_MELDUNGEN[parameter["sso"]] ?? SSO_MELDUNGEN["idp"]) : null;
if (await aktuellerBenutzer()) redirect(ziel);
return (
@@ -29,7 +44,27 @@ export default async function Anmelden({
</h1>
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
</div>
{ssoFehler ? (
<Status art="fehler" className="mb-4 text-sm">
{ssoFehler}
</Status>
) : null}
<Anmeldeformular ziel={ziel} />
{sso ? (
<div className="mt-6 flex flex-col gap-3">
<div className="text-muted flex items-center gap-3 text-xs" aria-hidden="true">
<span className="bg-linie h-px flex-1" />
oder
<span className="bg-linie h-px flex-1" />
</div>
<a
href={`/api/v1/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center justify-center rounded-lg border px-4 text-sm font-semibold"
>
Mit {sso.anzeigename} anmelden
</a>
</div>
) : null}
</div>
</main>
);