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
21 changes: 21 additions & 0 deletions .changeset/totp-mfa-routes.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 12 additions & 1 deletion packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -237,8 +242,14 @@ export async function ensureCookies(
input: EnsureCookiesInput,
opts: EnsureCookiesOptions,
): Promise<EnsureCookiesResult> {
// 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) {
Expand Down
50 changes: 50 additions & 0 deletions packages/core/tests/ensureCookes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
1 change: 1 addition & 0 deletions packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
190 changes: 190 additions & 0 deletions packages/express/tests/totpProxy.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading