/** * OIDC-Client fuer "Mit Microsoft anmelden" (Entra ID / Azure AD) — * OAuth2 Authorization Code Flow mit PKCE gegen die Microsoft Identity * Platform, siehe learn.microsoft.com/entra/identity-platform/ * v2-oauth2-auth-code-flow und .../v2-protocols-oidc (Stand der Recherche: * September 2026). * * Endpunkte (single-tenant, {tenant} = ENTRA_TENANT_ID): * Autorisierung: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize * Token: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token * JWKS: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys * Issuer (iss-Claim im ID-Token): https://login.microsoftonline.com/{tenant}/v2.0 * * response_type=code (reiner Authorization-Code-Flow, kein Hybrid-Flow) — * das ID-Token kommt ausschliesslich ueber den serverseitigen Token- * Endpunkt zurueck (Backchannel, per client_secret authentifiziert), nicht * ueber die Redirect-URL. Ein "nonce"-Parameter ist daher nicht noetig * (der schuetzt beim Hybrid-/Implicit-Flow vor Replay eines im Front- * channel/URL-Fragment sichtbaren ID-Tokens). */ import { randomBytes, createHash } from "node:crypto"; import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose"; export class EntraNotConfiguredError extends Error { constructor() { super( "Microsoft-Anmeldung (SSO) ist noch nicht konfiguriert. Bitte ENTRA_TENANT_ID, ENTRA_CLIENT_ID, " + "ENTRA_CLIENT_SECRET und ENTRA_REDIRECT_URI in der .env-Datei setzen (siehe .env.example)." ); this.name = "EntraNotConfiguredError"; } } export class EntraAuthError extends Error { constructor(message: string) { super(message); this.name = "EntraAuthError"; } } interface EntraConfig { tenantId: string; clientId: string; clientSecret: string; redirectUri: string; } export interface EntraAppCredentials { tenantId: string; clientId: string; clientSecret: string; } /** * Zugangsdaten der EINEN App-Registrierung in Entra ID. Sie dient zwei * Zwecken: der Anmeldung "Mit Microsoft anmelden" (dieses Modul) und dem * Mailversand ueber Microsoft 365 per OAuth 2.0 (lib/mail.ts, Berechtigung * SMTP.SendAsApp) — eine Unternehmensanwendung statt zwei. Beim Secret wird * nur ein Zeilenumbruch am Ende entfernt (typischer Fehler beim Anlegen des * Kubernetes-Secrets), sonstige Werte werden getrimmt. null, wenn etwas fehlt. */ export function readEntraAppCredentials(): EntraAppCredentials | null { const tenantId = process.env.ENTRA_TENANT_ID?.trim() ?? ""; const clientId = process.env.ENTRA_CLIENT_ID?.trim() ?? ""; const clientSecret = process.env.ENTRA_CLIENT_SECRET?.replace(/[\r\n]+$/, "") ?? ""; if (!tenantId || !clientId || !clientSecret) return null; return { tenantId, clientId, clientSecret }; } function getEntraConfig(): EntraConfig { const credentials = readEntraAppCredentials(); const redirectUri = process.env.ENTRA_REDIRECT_URI; if (!credentials || !redirectUri) { throw new EntraNotConfiguredError(); } return { ...credentials, redirectUri }; } /** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */ export function isEntraConfigured(): boolean { try { getEntraConfig(); return true; } catch { return false; } } function authorizeEndpoint(tenantId: string): string { return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; } function tokenEndpoint(tenantId: string): string { return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; } function issuer(tenantId: string): string { return `https://login.microsoftonline.com/${tenantId}/v2.0`; } function jwksUri(tenantId: string): string { return `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`; } // --------------------------------------------------------------------------- // PKCE // --------------------------------------------------------------------------- export interface PkcePair { codeVerifier: string; codeChallenge: string; } /** * code_verifier: 43-128 Zeichen aus dem PKCE-erlaubten Zeichensatz (RFC 7636). * base64url-Kodierung von 32 Zufallsbytes ergibt 43 Zeichen aus exakt diesem * Zeichensatz ([A-Za-z0-9_-]) — kein zusaetzlicher Zeichensatz-Filter noetig. * code_challenge = base64url(SHA256(code_verifier)), Methode "S256". */ export function generatePkcePair(): PkcePair { const codeVerifier = randomBytes(32).toString("base64url"); const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); return { codeVerifier, codeChallenge }; } export function generateOAuthState(): string { return randomBytes(24).toString("base64url"); } // --------------------------------------------------------------------------- // 1. Redirect zu Microsoft // --------------------------------------------------------------------------- export function buildEntraAuthorizeUrl(params: { state: string; codeChallenge: string }): string { const config = getEntraConfig(); const url = new URL(authorizeEndpoint(config.tenantId)); url.searchParams.set("client_id", config.clientId); url.searchParams.set("response_type", "code"); url.searchParams.set("redirect_uri", config.redirectUri); url.searchParams.set("response_mode", "query"); // "email" liefert den verifizierten E-Mail-Claim, "profile" Vor-/Nachname // fuer die Konto-Erstellung. Kein "offline_access" — wir brauchen keinen // Refresh-Token, es findet kein weiterer API-Zugriff im Namen des Nutzers statt. url.searchParams.set("scope", "openid profile email"); url.searchParams.set("state", params.state); url.searchParams.set("code_challenge", params.codeChallenge); url.searchParams.set("code_challenge_method", "S256"); // Zeigt immer den Konto-Auswahldialog statt automatischem Single-Sign-On // auf ein evtl. im Browser noch aktives fremdes Microsoft-Konto. url.searchParams.set("prompt", "select_account"); return url.toString(); } // --------------------------------------------------------------------------- // 2. Code gegen Tokens tauschen // --------------------------------------------------------------------------- interface TokenResponse { id_token: string; access_token?: string; token_type?: string; expires_in?: number; } export async function exchangeEntraAuthorizationCode(params: { code: string; codeVerifier: string; }): Promise { const config = getEntraConfig(); let response: Response; try { response = await fetch(tokenEndpoint(config.tenantId), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, grant_type: "authorization_code", code: params.code, redirect_uri: config.redirectUri, code_verifier: params.codeVerifier, scope: "openid profile email", }), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler"; throw new EntraAuthError(`Microsoft-Token-Endpunkt nicht erreichbar: ${message}`); } let payload: Record | undefined; try { payload = await response.json(); } catch { payload = undefined; } if (!response.ok) { const detail = (typeof payload?.error_description === "string" && payload.error_description) || (typeof payload?.error === "string" && payload.error) || `HTTP ${response.status}`; throw new EntraAuthError(`Microsoft-Anmeldung fehlgeschlagen: ${detail}`); } if (!payload || typeof payload.id_token !== "string") { throw new EntraAuthError("Microsoft-Antwort enthielt kein ID-Token."); } return { id_token: payload.id_token, access_token: typeof payload.access_token === "string" ? payload.access_token : undefined, token_type: typeof payload.token_type === "string" ? payload.token_type : undefined, expires_in: typeof payload.expires_in === "number" ? payload.expires_in : undefined, }; } // --------------------------------------------------------------------------- // 3. ID-Token verifizieren (Signatur + Claims) — NIE ungeprueft vertrauen // --------------------------------------------------------------------------- export interface EntraIdTokenClaims extends JWTPayload { email?: string; preferred_username?: string; given_name?: string; family_name?: string; name?: string; oid?: string; tid?: string; } // Ein JWKSet pro Tenant reicht (dieses Projekt bindet genau einen Tenant an) — // jose cached/erneuert die Schluessel intern automatisch (Key-Rollover). let cachedJwks: ReturnType | null = null; let cachedJwksTenantId: string | null = null; function getJwks(tenantId: string): ReturnType { if (cachedJwks && cachedJwksTenantId === tenantId) return cachedJwks; cachedJwks = createRemoteJWKSet(new URL(jwksUri(tenantId))); cachedJwksTenantId = tenantId; return cachedJwks; } /** * Verifiziert Signatur, issuer, audience und Ablauf des ID-Tokens gegen * Microsofts oeffentliche Signaturschluessel (JWKS) — niemals ein Token * ungeprueft dekodieren und seinen Claims vertrauen, sonst koennte jeder ein * beliebiges Konto durch ein selbstgebasteltes JWT vortaeuschen. Gibt bei * Erfolg die geprueften Claims zurueck, wirft sonst EntraAuthError. */ export async function verifyEntraIdToken(idToken: string): Promise { const config = getEntraConfig(); try { const { payload } = await jwtVerify(idToken, getJwks(config.tenantId), { issuer: issuer(config.tenantId), audience: config.clientId, algorithms: ["RS256"], }); return payload; } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Fehler"; throw new EntraAuthError(`ID-Token-Prüfung fehlgeschlagen: ${message}`); } } /** * Extrahiert die verifizierte E-Mail-Adresse aus den ID-Token-Claims. * Bewusst NUR der "email"-Claim (aus dem signierten Token, nie ein vom * Client mitgeschicktes Feld) — "preferred_username" ist bei Microsoft- * Konten zwar meist ebenfalls die E-Mail/UPN, aber laut Microsofts eigener * Dokumentation nicht garantiert ident/verifiziert und kann sich zudem * aendern. Fehlt der email-Claim (z.B. Konto ohne hinterlegte Mailbox), * wird die Anmeldung bewusst abgelehnt statt zu raten. */ export function getVerifiedEmail(claims: EntraIdTokenClaims): string | null { if (typeof claims.email === "string" && claims.email.includes("@")) { return claims.email.trim().toLowerCase(); } return null; }