/** * OAuth 2.0 fuer den Mailversand ueber Microsoft 365 (Exchange Online SMTP * mit XOAUTH2). Client-Credentials-Flow: der Shop meldet sich als eigene * App-Registrierung in Entra ID an — kein Benutzerpasswort, kein MFA-Konflikt, * unabhaengig vom Ende der Basic-Auth fuer SMTP. Die App darf nur mit dem * Postfach senden, fuer das Exchange ihr ausdruecklich Zugriff gibt * (Berechtigung SMTP.SendAsApp + Add-MailboxPermission, siehe README). Die * Zugangsdaten stammen aus der SSO-App-Registrierung (lib/entra.ts). */ export interface Ms365OAuthConfig { tenantId: string; clientId: string; clientSecret: string; } export class Ms365TokenError extends Error { /** Fehlernummer von Entra ID ("AADSTS7000215" → "7000215"), falls vorhanden. */ readonly aadCode: string | null; constructor(message: string, aadCode: string | null = null) { super(message); this.name = "Ms365TokenError"; this.aadCode = aadCode; } } export interface Ms365TokenProvider { /** Liefert ein gueltiges Zugriffstoken; `forceRefresh` holt ein neues (nach einer Ablehnung durch den Mailserver). */ getToken(forceRefresh?: boolean): Promise; } interface ProviderOptions { fetchImpl?: typeof fetch; now?: () => number; } const SCOPE = "https://outlook.office365.com/.default"; /** Token 5 Minuten vor Ablauf erneuern — ein laufender Versand soll nie mit einem gerade abgelaufenen Token starten. */ const EXPIRY_MARGIN_MS = 5 * 60 * 1000; const REQUEST_TIMEOUT_MS = 15_000; const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i; function redact(text: string, secret: string): string { return secret ? text.split(secret).join("***") : text; } export function createMs365TokenProvider(config: Ms365OAuthConfig, options: ProviderOptions = {}): Ms365TokenProvider { // Die Tenant-ID landet im URL-Pfad: nur GUID oder Domainname, sonst // koennte ein manipulierter Wert die Anfrage an einen anderen Endpunkt lenken. if (!GUID.test(config.tenantId) && !DOMAIN.test(config.tenantId)) { throw new Ms365TokenError("ENTRA_TENANT_ID ist weder eine Tenant-GUID noch ein Domainname."); } const fetchImpl = options.fetchImpl ?? fetch; const now = options.now ?? Date.now; const url = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`; let cached: { token: string; refreshAt: number } | null = null; let inFlight: Promise | null = null; async function requestToken(): Promise { const body = new URLSearchParams({ grant_type: "client_credentials", client_id: config.clientId, client_secret: config.clientSecret, scope: SCOPE, }); let response: Response; try { response = await fetchImpl(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unbekannter Fehler"; throw new Ms365TokenError(`Entra ID nicht erreichbar: ${redact(message, config.clientSecret)}`); } const payload: unknown = await response.json().catch(() => null); const data = (payload && typeof payload === "object" ? payload : {}) as Record; if (!response.ok || typeof data.access_token !== "string") { const description = typeof data.error_description === "string" ? data.error_description : `HTTP ${response.status}`; const aadCode = /AADSTS(\d+)/.exec(description)?.[1] ?? null; // Nur die erste Zeile: Entra haengt Trace- und Korrelations-IDs an. throw new Ms365TokenError(`Token-Abruf abgelehnt: ${redact(description.split(/\r?\n/)[0], config.clientSecret)}`, aadCode); } const expiresInMs = (typeof data.expires_in === "number" ? data.expires_in : 3600) * 1000; cached = { token: data.access_token, refreshAt: now() + expiresInMs - EXPIRY_MARGIN_MS }; return data.access_token; } return { async getToken(forceRefresh = false): Promise { if (!forceRefresh && cached && now() < cached.refreshAt) return cached.token; // Parallele Mails teilen sich einen Abruf, statt Entra gleichzeitig mehrfach zu fragen. if (!inFlight) { inFlight = requestToken().finally(() => { inFlight = null; }); } return inFlight; }, }; }