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>
100 lines
3.6 KiB
TypeScript
100 lines
3.6 KiB
TypeScript
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;
|
||
}
|