From c4b55330e9a8448a1bd71c3f370d9bbd254e26ac Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 26 May 2026 23:17:42 -0400 Subject: [PATCH 1/9] feat: sensitive data redaction --- README.md | 4 ++++ packages/core/src/ensureCookies.ts | 3 ++- .../core/src/handlers/bootstrapAdminInvite.ts | 3 +++ packages/core/src/handlers/register.ts | 2 ++ .../src/handlers/requestMagicLinkHandler.ts | 2 ++ packages/core/src/handlers/requestOtpHandler.ts | 2 ++ packages/core/src/redaction.ts | 17 +++++++++++++++++ packages/core/src/verifySignedAuthResponse.ts | 4 ++-- packages/core/tests/redaction.test.js | 14 ++++++++++++++ packages/express/README.md | 2 +- .../src/handlers/bootstrapAdmininvite.ts | 4 ++++ packages/express/src/handlers/register.ts | 4 ++++ .../express/src/handlers/requestMagicLink.ts | 8 +++++++- packages/express/src/handlers/requestOtp.ts | 8 +++++++- .../express/src/internal/buildAuthorization.ts | 12 ++++++++++++ packages/express/src/middleware/requireAuth.ts | 3 +-- .../express/tests/messagingDelivery.test.js | 6 +----- 17 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/redaction.ts create mode 100644 packages/core/tests/redaction.test.js diff --git a/README.md b/README.md index 769bf11..a140611 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ For WebAuthn PRF flows, the adapter proxies PRF registration query flags and ass For OAuth flows, the adapter proxies provider discovery, OAuth start, and OAuth callback completion. The callback exchanges the provider authorization code at the private Seamless Auth API, then the adapter sets the normal signed access and refresh cookies for the application. +Auth-message delivery payloads are treated as sensitive. When adopter-supplied messaging is +configured, the adapter requests those payloads using its short-lived service token, sends through the +configured transport or handler, and strips the raw OTP/link details before responding to the browser. + Location: ``` diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 9005d7c..8761342 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -1,5 +1,6 @@ import { verifyCookieJwt } from "./verifyCookieJwt.js"; import { refreshAccessToken } from "./refreshAccessToken.js"; +import { redactSensitiveText } from "./redaction.js"; export interface EnsureCookiesInput { path: string; @@ -165,7 +166,7 @@ export async function ensureCookies( if (!match) { console.debug( "[SEAMLESS-AUTH-CORE] - (ensureCookies) - No cookie requirements for this path. Path: ", - input.path, + redactSensitiveText(input.path), ); return { type: "ok" }; } diff --git a/packages/core/src/handlers/bootstrapAdminInvite.ts b/packages/core/src/handlers/bootstrapAdminInvite.ts index 8aa7d74..6374535 100644 --- a/packages/core/src/handlers/bootstrapAdminInvite.ts +++ b/packages/core/src/handlers/bootstrapAdminInvite.ts @@ -4,6 +4,7 @@ export interface BootstrapAdminInviteOptions { authServerUrl: string; email: string; authorization?: string; + serviceAuthorization?: string; externalDelivery?: boolean; forwardedClientIp?: string; } @@ -14,6 +15,7 @@ export interface BootstrapAdminInviteResult { url?: string; expiresAt: string; token?: string; + delivery?: unknown; }; error?: string; } @@ -26,6 +28,7 @@ export async function bootstrapAdminInviteHandler( { method: "POST", authorization: opts.authorization, + serviceAuthorization: opts.serviceAuthorization, forwardedClientIp: opts.forwardedClientIp, headers: { ...(opts.externalDelivery diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index ba6c988..f1e07b6 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -11,6 +11,7 @@ export interface RegisterOptions { registrationCookieName: string; externalDelivery?: boolean; forwardedClientIp?: string; + serviceAuthorization?: string; } export interface RegisterResult { @@ -33,6 +34,7 @@ export async function registerHandler( method: "POST", body: input.body, forwardedClientIp: opts.forwardedClientIp, + serviceAuthorization: opts.serviceAuthorization, ...(opts.externalDelivery ? { headers: { diff --git a/packages/core/src/handlers/requestMagicLinkHandler.ts b/packages/core/src/handlers/requestMagicLinkHandler.ts index 5f7f716..351312b 100644 --- a/packages/core/src/handlers/requestMagicLinkHandler.ts +++ b/packages/core/src/handlers/requestMagicLinkHandler.ts @@ -8,6 +8,7 @@ export interface RequestMagicLinkOptions { authServerUrl: string; externalDelivery?: boolean; forwardedClientIp?: string; + serviceAuthorization?: string; } export interface RequestMagicLinkResult { @@ -24,6 +25,7 @@ export async function requestMagicLinkHandler( method: "GET", authorization: input.authorization, forwardedClientIp: opts.forwardedClientIp, + serviceAuthorization: opts.serviceAuthorization, ...(opts.externalDelivery ? { headers: { diff --git a/packages/core/src/handlers/requestOtpHandler.ts b/packages/core/src/handlers/requestOtpHandler.ts index 939a2bb..2187e70 100644 --- a/packages/core/src/handlers/requestOtpHandler.ts +++ b/packages/core/src/handlers/requestOtpHandler.ts @@ -10,6 +10,7 @@ export interface RequestOtpOptions { authServerUrl: string; externalDelivery?: boolean; forwardedClientIp?: string; + serviceAuthorization?: string; } export interface RequestOtpResult { @@ -36,6 +37,7 @@ export async function requestOtpHandler( method: "GET", authorization: input.authorization, forwardedClientIp: opts.forwardedClientIp, + serviceAuthorization: opts.serviceAuthorization, ...(opts.externalDelivery ? { headers: { diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts new file mode 100644 index 0000000..99d507a --- /dev/null +++ b/packages/core/src/redaction.ts @@ -0,0 +1,17 @@ +const REDACTED = "[REDACTED]"; + +const SENSITIVE_TEXT_PATTERNS: Array<[RegExp, string]> = [ + [/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED], + [/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, `$1${REDACTED}`], + [/(\/magic-link\/verify\/)[^/?#\s]+/gi, `$1${REDACTED}`], + [/([?&](?:token|bootstrapToken|state|code|salt)=)[^&#\s]+/gi, `$1${REDACTED}`], + [/\b((?:token|bootstrapToken|verificationToken|identifier|phone|state|code|secret|salt)\s*[:=]\s*)[^,&\s}]+/gi, `$1${REDACTED}`], + [/\b(client_secret=)[^&\s]+/gi, `$1${REDACTED}`], +]; + +export function redactSensitiveText(value: string) { + return SENSITIVE_TEXT_PATTERNS.reduce( + (current, [pattern, replacement]) => current.replace(pattern, replacement), + value, + ); +} diff --git a/packages/core/src/verifySignedAuthResponse.ts b/packages/core/src/verifySignedAuthResponse.ts index dd6e863..0d58f93 100644 --- a/packages/core/src/verifySignedAuthResponse.ts +++ b/packages/core/src/verifySignedAuthResponse.ts @@ -14,8 +14,8 @@ export async function verifySignedAuthResponse( }); return payload as T; - } catch (err) { - console.error("[SeamlessAuth] Failed to verify signed auth response:", err); + } catch { + console.error("[SeamlessAuth] Failed to verify signed auth response."); return null; } } diff --git a/packages/core/tests/redaction.test.js b/packages/core/tests/redaction.test.js new file mode 100644 index 0000000..46f3f87 --- /dev/null +++ b/packages/core/tests/redaction.test.js @@ -0,0 +1,14 @@ +import { describe, expect, it } from "@jest/globals"; +import { redactSensitiveText } from "../dist/redaction.js"; + +describe("redaction", () => { + it("redacts tokens and OAuth values embedded in URLs", () => { + expect( + redactSensitiveText( + "/magic-link/verify/abc123?bootstrapToken=secret&code=oauth-code&salt=prf-salt", + ), + ).toBe( + "/magic-link/verify/[REDACTED]?bootstrapToken=[REDACTED]&code=[REDACTED]&salt=[REDACTED]", + ); + }); +}); diff --git a/packages/express/README.md b/packages/express/README.md index 78e86f4..8457e00 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -165,7 +165,7 @@ Routes include: `messaging` is the initializer-facing contract for adopter-supplied auth messaging capabilities. -When `messaging` is provided, `@seamless-auth/express` requests external-delivery payloads from the upstream auth server for auth-message flows and completes delivery locally through the configured transports or handlers. +When `messaging` is provided, `@seamless-auth/express` requests external-delivery payloads from the upstream auth server for auth-message flows and completes delivery locally through the configured transports or handlers. These payloads contain OTPs or one-time links and are stripped before the adapter responds to the browser. This currently applies to: diff --git a/packages/express/src/handlers/bootstrapAdmininvite.ts b/packages/express/src/handlers/bootstrapAdmininvite.ts index 882c69c..1d36c18 100644 --- a/packages/express/src/handlers/bootstrapAdmininvite.ts +++ b/packages/express/src/handlers/bootstrapAdmininvite.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { bootstrapAdminInviteHandler } from "@seamless-auth/core/handlers/bootstrapAdminInvite"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { buildInternalServiceAuthorization } from "../internal/buildAuthorization"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -15,6 +16,9 @@ export async function bootstrapAdminInvite( authorization: req.headers["authorization"], externalDelivery: Boolean(opts.messaging), forwardedClientIp: buildForwardedClientIp(req), + serviceAuthorization: opts.messaging + ? buildInternalServiceAuthorization(opts) + : undefined, } as any); if (result.error) { diff --git a/packages/express/src/handlers/register.ts b/packages/express/src/handlers/register.ts index a58da8f..a5a1ed3 100644 --- a/packages/express/src/handlers/register.ts +++ b/packages/express/src/handlers/register.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { registerHandler } from "@seamless-auth/core/handlers/register"; import { setSessionCookie } from "../internal/cookie"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { buildInternalServiceAuthorization } from "../internal/buildAuthorization"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -27,6 +28,9 @@ export async function register( registrationCookieName: opts.registrationCookieName!, externalDelivery: Boolean(opts.messaging), forwardedClientIp: buildForwardedClientIp(req), + serviceAuthorization: opts.messaging + ? buildInternalServiceAuthorization(opts) + : undefined, } as any, ); diff --git a/packages/express/src/handlers/requestMagicLink.ts b/packages/express/src/handlers/requestMagicLink.ts index c48c1d1..9d1a4ad 100644 --- a/packages/express/src/handlers/requestMagicLink.ts +++ b/packages/express/src/handlers/requestMagicLink.ts @@ -1,6 +1,9 @@ import { Request, Response } from "express"; import { requestMagicLinkHandler } from "@seamless-auth/core/handlers/requestMagicLinkHandler"; -import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { + buildInternalServiceAuthorization, + buildServiceAuthorization, +} from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -18,6 +21,9 @@ export async function requestMagicLink( authServerUrl: opts.authServerUrl, externalDelivery: Boolean(opts.messaging), forwardedClientIp: buildForwardedClientIp(req), + serviceAuthorization: opts.messaging + ? buildInternalServiceAuthorization(opts) + : undefined, } as any, ); diff --git a/packages/express/src/handlers/requestOtp.ts b/packages/express/src/handlers/requestOtp.ts index d858b45..73feece 100644 --- a/packages/express/src/handlers/requestOtp.ts +++ b/packages/express/src/handlers/requestOtp.ts @@ -1,6 +1,9 @@ import { Request, Response } from "express"; import { requestOtpHandler } from "@seamless-auth/core/handlers/requestOtpHandler"; -import { buildServiceAuthorization } from "../internal/buildAuthorization"; +import { + buildInternalServiceAuthorization, + buildServiceAuthorization, +} from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { deliverAuthMessage, stripDelivery } from "../internal/deliverAuthMessage"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -22,6 +25,9 @@ export async function requestOtp( authServerUrl: opts.authServerUrl, externalDelivery: Boolean(opts.messaging), forwardedClientIp: buildForwardedClientIp(req), + serviceAuthorization: opts.messaging + ? buildInternalServiceAuthorization(opts) + : undefined, } as any, ); diff --git a/packages/express/src/internal/buildAuthorization.ts b/packages/express/src/internal/buildAuthorization.ts index 9dd001c..bf44866 100644 --- a/packages/express/src/internal/buildAuthorization.ts +++ b/packages/express/src/internal/buildAuthorization.ts @@ -22,3 +22,15 @@ export function buildServiceAuthorization( return `Bearer ${token}`; } + +export function buildInternalServiceAuthorization(opts: SeamlessAuthServerOptions) { + const token = createServiceToken({ + subject: "seamless-auth-external-delivery", + issuer: "seamless-portal-api", + audience: "seamless-auth", + serviceSecret: opts.serviceSecret, + keyId: opts.jwksKid || "dev-main", + }); + + return `Bearer ${token}`; +} diff --git a/packages/express/src/middleware/requireAuth.ts b/packages/express/src/middleware/requireAuth.ts index 710670e..4ae76e1 100644 --- a/packages/express/src/middleware/requireAuth.ts +++ b/packages/express/src/middleware/requireAuth.ts @@ -82,8 +82,7 @@ export function requireAuth(opts: RequireAuthOptions) { if (!token) { console.error( - "[SEAMLESS-AUTH-EXPRESS] - (requireAuth) - Missing expected cookie. Ensure you are using `cookieParser` in your express server", - cookieName, + "[SEAMLESS-AUTH-EXPRESS] - (requireAuth) - Missing expected auth cookie. Ensure you are using `cookieParser` in your express server", ); res.status(401).json({ error: "Failed to find authentication token required", diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 51e0622..6f54a40 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -128,9 +128,7 @@ describe("messaging delivery routes", () => { createJsonResponse(201, { success: true, data: { - url: "https://app.example.com/login?bootstrapToken=bootstrap-token", expiresAt: "2026-04-21T20:00:00.000Z", - token: "bootstrap-token", delivery: { kind: "bootstrap_invite_email", to: "admin@example.com", @@ -148,9 +146,7 @@ describe("messaging delivery routes", () => { expect(res.status).toBe(201); expect(res.body).toEqual({ - url: "https://app.example.com/login?bootstrapToken=bootstrap-token", expiresAt: "2026-04-21T20:00:00.000Z", - token: "bootstrap-token", }); expect(global.fetch).toHaveBeenCalledWith( @@ -160,7 +156,7 @@ describe("messaging delivery routes", () => { headers: expect.objectContaining({ "Content-Type": "application/json", Authorization: "Bearer bootstrap-secret", - "x-seamless-service-token": "Bearer bootstrap-secret", + "x-seamless-service-token": expect.stringMatching(/^Bearer /), "x-seamless-client-ip": expect.any(String), "x-seamless-auth-delivery-mode": "external", }), From 667d358e2d0f543ac218b9f0c61ea2e3259f90d4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 26 May 2026 23:58:26 -0400 Subject: [PATCH 2/9] ci: no need to run CI after the merge --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a575e63..8d6cd91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,6 @@ name: tests on: - push: - branches: - - dev - - main pull_request: branches: - dev From a7e1d1cba4bdc0c999d80d732b61da0f1903f542 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 27 May 2026 10:34:35 -0400 Subject: [PATCH 3/9] refactor: remove web mode support --- packages/core/src/handlers/register.ts | 44 ++++--------------- packages/express/src/createServer.ts | 7 ++- .../express/src/handlers/finishRegister.ts | 17 ------- 3 files changed, 11 insertions(+), 57 deletions(-) diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index f1e07b6..f0007a0 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -53,44 +53,16 @@ export async function registerHandler( }; } - const rawCookies = - (up.headers as any).getSetCookie?.() || - up.headers.get?.("set-cookie")?.split(",") || - []; - - let bootstrapCookie; - - for (const cookie of rawCookies) { - if (cookie.startsWith("seamless_bootstrap_token=")) { - const value = cookie.split(";")[0].split("=")[1]; - - bootstrapCookie = { - name: "seamless_bootstrap_token", - value: { sub: value }, - ttl: "900", - domain: opts.cookieDomain, - }; - - break; - } - } - - const setCookies = [ - { - name: opts.registrationCookieName, - value: { sub: data.sub }, - ttl: data.ttl, - domain: opts.cookieDomain, - }, - ]; - - if (bootstrapCookie) { - setCookies.push(bootstrapCookie); - } - return { status: 200, body: data, - setCookies, + setCookies: [ + { + name: opts.registrationCookieName, + value: { sub: data.sub }, + ttl: data.ttl, + domain: opts.cookieDomain, + }, + ], }; } diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 9b4a61a..22c8929 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -113,10 +113,9 @@ function routeParam(req: Request, name: string): string { /** * Creates an Express Router that proxies all authentication traffic to a Seamless Auth server. * - * This helper wires your API backend to a Seamless Auth instance running in - * "server mode." It automatically forwards login, registration, WebAuthn, - * logout, token refresh, and session validation routes to the auth server - * and handles all cookie management required for a seamless login flow. + * This helper wires your API backend to a Seamless Auth instance. It automatically forwards + * login, registration, WebAuthn, logout, token refresh, and session validation routes to the + * auth server and handles all cookie management required for a seamless login flow. * * ### Responsibilities * - Proxies all `/auth/*` routes to the upstream Seamless Auth server diff --git a/packages/express/src/handlers/finishRegister.ts b/packages/express/src/handlers/finishRegister.ts index 6424b1e..61c1b87 100644 --- a/packages/express/src/handlers/finishRegister.ts +++ b/packages/express/src/handlers/finishRegister.ts @@ -4,7 +4,6 @@ import { setSessionCookie } from "../internal/cookie"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; -import { verifyCookieJwt } from "@seamless-auth/core"; export async function finishRegister( req: Request & { cookiePayload?: any }, @@ -22,26 +21,10 @@ export async function finishRegister( const authorization = buildServiceAuthorization(req, opts); - const bootstrapToken = req.cookies?.["seamless_bootstrap_token"]; - - const headers: Record = {}; - - if (bootstrapToken) { - const payload = verifyCookieJwt(bootstrapToken, opts.cookieSecret); - if (!payload || !payload.sub) { - res.status(401).json({ - error: "Invalid or expired session", - }); - return; - } - headers["cookie"] = `seamless_bootstrap_token=${payload.sub}`; - } - const result = await finishRegisterHandler( { body: req.body, authorization, - headers, forwardedClientIp: buildForwardedClientIp(req), } as any, { From 14f554c4a439837d3b073bb0d9244a1d25d55152 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 29 May 2026 08:17:02 -0400 Subject: [PATCH 4/9] chore: v1 hardening --- README.md | 13 +++++++++++ packages/core/src/handlers/admin.ts | 9 ++++++++ packages/express/README.md | 14 ++++++++++++ packages/express/src/createServer.ts | 6 +++++ packages/express/src/handlers/admin.ts | 31 ++++++++++++++++++++++++++ 5 files changed, 73 insertions(+) diff --git a/README.md b/README.md index a140611..fa7d5d5 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ OAuth support is intentionally split across the same trust boundary as the rest - the adapter stores only the resulting SeamlessAuth cookies Provider access tokens are not stored by the adapter, returned to the frontend, or placed in cookies. +The adapter also does not handle provider client secrets; those remain on the Seamless Auth API host. Mounted Express routes include: @@ -166,6 +167,18 @@ Configure providers on the Seamless Auth API using `oauth_providers` and `LOGIN_ Each provider references its client secret by environment variable name, for example `clientSecretEnv: "GOOGLE_CLIENT_SECRET"`. +## Admin Hardening Routes + +The Express adapter exposes the v1 admin recovery and session hygiene routes under the mounted auth +path: + +- `DELETE /auth/admin/sessions/by-id/:id` +- `DELETE /auth/admin/sessions/:userId/revoke-all` +- `POST /auth/admin/users/:userId/recovery/device-replacement` + +The device-replacement route is enforced by the Seamless Auth API and requires a fresh step-up +session before sessions, passkeys, or TOTP credentials are reset. + --- ## Extensibility diff --git a/packages/core/src/handlers/admin.ts b/packages/core/src/handlers/admin.ts index 5b97b39..cf54d30 100644 --- a/packages/core/src/handlers/admin.ts +++ b/packages/core/src/handlers/admin.ts @@ -92,5 +92,14 @@ export const listAllSessionsHandler = (opts: WithQuery) => export const listUserSessionsHandler = (userId: string, opts: BaseOpts) => request("GET", `/admin/sessions/${userId}`, opts); +export const revokeUserSessionHandler = (id: string, opts: BaseOpts) => + request("DELETE", `/admin/sessions/by-id/${id}`, opts); + export const revokeAllUserSessionsHandler = (userId: string, opts: BaseOpts) => request("DELETE", `/admin/sessions/${userId}/revoke-all`, opts); + +export const recoverUserForDeviceReplacementHandler = ( + userId: string, + opts: WithBody, +) => + request("POST", `/admin/users/${userId}/recovery/device-replacement`, opts); diff --git a/packages/express/README.md b/packages/express/README.md index 8457e00..b92b3fa 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -230,6 +230,20 @@ Provider access tokens are never stored in adapter cookies or returned to the fr --- +### Admin Hardening Routes + +When mounted under `/auth`, the adapter proxies the admin hardening endpoints used by the +Seamless Auth dashboard: + +- `DELETE /auth/admin/sessions/by-id/:id` +- `DELETE /auth/admin/sessions/:userId/revoke-all` +- `POST /auth/admin/users/:userId/recovery/device-replacement` + +The device-replacement endpoint requires the current admin session to have fresh step-up +authentication in the Seamless Auth API. + +--- + ### `requireAuth(options?)` Express middleware that verifies a signed access cookie and attaches the decoded user payload to `req.user`. diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 22c8929..0fb4c9e 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -461,6 +461,9 @@ export function createSeamlessAuthServer( r.patch("/admin/users/:userId", (req, res) => admin.updateUser(req, res, resolvedOpts), ); + r.post("/admin/users/:userId/recovery/device-replacement", (req, res) => + admin.recoverUserForDeviceReplacement(req, res, resolvedOpts), + ); r.get("/admin/users/:userId", (req, res) => admin.getUserDetail(req, res, resolvedOpts), ); @@ -541,6 +544,9 @@ export function createSeamlessAuthServer( r.get("/admin/sessions/:userId", (req, res) => admin.listUserSessions(req, res, resolvedOpts), ); + r.delete("/admin/sessions/by-id/:id", (req, res) => + admin.revokeUserSession(req, res, resolvedOpts), + ); r.delete("/admin/sessions/:userId/revoke-all", (req, res) => admin.revokeAllUserSessions(req, res, resolvedOpts), ); diff --git a/packages/express/src/handlers/admin.ts b/packages/express/src/handlers/admin.ts index ac082aa..81837e8 100644 --- a/packages/express/src/handlers/admin.ts +++ b/packages/express/src/handlers/admin.ts @@ -10,7 +10,9 @@ import { getCredentialCountHandler, listAllSessionsHandler, listUserSessionsHandler, + recoverUserForDeviceReplacementHandler, revokeAllUserSessionsHandler, + revokeUserSessionHandler, } from "@seamless-auth/core/handlers/admin"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; @@ -168,6 +170,20 @@ export const listUserSessions = async ( } as any), ); +export const revokeUserSession = async ( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) => + handle( + res, + await revokeUserSessionHandler(req.params.id as string, { + authServerUrl: opts.authServerUrl, + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + } as any), + ); + export const revokeAllUserSessions = async ( req: Request, res: Response, @@ -181,3 +197,18 @@ export const revokeAllUserSessions = async ( forwardedClientIp: buildForwardedClientIp(req), } as any), ); + +export const recoverUserForDeviceReplacement = async ( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) => + handle( + res, + await recoverUserForDeviceReplacementHandler(req.params.userId as string, { + authServerUrl: opts.authServerUrl, + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + body: req.body, + } as any), + ); From 357b439d59f74dec0e06c8cd512f0d84255500d6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 29 May 2026 22:57:48 -0400 Subject: [PATCH 5/9] chore: updated logout function to support single session or multiple sessions --- packages/core/src/ensureCookies.ts | 1 + packages/core/src/handlers/logout.ts | 28 ++++- packages/core/tests/logoutHandler.test.js | 59 ++++++++++ packages/express/README.md | 4 +- packages/express/src/createServer.ts | 10 +- packages/express/src/handlers/logout.ts | 7 +- .../src/internal/buildAuthorization.ts | 2 + packages/express/tests/logoutRoutes.test.js | 108 ++++++++++++++++++ 8 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 packages/core/tests/logoutHandler.test.js create mode 100644 packages/express/tests/logoutRoutes.test.js diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 8761342..8d6b847 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -107,6 +107,7 @@ const COOKIE_REQUIREMENTS: Record< name: "preAuthCookieName", required: true, }, + "/logout/all": { name: "accessCookieName", required: true }, "/logout": { name: "accessCookieName", required: true }, "/users/me": { name: "accessCookieName", required: true }, "/organizations": { name: "accessCookieName", required: true }, diff --git a/packages/core/src/handlers/logout.ts b/packages/core/src/handlers/logout.ts index 55c2764..b7cb4ad 100644 --- a/packages/core/src/handlers/logout.ts +++ b/packages/core/src/handlers/logout.ts @@ -5,7 +5,9 @@ export interface LogoutOptions { accessCookieName: string; registrationCookieName: string; refreshCookieName: string; + authorization?: string; forwardedClientIp?: string; + scope?: LogoutScope; } export interface LogoutResult { @@ -13,16 +15,22 @@ export interface LogoutResult { clearCookies: string[]; } -export async function logoutHandler( - opts: LogoutOptions, -): Promise { - await authFetch(`${opts.authServerUrl}/logout`, { - method: "GET", +export type LogoutScope = "current_session" | "all_sessions"; + +function getLogoutPath(scope: LogoutScope) { + return scope === "all_sessions" ? "/logout/all" : "/logout"; +} + +export async function logoutHandler(opts: LogoutOptions): Promise { + const scope = opts.scope ?? "all_sessions"; + const upstream = await authFetch(`${opts.authServerUrl}${getLogoutPath(scope)}`, { + method: "DELETE", + authorization: opts.authorization, forwardedClientIp: opts.forwardedClientIp, }); return { - status: 204, + status: upstream.ok ? 204 : upstream.status, clearCookies: [ opts.accessCookieName, opts.registrationCookieName, @@ -30,3 +38,11 @@ export async function logoutHandler( ], }; } + +export function logoutCurrentSessionHandler(opts: Omit) { + return logoutHandler({ ...opts, scope: "current_session" }); +} + +export function logoutAllSessionsHandler(opts: Omit) { + return logoutHandler({ ...opts, scope: "all_sessions" }); +} diff --git a/packages/core/tests/logoutHandler.test.js b/packages/core/tests/logoutHandler.test.js new file mode 100644 index 0000000..8225e03 --- /dev/null +++ b/packages/core/tests/logoutHandler.test.js @@ -0,0 +1,59 @@ +import { jest } from "@jest/globals"; + +const authFetchMock = jest.fn(); + +jest.unstable_mockModule("../dist/authFetch.js", () => ({ + authFetch: authFetchMock, +})); + +const baseOptions = { + authServerUrl: "https://auth.example.com", + accessCookieName: "access", + registrationCookieName: "registration", + refreshCookieName: "refresh", + authorization: "Bearer service-token", + forwardedClientIp: "203.0.113.44", +}; + +describe("logoutHandler", () => { + beforeEach(() => authFetchMock.mockReset()); + + it("logs out all sessions by default for backward compatibility", async () => { + const { logoutHandler } = await import("../dist/handlers/logout.js"); + + authFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await logoutHandler(baseOptions); + + expect(authFetchMock).toHaveBeenCalledWith( + "https://auth.example.com/logout/all", + { + method: "DELETE", + authorization: "Bearer service-token", + forwardedClientIp: "203.0.113.44", + }, + ); + expect(result).toEqual({ + status: 204, + clearCookies: ["access", "registration", "refresh"], + }); + }); + + it("can log out only the current session", async () => { + const { logoutCurrentSessionHandler } = await import( + "../dist/handlers/logout.js" + ); + + authFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + await logoutCurrentSessionHandler(baseOptions); + + expect(authFetchMock).toHaveBeenCalledWith( + "https://auth.example.com/logout", + expect.objectContaining({ + method: "DELETE", + authorization: "Bearer service-token", + }), + ); + }); +}); diff --git a/packages/express/README.md b/packages/express/README.md index b92b3fa..1ecbe87 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -142,7 +142,9 @@ Routes include: - `/auth/step-up/*` - `/auth/registration/*` - `/auth/users/me` -- `/auth/logout` +- `DELETE /auth/logout` for the current session +- `DELETE /auth/logout/all` for every session owned by the current user +- `GET /auth/logout` as a deprecated all-session compatibility route **Options** diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 0fb4c9e..62deeae 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -317,7 +317,15 @@ export function createSeamlessAuthServer( ); r.get("/users/me", (req, res) => me(req, res, resolvedOpts)); - r.get("/logout", (req, res) => logout(req, res, resolvedOpts)); + r.get("/logout", (req, res) => + logout(req, res, resolvedOpts, "all_sessions"), + ); + r.delete("/logout", (req, res) => + logout(req, res, resolvedOpts, "current_session"), + ); + r.delete("/logout/all", (req, res) => + logout(req, res, resolvedOpts, "all_sessions"), + ); r.get("/organizations", proxyWithIdentity("organizations", "access", "GET")); r.post("/organizations", proxyWithIdentity("organizations", "access")); diff --git a/packages/express/src/handlers/logout.ts b/packages/express/src/handlers/logout.ts index 93b665a..5558d98 100644 --- a/packages/express/src/handlers/logout.ts +++ b/packages/express/src/handlers/logout.ts @@ -1,21 +1,26 @@ import { Request, Response } from "express"; import { logoutHandler } from "@seamless-auth/core/handlers/logout"; +import type { LogoutScope } from "@seamless-auth/core/handlers/logout"; import { clearAllCookies } from "../internal/cookie"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; +import { buildServiceAuthorization } from "../internal/buildAuthorization"; export async function logout( req: Request, res: Response, opts: SeamlessAuthServerOptions, + scope: LogoutScope = "current_session", ) { const result = await logoutHandler({ authServerUrl: opts.authServerUrl, accessCookieName: opts.accessCookieName!, registrationCookieName: opts.registrationCookieName!, refreshCookieName: opts.refreshCookieName!, + authorization: buildServiceAuthorization(req, opts), forwardedClientIp: buildForwardedClientIp(req), - } as any); + scope, + }); clearAllCookies(res, opts.cookieDomain || "", ...result.clearCookies); diff --git a/packages/express/src/internal/buildAuthorization.ts b/packages/express/src/internal/buildAuthorization.ts index bf44866..5bf3bdd 100644 --- a/packages/express/src/internal/buildAuthorization.ts +++ b/packages/express/src/internal/buildAuthorization.ts @@ -7,6 +7,7 @@ export function buildServiceAuthorization( opts: SeamlessAuthServerOptions, ) { const subject = req.cookiePayload?.sub || req.user?.sub; + const sessionId = req.cookiePayload?.sessionId || req.user?.sessionId; if (!subject) { return undefined; @@ -18,6 +19,7 @@ export function buildServiceAuthorization( audience: opts.audience, serviceSecret: opts.serviceSecret, keyId: opts.jwksKid || "dev-main", + ...(sessionId === undefined ? {} : { sessionId }), }); return `Bearer ${token}`; diff --git a/packages/express/tests/logoutRoutes.test.js b/packages/express/tests/logoutRoutes.test.js new file mode 100644 index 0000000..a5d695a --- /dev/null +++ b/packages/express/tests/logoutRoutes.test.js @@ -0,0 +1,108 @@ +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 createResponse(status = 200) { + return { + ok: status >= 200 && status < 300, + status, + }; +} + +function createAccessCookie(subject = "user-123") { + const token = jwt.sign( + { sub: subject, roles: ["user"], sessionId: "session-123" }, + "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("logout routes", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue(createResponse()); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("logs out the current session with DELETE /logout", async () => { + const res = await request(createApp()) + .delete("/auth/logout") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(204); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/logout", + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Bearer /), + "x-seamless-service-token": expect.stringMatching(/^Bearer /), + }), + }), + ); + + const authorization = + global.fetch.mock.calls[0][1].headers.Authorization.replace("Bearer ", ""); + const decoded = jwt.decode(authorization); + + expect(decoded.sid).toBe("session-123"); + }); + + it("keeps GET /logout as an all-session compatibility route", async () => { + const res = await request(createApp()) + .get("/auth/logout") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(204); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/logout/all", + expect.objectContaining({ + method: "DELETE", + }), + ); + }); + + it("logs out all sessions with DELETE /logout/all", async () => { + const res = await request(createApp()) + .delete("/auth/logout/all") + .set("Cookie", createAccessCookie()); + + expect(res.status).toBe(204); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/logout/all", + expect.objectContaining({ + method: "DELETE", + }), + ); + }); +}); From d4d9e431903f088f137c18f3ce398fa5a17ee662 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 08:41:08 -0400 Subject: [PATCH 6/9] chore: clean up work --- packages/core/src/ensureCookies.ts | 4 ++ packages/core/src/handlers/finishLogin.ts | 1 + packages/core/src/handlers/finishRegister.ts | 1 + packages/core/src/handlers/oauthHandlers.ts | 1 + .../pollMagicLinkConfirmationHandler.ts | 1 + packages/core/src/handlers/register.ts | 2 +- .../src/handlers/switchOrganizationHandler.ts | 1 + .../src/handlers/verifyLoginOtpHandler.ts | 1 + packages/core/tests/ensureCookes.test.js | 6 +++ packages/core/tests/oauthHandlers.test.js | 1 + packages/core/tests/registerHandler.test.js | 54 +++++++++++++++++++ packages/express/package.json | 4 +- .../src/internal/buildAuthorization.ts | 20 ++----- packages/express/tests/loginOtpRoutes.test.js | 44 ++++++++++++--- packages/express/tests/logoutRoutes.test.js | 17 +++--- .../express/tests/messagingDelivery.test.js | 14 +++-- .../express/tests/organizationRoutes.test.js | 11 ++-- packages/express/tests/stepUpProxy.test.js | 32 ++++++----- 18 files changed, 160 insertions(+), 55 deletions(-) create mode 100644 packages/core/tests/registerHandler.test.js diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 8d6b847..a68f540 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -32,6 +32,7 @@ export interface EnsureCookiesResult { user?: { sub: string; sessionId?: string; + token?: string; roles?: string[]; }; setCookies?: CookieInstruction[]; @@ -224,6 +225,7 @@ export async function ensureCookies( ...(refreshed.sessionId === undefined ? {} : { sessionId: refreshed.sessionId }), + token: refreshed.token, roles: refreshed.roles, }, setCookies: [ @@ -234,6 +236,7 @@ export async function ensureCookies( ...(refreshed.sessionId === undefined ? {} : { sessionId: refreshed.sessionId }), + token: refreshed.token, roles: refreshed.roles, email: refreshed.email, phone: refreshed.phone, @@ -272,6 +275,7 @@ export async function ensureCookies( ...(typeof payload.sessionId === "string" ? { sessionId: payload.sessionId } : {}), + ...(typeof payload.token === "string" ? { token: payload.token } : {}), roles: payload.roles as string[] | undefined, }, }; diff --git a/packages/core/src/handlers/finishLogin.ts b/packages/core/src/handlers/finishLogin.ts index 168f510..6e77a92 100644 --- a/packages/core/src/handlers/finishLogin.ts +++ b/packages/core/src/handlers/finishLogin.ts @@ -74,6 +74,7 @@ export async function finishLoginHandler( value: { sub: data.sub, ...(sessionId === undefined ? {} : { sessionId }), + token: data.token, roles: data.roles, email: data.email, phone: data.phone, diff --git a/packages/core/src/handlers/finishRegister.ts b/packages/core/src/handlers/finishRegister.ts index 6ce9a35..721382f 100644 --- a/packages/core/src/handlers/finishRegister.ts +++ b/packages/core/src/handlers/finishRegister.ts @@ -72,6 +72,7 @@ export async function finishRegisterHandler( value: { sub: data.sub, ...(sessionId === undefined ? {} : { sessionId }), + token: data.token, roles: data.roles, email: data.email, phone: data.phone, diff --git a/packages/core/src/handlers/oauthHandlers.ts b/packages/core/src/handlers/oauthHandlers.ts index bac3164..203c8ca 100644 --- a/packages/core/src/handlers/oauthHandlers.ts +++ b/packages/core/src/handlers/oauthHandlers.ts @@ -112,6 +112,7 @@ export async function finishOAuthLoginHandler( value: { sub: data.sub, ...(sessionId === undefined ? {} : { sessionId }), + token: data.token, roles: data.roles, email: data.email, phone: data.phone, diff --git a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts index 2497b2f..4e1d765 100644 --- a/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts +++ b/packages/core/src/handlers/pollMagicLinkConfirmationHandler.ts @@ -89,6 +89,7 @@ export async function pollMagicLinkConfirmationHandler( value: { sub: data.sub, ...(sessionId === undefined ? {} : { sessionId }), + token: data.token, roles: data.roles, email: data.email, phone: data.phone, diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index f0007a0..dc780e8 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -59,7 +59,7 @@ export async function registerHandler( setCookies: [ { name: opts.registrationCookieName, - value: { sub: data.sub }, + value: { sub: data.sub, token: data.token }, ttl: data.ttl, domain: opts.cookieDomain, }, diff --git a/packages/core/src/handlers/switchOrganizationHandler.ts b/packages/core/src/handlers/switchOrganizationHandler.ts index 27e7fdd..ba38d44 100644 --- a/packages/core/src/handlers/switchOrganizationHandler.ts +++ b/packages/core/src/handlers/switchOrganizationHandler.ts @@ -82,6 +82,7 @@ export async function switchOrganizationHandler( value: { sub: data.sub, ...(sessionId === undefined ? {} : { sessionId }), + token: data.token, roles: data.roles, email: data.email, phone: data.phone, diff --git a/packages/core/src/handlers/verifyLoginOtpHandler.ts b/packages/core/src/handlers/verifyLoginOtpHandler.ts index ef69426..1dc257c 100644 --- a/packages/core/src/handlers/verifyLoginOtpHandler.ts +++ b/packages/core/src/handlers/verifyLoginOtpHandler.ts @@ -80,6 +80,7 @@ export async function verifyLoginOtpHandler( value: { sub: data.sub, ...(sessionId === undefined ? {} : { sessionId }), + token: data.token, roles: data.roles, email: data.email, phone: data.phone, diff --git a/packages/core/tests/ensureCookes.test.js b/packages/core/tests/ensureCookes.test.js index b079fe9..bd338f8 100644 --- a/packages/core/tests/ensureCookes.test.js +++ b/packages/core/tests/ensureCookes.test.js @@ -107,6 +107,7 @@ describe("ensureCookies", () => { expect(result.type).toBe("ok"); expect(result.user?.sub).toBe("user-123"); expect(result.user?.sessionId).toBe("session-123"); + expect(result.user?.token).toBe("new-access"); expect(result.setCookies).toHaveLength(2); @@ -115,6 +116,7 @@ describe("ensureCookies", () => { expect(accessCookie.value).toEqual({ sub: "user-123", sessionId: "session-123", + token: "new-access", roles: ["user"], email: "test@example.com", phone: "+14155552671", @@ -209,6 +211,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "access-token", sessionId: "session-123", roles: ["user"], }); @@ -225,6 +228,7 @@ describe("ensureCookies", () => { expect(result.user).toEqual({ sub: "user-123", sessionId: "session-123", + token: "access-token", roles: ["user"], }); }); @@ -234,6 +238,7 @@ describe("ensureCookies", () => { verifyCookieJwtMock.mockReturnValue({ sub: "user-123", + token: "access-token", sessionId: "session-123", roles: ["user"], }); @@ -250,6 +255,7 @@ describe("ensureCookies", () => { expect(result.user).toEqual({ sub: "user-123", sessionId: "session-123", + token: "access-token", roles: ["user"], }); }); diff --git a/packages/core/tests/oauthHandlers.test.js b/packages/core/tests/oauthHandlers.test.js index eba503f..0466d6d 100644 --- a/packages/core/tests/oauthHandlers.test.js +++ b/packages/core/tests/oauthHandlers.test.js @@ -104,6 +104,7 @@ describe("oauthHandlers", () => { value: expect.objectContaining({ sub: "user-123", sessionId: "session-123", + token: "access-token", organizationId: "org-123", }), }), diff --git a/packages/core/tests/registerHandler.test.js b/packages/core/tests/registerHandler.test.js new file mode 100644 index 0000000..c1402b7 --- /dev/null +++ b/packages/core/tests/registerHandler.test.js @@ -0,0 +1,54 @@ +import { jest } from "@jest/globals"; + +function createJsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +describe("registerHandler", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("stores the upstream ephemeral token in the registration cookie", async () => { + const { registerHandler } = await import("../dist/handlers/register.js"); + + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "Success", + sub: "user-123", + token: "ephemeral-token", + ttl: 300, + }), + ); + + const result = await registerHandler( + { + body: { email: "user@example.com", phone: "+14155552671" }, + }, + { + authServerUrl: "https://auth.example.com", + registrationCookieName: "registration", + }, + ); + + expect(result.status).toBe(200); + expect(result.setCookies).toEqual([ + { + name: "registration", + value: { sub: "user-123", token: "ephemeral-token" }, + ttl: 300, + domain: undefined, + }, + ]); + }); +}); diff --git a/packages/express/package.json b/packages/express/package.json index 3670d27..227f38a 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -41,7 +41,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "workspace:^", + "@seamless-auth/core": "file:../core", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, @@ -60,4 +60,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/express/src/internal/buildAuthorization.ts b/packages/express/src/internal/buildAuthorization.ts index 5bf3bdd..ef019ba 100644 --- a/packages/express/src/internal/buildAuthorization.ts +++ b/packages/express/src/internal/buildAuthorization.ts @@ -4,25 +4,11 @@ import { SeamlessAuthServerOptions } from "../createServer"; export function buildServiceAuthorization( req: Request & { cookiePayload?: any }, - opts: SeamlessAuthServerOptions, + _opts?: SeamlessAuthServerOptions, ) { - const subject = req.cookiePayload?.sub || req.user?.sub; - const sessionId = req.cookiePayload?.sessionId || req.user?.sessionId; + const token = req.cookiePayload?.token || req.user?.token; - if (!subject) { - return undefined; - } - - const token = createServiceToken({ - subject, - issuer: opts.issuer, - audience: opts.audience, - serviceSecret: opts.serviceSecret, - keyId: opts.jwksKid || "dev-main", - ...(sessionId === undefined ? {} : { sessionId }), - }); - - return `Bearer ${token}`; + return typeof token === "string" ? `Bearer ${token}` : undefined; } export function buildInternalServiceAuthorization(opts: SeamlessAuthServerOptions) { diff --git a/packages/express/tests/loginOtpRoutes.test.js b/packages/express/tests/loginOtpRoutes.test.js index 7b34277..814068b 100644 --- a/packages/express/tests/loginOtpRoutes.test.js +++ b/packages/express/tests/loginOtpRoutes.test.js @@ -14,10 +14,14 @@ function createJsonResponse(status, body) { } function createPreAuthCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, token: "ephemeral-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-ephemeral=${token}`; } @@ -68,8 +72,36 @@ describe("login OTP routes", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer ephemeral-token", + "x-seamless-service-token": "Bearer ephemeral-token", + }), + }), + ); + }); + + it("proxies registration phone OTP verification with the stored ephemeral token", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { + message: "Phone verified successfully.", + }), + ); + + const body = { verificationToken: "123456" }; + + const res = await request(createApp()) + .post("/auth/otp/verify-phone-otp") + .set("Cookie", createPreAuthCookie()) + .send(body); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/otp/verify-phone-otp", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(body), + headers: expect.objectContaining({ + Authorization: "Bearer ephemeral-token", + "x-seamless-service-token": "Bearer ephemeral-token", }), }), ); diff --git a/packages/express/tests/logoutRoutes.test.js b/packages/express/tests/logoutRoutes.test.js index a5d695a..a3c1a31 100644 --- a/packages/express/tests/logoutRoutes.test.js +++ b/packages/express/tests/logoutRoutes.test.js @@ -14,7 +14,12 @@ function createResponse(status = 200) { function createAccessCookie(subject = "user-123") { const token = jwt.sign( - { sub: subject, roles: ["user"], sessionId: "session-123" }, + { + sub: subject, + roles: ["user"], + sessionId: "session-123", + token: "access-token", + }, "cookie-secret", { algorithm: "HS256", @@ -65,17 +70,11 @@ describe("logout routes", () => { expect.objectContaining({ method: "DELETE", headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", }), }), ); - - const authorization = - global.fetch.mock.calls[0][1].headers.Authorization.replace("Bearer ", ""); - const decoded = jwt.decode(authorization); - - expect(decoded.sid).toBe("session-123"); }); it("keeps GET /logout as an all-session compatibility route", async () => { diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index 6f54a40..328be6c 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -14,10 +14,14 @@ function createJsonResponse(status, body) { } function createPreAuthCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, token: "ephemeral-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-ephemeral=${token}`; } @@ -96,7 +100,7 @@ describe("messaging delivery routes", () => { headers: expect.objectContaining({ "Content-Type": "application/json", "x-seamless-auth-delivery-mode": "external", - Authorization: expect.stringMatching(/^Bearer /), + Authorization: "Bearer ephemeral-token", "x-seamless-service-token": expect.stringMatching(/^Bearer /), "x-seamless-client-ip": expect.any(String), }), diff --git a/packages/express/tests/organizationRoutes.test.js b/packages/express/tests/organizationRoutes.test.js index c376605..204cf48 100644 --- a/packages/express/tests/organizationRoutes.test.js +++ b/packages/express/tests/organizationRoutes.test.js @@ -15,7 +15,12 @@ function createJsonResponse(status, body) { function createAccessCookie(subject = "user-123") { const token = jwt.sign( - { sub: subject, roles: ["admin"], sessionId: "session-123" }, + { + sub: subject, + roles: ["admin"], + sessionId: "session-123", + token: "access-token", + }, "cookie-secret", { algorithm: "HS256", @@ -77,8 +82,8 @@ describe("organization proxy routes", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", }), }), ); diff --git a/packages/express/tests/stepUpProxy.test.js b/packages/express/tests/stepUpProxy.test.js index c2e05e7..27488fd 100644 --- a/packages/express/tests/stepUpProxy.test.js +++ b/packages/express/tests/stepUpProxy.test.js @@ -14,19 +14,27 @@ function createJsonResponse(status, body) { } function createAccessCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject, roles: ["user"] }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, roles: ["user"], token: "access-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-access=${token}`; } function createRegistrationCookie(subject = "user-123") { - const token = jwt.sign({ sub: subject, roles: ["user"] }, "cookie-secret", { - algorithm: "HS256", - expiresIn: "300s", - }); + const token = jwt.sign( + { sub: subject, roles: ["user"], token: "ephemeral-token" }, + "cookie-secret", + { + algorithm: "HS256", + expiresIn: "300s", + }, + ); return `seamless-ephemeral=${token}`; } @@ -89,8 +97,8 @@ describe("step-up proxy routes", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", }), }), ); @@ -131,8 +139,8 @@ describe("step-up proxy routes", () => { method: "POST", body: JSON.stringify(body), headers: expect.objectContaining({ - Authorization: expect.stringMatching(/^Bearer /), - "x-seamless-service-token": expect.stringMatching(/^Bearer /), + Authorization: "Bearer access-token", + "x-seamless-service-token": "Bearer access-token", }), }), ); From ac962999321f9d849ff4a1d7d291971dd255a5da Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 08:44:33 -0400 Subject: [PATCH 7/9] chore: changeset --- .changeset/soft-chefs-retire.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/soft-chefs-retire.md diff --git a/.changeset/soft-chefs-retire.md b/.changeset/soft-chefs-retire.md new file mode 100644 index 0000000..9083d56 --- /dev/null +++ b/.changeset/soft-chefs-retire.md @@ -0,0 +1,6 @@ +--- +"@seamless-auth/express": patch +"@seamless-auth/core": patch +--- + +Operational tidy work and extension of the logout functions for future use From 0a598fcfe85cffc3d689328ffd1cc655ff27793d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 08:46:37 -0400 Subject: [PATCH 8/9] fix: undo local core install --- packages/express/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/express/package.json b/packages/express/package.json index 227f38a..8912130 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -41,7 +41,7 @@ "express": ">=4.18.0" }, "dependencies": { - "@seamless-auth/core": "file:../core", + "@seamless-auth/core": "workspace:^", "cookie-parser": "^1.4.6", "jsonwebtoken": "^9.0.3" }, From e0fb657219d7beb77468159cecac439020f1dc25 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 30 May 2026 17:39:37 -0400 Subject: [PATCH 9/9] fix: duplicate exports of buildAuthtorization after rebase --- .../express/src/internal/buildAuthorization.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/express/src/internal/buildAuthorization.ts b/packages/express/src/internal/buildAuthorization.ts index 2528684..0dd851e 100644 --- a/packages/express/src/internal/buildAuthorization.ts +++ b/packages/express/src/internal/buildAuthorization.ts @@ -11,19 +11,9 @@ export function buildServiceAuthorization( return typeof token === "string" ? `Bearer ${token}` : undefined; } -export function buildInternalServiceAuthorization(opts: SeamlessAuthServerOptions) { - const token = createServiceToken({ - subject: "seamless-auth-external-delivery", - issuer: "seamless-portal-api", - audience: "seamless-auth", - serviceSecret: opts.serviceSecret, - keyId: opts.jwksKid || "dev-main", - }); - - return `Bearer ${token}`; -} - -export function buildInternalServiceAuthorization(opts: SeamlessAuthServerOptions) { +export function buildInternalServiceAuthorization( + opts: SeamlessAuthServerOptions, +) { const token = createServiceToken({ subject: "seamless-auth-external-delivery", issuer: "seamless-portal-api",