From d5954eb6dd34405c1779b8f842e7da5df56745b9 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 20:04:25 -0400 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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 }}