From c4b55330e9a8448a1bd71c3f370d9bbd254e26ac Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 26 May 2026 23:17:42 -0400 Subject: [PATCH 1/3] 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/3] 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 0bdfcdf4ea6e21d994fc5440006b55e80761bc55 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 27 May 2026 10:34:35 -0400 Subject: [PATCH 3/3] 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, {