feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)

Sending no longer needs a user password for Microsoft 365. When
SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET
are set, the shop fetches an app-only token from Entra ID and signs in to
smtp.office365.com with XOAUTH2. This works with security defaults and
MFA, survives the end of SMTP basic auth (default off from end of
December 2026) and limits the app to the one mailbox Exchange grants it
(SMTP.SendAsApp + Add-MailboxPermission). Password login stays available
for other providers; a partial OAuth setup never silently falls back.

- lib/ms365-oauth.ts: token cached until 5 minutes before expiry,
  concurrent requests share one fetch, forced refresh after a server
  rejection, 15 s timeout, tenant id validated before it enters the URL,
  client secret redacted from every error.
- lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of
  value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant)
  and maps a rejected XOAUTH2 login to missing mailbox access.
- Admin e-mail test shows the auth mode and lists missing settings.
- k8s manifests, preflight check and README (step-by-step Entra and
  Exchange setup) updated; misplaced SMTP_PORT comment moved.

Verified against Entra ID and smtp.office365.com with fake values:
AADSTS90002 is classified as tenant error without leaking the secret,
an invalid token yields 535 5.7.3 and is classified as mailbox access.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 18:17:33 +02:00
1 parent 302a18baa6
commit 0620e9d79a
13 files changed
+511 -88

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
/**
* 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).
*/
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<string>;
}
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("SMTP_OAUTH_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<string> | null = null;
async function requestToken(): Promise<string> {
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<string, unknown>;
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<string> {
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;
},
};
}