refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
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]>
This commit is contained in:
1 parent
0620e9d79a
commit
11910dd6dd
15 files changed
+211
-309
No files matched your search
@@ -7,12 +7,36 @@ function smtpError(message: string, extra: Record<string, unknown> = {}): Error
|
||||
}
|
||||
|
||||
describe("explainSmtpError", () => {
|
||||
it("recognises security defaults blocking basic auth", () => {
|
||||
const result = explainSmtpError(
|
||||
smtpError("Invalid login: 535 5.7.139 Authentication unsuccessful, user is locked by your organization's security defaults policy.", { code: "EAUTH", responseCode: 535 })
|
||||
);
|
||||
expect(result.cause).toBe("security-defaults");
|
||||
expect(result.hint).toContain("Sicherheitsstandards");
|
||||
it("explains a client secret ID used instead of its value", () => {
|
||||
const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"));
|
||||
expect(result.cause).toBe("oauth-token");
|
||||
expect(result.hint).toContain("ENTRA_CLIENT_SECRET");
|
||||
expect(result.hint).toContain("Wert");
|
||||
});
|
||||
|
||||
it("explains an expired client secret and that SSO is affected too", () => {
|
||||
const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222"));
|
||||
expect(result.cause).toBe("oauth-token");
|
||||
expect(result.hint).toContain("abgelaufen");
|
||||
expect(result.hint).toContain("Mit Microsoft anmelden");
|
||||
});
|
||||
|
||||
it("explains an unknown app or tenant", () => {
|
||||
expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016")).hint).toContain("ENTRA_CLIENT_ID");
|
||||
expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002")).hint).toContain("ENTRA_TENANT_ID");
|
||||
});
|
||||
|
||||
it("falls back to a general token hint for other AADSTS codes", () => {
|
||||
const result = explainSmtpError(new Ms365TokenError("AADSTS1234: something", "1234"));
|
||||
expect(result.cause).toBe("oauth-token");
|
||||
expect(result.hint).toContain("ENTRA_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" }));
|
||||
expect(result.cause).toBe("oauth-mailbox-access");
|
||||
expect(result.hint).toContain("SMTP.SendAsApp");
|
||||
expect(result.hint).toContain("Add-MailboxPermission");
|
||||
});
|
||||
|
||||
it("recognises SMTP AUTH disabled for the tenant or mailbox", () => {
|
||||
@@ -24,20 +48,6 @@ describe("explainSmtpError", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises MFA or conditional access rejections", () => {
|
||||
expect(
|
||||
explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, the request did not meet the criteria to be authenticated successfully.")).cause
|
||||
).toBe("mfa-or-conditional-access");
|
||||
});
|
||||
|
||||
it("recognises wrong credentials", () => {
|
||||
expect(explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" })).cause).toBe("wrong-credentials");
|
||||
});
|
||||
|
||||
it("recognises the basic auth retirement", () => {
|
||||
expect(explainSmtpError(smtpError("550 5.7.30 Basic authentication is not supported for Client Submission.")).cause).toBe("basic-auth-retired");
|
||||
});
|
||||
|
||||
it("recognises a sender address the mailbox may not use", () => {
|
||||
expect(
|
||||
explainSmtpError(smtpError("Message failed: 554 5.2.252 SendAsDenied; [email protected] not allowed to send as [email protected]")).cause
|
||||
@@ -47,7 +57,7 @@ describe("explainSmtpError", () => {
|
||||
|
||||
it("recognises network problems", () => {
|
||||
expect(explainSmtpError(smtpError("connect ETIMEDOUT 52.97.1.1:587", { code: "ETIMEDOUT" })).cause).toBe("network");
|
||||
expect(explainSmtpError(smtpError("getaddrinfo ENOTFOUND smtp.office356.com", { code: "EDNS" })).cause).toBe("network");
|
||||
expect(explainSmtpError(smtpError("getaddrinfo ENOTFOUND smtp.office365.com", { code: "EDNS" })).cause).toBe("network");
|
||||
});
|
||||
|
||||
it("falls back to unknown and never throws on odd input", () => {
|
||||
@@ -55,54 +65,8 @@ describe("explainSmtpError", () => {
|
||||
expect(explainSmtpError(null).cause).toBe("unknown");
|
||||
});
|
||||
|
||||
it("never echoes a password that appears in the error", () => {
|
||||
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"), { 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"] });
|
||||
it("redacts secrets from the echoed server message", () => {
|
||||
const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { secrets: ["eyJtoken", "secret-xyz"] });
|
||||
expect(result.detail).not.toContain("eyJtoken");
|
||||
expect(result.detail).not.toContain("secret-xyz");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user