From d5954eb6dd34405c1779b8f842e7da5df56745b9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 20:04:25 -0400 Subject: [PATCH 01/11] fix: correct and admin user delete impl, as it needs to preserve the body --- packages/core/src/handlers/admin.ts | 2 +- packages/core/tests/adminHandler.test.js | 52 +++++++++++++ packages/express/src/handlers/admin.ts | 1 + packages/express/tests/adminRoutes.test.js | 89 ++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/adminHandler.test.js create mode 100644 packages/express/tests/adminRoutes.test.js diff --git a/packages/core/src/handlers/admin.ts b/packages/core/src/handlers/admin.ts index cf54d30..296bb3b 100644 --- a/packages/core/src/handlers/admin.ts +++ b/packages/core/src/handlers/admin.ts @@ -68,7 +68,7 @@ export const getUsersHandler = (opts: BaseOpts) => export const createUserHandler = (opts: WithBody) => request("POST", "/admin/users", opts); -export const deleteUserHandler = (opts: BaseOpts) => +export const deleteUserHandler = (opts: WithBody) => request("DELETE", "/admin/users", opts); export const updateUserHandler = (userId: string, opts: WithBody) => diff --git a/packages/core/tests/adminHandler.test.js b/packages/core/tests/adminHandler.test.js new file mode 100644 index 0000000..1330fb1 --- /dev/null +++ b/packages/core/tests/adminHandler.test.js @@ -0,0 +1,52 @@ +import { jest } from "@jest/globals"; + +const authFetchMock = jest.fn(); + +jest.unstable_mockModule("../dist/authFetch.js", () => ({ + authFetch: authFetchMock, +})); + +const baseOptions = { + authServerUrl: "https://auth.example.com", + authorization: "Bearer service-token", + forwardedClientIp: "203.0.113.44", +}; + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +describe("admin handlers", () => { + beforeEach(() => authFetchMock.mockReset()); + + it("forwards the delete user request body", async () => { + const { deleteUserHandler } = await import("../dist/handlers/admin.js"); + + authFetchMock.mockResolvedValue( + createJsonResponse(200, { message: "Success" }), + ); + + const result = await deleteUserHandler({ + ...baseOptions, + body: { userId: "user-1" }, + }); + + expect(authFetchMock).toHaveBeenCalledWith( + "https://auth.example.com/admin/users", + { + method: "DELETE", + authorization: "Bearer service-token", + body: { userId: "user-1" }, + forwardedClientIp: "203.0.113.44", + }, + ); + expect(result).toEqual({ + status: 200, + body: { message: "Success" }, + }); + }); +}); diff --git a/packages/express/src/handlers/admin.ts b/packages/express/src/handlers/admin.ts index 81837e8..6754879 100644 --- a/packages/express/src/handlers/admin.ts +++ b/packages/express/src/handlers/admin.ts @@ -66,6 +66,7 @@ export const deleteUser = async ( authServerUrl: opts.authServerUrl, authorization: buildServiceAuthorization(req, opts), forwardedClientIp: buildForwardedClientIp(req), + body: req.body, } as any), ); diff --git a/packages/express/tests/adminRoutes.test.js b/packages/express/tests/adminRoutes.test.js new file mode 100644 index 0000000..8d32b59 --- /dev/null +++ b/packages/express/tests/adminRoutes.test.js @@ -0,0 +1,89 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createAccessCookie(subject = "admin-123") { + const token = jwt.sign( + { + sub: subject, + roles: ["admin"], + sessionId: "session-123", + token: "access-token", + }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); + + return `seamless-access=${token}`; +} + +function createApp() { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "dev-main", + }), + ); + + return app; +} + +describe("admin routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards the delete user body to the auth API", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { message: "Success" }), + ); + + const body = { userId: "user-1" }; + + const res = await request(createApp()) + .delete("/auth/admin/users") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: "Success" }); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/admin/users", + expect.objectContaining({ + method: "DELETE", + body: JSON.stringify(body), + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", + }), + }), + ); + }); +}); From 02a3deebfd498cbc849b8b536cbb00b91231f4d3 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 23:07:40 -0400 Subject: [PATCH 02/11] fix: add internal auth events to our cookie reqs --- packages/core/src/ensureCookies.ts | 4 + packages/core/tests/ensureCookes.test.js | 27 ++++++ .../tests/internalMetricsRoutes.test.js | 89 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 packages/express/tests/internalMetricsRoutes.test.js diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index a68f540..e10c763 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -116,6 +116,10 @@ const COOKIE_REQUIREMENTS: Record< "/step-up/webauthn/start": { name: "accessCookieName", required: true }, "/step-up/webauthn/finish": { name: "accessCookieName", required: true }, "/internal/metrics/dashboard": { name: "accessCookieName", required: true }, + "/internal/auth-events/summary": { + name: "accessCookieName", + required: true, + }, "/internal/auth-events/timeseries": { name: "accessCookieName", required: true, diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index bd338f8..a3d9b4e 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -233,6 +233,33 @@ describe("ensureCookies", () => { }); }); + it("requires the access cookie for auth event summary metrics", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "admin-123", + token: "access-token", + sessionId: "session-123", + roles: ["admin"], + }); + + const result = await ensureCookies( + { + path: "/internal/auth-events/summary", + cookies: { access: "valid.access.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "admin-123", + sessionId: "session-123", + token: "access-token", + roles: ["admin"], + }); + }); + it("requires the access cookie for organization routes", async () => { const { ensureCookies } = await import("../dist/ensureCookies.js"); diff --git a/packages/express/tests/internalMetricsRoutes.test.js b/packages/express/tests/internalMetricsRoutes.test.js new file mode 100644 index 0000000..a9edbea --- /dev/null +++ b/packages/express/tests/internalMetricsRoutes.test.js @@ -0,0 +1,89 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createAccessCookie(subject = "admin-123") { + const token = jwt.sign( + { + sub: subject, + roles: ["admin"], + sessionId: "session-123", + token: "access-token", + }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); + + return `seamless-access=${token}`; +} + +function createApp() { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "dev-main", + }), + ); + + return app; +} + +describe("internal metrics routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("forwards auth event summary requests with access identity", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + summary: [{ type: "login_success", count: 5 }], + }), + ); + + const res = await request(createApp()) + .get("/auth/internal/auth-events/summary") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + summary: [{ type: "login_success", count: 5 }], + }); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/internal/auth-events/summary", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", + }), + }), + ); + }); +}); From 46ed1bb9a0b0f802f7e00f952d4b82105e0ffa01 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 31 May 2026 08:36:16 -0400 Subject: [PATCH 03/11] fix: update seamless core cookie contract for ensure cookies, fail earlier --- packages/core/src/ensureCookies.ts | 153 ++++++++++++++--------- packages/core/tests/ensureCookes.test.js | 44 +++++++ 2 files changed, 137 insertions(+), 60 deletions(-) diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index e10c763..6b677ef 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -161,6 +161,78 @@ const COOKIE_REQUIREMENTS: Record< }, }; +async function refreshRequiredCookie( + cookieName: string, + refreshCookie: string | undefined, + opts: EnsureCookiesOptions, +): Promise { + if (!refreshCookie) { + return null; + } + + const refreshed = await refreshAccessToken(refreshCookie, { + authServerUrl: opts.authServerUrl, + cookieSecret: opts.cookieSecret, + serviceSecret: opts.serviceSecret, + issuer: opts.issuer, + audience: opts.audience, + keyId: opts.keyId, + forwardedClientIp: opts.forwardedClientIp, + }); + + if (!refreshed?.token) { + return { + type: "error", + status: 401, + error: "Refresh failed", + clearCookies: [ + cookieName, + opts.registrationCookieName, + opts.refreshCookieName, + ], + }; + } + + return { + type: "ok", + user: { + sub: refreshed.sub, + ...(refreshed.sessionId === undefined + ? {} + : { sessionId: refreshed.sessionId }), + token: refreshed.token, + roles: refreshed.roles, + }, + setCookies: [ + { + name: cookieName, + value: { + sub: refreshed.sub, + ...(refreshed.sessionId === undefined + ? {} + : { sessionId: refreshed.sessionId }), + token: refreshed.token, + roles: refreshed.roles, + email: refreshed.email, + phone: refreshed.phone, + organizationId: refreshed.organizationId ?? null, + }, + ttl: refreshed.ttl, + domain: opts.cookieDomain, + }, + { + name: opts.refreshCookieName, + value: { + sub: refreshed.sub, + refreshToken: refreshed.refreshToken, + }, + ttl: refreshed.refreshTtl, + domain: opts.cookieDomain, + }, + ], + }; +} + export async function ensureCookies( input: EnsureCookiesInput, opts: EnsureCookiesOptions, @@ -191,7 +263,9 @@ export async function ensureCookies( const refreshCookie = input.cookies[opts.refreshCookieName]; if (required && !cookieValue) { - if (!refreshCookie) { + const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts); + + if (!refreshed) { return { type: "error", status: 400, @@ -199,76 +273,35 @@ export async function ensureCookies( }; } - const refreshed = await refreshAccessToken(refreshCookie, { - authServerUrl: opts.authServerUrl, - cookieSecret: opts.cookieSecret, - serviceSecret: opts.serviceSecret, - issuer: opts.issuer, - audience: opts.audience, - keyId: opts.keyId, - forwardedClientIp: opts.forwardedClientIp, - }); + return refreshed; + } - if (!refreshed?.token) { + if (cookieValue) { + const payload = verifyCookieJwt(cookieValue, opts.cookieSecret); + if (!payload) { return { type: "error", status: 401, - error: "Refresh failed", - clearCookies: [ - cookieName, - opts.registrationCookieName, - opts.refreshCookieName, - ], + error: `Invalid or expired ${cookieName} cookie`, }; } - return { - type: "ok", - user: { - sub: refreshed.sub, - ...(refreshed.sessionId === undefined - ? {} - : { sessionId: refreshed.sessionId }), - token: refreshed.token, - roles: refreshed.roles, - }, - setCookies: [ - { - name: cookieName, - value: { - sub: refreshed.sub, - ...(refreshed.sessionId === undefined - ? {} - : { sessionId: refreshed.sessionId }), - token: refreshed.token, - roles: refreshed.roles, - email: refreshed.email, - phone: refreshed.phone, - organizationId: refreshed.organizationId ?? null, - }, - ttl: refreshed.ttl, - domain: opts.cookieDomain, - }, - { - name: opts.refreshCookieName, - value: { - sub: refreshed.sub, - refreshToken: refreshed.refreshToken, - }, - ttl: refreshed.refreshTtl, - domain: opts.cookieDomain, - }, - ], - }; - } + const token = typeof payload.token === "string" ? payload.token : undefined; - if (cookieValue) { - const payload = verifyCookieJwt(cookieValue, opts.cookieSecret); - if (!payload) { + if (required && !token && cookieName === opts.accessCookieName) { + const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts); + + if (refreshed) { + return refreshed; + } + } + + if (required && !token) { return { type: "error", status: 401, error: `Invalid or expired ${cookieName} cookie`, + clearCookies: [cookieName], }; } @@ -279,7 +312,7 @@ export async function ensureCookies( ...(typeof payload.sessionId === "string" ? { sessionId: payload.sessionId } : {}), - ...(typeof payload.token === "string" ? { token: payload.token } : {}), + ...(token === undefined ? {} : { token }), roles: payload.roles as string[] | undefined, }, }; diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index a3d9b4e..72ef5d4 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -47,6 +47,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "access-token", roles: ["user"], }); @@ -61,6 +62,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user).toEqual({ sub: "user-123", + token: "access-token", roles: ["user"], }); }); @@ -125,6 +127,44 @@ describe("ensureCookies", () => { expect(refreshCookie.name).toBe("refresh"); }); + it("refreshes old access cookies that do not contain a stored auth token", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + sessionId: "session-123", + roles: ["user"], + }); + refreshAccessTokenMock.mockResolvedValue({ + sub: "user-123", + sessionId: "session-456", + token: "new-access", + refreshToken: "new-refresh", + roles: ["user"], + email: "test@example.com", + phone: "+14155552671", + organizationId: null, + ttl: 300, + refreshTtl: 3600, + }); + + const result = await ensureCookies( + { + path: "/internal/auth-events/summary", + cookies: { access: "old.access.jwt", refresh: "refresh.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user).toEqual({ + sub: "user-123", + sessionId: "session-456", + token: "new-access", + roles: ["user"], + }); + }); + it("returns error and clears cookies when refresh fails", async () => { const { ensureCookies } = await import("../dist/ensureCookies.js"); @@ -165,6 +205,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); @@ -179,6 +220,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user).toEqual({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); }); @@ -188,6 +230,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); @@ -202,6 +245,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user).toEqual({ sub: "user-123", + token: "ephemeral-token", roles: ["user"], }); }); From 0e7b5c890f91ddeac2382bfc1bed7a98cc1fddd0 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 31 May 2026 23:50:00 -0400 Subject: [PATCH 04/11] chore: changset update --- .changeset/tough-nights-stop.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tough-nights-stop.md diff --git a/.changeset/tough-nights-stop.md b/.changeset/tough-nights-stop.md new file mode 100644 index 0000000..d8767e4 --- /dev/null +++ b/.changeset/tough-nights-stop.md @@ -0,0 +1,6 @@ +--- +"@seamless-auth/express": patch +"@seamless-auth/core": patch +--- + +Fixes for deleting users as an admin, and internal auth events summary route token handling From 5b4ad1e26d4465f9c3ca8a57f7acf06d880c9164 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 3 Jun 2026 18:04:29 -0400 Subject: [PATCH 05/11] chore: correct seamless cli name --- packages/express/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/express/README.md b/packages/express/README.md index 1ecbe87..6fad03d 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -24,7 +24,7 @@ This package: Pair this with: - **React SDK:** https://github.com/fells-code/seamless-auth-react -- **Starter app:** https://github.com/fells-code/create-seamless +- **Starter app:** https://github.com/fells-code/seamless-cli --- From da9a6011d3ceed66e96755916f01c4dff04f67ea Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 9 Jun 2026 08:12:26 -0400 Subject: [PATCH 06/11] fix: add mising service token for polling magic links (this is why we can't write everything with AI) --- .../pollMagicLinkConfirmationHandler.ts | 5 ++- .../src/handlers/pollMagicLinkConfirmation.ts | 9 +++-- .../express/tests/messagingDelivery.test.js | 36 +++++++++++++++++++ packages/express/tests/requireAuth.test.js | 2 +- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts index 4e1d765..b7b334c 100644 --- a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -12,6 +12,7 @@ export interface PollMagicLinkConfirmationOptions { cookieDomain?: string; accessCookieName: string; refreshCookieName: string; + serviceAuthorization?: string; } export interface PollMagicLinkConfirmationResult { @@ -34,9 +35,9 @@ export async function pollMagicLinkConfirmationHandler( method: "GET", authorization: input.authorization, forwardedClientIp: input.forwardedClientIp, + serviceAuthorization: opts.serviceAuthorization, }); - // πŸ‘‡ Pending state (important for polling UX) if (up.status === 204) { return { status: 204, @@ -53,7 +54,6 @@ export async function pollMagicLinkConfirmationHandler( }; } - // πŸ‘‡ Web mode: auth server already handled cookies if (!data?.token || !data?.refreshToken || !data?.sub) { return { status: up.status, @@ -61,7 +61,6 @@ export async function pollMagicLinkConfirmationHandler( }; } - // πŸ” Verify signed response (same as WebAuthn flow) const verifiedAccessToken = await verifySignedAuthResponse( data.token, opts.authServerUrl, diff --git a/packages/express/src/handlers/pollMagicLinkConfirmation.ts b/packages/express/src/handlers/pollMagicLinkConfirmation.ts index d0b0951..bb9d0a6 100644 --- a/packages/express/src/handlers/pollMagicLinkConfirmation.ts +++ b/packages/express/src/handlers/pollMagicLinkConfirmation.ts @@ -1,7 +1,10 @@ import { Request, Response } from "express"; import { pollMagicLinkConfirmationHandler } from "@seamless-auth/core/handlers/pollMagicLinkConfirmationHandler"; import { setSessionCookie } from "../internal/cookie"; -import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { + buildInternalServiceAuthorization, + buildServiceAuthorization, +} from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -31,6 +34,9 @@ export async function pollMagicLinkConfirmation( cookieDomain: opts.cookieDomain, accessCookieName: opts.accessCookieName!, refreshCookieName: opts.refreshCookieName!, + serviceAuthorization: opts.messaging + ? buildInternalServiceAuthorization(opts) + : undefined, }, ); @@ -38,7 +44,6 @@ export async function pollMagicLinkConfirmation( throw new Error("Missing COOKIE_SIGNING_KEY"); } - // πŸͺ Set cookies if returned if (result.setCookies) { for (const c of result.setCookies) { setSessionCookie( diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 328be6c..aa9d07d 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -118,6 +118,42 @@ describe("messaging delivery routes", () => { ); }); + it("polls magic-link confirmation with the trusted client IP service token", async () => { + const emailTransport = { + name: "test-email", + send: jest.fn(), + }; + + global.fetch.mockResolvedValue( + createJsonResponse(204, { + message: "Success", + }), + ); + + const res = await request(createApp(emailTransport)) + .get("/auth/magic-link/check") + .set("Cookie", createPreAuthCookie()); + + expect(res.status).toBe(204); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/magic-link/check", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer ephemeral-token", + "x-seamless-service-token": expect.stringMatching(/^Bearer /), + "x-seamless-client-ip": expect.any(String), + }), + }), + ); + + expect(global.fetch.mock.calls[0][1].headers["x-seamless-service-token"]).not.toBe( + "Bearer ephemeral-token", + ); + }); + it("delivers bootstrap invites through the configured email transport and strips delivery details", async () => { const emailTransport = { name: "test-email", diff --git a/packages/express/tests/requireAuth.test.js b/packages/express/tests/requireAuth.test.js index 27cd3d8..96e6ba8 100644 --- a/packages/express/tests/requireAuth.test.js +++ b/packages/express/tests/requireAuth.test.js @@ -12,7 +12,7 @@ describe("requireAuth (smoke)", () => { const token = jwt.sign({ sub: "user-123" }, secret, { expiresIn: "1h" }); const app = express(); - app.use(cookieParser()); // πŸ”‘ REQUIRED + app.use(cookieParser()); app.use( requireAuth({ cookieName: "access", From 9c7edad3121fd8fe65a1df0f3b549501482306f2 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 9 Jun 2026 08:15:29 -0400 Subject: [PATCH 07/11] docs: changelog --- .changeset/sharp-breads-stop.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/sharp-breads-stop.md diff --git a/.changeset/sharp-breads-stop.md b/.changeset/sharp-breads-stop.md new file mode 100644 index 0000000..e2aa9ca --- /dev/null +++ b/.changeset/sharp-breads-stop.md @@ -0,0 +1,6 @@ +--- +"@seamless-auth/express": patch +"@seamless-auth/core": patch +--- + +fix: updates core implementation to supply the authorization value during polling for magic links From 18df90eb9922497896c868163d5655452e4bb771 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 28 Jun 2026 23:45:15 +0200 Subject: [PATCH 08/11] fix: issue a session on OTP-based registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration now starts with just an email; verifying the registration email OTP completes sign-up and returns a session. Add verifyRegistrationOtpHandler (core) and verifyRegistrationOtp (express) so /otp/verify-email-otp and /otp/verify-phone-otp set the session cookies β€” they previously proxied without cookies, leaving browser users unauthenticated after registering. The shared verify helper tolerates a phone-first step that returns no session yet. --- .changeset/registration-session-cookie.md | 13 ++++++ .../src/handlers/verifyLoginOtpHandler.ts | 42 ++++++++++++++++--- packages/express/src/createServer.ts | 15 +++---- .../express/src/handlers/verifyLoginOtp.ts | 34 +++++++++++++-- 4 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 .changeset/registration-session-cookie.md diff --git a/.changeset/registration-session-cookie.md b/.changeset/registration-session-cookie.md new file mode 100644 index 0000000..9a5b010 --- /dev/null +++ b/.changeset/registration-session-cookie.md @@ -0,0 +1,13 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": patch +--- + +Issue a session on OTP-based registration. Registration now starts with just an +email, and verifying the registration email OTP completes sign-up and returns a +session. The adapter previously proxied `/otp/verify-email-otp` and +`/otp/verify-phone-otp` without setting cookies, so browser users finished +registration unauthenticated. A new `verifyRegistrationOtpHandler` (core) plus a +`verifyRegistrationOtp` express handler now set the session cookies on these +routes (tolerating a phone-first step that returns no session yet), mirroring the +login OTP verify handlers. diff --git a/packages/core/src/handlers/verifyLoginOtpHandler.ts b/packages/core/src/handlers/verifyLoginOtpHandler.ts index 1dc257c..7057763 100644 --- a/packages/core/src/handlers/verifyLoginOtpHandler.ts +++ b/packages/core/src/handlers/verifyLoginOtpHandler.ts @@ -28,15 +28,16 @@ export interface VerifyLoginOtpResult { }[]; } -export async function verifyLoginOtpHandler( +// Shared by the login and registration OTP verify handlers: POST to the given +// auth-server path and, when the response carries a session, validate the signed +// access token and build the session cookies. Registration can complete without +// a session yet (e.g. a phone-first step before email is verified), in which case +// there is no token to turn into cookies β€” the body is returned as-is. +async function verifyOtp( + path: string, input: VerifyLoginOtpInput, opts: VerifyLoginOtpOptions, ): Promise { - const path = - input.kind === "email" - ? "otp/verify-login-email-otp" - : "otp/verify-login-phone-otp"; - const up = await authFetch(`${opts.authServerUrl}/${path}`, { method: "POST", body: input.body, @@ -53,6 +54,13 @@ export async function verifyLoginOtpHandler( }; } + if (!data?.token) { + return { + status: up.status, + body: data, + }; + } + const verifiedAccessToken = await verifySignedAuthResponse( data.token, opts.authServerUrl, @@ -101,3 +109,25 @@ export async function verifyLoginOtpHandler( ], }; } + +export async function verifyLoginOtpHandler( + input: VerifyLoginOtpInput, + opts: VerifyLoginOtpOptions, +): Promise { + const path = + input.kind === "email" + ? "otp/verify-login-email-otp" + : "otp/verify-login-phone-otp"; + + return verifyOtp(path, input, opts); +} + +export async function verifyRegistrationOtpHandler( + input: VerifyLoginOtpInput, + opts: VerifyLoginOtpOptions, +): Promise { + const path = + input.kind === "email" ? "otp/verify-email-otp" : "otp/verify-phone-otp"; + + return verifyOtp(path, input, opts); +} diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 62deeae..2625073 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -8,7 +8,10 @@ import { login } from "./handlers/login"; import { finishLogin } from "./handlers/finishLogin"; import { register } from "./handlers/register"; import { requestOtp } from "./handlers/requestOtp"; -import { verifyLoginOtp } from "./handlers/verifyLoginOtp"; +import { + verifyLoginOtp, + verifyRegistrationOtp, +} from "./handlers/verifyLoginOtp"; import { switchOrganization } from "./handlers/switchOrganization"; import { finishRegister } from "./handlers/finishRegister"; import { me } from "./handlers/me"; @@ -274,13 +277,11 @@ export function createSeamlessAuthServer( finishRegister(req, res, resolvedOpts), ); - r.post( - "/otp/verify-phone-otp", - proxyWithIdentity("otp/verify-phone-otp", "preAuth"), + r.post("/otp/verify-phone-otp", (req, res) => + verifyRegistrationOtp(req, res, resolvedOpts, "phone"), ); - r.post( - "/otp/verify-email-otp", - proxyWithIdentity("otp/verify-email-otp", "preAuth"), + r.post("/otp/verify-email-otp", (req, res) => + verifyRegistrationOtp(req, res, resolvedOpts, "email"), ); r.post("/otp/verify-login-phone-otp", (req, res) => verifyLoginOtp(req, res, resolvedOpts, "phone"), diff --git a/packages/express/src/handlers/verifyLoginOtp.ts b/packages/express/src/handlers/verifyLoginOtp.ts index 19f361c..8a4a556 100644 --- a/packages/express/src/handlers/verifyLoginOtp.ts +++ b/packages/express/src/handlers/verifyLoginOtp.ts @@ -1,15 +1,19 @@ import { Request, Response } from "express"; -import { verifyLoginOtpHandler } from "@seamless-auth/core/handlers/verifyLoginOtpHandler"; +import { + verifyLoginOtpHandler, + verifyRegistrationOtpHandler, +} from "@seamless-auth/core/handlers/verifyLoginOtpHandler"; import { setSessionCookie } from "../internal/cookie"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; -export async function verifyLoginOtp( +async function verifyOtp( req: Request & { cookiePayload?: any }, res: Response, opts: SeamlessAuthServerOptions, kind: "email" | "phone", + flow: "login" | "register", ) { const cookieSigner = { secret: opts.cookieSecret, @@ -20,7 +24,10 @@ export async function verifyLoginOtp( : ("lax" as "none" | "lax"), }; - const result = await verifyLoginOtpHandler( + const handler = + flow === "register" ? verifyRegistrationOtpHandler : verifyLoginOtpHandler; + + const result = await handler( { body: req.body, authorization: buildServiceAuthorization(req, opts), @@ -60,3 +67,24 @@ export async function verifyLoginOtp( return res.status(result.status).json(result.body); } + +export function verifyLoginOtp( + req: Request & { cookiePayload?: any }, + res: Response, + opts: SeamlessAuthServerOptions, + kind: "email" | "phone", +) { + return verifyOtp(req, res, opts, kind, "login"); +} + +// Registration OTP verify: identical cookie handling, but a successful email +// verify now completes registration and issues a session, so the session cookies +// must be set here (a phone-first step that returns no session sets none). +export function verifyRegistrationOtp( + req: Request & { cookiePayload?: any }, + res: Response, + opts: SeamlessAuthServerOptions, + kind: "email" | "phone", +) { + return verifyOtp(req, res, opts, kind, "register"); +} From 6bd1e0f5ce259a572ac2f27985166a9299902b2c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 29 Jun 2026 01:25:40 +0200 Subject: [PATCH 09/11] fix: don't crash on non-JSON upstream responses (#41) authFetch now parses response bodies defensively: a plain-text error (e.g. a rate-limited 429) or an empty body (204) no longer throws in handlers that read the body before checking status, which previously crashed the adapter process. Non-JSON -> { message: text }, empty -> undefined. Adds authFetch regression tests. --- .changeset/adapter-non-json-response.md | 11 ++++++ packages/core/src/authFetch.ts | 30 ++++++++++++++++- packages/core/tests/authFetch.test.js | 45 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 .changeset/adapter-non-json-response.md diff --git a/.changeset/adapter-non-json-response.md b/.changeset/adapter-non-json-response.md new file mode 100644 index 0000000..17c6d0b --- /dev/null +++ b/.changeset/adapter-non-json-response.md @@ -0,0 +1,11 @@ +--- +"@seamless-auth/core": patch +"@seamless-auth/express": patch +--- + +Don't crash on non-JSON upstream responses. `authFetch` now parses response bodies +defensively, so a plain-text error (e.g. a rate-limited `429 Too many requests`) or an +empty body (`204`) no longer throws in handlers that read the body before checking the +status β€” which previously surfaced as an unhandled rejection that took down the adapter +process. Non-JSON bodies are returned as `{ message: }`; empty bodies as +`undefined`. Fixes #41. diff --git a/packages/core/src/authFetch.ts b/packages/core/src/authFetch.ts index 4421c72..1407ffb 100644 --- a/packages/core/src/authFetch.ts +++ b/packages/core/src/authFetch.ts @@ -30,9 +30,37 @@ export async function authFetch( : {}), }; - return fetch(url, { + const response = await fetch(url, { method: options.method, headers, body: options.body ? JSON.stringify(options.body) : undefined, }); + + return makeJsonTolerant(response); +} + +// Upstream responses aren't always JSON: a rate-limited request comes back as plain +// text ("Too many requests…") and a 204 has no body. Native Response.json() throws on +// both, which would crash callers that parse the body before checking the status. Make +// json() tolerant so callers always get a value β€” parsed JSON, { message: text } for a +// non-JSON body, or undefined for an empty one. +function makeJsonTolerant(response: Response): Response { + if (typeof response.text !== "function") { + return response; + } + + const readText = response.text.bind(response); + response.json = async () => { + const text = await readText(); + if (!text) { + return undefined; + } + try { + return JSON.parse(text); + } catch { + return { message: text }; + } + }; + + return response; } diff --git a/packages/core/tests/authFetch.test.js b/packages/core/tests/authFetch.test.js index d1f95c9..abc503b 100644 --- a/packages/core/tests/authFetch.test.js +++ b/packages/core/tests/authFetch.test.js @@ -55,4 +55,49 @@ describe("authFetch", () => { }), ); }); + + it("does not throw when the response body is non-JSON (e.g. a 429)", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 429, + text: async () => "Too many requests, please try again later.", + }); + + const { authFetch } = await import("../dist/authFetch.js"); + const res = await authFetch("https://auth.example.com/otp/verify-email-otp", { + method: "POST", + }); + + await expect(res.json()).resolves.toEqual({ + message: "Too many requests, please try again later.", + }); + }); + + it("parses a JSON body normally", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ token: "abc", sub: "user-1" }), + }); + + const { authFetch } = await import("../dist/authFetch.js"); + const res = await authFetch("https://auth.example.com/login", { method: "POST" }); + + await expect(res.json()).resolves.toEqual({ token: "abc", sub: "user-1" }); + }); + + it("returns undefined for an empty body (e.g. a 204)", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 204, + text: async () => "", + }); + + const { authFetch } = await import("../dist/authFetch.js"); + const res = await authFetch("https://auth.example.com/magic-link/check", { + method: "GET", + }); + + await expect(res.json()).resolves.toBeUndefined(); + }); }); From 32af703d962bae40d382384b56b3be9c825df888 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 29 Jun 2026 18:31:19 +0200 Subject: [PATCH 10/11] ci: run seamless verify conformance matrix on PRs --- .github/workflows/conformance.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/conformance.yml diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..0e4a9cb --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,12 @@ +# Run the cross-repo conformance matrix on PRs, testing this repo's change against +# the rest of the ecosystem. The reusable workflow lives in seamless-cli. +name: conformance + +on: + pull_request: + +jobs: + verify: + uses: fells-code/seamless-cli/.github/workflows/verify-conformance.yml@main + with: + server-ref: ${{ github.event.pull_request.head.sha }} From 5388ade14a3b57499b1127bfc471f2de537ef8d8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 6 Jul 2026 21:03:56 -0400 Subject: [PATCH 11/11] feat(totp): expose TOTP routes through the adapter Mount the TOTP surface in @seamless-auth/express so frontends can drive TOTP enrollment, management, and TOTP-based step-up verification: - GET /auth/totp/status - POST /auth/totp/enroll/start - POST /auth/totp/enroll/verify - POST /auth/totp/disable - POST /auth/totp/verify-mfa These proxy the caller's access session upstream, matching the step-up route pattern. They update server-side state only and mint no new session cookies. TOTP as a login second factor is intentionally out of scope: the auth API does not gate login on TOTP today, so /totp/verify-login has no trigger yet. @seamless-auth/core gains the matching access-cookie requirements and now matches cookie requirements case-insensitively. Express route matching is case-insensitive by default, so a client path whose casing differed from the mounted route (e.g. /webauthn vs /webAuthn) previously failed the case-sensitive requirement lookup, silently skipped cookie loading, and broke the request. The lookup is normalized to lower case on both sides. Adds core unit tests for the TOTP requirements and case-insensitive matching, and an express integration test covering each proxied TOTP route. --- .changeset/totp-mfa-routes.md | 21 +++ packages/core/src/ensureCookies.ts | 13 +- packages/core/tests/ensureCookes.test.js | 50 ++++++ packages/express/README.md | 1 + packages/express/src/createServer.ts | 17 ++ packages/express/tests/totpProxy.test.js | 190 +++++++++++++++++++++++ 6 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 .changeset/totp-mfa-routes.md create mode 100644 packages/express/tests/totpProxy.test.js diff --git a/.changeset/totp-mfa-routes.md b/.changeset/totp-mfa-routes.md new file mode 100644 index 0000000..753159f --- /dev/null +++ b/.changeset/totp-mfa-routes.md @@ -0,0 +1,21 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": minor +--- + +Expose TOTP routes through the adapter. `@seamless-auth/express` now mounts +`GET /auth/totp/status`, `POST /auth/totp/enroll/start`, +`POST /auth/totp/enroll/verify`, `POST /auth/totp/disable`, and +`POST /auth/totp/verify-mfa`, proxying the caller's access session upstream like +the step-up routes. This lets frontends drive TOTP enrollment, management, and +TOTP-based step-up verification, which previously had no adapter surface. + +`@seamless-auth/core` adds the matching access-cookie requirements for those +paths and now matches cookie requirements case-insensitively. Express route +matching is case-insensitive by default, so a client path whose casing differed +from the mounted route (for example `/webauthn/...` vs `/webAuthn/...`) +previously failed the case-sensitive requirement lookup, silently skipped cookie +loading, and broke the request downstream. The lookup is now normalized. + +TOTP as a login second factor is intentionally not included: the auth API does +not currently gate login on TOTP, so `/totp/verify-login` has no trigger yet. diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 6b677ef..8327d28 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -115,6 +115,11 @@ const COOKIE_REQUIREMENTS: Record< "/step-up/status": { name: "accessCookieName", required: true }, "/step-up/webauthn/start": { name: "accessCookieName", required: true }, "/step-up/webauthn/finish": { name: "accessCookieName", required: true }, + "/totp/status": { name: "accessCookieName", required: true }, + "/totp/enroll/start": { name: "accessCookieName", required: true }, + "/totp/enroll/verify": { name: "accessCookieName", required: true }, + "/totp/disable": { name: "accessCookieName", required: true }, + "/totp/verify-mfa": { name: "accessCookieName", required: true }, "/internal/metrics/dashboard": { name: "accessCookieName", required: true }, "/internal/auth-events/summary": { name: "accessCookieName", @@ -237,8 +242,14 @@ export async function ensureCookies( input: EnsureCookiesInput, opts: EnsureCookiesOptions, ): Promise { + // Match case-insensitively: Express route matching is case-insensitive by + // default, so a client may send a path whose casing differs from the mounted + // route (e.g. "/webauthn/..." vs "/webAuthn/..."). A case-sensitive miss here + // would silently skip cookie loading and break the request downstream, so the + // comparison is normalized to lower case on both sides. + const requestPath = input.path.toLowerCase(); const match = Object.entries(COOKIE_REQUIREMENTS).find(([path]) => - input.path.startsWith(path), + requestPath.startsWith(path.toLowerCase()), ); if (!match) { diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index 72ef5d4..cc0595b 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -304,6 +304,56 @@ describe("ensureCookies", () => { }); }); + it("requires the access cookie for TOTP routes", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + token: "access-token", + sessionId: "session-123", + roles: ["user"], + }); + + for (const path of [ + "/totp/status", + "/totp/enroll/start", + "/totp/enroll/verify", + "/totp/disable", + "/totp/verify-mfa", + ]) { + const result = await ensureCookies( + { path, cookies: { access: "valid.access.jwt" } }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user?.token).toBe("access-token"); + } + }); + + it("matches cookie requirements case-insensitively", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + verifyCookieJwtMock.mockReturnValue({ + sub: "user-123", + token: "ephemeral-token", + roles: ["user"], + }); + + // A client may send "/webauthn/..." even though the requirement is keyed + // "/webAuthn/..."; the pre-auth cookie requirement must still apply. + const result = await ensureCookies( + { + path: "/webauthn/login/finish", + cookies: { preauth: "valid.preauth.jwt" }, + }, + BASE_OPTS, + ); + + expect(result.type).toBe("ok"); + expect(result.user?.token).toBe("ephemeral-token"); + }); + it("requires the access cookie for organization routes", async () => { const { ensureCookies } = await import("../dist/ensureCookies.js"); diff --git a/packages/express/README.md b/packages/express/README.md index 6fad03d..9c5577f 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -140,6 +140,7 @@ Routes include: - `/auth/oauth/:providerId/callback` - `/auth/webauthn/*` - `/auth/step-up/*` +- `/auth/totp/*` (enrollment, disable, status, and `verify-mfa` step-up) - `/auth/registration/*` - `/auth/users/me` - `DELETE /auth/logout` for the current session diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 2625073..03980e0 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -396,6 +396,23 @@ export function createSeamlessAuthServer( proxyWithIdentity("step-up/webauthn/finish", "access"), ); + // TOTP enrollment, management, and step-up verification. All require a full + // access session and update server-side state only (no new session cookies), + // so they proxy the user's access token upstream like the step-up routes. + // TOTP-as-a-login-second-factor is intentionally not mounted here: the auth + // API does not gate login on TOTP today, so /totp/verify-login has no trigger. + r.get("/totp/status", proxyWithIdentity("totp/status", "access", "GET")); + r.post( + "/totp/enroll/start", + proxyWithIdentity("totp/enroll/start", "access"), + ); + r.post( + "/totp/enroll/verify", + proxyWithIdentity("totp/enroll/verify", "access"), + ); + r.post("/totp/disable", proxyWithIdentity("totp/disable", "access")); + r.post("/totp/verify-mfa", proxyWithIdentity("totp/verify-mfa", "access")); + r.post("/users/update", proxyWithIdentity("users/update", "access")); r.post( "/users/credentials", diff --git a/packages/express/tests/totpProxy.test.js b/packages/express/tests/totpProxy.test.js new file mode 100644 index 0000000..0770229 --- /dev/null +++ b/packages/express/tests/totpProxy.test.js @@ -0,0 +1,190 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function createAccessCookie(subject = "user-123") { + const token = jwt.sign( + { sub: subject, roles: ["user"], token: "access-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); + + return `seamless-access=${token}`; +} + +function createApp() { + const app = express(); + + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: "https://auth.example.com", + cookieSecret: "cookie-secret", + serviceSecret: "service-secret", + issuer: "https://api.example.com", + audience: "https://auth.example.com", + jwksKid: "dev-main", + }), + ); + + return app; +} + +describe("TOTP proxy routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("proxies TOTP status with access identity", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + enabled: true, + verifiedAt: "2026-05-15T12:00:00.000Z", + lastUsedAt: null, + }), + ); + + const res = await request(createApp()) + .get("/auth/totp/status") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + enabled: true, + verifiedAt: "2026-05-15T12:00:00.000Z", + lastUsedAt: null, + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/totp/status", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + }), + }), + ); + }); + + it("proxies TOTP enrollment start", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "Success", + secret: "BASE32SECRET", + otpauthUrl: "otpauth://totp/Seamless:user@example.com?secret=BASE32SECRET", + }), + ); + + const res = await request(createApp()) + .post("/auth/totp/enroll/start") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(200); + expect(res.body.secret).toBe("BASE32SECRET"); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/totp/enroll/start", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("proxies TOTP enrollment verify with the code body", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { message: "Success" }), + ); + + const body = { code: "123456" }; + + const res = await request(createApp()) + .post("/auth/totp/enroll/verify") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/totp/enroll/verify", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + }), + ); + }); + + it("proxies TOTP disable with the code body", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { message: "Success" }), + ); + + const body = { code: "123456" }; + + const res = await request(createApp()) + .post("/auth/totp/disable") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/totp/disable", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + }), + ); + }); + + it("proxies TOTP step-up verification with the code body", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "Success", + method: "totp", + fresh: true, + verifiedAt: "2026-05-15T12:00:00.000Z", + expiresAt: "2026-05-15T12:05:00.000Z", + maxAgeSeconds: 300, + }), + ); + + const body = { code: "123456" }; + + const res = await request(createApp()) + .post("/auth/totp/verify-mfa") + .set("Cookie", createAccessCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(res.body.method).toBe("totp"); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/totp/verify-mfa", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + }), + ); + }); + + it("rejects TOTP routes without an access session", async () => { + const res = await request(createApp()).get("/auth/totp/status"); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(global.fetch).not.toHaveBeenCalled(); + }); +});