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:
1 parent
302a18baa6
commit
0620e9d79a
13 files changed
+511
-88
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { explainSmtpError } from "./smtp-diagnose";
|
||||
import { Ms365TokenError } from "./ms365-oauth";
|
||||
|
||||
function smtpError(message: string, extra: Record<string, unknown> = {}): Error {
|
||||
return Object.assign(new Error(message), extra);
|
||||
@@ -55,9 +56,54 @@ describe("explainSmtpError", () => {
|
||||
});
|
||||
|
||||
it("never echoes a password that appears in the error", () => {
|
||||
const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), "geheim123");
|
||||
const result = explainSmtpError(smtpError("535 5.7.3 Authentication unsuccessful"), { secrets: ["geheim123"] });
|
||||
expect(JSON.stringify(result)).not.toContain("geheim123");
|
||||
const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), "geheim123");
|
||||
const leaking = explainSmtpError(smtpError("AUTH PLAIN failed for secret geheim123"), { secrets: ["geheim123"] });
|
||||
expect(leaking.detail).not.toContain("geheim123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("explainSmtpError with OAuth 2.0", () => {
|
||||
it("explains an invalid client secret", () => {
|
||||
const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"), {
|
||||
authMode: "oauth2",
|
||||
});
|
||||
expect(result.cause).toBe("oauth-token");
|
||||
expect(result.hint).toContain("Wert");
|
||||
});
|
||||
|
||||
it("explains an expired client secret", () => {
|
||||
const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222"), {
|
||||
authMode: "oauth2",
|
||||
});
|
||||
expect(result.cause).toBe("oauth-token");
|
||||
expect(result.hint).toContain("abgelaufen");
|
||||
});
|
||||
|
||||
it("explains an unknown app or tenant", () => {
|
||||
expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016"), { authMode: "oauth2" }).hint).toContain(
|
||||
"SMTP_OAUTH_CLIENT_ID"
|
||||
);
|
||||
expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002"), { authMode: "oauth2" }).hint).toContain(
|
||||
"SMTP_OAUTH_TENANT_ID"
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a rejected XOAUTH2 login to missing mailbox access", () => {
|
||||
const result = explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" }), { authMode: "oauth2" });
|
||||
expect(result.cause).toBe("oauth-mailbox-access");
|
||||
expect(result.hint).toContain("Add-MailboxPermission");
|
||||
});
|
||||
|
||||
it("keeps SMTP AUTH disabled as its own cause in OAuth mode", () => {
|
||||
expect(
|
||||
explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox."), { authMode: "oauth2" }).cause
|
||||
).toBe("smtp-auth-disabled");
|
||||
});
|
||||
|
||||
it("redacts the access token and client secret", () => {
|
||||
const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { authMode: "oauth2", secrets: ["eyJtoken", "secret-xyz"] });
|
||||
expect(result.detail).not.toContain("eyJtoken");
|
||||
expect(result.detail).not.toContain("secret-xyz");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user