Anmeldung ueber Authentik und andere OIDC-Anbieter

Anmeldefluss mit PKCE (S256), State und Nonce. Das ID-Token wird gegen den
JWKS des Anbieters geprueft — Signatur, Aussteller, Empfaenger, Ablauf und
Nonce; alles Noetige kommt aus dem Discovery-Dokument, damit die Anbindung
nicht auf Authentik festgelegt ist.

Wiedererkannt wird jemand an der Kennung des Anbieters (sub), nicht an der
E-Mail-Adresse — Adressen aendern sich. Ein bestehendes Konto mit derselben
Adresse wird verknuepft statt verdoppelt, aber nur bei bestaetigter Adresse:
sonst koennte sich beim Anbieter jemand eine fremde Adresse eintragen und
damit ein hiesiges Konto uebernehmen.

Uebernommen wird bewusst nur, wer jemand ist — plus die Instanz-Administration,
wenn OIDC_ADMIN_GROUP gesetzt ist. Gruppen und Rollen bleiben Sache dieser
Anwendung: die Gruppen im Verzeichnis bedeuten etwas anderes als die hier.

Konten aus dem Verzeichnis haben kein Passwort mehr (passwordHash ist jetzt
optional); Konto-Bereich und Passwort-Anmeldung sagen das statt an einer
leeren Pruefung zu scheitern.

Anleitung mit allen Feldern in Authentik: docs/authentik.md
This commit is contained in:
Florian Wilde
2026-08-15 00:12:21 +02:00
parent e296b1e6c2
commit c8d60d8006
14 changed files with 1039 additions and 11 deletions
+64 -6
View File
@@ -5,12 +5,15 @@ import { LoginForm, SetupForm } from "@/components/auth-forms";
import { Icon } from "@/components/icons";
import { Notice } from "@/components/ui";
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
import { getOidcConfig, isOidcEnabled } from "@/lib/auth/oidc";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
export const metadata: Metadata = { title: "Anmelden" };
export const dynamic = "force-dynamic";
export default async function SignInPage() {
type PageProps = { searchParams: Promise<{ fehler?: string }> };
export default async function SignInPage({ searchParams }: PageProps) {
// Ohne Anmeldung gibt es hier nichts zu tun.
if (!isAuthEnabled()) redirect("/");
@@ -18,30 +21,85 @@ export default async function SignInPage() {
if (actor.kind === "user") redirect("/");
const setup = await isSetupOpen();
const { fehler } = await searchParams;
// Eine unvollständige OIDC-Konfiguration soll die Anmeldeseite nicht
// unbenutzbar machen — dann bleibt wenigstens das Passwortfeld.
let oidc: { label: string; nurOidc: boolean } | null = null;
let oidcFehler: string | null = null;
if (isOidcEnabled()) {
try {
const config = getOidcConfig();
oidc = { label: config.buttonLabel, nurOidc: !config.allowLocalLogin };
} catch (error) {
oidcFehler =
error instanceof Error
? error.message
: "Die Anmeldung über den externen Anbieter ist nicht eingerichtet.";
}
}
// Bei reiner Anbieter-Anmeldung gibt es keine Ersteinrichtung mit Passwort:
// Der erste, der sich über den Anbieter anmeldet, wird Administrator.
const zeigeFormular = !oidc?.nurOidc;
return (
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
<div className="text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
<Icon name={setup ? "lock" : "qr"} className="size-6" />
<Icon name={setup && zeigeFormular ? "lock" : "qr"} className="size-6" />
</span>
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
{setup ? "Ersteinrichtung" : "Anmelden"}
{setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}
</h1>
<p className="mt-2 text-sm text-slate-600">
{setup
{setup && zeigeFormular
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
</p>
</div>
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />}
{fehler ? <Notice tone="warning">{fehler}</Notice> : null}
{oidcFehler ? <Notice tone="warning">{oidcFehler}</Notice> : null}
{setup ? (
{oidc ? (
<a
href="/api/auth/oidc/start"
className="flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700"
>
<Icon name="lock" className="size-4" />
{oidc.label}
</a>
) : null}
{oidc && zeigeFormular ? (
<div className="flex items-center gap-3 text-xs text-slate-400 uppercase">
<span className="h-px flex-1 bg-slate-200" />
oder mit Passwort
<span className="h-px flex-1 bg-slate-200" />
</div>
) : null}
{zeigeFormular ? (
setup ? (
<SetupForm minLength={MIN_PASSWORD_LENGTH} />
) : (
<LoginForm />
)
) : null}
{setup && zeigeFormular ? (
<Notice tone="info">
Der erste Benutzer wird Administrator der Installation und darf
Gruppen, Benutzer und die QR-Typen verwalten.
</Notice>
) : oidc?.nurOidc ? (
<p className="text-center text-sm text-slate-500">
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>).
{setup
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
: ""}
</p>
) : (
<p className="text-center text-sm text-slate-500">
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
+21 -1
View File
@@ -7,6 +7,7 @@ import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePageActor } from "@/lib/auth/guard";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Konto" };
export const dynamic = "force-dynamic";
@@ -14,6 +15,15 @@ export const dynamic = "force-dynamic";
export default async function AccountPage() {
const actor = await requirePageActor();
// Konten aus dem externen Verzeichnis haben hier kein Passwort.
const ausVerzeichnis =
actor.kind === "user"
? ((await prisma.user.findUnique({
where: { id: actor.user.id },
select: { passwordHash: true },
}))?.passwordHash ?? null) === null
: false;
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
// wie sich das umstellen lässt, statt einfach leer zu sein.
if (actor.kind !== "user") {
@@ -123,7 +133,17 @@ export default async function AccountPage() {
{isAuthEnabled() ? (
<>
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
{ausVerzeichnis ? (
<Card>
<CardTitle>Passwort</CardTitle>
<p className="mt-2 text-sm text-slate-600">
Dieses Konto meldet sich über den externen Anbieter an. Ein
Passwort gibt es hier nicht — geändert wird es beim Anbieter.
</p>
</Card>
) : (
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
)}
<SignOutCard />
</>
) : null}
+226
View File
@@ -0,0 +1,226 @@
import type { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { isAuthEnabled } from "@/lib/auth/actor";
import {
exchangeCode,
getOidcConfig,
isOidcEnabled,
OIDC_FLOW_COOKIE,
type OidcConfig,
type OidcIdentity,
} from "@/lib/auth/oidc";
import { createSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { getBaseUrl } from "@/lib/env";
export const dynamic = "force-dynamic";
/** Zurück zur Anmeldeseite, mit einer Meldung, die dort angezeigt wird. */
function zurueckMitFehler(meldung: string): Response {
const ziel = new URL("/anmelden", getBaseUrl());
ziel.searchParams.set("fehler", meldung);
return Response.redirect(ziel.toString(), 302);
}
type FlowState = { state: string; nonce: string; codeVerifier: string };
/**
* Erkennt Fehler, deren Meldung an den Browser darf. Wie in `lib/http.ts`
* über eine Eigenschaft statt `instanceof` — die Fehlerklassen landen in
* mehreren Bündeln und hätten dort verschiedene Identitäten.
*/
function istBenutzerfehler(error: unknown): error is Error {
return (
error instanceof Error &&
(error as Error & { userFacing?: unknown }).userFacing === true
);
}
function readFlow(raw: string | undefined): FlowState | null {
if (!raw) return null;
try {
const wert = JSON.parse(raw) as Partial<FlowState>;
if (
typeof wert.state === "string" &&
typeof wert.nonce === "string" &&
typeof wert.codeVerifier === "string"
) {
return wert as FlowState;
}
} catch {
// Unbrauchbarer Cookie — behandeln wie keiner.
}
return null;
}
/**
* Legt das Konto an oder findet es wieder.
*
* Wiedererkannt wird über die Kennung des Anbieters (`sub`), nicht über die
* E-Mail-Adresse — Adressen ändern sich, die Kennung nicht. Ein bestehendes
* Konto mit derselben Adresse wird beim ersten Mal verknüpft, damit aus einem
* lokal angelegten Administrator nicht plötzlich ein zweites Konto wird.
*/
async function findeOderLegeAn(
identity: OidcIdentity,
config: OidcConfig,
): Promise<{ id: string; isActive: boolean } | { fehler: string }> {
const nachSubject = await prisma.user.findUnique({
where: { oidcSubject: identity.subject },
});
const istAdmin = config.adminGroup
? identity.groups.includes(config.adminGroup)
: undefined;
if (nachSubject) {
const aktualisiert = await prisma.user.update({
where: { id: nachSubject.id },
data: {
email: identity.email,
name: identity.name ?? nachSubject.name,
lastLoginAt: new Date(),
failedLogins: 0,
lockedUntil: null,
// Nur wenn eine Admin-Gruppe konfiguriert ist, entscheidet der
// Anbieter darüber. Sonst bleibt es bei dem, was hier eingestellt ist.
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
},
});
return { id: aktualisiert.id, isActive: aktualisiert.isActive };
}
const nachEmail = await prisma.user.findUnique({
where: { email: identity.email },
});
if (nachEmail) {
// Verknüpfen nur bei bestätigter Adresse: Sonst könnte sich beim Anbieter
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
// übernehmen.
if (!identity.emailVerified) {
return {
fehler:
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
};
}
const verknuepft = await prisma.user.update({
where: { id: nachEmail.id },
data: {
oidcSubject: identity.subject,
name: identity.name ?? nachEmail.name,
lastLoginAt: new Date(),
failedLogins: 0,
lockedUntil: null,
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
},
});
return { id: verknuepft.id, isActive: verknuepft.isActive };
}
// Neues Konto. Ohne Passwort — angemeldet wird über den Anbieter.
const standardgruppe = await prisma.group.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
const istErster = (await prisma.user.count()) === 0;
const angelegt = await prisma.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: null,
oidcSubject: identity.subject,
isInstanceAdmin: istAdmin ?? istErster,
lastLoginAt: new Date(),
memberships: standardgruppe
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
: undefined,
},
});
return { id: angelegt.id, isActive: angelegt.isActive };
}
export async function GET(request: NextRequest): Promise<Response> {
const store = await cookies();
try {
if (!isAuthEnabled() || !isOidcEnabled()) {
return zurueckMitFehler("Die Anmeldung über den Anbieter ist ausgeschaltet.");
}
const params = request.nextUrl.searchParams;
// Der Anbieter meldet einen Abbruch oder eine verweigerte Freigabe.
const fehler = params.get("error");
if (fehler) {
const beschreibung = params.get("error_description");
console.error("OIDC: Anbieter meldet Fehler:", fehler, beschreibung);
return zurueckMitFehler(
fehler === "access_denied"
? "Der Anbieter hat die Anmeldung abgelehnt. Ist dein Konto für diese Anwendung freigegeben?"
: "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
);
}
const flow = readFlow(store.get(OIDC_FLOW_COOKIE)?.value);
if (!flow) {
return zurueckMitFehler(
"Der Anmeldevorgang ist abgelaufen. Bitte noch einmal beginnen.",
);
}
const code = params.get("code");
const state = params.get("state");
if (!code || !state) {
return zurueckMitFehler("Die Antwort des Anbieters war unvollständig.");
}
if (state !== flow.state) {
// Klassischer CSRF-Schutz: Die Antwort gehört nicht zu diesem Aufruf.
return zurueckMitFehler(
"Die Antwort des Anbieters passt nicht zu diesem Anmeldevorgang.",
);
}
const config = getOidcConfig();
const identity = await exchangeCode({
config,
code,
codeVerifier: flow.codeVerifier,
nonce: flow.nonce,
});
const ergebnis = await findeOderLegeAn(identity, config);
if ("fehler" in ergebnis) {
return zurueckMitFehler(ergebnis.fehler);
}
if (!ergebnis.isActive) {
return zurueckMitFehler(
"Dieses Konto ist in der Anwendung deaktiviert. Bitte wende dich an einen Administrator.",
);
}
await createSession(ergebnis.id);
const ziel = new URL("/", getBaseUrl());
return Response.redirect(ziel.toString(), 302);
} catch (error) {
console.error("OIDC: Rückmeldung konnte nicht verarbeitet werden:", error);
// Nur Meldungen, die ausdrücklich für die Oberfläche gedacht sind —
// alles andere könnte Interna verraten.
const meldung = istBenutzerfehler(error)
? error.message
: "Die Anmeldung ist fehlgeschlagen.";
return zurueckMitFehler(meldung);
} finally {
// In jedem Fall aufräumen — der Cookie ist nur für einen Durchlauf gut.
store.delete(OIDC_FLOW_COOKIE);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { isAuthEnabled } from "@/lib/auth/actor";
import {
discover,
getOidcConfig,
isOidcEnabled,
OIDC_FLOW_COOKIE,
OIDC_FLOW_MINUTES,
redirectUri,
} from "@/lib/auth/oidc";
import { errorResponse } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
function base64url(buffer: Buffer): string {
return buffer.toString("base64url");
}
/**
* Schickt den Browser zum Anbieter.
*
* Mit PKCE (RFC 7636): Der Code allein nützt niemandem, weil beim Einlösen
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
*/
export async function GET(): Promise<Response> {
try {
if (!isAuthEnabled()) {
throw new QrRequestError(
"Die Anmeldung ist ausgeschaltet (AUTH_ENABLED=false).",
400,
);
}
if (!isOidcEnabled()) {
throw new QrRequestError(
"Die Anmeldung über einen externen Anbieter ist ausgeschaltet (OIDC_ENABLED=false).",
400,
);
}
const config = getOidcConfig();
const meta = await discover(config.issuer);
const state = base64url(randomBytes(32));
const nonce = base64url(randomBytes(32));
const codeVerifier = base64url(randomBytes(32));
const codeChallenge = base64url(
createHash("sha256").update(codeVerifier).digest(),
);
const store = await cookies();
store.set(OIDC_FLOW_COOKIE, JSON.stringify({ state, nonce, codeVerifier }), {
httpOnly: true,
// `lax` genügt: Der Anbieter schickt per GET zurück, dabei wird der
// Cookie mitgeschickt.
sameSite: "lax",
path: "/",
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
maxAge: OIDC_FLOW_MINUTES * 60,
});
const ziel = new URL(meta.authorization_endpoint);
ziel.searchParams.set("response_type", "code");
ziel.searchParams.set("client_id", config.clientId);
ziel.searchParams.set("redirect_uri", redirectUri());
ziel.searchParams.set("scope", config.scopes);
ziel.searchParams.set("state", state);
ziel.searchParams.set("nonce", nonce);
ziel.searchParams.set("code_challenge", codeChallenge);
ziel.searchParams.set("code_challenge_method", "S256");
return Response.redirect(ziel.toString(), 302);
} catch (error) {
return errorResponse(error);
}
}
+16
View File
@@ -140,6 +140,15 @@ async function handleChangePassword(
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
}
// Konten aus dem externen Verzeichnis haben hier kein Passwort — das
// wird beim Anbieter gewechselt, nicht hier.
if (!user.passwordHash) {
throw new QrRequestError(
"Dieses Konto meldet sich über den externen Anbieter an. Das Passwort wird dort geändert.",
400,
);
}
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
}
@@ -227,6 +236,13 @@ async function handleLogin(
);
}
if (!user.passwordHash) {
throw new QrRequestError(
"Dieses Konto meldet sich über den externen Anbieter an. Bitte den Knopf oben benutzen.",
403,
);
}
if (!(await verifyPassword(input.password, user.passwordHash))) {
const failedLogins = user.failedLogins + 1;
await prisma.user.update({
+281
View File
@@ -0,0 +1,281 @@
import "server-only";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { getBaseUrl } from "@/lib/env";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Anmeldung über einen externen Anbieter (OpenID Connect) — gedacht und
* getestet für Authentik, aber nicht darauf festgelegt: Alles Nötige kommt
* aus dem Discovery-Dokument des Anbieters.
*
* Bewusst begrenzt auf die Frage „wer bist du?“. Wer in welcher Gruppe welche
* Rolle hat, bleibt Sache der Verwaltung dieser Anwendung — die Rollen hier
* bedeuten etwas anderes als die Gruppen im Verzeichnis, und eine automatische
* Übersetzung wäre eine Quelle stiller Überraschungen. Einzige Ausnahme ist
* die Instanz-Administration, die sich über `OIDC_ADMIN_GROUP` an eine Gruppe
* beim Anbieter hängen lässt.
*/
export type OidcConfig = {
issuer: string;
clientId: string;
clientSecret: string;
scopes: string;
/** Anzeigename des Knopfs auf der Anmeldeseite. */
buttonLabel: string;
/** Gruppe beim Anbieter, die zur Instanz-Administration berechtigt. */
adminGroup: string | null;
/** Name des Anspruchs, in dem die Gruppen stehen. */
groupsClaim: string;
/** Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen. */
defaultRole: "ADMIN" | "EDITOR" | "VIEWER";
/** Ist die Anmeldung mit Passwort daneben noch erlaubt? */
allowLocalLogin: boolean;
};
function env(name: string): string {
return process.env[name]?.trim() ?? "";
}
/**
* Cookie, der State, Nonce und PKCE-Geheimnis über die Weiterleitung zum
* Anbieter trägt. Lebt nur für die Dauer des Anmeldevorgangs.
*/
export const OIDC_FLOW_COOKIE = "qrgen_oidc_flow";
export const OIDC_FLOW_MINUTES = 10;
export function isOidcEnabled(): boolean {
return env("OIDC_ENABLED").toLowerCase() === "true";
}
/**
* Liest die Konfiguration. Wirft mit einer Meldung, die sagt, was fehlt —
* eine halb eingerichtete Anmeldung soll nicht als „irgendwas ging schief“
* enden.
*/
export function getOidcConfig(): OidcConfig {
const issuer = env("OIDC_ISSUER").replace(/\/+$/, "");
const clientId = env("OIDC_CLIENT_ID");
const clientSecret = env("OIDC_CLIENT_SECRET");
const fehlend = [
!issuer && "OIDC_ISSUER",
!clientId && "OIDC_CLIENT_ID",
!clientSecret && "OIDC_CLIENT_SECRET",
].filter(Boolean);
if (fehlend.length > 0) {
throw new QrRequestError(
`Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet. Es fehlt: ${fehlend.join(", ")}.`,
500,
);
}
const rolle = env("OIDC_DEFAULT_ROLE").toUpperCase();
return {
issuer,
clientId,
clientSecret,
scopes: env("OIDC_SCOPES") || "openid email profile",
buttonLabel: env("OIDC_BUTTON_LABEL") || "Mit Authentik anmelden",
adminGroup: env("OIDC_ADMIN_GROUP") || null,
groupsClaim: env("OIDC_GROUPS_CLAIM") || "groups",
defaultRole:
rolle === "ADMIN" || rolle === "EDITOR" || rolle === "VIEWER"
? rolle
: "VIEWER",
allowLocalLogin: env("OIDC_ONLY").toLowerCase() !== "true",
};
}
/** Adresse, an die der Anbieter nach der Anmeldung zurückschickt. */
export function redirectUri(): string {
return `${getBaseUrl()}/api/auth/oidc/callback`;
}
type Discovery = {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
end_session_endpoint?: string;
};
/**
* Das Discovery-Dokument ändert sich praktisch nie, der Abruf kostet aber bei
* jeder Anmeldung eine Runde. Deshalb im Speicher halten — eine Stunde ist
* kurz genug, um einen Schlüsselwechsel mitzubekommen.
*/
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
let discoveryCache: { url: string; bis: number; wert: Discovery } | null = null;
export async function discover(issuer: string): Promise<Discovery> {
const url = `${issuer}/.well-known/openid-configuration`;
const jetzt = Date.now();
if (discoveryCache && discoveryCache.url === url && discoveryCache.bis > jetzt) {
return discoveryCache.wert;
}
let response: Response;
try {
response = await fetch(url, { headers: { Accept: "application/json" } });
} catch {
throw new QrRequestError(
`Der Anmelde-Anbieter ist nicht erreichbar (${issuer}). Läuft er, und stimmt OIDC_ISSUER?`,
502,
);
}
if (!response.ok) {
throw new QrRequestError(
`Der Anmelde-Anbieter liefert unter ${url} den Status ${response.status}. Stimmt OIDC_ISSUER?`,
502,
);
}
const wert = (await response.json()) as Discovery;
if (!wert.authorization_endpoint || !wert.token_endpoint || !wert.jwks_uri) {
throw new QrRequestError(
"Der Anmelde-Anbieter liefert ein unvollständiges Discovery-Dokument.",
502,
);
}
discoveryCache = { url, bis: jetzt + DISCOVERY_TTL_MS, wert };
return wert;
}
/** JWKS je Adresse zwischenspeichern — `jose` erneuert bei Bedarf selbst. */
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function jwks(uri: string) {
const vorhanden = jwksCache.get(uri);
if (vorhanden) return vorhanden;
const neu = createRemoteJWKSet(new URL(uri));
jwksCache.set(uri, neu);
return neu;
}
export type OidcIdentity = {
subject: string;
email: string;
emailVerified: boolean;
name: string | null;
groups: string[];
};
function readGroups(payload: JWTPayload, claim: string): string[] {
const wert = payload[claim];
if (Array.isArray(wert)) {
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
}
// Manche Anbieter liefern eine durch Leerzeichen getrennte Liste.
if (typeof wert === "string") return wert.split(/\s+/).filter(Boolean);
return [];
}
/**
* Tauscht den Code gegen Token und prüft das ID-Token.
*
* Geprüft wird die Signatur gegen den JWKS des Anbieters sowie Aussteller,
* Empfänger und die Nonce. Letztere schließt aus, dass ein anderswo
* erbeutetes Token hier eingereicht wird.
*/
export async function exchangeCode(options: {
config: OidcConfig;
code: string;
codeVerifier: string;
nonce: string;
}): Promise<OidcIdentity> {
const { config, code, codeVerifier, nonce } = options;
const meta = await discover(config.issuer);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri(),
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: codeVerifier,
});
const response = await fetch(meta.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body,
});
if (!response.ok) {
// Der Text des Anbieters ist für Betreiber oft hilfreich, gehört aber
// nicht ungefiltert in den Browser.
const text = await response.text().catch(() => "");
console.error("OIDC: Token-Tausch fehlgeschlagen:", response.status, text);
throw new QrRequestError(
"Die Anmeldung beim Anbieter ist fehlgeschlagen. Stimmen Client-ID und Geheimnis?",
502,
);
}
const token = (await response.json()) as { id_token?: string };
if (!token.id_token) {
throw new QrRequestError(
"Der Anbieter hat kein ID-Token geliefert. Ist der Scope „openid“ erlaubt?",
502,
);
}
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(token.id_token, jwks(meta.jwks_uri), {
issuer: meta.issuer || config.issuer,
audience: config.clientId,
}));
} catch (error) {
console.error("OIDC: ID-Token nicht gültig:", error);
throw new QrRequestError("Das Anmelde-Token des Anbieters ist ungültig.", 502);
}
if (payload.nonce !== nonce) {
throw new QrRequestError(
"Die Anmeldung passt nicht zu dieser Sitzung. Bitte versuche es erneut.",
400,
);
}
const subject = typeof payload.sub === "string" ? payload.sub : "";
const email =
typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
if (!subject) {
throw new QrRequestError("Der Anbieter hat keine Kennung geliefert.", 502);
}
if (!email) {
throw new QrRequestError(
"Der Anbieter hat keine E-Mail-Adresse geliefert. Bitte den Scope „email“ freigeben und das Attribut mitgeben.",
502,
);
}
const name =
typeof payload.name === "string" && payload.name.trim().length > 0
? payload.name.trim()
: typeof payload.preferred_username === "string"
? payload.preferred_username.trim()
: null;
return {
subject,
email,
emailVerified: payload.email_verified !== false,
name,
groups: readGroups(payload, config.groupsClaim),
};
}