Files
Wabenhain/src/lib/ms365-oauth.ts
T
Nick SzaunigandClaude Opus 5.5 11910dd6dd
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
refactor(mail): send via the SSO app registration, drop SMTP basic auth
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:23:05 +02:00

109 lines
4.4 KiB
TypeScript

/**
* 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<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("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<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;
},
};
}