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]>
85 lines
4.2 KiB
TypeScript
85 lines
4.2 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { createMs365TokenProvider, Ms365TokenError } from "./ms365-oauth";
|
|
|
|
const CONFIG = { tenantId: "wabenhain.onmicrosoft.com", clientId: "11111111-2222-3333-4444-555555555555", clientSecret: "s3cr3t~value" };
|
|
|
|
function tokenResponse(token: string, expiresIn = 3600): Response {
|
|
return new Response(JSON.stringify({ token_type: "Bearer", expires_in: expiresIn, access_token: token }), { status: 200 });
|
|
}
|
|
|
|
describe("createMs365TokenProvider", () => {
|
|
it("requests a client-credentials token for Exchange Online", async () => {
|
|
const fetchImpl = vi.fn(async () => tokenResponse("tok-1"));
|
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
|
|
|
await expect(provider.getToken()).resolves.toBe("tok-1");
|
|
|
|
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
|
|
expect(url).toBe("https://login.microsoftonline.com/wabenhain.onmicrosoft.com/oauth2/v2.0/token");
|
|
expect(init.method).toBe("POST");
|
|
const body = new URLSearchParams(String(init.body));
|
|
expect(body.get("grant_type")).toBe("client_credentials");
|
|
expect(body.get("scope")).toBe("https://outlook.office365.com/.default");
|
|
expect(body.get("client_id")).toBe(CONFIG.clientId);
|
|
expect(body.get("client_secret")).toBe(CONFIG.clientSecret);
|
|
});
|
|
|
|
it("reuses the cached token until shortly before it expires", async () => {
|
|
let now = 1_000_000;
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1", 3600)).mockResolvedValueOnce(tokenResponse("tok-2", 3600));
|
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl, now: () => now });
|
|
|
|
expect(await provider.getToken()).toBe("tok-1");
|
|
now += 50 * 60 * 1000; // 50 Minuten: noch gueltig
|
|
expect(await provider.getToken()).toBe("tok-1");
|
|
now += 6 * 60 * 1000; // 56 Minuten: innerhalb der 5-Minuten-Reserve
|
|
expect(await provider.getToken()).toBe("tok-2");
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("fetches a fresh token when a renewal is forced", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1")).mockResolvedValueOnce(tokenResponse("tok-2"));
|
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
|
await provider.getToken();
|
|
expect(await provider.getToken(true)).toBe("tok-2");
|
|
});
|
|
|
|
it("shares one request between concurrent callers", async () => {
|
|
const fetchImpl = vi.fn(async () => tokenResponse("tok-1"));
|
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
|
const tokens = await Promise.all([provider.getToken(), provider.getToken(), provider.getToken()]);
|
|
expect(tokens).toEqual(["tok-1", "tok-1", "tok-1"]);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reports the AADSTS code and never the client secret", async () => {
|
|
const fetchImpl = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
error: "invalid_client",
|
|
error_description: `AADSTS7000215: Invalid client secret provided. Ensure the secret being sent is the client secret value, not the client secret ID, for a secret added to app '${CONFIG.clientId}'. Secret: ${CONFIG.clientSecret}`,
|
|
}),
|
|
{ status: 401 }
|
|
)
|
|
);
|
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
|
const error = await provider.getToken().catch((e: unknown) => e);
|
|
expect(error).toBeInstanceOf(Ms365TokenError);
|
|
expect((error as Ms365TokenError).aadCode).toBe("7000215");
|
|
expect((error as Error).message).not.toContain(CONFIG.clientSecret);
|
|
});
|
|
|
|
it("does not cache a failed request", async () => {
|
|
const fetchImpl = vi.fn().mockRejectedValueOnce(new Error("fetch failed")).mockResolvedValueOnce(tokenResponse("tok-1"));
|
|
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
|
|
await expect(provider.getToken()).rejects.toBeInstanceOf(Ms365TokenError);
|
|
await expect(provider.getToken()).resolves.toBe("tok-1");
|
|
});
|
|
|
|
it("rejects a tenant id that could change the request URL", () => {
|
|
expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "evil.com/x?" })).toThrow(Ms365TokenError);
|
|
expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "../common" })).toThrow(Ms365TokenError);
|
|
});
|
|
});
|