From f0e7109165754dc6d0256522749f472a727c7e06 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 8 Oct 2026 08:10:16 -0400 Subject: [PATCH] fix(core): keep ephemeral tokens out of cookie-transport bodies Under cookie transport the OTP send routes returned the ephemeral token the auth API re-mints on every send, and registration returned the token it had just stored in the registration cookie. Page scripts could read both, which is what the httpOnly cookie exists to prevent. Both now drop token and refreshToken from the body in cookie transport, through one helper the manifest proxy also uses. requestOtpHandler takes a transport option, which every adapter passes; without it the token is dropped. Bearer transport is unchanged. Closes #202. --- .../cookie-transport-ephemeral-tokens.md | 12 +++ packages/core/src/bodyTokens.ts | 22 ++++++ packages/core/src/handlers/register.ts | 3 +- .../core/src/handlers/requestOtpHandler.ts | 6 +- packages/core/src/manifestProxy.ts | 22 ++---- packages/core/tests/registerHandler.test.js | 42 +++++++++++ packages/core/tests/requestOtpHandler.test.js | 67 +++++++++++++++++ packages/express/src/handlers/requestOtp.ts | 2 + packages/fastify/src/routes/authRoutes.ts | 1 + packages/fastify/tests/parity.test.js | 73 +++++++++++++++++++ packages/nextjs/src/routes/authRoutes.ts | 1 + packages/nextjs/tests/parity.test.js | 73 +++++++++++++++++++ 12 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 .changeset/cookie-transport-ephemeral-tokens.md create mode 100644 packages/core/src/bodyTokens.ts diff --git a/.changeset/cookie-transport-ephemeral-tokens.md b/.changeset/cookie-transport-ephemeral-tokens.md new file mode 100644 index 0000000..9c62ef6 --- /dev/null +++ b/.changeset/cookie-transport-ephemeral-tokens.md @@ -0,0 +1,12 @@ +--- +"@seamless-auth/core": patch +"@seamless-auth/express": patch +"@seamless-auth/fastify": patch +"@seamless-auth/nextjs": patch +--- + +Keep ephemeral tokens out of cookie-transport response bodies (#202). + +The OTP send routes (`POST /otp/generate-email-otp`, `/otp/generate-phone-otp`, `/otp/generate-login-email-otp`, `/otp/generate-login-phone-otp`) returned the ephemeral token the auth API re-mints on every send, and `POST /registration/register` returned the registration token it had just stored in the cookie. Page scripts could read both, which the httpOnly cookie exists to prevent. Under cookie transport these bodies no longer carry `token` or `refreshToken`. Bearer transport is unchanged, because the client holds its own tokens there. + +`requestOtpHandler` takes an optional `transport`. Without it the token is dropped, so a caller serving bearer clients through this handler directly should pass `transport: "bearer"`. diff --git a/packages/core/src/bodyTokens.ts b/packages/core/src/bodyTokens.ts new file mode 100644 index 0000000..16d8eff --- /dev/null +++ b/packages/core/src/bodyTokens.ts @@ -0,0 +1,22 @@ +/** + * A response body with the auth API's tokens removed. + * + * In cookie transport the cookies carry every token, so the browser must never + * see one in a body: a page script could read it, and bodies end up where cookies + * do not (devtools exports, APM payload capture, service workers, proxy logs). + * That covers ephemeral tokens as well as sessions, because the auth API also + * returns the token it re-mints on an OTP send and the one it issues on + * registration. + */ +export function withoutTokens(data: unknown): unknown { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return data; + } + + const { token: _token, refreshToken: _refreshToken, ...rest } = data as Record< + string, + unknown + >; + + return rest; +} diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index 812e7d4..7fa0501 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -4,6 +4,7 @@ import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; import type { AuthTransport } from "../transport.js"; +import { withoutTokens } from "../bodyTokens.js"; export interface RegisterInput { body: unknown; @@ -63,7 +64,7 @@ export async function registerHandler( return { status: 200, - body: data, + body: withoutTokens(data), setCookies: [ { name: opts.registrationCookieName, diff --git a/packages/core/src/handlers/requestOtpHandler.ts b/packages/core/src/handlers/requestOtpHandler.ts index 685d539..6347463 100644 --- a/packages/core/src/handlers/requestOtpHandler.ts +++ b/packages/core/src/handlers/requestOtpHandler.ts @@ -2,6 +2,8 @@ import { authFetch } from "../authFetch.js"; import { readPassthroughFailure } from "../upstreamError.js"; import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; +import type { AuthTransport } from "../transport.js"; +import { withoutTokens } from "../bodyTokens.js"; export interface RequestOtpInput { authorization?: string; @@ -15,6 +17,8 @@ export interface RequestOtpOptions { forwardedClientIp?: string; forwardedUserAgent?: string; serviceAuthorization?: string; + /** Cookie transport (the default) drops the re-minted token from the body. */ + transport?: AuthTransport; } export interface RequestOtpResult extends ResultFailure { @@ -60,6 +64,6 @@ export async function requestOtpHandler( return { status: up.status, - body: data, + body: opts.transport === "bearer" ? data : withoutTokens(data), }; } diff --git a/packages/core/src/manifestProxy.ts b/packages/core/src/manifestProxy.ts index f6f4687..431955f 100644 --- a/packages/core/src/manifestProxy.ts +++ b/packages/core/src/manifestProxy.ts @@ -3,6 +3,7 @@ import { authFetch } from "./authFetch.js"; import type { AuthServerIssuerOption } from "./authServerIssuer.js"; import type { SeamlessAuthMessagingOptions } from "./authMessaging.js"; import { EXTERNAL_DELIVERY_HEADERS } from "./apiContract.js"; +import { withoutTokens } from "./bodyTokens.js"; import { applyExternalDelivery } from "./deliverAuthMessage.js"; import { type AdapterHeld, @@ -80,24 +81,13 @@ function isUpstreamSession(value: unknown): value is UpstreamSessionResponse { ); } -/** - * The body a cookie-transport caller receives. - * - * The browser never sees a token: the cookies carry them. That holds for every - * route, not only session-issuing ones, because the auth API also returns the - * ephemeral token it re-mints on an OTP send. - */ +/** The body a cookie-transport caller receives: a `pick`, or everything but tokens. */ function cookieTransportBody(route: AdapterManifestRoute, data: unknown) { - if (!isObject(data)) return data; + if (!isObject(data) || !route.body) return withoutTokens(data); - if (route.body) { - return Object.fromEntries( - route.body.pick.filter((key) => key in data).map((key) => [key, data[key]]), - ); - } - - const { token: _token, refreshToken: _refreshToken, ...rest } = data; - return rest; + return Object.fromEntries( + route.body.pick.filter((key) => key in data).map((key) => [key, data[key]]), + ); } async function cookiesFor( diff --git a/packages/core/tests/registerHandler.test.js b/packages/core/tests/registerHandler.test.js index c1402b7..34ee708 100644 --- a/packages/core/tests/registerHandler.test.js +++ b/packages/core/tests/registerHandler.test.js @@ -51,4 +51,46 @@ describe("registerHandler", () => { }, ]); }); + + const REGISTERED = { + message: "Success", + sub: "user-123", + token: "ephemeral-token", + ttl: 300, + }; + + // The cookie holds the registration token. Returning it in the body as well + // let page scripts read the token the httpOnly cookie exists to hide (#202). + it("keeps the registration token out of a cookie-transport body", async () => { + const { registerHandler } = await import("../dist/handlers/register.js"); + global.fetch.mockResolvedValue(createJsonResponse(200, REGISTERED)); + + const result = await registerHandler( + { body: { email: "user@example.com" } }, + { + authServerUrl: "https://auth.example.com", + registrationCookieName: "registration", + }, + ); + + expect(result.body).toEqual({ message: "Success", sub: "user-123", ttl: 300 }); + expect(result.setCookies[0].value.token).toBe("ephemeral-token"); + }); + + it("returns the whole body in bearer transport", async () => { + const { registerHandler } = await import("../dist/handlers/register.js"); + global.fetch.mockResolvedValue(createJsonResponse(200, REGISTERED)); + + const result = await registerHandler( + { body: { email: "user@example.com" } }, + { + authServerUrl: "https://auth.example.com", + registrationCookieName: "registration", + transport: "bearer", + }, + ); + + expect(result.body).toEqual(REGISTERED); + expect(result.setCookies).toBeUndefined(); + }); }); diff --git a/packages/core/tests/requestOtpHandler.test.js b/packages/core/tests/requestOtpHandler.test.js index 70bf601..5451827 100644 --- a/packages/core/tests/requestOtpHandler.test.js +++ b/packages/core/tests/requestOtpHandler.test.js @@ -55,4 +55,71 @@ describe("requestOtpHandler", () => { expect.objectContaining({ method: "GET" }), ); }); + + // The auth API re-mints the caller's ephemeral token on every send. The cookie + // already holds one, and page scripts must not be able to read it (#202). + it("drops the re-minted token from the body by default", async () => { + const { requestOtpHandler } = await import( + "../dist/handlers/requestOtpHandler.js" + ); + global.fetch.mockResolvedValue( + jsonResponse(200, { message: "success", token: "re-minted" }), + ); + + const result = await requestOtpHandler( + { kind: "email", flow: "login", authorization: "Bearer pre-auth" }, + { authServerUrl: "https://auth.example.com", transport: "cookie" }, + ); + + expect(result.body).toEqual({ message: "success" }); + }); + + it("drops it when no transport is given", async () => { + const { requestOtpHandler } = await import( + "../dist/handlers/requestOtpHandler.js" + ); + global.fetch.mockResolvedValue( + jsonResponse(200, { message: "success", token: "re-minted" }), + ); + + const result = await requestOtpHandler( + { kind: "email", authorization: "Bearer pre-auth" }, + { authServerUrl: "https://auth.example.com" }, + ); + + expect(result.body).toEqual({ message: "success" }); + }); + + it("keeps it for a bearer client, which holds its own token", async () => { + const { requestOtpHandler } = await import( + "../dist/handlers/requestOtpHandler.js" + ); + global.fetch.mockResolvedValue( + jsonResponse(200, { message: "success", token: "re-minted" }), + ); + + const result = await requestOtpHandler( + { kind: "email", flow: "login", authorization: "Bearer pre-auth" }, + { authServerUrl: "https://auth.example.com", transport: "bearer" }, + ); + + expect(result.body).toEqual({ message: "success", token: "re-minted" }); + }); + + it("keeps the delivery payload for the adapter to send", async () => { + const { requestOtpHandler } = await import( + "../dist/handlers/requestOtpHandler.js" + ); + const delivery = { kind: "otp_email", to: "a@b.c", token: "123456" }; + global.fetch.mockResolvedValue( + jsonResponse(200, { message: "success", token: "re-minted", delivery }), + ); + + const result = await requestOtpHandler( + { kind: "email", authorization: "Bearer pre-auth" }, + { authServerUrl: "https://auth.example.com", externalDelivery: true }, + ); + + expect(result.body).toEqual({ message: "success", delivery }); + }); }); diff --git a/packages/express/src/handlers/requestOtp.ts b/packages/express/src/handlers/requestOtp.ts index ca1141b..ffad35b 100644 --- a/packages/express/src/handlers/requestOtp.ts +++ b/packages/express/src/handlers/requestOtp.ts @@ -8,6 +8,7 @@ import { } from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { buildForwardedUserAgent } from "../internal/buildForwardedUserAgent"; +import { transportOf } from "../internal/transport"; import { applyExternalDelivery } from "@seamless-auth/core"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -26,6 +27,7 @@ export async function requestOtp( }, { authServerUrl: opts.authServerUrl, + transport: transportOf(req), externalDelivery: Boolean(opts.messaging), forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), forwardedUserAgent: buildForwardedUserAgent(req), diff --git a/packages/fastify/src/routes/authRoutes.ts b/packages/fastify/src/routes/authRoutes.ts index 26c9dcc..168b652 100644 --- a/packages/fastify/src/routes/authRoutes.ts +++ b/packages/fastify/src/routes/authRoutes.ts @@ -153,6 +153,7 @@ export function registerAuthRoutes( { kind, flow, authorization: buildServiceAuthorization(req) }, { ...common(req), + transport: transportOf(req), externalDelivery: Boolean(opts.messaging), serviceAuthorization: deliveryServiceAuthorization(), }, diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js index cc9d6c2..57a3c48 100644 --- a/packages/fastify/tests/parity.test.js +++ b/packages/fastify/tests/parity.test.js @@ -1200,3 +1200,76 @@ describe("fastify and express agree on routes served from the manifest", () => { expect(fastify).toEqual(expressResult); }); }); + +// The cookies carry every token, so none may reach the browser in a body (#202). +describe("no adapter returns an ephemeral token in a cookie-transport body", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + it.each([ + "/otp/generate-email-otp", + "/otp/generate-phone-otp", + "/otp/generate-login-email-otp", + "/otp/generate-login-phone-otp", + ])("%s", async (path) => { + const scenario = { method: "post", path, cookie: preAuthCookie(), payload: {} }; + const sent = upstream(200, { message: "success", token: "re-minted" }); + + global.fetch = jest.fn(async () => sent); + const fastify = await viaFastify(scenario); + global.fetch = jest.fn(async () => sent); + const expressResult = await viaExpress(scenario); + + expect(fastify.status).toBe(200); + expect(fastify.body).toEqual({ message: "success" }); + expect(expressResult.body).toEqual(fastify.body); + }); + + it("/registration/register", async () => { + const scenario = { + method: "post", + path: "/registration/register", + payload: { email: "user@example.com" }, + }; + const registered = upstream(200, { + message: "Success", + sub: "user-123", + token: "registration-token", + ttl: 300, + }); + + global.fetch = jest.fn(async () => registered); + const fastify = await viaFastify(scenario); + global.fetch = jest.fn(async () => registered); + const expressResult = await viaExpress(scenario); + + expect(fastify.status).toBe(200); + expect(fastify.body).toEqual({ message: "Success", sub: "user-123", ttl: 300 }); + expect(expressResult.body).toEqual(fastify.body); + expect(fastify.cookies).toEqual(expressResult.cookies); + expect(fastify.cookies).toEqual([expect.stringMatching(/^seamless-ephemeral=/)]); + }); + + it("still returns the token to a bearer client", async () => { + const scenario = { + method: "post", + path: "/otp/generate-login-email-otp", + headers: { + "x-seamless-auth-transport": "bearer", + authorization: "Bearer client-pre-auth", + }, + payload: {}, + }; + const sent = upstream(200, { message: "success", token: "re-minted" }); + + global.fetch = jest.fn(async () => sent); + const fastify = await viaFastify(scenario); + global.fetch = jest.fn(async () => sent); + const expressResult = await viaExpress(scenario); + + expect(fastify.body).toEqual({ message: "success", token: "re-minted" }); + expect(expressResult.body).toEqual(fastify.body); + }); +}); diff --git a/packages/nextjs/src/routes/authRoutes.ts b/packages/nextjs/src/routes/authRoutes.ts index 3a393f0..bb6746a 100644 --- a/packages/nextjs/src/routes/authRoutes.ts +++ b/packages/nextjs/src/routes/authRoutes.ts @@ -80,6 +80,7 @@ const otpRequestRoutes: Route[] = ( { kind, flow, authorization: buildServiceAuthorization(ctx) }, { ...common(ctx, opts), + transport: ctx.transport, externalDelivery: Boolean(opts.messaging), serviceAuthorization: deliveryServiceAuthorization(opts), }, diff --git a/packages/nextjs/tests/parity.test.js b/packages/nextjs/tests/parity.test.js index 84ca1e5..0701b15 100644 --- a/packages/nextjs/tests/parity.test.js +++ b/packages/nextjs/tests/parity.test.js @@ -1161,3 +1161,76 @@ describe("next and express agree on routes served from the manifest", () => { expect(next).toEqual(expressResult); }); }); + +// The cookies carry every token, so none may reach the browser in a body (#202). +describe("no adapter returns an ephemeral token in a cookie-transport body", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + it.each([ + "/otp/generate-email-otp", + "/otp/generate-phone-otp", + "/otp/generate-login-email-otp", + "/otp/generate-login-phone-otp", + ])("%s", async (path) => { + const scenario = { method: "post", path, cookie: preAuthCookie(), payload: {} }; + const sent = upstream(200, { message: "success", token: "re-minted" }); + + global.fetch = jest.fn(async () => sent); + const next = await viaNext(scenario); + global.fetch = jest.fn(async () => sent); + const expressResult = await viaExpress(scenario); + + expect(next.status).toBe(200); + expect(next.body).toEqual({ message: "success" }); + expect(expressResult.body).toEqual(next.body); + }); + + it("/registration/register", async () => { + const scenario = { + method: "post", + path: "/registration/register", + payload: { email: "user@example.com" }, + }; + const registered = upstream(200, { + message: "Success", + sub: "user-123", + token: "registration-token", + ttl: 300, + }); + + global.fetch = jest.fn(async () => registered); + const next = await viaNext(scenario); + global.fetch = jest.fn(async () => registered); + const expressResult = await viaExpress(scenario); + + expect(next.status).toBe(200); + expect(next.body).toEqual({ message: "Success", sub: "user-123", ttl: 300 }); + expect(expressResult.body).toEqual(next.body); + expect(next.cookies).toEqual(expressResult.cookies); + expect(next.cookies).toEqual([expect.stringMatching(/^seamless-ephemeral=/)]); + }); + + it("still returns the token to a bearer client", async () => { + const scenario = { + method: "post", + path: "/otp/generate-login-email-otp", + headers: { + "x-seamless-auth-transport": "bearer", + authorization: "Bearer client-pre-auth", + }, + payload: {}, + }; + const sent = upstream(200, { message: "success", token: "re-minted" }); + + global.fetch = jest.fn(async () => sent); + const next = await viaNext(scenario); + global.fetch = jest.fn(async () => sent); + const expressResult = await viaExpress(scenario); + + expect(next.body).toEqual({ message: "success", token: "re-minted" }); + expect(expressResult.body).toEqual(next.body); + }); +});