Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warn-on-missing-external-delivery.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down
12 changes: 3 additions & 9 deletions packages/express/src/handlers/bootstrapAdmininvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
}
12 changes: 3 additions & 9 deletions packages/express/src/handlers/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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();
}
12 changes: 3 additions & 9 deletions packages/express/src/handlers/requestMagicLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
}
12 changes: 3 additions & 9 deletions packages/express/src/handlers/requestOtp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}
29 changes: 29 additions & 0 deletions packages/express/src/internal/deliverAuthMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,32 @@ export function stripDelivery<T extends { delivery?: unknown }>(body: T): Omit<T
const { delivery: _delivery, ...rest } = body;
return rest;
}

/**
* Applies external delivery to an upstream response body and returns the body to send on.
*
* When messaging is configured the adapter asks the auth API for a delivery payload instead of
* having the API send the message itself. A missing payload means nobody sent anything, so it is
* surfaced here: the four endpoints that route through this helper always include `delivery` when
* the API accepts the request as externally delivered.
*/
export async function applyExternalDelivery(
messaging: SeamlessAuthMessagingOptions | undefined,
body: unknown,
): Promise<unknown> {
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;
}
64 changes: 64 additions & 0 deletions packages/express/tests/messagingDelivery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading