Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
name: tests

on:
push:
branches:
- dev
- main
pull_request:
branches:
- dev
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { verifyCookieJwt } from "./verifyCookieJwt.js";
import { refreshAccessToken } from "./refreshAccessToken.js";
import { redactSensitiveText } from "./redaction.js";

export interface EnsureCookiesInput {
path: string;
Expand Down Expand Up @@ -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" };
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/handlers/bootstrapAdminInvite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface BootstrapAdminInviteOptions {
authServerUrl: string;
email: string;
authorization?: string;
serviceAuthorization?: string;
externalDelivery?: boolean;
forwardedClientIp?: string;
}
Expand All @@ -14,6 +15,7 @@ export interface BootstrapAdminInviteResult {
url?: string;
expiresAt: string;
token?: string;
delivery?: unknown;
};
error?: string;
}
Expand All @@ -26,6 +28,7 @@ export async function bootstrapAdminInviteHandler(
{
method: "POST",
authorization: opts.authorization,
serviceAuthorization: opts.serviceAuthorization,
forwardedClientIp: opts.forwardedClientIp,
headers: {
...(opts.externalDelivery
Expand Down
46 changes: 10 additions & 36 deletions packages/core/src/handlers/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface RegisterOptions {
registrationCookieName: string;
externalDelivery?: boolean;
forwardedClientIp?: string;
serviceAuthorization?: string;
}

export interface RegisterResult {
Expand All @@ -33,6 +34,7 @@ export async function registerHandler(
method: "POST",
body: input.body,
forwardedClientIp: opts.forwardedClientIp,
serviceAuthorization: opts.serviceAuthorization,
...(opts.externalDelivery
? {
headers: {
Expand All @@ -51,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,
},
],
};
}
2 changes: 2 additions & 0 deletions packages/core/src/handlers/requestMagicLinkHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface RequestMagicLinkOptions {
authServerUrl: string;
externalDelivery?: boolean;
forwardedClientIp?: string;
serviceAuthorization?: string;
}

export interface RequestMagicLinkResult {
Expand All @@ -24,6 +25,7 @@ export async function requestMagicLinkHandler(
method: "GET",
authorization: input.authorization,
forwardedClientIp: opts.forwardedClientIp,
serviceAuthorization: opts.serviceAuthorization,
...(opts.externalDelivery
? {
headers: {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/handlers/requestOtpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface RequestOtpOptions {
authServerUrl: string;
externalDelivery?: boolean;
forwardedClientIp?: string;
serviceAuthorization?: string;
}

export interface RequestOtpResult {
Expand All @@ -36,6 +37,7 @@ export async function requestOtpHandler(
method: "GET",
authorization: input.authorization,
forwardedClientIp: opts.forwardedClientIp,
serviceAuthorization: opts.serviceAuthorization,
...(opts.externalDelivery
? {
headers: {
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/redaction.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
4 changes: 2 additions & 2 deletions packages/core/src/verifySignedAuthResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export async function verifySignedAuthResponse<T = any>(
});

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;
}
}
14 changes: 14 additions & 0 deletions packages/core/tests/redaction.test.js
Original file line number Diff line number Diff line change
@@ -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]",
);
});
});
2 changes: 1 addition & 1 deletion packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
7 changes: 3 additions & 4 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/express/src/handlers/bootstrapAdmininvite.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) {
Expand Down
17 changes: 0 additions & 17 deletions packages/express/src/handlers/finishRegister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -22,26 +21,10 @@ export async function finishRegister(

const authorization = buildServiceAuthorization(req, opts);

const bootstrapToken = req.cookies?.["seamless_bootstrap_token"];

const headers: Record<string, string> = {};

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,
{
Expand Down
4 changes: 4 additions & 0 deletions packages/express/src/handlers/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
);

Expand Down
8 changes: 7 additions & 1 deletion packages/express/src/handlers/requestMagicLink.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
);

Expand Down
8 changes: 7 additions & 1 deletion packages/express/src/handlers/requestOtp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
);

Expand Down
12 changes: 12 additions & 0 deletions packages/express/src/internal/buildAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
3 changes: 1 addition & 2 deletions packages/express/src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 1 addition & 5 deletions packages/express/tests/messagingDelivery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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",
}),
Expand Down
Loading