feat: SSO-Login per OpenID Connect (Authentik u.a.)

Dritte Login-Methode neben Passwort und Passkey, konfiguriert über
OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (optional, Button bleibt
ohne diese Vars ausgeblendet). Authorization-Code-Flow mit PKCE,
ID-Token-Signaturprüfung gegen das JWKS des Providers via neuer
Abhängigkeit "jose" (RS256 fest gepinnt gegen Algorithm-Confusion),
State/Nonce kurzlebig und einmalig verwendbar in neuer Tabelle
oidc_states (Migration 0003, analog zu webauthn_challenges).

Funktioniert ausschließlich für bereits angelegte Benutzer (Abgleich
per E-Mail) – kein Auto-Provisioning, keine Rollenübernahme aus
Claims, beides bleibt exklusiv über die bestehende Benutzerverwaltung
steuerbar.

app/login/page.tsx zu Server-Component-Wrapper reduziert (prüft
isConfigured(), liest ?error= aus searchParams), bisheriger Inhalt
nach components/LoginForm.tsx verschoben.

Version 1.9.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:29:57 +02:00
co-authored by Claude Sonnet 5
parent 73cbda65a1
commit 32ef93ae8e
15 changed files with 533 additions and 144 deletions
+99
View File
@@ -0,0 +1,99 @@
import "server-only";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { clientId, clientSecret, issuer, redirectUri } from "./oidc-config";
export interface OidcDiscovery {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
userinfo_endpoint?: string;
}
// Kein Caching des Discovery-Dokuments: Login ist bei ~250 Mitarbeitern
// kein Hot Path, ein zusätzlicher HTTPS-Request pro Anmeldeversuch
// fällt nicht ins Gewicht – vermeidet Cache-Invalidierungslogik ohne
// echten Bedarf.
export async function discover(): Promise<OidcDiscovery> {
const res = await fetch(`${issuer().replace(/\/$/, "")}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`oidc_discovery_failed_${res.status}`);
return (await res.json()) as OidcDiscovery;
}
export function buildAuthorizeUrl(
discovery: OidcDiscovery,
params: { state: string; nonce: string; codeChallenge: string }
): string {
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", clientId());
url.searchParams.set("redirect_uri", redirectUri());
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("state", params.state);
url.searchParams.set("nonce", params.nonce);
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
export async function exchangeCode(
discovery: OidcDiscovery,
code: string,
codeVerifier: string
): Promise<{ idToken: string; accessToken: string | null }> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId(),
client_secret: clientSecret(),
redirect_uri: redirectUri(),
code,
code_verifier: codeVerifier,
});
const res = await fetch(discovery.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) throw new Error(`oidc_token_exchange_failed_${res.status}`);
const data = (await res.json()) as { id_token?: string; access_token?: string };
if (!data.id_token) throw new Error("oidc_missing_id_token");
return { idToken: data.id_token, accessToken: data.access_token ?? null };
}
// Algorithmus bewusst auf RS256 festgenagelt (Authentik-Standard) statt
// dem Token zu vertrauen, welchen Algorithmus es selbst behauptet zu
// nutzen ("algorithm confusion"-Angriffsklasse bei JWTs).
export async function verifyIdToken(
discovery: OidcDiscovery,
idToken: string,
expectedNonce: string
): Promise<JWTPayload> {
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
const { payload } = await jwtVerify(idToken, jwks, {
issuer: issuer(),
audience: clientId(),
algorithms: ["RS256"],
});
if (payload.nonce !== expectedNonce) {
throw new Error("oidc_nonce_mismatch");
}
if (payload.email_verified === false) {
throw new Error("oidc_email_not_verified");
}
return payload;
}
/** E-Mail bevorzugt aus dem ID-Token, sonst Fallback auf den Userinfo-Endpoint. */
export async function resolveEmail(
discovery: OidcDiscovery,
payload: JWTPayload,
accessToken: string | null
): Promise<string | null> {
if (typeof payload.email === "string" && payload.email) return payload.email;
if (!accessToken || !discovery.userinfo_endpoint) return null;
const res = await fetch(discovery.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
const info = (await res.json()) as { email?: string };
return typeof info.email === "string" && info.email ? info.email : null;
}
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
// SSO-Konfiguration wird direkt aus Env-Vars abgeleitet – kein
// Cloud-Konfigurationsschritt, analog zu lib/auth/webauthn-config.ts.
// "Aktiviert" ergibt sich implizit aus dem Vorhandensein der drei
// Pflicht-Vars (wie bei DEEPL_API_KEY), kein separates Enable-Flag.
export function isConfigured(): boolean {
return !!(
process.env.OIDC_ISSUER &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET
);
}
export function issuer(): string {
return process.env.OIDC_ISSUER!;
}
export function clientId(): string {
return process.env.OIDC_CLIENT_ID!;
}
export function clientSecret(): string {
return process.env.OIDC_CLIENT_SECRET!;
}
// Bewusst aus APP_URL abgeleitet statt aus Request-Headern (wie z. B.
// proxy.ts es für den Login-Redirect tut): der Wert muss exakt mit der
// beim Identity Provider hinterlegten Redirect-URI übereinstimmen,
// darf sich also nicht je nach eingehendem Host-Header ändern.
export function redirectUri(): string {
const appUrl = new URL(process.env.APP_URL ?? "http://localhost:3000");
return `${appUrl.origin}/api/auth/oidc/callback`;
}
+31
View File
@@ -0,0 +1,31 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
const STATE_TTL_MS = 10 * 60 * 1000;
export async function storeState(
state: string,
nonce: string,
codeVerifier: string
): Promise<void> {
// Kein Cron nötig: abgelaufene Zeilen bei jedem neuen State gleich
// mit aufräumen (wie bei webauthn_challenges).
await query("delete from oidc_states where expires_at < now()");
await query(
"insert into oidc_states (state, nonce, code_verifier, expires_at) values ($1, $2, $3, $4)",
[state, nonce, codeVerifier, new Date(Date.now() + STATE_TTL_MS)]
);
}
/** Einmalig verwendbar: die Zeile wird beim Abruf sofort gelöscht. */
export async function consumeState(
state: string
): Promise<{ nonce: string; codeVerifier: string } | null> {
const row = await queryOne<{ nonce: string; code_verifier: string }>(
`delete from oidc_states
where state = $1 and expires_at > now()
returning nonce, code_verifier`,
[state]
);
return row ? { nonce: row.nonce, codeVerifier: row.code_verifier } : null;
}