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/.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/.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 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/.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 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(); + }); +});