From bef3fdc824b228bf2eff288b6f1b6cde000bc074 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 6 Jul 2026 21:03:56 -0400 Subject: [PATCH] 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(); + }); +});