diff --git a/.changeset/warn-on-missing-external-delivery.md b/.changeset/warn-on-missing-external-delivery.md new file mode 100644 index 0000000..c84594a --- /dev/null +++ b/.changeset/warn-on-missing-external-delivery.md @@ -0,0 +1,5 @@ +--- +"@seamless-auth/express": patch +--- + +Warn when external delivery is requested but the auth server returns no delivery payload. The four auth-message routes (OTP email, OTP SMS, magic-link email, bootstrap invite email) previously fell through to a plain success response in that case, so a `serviceSecret` that does not match the auth server's `API_SERVICE_TOKEN` produced a successful-looking response with no message sent and nothing logged. The delivery branch shared by those routes is now a single `applyExternalDelivery` helper that logs a warning on a missing payload. Response bodies and status codes are unchanged. The messaging section of the README now documents `serviceSecret` as a prerequisite for auth-message delivery. diff --git a/packages/express/README.md b/packages/express/README.md index ffd1603..e96272b 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -281,6 +281,8 @@ This currently applies to: - magic-link email - bootstrap invite email +External delivery is a credentialed request. The adapter mints a short-lived service token from `serviceSecret`, and the auth server withholds the delivery payload if that token does not validate. `serviceSecret` must therefore match the auth server's `API_SERVICE_TOKEN` in every environment, including local development. If it does not, auth-message routes still return a success response but no message is delivered, so the adapter logs a warning whenever it requests external delivery and receives no payload back. + --- ### `createSeamlessConsoleProxy(options)` diff --git a/packages/express/src/handlers/bootstrapAdmininvite.ts b/packages/express/src/handlers/bootstrapAdmininvite.ts index 7bd9db5..1f1c007 100644 --- a/packages/express/src/handlers/bootstrapAdmininvite.ts +++ b/packages/express/src/handlers/bootstrapAdmininvite.ts @@ -5,7 +5,7 @@ import { buildInternalServiceAuthorization, buildProxyServiceAuthorization, } from "../internal/buildAuthorization"; -import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; +import { applyExternalDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; export async function bootstrapAdminInvite( @@ -28,13 +28,7 @@ export async function bootstrapAdminInvite( return res.status(result.status).json({ error: result.error }); } - if (result.body && typeof result.body === "object" && "delivery" in result.body) { - await deliverAuthMessage( - opts.messaging, - (result.body as { delivery?: any }).delivery, - ); - return res.status(result.status).json(stripDelivery(result.body as any)); - } + const body = await applyExternalDelivery(opts.messaging, result.body); - res.status(result.status).json(result.body); + res.status(result.status).json(body); } diff --git a/packages/express/src/handlers/register.ts b/packages/express/src/handlers/register.ts index 5572e66..12710ea 100644 --- a/packages/express/src/handlers/register.ts +++ b/packages/express/src/handlers/register.ts @@ -6,7 +6,7 @@ import { buildInternalServiceAuthorization, buildProxyServiceAuthorization, } from "../internal/buildAuthorization"; -import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; +import { applyExternalDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; export async function register( @@ -49,13 +49,7 @@ export async function register( return res.status(result.status).json(result.error); } - if (result.body && typeof result.body === "object" && "delivery" in result.body) { - await deliverAuthMessage( - opts.messaging, - (result.body as { delivery?: any }).delivery, - ); - return res.status(result.status).json(stripDelivery(result.body as any)).end(); - } + const body = await applyExternalDelivery(opts.messaging, result.body); - res.status(result.status).json(result.body).end(); + res.status(result.status).json(body).end(); } diff --git a/packages/express/src/handlers/requestMagicLink.ts b/packages/express/src/handlers/requestMagicLink.ts index 8bbbe4c..1ecc894 100644 --- a/packages/express/src/handlers/requestMagicLink.ts +++ b/packages/express/src/handlers/requestMagicLink.ts @@ -6,7 +6,7 @@ import { buildServiceAuthorization, } from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; -import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; +import { applyExternalDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; export async function requestMagicLink( @@ -32,13 +32,7 @@ export async function requestMagicLink( return res.status(result.status).json(result.error); } - if (result.body && typeof result.body === "object" && "delivery" in result.body) { - await deliverAuthMessage( - opts.messaging, - (result.body as { delivery?: any }).delivery, - ); - return res.status(result.status).json(stripDelivery(result.body as any)); - } + const body = await applyExternalDelivery(opts.messaging, result.body); - return res.status(result.status).json(result.body); + return res.status(result.status).json(body); } diff --git a/packages/express/src/handlers/requestOtp.ts b/packages/express/src/handlers/requestOtp.ts index 3995e9c..ee0c361 100644 --- a/packages/express/src/handlers/requestOtp.ts +++ b/packages/express/src/handlers/requestOtp.ts @@ -6,7 +6,7 @@ import { buildServiceAuthorization, } from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; -import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; +import { applyExternalDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; export async function requestOtp( @@ -36,13 +36,7 @@ export async function requestOtp( return res.status(result.status).json(result.error); } - if (result.body && typeof result.body === "object" && "delivery" in result.body) { - await deliverAuthMessage( - opts.messaging, - (result.body as { delivery?: any }).delivery, - ); - return res.status(result.status).json(stripDelivery(result.body as any)); - } + const body = await applyExternalDelivery(opts.messaging, result.body); - return res.status(result.status).json(result.body); + return res.status(result.status).json(body); } diff --git a/packages/express/src/internal/deliverAuthMessage.ts b/packages/express/src/internal/deliverAuthMessage.ts index 68a33f5..ec5567d 100644 --- a/packages/express/src/internal/deliverAuthMessage.ts +++ b/packages/express/src/internal/deliverAuthMessage.ts @@ -210,3 +210,32 @@ export function stripDelivery(body: T): Omit { + if (body && typeof body === "object" && "delivery" in body) { + await deliverAuthMessage( + messaging, + (body as { delivery?: AuthDeliveryInstruction }).delivery, + ); + return stripDelivery(body as { delivery?: unknown }); + } + + if (messaging) { + console.warn( + "[SEAMLESS-AUTH-EXPRESS] - External delivery was requested but the auth API returned no delivery payload, so no message was sent. Verify that serviceSecret matches the auth API's API_SERVICE_TOKEN.", + ); + } + + return body; +} diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 1813338..d1f098c 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -213,4 +213,68 @@ describe("messaging delivery routes", () => { }), ); }); + it("warns and sends nothing when the auth API returns no delivery payload", async () => { + const emailTransport = { + name: "test-email", + send: jest.fn(), + }; + + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "If an account exists, a login link has been sent.", + }), + ); + + const res = await request(createApp(emailTransport)) + .get("/auth/magic-link") + .set("Cookie", createPreAuthCookie()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + message: "If an account exists, a login link has been sent.", + }); + + expect(emailTransport.send).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("returned no delivery payload"), + ); + + warn.mockRestore(); + }); + + it("does not warn about a missing delivery payload when messaging is not configured", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const app = express(); + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret-cookie-secret-cookie-secret", + serviceSecret: "service-secret-service-secret-service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "test-main", + }), + ); + + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "If an account exists, a login link has been sent.", + }), + ); + + const res = await request(app) + .get("/auth/magic-link") + .set("Cookie", createPreAuthCookie()); + + expect(res.status).toBe(200); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining("returned no delivery payload"), + ); + + warn.mockRestore(); + }); });