Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
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]>
109 lines
4.4 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|