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 { 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 { 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 { 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; }