From 16ae32fd51dd20a8598ed5606dd6f8cf936e9224 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Sat, 4 Jul 2026 14:45:08 +0530 Subject: [PATCH 01/36] fix: prevent __txn_* cookie accumulation via value-prefix encoding and targeted cleanup --- src/server/auth-client.test.ts | 31 +- src/server/auth-client.ts | 68 +- src/server/client.ts | 24 +- src/server/mfa-popup.test.ts | 8 +- src/server/transaction-store.test.ts | 50 +- src/server/transaction-store.ts | 185 ++++- src/server/txn-cookie-accumulation.test.ts | 816 +++++++++++++++++++++ src/test/utils.ts | 13 + src/utils/request.ts | 24 + 9 files changed, 1159 insertions(+), 60 deletions(-) create mode 100644 src/server/txn-cookie-accumulation.test.ts diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index f8382cf7d..34e41ca8b 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -24,7 +24,7 @@ import { TokenRevocationErrorCode } from "../errors/index.js"; import { getDefaultRoutes } from "../test/defaults.js"; -import { generateSecret } from "../test/utils.js"; +import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js"; import { AccessTokenSet, RESPONSE_TYPES, @@ -1674,7 +1674,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2004,7 +2004,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2358,7 +2358,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2405,7 +2405,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2448,7 +2448,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2499,7 +2499,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2554,7 +2554,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2744,7 +2744,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie.value, + stripTransactionValuePrefix(transactionCookie.value), secret )) as jose.JWTDecryptResult ).payload @@ -2908,7 +2908,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie.value, + stripTransactionValuePrefix(transactionCookie.value), secret )) as jose.JWTDecryptResult ).payload @@ -2995,7 +2995,10 @@ ca/T0LLtgmbMmxSv/MmzIg== const state = transactionCookie.name.replace("__txn_", ""); expect(transactionCookie).toBeDefined(); expect( - (await decrypt(transactionCookie!.value, secret))!.payload + (await decrypt( + stripTransactionValuePrefix(transactionCookie!.value), + secret + ))!.payload ).toEqual( expect.objectContaining({ nonce: expect.any(String), @@ -7544,7 +7547,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -7691,7 +7694,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -8134,7 +8137,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 5edf2ba12..a8cc9ad40 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -153,6 +153,7 @@ import { buildForwardedResponseHeaders, transformTargetUrl } from "../utils/proxy.js"; +import { isNonNavigationalRequest } from "../utils/request.js"; import { ensureDefaultScope, getScopeForAudience @@ -370,6 +371,18 @@ export interface AuthClientOptions { */ cspNonce?: string; + /** + * When `false` (default), the SDK returns a `401` on prefetch requests to the + * login route, preventing `__txn_*` cookies from being created for OAuth flows + * that will never complete. + * + * Set to `true` only if your login route renders custom page content worth + * prefetching (i.e. it does not immediately redirect to Auth0). + * + * @default false + */ + dangerouslyAllowLoginPrefetch?: boolean; + /** * @future This option is reserved for future implementation. * Currently not used - placeholder for upcoming nonce persistence feature. @@ -428,6 +441,7 @@ export class AuthClient { private readonly mfaTokenTtl: number; private readonly cspNonce?: string; + private readonly dangerouslyAllowLoginPrefetch: boolean; private proxyDpopHandles: { [audience: string]: oauth.DPoPHandle } = {}; @@ -614,6 +628,8 @@ export class AuthClient { // CSP nonce for popup postMessage inline scripts this.cspNonce = options.cspNonce; + this.dangerouslyAllowLoginPrefetch = + options.dangerouslyAllowLoginPrefetch ?? false; // Store keypair if provided, but validate lazily to avoid crypto bundling this.dpopKeyPair = options.dpopKeyPair; @@ -656,6 +672,19 @@ export class AuthClient { this.dpopValidated = true; } + private async cleanupTransactionCookies( + req: NextRequest, + resCookies: ResponseCookies, + state: string + ): Promise { + // Targeted cleanup — regardless of dangerouslyAllowLoginPrefetch flag: + // 1. Sweep all accumulated "p:" prefetch cookies — provably garbage, never match a callback + // 2. Delete only the single __txn_{state} that belongs to this completing flow + // All other real login cookies (e.g. Tab B mid-login, prompt:login multi-account) are untouched. + await this.transactionStore.deletePrefetchCookies(req.cookies, resCookies); + await this.transactionStore.delete(resCookies, state); + } + async handler(req: NextRequest): Promise { let { pathname } = req.nextUrl; @@ -672,6 +701,12 @@ export class AuthClient { const method = req.method; if (method === "GET" && sanitizedPathname === this.routes.login) { + if ( + !this.dangerouslyAllowLoginPrefetch && + isNonNavigationalRequest(req) + ) { + return new NextResponse(null, { status: 401 }); + } return this.handleLogin(req); } else if (method === "GET" && sanitizedPathname === this.routes.logout) { return this.handleLogout(req); @@ -947,8 +982,14 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - // Save transaction state - await this.transactionStore.save(res.cookies, transactionState); + // Save transaction state; pass req.cookies so save() can apply maxSizeBytes eviction. + // isPrefetch encodes "p:" prefix in value so eviction and cleanup can classify O(1). + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies, + req ? isNonNavigationalRequest(req) : false + ); return res; } @@ -1256,7 +1297,7 @@ export class AuthClient { session ); - await this.transactionStore.delete(res.cookies, state); + await this.cleanupTransactionCookies(req, res.cookies, state); return res; } @@ -1466,7 +1507,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.transactionStore.delete(popupResponse.cookies, state); + await this.cleanupTransactionCookies(req, popupResponse.cookies, state); return popupResponse; } else { // No existing session (edge case: session expired during popup flow) @@ -1536,7 +1577,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.transactionStore.delete(popupResponse.cookies, state); + await this.cleanupTransactionCookies(req, popupResponse.cookies, state); return popupResponse; } } @@ -1598,8 +1639,7 @@ export class AuthClient { await this.sessionStore.set(req.cookies, res.cookies, session, true); addCacheControlHeadersForSession(res); - // Clean up the current transaction cookie after successful authentication - await this.transactionStore.delete(res.cookies, state); + await this.cleanupTransactionCookies(req, res.cookies, state); return res; } @@ -4177,7 +4217,12 @@ export class AuthClient { `${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}` ); - await this.transactionStore.save(res.cookies, transactionState); + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies, + false // connect account — always a real user-initiated flow, never prefetch + ); return [null, res]; } @@ -5819,7 +5864,12 @@ export class AuthClient { "Pass the NextResponse cookies (App Router: next/headers cookies; Pages Router: res.cookies)." ); } - await this.transactionStore.save(resCookies, magicLinkTransactionState); + await this.transactionStore.save( + resCookies, + magicLinkTransactionState, + req?.cookies, + false // magic link — always a real user-initiated flow, never prefetch + ); } } diff --git a/src/server/client.ts b/src/server/client.ts index 33ad9ea2f..945752c76 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -278,6 +278,21 @@ export interface Auth0ClientOptions { enableParallelTransactions?: boolean; + /** + * When `false` (default), the SDK returns a `401` on prefetch requests to the + * login route (`/auth/login`), preventing `__txn_*` transaction cookies from + * being created for OAuth flows that will never complete. + * + * The standard login route immediately redirects to Auth0's hosted login page — + * there is no page content to prefetch, so blocking prefetch has no user-visible cost. + * + * Set to `true` only if you have overridden the login route to render custom + * page content (e.g. an embedded login form) that is worth prefetching. + * + * @default false + */ + dangerouslyAllowLoginPrefetch?: boolean; + /** * If true, the `/auth/connect` endpoint will be mounted to enable users to connect additional accounts. */ @@ -617,7 +632,8 @@ export class Auth0Client { path: options.transactionCookie?.path ?? basePath ?? "/", maxAge: options.transactionCookie?.maxAge ?? 3600, domain: - options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN + options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN, + maxSizeBytes: options.transactionCookie?.maxSizeBytes }; if (appBaseUrl) { @@ -710,7 +726,9 @@ export class Auth0Client { this.transactionStore = new TransactionStore({ secret, cookieOptions: transactionCookieOptions, - enableParallelTransactions: options.enableParallelTransactions ?? true + enableParallelTransactions: options.enableParallelTransactions ?? true, + dangerouslyAllowLoginPrefetch: + options.dangerouslyAllowLoginPrefetch ?? false }); this.sessionStore = options.sessionStore @@ -798,6 +816,8 @@ export class Auth0Client { fetch: options.customFetch, mfaTokenTtl, cspNonce: options.cspNonce, + dangerouslyAllowLoginPrefetch: + options.dangerouslyAllowLoginPrefetch ?? false, discoveryCache, provider: this.provider diff --git a/src/server/mfa-popup.test.ts b/src/server/mfa-popup.test.ts index 773772ddd..3782bb63e 100644 --- a/src/server/mfa-popup.test.ts +++ b/src/server/mfa-popup.test.ts @@ -4,7 +4,7 @@ import * as oauth from "oauth4webapi"; import { describe, expect, it, vi } from "vitest"; import { getDefaultRoutes } from "../test/defaults.js"; -import { generateSecret } from "../test/utils.js"; +import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js"; import { RESPONSE_TYPES, SessionData } from "../types/index.js"; import { createAuthCompletePostMessageResponse } from "../utils/html-helpers.js"; import { AuthClient } from "./auth-client.js"; @@ -166,7 +166,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => { const transactionCookie = response.cookies.get(`__txn_${state}`); expect(transactionCookie).toBeDefined(); const { payload: txn } = (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult; expect(txn.challengeMode).toBe("popup"); @@ -200,7 +200,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => { const state = authUrl.searchParams.get("state")!; const transactionCookie = response.cookies.get(`__txn_${state}`); const { payload: txn } = (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult; // When challengeMode is 'redirect' (default), it's not stored to minimize cookie size @@ -260,7 +260,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => { const state = authUrl.searchParams.get("state")!; const transactionCookie = response.cookies.get(`__txn_${state}`); const { payload: txn } = (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult; diff --git a/src/server/transaction-store.test.ts b/src/server/transaction-store.test.ts index a8446d9d2..bb72a62de 100644 --- a/src/server/transaction-store.test.ts +++ b/src/server/transaction-store.test.ts @@ -2,7 +2,7 @@ import * as jose from "jose"; import * as oauth from "oauth4webapi"; import { describe, expect, it } from "vitest"; -import { generateSecret } from "../test/utils.js"; +import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js"; import { RESPONSE_TYPES } from "../types/connected-accounts.js"; import { decrypt, @@ -108,8 +108,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -172,8 +176,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -211,8 +219,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -250,8 +262,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/custom-path"); }); @@ -285,8 +301,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -325,8 +345,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 74948a33a..67e456f58 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,6 +5,11 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; +// Value prefix for prefetch-created cookies — pure garbage, never leads to a +// real callback. Short maxAge (60s) further limits accumulation window. +const PREFETCH_VALUE_PREFIX = "p:"; +const PREFETCH_MAX_AGE = 60; // seconds + export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -52,6 +57,24 @@ export interface TransactionCookieOptions { * Default: `__txn_{state}`. */ prefix?: string; + /** + * Maximum total byte size of all transaction cookies combined. When the + * accumulated size meets or exceeds this limit, cookies are evicted before + * the new one is written using a two-phase strategy: + * + * Phase 1 — delete all prefetch cookies (value prefix `p:`). These are + * provably garbage and never lead to a completed OAuth flow. + * + * Phase 2 — if still over threshold after phase 1, evict real login cookies + * oldest-first by the timestamp encoded in their value prefix (`{ts}:`). + * Zero crypto decryption happens during eviction. + * + * One `__txn_*` JWE is ~450–555 bytes. Default `4096` allows ~7–9 cookies — + * well under the 8 KB request-header limit most servers enforce. + * + * @default 4096 + */ + maxSizeBytes?: number; /** * The sameSite attribute of the transaction cookie. * @@ -94,6 +117,13 @@ export interface TransactionStoreOptions { * @default true */ enableParallelTransactions?: boolean; + /** + * Mirrors the `dangerouslyAllowLoginPrefetch` flag from `Auth0ClientOptions`. + * Controls the eviction strategy when `maxSizeBytes` is exceeded. + * + * @default false + */ + dangerouslyAllowLoginPrefetch?: boolean; } /** @@ -106,11 +136,14 @@ export class TransactionStore { private readonly transactionCookiePrefix: string; private readonly cookieOptions: cookies.CookieOptions; private readonly enableParallelTransactions: boolean; + private readonly maxSizeBytes: number; + private readonly dangerouslyAllowLoginPrefetch: boolean; constructor({ secret, cookieOptions, - enableParallelTransactions + enableParallelTransactions, + dangerouslyAllowLoginPrefetch }: TransactionStoreOptions) { this.secret = secret; this.transactionCookiePrefix = @@ -124,6 +157,8 @@ export class TransactionStore { maxAge: cookieOptions?.maxAge || 60 * 60 // 1 hour in seconds }; this.enableParallelTransactions = enableParallelTransactions ?? true; + this.maxSizeBytes = cookieOptions?.maxSizeBytes ?? 4096; + this.dangerouslyAllowLoginPrefetch = dangerouslyAllowLoginPrefetch ?? false; } /** @@ -149,34 +184,97 @@ export class TransactionStore { * * @param resCookies - The response cookies object to set the transaction cookie on * @param transactionState - The transaction state to save - * @param reqCookies - Optional request cookies to check for existing transactions. - * When provided and `enableParallelTransactions` is false, - * will check for existing transaction cookies. When omitted, - * the existence check is skipped for performance optimization. + * @param reqCookies - Optional request cookies. When provided, enables maxSizeBytes + * eviction before writing the new cookie. + * @param isPrefetch - When true, the cookie value is prefixed with "p:" and gets a + * short maxAge (60s). Prefetch cookies are evicted first during + * eviction and never match a real callback. * @throws {Error} When transaction state is missing required state parameter */ async save( resCookies: cookies.ResponseCookies, transactionState: TransactionState, - reqCookies?: cookies.RequestCookies + reqCookies?: cookies.RequestCookies, + isPrefetch?: boolean ) { if (!transactionState.state) { throw new Error("Transaction state is required"); } - // When parallel transactions are disabled, check if a transaction already exists - if (reqCookies && !this.enableParallelTransactions) { - const cookieName = this.getTransactionCookieName(transactionState.state); - const existingCookie = reqCookies.get(cookieName); - if (existingCookie) { - console.warn( - "A transaction is already in progress. Only one transaction is allowed when parallel transactions are disabled." + // Evict accumulated transaction cookies when accumulated size meets the cap. + // Safety net for abandoned logins and silent prefetches that bypass Fix 1 + // (e.g. router.prefetch(), CDNs that strip sec-fetch-mode). + if (reqCookies) { + const existing = reqCookies + .getAll() + .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); + const totalBytes = existing.reduce( + (sum, c) => + sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + 0 + ); + if (totalBytes >= this.maxSizeBytes) { + // Two-phase eviction — zero crypto decryption. + // Phase 1: evict all prefetch cookies (value starts with "p:") — always garbage. + // Phase 2: if still over threshold, evict real login cookies oldest-first + // by timestamp encoded in value prefix ("{ts}:"). + const deleteOptions = { + domain: this.cookieOptions.domain, + path: this.cookieOptions.path, + secure: this.cookieOptions.secure, + sameSite: this.cookieOptions.sameSite, + httpOnly: this.cookieOptions.httpOnly + }; + + const prefetchCookies = existing.filter((c) => + c.value.startsWith(PREFETCH_VALUE_PREFIX) + ); + const realCookies = existing + .filter((c) => !c.value.startsWith(PREFETCH_VALUE_PREFIX)) + .sort((a, b) => { + // Parse timestamp from "{ts}:{jwe}" — legacy "{jwe}" gets timestamp 0 + const tsA = parseInt(a.value) || 0; + const tsB = parseInt(b.value) || 0; + return tsA - tsB; // ascending — oldest first + }); + + let freed = prefetchCookies.reduce( + (sum, c) => + sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + 0 ); - return; + + const toEvict = [...prefetchCookies]; + if (freed < totalBytes - this.maxSizeBytes + 1) { + // Phase 1 insufficient — evict oldest real login cookies until under threshold + for (const c of realCookies) { + toEvict.push(c); + freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; + if (freed >= totalBytes - this.maxSizeBytes + 1) break; + } + } + + if (toEvict.length > 0) { + const evictedPrefetch = toEvict.filter((c) => + c.value.startsWith(PREFETCH_VALUE_PREFIX) + ).length; + const evictedReal = toEvict.length - evictedPrefetch; + console.warn( + `[auth0] Evicting ${toEvict.length} transaction cookie(s) ` + + `(${totalBytes} bytes ≥ ${this.maxSizeBytes} byte limit): ` + + `${evictedPrefetch} prefetch, ${evictedReal} real login(s). ` + + `Increase transactionCookie.maxSizeBytes to reduce eviction of in-flight logins.` + ); + for (const c of toEvict) { + cookies.deleteCookie(resCookies, c.name, deleteOptions); + } + } } } - const expirationSeconds = this.cookieOptions.maxAge!; + const expirationSeconds = isPrefetch + ? PREFETCH_MAX_AGE + : this.cookieOptions.maxAge!; const expiration = Math.floor(Date.now() / 1000 + expirationSeconds); const jwe = await cookies.encrypt( transactionState, @@ -184,10 +282,23 @@ export class TransactionStore { expiration ); + // Encode type and creation timestamp in the value for O(1) classification + // during eviction — no cookie name change, no breaking change. + // "p:{jwe}" → prefetch cookie (60s TTL, evicted first) + // "{ts}:{jwe}" → real login cookie (FIFO by ts during phase-2 eviction) + const ts = Math.floor(Date.now() / 1000); + const encodedValue = isPrefetch + ? `${PREFETCH_VALUE_PREFIX}${jwe}` + : `${ts}:${jwe}`; + + const cookieOptions = isPrefetch + ? { ...this.cookieOptions, maxAge: PREFETCH_MAX_AGE } + : this.cookieOptions; + resCookies.set( this.getTransactionCookieName(transactionState.state), - jwe.toString(), - this.cookieOptions + encodedValue, + cookieOptions ); } @@ -199,7 +310,14 @@ export class TransactionStore { return null; } - return cookies.decrypt(cookieValue, this.secret); + // Strip value prefix before decryption — backward compatible with legacy "{jwe}" format. + // "p:{jwe}" → strip "p:" prefix + // "{ts}:{jwe}" → strip "{ts}:" prefix (find first colon) + // "{jwe}" → no prefix, decrypt as-is (legacy) + const colonIdx = cookieValue.indexOf(":"); + const jwe = colonIdx !== -1 ? cookieValue.slice(colonIdx + 1) : cookieValue; + + return cookies.decrypt(jwe, this.secret); } async delete(resCookies: cookies.ResponseCookies, state: string) { @@ -234,4 +352,35 @@ export class TransactionStore { } }); } + + /** + * Deletes all prefetch-created transaction cookies (value prefix "p:"). + * These are provably garbage — they were created by non-navigational requests + * and can never lead to a completed OAuth flow. + * + * Called on callback success to sweep accumulated prefetch cookies without + * touching real in-flight logins from other tabs. + */ + async deletePrefetchCookies( + reqCookies: cookies.RequestCookies, + resCookies: cookies.ResponseCookies + ) { + const txnPrefix = this.getCookiePrefix(); + const deleteOptions = { + domain: this.cookieOptions.domain, + path: this.cookieOptions.path, + secure: this.cookieOptions.secure, + sameSite: this.cookieOptions.sameSite, + httpOnly: this.cookieOptions.httpOnly + }; + + reqCookies.getAll().forEach((cookie) => { + if ( + cookie.name.startsWith(txnPrefix) && + cookie.value.startsWith(PREFETCH_VALUE_PREFIX) + ) { + cookies.deleteCookie(resCookies, cookie.name, deleteOptions); + } + }); + } } diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts new file mode 100644 index 000000000..d8bff602e --- /dev/null +++ b/src/server/txn-cookie-accumulation.test.ts @@ -0,0 +1,816 @@ +import { NextRequest } from "next/server.js"; +import * as jose from "jose"; +import * as oauth from "oauth4webapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import { RESPONSE_TYPES } from "../types/connected-accounts.js"; +import { isNonNavigationalRequest } from "../utils/request.js"; +import { AuthClient } from "./auth-client.js"; +import { RequestCookies, ResponseCookies } from "./cookies.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; +import { TransactionState, TransactionStore } from "./transaction-store.js"; + +vi.mock("oauth4webapi", async () => { + const actual = await vi.importActual("oauth4webapi"); + return { + ...actual, + generateRandomState: vi.fn(), + generateRandomNonce: vi.fn(), + generateRandomCodeVerifier: vi.fn(), + calculatePKCECodeChallenge: vi.fn(), + discoveryRequest: vi.fn(), + processDiscoveryResponse: vi.fn(), + validateAuthResponse: vi.fn(), + getValidatedIdTokenClaims: vi.fn(), + processAuthorizationCodeResponse: vi.fn(), + authorizationCodeGrantRequest: vi.fn() + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeTransactionState = ( + state: string, + overrides: Partial = {} +): TransactionState => ({ + nonce: "test-nonce", + codeVerifier: "test-cv", + responseType: RESPONSE_TYPES.CODE, + maxAge: 3600, + returnTo: "/", + state, + ...overrides +}); + +/** Build a RequestCookies instance pre-populated with the given name=value pairs. */ +const makeRequestCookies = (pairs: Record): RequestCookies => { + const headers = new Headers(); + const cookieHeader = Object.entries(pairs) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + headers.append("cookie", cookieHeader); + return new RequestCookies(headers); +}; + +/** Build an empty ResponseCookies. */ +const makeResponseCookies = (): ResponseCookies => { + return new ResponseCookies(new Headers()); +}; + +// --------------------------------------------------------------------------- +// Fix 1 — isNonNavigationalRequest +// --------------------------------------------------------------------------- + +describe("Fix 1 — isNonNavigationalRequest()", () => { + const makeReq = (headers: Record) => { + const req = new NextRequest("http://localhost:3000/auth/login"); + Object.entries(headers).forEach(([k, v]) => req.headers.set(k, v)); + return req; + }; + + describe("sec-fetch-mode (primary signal)", () => { + it("returns false for sec-fetch-mode: navigate (real navigation)", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) + ).toBe(false); + }); + + it("returns true for sec-fetch-mode: cors (Next.js prefetch)", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) + ).toBe(true); + }); + + it("returns true for sec-fetch-mode: no-cors", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "no-cors" })) + ).toBe(true); + }); + + it("returns true for sec-fetch-mode: same-origin (XHR / fetch)", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) + ).toBe(true); + }); + }); + + describe("fallback headers (sec-fetch-mode absent)", () => { + it("returns true when next-router-prefetch is 1", () => { + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "1" })) + ).toBe(true); + }); + + it("returns true when accept is text/x-component", () => { + expect( + isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) + ).toBe(true); + }); + + it("returns true when purpose is prefetch", () => { + expect(isNonNavigationalRequest(makeReq({ purpose: "prefetch" }))).toBe( + true + ); + }); + + it("returns true when sec-purpose is prefetch", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-purpose": "prefetch" })) + ).toBe(true); + }); + + it("returns true when x-middleware-prefetch is 1", () => { + expect( + isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) + ).toBe(true); + }); + + it("returns false when no prefetch headers are present (plain request)", () => { + expect(isNonNavigationalRequest(makeReq({ accept: "text/html" }))).toBe( + false + ); + }); + }); + + describe("sec-fetch-mode takes precedence over fallbacks", () => { + it("returns false when sec-fetch-mode is navigate even if next-router-prefetch is 1", () => { + expect( + isNonNavigationalRequest( + makeReq({ + "sec-fetch-mode": "navigate", + "next-router-prefetch": "1" + }) + ) + ).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 2 — maxSizeBytes eviction in TransactionStore.save() +// --------------------------------------------------------------------------- + +describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { + let secret: string; + + beforeEach(async () => { + secret = await generateSecret(32); + }); + + it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 10 } + }); + const resCookies = makeResponseCookies(); + const state = "state-no-evict"; + + // Even with a tiny maxSizeBytes, passing no reqCookies skips eviction + await expect( + store.save(resCookies, makeTransactionState(state)) + ).resolves.not.toThrow(); + + expect(resCookies.get(`__txn_${state}`)?.value).toBeTruthy(); + }); + + it("does not evict when accumulated bytes are below maxSizeBytes", async () => { + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 99999 } + }); + + const existingState = "existing-state"; + const reqCookies = makeRequestCookies({ + [`__txn_${existingState}`]: "short" + }); + const resCookies = makeResponseCookies(); + const newState = "new-state"; + + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Existing cookie was not evicted (no delete set on it) + const evicted = resCookies + .getAll() + .filter((c) => c.name === `__txn_${existingState}` && c.maxAge === 0); + expect(evicted).toHaveLength(0); + + // New cookie was written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + }); + + it("phase-1 evicts prefetch cookies first, leaves real login cookies untouched when phase-1 sufficient", async () => { + // Set maxSizeBytes just above the real login cookie size so that phase-1 + // (evicting only prefetch cookies) frees enough to get under the threshold, + // without needing to touch the real login cookie. + const pfState1 = "pf1"; + const pfState2 = "pf2"; + const realState = "real"; + const pfValue1 = "p:short_jwe_1"; + const pfValue2 = "p:short_jwe_2"; + const realValue = "1000000000:real_jwe_value"; + + // Calculate actual byte sizes so we can set maxSizeBytes precisely. + const enc = new TextEncoder(); + const pfBytes1 = enc.encode(`__txn_${pfState1}=${pfValue1}`).length; + const pfBytes2 = enc.encode(`__txn_${pfState2}=${pfValue2}`).length; + const realBytes = enc.encode(`__txn_${realState}=${realValue}`).length; + const totalBytes = pfBytes1 + pfBytes2 + realBytes; + + // maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1: + // triggers eviction, but phase-1 (freeing pfBytes1 + pfBytes2) is enough. + const maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1; + + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes } + }); + + const reqCookies = makeRequestCookies({ + [`__txn_${pfState1}`]: pfValue1, + [`__txn_${pfState2}`]: pfValue2, + [`__txn_${realState}`]: realValue + }); + const resCookies = makeResponseCookies(); + + const newState = "newstate"; + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Prefetch cookies evicted + expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); + expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); + + // Real login cookie untouched (phase-1 freed enough) + expect(resCookies.get(`__txn_${realState}`)?.maxAge).not.toBe(0); + + // New cookie written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + }); + + it("phase-2 evicts oldest real login cookies first when phase-1 insufficient", async () => { + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 1 } + }); + + const olderState = "older"; + const newerState = "newer"; + // Older timestamp should be evicted first + const reqCookies = makeRequestCookies({ + [`__txn_${olderState}`]: "1000:jwe_older", + [`__txn_${newerState}`]: "9999:jwe_newer" + }); + const resCookies = makeResponseCookies(); + + const newState = "latest"; + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Older cookie evicted first + expect(resCookies.get(`__txn_${olderState}`)?.maxAge).toBe(0); + // New cookie written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + }); + + it("evicts legacy cookies (no prefix) in phase-2 as oldest (timestamp=0)", async () => { + // Legacy format "{jwe}" has no prefix → gets timestamp 0 → oldest in FIFO + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 1 } + }); + + const legacyState = "legacy"; + const newerState = "newer"; + const reqCookies = makeRequestCookies({ + [`__txn_${legacyState}`]: "raw_jwe_no_prefix", + [`__txn_${newerState}`]: "9999:jwe_newer", + other_cookie: "keep_me" + }); + const resCookies = makeResponseCookies(); + + const newState = "newstate"; + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Legacy cookie evicted (ts=0, oldest) + expect(resCookies.get(`__txn_${legacyState}`)?.maxAge).toBe(0); + // New cookie written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + // Non-txn cookie untouched + expect(resCookies.get("other_cookie")).toBeUndefined(); + }); + + it("only evicts cookies matching the configured prefix", async () => { + const customPrefix = "__my_txn_"; + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 1, prefix: customPrefix } + }); + + const reqCookies = makeRequestCookies({ + [`${customPrefix}state1`]: "p:prefetch_jwe", + __txn_other: "1000:other_jwe" // different prefix — should NOT be evicted + }); + const resCookies = makeResponseCookies(); + resCookies.set(`${customPrefix}state1`, "p:prefetch_jwe"); + resCookies.set("__txn_other", "1000:other_jwe"); + + await store.save( + resCookies, + makeTransactionState("new", { state: "new" }), + reqCookies + ); + + expect(resCookies.get(`${customPrefix}state1`)?.maxAge).toBe(0); + // __txn_other has a different prefix — not touched by this store + expect(resCookies.get("__txn_other")?.value).toBe("1000:other_jwe"); + }); + + it("real login cookie value is encoded as '{ts}:{jwe}'", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "real-login-state"; + + await store.save(resCookies, makeTransactionState(state), undefined, false); + + const value = resCookies.get(`__txn_${state}`)?.value ?? ""; + const colonIdx = value.indexOf(":"); + expect(colonIdx).toBeGreaterThan(0); + const ts = parseInt(value.slice(0, colonIdx)); + expect(ts).toBeGreaterThan(0); // epoch timestamp + expect(value.slice(colonIdx + 1)).toBeTruthy(); // JWE after colon + }); + + it("prefetch cookie value is encoded as 'p:{jwe}'", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "prefetch-state"; + + await store.save(resCookies, makeTransactionState(state), undefined, true); + + const value = resCookies.get(`__txn_${state}`)?.value ?? ""; + expect(value.startsWith("p:")).toBe(true); + expect(value.slice(2)).toBeTruthy(); // JWE after "p:" + }); + + it("prefetch cookie gets maxAge of 60s", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "prefetch-short-ttl"; + + await store.save(resCookies, makeTransactionState(state), undefined, true); + + const cookie = resCookies.get(`__txn_${state}`); + expect(cookie?.maxAge).toBe(60); + }); + + it("real login cookie gets full maxAge (1h default)", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "real-full-ttl"; + + await store.save(resCookies, makeTransactionState(state), undefined, false); + + const cookie = resCookies.get(`__txn_${state}`); + expect(cookie?.maxAge).toBe(3600); + }); + + it("get() strips 'p:' prefix before decrypting prefetch cookie", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "pf-get-test"; + + await store.save(resCookies, makeTransactionState(state), undefined, true); + + const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; + expect(encodedValue.startsWith("p:")).toBe(true); + + const reqCookies = makeRequestCookies({ [`__txn_${state}`]: encodedValue }); + const result = await store.get(reqCookies, state); + + expect(result).not.toBeNull(); + expect(result?.payload?.state).toBe(state); + }); + + it("get() strips '{ts}:' prefix before decrypting real login cookie", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "real-get-test"; + + await store.save(resCookies, makeTransactionState(state), undefined, false); + + const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; + expect(encodedValue.match(/^\d+:/)).toBeTruthy(); + + const reqCookies = makeRequestCookies({ [`__txn_${state}`]: encodedValue }); + const result = await store.get(reqCookies, state); + + expect(result).not.toBeNull(); + expect(result?.payload?.state).toBe(state); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 3 — Dormant early-return removed for enableParallelTransactions: false +// --------------------------------------------------------------------------- + +describe("Fix 3 — No lock-out in single-transaction mode", () => { + let secret: string; + + beforeEach(async () => { + secret = await generateSecret(32); + }); + + it("overwrites stale __txn_ cookie when user retries login after abandonment", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false + }); + + // Simulate stale cookie from abandoned login sitting in browser + const reqCookies = makeRequestCookies({ __txn_: "stale_jwe_value" }); + const resCookies = makeResponseCookies(); + + const newState = "new-login-state"; + + // Before Fix 3 this would return early and skip writing — now it must overwrite + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + const written = resCookies.get("__txn_"); + expect(written).toBeDefined(); + expect(written?.value).not.toBe("stale_jwe_value"); + expect(written?.value).toBeTruthy(); + expect(written?.maxAge).not.toBe(0); + }); + + it("uses fixed cookie name __txn_ regardless of state value", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false + }); + + const resCookies = makeResponseCookies(); + const state = "some-state-value"; + await store.save(resCookies, makeTransactionState(state)); + + // Cookie name must be "__txn_", not "__txn_{state}" + expect(resCookies.get("__txn_")).toBeDefined(); + expect(resCookies.get(`__txn_${state}`)).toBeUndefined(); + }); + + it("creates unique __txn_{state} cookies in parallel mode (baseline)", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: true + }); + + const resCookies = makeResponseCookies(); + const stateA = "stateA"; + const stateB = "stateB"; + + await store.save(resCookies, makeTransactionState(stateA)); + await store.save(resCookies, makeTransactionState(stateB)); + + expect(resCookies.get(`__txn_${stateA}`)?.value).toBeTruthy(); + expect(resCookies.get(`__txn_${stateB}`)?.value).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 4 — Targeted callback cleanup: sweep prefetch + delete only completing cookie +// --------------------------------------------------------------------------- + +describe("Fix 4 — targeted cleanup: deletePrefetchCookies + delete(state)", () => { + let secret: string; + + beforeEach(async () => { + secret = await generateSecret(32); + }); + + describe("deletePrefetchCookies()", () => { + it("deletes all 'p:' prefetch cookies, leaves real login cookies untouched", async () => { + const store = new TransactionStore({ secret }); + const reqCookies = makeRequestCookies({ + __txn_pf1: "p:jwe_prefetch_1", + __txn_pf2: "p:jwe_prefetch_2", + __txn_real: "1000000000:jwe_real_login" + }); + const resCookies = makeResponseCookies(); + + await store.deletePrefetchCookies(reqCookies, resCookies); + + expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); + expect(resCookies.get("__txn_pf2")?.maxAge).toBe(0); + // Real login cookie must NOT be touched + expect(resCookies.get("__txn_real")?.maxAge).not.toBe(0); + }); + + it("does not touch non-txn cookies", async () => { + const store = new TransactionStore({ secret }); + const reqCookies = makeRequestCookies({ + __txn_pf1: "p:jwe_pf", + __session: "session_value" + }); + const resCookies = makeResponseCookies(); + resCookies.set("__session", "session_value"); + + await store.deletePrefetchCookies(reqCookies, resCookies); + + expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); + expect(resCookies.get("__session")?.value).toBe("session_value"); + }); + + it("does not throw when no prefetch cookies exist", async () => { + const store = new TransactionStore({ secret }); + const reqCookies = makeRequestCookies({ + __txn_real: "1000000000:jwe_real" + }); + const resCookies = makeResponseCookies(); + + await expect( + store.deletePrefetchCookies(reqCookies, resCookies) + ).resolves.not.toThrow(); + }); + }); + + describe("delete(state)", () => { + it("deletes only the specific __txn_{state} cookie", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_stateA", "1000:jwe_a"); // completing flow + resCookies.set("__txn_stateB", "2000:jwe_b"); // Tab B — must survive + + await store.delete(resCookies, "stateA"); + + expect(resCookies.get("__txn_stateA")?.maxAge).toBe(0); + // Tab B's real login cookie must not be touched + expect(resCookies.get("__txn_stateB")?.value).toBe("2000:jwe_b"); + expect(resCookies.get("__txn_stateB")?.maxAge).not.toBe(0); + }); + + it("does not throw when deleting a non-existent state", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + + await expect( + store.delete(resCookies, "nonexistent-state") + ).resolves.not.toThrow(); + }); + }); + + describe("combined: sweep prefetch + delete completing cookie — multi-tab safe", () => { + it("sweeps prefetch cookies and deletes only the completing flow's cookie, leaving Tab B untouched", async () => { + const store = new TransactionStore({ secret }); + + // Tab A completing login + const completingState = "tabA-state"; + // Tab B mid-login under different account + const otherRealState = "tabB-state"; + // Accumulated prefetch garbage + const pfState1 = "pf-orphan-1"; + const pfState2 = "pf-orphan-2"; + + const reqCookies = makeRequestCookies({ + [`__txn_${completingState}`]: "1000:jwe_tabA", + [`__txn_${otherRealState}`]: "2000:jwe_tabB", + [`__txn_${pfState1}`]: "p:jwe_pf1", + [`__txn_${pfState2}`]: "p:jwe_pf2" + }); + const resCookies = makeResponseCookies(); + + await store.deletePrefetchCookies(reqCookies, resCookies); + await store.delete(resCookies, completingState); + + // Completing cookie deleted + expect(resCookies.get(`__txn_${completingState}`)?.maxAge).toBe(0); + // Prefetch cookies swept + expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); + expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); + // Tab B's real login cookie must be untouched + expect(resCookies.get(`__txn_${otherRealState}`)?.maxAge).not.toBe(0); + }); + + it("single-transaction mode: delete(state) resolves to __txn_ regardless of state value", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false + }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_", "1000:stale_jwe"); + + // state value is ignored in single mode — always resolves to "__txn_" + await store.delete(resCookies, "any-state-value"); + + expect(resCookies.get("__txn_")?.maxAge).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — handler() prefetch guard + handleCallback sweep +// These cover the three checklist items that require AuthClient + real flows. +// --------------------------------------------------------------------------- + +describe("Integration — prefetch guard and callback cleanup via AuthClient", () => { + const domain = "test.auth0.com"; + const clientId = "test-client-id"; + let keyPair: jose.GenerateKeyPairResult; + let secret: string; + + // Minimal mock authorization server used across all integration tests. + const makeFetch = () => + vi.fn(async (input: RequestInfo | URL): Promise => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.pathname === "/.well-known/openid-configuration") { + return Response.json({ + issuer: `https://${domain}/`, + authorization_endpoint: `https://${domain}/authorize`, + token_endpoint: `https://${domain}/oauth/token`, + jwks_uri: `https://${domain}/.well-known/jwks.json` + }); + } + if (url.pathname === "/.well-known/jwks.json") { + return Response.json({ + keys: [ + { + ...(await jose.exportJWK(keyPair.publicKey)), + kid: "k1", + use: "sig" + } + ] + }); + } + if (url.pathname === "/oauth/token") { + const idToken = await new jose.SignJWT({ + sub: "user123", + sid: "sid123", + nonce: "test-nonce", + aud: clientId, + iss: `https://${domain}/` + }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime("2h") + .sign(keyPair.privateKey); + return Response.json({ + token_type: "Bearer", + access_token: "at_123", + id_token: idToken, + expires_in: 3600 + }); + } + return new Response(null, { status: 404 }); + }); + + const makeAuthClient = ( + opts: { dangerouslyAllowLoginPrefetch?: boolean } = {} + ) => { + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + return new AuthClient({ + domain, + clientId, + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + transactionStore, + sessionStore, + routes: getDefaultRoutes(), + fetch: makeFetch(), + ...opts + }); + }; + + beforeEach(async () => { + vi.clearAllMocks(); + secret = await generateSecret(32); + keyPair = await jose.generateKeyPair("RS256"); + + vi.mocked(oauth.generateRandomState).mockReturnValue("test-state"); + vi.mocked(oauth.generateRandomNonce).mockReturnValue("test-nonce"); + vi.mocked(oauth.generateRandomCodeVerifier).mockReturnValue("cv"); + vi.mocked(oauth.calculatePKCECodeChallenge).mockResolvedValue("cc"); + vi.mocked(oauth.validateAuthResponse).mockReturnValue( + new URLSearchParams("code=auth_code&state=test-state") + ); + vi.mocked(oauth.discoveryRequest).mockResolvedValue(new Response()); + vi.mocked(oauth.processDiscoveryResponse).mockResolvedValue({ + issuer: `https://${domain}/`, + authorization_endpoint: `https://${domain}/authorize`, + token_endpoint: `https://${domain}/oauth/token`, + jwks_uri: `https://${domain}/.well-known/jwks.json` + } as oauth.AuthorizationServer); + vi.mocked(oauth.authorizationCodeGrantRequest).mockResolvedValue( + new Response() + ); + vi.mocked(oauth.processAuthorizationCodeResponse).mockResolvedValue({ + token_type: "Bearer", + access_token: "at_123", + id_token: "id_token_placeholder", + expires_in: 3600 + } as oauth.TokenEndpointResponse); + vi.mocked(oauth.getValidatedIdTokenClaims).mockReturnValue({ + sub: "user123", + sid: "sid123", + nonce: "test-nonce", + aud: clientId, + iss: `https://${domain}/`, + iat: Math.floor(Date.now() / 1000) - 60, + exp: Math.floor(Date.now() / 1000) + 3600 + }); + }); + + // Checklist: "Load bugs/txn-accumulation while logged out → no __txn_* cookies created" + it("Fix 1 — prefetch request returns 401 and no __txn_* cookie is written (guard on, default)", async () => { + const authClient = makeAuthClient(); + const req = new NextRequest("http://localhost:3000/auth/login", { + headers: { "sec-fetch-mode": "cors" } // prefetch signal + }); + + const res = await authClient.handler(req); + + expect(res.status).toBe(401); + const txnCookies = res.cookies + .getAll() + .filter((c) => c.name.startsWith("__txn_") && c.maxAge !== 0); + expect(txnCookies).toHaveLength(0); + }); + + // Checklist: "Set dangerouslyAllowLoginPrefetch: true → 4 __txn_* cookies appear" + it("Fix 1 — prefetch request is allowed through and __txn_* cookie is written (guard off)", async () => { + const authClient = makeAuthClient({ dangerouslyAllowLoginPrefetch: true }); + const req = new NextRequest("http://localhost:3000/auth/login", { + headers: { "sec-fetch-mode": "cors" } // same prefetch signal + }); + + const res = await authClient.handler(req); + + // Should redirect to Auth0 (3xx), not return 401 + expect(res.status).toBeGreaterThanOrEqual(300); + expect(res.status).toBeLessThan(400); + const txnCookies = res.cookies + .getAll() + .filter((c) => c.name.startsWith("__txn_") && (c.maxAge ?? 0) > 0); + expect(txnCookies.length).toBeGreaterThan(0); + }); + + // Checklist: "Complete login → completing txn + prefetch orphans deleted, other real logins untouched" + it("Fix 4 — handleCallback deletes completing cookie + sweeps prefetch orphans, leaves real Tab B cookie", async () => { + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + const authClient = new AuthClient({ + domain, + clientId, + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + transactionStore, + sessionStore, + routes: getDefaultRoutes(), + fetch: makeFetch() + // dangerouslyAllowLoginPrefetch: false (default) + }); + + // Step 1: login to get a real transaction cookie + const loginRes = await authClient.handleLogin( + new NextRequest("http://localhost:3000/auth/login") + ); + const state = new URL(loginRes.headers.get("Location")!).searchParams.get( + "state" + )!; + const txnCookie = loginRes.cookies.get(`__txn_${state}`); + expect(txnCookie).toBeDefined(); + // Verify login cookie has timestamp-prefixed value (real login, not prefetch) + expect(txnCookie!.value).toMatch(/^\d+:/); + + // Step 2: build callback request: + // - completing flow's cookie + // - two prefetch orphans (value prefix "p:") + // - one real in-flight login from Tab B (must survive) + const callbackReq = new NextRequest( + `http://localhost:3000/auth/callback?code=auth_code&state=${state}` + ); + callbackReq.cookies.set(`__txn_${state}`, txnCookie!.value); + callbackReq.cookies.set("__txn_orphan_pf1", "p:prefetch_jwe_1"); + callbackReq.cookies.set("__txn_orphan_pf2", "p:prefetch_jwe_2"); + callbackReq.cookies.set("__txn_tabB", "9999999999:tab_b_real_login_jwe"); + + const callbackRes = await authClient.handleCallback(callbackReq); + + expect(callbackRes.status).toBeGreaterThanOrEqual(300); + expect(callbackRes.status).toBeLessThan(400); + + // Completing cookie must be deleted + expect(callbackRes.cookies.get(`__txn_${state}`)?.maxAge).toBe(0); + // Prefetch orphans must be deleted + expect(callbackRes.cookies.get("__txn_orphan_pf1")?.maxAge).toBe(0); + expect(callbackRes.cookies.get("__txn_orphan_pf2")?.maxAge).toBe(0); + // Tab B real login cookie must NOT be deleted + const tabBCookie = callbackRes.cookies.get("__txn_tabB"); + expect(tabBCookie?.maxAge).not.toBe(0); + + // Session cookie written + expect(callbackRes.cookies.get("__session")?.value).toBeTruthy(); + }); +}); diff --git a/src/test/utils.ts b/src/test/utils.ts index 536212434..652cc5cd8 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -5,3 +5,16 @@ export async function generateSecret(length: number) { .map((b) => b.toString(16).padStart(2, "0")) .join(""); } + +/** + * Strip the value prefix that TransactionStore encodes in cookie values. + * "p:{jwe}" → prefetch cookie — strips "p:" prefix + * "{ts}:{jwe}" → real login — strips "{ts}:" prefix + * "{jwe}" → legacy (no prefix) — returned as-is + * + * Use this in tests that decrypt transaction cookie values directly. + */ +export function stripTransactionValuePrefix(value: string): string { + const colonIdx = value.indexOf(":"); + return colonIdx !== -1 ? value.slice(colonIdx + 1) : value; +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 545916eef..126f90ca0 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -16,3 +16,27 @@ export const isRequest = (req: Req): req is Request | NextRequest => { typeof (req as Request).bodyUsed === "boolean" ); }; + +/** + * Returns true if the request is non-navigational (e.g. a prefetch, fetch, or + * XHR) rather than a full browser navigation. Used to guard against Next.js + * prefetch requests triggering side-effectful handlers like handleLogin. + * + * Uses the W3C Fetch Metadata `sec-fetch-mode` header as the primary signal + * (supported in Chrome 76+, Firefox 90+, Safari 16.4+). Falls back to + * Next.js-specific and legacy prefetch headers for older environments. + */ +export const isNonNavigationalRequest = (req: NextRequest): boolean => { + const fetchMode = req.headers.get("sec-fetch-mode"); + if (fetchMode !== null) { + return fetchMode !== "navigate"; + } + + return ( + req.headers.get("next-router-prefetch") === "1" || + req.headers.get("accept") === "text/x-component" || + req.headers.get("purpose") === "prefetch" || + req.headers.get("sec-purpose") === "prefetch" || + req.headers.get("x-middleware-prefetch") === "1" + ); +}; From e29c7e0cf18af4cc7c9112f158b1fc123c113478 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Sat, 4 Jul 2026 14:46:17 +0530 Subject: [PATCH 02/36] docs: prevent __txn_* cookie accumulation via value-prefix encoding and targeted cleanup --- EXAMPLES.md | 49 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 7b8d735cc..a3027153d 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4074,20 +4074,49 @@ const authClient = new Auth0Client({ **Use Single Transaction Mode When:** -- You want to prevent cookie accumulation issues in applications with frequent login attempts -- You prefer simpler transaction management +- You want the simplest possible transaction management - Users typically don't need multiple concurrent login flows -- You're experiencing cookie header size limits due to abandoned transaction cookies edge cases ### Transaction Cookie Options -| Option | Type | Description | -| ---------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| cookieOptions.maxAge | `number` | The expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned transaction cookies will expire automatically. | -| cookieOptions.prefix | `string` | The prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`. In single mode, just `__txn_`. | -| cookieOptions.sameSite | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | -| cookieOptions.secure | `boolean` | When `true`, the cookie will only be sent over HTTPS connections. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | -| cookieOptions.path | `string` | Specifies the URL path for which the cookie is valid. Defaults to `"/"`. | +| Option | Type | Description | +| ----------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `transactionCookie.maxAge` | `number` | Expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned cookies expire automatically. | +| `transactionCookie.maxSizeBytes` | `number` | Maximum total byte size of all `__txn_*` cookies combined. Defaults to `4096`. When exceeded, the SDK evicts prefetch cookies first (phase 1), then oldest real login cookies (phase 2), before writing the new cookie. One JWE is ~450–555 bytes. | +| `transactionCookie.prefix` | `string` | Prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`; in single mode, just `__txn_`. | +| `transactionCookie.sameSite` | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | +| `transactionCookie.secure` | `boolean` | When `true`, the cookie is only sent over HTTPS. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | +| `transactionCookie.path` | `string` | URL path for which the cookie is valid. Defaults to `"/"`. | +| `dangerouslyAllowLoginPrefetch` | `boolean` | Defaults to `false`. When `false`, the SDK returns a `401` on non-navigational requests to `/auth/login` (Next.js prefetch, XHR), preventing prefetch cookies from accumulating. Set to `true` only for apps with custom login pages worth caching. | + +### Troubleshooting: 431 / cookie header too large + +If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookies have grown beyond your server's header size limit. + +**This is fixed in the current SDK version.** The SDK now: + +1. Returns `401` on Next.js prefetch requests to `/auth/login` so no cookie is written (`dangerouslyAllowLoginPrefetch: false` by default). +2. Automatically evicts accumulated cookies when the `maxSizeBytes` limit is reached — prefetch cookies first, then oldest real login cookies. + +If you are running an older version, adding `prefetch={false}` to `` components pointing to your login route is a safe fallback: + +```tsx +// Optional safety net — not required in current SDK versions + + Sign In + +``` + +If accumulation persists after upgrading, increase the byte limit or reduce `maxAge`: + +```ts +export const auth0 = new Auth0Client({ + transactionCookie: { + maxSizeBytes: 8192, // raise the ceiling (default 4096) + maxAge: 600, // shorten TTL to 10 minutes (default 3600) + }, +}); +``` ## Database sessions From b6a3296e6221fd6c3b937ef1ae43fa0ffd3fded0 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 10 Jul 2026 22:16:38 +0530 Subject: [PATCH 03/36] fix: remove prefetch flag, simplify txn cookie eviction to single-phase FIFO --- src/server/auth-client.ts | 48 +-- src/server/client.ts | 20 -- src/server/transaction-store.ts | 156 ++------- src/server/txn-cookie-accumulation.test.ts | 360 +++++---------------- src/utils/request.ts | 15 +- 5 files changed, 127 insertions(+), 472 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index a8cc9ad40..5bd5b5105 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -371,18 +371,6 @@ export interface AuthClientOptions { */ cspNonce?: string; - /** - * When `false` (default), the SDK returns a `401` on prefetch requests to the - * login route, preventing `__txn_*` cookies from being created for OAuth flows - * that will never complete. - * - * Set to `true` only if your login route renders custom page content worth - * prefetching (i.e. it does not immediately redirect to Auth0). - * - * @default false - */ - dangerouslyAllowLoginPrefetch?: boolean; - /** * @future This option is reserved for future implementation. * Currently not used - placeholder for upcoming nonce persistence feature. @@ -441,7 +429,6 @@ export class AuthClient { private readonly mfaTokenTtl: number; private readonly cspNonce?: string; - private readonly dangerouslyAllowLoginPrefetch: boolean; private proxyDpopHandles: { [audience: string]: oauth.DPoPHandle } = {}; @@ -628,8 +615,6 @@ export class AuthClient { // CSP nonce for popup postMessage inline scripts this.cspNonce = options.cspNonce; - this.dangerouslyAllowLoginPrefetch = - options.dangerouslyAllowLoginPrefetch ?? false; // Store keypair if provided, but validate lazily to avoid crypto bundling this.dpopKeyPair = options.dpopKeyPair; @@ -677,11 +662,6 @@ export class AuthClient { resCookies: ResponseCookies, state: string ): Promise { - // Targeted cleanup — regardless of dangerouslyAllowLoginPrefetch flag: - // 1. Sweep all accumulated "p:" prefetch cookies — provably garbage, never match a callback - // 2. Delete only the single __txn_{state} that belongs to this completing flow - // All other real login cookies (e.g. Tab B mid-login, prompt:login multi-account) are untouched. - await this.transactionStore.deletePrefetchCookies(req.cookies, resCookies); await this.transactionStore.delete(resCookies, state); } @@ -701,10 +681,7 @@ export class AuthClient { const method = req.method; if (method === "GET" && sanitizedPathname === this.routes.login) { - if ( - !this.dangerouslyAllowLoginPrefetch && - isNonNavigationalRequest(req) - ) { + if (isNonNavigationalRequest(req)) { return new NextResponse(null, { status: 401 }); } return this.handleLogin(req); @@ -982,14 +959,7 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - // Save transaction state; pass req.cookies so save() can apply maxSizeBytes eviction. - // isPrefetch encodes "p:" prefix in value so eviction and cleanup can classify O(1). - await this.transactionStore.save( - res.cookies, - transactionState, - req?.cookies, - req ? isNonNavigationalRequest(req) : false - ); + await this.transactionStore.save(res.cookies, transactionState, req?.cookies); return res; } @@ -4217,12 +4187,7 @@ export class AuthClient { `${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}` ); - await this.transactionStore.save( - res.cookies, - transactionState, - req?.cookies, - false // connect account — always a real user-initiated flow, never prefetch - ); + await this.transactionStore.save(res.cookies, transactionState, req?.cookies); return [null, res]; } @@ -5864,12 +5829,7 @@ export class AuthClient { "Pass the NextResponse cookies (App Router: next/headers cookies; Pages Router: res.cookies)." ); } - await this.transactionStore.save( - resCookies, - magicLinkTransactionState, - req?.cookies, - false // magic link — always a real user-initiated flow, never prefetch - ); + await this.transactionStore.save(resCookies, magicLinkTransactionState, req?.cookies); } } diff --git a/src/server/client.ts b/src/server/client.ts index 945752c76..290b3db64 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -278,21 +278,6 @@ export interface Auth0ClientOptions { enableParallelTransactions?: boolean; - /** - * When `false` (default), the SDK returns a `401` on prefetch requests to the - * login route (`/auth/login`), preventing `__txn_*` transaction cookies from - * being created for OAuth flows that will never complete. - * - * The standard login route immediately redirects to Auth0's hosted login page — - * there is no page content to prefetch, so blocking prefetch has no user-visible cost. - * - * Set to `true` only if you have overridden the login route to render custom - * page content (e.g. an embedded login form) that is worth prefetching. - * - * @default false - */ - dangerouslyAllowLoginPrefetch?: boolean; - /** * If true, the `/auth/connect` endpoint will be mounted to enable users to connect additional accounts. */ @@ -727,8 +712,6 @@ export class Auth0Client { secret, cookieOptions: transactionCookieOptions, enableParallelTransactions: options.enableParallelTransactions ?? true, - dangerouslyAllowLoginPrefetch: - options.dangerouslyAllowLoginPrefetch ?? false }); this.sessionStore = options.sessionStore @@ -816,9 +799,6 @@ export class Auth0Client { fetch: options.customFetch, mfaTokenTtl, cspNonce: options.cspNonce, - dangerouslyAllowLoginPrefetch: - options.dangerouslyAllowLoginPrefetch ?? false, - discoveryCache, provider: this.provider }); diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 67e456f58..50c8d573d 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,10 +5,6 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; -// Value prefix for prefetch-created cookies — pure garbage, never leads to a -// real callback. Short maxAge (60s) further limits accumulation window. -const PREFETCH_VALUE_PREFIX = "p:"; -const PREFETCH_MAX_AGE = 60; // seconds export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; @@ -117,13 +113,6 @@ export interface TransactionStoreOptions { * @default true */ enableParallelTransactions?: boolean; - /** - * Mirrors the `dangerouslyAllowLoginPrefetch` flag from `Auth0ClientOptions`. - * Controls the eviction strategy when `maxSizeBytes` is exceeded. - * - * @default false - */ - dangerouslyAllowLoginPrefetch?: boolean; } /** @@ -137,13 +126,11 @@ export class TransactionStore { private readonly cookieOptions: cookies.CookieOptions; private readonly enableParallelTransactions: boolean; private readonly maxSizeBytes: number; - private readonly dangerouslyAllowLoginPrefetch: boolean; constructor({ secret, cookieOptions, - enableParallelTransactions, - dangerouslyAllowLoginPrefetch + enableParallelTransactions }: TransactionStoreOptions) { this.secret = secret; this.transactionCookiePrefix = @@ -158,7 +145,6 @@ export class TransactionStore { }; this.enableParallelTransactions = enableParallelTransactions ?? true; this.maxSizeBytes = cookieOptions?.maxSizeBytes ?? 4096; - this.dangerouslyAllowLoginPrefetch = dangerouslyAllowLoginPrefetch ?? false; } /** @@ -186,24 +172,20 @@ export class TransactionStore { * @param transactionState - The transaction state to save * @param reqCookies - Optional request cookies. When provided, enables maxSizeBytes * eviction before writing the new cookie. - * @param isPrefetch - When true, the cookie value is prefixed with "p:" and gets a - * short maxAge (60s). Prefetch cookies are evicted first during - * eviction and never match a real callback. * @throws {Error} When transaction state is missing required state parameter */ async save( resCookies: cookies.ResponseCookies, transactionState: TransactionState, - reqCookies?: cookies.RequestCookies, - isPrefetch?: boolean + reqCookies?: cookies.RequestCookies ) { if (!transactionState.state) { throw new Error("Transaction state is required"); } - // Evict accumulated transaction cookies when accumulated size meets the cap. - // Safety net for abandoned logins and silent prefetches that bypass Fix 1 - // (e.g. router.prefetch(), CDNs that strip sec-fetch-mode). + // Evict oldest transaction cookies FIFO when total size exceeds the cap. + // Safety net for abandoned logins and silent prefetches not caught by the + // prefetch guard (e.g. router.prefetch(), CDN-stripped headers). if (reqCookies) { const existing = reqCookies .getAll() @@ -214,10 +196,6 @@ export class TransactionStore { 0 ); if (totalBytes >= this.maxSizeBytes) { - // Two-phase eviction — zero crypto decryption. - // Phase 1: evict all prefetch cookies (value starts with "p:") — always garbage. - // Phase 2: if still over threshold, evict real login cookies oldest-first - // by timestamp encoded in value prefix ("{ts}:"). const deleteOptions = { domain: this.cookieOptions.domain, path: this.cookieOptions.path, @@ -226,79 +204,40 @@ export class TransactionStore { httpOnly: this.cookieOptions.httpOnly }; - const prefetchCookies = existing.filter((c) => - c.value.startsWith(PREFETCH_VALUE_PREFIX) - ); - const realCookies = existing - .filter((c) => !c.value.startsWith(PREFETCH_VALUE_PREFIX)) - .sort((a, b) => { - // Parse timestamp from "{ts}:{jwe}" — legacy "{jwe}" gets timestamp 0 - const tsA = parseInt(a.value) || 0; - const tsB = parseInt(b.value) || 0; - return tsA - tsB; // ascending — oldest first - }); - - let freed = prefetchCookies.reduce( - (sum, c) => - sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, - 0 - ); - - const toEvict = [...prefetchCookies]; - if (freed < totalBytes - this.maxSizeBytes + 1) { - // Phase 1 insufficient — evict oldest real login cookies until under threshold - for (const c of realCookies) { - toEvict.push(c); - freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; - if (freed >= totalBytes - this.maxSizeBytes + 1) break; - } + // Sort by timestamp encoded in value prefix "{ts}:{jwe}". + // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. + const sorted = [...existing].sort((a, b) => { + const tsA = parseInt(a.value) || 0; + const tsB = parseInt(b.value) || 0; + return tsA - tsB; + }); + + let freed = 0; + const target = totalBytes - this.maxSizeBytes + 1; + for (const c of sorted) { + cookies.deleteCookie(resCookies, c.name, deleteOptions); + freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; + if (freed >= target) break; } - if (toEvict.length > 0) { - const evictedPrefetch = toEvict.filter((c) => - c.value.startsWith(PREFETCH_VALUE_PREFIX) - ).length; - const evictedReal = toEvict.length - evictedPrefetch; - console.warn( - `[auth0] Evicting ${toEvict.length} transaction cookie(s) ` + - `(${totalBytes} bytes ≥ ${this.maxSizeBytes} byte limit): ` + - `${evictedPrefetch} prefetch, ${evictedReal} real login(s). ` + - `Increase transactionCookie.maxSizeBytes to reduce eviction of in-flight logins.` - ); - for (const c of toEvict) { - cookies.deleteCookie(resCookies, c.name, deleteOptions); - } - } + console.warn( + `[auth0] Evicted transaction cookie(s) — total size ${totalBytes} bytes exceeded ` + + `${this.maxSizeBytes} byte limit. Increase transactionCookie.maxSizeBytes to ` + + `reduce eviction of in-flight logins.` + ); } } - const expirationSeconds = isPrefetch - ? PREFETCH_MAX_AGE - : this.cookieOptions.maxAge!; - const expiration = Math.floor(Date.now() / 1000 + expirationSeconds); - const jwe = await cookies.encrypt( - transactionState, - this.secret, - expiration - ); + const expiration = Math.floor(Date.now() / 1000 + this.cookieOptions.maxAge!); + const jwe = await cookies.encrypt(transactionState, this.secret, expiration); - // Encode type and creation timestamp in the value for O(1) classification - // during eviction — no cookie name change, no breaking change. - // "p:{jwe}" → prefetch cookie (60s TTL, evicted first) - // "{ts}:{jwe}" → real login cookie (FIFO by ts during phase-2 eviction) + // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. + // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". const ts = Math.floor(Date.now() / 1000); - const encodedValue = isPrefetch - ? `${PREFETCH_VALUE_PREFIX}${jwe}` - : `${ts}:${jwe}`; - - const cookieOptions = isPrefetch - ? { ...this.cookieOptions, maxAge: PREFETCH_MAX_AGE } - : this.cookieOptions; - resCookies.set( this.getTransactionCookieName(transactionState.state), - encodedValue, - cookieOptions + `${ts}:${jwe}`, + this.cookieOptions ); } @@ -310,10 +249,7 @@ export class TransactionStore { return null; } - // Strip value prefix before decryption — backward compatible with legacy "{jwe}" format. - // "p:{jwe}" → strip "p:" prefix - // "{ts}:{jwe}" → strip "{ts}:" prefix (find first colon) - // "{jwe}" → no prefix, decrypt as-is (legacy) + // Strip "{ts}:" prefix before decryption — backward compatible with legacy bare "{jwe}". const colonIdx = cookieValue.indexOf(":"); const jwe = colonIdx !== -1 ? cookieValue.slice(colonIdx + 1) : cookieValue; @@ -353,34 +289,4 @@ export class TransactionStore { }); } - /** - * Deletes all prefetch-created transaction cookies (value prefix "p:"). - * These are provably garbage — they were created by non-navigational requests - * and can never lead to a completed OAuth flow. - * - * Called on callback success to sweep accumulated prefetch cookies without - * touching real in-flight logins from other tabs. - */ - async deletePrefetchCookies( - reqCookies: cookies.RequestCookies, - resCookies: cookies.ResponseCookies - ) { - const txnPrefix = this.getCookiePrefix(); - const deleteOptions = { - domain: this.cookieOptions.domain, - path: this.cookieOptions.path, - secure: this.cookieOptions.secure, - sameSite: this.cookieOptions.sameSite, - httpOnly: this.cookieOptions.httpOnly - }; - - reqCookies.getAll().forEach((cookie) => { - if ( - cookie.name.startsWith(txnPrefix) && - cookie.value.startsWith(PREFETCH_VALUE_PREFIX) - ) { - cookies.deleteCookie(resCookies, cookie.name, deleteOptions); - } - }); - } } diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index d8bff602e..fd012359c 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -72,33 +72,7 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { return req; }; - describe("sec-fetch-mode (primary signal)", () => { - it("returns false for sec-fetch-mode: navigate (real navigation)", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) - ).toBe(false); - }); - - it("returns true for sec-fetch-mode: cors (Next.js prefetch)", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) - ).toBe(true); - }); - - it("returns true for sec-fetch-mode: no-cors", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "no-cors" })) - ).toBe(true); - }); - - it("returns true for sec-fetch-mode: same-origin (XHR / fetch)", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) - ).toBe(true); - }); - }); - - describe("fallback headers (sec-fetch-mode absent)", () => { + describe("known prefetch headers — positive detection only", () => { it("returns true when next-router-prefetch is 1", () => { expect( isNonNavigationalRequest(makeReq({ "next-router-prefetch": "1" })) @@ -128,25 +102,36 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) ).toBe(true); }); + }); - it("returns false when no prefetch headers are present (plain request)", () => { + describe("requests that must not be blocked", () => { + it("returns false for plain navigation with no prefetch headers", () => { expect(isNonNavigationalRequest(makeReq({ accept: "text/html" }))).toBe( false ); }); - }); - describe("sec-fetch-mode takes precedence over fallbacks", () => { - it("returns false when sec-fetch-mode is navigate even if next-router-prefetch is 1", () => { + it("returns false for sec-fetch-mode: navigate", () => { expect( - isNonNavigationalRequest( - makeReq({ - "sec-fetch-mode": "navigate", - "next-router-prefetch": "1" - }) - ) + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) + ).toBe(false); + }); + + it("returns false for sec-fetch-mode: cors — legitimate fetch()/XHR must not be blocked", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) ).toBe(false); }); + + it("returns false for sec-fetch-mode: same-origin — legitimate fetch()/XHR must not be blocked", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) + ).toBe(false); + }); + + it("returns false when no headers present", () => { + expect(isNonNavigationalRequest(makeReq({}))).toBe(false); + }); }); }); @@ -202,27 +187,19 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("phase-1 evicts prefetch cookies first, leaves real login cookies untouched when phase-1 sufficient", async () => { - // Set maxSizeBytes just above the real login cookie size so that phase-1 - // (evicting only prefetch cookies) frees enough to get under the threshold, - // without needing to touch the real login cookie. - const pfState1 = "pf1"; - const pfState2 = "pf2"; - const realState = "real"; - const pfValue1 = "p:short_jwe_1"; - const pfValue2 = "p:short_jwe_2"; - const realValue = "1000000000:real_jwe_value"; - - // Calculate actual byte sizes so we can set maxSizeBytes precisely. + it("evicts oldest cookie first when threshold exceeded with mixed timestamps", async () => { + const olderState = "older"; + const newerState = "newer"; + const olderValue = "1000:jwe_older"; + const newerValue = "9999:jwe_newer"; + const enc = new TextEncoder(); - const pfBytes1 = enc.encode(`__txn_${pfState1}=${pfValue1}`).length; - const pfBytes2 = enc.encode(`__txn_${pfState2}=${pfValue2}`).length; - const realBytes = enc.encode(`__txn_${realState}=${realValue}`).length; - const totalBytes = pfBytes1 + pfBytes2 + realBytes; + const olderBytes = enc.encode(`__txn_${olderState}=${olderValue}`).length; + const newerBytes = enc.encode(`__txn_${newerState}=${newerValue}`).length; + const totalBytes = olderBytes + newerBytes; - // maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1: - // triggers eviction, but phase-1 (freeing pfBytes1 + pfBytes2) is enough. - const maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1; + // maxSizeBytes just below total — eviction fires but only needs to remove one + const maxSizeBytes = totalBytes - olderBytes + 1; const store = new TransactionStore({ secret, @@ -230,22 +207,18 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { }); const reqCookies = makeRequestCookies({ - [`__txn_${pfState1}`]: pfValue1, - [`__txn_${pfState2}`]: pfValue2, - [`__txn_${realState}`]: realValue + [`__txn_${olderState}`]: olderValue, + [`__txn_${newerState}`]: newerValue }); const resCookies = makeResponseCookies(); const newState = "newstate"; await store.save(resCookies, makeTransactionState(newState), reqCookies); - // Prefetch cookies evicted - expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); - expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); - - // Real login cookie untouched (phase-1 freed enough) - expect(resCookies.get(`__txn_${realState}`)?.maxAge).not.toBe(0); - + // Older cookie evicted first + expect(resCookies.get(`__txn_${olderState}`)?.maxAge).toBe(0); + // Newer cookie untouched — eviction stopped after freeing enough + expect(resCookies.get(`__txn_${newerState}`)?.maxAge).not.toBe(0); // New cookie written expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); @@ -327,78 +300,37 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get("__txn_other")?.value).toBe("1000:other_jwe"); }); - it("real login cookie value is encoded as '{ts}:{jwe}'", async () => { + it("cookie value is encoded as '{ts}:{jwe}'", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); - const state = "real-login-state"; + const state = "login-state"; - await store.save(resCookies, makeTransactionState(state), undefined, false); + await store.save(resCookies, makeTransactionState(state)); const value = resCookies.get(`__txn_${state}`)?.value ?? ""; const colonIdx = value.indexOf(":"); expect(colonIdx).toBeGreaterThan(0); const ts = parseInt(value.slice(0, colonIdx)); - expect(ts).toBeGreaterThan(0); // epoch timestamp - expect(value.slice(colonIdx + 1)).toBeTruthy(); // JWE after colon - }); - - it("prefetch cookie value is encoded as 'p:{jwe}'", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - const state = "prefetch-state"; - - await store.save(resCookies, makeTransactionState(state), undefined, true); - - const value = resCookies.get(`__txn_${state}`)?.value ?? ""; - expect(value.startsWith("p:")).toBe(true); - expect(value.slice(2)).toBeTruthy(); // JWE after "p:" - }); - - it("prefetch cookie gets maxAge of 60s", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - const state = "prefetch-short-ttl"; - - await store.save(resCookies, makeTransactionState(state), undefined, true); - - const cookie = resCookies.get(`__txn_${state}`); - expect(cookie?.maxAge).toBe(60); - }); - - it("real login cookie gets full maxAge (1h default)", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - const state = "real-full-ttl"; - - await store.save(resCookies, makeTransactionState(state), undefined, false); - - const cookie = resCookies.get(`__txn_${state}`); - expect(cookie?.maxAge).toBe(3600); + expect(ts).toBeGreaterThan(0); + expect(value.slice(colonIdx + 1)).toBeTruthy(); }); - it("get() strips 'p:' prefix before decrypting prefetch cookie", async () => { + it("cookie gets full maxAge (1h default)", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); - const state = "pf-get-test"; + const state = "full-ttl"; - await store.save(resCookies, makeTransactionState(state), undefined, true); - - const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; - expect(encodedValue.startsWith("p:")).toBe(true); - - const reqCookies = makeRequestCookies({ [`__txn_${state}`]: encodedValue }); - const result = await store.get(reqCookies, state); + await store.save(resCookies, makeTransactionState(state)); - expect(result).not.toBeNull(); - expect(result?.payload?.state).toBe(state); + expect(resCookies.get(`__txn_${state}`)?.maxAge).toBe(3600); }); - it("get() strips '{ts}:' prefix before decrypting real login cookie", async () => { + it("get() strips '{ts}:' prefix before decrypting", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); - const state = "real-get-test"; + const state = "get-test"; - await store.save(resCookies, makeTransactionState(state), undefined, false); + await store.save(resCookies, makeTransactionState(state)); const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; expect(encodedValue.match(/^\d+:/)).toBeTruthy(); @@ -478,132 +410,49 @@ describe("Fix 3 — No lock-out in single-transaction mode", () => { }); // --------------------------------------------------------------------------- -// Fix 4 — Targeted callback cleanup: sweep prefetch + delete only completing cookie +// Fix 4 — Callback cleanup: delete only the completing flow's cookie // --------------------------------------------------------------------------- -describe("Fix 4 — targeted cleanup: deletePrefetchCookies + delete(state)", () => { +describe("Fix 4 — callback cleanup: delete(state)", () => { let secret: string; beforeEach(async () => { secret = await generateSecret(32); }); - describe("deletePrefetchCookies()", () => { - it("deletes all 'p:' prefetch cookies, leaves real login cookies untouched", async () => { - const store = new TransactionStore({ secret }); - const reqCookies = makeRequestCookies({ - __txn_pf1: "p:jwe_prefetch_1", - __txn_pf2: "p:jwe_prefetch_2", - __txn_real: "1000000000:jwe_real_login" - }); - const resCookies = makeResponseCookies(); - - await store.deletePrefetchCookies(reqCookies, resCookies); - - expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); - expect(resCookies.get("__txn_pf2")?.maxAge).toBe(0); - // Real login cookie must NOT be touched - expect(resCookies.get("__txn_real")?.maxAge).not.toBe(0); - }); - - it("does not touch non-txn cookies", async () => { - const store = new TransactionStore({ secret }); - const reqCookies = makeRequestCookies({ - __txn_pf1: "p:jwe_pf", - __session: "session_value" - }); - const resCookies = makeResponseCookies(); - resCookies.set("__session", "session_value"); - - await store.deletePrefetchCookies(reqCookies, resCookies); - - expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); - expect(resCookies.get("__session")?.value).toBe("session_value"); - }); + it("deletes only the completing flow's cookie, leaves other real login cookies untouched", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_stateA", "1000:jwe_a"); + resCookies.set("__txn_stateB", "2000:jwe_b"); - it("does not throw when no prefetch cookies exist", async () => { - const store = new TransactionStore({ secret }); - const reqCookies = makeRequestCookies({ - __txn_real: "1000000000:jwe_real" - }); - const resCookies = makeResponseCookies(); + await store.delete(resCookies, "stateA"); - await expect( - store.deletePrefetchCookies(reqCookies, resCookies) - ).resolves.not.toThrow(); - }); + expect(resCookies.get("__txn_stateA")?.maxAge).toBe(0); + expect(resCookies.get("__txn_stateB")?.value).toBe("2000:jwe_b"); + expect(resCookies.get("__txn_stateB")?.maxAge).not.toBe(0); }); - describe("delete(state)", () => { - it("deletes only the specific __txn_{state} cookie", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - resCookies.set("__txn_stateA", "1000:jwe_a"); // completing flow - resCookies.set("__txn_stateB", "2000:jwe_b"); // Tab B — must survive - - await store.delete(resCookies, "stateA"); - - expect(resCookies.get("__txn_stateA")?.maxAge).toBe(0); - // Tab B's real login cookie must not be touched - expect(resCookies.get("__txn_stateB")?.value).toBe("2000:jwe_b"); - expect(resCookies.get("__txn_stateB")?.maxAge).not.toBe(0); - }); - - it("does not throw when deleting a non-existent state", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); + it("does not throw when deleting a non-existent state", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); - await expect( - store.delete(resCookies, "nonexistent-state") - ).resolves.not.toThrow(); - }); + await expect( + store.delete(resCookies, "nonexistent-state") + ).resolves.not.toThrow(); }); - describe("combined: sweep prefetch + delete completing cookie — multi-tab safe", () => { - it("sweeps prefetch cookies and deletes only the completing flow's cookie, leaving Tab B untouched", async () => { - const store = new TransactionStore({ secret }); - - // Tab A completing login - const completingState = "tabA-state"; - // Tab B mid-login under different account - const otherRealState = "tabB-state"; - // Accumulated prefetch garbage - const pfState1 = "pf-orphan-1"; - const pfState2 = "pf-orphan-2"; - - const reqCookies = makeRequestCookies({ - [`__txn_${completingState}`]: "1000:jwe_tabA", - [`__txn_${otherRealState}`]: "2000:jwe_tabB", - [`__txn_${pfState1}`]: "p:jwe_pf1", - [`__txn_${pfState2}`]: "p:jwe_pf2" - }); - const resCookies = makeResponseCookies(); - - await store.deletePrefetchCookies(reqCookies, resCookies); - await store.delete(resCookies, completingState); - - // Completing cookie deleted - expect(resCookies.get(`__txn_${completingState}`)?.maxAge).toBe(0); - // Prefetch cookies swept - expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); - expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); - // Tab B's real login cookie must be untouched - expect(resCookies.get(`__txn_${otherRealState}`)?.maxAge).not.toBe(0); + it("single-transaction mode: delete(state) resolves to __txn_ regardless of state value", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_", "1000:stale_jwe"); - it("single-transaction mode: delete(state) resolves to __txn_ regardless of state value", async () => { - const store = new TransactionStore({ - secret, - enableParallelTransactions: false - }); - const resCookies = makeResponseCookies(); - resCookies.set("__txn_", "1000:stale_jwe"); - - // state value is ignored in single mode — always resolves to "__txn_" - await store.delete(resCookies, "any-state-value"); + await store.delete(resCookies, "any-state-value"); - expect(resCookies.get("__txn_")?.maxAge).toBe(0); - }); + expect(resCookies.get("__txn_")?.maxAge).toBe(0); }); }); @@ -663,9 +512,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( return new Response(null, { status: 404 }); }); - const makeAuthClient = ( - opts: { dangerouslyAllowLoginPrefetch?: boolean } = {} - ) => { + const makeAuthClient = () => { const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); return new AuthClient({ @@ -677,8 +524,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( transactionStore, sessionStore, routes: getDefaultRoutes(), - fetch: makeFetch(), - ...opts + fetch: makeFetch() }); }; @@ -721,11 +567,10 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( }); }); - // Checklist: "Load bugs/txn-accumulation while logged out → no __txn_* cookies created" - it("Fix 1 — prefetch request returns 401 and no __txn_* cookie is written (guard on, default)", async () => { + it("Fix 1 — known prefetch header returns 401 and no __txn_* cookie is written", async () => { const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { - headers: { "sec-fetch-mode": "cors" } // prefetch signal + headers: { "next-router-prefetch": "1" } }); const res = await authClient.handler(req); @@ -737,16 +582,14 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(txnCookies).toHaveLength(0); }); - // Checklist: "Set dangerouslyAllowLoginPrefetch: true → 4 __txn_* cookies appear" - it("Fix 1 — prefetch request is allowed through and __txn_* cookie is written (guard off)", async () => { - const authClient = makeAuthClient({ dangerouslyAllowLoginPrefetch: true }); + it("Fix 1 — real navigation is allowed through and __txn_* cookie is written", async () => { + const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { - headers: { "sec-fetch-mode": "cors" } // same prefetch signal + headers: { "sec-fetch-mode": "navigate" } }); const res = await authClient.handler(req); - // Should redirect to Auth0 (3xx), not return 401 expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); const txnCookies = res.cookies @@ -755,24 +598,9 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(txnCookies.length).toBeGreaterThan(0); }); - // Checklist: "Complete login → completing txn + prefetch orphans deleted, other real logins untouched" - it("Fix 4 — handleCallback deletes completing cookie + sweeps prefetch orphans, leaves real Tab B cookie", async () => { - const transactionStore = new TransactionStore({ secret }); - const sessionStore = new StatelessSessionStore({ secret }); - const authClient = new AuthClient({ - domain, - clientId, - clientSecret: "test-secret", - appBaseUrl: "http://localhost:3000", - secret, - transactionStore, - sessionStore, - routes: getDefaultRoutes(), - fetch: makeFetch() - // dangerouslyAllowLoginPrefetch: false (default) - }); + it("Fix 4 — handleCallback deletes only the completing cookie, leaves Tab B cookie untouched", async () => { + const authClient = makeAuthClient(); - // Step 1: login to get a real transaction cookie const loginRes = await authClient.handleLogin( new NextRequest("http://localhost:3000/auth/login") ); @@ -781,19 +609,12 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( )!; const txnCookie = loginRes.cookies.get(`__txn_${state}`); expect(txnCookie).toBeDefined(); - // Verify login cookie has timestamp-prefixed value (real login, not prefetch) expect(txnCookie!.value).toMatch(/^\d+:/); - // Step 2: build callback request: - // - completing flow's cookie - // - two prefetch orphans (value prefix "p:") - // - one real in-flight login from Tab B (must survive) const callbackReq = new NextRequest( `http://localhost:3000/auth/callback?code=auth_code&state=${state}` ); callbackReq.cookies.set(`__txn_${state}`, txnCookie!.value); - callbackReq.cookies.set("__txn_orphan_pf1", "p:prefetch_jwe_1"); - callbackReq.cookies.set("__txn_orphan_pf2", "p:prefetch_jwe_2"); callbackReq.cookies.set("__txn_tabB", "9999999999:tab_b_real_login_jwe"); const callbackRes = await authClient.handleCallback(callbackReq); @@ -801,16 +622,11 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(callbackRes.status).toBeGreaterThanOrEqual(300); expect(callbackRes.status).toBeLessThan(400); - // Completing cookie must be deleted + // Completing cookie deleted expect(callbackRes.cookies.get(`__txn_${state}`)?.maxAge).toBe(0); - // Prefetch orphans must be deleted - expect(callbackRes.cookies.get("__txn_orphan_pf1")?.maxAge).toBe(0); - expect(callbackRes.cookies.get("__txn_orphan_pf2")?.maxAge).toBe(0); // Tab B real login cookie must NOT be deleted - const tabBCookie = callbackRes.cookies.get("__txn_tabB"); - expect(tabBCookie?.maxAge).not.toBe(0); - - // Session cookie written + expect(callbackRes.cookies.get("__txn_tabB")?.maxAge).not.toBe(0); + // Session written expect(callbackRes.cookies.get("__session")?.value).toBeTruthy(); }); }); diff --git a/src/utils/request.ts b/src/utils/request.ts index 126f90ca0..e9a548447 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -18,20 +18,13 @@ export const isRequest = (req: Req): req is Request | NextRequest => { }; /** - * Returns true if the request is non-navigational (e.g. a prefetch, fetch, or - * XHR) rather than a full browser navigation. Used to guard against Next.js - * prefetch requests triggering side-effectful handlers like handleLogin. + * Returns true only when a request carries a known prefetch signal. + * Used to block Next.js prefetch requests from triggering handleLogin. * - * Uses the W3C Fetch Metadata `sec-fetch-mode` header as the primary signal - * (supported in Chrome 76+, Firefox 90+, Safari 16.4+). Falls back to - * Next.js-specific and legacy prefetch headers for older environments. + * Intentionally excludes `sec-fetch-mode` — that header also matches + * legitimate fetch()/XHR calls to /auth/login which must not be blocked. */ export const isNonNavigationalRequest = (req: NextRequest): boolean => { - const fetchMode = req.headers.get("sec-fetch-mode"); - if (fetchMode !== null) { - return fetchMode !== "navigate"; - } - return ( req.headers.get("next-router-prefetch") === "1" || req.headers.get("accept") === "text/x-component" || From 541a86ba43d7046b20ce8dfacd1fbc8fd732b067 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 10 Jul 2026 22:17:07 +0530 Subject: [PATCH 04/36] fix: lint fix --- src/server/auth-client.ts | 18 +++++++++++++++--- src/server/client.ts | 2 +- src/server/transaction-store.ts | 12 ++++++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 5bd5b5105..e3fa4ce76 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -959,7 +959,11 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - await this.transactionStore.save(res.cookies, transactionState, req?.cookies); + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies + ); return res; } @@ -4187,7 +4191,11 @@ export class AuthClient { `${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}` ); - await this.transactionStore.save(res.cookies, transactionState, req?.cookies); + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies + ); return [null, res]; } @@ -5829,7 +5837,11 @@ export class AuthClient { "Pass the NextResponse cookies (App Router: next/headers cookies; Pages Router: res.cookies)." ); } - await this.transactionStore.save(resCookies, magicLinkTransactionState, req?.cookies); + await this.transactionStore.save( + resCookies, + magicLinkTransactionState, + req?.cookies + ); } } diff --git a/src/server/client.ts b/src/server/client.ts index 290b3db64..d333dbf53 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -711,7 +711,7 @@ export class Auth0Client { this.transactionStore = new TransactionStore({ secret, cookieOptions: transactionCookieOptions, - enableParallelTransactions: options.enableParallelTransactions ?? true, + enableParallelTransactions: options.enableParallelTransactions ?? true }); this.sessionStore = options.sessionStore diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 50c8d573d..e0e3ed268 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,7 +5,6 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; - export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -228,8 +227,14 @@ export class TransactionStore { } } - const expiration = Math.floor(Date.now() / 1000 + this.cookieOptions.maxAge!); - const jwe = await cookies.encrypt(transactionState, this.secret, expiration); + const expiration = Math.floor( + Date.now() / 1000 + this.cookieOptions.maxAge! + ); + const jwe = await cookies.encrypt( + transactionState, + this.secret, + expiration + ); // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". @@ -288,5 +293,4 @@ export class TransactionStore { } }); } - } From b30ec24e87ab3df0545b7eee895fa0f53426006a Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 13 Jul 2026 19:42:06 +0530 Subject: [PATCH 05/36] fix: fix txn cookie eviction limit and add session size warning --- EXAMPLES.md | 9 +- src/server/client.ts | 3 +- .../session/stateless-session-store.test.ts | 61 ++++++++++ src/server/session/stateless-session-store.ts | 56 ++++++--- src/server/transaction-store.ts | 106 +++++++++--------- src/server/txn-cookie-accumulation.test.ts | 101 ++++++++--------- 6 files changed, 209 insertions(+), 127 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index a3027153d..74fdb9ab3 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4082,12 +4082,10 @@ const authClient = new Auth0Client({ | Option | Type | Description | | ----------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `transactionCookie.maxAge` | `number` | Expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned cookies expire automatically. | -| `transactionCookie.maxSizeBytes` | `number` | Maximum total byte size of all `__txn_*` cookies combined. Defaults to `4096`. When exceeded, the SDK evicts prefetch cookies first (phase 1), then oldest real login cookies (phase 2), before writing the new cookie. One JWE is ~450–555 bytes. | | `transactionCookie.prefix` | `string` | Prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`; in single mode, just `__txn_`. | | `transactionCookie.sameSite` | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | | `transactionCookie.secure` | `boolean` | When `true`, the cookie is only sent over HTTPS. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | | `transactionCookie.path` | `string` | URL path for which the cookie is valid. Defaults to `"/"`. | -| `dangerouslyAllowLoginPrefetch` | `boolean` | Defaults to `false`. When `false`, the SDK returns a `401` on non-navigational requests to `/auth/login` (Next.js prefetch, XHR), preventing prefetch cookies from accumulating. Set to `true` only for apps with custom login pages worth caching. | ### Troubleshooting: 431 / cookie header too large @@ -4095,8 +4093,8 @@ If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookie **This is fixed in the current SDK version.** The SDK now: -1. Returns `401` on Next.js prefetch requests to `/auth/login` so no cookie is written (`dangerouslyAllowLoginPrefetch: false` by default). -2. Automatically evicts accumulated cookies when the `maxSizeBytes` limit is reached — prefetch cookies first, then oldest real login cookies. +1. Returns `401` on Next.js prefetch requests to `/auth/login` (detected via prefetch headers such as `next-router-prefetch`, `purpose`, `sec-purpose`, and `x-middleware-prefetch`), so no `__txn_*` cookie is written for a flow that will never complete. +2. Automatically evicts accumulated `__txn_*` cookies once their combined size reaches a fixed internal limit (3500 bytes, roughly six concurrent in-flight logins) — oldest-first (FIFO) by creation timestamp — before writing the new cookie. Only transaction cookies are measured and evicted; the session and other cookies are never touched. This limit is not configurable. If you are running an older version, adding `prefetch={false}` to `` components pointing to your login route is a safe fallback: @@ -4107,12 +4105,11 @@ If you are running an older version, adding `prefetch={false}` to `` compo ``` -If accumulation persists after upgrading, increase the byte limit or reduce `maxAge`: +If accumulation persists after upgrading, shorten the transaction cookie lifetime so abandoned logins expire sooner: ```ts export const auth0 = new Auth0Client({ transactionCookie: { - maxSizeBytes: 8192, // raise the ceiling (default 4096) maxAge: 600, // shorten TTL to 10 minutes (default 3600) }, }); diff --git a/src/server/client.ts b/src/server/client.ts index d333dbf53..9378b0e4e 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -617,8 +617,7 @@ export class Auth0Client { path: options.transactionCookie?.path ?? basePath ?? "/", maxAge: options.transactionCookie?.maxAge ?? 3600, domain: - options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN, - maxSizeBytes: options.transactionCookie?.maxSizeBytes + options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN }; if (appBaseUrl) { diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 52586042f..332122614 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -949,6 +949,67 @@ describe("Stateless Session Store", async () => { } ); }); + + describe("session cookie size warning", async () => { + const baseSession = (createdAt: number): SessionData => ({ + user: { sub: "user_123" }, + tokenSet: { + accessToken: "at_123", + refreshToken: "rt_123", + expiresAt: 123456 + }, + internal: { sid: "auth0-sid", createdAt } + }); + + it("warns when the session cookie exceeds the size threshold", async () => { + const secret = await generateSecret(32); + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + try { + const session = baseSession(Math.floor(Date.now() / 1000)); + // Large custom claim pushes the encoded session well past 4096 bytes + // (and across multiple __session chunks). + (session.user as Record).bigClaim = "x".repeat(6000); + + const sessionStore = new StatelessSessionStore({ secret }); + await sessionStore.set( + new RequestCookies(new Headers()), + new ResponseCookies(new Headers()), + session + ); + + const warned = consoleWarnSpy.mock.calls.some((c) => + String(c[0]).includes("__session cookie size") + ); + expect(warned).toBe(true); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it("does not warn for a small session cookie", async () => { + const secret = await generateSecret(32); + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + try { + const sessionStore = new StatelessSessionStore({ secret }); + await sessionStore.set( + new RequestCookies(new Headers()), + new ResponseCookies(new Headers()), + baseSession(Math.floor(Date.now() / 1000)) + ); + + const warned = consoleWarnSpy.mock.calls.some((c) => + String(c[0]).includes("__session cookie size") + ); + expect(warned).toBe(false); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + }); }); describe("delete", async () => { diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index e8c0a5077..4d6b83c2d 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -17,6 +17,14 @@ import { normalizeStatelessSession } from "./normalize-session.js"; +// Total encoded session-cookie size (across all `__session` chunks) above which +// we warn. A large session is the main remaining cause of `431 Request Header +// Fields Too Large`, since — unlike transaction cookies — the session is not +// evicted. 4096 bytes mirrors the per-cookie limit browsers guarantee and is a +// good "trim your claims or go stateful" signal well before typical 8 KB proxy +// header limits are hit. +const SESSION_COOKIE_SIZE_WARN_BYTES = 4096; + interface StatelessSessionStoreOptions { secret: string; @@ -125,6 +133,33 @@ export class StatelessSessionStore extends AbstractSessionStore { resCookies ); + // Warn when the session cookie is large. This is the main remaining cause of + // 431 errors: the session (unlike transaction cookies) is never evicted, so + // an oversized session can overflow the request-header limit on its own. + // Measure the total bytes of all `__session` chunks written to the response. + const sessionCookieBytes = resCookies + .getAll() + .filter( + (c) => + c.name === this.sessionCookieName || + c.name.startsWith(`${this.sessionCookieName}__`) + ) + .reduce( + (sum, c) => + sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + 0 + ); + + if (sessionCookieBytes >= SESSION_COOKIE_SIZE_WARN_BYTES) { + console.warn( + `The ${this.sessionCookieName} cookie size is ${sessionCookieBytes} bytes, which may ` + + "exceed request header size limits and cause 431 Request Header Fields Too Large errors " + + "on some servers, proxies, or CDNs. Consider removing unnecessary custom claims from the " + + "access token or the user profile, or use a stateful session implementation to store the " + + "session data in a data store." + ); + } + // Store connection access tokens, each in its own cookie if (connectionTokenSets?.length) { await Promise.all( @@ -228,20 +263,15 @@ export class StatelessSessionStore extends AbstractSessionStore { maxAge }); + // storeInCookie only ever writes connection-token (`__FC_*`) cookies — the + // session cookie is written (and size-checked) separately in set(). Warn if + // an individual connection-token cookie is large enough to risk browser or + // header limits. if (new TextEncoder().encode(cookieJarSizeTest.toString()).length >= 4096) { - // if the cookie is the session cookie, log a warning with additional information about the claims and user profile. - if (cookieName === this.sessionCookieName) { - console.warn( - `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + - "Consider removing any unnecessary custom claims from the access token or the user profile. " + - "Alternatively, you can use a stateful session implementation to store the session data in a data store." - ); - } else { - console.warn( - `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + - "You can use a stateful session implementation to store the session data in a data store." - ); - } + console.warn( + `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + + "You can use a stateful session implementation to store the session data in a data store." + ); } } diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index e0e3ed268..11c4b306c 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,6 +5,16 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; +// Maximum total byte size of all transaction (`__txn_*`) cookies combined. +// When the accumulated size meets or exceeds this limit, the oldest cookies are +// evicted (FIFO by creation timestamp) before a new one is written. One JWE is +// ~450–555 bytes, so this allows ~6 concurrent in-flight logins — enough for +// multi-tab use while staying well under the request-header limits enforced by +// browsers (~4 KB per cookie) and servers/proxies. Intentionally fixed and not +// configurable: it caps transaction-cookie accumulation regardless of the +// deployment's header limit, which the SDK cannot know. +const MAX_TRANSACTION_COOKIE_BYTES = 3500; + export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -52,24 +62,6 @@ export interface TransactionCookieOptions { * Default: `__txn_{state}`. */ prefix?: string; - /** - * Maximum total byte size of all transaction cookies combined. When the - * accumulated size meets or exceeds this limit, cookies are evicted before - * the new one is written using a two-phase strategy: - * - * Phase 1 — delete all prefetch cookies (value prefix `p:`). These are - * provably garbage and never lead to a completed OAuth flow. - * - * Phase 2 — if still over threshold after phase 1, evict real login cookies - * oldest-first by the timestamp encoded in their value prefix (`{ts}:`). - * Zero crypto decryption happens during eviction. - * - * One `__txn_*` JWE is ~450–555 bytes. Default `4096` allows ~7–9 cookies — - * well under the 8 KB request-header limit most servers enforce. - * - * @default 4096 - */ - maxSizeBytes?: number; /** * The sameSite attribute of the transaction cookie. * @@ -124,7 +116,6 @@ export class TransactionStore { private readonly transactionCookiePrefix: string; private readonly cookieOptions: cookies.CookieOptions; private readonly enableParallelTransactions: boolean; - private readonly maxSizeBytes: number; constructor({ secret, @@ -143,7 +134,6 @@ export class TransactionStore { maxAge: cookieOptions?.maxAge || 60 * 60 // 1 hour in seconds }; this.enableParallelTransactions = enableParallelTransactions ?? true; - this.maxSizeBytes = cookieOptions?.maxSizeBytes ?? 4096; } /** @@ -169,8 +159,9 @@ export class TransactionStore { * * @param resCookies - The response cookies object to set the transaction cookie on * @param transactionState - The transaction state to save - * @param reqCookies - Optional request cookies. When provided, enables maxSizeBytes - * eviction before writing the new cookie. + * @param reqCookies - Optional request cookies. When provided, enables FIFO + * eviction of accumulated transaction cookies (capped at + * {@link MAX_TRANSACTION_COOKIE_BYTES}) before writing the new cookie. * @throws {Error} When transaction state is missing required state parameter */ async save( @@ -182,19 +173,41 @@ export class TransactionStore { throw new Error("Transaction state is required"); } - // Evict oldest transaction cookies FIFO when total size exceeds the cap. - // Safety net for abandoned logins and silent prefetches not caught by the - // prefetch guard (e.g. router.prefetch(), CDN-stripped headers). + const expiration = Math.floor( + Date.now() / 1000 + this.cookieOptions.maxAge! + ); + const jwe = await cookies.encrypt( + transactionState, + this.secret, + expiration + ); + + // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. + // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". + const ts = Math.floor(Date.now() / 1000); + const newCookieName = this.getTransactionCookieName(transactionState.state); + const newCookieValue = `${ts}:${jwe}`; + + // Evict oldest transaction cookies FIFO before writing the new one, so the + // accumulated `__txn_*` cookies stay under MAX_TRANSACTION_COOKIE_BYTES. + // Only transaction cookies are ever measured or deleted here — the session + // and other cookies are left untouched, and the platform's own request-header + // limit is not second-guessed (the SDK cannot know it, and guessing risks + // evicting in-flight logins that would otherwise have fit). if (reqCookies) { - const existing = reqCookies + const enc = new TextEncoder(); + const sizeOf = (name: string, value: string) => + enc.encode(`${name}=${value}`).length; + + const txnCookies = reqCookies .getAll() .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); - const totalBytes = existing.reduce( - (sum, c) => - sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + const txnBytes = txnCookies.reduce( + (sum, c) => sum + sizeOf(c.name, c.value), 0 ); - if (totalBytes >= this.maxSizeBytes) { + + if (txnBytes >= MAX_TRANSACTION_COOKIE_BYTES) { const deleteOptions = { domain: this.cookieOptions.domain, path: this.cookieOptions.path, @@ -205,45 +218,32 @@ export class TransactionStore { // Sort by timestamp encoded in value prefix "{ts}:{jwe}". // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. - const sorted = [...existing].sort((a, b) => { + const sorted = [...txnCookies].sort((a, b) => { const tsA = parseInt(a.value) || 0; const tsB = parseInt(b.value) || 0; return tsA - tsB; }); let freed = 0; - const target = totalBytes - this.maxSizeBytes + 1; + const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; for (const c of sorted) { + // Never evict the cookie we are about to (re)write for this state. + if (c.name === newCookieName) continue; cookies.deleteCookie(resCookies, c.name, deleteOptions); - freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; + freed += sizeOf(c.name, c.value); if (freed >= target) break; } console.warn( - `[auth0] Evicted transaction cookie(s) — total size ${totalBytes} bytes exceeded ` + - `${this.maxSizeBytes} byte limit. Increase transactionCookie.maxSizeBytes to ` + - `reduce eviction of in-flight logins.` + `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + + `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` ); } } - const expiration = Math.floor( - Date.now() / 1000 + this.cookieOptions.maxAge! - ); - const jwe = await cookies.encrypt( - transactionState, - this.secret, - expiration - ); - - // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. - // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". - const ts = Math.floor(Date.now() / 1000); - resCookies.set( - this.getTransactionCookieName(transactionState.state), - `${ts}:${jwe}`, - this.cookieOptions - ); + resCookies.set(newCookieName, newCookieValue, this.cookieOptions); } async get(reqCookies: cookies.RequestCookies, state: string) { diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index fd012359c..bf6c16560 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -136,10 +136,16 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { }); // --------------------------------------------------------------------------- -// Fix 2 — maxSizeBytes eviction in TransactionStore.save() +// Fix 2 — transaction cookie eviction in TransactionStore.save() +// The byte limit is fixed at 3500 bytes and not configurable. Tests exercise it +// by building transaction cookies whose combined size crosses that threshold. // --------------------------------------------------------------------------- -describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { +// A single transaction cookie value large enough that two of them exceed the +// fixed 3500-byte limit but one does not (~1900 bytes of value each). +const BIG_VALUE = (ts: number) => `${ts}:${"j".repeat(1900)}`; + +describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () => { let secret: string; beforeEach(async () => { @@ -147,14 +153,11 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { }); it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 10 } - }); + const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); const state = "state-no-evict"; - // Even with a tiny maxSizeBytes, passing no reqCookies skips eviction + // With no reqCookies snapshot, eviction is skipped entirely. await expect( store.save(resCookies, makeTransactionState(state)) ).resolves.not.toThrow(); @@ -162,15 +165,12 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${state}`)?.value).toBeTruthy(); }); - it("does not evict when accumulated bytes are below maxSizeBytes", async () => { - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 99999 } - }); + it("does not evict when accumulated bytes are below the limit", async () => { + const store = new TransactionStore({ secret }); const existingState = "existing-state"; const reqCookies = makeRequestCookies({ - [`__txn_${existingState}`]: "short" + [`__txn_${existingState}`]: "1000:short" }); const resCookies = makeResponseCookies(); const newState = "new-state"; @@ -187,28 +187,16 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("evicts oldest cookie first when threshold exceeded with mixed timestamps", async () => { + it("evicts oldest cookie first when the 3500 byte limit is exceeded", async () => { + const store = new TransactionStore({ secret }); + const olderState = "older"; const newerState = "newer"; - const olderValue = "1000:jwe_older"; - const newerValue = "9999:jwe_newer"; - - const enc = new TextEncoder(); - const olderBytes = enc.encode(`__txn_${olderState}=${olderValue}`).length; - const newerBytes = enc.encode(`__txn_${newerState}=${newerValue}`).length; - const totalBytes = olderBytes + newerBytes; - - // maxSizeBytes just below total — eviction fires but only needs to remove one - const maxSizeBytes = totalBytes - olderBytes + 1; - - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes } - }); - + // Two big cookies together exceed 3500 bytes → eviction fires and only needs + // to remove the single oldest to get back under the limit. const reqCookies = makeRequestCookies({ - [`__txn_${olderState}`]: olderValue, - [`__txn_${newerState}`]: newerValue + [`__txn_${olderState}`]: BIG_VALUE(1000), + [`__txn_${newerState}`]: BIG_VALUE(9999) }); const resCookies = makeResponseCookies(); @@ -223,18 +211,15 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("phase-2 evicts oldest real login cookies first when phase-1 insufficient", async () => { - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 1 } - }); + it("evicts oldest login cookies first (FIFO by timestamp)", async () => { + const store = new TransactionStore({ secret }); const olderState = "older"; const newerState = "newer"; - // Older timestamp should be evicted first + // Older timestamp should be evicted first once the limit is crossed. const reqCookies = makeRequestCookies({ - [`__txn_${olderState}`]: "1000:jwe_older", - [`__txn_${newerState}`]: "9999:jwe_newer" + [`__txn_${olderState}`]: BIG_VALUE(1000), + [`__txn_${newerState}`]: BIG_VALUE(9999) }); const resCookies = makeResponseCookies(); @@ -247,18 +232,15 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("evicts legacy cookies (no prefix) in phase-2 as oldest (timestamp=0)", async () => { + it("evicts legacy cookies (no timestamp prefix) as oldest (timestamp=0)", async () => { // Legacy format "{jwe}" has no prefix → gets timestamp 0 → oldest in FIFO - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 1 } - }); + const store = new TransactionStore({ secret }); const legacyState = "legacy"; const newerState = "newer"; const reqCookies = makeRequestCookies({ - [`__txn_${legacyState}`]: "raw_jwe_no_prefix", - [`__txn_${newerState}`]: "9999:jwe_newer", + [`__txn_${legacyState}`]: "r".repeat(1900), // legacy bare value, no "{ts}:" + [`__txn_${newerState}`]: BIG_VALUE(9999), other_cookie: "keep_me" }); const resCookies = makeResponseCookies(); @@ -278,16 +260,17 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { const customPrefix = "__my_txn_"; const store = new TransactionStore({ secret, - cookieOptions: { maxSizeBytes: 1, prefix: customPrefix } + cookieOptions: { prefix: customPrefix } }); + // Two big custom-prefix cookies exceed the limit; a same-sized cookie with a + // different prefix must not be counted toward the budget or evicted. const reqCookies = makeRequestCookies({ - [`${customPrefix}state1`]: "p:prefetch_jwe", - __txn_other: "1000:other_jwe" // different prefix — should NOT be evicted + [`${customPrefix}state1`]: BIG_VALUE(1000), + [`${customPrefix}state2`]: BIG_VALUE(2000), + __txn_other: BIG_VALUE(1000) // different prefix — should NOT be evicted }); const resCookies = makeResponseCookies(); - resCookies.set(`${customPrefix}state1`, "p:prefetch_jwe"); - resCookies.set("__txn_other", "1000:other_jwe"); await store.save( resCookies, @@ -295,9 +278,21 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { reqCookies ); + // Oldest custom-prefix cookie evicted expect(resCookies.get(`${customPrefix}state1`)?.maxAge).toBe(0); // __txn_other has a different prefix — not touched by this store - expect(resCookies.get("__txn_other")?.value).toBe("1000:other_jwe"); + expect(resCookies.get("__txn_other")).toBeUndefined(); + }); + + it("does not expose maxSizeBytes as a configurable option", () => { + // Type-level guarantee that the option was removed; passing it is a no-op + // and the fixed limit still governs eviction. + const store = new TransactionStore({ + secret, + // @ts-expect-error maxSizeBytes is no longer a supported option + cookieOptions: { maxSizeBytes: 1 } + }); + expect(store).toBeInstanceOf(TransactionStore); }); it("cookie value is encoded as '{ts}:{jwe}'", async () => { From 4dd65a780380e16dfafc845adcd58f78ad03d761 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 13 Jul 2026 20:13:15 +0530 Subject: [PATCH 06/36] fix: consolidate 431 docs and inline transaction cookie cleanup --- EXAMPLES.md | 53 ++++++++++++++++++++++++++++++++++++--- README.md | 2 +- src/server/auth-client.ts | 16 +++--------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 74fdb9ab3..dd979cb92 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -151,6 +151,7 @@ - [Customizing Transaction Cookie Expiration](#customizing-transaction-cookie-expiration) - [Transaction Management Modes](#transaction-management-modes) - [Transaction Cookie Options](#transaction-cookie-options) + - [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors) - [Database sessions](#database-sessions) - [Using Client-Initiated Backchannel Authentication](#using-client-initiated-backchannel-authentication) - [Connected Accounts](#connected-accounts) @@ -240,6 +241,9 @@ The second option is through the query parameters to the `/auth/login` endpoint Login ``` +> [!NOTE] +> Link to your login route with a plain `` tag (as shown above) or `` — never ``. A prefetched `` starts a login flow that never completes, accumulating transaction cookies. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). + ### Social Login To skip the Universal Login page and send users directly to a social provider, pass the `connection` parameter with the Auth0 connection name: @@ -573,6 +577,9 @@ export async function middleware(request: NextRequest) { ## Protecting a Server-Side Rendered (SSR) Page +> [!TIP] +> Prefer `withPageAuthRequired` (below) over redirecting to `/auth/login` from middleware. Its redirect happens inside the render and is not followed during a Next.js prefetch, so no transaction cookie is written for prefetched protected pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). + #### Page Router Requests to `/pages/profile` without a valid session cookie will be redirected to the login page. @@ -615,6 +622,9 @@ export default auth0.withPageAuthRequired( To protect a Client-Side Rendered (CSR) page, you can use the `withPageAuthRequired` higher-order function. Requests to `/profile` without a valid session cookie will be redirected to the login page. +> [!TIP] +> Using `withPageAuthRequired` (rather than a middleware redirect to `/auth/login`) also avoids transaction-cookie accumulation on prefetched pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). + ```tsx // app/profile/page.tsx "use client"; @@ -4087,7 +4097,7 @@ const authClient = new Auth0Client({ | `transactionCookie.secure` | `boolean` | When `true`, the cookie is only sent over HTTPS. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | | `transactionCookie.path` | `string` | URL path for which the cookie is valid. Defaults to `"/"`. | -### Troubleshooting: 431 / cookie header too large +### Preventing "431 Request Header Fields Too Large" Errors If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookies have grown beyond your server's header size limit. @@ -4096,15 +4106,52 @@ If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookie 1. Returns `401` on Next.js prefetch requests to `/auth/login` (detected via prefetch headers such as `next-router-prefetch`, `purpose`, `sec-purpose`, and `x-middleware-prefetch`), so no `__txn_*` cookie is written for a flow that will never complete. 2. Automatically evicts accumulated `__txn_*` cookies once their combined size reaches a fixed internal limit (3500 bytes, roughly six concurrent in-flight logins) — oldest-first (FIFO) by creation timestamp — before writing the new cookie. Only transaction cookies are measured and evicted; the session and other cookies are never touched. This limit is not configurable. -If you are running an older version, adding `prefetch={false}` to `` components pointing to your login route is a safe fallback: +#### Recommended practices to avoid transaction cookie accumulation + +Even with the automatic protections above, follow these two practices so login flows are only started by real user navigation: + +**1. Do not use ``. Use a plain `` tag or ``.** + +Next.js prefetches `` targets on hover or when they scroll into view. A prefetch of `/auth/login` starts a login flow (writing a `__txn_*` cookie) that the user never completes, since the prefetched response is discarded. Prevent it by not prefetching the login route: ```tsx -// Optional safety net — not required in current SDK versions +// ✅ Do — a plain anchor never prefetches +Sign In + +// ✅ Do — Link with prefetch disabled Sign In + +// ❌ Don't — this prefetches /auth/login and writes a __txn_* cookie on hover/scroll +Sign In ``` +**2. Prefer `withPageAuthRequired` over middleware redirects to protect pages.** + +`withPageAuthRequired` redirects to the login route from inside the React Server Component render. Next.js does **not** follow that redirect during a prefetch, so `handleLogin` is never called and no `__txn_*` cookie is written for prefetched protected pages. A middleware redirect to `/auth/login`, by contrast, is followed on prefetch of a protected page while the user is logged out — each prefetch then writes a transaction cookie. + +```tsx +// ✅ Preferred — redirect happens in RSC render, not followed on prefetch +export default auth0.withPageAuthRequired(async function Page() { + return
Protected content
; +}, { returnTo: "/protected" }); +``` + +```ts +// ⚠️ Middleware redirect — followed on prefetch of a protected page while +// logged out, writing a __txn_* cookie for a flow that never completes. +export async function middleware(request: NextRequest) { + const session = await auth0.getSession(request); + if (!session) { + return NextResponse.redirect(new URL("/auth/login", request.nextUrl.origin)); + } + return NextResponse.next(); +} +``` + +If you are running an older SDK version without the automatic protections above, adding `prefetch={false}` to `` components pointing to your login route is the key fallback. + If accumulation persists after upgrading, shorten the transaction cookie lifetime so abandoned logins expire sooner: ```ts diff --git a/README.md b/README.md index 92089e80a..8dc363ee6 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ export default async function Home() { ``` > [!IMPORTANT] -> You must use `` tags instead of the `` component to ensure that the routing is not done client-side as that may result in some unexpected behavior. +> You must use `` tags instead of the `` component to ensure that the routing is not done client-side as that may result in some unexpected behavior. Prefetching a `` also starts login flows that never complete, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. ## Customizing the client diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index e3fa4ce76..0fc2aa0be 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -657,14 +657,6 @@ export class AuthClient { this.dpopValidated = true; } - private async cleanupTransactionCookies( - req: NextRequest, - resCookies: ResponseCookies, - state: string - ): Promise { - await this.transactionStore.delete(resCookies, state); - } - async handler(req: NextRequest): Promise { let { pathname } = req.nextUrl; @@ -1271,7 +1263,7 @@ export class AuthClient { session ); - await this.cleanupTransactionCookies(req, res.cookies, state); + await this.transactionStore.delete(res.cookies, state); return res; } @@ -1481,7 +1473,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.cleanupTransactionCookies(req, popupResponse.cookies, state); + await this.transactionStore.delete(popupResponse.cookies, state); return popupResponse; } else { // No existing session (edge case: session expired during popup flow) @@ -1551,7 +1543,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.cleanupTransactionCookies(req, popupResponse.cookies, state); + await this.transactionStore.delete(popupResponse.cookies, state); return popupResponse; } } @@ -1613,7 +1605,7 @@ export class AuthClient { await this.sessionStore.set(req.cookies, res.cookies, session, true); addCacheControlHeadersForSession(res); - await this.cleanupTransactionCookies(req, res.cookies, state); + await this.transactionStore.delete(res.cookies, state); return res; } From 537de26b062f1fd3f342d675d0b4810dea00638e Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 13 Jul 2026 21:06:01 +0530 Subject: [PATCH 07/36] fix: extract eviction logic into evictOldestTransactionCookies private func --- src/server/transaction-store.ts | 119 ++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 11c4b306c..c911d6fd5 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -189,63 +189,80 @@ export class TransactionStore { const newCookieValue = `${ts}:${jwe}`; // Evict oldest transaction cookies FIFO before writing the new one, so the - // accumulated `__txn_*` cookies stay under MAX_TRANSACTION_COOKIE_BYTES. - // Only transaction cookies are ever measured or deleted here — the session - // and other cookies are left untouched, and the platform's own request-header - // limit is not second-guessed (the SDK cannot know it, and guessing risks - // evicting in-flight logins that would otherwise have fit). + // accumulated `__txn_*` cookies stay under the fixed byte limit. Only + // transaction cookies are measured/deleted — the session and other cookies + // are left untouched, and the cookie about to be written is never evicted. if (reqCookies) { - const enc = new TextEncoder(); - const sizeOf = (name: string, value: string) => - enc.encode(`${name}=${value}`).length; - - const txnCookies = reqCookies - .getAll() - .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); - const txnBytes = txnCookies.reduce( - (sum, c) => sum + sizeOf(c.name, c.value), - 0 - ); - - if (txnBytes >= MAX_TRANSACTION_COOKIE_BYTES) { - const deleteOptions = { - domain: this.cookieOptions.domain, - path: this.cookieOptions.path, - secure: this.cookieOptions.secure, - sameSite: this.cookieOptions.sameSite, - httpOnly: this.cookieOptions.httpOnly - }; - - // Sort by timestamp encoded in value prefix "{ts}:{jwe}". - // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. - const sorted = [...txnCookies].sort((a, b) => { - const tsA = parseInt(a.value) || 0; - const tsB = parseInt(b.value) || 0; - return tsA - tsB; - }); - - let freed = 0; - const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; - for (const c of sorted) { - // Never evict the cookie we are about to (re)write for this state. - if (c.name === newCookieName) continue; - cookies.deleteCookie(resCookies, c.name, deleteOptions); - freed += sizeOf(c.name, c.value); - if (freed >= target) break; - } - - console.warn( - `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + - `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + - `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + - `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` - ); - } + this.evictOldestTransactionCookies(reqCookies, resCookies, newCookieName); } resCookies.set(newCookieName, newCookieValue, this.cookieOptions); } + /** + * Evicts the oldest transaction cookies (FIFO by the `{ts}:` value prefix) from + * the response so the accumulated `__txn_*` cookies stay under + * {@link MAX_TRANSACTION_COOKIE_BYTES} before a new one is written. + * + * Only cookies matching the transaction prefix are measured and deleted — the + * session, connection-token, and application cookies are never touched. The + * cookie about to be (re)written for the current transaction (`skipCookieName`) + * is never evicted. No-op when the accumulated size is under the limit. + */ + private evictOldestTransactionCookies( + reqCookies: cookies.RequestCookies, + resCookies: cookies.ResponseCookies, + skipCookieName: string + ) { + const sizeOf = (name: string, value: string) => + new TextEncoder().encode(`${name}=${value}`).length; + + const txnCookies = reqCookies + .getAll() + .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); + const txnBytes = txnCookies.reduce( + (sum, c) => sum + sizeOf(c.name, c.value), + 0 + ); + + if (txnBytes < MAX_TRANSACTION_COOKIE_BYTES) { + return; + } + + const deleteOptions = { + domain: this.cookieOptions.domain, + path: this.cookieOptions.path, + secure: this.cookieOptions.secure, + sameSite: this.cookieOptions.sameSite, + httpOnly: this.cookieOptions.httpOnly + }; + + // Sort by timestamp encoded in value prefix "{ts}:{jwe}". + // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. + const sorted = [...txnCookies].sort((a, b) => { + const tsA = parseInt(a.value) || 0; + const tsB = parseInt(b.value) || 0; + return tsA - tsB; + }); + + let freed = 0; + const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; + for (const c of sorted) { + // Never evict the cookie we are about to (re)write for this state. + if (c.name === skipCookieName) continue; + cookies.deleteCookie(resCookies, c.name, deleteOptions); + freed += sizeOf(c.name, c.value); + if (freed >= target) break; + } + + console.warn( + `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + + `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` + ); + } + async get(reqCookies: cookies.RequestCookies, state: string) { const cookieName = this.getTransactionCookieName(state); const cookieValue = reqCookies.get(cookieName)?.value; From 27119c4a5d66b8c2dd8704099cb282b881fbda34 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 17 Jul 2026 18:22:46 +0530 Subject: [PATCH 08/36] fix: addressing coderabbit review comments --- README.md | 2 +- src/server/transaction-store.ts | 43 +++++++++++++++------- src/server/txn-cookie-accumulation.test.ts | 35 +++++++++++++++--- src/utils/request.ts | 14 +++++-- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8dc363ee6..149df3440 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ export default async function Home() { ``` > [!IMPORTANT] -> You must use `` tags instead of the `` component to ensure that the routing is not done client-side as that may result in some unexpected behavior. Prefetching a `` also starts login flows that never complete, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. +> Link to the login route with a plain `` tag or `` — do not use ``. A prefetched `` starts a login flow that never completes, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. ## Customizing the client diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index c911d6fd5..f29c7054e 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -193,7 +193,12 @@ export class TransactionStore { // transaction cookies are measured/deleted — the session and other cookies // are left untouched, and the cookie about to be written is never evicted. if (reqCookies) { - this.evictOldestTransactionCookies(reqCookies, resCookies, newCookieName); + this.evictOldestTransactionCookies( + reqCookies, + resCookies, + newCookieName, + newCookieValue + ); } resCookies.set(newCookieName, newCookieValue, this.cookieOptions); @@ -201,18 +206,23 @@ export class TransactionStore { /** * Evicts the oldest transaction cookies (FIFO by the `{ts}:` value prefix) from - * the response so the accumulated `__txn_*` cookies stay under - * {@link MAX_TRANSACTION_COOKIE_BYTES} before a new one is written. + * the response so that the accumulated `__txn_*` cookies — including the one + * about to be written — stay under {@link MAX_TRANSACTION_COOKIE_BYTES}. * * Only cookies matching the transaction prefix are measured and deleted — the * session, connection-token, and application cookies are never touched. The - * cookie about to be (re)written for the current transaction (`skipCookieName`) - * is never evicted. No-op when the accumulated size is under the limit. + * cookie about to be (re)written for the current transaction (`newCookieName`) + * is never evicted. No-op when the projected total is under the limit. + * + * @param newCookieName - Name of the cookie about to be written (never evicted). + * @param newCookieValue - Value of that cookie; its size is included in the cap + * so a large new cookie can still trigger eviction. */ private evictOldestTransactionCookies( reqCookies: cookies.RequestCookies, resCookies: cookies.ResponseCookies, - skipCookieName: string + newCookieName: string, + newCookieValue: string ) { const sizeOf = (name: string, value: string) => new TextEncoder().encode(`${name}=${value}`).length; @@ -220,12 +230,19 @@ export class TransactionStore { const txnCookies = reqCookies .getAll() .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); - const txnBytes = txnCookies.reduce( - (sum, c) => sum + sizeOf(c.name, c.value), + + // Existing transaction-cookie bytes, excluding any cookie with the same name + // as the one we're about to write — its old bytes are replaced, not added. + const existingBytes = txnCookies.reduce( + (sum, c) => + c.name === newCookieName ? sum : sum + sizeOf(c.name, c.value), 0 ); + // Project the total that will be on the request header after this write. + const projectedBytes = + existingBytes + sizeOf(newCookieName, newCookieValue); - if (txnBytes < MAX_TRANSACTION_COOKIE_BYTES) { + if (projectedBytes < MAX_TRANSACTION_COOKIE_BYTES) { return; } @@ -246,18 +263,18 @@ export class TransactionStore { }); let freed = 0; - const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; + const target = projectedBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; for (const c of sorted) { // Never evict the cookie we are about to (re)write for this state. - if (c.name === skipCookieName) continue; + if (c.name === newCookieName) continue; cookies.deleteCookie(resCookies, c.name, deleteOptions); freed += sizeOf(c.name, c.value); if (freed >= target) break; } console.warn( - `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + - `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `[auth0] Evicted the oldest transaction cookie(s) — projected total size ${projectedBytes} bytes ` + + `reached the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` ); diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index bf6c16560..7530d2594 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -79,12 +79,6 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { ).toBe(true); }); - it("returns true when accept is text/x-component", () => { - expect( - isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) - ).toBe(true); - }); - it("returns true when purpose is prefetch", () => { expect(isNonNavigationalRequest(makeReq({ purpose: "prefetch" }))).toBe( true @@ -111,6 +105,14 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { ); }); + it("returns false for accept: text/x-component — real RSC navigation must not be blocked", () => { + // text/x-component is sent by ALL App Router RSC requests, including a + // genuine client-side click — not just prefetches. + expect( + isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) + ).toBe(false); + }); + it("returns false for sec-fetch-mode: navigate", () => { expect( isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) @@ -187,6 +189,27 @@ describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); + it("counts the new cookie in the cap: evicts when existing is under-limit but projected total reaches it", async () => { + const store = new TransactionStore({ secret }); + + // One existing cookie sized just under 3500 bytes on its own — no eviction + // would fire if only existing bytes were counted. The ~500-byte new cookie + // pushes the projected total over the limit, so eviction MUST fire. + const existingState = "existing"; + const nearLimitValue = `1000:${"j".repeat(3400)}`; // ~3413 bytes with name + const reqCookies = makeRequestCookies({ + [`__txn_${existingState}`]: nearLimitValue + }); + const resCookies = makeResponseCookies(); + + await store.save(resCookies, makeTransactionState("newstate"), reqCookies); + + // The existing (older) cookie is evicted so the projected total stays bounded + expect(resCookies.get(`__txn_${existingState}`)?.maxAge).toBe(0); + // New cookie still written + expect(resCookies.get("__txn_newstate")?.value).toBeTruthy(); + }); + it("evicts oldest cookie first when the 3500 byte limit is exceeded", async () => { const store = new TransactionStore({ secret }); diff --git a/src/utils/request.ts b/src/utils/request.ts index e9a548447..80db68a6b 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -18,16 +18,22 @@ export const isRequest = (req: Req): req is Request | NextRequest => { }; /** - * Returns true only when a request carries a known prefetch signal. + * Returns true only when a request carries an unambiguous prefetch signal. * Used to block Next.js prefetch requests from triggering handleLogin. * - * Intentionally excludes `sec-fetch-mode` — that header also matches - * legitimate fetch()/XHR calls to /auth/login which must not be blocked. + * Only headers that are exclusive to prefetches are checked: + * - `next-router-prefetch` / `x-middleware-prefetch` — Next.js prefetch markers + * - `purpose` / `sec-purpose` = `prefetch` — W3C/browser prefetch hints + * + * Intentionally excludes: + * - `sec-fetch-mode` — also set on legitimate fetch()/XHR calls to /auth/login. + * - `accept: text/x-component` — sent by ALL App Router RSC requests, including + * real client-side `` navigations (e.g. ``), so + * matching it would 401 genuine login clicks, not just prefetches. */ export const isNonNavigationalRequest = (req: NextRequest): boolean => { return ( req.headers.get("next-router-prefetch") === "1" || - req.headers.get("accept") === "text/x-component" || req.headers.get("purpose") === "prefetch" || req.headers.get("sec-purpose") === "prefetch" || req.headers.get("x-middleware-prefetch") === "1" From d1d12f02d8a969bfa82a4ad9525a32805c65634a Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 27 Jul 2026 21:00:20 +0530 Subject: [PATCH 09/36] fix: address code review findings on txn cookie accumulation PR --- EXAMPLES.md | 3 ++ src/server/auth-client.ts | 5 ++- src/server/client.test.ts | 37 +++++++++++++++++++ src/server/client.ts | 9 ++++- src/server/cookies.ts | 17 +++++++-- src/server/session/stateless-session-store.ts | 24 +++--------- src/server/transaction-store.ts | 29 +++++++++++---- 7 files changed, 93 insertions(+), 31 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index dd979cb92..649728a95 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4087,6 +4087,9 @@ const authClient = new Auth0Client({ - You want the simplest possible transaction management - Users typically don't need multiple concurrent login flows +> [!NOTE] +> In single transaction mode, starting a new login while one is already in progress overwrites the existing `__txn_` cookie rather than rejecting the new attempt. If a user has two tabs open and starts a login in both, only the most recently started login can complete; the other tab's callback will fail because its transaction state was overwritten. This is expected in single transaction mode — use the default parallel mode if concurrent logins across tabs need to succeed. + ### Transaction Cookie Options | Option | Type | Description | diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 0fc2aa0be..5e65b2eb5 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -803,7 +803,8 @@ export class AuthClient { async startInteractiveLogin( options: StartInteractiveLoginOptions = {}, - req?: NextRequest + req?: NextRequest, + reqCookies?: RequestCookies | ReadonlyRequestCookies ): Promise { await this.ensureDpopValidated(); const appBaseUrl = resolveAppBaseUrl(this.appBaseUrl, req); @@ -954,7 +955,7 @@ export class AuthClient { await this.transactionStore.save( res.cookies, transactionState, - req?.cookies + req?.cookies ?? reqCookies ); return res; diff --git a/src/server/client.test.ts b/src/server/client.test.ts index e92c16629..cb58288f1 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1750,6 +1750,43 @@ describe("Auth0Client", () => { }); }); }); + + describe("startInteractiveLogin", () => { + it("forwards request cookies to AuthClient.startInteractiveLogin so transaction-cookie eviction can run", async () => { + process.env[ENV_VARS.DOMAIN] = "env.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "env_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "env_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.com"; + process.env[ENV_VARS.SECRET] = "env_secret"; + + const client = new Auth0Client(); + + const mockCookieJar = { getAll: () => [] }; + const nextHeaders = await import("next/headers.js"); + vi.mocked(nextHeaders.cookies).mockResolvedValue(mockCookieJar as any); + + const mockAuthClient = { + startInteractiveLogin: vi + .fn() + .mockResolvedValue(NextResponse.redirect("https://example.com")) + }; + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue( + mockAuthClient + ); + + await client.startInteractiveLogin(); + + expect(mockAuthClient.startInteractiveLogin).toHaveBeenCalledTimes(1); + const [, req, reqCookies] = + mockAuthClient.startInteractiveLogin.mock.calls[0]; + // No NextRequest is available from Server Components/Actions. + expect(req).toBeUndefined(); + // Cookies must be forwarded — otherwise TransactionStore.save() never + // runs eviction, and __txn_* cookies accumulate unbounded for logins + // started this way (e.g. from a Server Action). + expect(reqCookies).toBe(mockCookieJar); + }); + }); }); export type GetAccessTokenOptions = { diff --git a/src/server/client.ts b/src/server/client.ts index 9378b0e4e..061bbbd12 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1706,7 +1706,14 @@ export class Auth0Client { ): Promise { const reqHeaders = await getHeaders(); const authClient = await this.provider.forRequest(reqHeaders, undefined); - return authClient.startInteractiveLogin(options); + // Pass request cookies so the transaction store can evict accumulated + // `__txn_*` cookies before writing the new one — otherwise logins started + // from Server Components/Actions never trigger eviction. + return authClient.startInteractiveLogin( + options, + undefined, + await cookies() + ); } /** diff --git a/src/server/cookies.ts b/src/server/cookies.ts index 961f30b70..c960cef8b 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -211,6 +211,9 @@ const getAllChunkedCookies = ( * @param options - Options for setting the cookie. * @param reqCookies - The request cookies object, used to enable read-after-write in the same request for middleware. * @param resCookies - The response cookies object, used to set the cookies in the response. + * @returns The total encoded `name=value` byte size of the cookie(s) written — + * lets callers check against header-size limits without re-scanning + * `resCookies` afterwards. */ export function setChunkedCookie( name: string, @@ -218,7 +221,7 @@ export function setChunkedCookie( options: CookieOptions, reqCookies: RequestCookies, resCookies: ResponseCookies -): void { +): number { const { transient, ...restOptions } = options; const finalOptions = { ...restOptions }; @@ -226,7 +229,11 @@ export function setChunkedCookie( delete finalOptions.maxAge; } - const valueBytes = new TextEncoder().encode(value).length; + const encoder = new TextEncoder(); + const sizeOf = (cookieName: string, cookieValue: string) => + encoder.encode(`${cookieName}=${cookieValue}`).length; + + const valueBytes = encoder.encode(value).length; // If value fits in a single cookie, set it directly if (valueBytes <= MAX_CHUNK_SIZE) { @@ -247,12 +254,13 @@ export function setChunkedCookie( reqCookies.delete(cookieChunk.name); }); - return; + return sizeOf(name, value); } // Split value into chunks let position = 0; let chunkIndex = 0; + let totalBytes = 0; while (position < value.length) { const chunk = value.slice(position, position + MAX_CHUNK_SIZE); @@ -261,6 +269,7 @@ export function setChunkedCookie( resCookies.set(chunkName, chunk, finalOptions); // to enable read-after-write in the same request for middleware reqCookies.set(chunkName, chunk); + totalBytes += sizeOf(chunkName, chunk); position += MAX_CHUNK_SIZE; chunkIndex++; } @@ -292,6 +301,8 @@ export function setChunkedCookie( httpOnly: finalOptions.httpOnly }); reqCookies.delete(name); + + return totalBytes; } /** diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index 4d6b83c2d..f53fba6b9 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -125,7 +125,12 @@ export class StatelessSessionStore extends AbstractSessionStore { maxAge }; - cookies.setChunkedCookie( + // Warn when the session cookie is large. This is the main remaining cause of + // 431 errors: the session (unlike transaction cookies) is never evicted, so + // an oversized session can overflow the request-header limit on its own. + // setChunkedCookie returns the total bytes of the chunk(s) it wrote, so no + // separate re-scan of resCookies is needed. + const sessionCookieBytes = cookies.setChunkedCookie( this.sessionCookieName, cookieValue, options, @@ -133,23 +138,6 @@ export class StatelessSessionStore extends AbstractSessionStore { resCookies ); - // Warn when the session cookie is large. This is the main remaining cause of - // 431 errors: the session (unlike transaction cookies) is never evicted, so - // an oversized session can overflow the request-header limit on its own. - // Measure the total bytes of all `__session` chunks written to the response. - const sessionCookieBytes = resCookies - .getAll() - .filter( - (c) => - c.name === this.sessionCookieName || - c.name.startsWith(`${this.sessionCookieName}__`) - ) - .reduce( - (sum, c) => - sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, - 0 - ); - if (sessionCookieBytes >= SESSION_COOKIE_SIZE_WARN_BYTES) { console.warn( `The ${this.sessionCookieName} cookie size is ${sessionCookieBytes} bytes, which may ` + diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index f29c7054e..6ee4f1451 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -167,7 +167,7 @@ export class TransactionStore { async save( resCookies: cookies.ResponseCookies, transactionState: TransactionState, - reqCookies?: cookies.RequestCookies + reqCookies?: cookies.RequestCookies | cookies.ReadonlyRequestCookies ) { if (!transactionState.state) { throw new Error("Transaction state is required"); @@ -219,7 +219,7 @@ export class TransactionStore { * so a large new cookie can still trigger eviction. */ private evictOldestTransactionCookies( - reqCookies: cookies.RequestCookies, + reqCookies: cookies.RequestCookies | cookies.ReadonlyRequestCookies, resCookies: cookies.ResponseCookies, newCookieName: string, newCookieValue: string @@ -256,11 +256,10 @@ export class TransactionStore { // Sort by timestamp encoded in value prefix "{ts}:{jwe}". // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. - const sorted = [...txnCookies].sort((a, b) => { - const tsA = parseInt(a.value) || 0; - const tsB = parseInt(b.value) || 0; - return tsA - tsB; - }); + const sorted = [...txnCookies].sort( + (a, b) => + this.parseCookieTimestamp(a.value) - this.parseCookieTimestamp(b.value) + ); let freed = 0; const target = projectedBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; @@ -280,6 +279,22 @@ export class TransactionStore { ); } + /** + * Extracts the creation timestamp from a cookie value shaped "{ts}:{jwe}". + * Legacy bare "{jwe}" values (no colon) have no timestamp and sort first (0). + * Uses an explicit split on the first colon rather than `parseInt`, so a + * legacy JWE that happens to start with digits is never mistaken for a + * timestamp — consistent with the split used in {@link get}. + */ + private parseCookieTimestamp(value: string): number { + const colonIdx = value.indexOf(":"); + if (colonIdx === -1) { + return 0; + } + const ts = Number(value.slice(0, colonIdx)); + return Number.isFinite(ts) ? ts : 0; + } + async get(reqCookies: cookies.RequestCookies, state: string) { const cookieName = this.getTransactionCookieName(state); const cookieValue = reqCookies.get(cookieName)?.value; From e7a44b35e9ddbc4722c5a986df5742b6b18aa862 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Tue, 28 Jul 2026 10:33:07 +0530 Subject: [PATCH 10/36] fix: failing test with passwordless nonce --- src/server/passwordless-server.flow.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/server/passwordless-server.flow.test.ts b/src/server/passwordless-server.flow.test.ts index ad29d1cff..df02bbd37 100644 --- a/src/server/passwordless-server.flow.test.ts +++ b/src/server/passwordless-server.flow.test.ts @@ -268,8 +268,14 @@ describe("AuthClient passwordless methods", () => { const state = authParams.state as string; const txnCookie = resCookies.get(`__txn_${state}`); expect(txnCookie).toBeDefined(); + // Strip the {ts}: prefix added by the transaction store before decrypting + const colonIdx = txnCookie!.value.indexOf(":"); + const jweValue = + colonIdx !== -1 + ? txnCookie!.value.slice(colonIdx + 1) + : txnCookie!.value; const { payload } = (await decrypt( - txnCookie!.value, + jweValue, secret )) as jose.JWTDecryptResult; expect(payload.nonce).toBe(authParams.nonce); From 7867c52154e9a034cb869162de53ba2b104f56bd Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 29 Jul 2026 11:50:01 +0530 Subject: [PATCH 11/36] fix: detect Sec-Purpose prefetch;prerender in isNonNavigationalRequest to prevent orphaned txn cookies --- src/server/txn-cookie-accumulation.test.ts | 8 ++++++++ src/utils/request.ts | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index 7530d2594..bbdd8d6d6 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -91,6 +91,14 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { ).toBe(true); }); + it("returns true when sec-purpose is prefetch;prerender (Speculation Rules)", () => { + expect( + isNonNavigationalRequest( + makeReq({ "sec-purpose": "prefetch;prerender" }) + ) + ).toBe(true); + }); + it("returns true when x-middleware-prefetch is 1", () => { expect( isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) diff --git a/src/utils/request.ts b/src/utils/request.ts index 80db68a6b..e7cfa36cb 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -25,6 +25,11 @@ export const isRequest = (req: Req): req is Request | NextRequest => { * - `next-router-prefetch` / `x-middleware-prefetch` — Next.js prefetch markers * - `purpose` / `sec-purpose` = `prefetch` — W3C/browser prefetch hints * + * `sec-purpose` is matched with `includes("prefetch")` rather than an exact + * equality: Chromium's Speculation Rules API sends `Sec-Purpose: prefetch;prerender` + * for prerender hints, which is still a machine request that never completes OAuth. + * `prefetch` only appears as a structured purpose token, so the substring match is safe. + * * Intentionally excludes: * - `sec-fetch-mode` — also set on legitimate fetch()/XHR calls to /auth/login. * - `accept: text/x-component` — sent by ALL App Router RSC requests, including @@ -35,7 +40,7 @@ export const isNonNavigationalRequest = (req: NextRequest): boolean => { return ( req.headers.get("next-router-prefetch") === "1" || req.headers.get("purpose") === "prefetch" || - req.headers.get("sec-purpose") === "prefetch" || + (req.headers.get("sec-purpose")?.includes("prefetch") ?? false) || req.headers.get("x-middleware-prefetch") === "1" ); }; From 4bc926ba7dfe252f639fe18c9017b09ab98bbbaa Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 29 Jul 2026 13:47:46 +0530 Subject: [PATCH 12/36] fix: delete stale __session__N chunks deterministically to prevent request header growth toward 431 --- src/server/chunked-cookies.test.ts | 268 +++++++++++++----- src/server/cookies.ts | 73 +++-- .../session/stateless-session-store.test.ts | 82 ++++-- 3 files changed, 305 insertions(+), 118 deletions(-) diff --git a/src/server/chunked-cookies.test.ts b/src/server/chunked-cookies.test.ts index b64e90c0c..c050bca7a 100644 --- a/src/server/chunked-cookies.test.ts +++ b/src/server/chunked-cookies.test.ts @@ -130,10 +130,33 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, value, options, reqCookies, resCookies); - expect(resCookies.set).toHaveBeenCalledTimes(1); + // resCookies.set called 6 times: 1 set + 5 deletes for indices 0-4 + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(resCookies.set).toHaveBeenCalledWith(name, value, options); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__0`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__1`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__2`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { + maxAge: 0, + path: "/" + }); expect(reqCookies.set).toHaveBeenCalledTimes(1); expect(reqCookies.set).toHaveBeenCalledWith(name, value); + // reqCookies.delete called 5 times for indices 0-4 + expect(reqCookies.delete).toHaveBeenCalledTimes(5); }); it("should split cookie into chunks when value exceeds max size", () => { @@ -145,11 +168,11 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, largeValue, options, reqCookies, resCookies); - // Should create 3 chunks (8000 / 3500 ≈ 2.3, rounded up to 3) - // called 4 times: - // 3 calls to set the chunks - // 1 call to remove the non-chunked cookie - expect(resCookies.set).toHaveBeenCalledTimes(4); + // resCookies.set called 6 times: + // 3 calls to set the chunks (__0, __1, __2) + // 2 calls to delete higher indices (__3, __4) + // 1 call to delete the base cookie + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(reqCookies.set).toHaveBeenCalledTimes(3); // Check first chunk @@ -173,11 +196,22 @@ describe("Chunked Cookie Utils", () => { options ); - // Check removal of non-chunked cookie + // Check deletion of unused chunk indices and base cookie + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { + maxAge: 0, + path: "/" + }); expect(resCookies.set).toHaveBeenCalledWith(name, "", { maxAge: 0, path: "/" }); + + // reqCookies.delete called 3 times: __3, __4, and base name + expect(reqCookies.delete).toHaveBeenCalledTimes(3); }); it("should clear existing chunked cookies when setting a single cookie", () => { @@ -195,27 +229,39 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, value, options, reqCookies, resCookies); - // delete the 3 chunked cookies set above and then set the new cookie - expect(resCookies.set).toHaveBeenCalledTimes(4); - expect(resCookies.set).toHaveBeenNthCalledWith(1, name, value, options); - expect(resCookies.set).toHaveBeenNthCalledWith(2, `${name}__1`, "", { + // resCookies.set called 6 times: + // 1 set of main cookie, then 5 deletes of deterministic indices 0-4 + expect(resCookies.set).toHaveBeenCalledTimes(6); + expect(resCookies.set).toHaveBeenCalledWith(name, value, options); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__0`, "", { maxAge: 0, path: "/" }); - expect(resCookies.set).toHaveBeenNthCalledWith(3, `${name}__0`, "", { + expect(resCookies.set).toHaveBeenCalledWith(`${name}__1`, "", { maxAge: 0, path: "/" }); - expect(resCookies.set).toHaveBeenNthCalledWith(4, `${name}__2`, "", { + expect(resCookies.set).toHaveBeenCalledWith(`${name}__2`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { maxAge: 0, path: "/" }); expect(reqCookies.set).toHaveBeenCalledTimes(1); expect(reqCookies.set).toHaveBeenCalledWith(name, value); - expect(reqCookies.delete).toHaveBeenCalledTimes(3); + // reqCookies.delete called 5 times for deterministic indices 0-4 + expect(reqCookies.delete).toHaveBeenCalledTimes(5); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__0`); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__1`); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__2`); + expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__3`); + expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__4`); }); it("should clear existing single cookies when setting a chunked cookie", () => { @@ -230,35 +276,71 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, largeValue, options, reqCookies, resCookies); - expect(reqCookies.delete).toHaveBeenCalledTimes(1); + // reqCookies.delete called 3 times: once for base name, then __3, __4 + expect(reqCookies.delete).toHaveBeenCalledTimes(3); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}`); - // set a chunked cookie with 3 chunks and delete the existing single cookie - expect(resCookies.set).toHaveBeenCalledTimes(4); - expect(resCookies.set).toHaveBeenNthCalledWith( - 1, + expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__3`); + expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__4`); + + // resCookies.set called 6 times: + // 3 calls to set the chunks (__0, __1, __2) + // 2 calls to delete higher indices (__3, __4) + // 1 call to delete the base cookie + expect(resCookies.set).toHaveBeenCalledTimes(6); + expect(resCookies.set).toHaveBeenCalledWith( `${name}__0`, largeValue.slice(0, 3500), options ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 2, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__1`, largeValue.slice(3500, 7000), options ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 3, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__2`, largeValue.slice(7000), options ); - expect(resCookies.set).toHaveBeenNthCalledWith(4, name, "", { + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { + maxAge: 0, + path: "/" + }); + expect(resCookies.set).toHaveBeenCalledWith(name, "", { maxAge: 0, path: "/" }); expect(reqCookies.set).toHaveBeenCalledTimes(3); }); + it("deletes higher-index chunks even when absent from the request snapshot (cross-tab orphan)", () => { + // A concurrent tab wrote __session__2, but this request's reqCookies only + // shows __0/__1. The deterministic sweep must still issue a deletion for + // __2 (and the rest of the range) so no chunk is left orphaned. + const name = "__session"; + const options = { path: "/" } as CookieOptions; + + // reqCookies snapshot is missing the concurrently-written __2 chunk. + cookieStore.set(`${name}__0`, "old0"); + cookieStore.set(`${name}__1`, "old1"); + + // New value is small → single-cookie path, which sweeps chunk indices 0..4. + setChunkedCookie(name, "small", options, reqCookies, resCookies); + + // A deletion is issued for every index in the deterministic range, + // including __2 which was never in reqCookies. + for (let i = 0; i < 5; i++) { + expect(resCookies.set).toHaveBeenCalledWith(`${name}__${i}`, "", { + maxAge: 0, + path: "/" + }); + } + }); + it("should clean up unused chunks when cookie shrinks", () => { const name = "testCookie"; const options = { path: "/" } as CookieOptions; @@ -301,7 +383,8 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, value, options, reqCookies, resCookies); - expect(resCookies.set).toHaveBeenCalledTimes(1); + // resCookies.set called 6 times: 1 set + 5 deletes for indices 0-4 + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(resCookies.set).toHaveBeenCalledWith( name, value, @@ -322,29 +405,43 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, largeValue, options, reqCookies, resCookies); - // called 4 times: - // 3 calls to set the chunks - // 1 call to remove the non-chunked cookie - expect(resCookies.set).toHaveBeenCalledTimes(4); - expect(resCookies.set).toHaveBeenNthCalledWith( - 1, + // resCookies.set called 6 times: + // 3 calls to set the chunks (__0, __1, __2) + // 2 calls to delete higher indices (__3, __4) + // 1 call to delete the base cookie + expect(resCookies.set).toHaveBeenCalledTimes(6); + expect(resCookies.set).toHaveBeenCalledWith( `${name}__0`, expect.any(String), expect.objectContaining({ domain: "example.com" }) ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 2, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__1`, expect.any(String), expect.objectContaining({ domain: "example.com" }) ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 3, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__2`, expect.any(String), expect.objectContaining({ domain: "example.com" }) ); - expect(resCookies.set).toHaveBeenNthCalledWith(4, name, "", { + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + domain: "example.com", + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: true + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { + domain: "example.com", + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: true + }); + expect(resCookies.set).toHaveBeenCalledWith(name, "", { domain: "example.com", httpOnly: true, maxAge: 0, @@ -371,7 +468,8 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, value, options, reqCookies, resCookies); - expect(resCookies.set).toHaveBeenCalledTimes(1); + // resCookies.set called 6 times: 1 set + 5 deletes for indices 0-4 + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(resCookies.set).toHaveBeenCalledWith(name, value, expectedOptions); expect(resCookies.set).not.toHaveBeenCalledWith( name, @@ -397,29 +495,41 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, largeValue, options, reqCookies, resCookies); - // called 4 times: - // 3 calls to set the chunks - // 1 call to remove the non-chunked cookie - expect(resCookies.set).toHaveBeenCalledTimes(4); - expect(resCookies.set).toHaveBeenNthCalledWith( - 1, + // resCookies.set called 6 times: + // 3 calls to set the chunks (__0, __1, __2) + // 2 calls to delete higher indices (__3, __4) + // 1 call to delete the base cookie + expect(resCookies.set).toHaveBeenCalledTimes(6); + expect(resCookies.set).toHaveBeenCalledWith( `${name}__0`, expect.any(String), expectedOptions ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 2, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__1`, expect.any(String), expectedOptions ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 3, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__2`, expect.any(String), expectedOptions ); - expect(resCookies.set).toHaveBeenNthCalledWith(4, name, "", { + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: true + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: true + }); + expect(resCookies.set).toHaveBeenCalledWith(name, "", { httpOnly: true, maxAge: 0, path: "/", @@ -449,7 +559,8 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, value, options, reqCookies, resCookies); - expect(resCookies.set).toHaveBeenCalledTimes(1); + // resCookies.set called 6 times: 1 set + 5 deletes for indices 0-4 + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(resCookies.set).toHaveBeenCalledWith(name, value, expectedOptions); expect(resCookies.set).toHaveBeenCalledWith( name, @@ -474,29 +585,41 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, largeValue, options, reqCookies, resCookies); - // called 4 times: - // 3 calls to set the chunks - // 1 call to remove the non-chunked cookie - expect(resCookies.set).toHaveBeenCalledTimes(4); - expect(resCookies.set).toHaveBeenNthCalledWith( - 1, + // resCookies.set called 6 times: + // 3 calls to set the chunks (__0, __1, __2) + // 2 calls to delete higher indices (__3, __4) + // 1 call to delete the base cookie + expect(resCookies.set).toHaveBeenCalledTimes(6); + expect(resCookies.set).toHaveBeenCalledWith( `${name}__0`, expect.any(String), expectedOptions ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 2, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__1`, expect.any(String), expectedOptions ); - expect(resCookies.set).toHaveBeenNthCalledWith( - 3, + expect(resCookies.set).toHaveBeenCalledWith( `${name}__2`, expect.any(String), expectedOptions ); - expect(resCookies.set).toHaveBeenNthCalledWith(4, name, "", { + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: true + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: true + }); + expect(resCookies.set).toHaveBeenCalledWith(name, "", { httpOnly: true, maxAge: 0, path: "/", @@ -592,8 +715,8 @@ describe("Chunked Cookie Utils", () => { deleteChunkedCookie(name, reqCookies, resCookies); - // Should delete main cookie and 3 chunks - expect(resCookies.set).toHaveBeenCalledTimes(4); + // Should delete main cookie and deterministic range of chunks (0-4) + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(resCookies.set).toHaveBeenCalledWith(name, "", { maxAge: 0 }); @@ -606,6 +729,12 @@ describe("Chunked Cookie Utils", () => { expect(resCookies.set).toHaveBeenCalledWith(`${name}__2`, "", { maxAge: 0 }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__3`, "", { + maxAge: 0 + }); + expect(resCookies.set).toHaveBeenCalledWith(`${name}__4`, "", { + maxAge: 0 + }); // Should not delete unrelated cookies expect(resCookies.set).not.toHaveBeenCalledWith("otherCookie", "", { maxAge: 0 @@ -621,7 +750,8 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, value, options, reqCookies, resCookies); - expect(resCookies.set).toHaveBeenCalledTimes(1); + // resCookies.set called 6 times: 1 set + 5 deletes for indices 0-4 + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(resCookies.set).toHaveBeenCalledWith(name, value, options); }); @@ -632,8 +762,9 @@ describe("Chunked Cookie Utils", () => { setChunkedCookie(name, value, options, reqCookies, resCookies); - // Should still fit in one cookie - expect(resCookies.set).toHaveBeenCalledTimes(1); + // Should still fit in one cookie, but deterministic deletes still happen + // resCookies.set called 6 times: 1 set + 5 deletes for indices 0-4 + expect(resCookies.set).toHaveBeenCalledTimes(6); expect(resCookies.set).toHaveBeenCalledWith(name, value, options); }); @@ -688,10 +819,11 @@ describe("Chunked Cookie Utils", () => { // Get chunks count (10000 / 3500 ≈ 2.86, so we need 3 chunks) const expectedChunks = Math.ceil(10000 / 3500); - // called 4 times: + // resCookies.set called 6 times: // 3 calls to set the chunks - // 1 call to remove the non-chunked cookie - expect(resCookies.set).toHaveBeenCalledTimes(expectedChunks + 1); + // 2 calls to delete higher indices (__3, __4) + // 1 call to delete the base cookie + expect(resCookies.set).toHaveBeenCalledTimes(6); // Clear and set up cookies for retrieval test cookieStore.clear(); diff --git a/src/server/cookies.ts b/src/server/cookies.ts index c960cef8b..f30b1b213 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -157,6 +157,13 @@ const MAX_CHUNK_SIZE = 3500; // Slightly under 4KB const CHUNK_PREFIX = "__"; const CHUNK_INDEX_REGEX = new RegExp(`${CHUNK_PREFIX}(\\d+)$`); const LEGACY_CHUNK_INDEX_REGEX = /\.(\d+)$/; +// Upper bound on chunk indices to clear when a chunked cookie shrinks (or is +// replaced by a single cookie). 5 × 3500 = 17,500 bytes — far beyond any real +// session, so no valid chunk is ever missed. Deleting a deterministic index +// range instead of scanning `reqCookies` avoids leaving orphaned chunks when a +// concurrent request/tab wrote a higher-index chunk not present in this +// request's (stale) cookie snapshot. +const MAX_CHUNKS = 5; /** * Retrieves the index of a cookie based on its name. @@ -241,18 +248,23 @@ export function setChunkedCookie( // to enable read-after-write in the same request for middleware reqCookies.set(name, value); - // When we are writing a non-chunked cookie, we should remove the chunked cookies - // Remove any previously stored chunks for this cookie name - getAllChunkedCookies(reqCookies, name).forEach((cookieChunk) => { - deleteCookie(resCookies, cookieChunk.name, { + // When we are writing a non-chunked cookie, remove any previously stored + // chunks for this cookie name. Delete a deterministic index range rather + // than scanning `reqCookies` — a concurrent request/tab may have written a + // higher-index chunk that this request's cookie snapshot does not include, + // which a snapshot-based scan would leave orphaned. The browser ignores + // deletions for cookies that do not exist. + for (let i = 0; i < MAX_CHUNKS; i++) { + const chunkName = `${name}${CHUNK_PREFIX}${i}`; + deleteCookie(resCookies, chunkName, { path: finalOptions.path, domain: finalOptions.domain, secure: finalOptions.secure, sameSite: finalOptions.sameSite, httpOnly: finalOptions.httpOnly }); - reqCookies.delete(cookieChunk.name); - }); + reqCookies.delete(chunkName); + } return sizeOf(name, value); } @@ -274,22 +286,21 @@ export function setChunkedCookie( chunkIndex++; } - // clear unused chunks - const chunks = getAllChunkedCookies(reqCookies, name); - const chunksToRemove = chunks.length - chunkIndex; - if (chunksToRemove > 0) { - for (let i = 0; i < chunksToRemove; i++) { - const chunkIndexToRemove = chunkIndex + i; - const chunkName = `${name}${CHUNK_PREFIX}${chunkIndexToRemove}`; - deleteCookie(resCookies, chunkName, { - path: finalOptions.path, - domain: finalOptions.domain, - secure: finalOptions.secure, - sameSite: finalOptions.sameSite, - httpOnly: finalOptions.httpOnly - }); - reqCookies.delete(chunkName); - } + // Clear any now-unused higher-index chunks. Delete a deterministic range + // (`chunkIndex .. MAX_CHUNKS-1`) rather than scanning `reqCookies`: a + // concurrent request/tab may have written a higher-index chunk that this + // request's cookie snapshot does not include, which a snapshot-based scan + // would leave orphaned. The browser ignores deletions for absent cookies. + for (let i = chunkIndex; i < MAX_CHUNKS; i++) { + const chunkName = `${name}${CHUNK_PREFIX}${i}`; + deleteCookie(resCookies, chunkName, { + path: finalOptions.path, + domain: finalOptions.domain, + secure: finalOptions.secure, + sameSite: finalOptions.sameSite, + httpOnly: finalOptions.httpOnly + }); + reqCookies.delete(chunkName); } // When we have written chunked cookies, we should remove the non-chunked cookie @@ -376,9 +387,21 @@ export function deleteChunkedCookie( // Delete main cookie deleteCookie(resCookies, name, options); - getAllChunkedCookies(reqCookies, name, isLegacyCookie).forEach((cookie) => { - deleteCookie(resCookies, cookie.name, options); // Delete each filtered cookie - }); + if (isLegacyCookie) { + // Legacy `{name}.{index}` chunks are no longer written, so their count is + // whatever a prior SDK version left behind — scan the request to find them. + getAllChunkedCookies(reqCookies, name, isLegacyCookie).forEach((cookie) => { + deleteCookie(resCookies, cookie.name, options); + }); + return; + } + + // Delete a deterministic index range instead of scanning `reqCookies`, so a + // chunk written by a concurrent request/tab (absent from this request's + // snapshot) is still removed. The browser ignores deletions for absent cookies. + for (let i = 0; i < MAX_CHUNKS; i++) { + deleteCookie(resCookies, `${name}${CHUNK_PREFIX}${i}`, options); + } } /** diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 332122614..71113d668 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -615,15 +615,44 @@ describe("Stateless Session Store", async () => { await sessionStore.set(requestCookies, responseCookies, session); - expect(responseCookies.set).toHaveBeenCalledTimes(4); - expect(responseCookies.set).toHaveBeenNthCalledWith( - 1, + // setChunkedCookie for __session now makes 6 calls (1 set + 5 deletes of __session__0..4) + // Then legacy cookie deletion: 1 base + 2 legacy chunks = 3 + // Total: 6 + 1 + 2 = 9 calls + expect(responseCookies.set).toHaveBeenCalledTimes(9); + // Verify main __session cookie is set (without maxAge: 0) + expect(responseCookies.set).toHaveBeenCalledWith( "__session", expect.any(String), expect.not.objectContaining({ maxAge: 0, path: "/" }) ); - expect(responseCookies.set).toHaveBeenNthCalledWith( - 2, + // Verify deterministic deletes of __session__0..4 + expect(responseCookies.set).toHaveBeenCalledWith( + `__session__0`, + "", + expect.objectContaining({ maxAge: 0 }) + ); + expect(responseCookies.set).toHaveBeenCalledWith( + `__session__1`, + "", + expect.objectContaining({ maxAge: 0 }) + ); + expect(responseCookies.set).toHaveBeenCalledWith( + `__session__2`, + "", + expect.objectContaining({ maxAge: 0 }) + ); + expect(responseCookies.set).toHaveBeenCalledWith( + `__session__3`, + "", + expect.objectContaining({ maxAge: 0 }) + ); + expect(responseCookies.set).toHaveBeenCalledWith( + `__session__4`, + "", + expect.objectContaining({ maxAge: 0 }) + ); + // Verify legacy cookie base is deleted + expect(responseCookies.set).toHaveBeenCalledWith( LEGACY_COOKIE_NAME, "", { @@ -634,8 +663,8 @@ describe("Stateless Session Store", async () => { secure: false } ); - expect(responseCookies.set).toHaveBeenNthCalledWith( - 3, + // Verify legacy chunks .0 and .1 are deleted + expect(responseCookies.set).toHaveBeenCalledWith( `${LEGACY_COOKIE_NAME}.0`, "", { @@ -646,8 +675,7 @@ describe("Stateless Session Store", async () => { secure: false } ); - expect(responseCookies.set).toHaveBeenNthCalledWith( - 4, + expect(responseCookies.set).toHaveBeenCalledWith( `${LEGACY_COOKIE_NAME}.1`, "", { @@ -928,26 +956,30 @@ describe("Stateless Session Store", async () => { const decryptedPayload = decryptedNewSession!.payload; expect(decryptedPayload).toEqual(expect.objectContaining(sessionToSet)); - // set should be called once for setting the new session cookie and once for deleting the legacy cookie - expect(setSpy).toHaveBeenCalledTimes(2); - expect(setSpy).toHaveBeenNthCalledWith( - 1, + // setChunkedCookie for __session makes 6 calls (1 set + 5 deletes of __session__0..4) + // legacyCookiesInSetup will have entries from setChunkedCookie on the legacy cookie + // For a chunked legacy cookie, it would be: 3 sets + 2 deletes + 1 base delete = 6 calls in setup + // But those were on tempResCookies. In sessionStore.set, we have: + // - 6 calls for __session from setChunkedCookie + // - 1 call for deleting legacy cookie base + // Total: 7 calls + expect(setSpy).toHaveBeenCalledTimes(7); + + // Verify main __session cookie is set + expect(setSpy).toHaveBeenCalledWith( "__session", expect.any(String), expect.not.objectContaining({ maxAge: 0, path: "/" }) ); - expect(setSpy).toHaveBeenNthCalledWith( - 2, - legacyCookiesInSetup[0].name, - "", - { - httpOnly: true, - maxAge: 0, - path: "/", - sameSite: "lax", - secure: false - } - ); + + // Verify legacy cookie base is deleted + expect(setSpy).toHaveBeenCalledWith(LEGACY_COOKIE_NAME, "", { + httpOnly: true, + maxAge: 0, + path: "/", + sameSite: "lax", + secure: false + }); }); describe("session cookie size warning", async () => { From e6193cf6f2a8ccc1d39032d9147a6bcfb02a31c7 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 29 Jul 2026 13:48:35 +0530 Subject: [PATCH 13/36] fix: dedup MFA step-up access tokens to prevent session-cookie growth toward 431 --- src/server/auth-client.ts | 18 +++- src/server/mfa-server.flow.test.ts | 163 +++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 5e65b2eb5..5df1e364d 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -4903,7 +4903,8 @@ export class AuthClient { ); session.accessTokens = session.accessTokens || []; - session.accessTokens.push({ + + const newAccessTokenSet = { accessToken: tokenResponse.access_token, scope: tokenResponse.scope, // oauth4webapi TokenEndpointResponse does NOT include audience field @@ -4911,7 +4912,20 @@ export class AuthClient { expiresAt: Math.floor(Date.now() / 1000) + Number(tokenResponse.expires_in), token_type: tokenResponse.token_type - }); + }; + + // Replace an existing token for the same audience, or append a new one. + // Without this, each MFA step-up appends another full token set for the same + // audience — growing the session cookie unbounded (and eventually a 431). + // Mirrors mergePopupTokenIntoSession's replace-or-append behavior. + const existingIdx = session.accessTokens.findIndex( + (t) => t.audience === newAccessTokenSet.audience + ); + if (existingIdx >= 0) { + session.accessTokens[existingIdx] = newAccessTokenSet; + } else { + session.accessTokens.push(newAccessTokenSet); + } // Persist updated session await this.sessionStore.set(reqCookies, resCookies, session); diff --git a/src/server/mfa-server.flow.test.ts b/src/server/mfa-server.flow.test.ts index a9e149bea..27a256f9c 100644 --- a/src/server/mfa-server.flow.test.ts +++ b/src/server/mfa-server.flow.test.ts @@ -496,6 +496,169 @@ describe("AuthClient MFA Methods", () => { ); }); + it("should replace (not append) the access token for the same audience on repeated step-up", async () => { + const { RequestCookies, ResponseCookies } = + await import("@edge-runtime/cookies"); + + const session: SessionData = { + user: { sub: DEFAULT.sub }, + tokenSet: { + idToken: "id-token", + accessToken: "old-access-token", + refreshToken: "refresh-token", + expiresAt: 123456 + }, + internal: { + sid: "session-id", + createdAt: Math.floor(Date.now() / 1000) + } + }; + + const sessionCookie = await createSessionCookie(session, secret); + const reqHeaders = new Headers(); + reqHeaders.append("cookie", `__session=${sessionCookie}`); + + const encryptedToken = await encryptMfaToken( + DEFAULT.mfaToken, + "https://api.example.com", + "read:data", + { challenge: [{ type: "otp" }] }, + secret, + 300 + ); + + let issued = 0; + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, () => { + issued += 1; + return HttpResponse.json({ + access_token: `mfa-access-token-${issued}`, + token_type: "Bearer", + expires_in: 3600, + scope: "read:data" + }); + }) + ); + + const reqCookies = new RequestCookies(reqHeaders); + const resCookies = new ResponseCookies(new Headers()); + + // First step-up for the audience. + const first = await authClient.mfaVerify({ + mfaToken: encryptedToken, + otp: "123456" + }); + await authClient.cacheTokenFromMfaVerify( + first, + encryptedToken, + reqCookies, + resCookies + ); + + // Second step-up for the SAME audience — must replace, not stack. + const second = await authClient.mfaVerify({ + mfaToken: encryptedToken, + otp: "123456" + }); + await authClient.cacheTokenFromMfaVerify( + second, + encryptedToken, + reqCookies, + resCookies + ); + + const updatedSession = await sessionStore.get(reqCookies); + const forAudience = updatedSession?.accessTokens?.filter( + (t) => t.audience === "https://api.example.com" + ); + // Exactly one entry for the audience (replaced, not appended)... + expect(forAudience?.length).toBe(1); + // ...holding the latest token. + expect(forAudience?.[0].accessToken).toBe("mfa-access-token-2"); + }); + + it("should keep separate entries for different audiences", async () => { + const { RequestCookies, ResponseCookies } = + await import("@edge-runtime/cookies"); + + const session: SessionData = { + user: { sub: DEFAULT.sub }, + tokenSet: { + idToken: "id-token", + accessToken: "old-access-token", + refreshToken: "refresh-token", + expiresAt: 123456 + }, + internal: { + sid: "session-id", + createdAt: Math.floor(Date.now() / 1000) + } + }; + + const sessionCookie = await createSessionCookie(session, secret); + const reqHeaders = new Headers(); + reqHeaders.append("cookie", `__session=${sessionCookie}`); + + const tokenA = await encryptMfaToken( + DEFAULT.mfaToken, + "https://api-a.example.com", + "read:data", + { challenge: [{ type: "otp" }] }, + secret, + 300 + ); + const tokenB = await encryptMfaToken( + DEFAULT.mfaToken, + "https://api-b.example.com", + "read:data", + { challenge: [{ type: "otp" }] }, + secret, + 300 + ); + + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, () => { + return HttpResponse.json({ + access_token: "at", + token_type: "Bearer", + expires_in: 3600, + scope: "read:data" + }); + }) + ); + + const reqCookies = new RequestCookies(reqHeaders); + const resCookies = new ResponseCookies(new Headers()); + + const resA = await authClient.mfaVerify({ + mfaToken: tokenA, + otp: "123456" + }); + await authClient.cacheTokenFromMfaVerify( + resA, + tokenA, + reqCookies, + resCookies + ); + + const resB = await authClient.mfaVerify({ + mfaToken: tokenB, + otp: "123456" + }); + await authClient.cacheTokenFromMfaVerify( + resB, + tokenB, + reqCookies, + resCookies + ); + + const updatedSession = await sessionStore.get(reqCookies); + const audiences = updatedSession?.accessTokens?.map((t) => t.audience); + expect(audiences).toContain("https://api-a.example.com"); + expect(audiences).toContain("https://api-b.example.com"); + expect(updatedSession?.accessTokens?.length).toBe(2); + }); + it("should work without session (stateless operation)", async () => { const encryptedToken = await encryptMfaToken( DEFAULT.mfaToken, From 28a8c23e83be1f1610c2bb59bb5147700843a7ae Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 30 Jul 2026 01:29:06 +0530 Subject: [PATCH 14/36] key MFA step-up token dedup on audience & scope to preserve differently-scoped tokens --- src/server/auth-client.ts | 17 +++-- src/server/mfa-server.flow.test.ts | 100 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 5df1e364d..668c10d7d 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -4914,12 +4914,19 @@ export class AuthClient { token_type: tokenResponse.token_type }; - // Replace an existing token for the same audience, or append a new one. - // Without this, each MFA step-up appends another full token set for the same - // audience — growing the session cookie unbounded (and eventually a 431). - // Mirrors mergePopupTokenIntoSession's replace-or-append behavior. + // Replace an existing token for the same audience AND scope, or append a + // new one. Without this, each MFA step-up appends another full token set — + // growing the session cookie unbounded (and eventually a 431). The key is + // audience + scope (not audience alone) to match findAccessTokenSet, which + // deliberately holds multiple same-audience token sets distinguished by + // scope; keying on audience alone would evict a differently-scoped token. + const normalizeScope = (scope?: string) => + (scope ?? "").trim().split(/\s+/).filter(Boolean).sort().join(" "); + const newScope = normalizeScope(newAccessTokenSet.scope); const existingIdx = session.accessTokens.findIndex( - (t) => t.audience === newAccessTokenSet.audience + (t) => + t.audience === newAccessTokenSet.audience && + normalizeScope(t.scope) === newScope ); if (existingIdx >= 0) { session.accessTokens[existingIdx] = newAccessTokenSet; diff --git a/src/server/mfa-server.flow.test.ts b/src/server/mfa-server.flow.test.ts index 27a256f9c..1bdbc36c9 100644 --- a/src/server/mfa-server.flow.test.ts +++ b/src/server/mfa-server.flow.test.ts @@ -659,6 +659,106 @@ describe("AuthClient MFA Methods", () => { expect(updatedSession?.accessTokens?.length).toBe(2); }); + it("should keep separate entries for the same audience at different scopes", async () => { + const { RequestCookies, ResponseCookies } = + await import("@edge-runtime/cookies"); + + const session: SessionData = { + user: { sub: DEFAULT.sub }, + tokenSet: { + idToken: "id-token", + accessToken: "old-access-token", + refreshToken: "refresh-token", + expiresAt: 123456 + }, + internal: { + sid: "session-id", + createdAt: Math.floor(Date.now() / 1000) + } + }; + + const sessionCookie = await createSessionCookie(session, secret); + const reqHeaders = new Headers(); + reqHeaders.append("cookie", `__session=${sessionCookie}`); + + const audience = "https://api.example.com"; + const readToken = await encryptMfaToken( + DEFAULT.mfaToken, + audience, + "read:data", + { challenge: [{ type: "otp" }] }, + secret, + 300 + ); + const writeToken = await encryptMfaToken( + DEFAULT.mfaToken, + audience, + "write:data", + { challenge: [{ type: "otp" }] }, + secret, + 300 + ); + + // Return a scope matching the step-up so the two token sets differ only + // by scope (same audience). + const scopeByToken: Record = { + "read-access-token": "read:data", + "write-access-token": "write:data" + }; + let issued = 0; + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, () => { + const accessToken = + issued === 0 ? "read-access-token" : "write-access-token"; + issued += 1; + return HttpResponse.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + scope: scopeByToken[accessToken] + }); + }) + ); + + const reqCookies = new RequestCookies(reqHeaders); + const resCookies = new ResponseCookies(new Headers()); + + // Step up for the audience at scope read:data. + const readRes = await authClient.mfaVerify({ + mfaToken: readToken, + otp: "123456" + }); + await authClient.cacheTokenFromMfaVerify( + readRes, + readToken, + reqCookies, + resCookies + ); + + // Step up for the SAME audience at a different scope write:data — must + // append, not replace, so the read:data token survives. + const writeRes = await authClient.mfaVerify({ + mfaToken: writeToken, + otp: "123456" + }); + await authClient.cacheTokenFromMfaVerify( + writeRes, + writeToken, + reqCookies, + resCookies + ); + + const updatedSession = await sessionStore.get(reqCookies); + const forAudience = updatedSession?.accessTokens?.filter( + (t) => t.audience === audience + ); + // Both scope-distinct tokens are retained for the audience. + expect(forAudience?.length).toBe(2); + const scopes = forAudience?.map((t) => t.scope); + expect(scopes).toContain("read:data"); + expect(scopes).toContain("write:data"); + }); + it("should work without session (stateless operation)", async () => { const encryptedToken = await encryptMfaToken( DEFAULT.mfaToken, From 27d2f7f0382440f89b25044bc22ec073e623f616 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 30 Jul 2026 01:46:30 +0530 Subject: [PATCH 15/36] fix: key MFA token dedup on requested scope and tighten session cookie test assertions --- src/server/auth-client.ts | 14 ++++++++++---- src/server/session/stateless-session-store.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 668c10d7d..03b60b60d 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -4896,8 +4896,8 @@ export class AuthClient { ); } - // Decrypt token to extract audience - const { audience } = await decryptMfaToken( + // Decrypt token to extract audience and the requested scope + const { audience, scope: requestedScope } = await decryptMfaToken( encryptedMfaToken, this.sessionStore.secret ); @@ -4907,6 +4907,7 @@ export class AuthClient { const newAccessTokenSet = { accessToken: tokenResponse.access_token, scope: tokenResponse.scope, + requestedScope, // oauth4webapi TokenEndpointResponse does NOT include audience field audience: audience || "", expiresAt: @@ -4920,13 +4921,18 @@ export class AuthClient { // audience + scope (not audience alone) to match findAccessTokenSet, which // deliberately holds multiple same-audience token sets distinguished by // scope; keying on audience alone would evict a differently-scoped token. + // Key on the requested scope (always present, with a fallback to the + // granted scope for legacy entries): the granted `scope` may be reduced or + // omitted by the server, which would otherwise collide distinct requests. const normalizeScope = (scope?: string) => (scope ?? "").trim().split(/\s+/).filter(Boolean).sort().join(" "); - const newScope = normalizeScope(newAccessTokenSet.scope); + const newScope = normalizeScope( + newAccessTokenSet.requestedScope ?? newAccessTokenSet.scope + ); const existingIdx = session.accessTokens.findIndex( (t) => t.audience === newAccessTokenSet.audience && - normalizeScope(t.scope) === newScope + normalizeScope(t.requestedScope ?? t.scope) === newScope ); if (existingIdx >= 0) { session.accessTokens[existingIdx] = newAccessTokenSet; diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 71113d668..7b9fa52e1 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -623,7 +623,7 @@ describe("Stateless Session Store", async () => { expect(responseCookies.set).toHaveBeenCalledWith( "__session", expect.any(String), - expect.not.objectContaining({ maxAge: 0, path: "/" }) + expect.not.objectContaining({ maxAge: 0 }) ); // Verify deterministic deletes of __session__0..4 expect(responseCookies.set).toHaveBeenCalledWith( @@ -969,7 +969,7 @@ describe("Stateless Session Store", async () => { expect(setSpy).toHaveBeenCalledWith( "__session", expect.any(String), - expect.not.objectContaining({ maxAge: 0, path: "/" }) + expect.not.objectContaining({ maxAge: 0 }) ); // Verify legacy cookie base is deleted From f97c52a01f9f547e45ccf303a8281d521423e597 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 12 Aug 2026 14:24:40 +0530 Subject: [PATCH 16/36] fix: addressing review comments --- src/server/auth-client.ts | 17 +++--- src/server/mfa-server.flow.test.ts | 85 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 03b60b60d..4e7e55007 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -4929,16 +4929,17 @@ export class AuthClient { const newScope = normalizeScope( newAccessTokenSet.requestedScope ?? newAccessTokenSet.scope ); - const existingIdx = session.accessTokens.findIndex( + // Remove ALL existing entries for this audience + scope (not just the first) + // so sessions that accumulated duplicates before this fix deployed are fully + // compacted on the next step-up, not left with N-1 stale entries. + session.accessTokens = session.accessTokens.filter( (t) => - t.audience === newAccessTokenSet.audience && - normalizeScope(t.requestedScope ?? t.scope) === newScope + !( + t.audience === newAccessTokenSet.audience && + normalizeScope(t.requestedScope ?? t.scope) === newScope + ) ); - if (existingIdx >= 0) { - session.accessTokens[existingIdx] = newAccessTokenSet; - } else { - session.accessTokens.push(newAccessTokenSet); - } + session.accessTokens.push(newAccessTokenSet); // Persist updated session await this.sessionStore.set(reqCookies, resCookies, session); diff --git a/src/server/mfa-server.flow.test.ts b/src/server/mfa-server.flow.test.ts index 1bdbc36c9..db6dbf453 100644 --- a/src/server/mfa-server.flow.test.ts +++ b/src/server/mfa-server.flow.test.ts @@ -577,6 +577,91 @@ describe("AuthClient MFA Methods", () => { expect(forAudience?.[0].accessToken).toBe("mfa-access-token-2"); }); + it("purges all pre-existing duplicates for the same audience+scope on step-up", async () => { + const { RequestCookies, ResponseCookies } = + await import("@edge-runtime/cookies"); + + // Seed the session with two duplicate entries for the same audience+scope + // (the state a session could be in after accumulating them before this fix). + const session: SessionData = { + user: { sub: DEFAULT.sub }, + tokenSet: { + idToken: "id-token", + accessToken: "old-access-token", + refreshToken: "refresh-token", + expiresAt: 123456 + }, + internal: { + sid: "session-id", + createdAt: Math.floor(Date.now() / 1000) + }, + accessTokens: [ + { + accessToken: "stale-token-1", + scope: "read:data", + requestedScope: "read:data", + audience: "https://api.example.com", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + token_type: "Bearer" + }, + { + accessToken: "stale-token-2", + scope: "read:data", + requestedScope: "read:data", + audience: "https://api.example.com", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + token_type: "Bearer" + } + ] + }; + + const sessionCookie = await createSessionCookie(session, secret); + const reqHeaders = new Headers(); + reqHeaders.append("cookie", `__session=${sessionCookie}`); + + const encryptedToken = await encryptMfaToken( + DEFAULT.mfaToken, + "https://api.example.com", + "read:data", + { challenge: [{ type: "otp" }] }, + secret, + 300 + ); + + server.use( + http.post(`https://${DEFAULT.domain}/oauth/token`, () => + HttpResponse.json({ + access_token: "fresh-token", + token_type: "Bearer", + expires_in: 3600, + scope: "read:data" + }) + ) + ); + + const reqCookies = new RequestCookies(reqHeaders); + const resCookies = new ResponseCookies(new Headers()); + + const res = await authClient.mfaVerify({ + mfaToken: encryptedToken, + otp: "123456" + }); + await authClient.cacheTokenFromMfaVerify( + res, + encryptedToken, + reqCookies, + resCookies + ); + + const updatedSession = await sessionStore.get(reqCookies); + const forAudience = updatedSession?.accessTokens?.filter( + (t) => t.audience === "https://api.example.com" + ); + // Both stale duplicates must be gone; exactly one fresh entry remains. + expect(forAudience?.length).toBe(1); + expect(forAudience?.[0].accessToken).toBe("fresh-token"); + }); + it("should keep separate entries for different audiences", async () => { const { RequestCookies, ResponseCookies } = await import("@edge-runtime/cookies"); From 9564d85d1aa3d07730099787ef928f5cf926d6b8 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Sat, 4 Jul 2026 14:45:08 +0530 Subject: [PATCH 17/36] fix: prevent __txn_* cookie accumulation via value-prefix encoding and targeted cleanup --- src/server/auth-client.test.ts | 31 +- src/server/auth-client.ts | 68 +- src/server/client.ts | 24 +- src/server/mfa-popup.test.ts | 8 +- src/server/transaction-store.test.ts | 50 +- src/server/transaction-store.ts | 185 ++++- src/server/txn-cookie-accumulation.test.ts | 816 +++++++++++++++++++++ src/test/utils.ts | 13 + src/utils/request.ts | 24 + 9 files changed, 1159 insertions(+), 60 deletions(-) create mode 100644 src/server/txn-cookie-accumulation.test.ts diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index f8382cf7d..34e41ca8b 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -24,7 +24,7 @@ import { TokenRevocationErrorCode } from "../errors/index.js"; import { getDefaultRoutes } from "../test/defaults.js"; -import { generateSecret } from "../test/utils.js"; +import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js"; import { AccessTokenSet, RESPONSE_TYPES, @@ -1674,7 +1674,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2004,7 +2004,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2358,7 +2358,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2405,7 +2405,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2448,7 +2448,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2499,7 +2499,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2554,7 +2554,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -2744,7 +2744,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie.value, + stripTransactionValuePrefix(transactionCookie.value), secret )) as jose.JWTDecryptResult ).payload @@ -2908,7 +2908,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie.value, + stripTransactionValuePrefix(transactionCookie.value), secret )) as jose.JWTDecryptResult ).payload @@ -2995,7 +2995,10 @@ ca/T0LLtgmbMmxSv/MmzIg== const state = transactionCookie.name.replace("__txn_", ""); expect(transactionCookie).toBeDefined(); expect( - (await decrypt(transactionCookie!.value, secret))!.payload + (await decrypt( + stripTransactionValuePrefix(transactionCookie!.value), + secret + ))!.payload ).toEqual( expect.objectContaining({ nonce: expect.any(String), @@ -7544,7 +7547,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -7691,7 +7694,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload @@ -8134,7 +8137,7 @@ ca/T0LLtgmbMmxSv/MmzIg== expect( ( (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult ).payload diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 65f69021e..26c9fc420 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -153,6 +153,7 @@ import { buildForwardedResponseHeaders, transformTargetUrl } from "../utils/proxy.js"; +import { isNonNavigationalRequest } from "../utils/request.js"; import { ensureDefaultScope, getScopeForAudience @@ -370,6 +371,18 @@ export interface AuthClientOptions { */ cspNonce?: string; + /** + * When `false` (default), the SDK returns a `401` on prefetch requests to the + * login route, preventing `__txn_*` cookies from being created for OAuth flows + * that will never complete. + * + * Set to `true` only if your login route renders custom page content worth + * prefetching (i.e. it does not immediately redirect to Auth0). + * + * @default false + */ + dangerouslyAllowLoginPrefetch?: boolean; + /** * @future This option is reserved for future implementation. * Currently not used - placeholder for upcoming nonce persistence feature. @@ -428,6 +441,7 @@ export class AuthClient { private readonly mfaTokenTtl: number; private readonly cspNonce?: string; + private readonly dangerouslyAllowLoginPrefetch: boolean; private proxyDpopHandles: { [audience: string]: oauth.DPoPHandle } = {}; @@ -614,6 +628,8 @@ export class AuthClient { // CSP nonce for popup postMessage inline scripts this.cspNonce = options.cspNonce; + this.dangerouslyAllowLoginPrefetch = + options.dangerouslyAllowLoginPrefetch ?? false; // Store keypair if provided, but validate lazily to avoid crypto bundling this.dpopKeyPair = options.dpopKeyPair; @@ -656,6 +672,19 @@ export class AuthClient { this.dpopValidated = true; } + private async cleanupTransactionCookies( + req: NextRequest, + resCookies: ResponseCookies, + state: string + ): Promise { + // Targeted cleanup — regardless of dangerouslyAllowLoginPrefetch flag: + // 1. Sweep all accumulated "p:" prefetch cookies — provably garbage, never match a callback + // 2. Delete only the single __txn_{state} that belongs to this completing flow + // All other real login cookies (e.g. Tab B mid-login, prompt:login multi-account) are untouched. + await this.transactionStore.deletePrefetchCookies(req.cookies, resCookies); + await this.transactionStore.delete(resCookies, state); + } + async handler(req: NextRequest): Promise { let { pathname } = req.nextUrl; @@ -672,6 +701,12 @@ export class AuthClient { const method = req.method; if (method === "GET" && sanitizedPathname === this.routes.login) { + if ( + !this.dangerouslyAllowLoginPrefetch && + isNonNavigationalRequest(req) + ) { + return new NextResponse(null, { status: 401 }); + } return this.handleLogin(req); } else if (method === "GET" && sanitizedPathname === this.routes.logout) { return this.handleLogout(req); @@ -947,8 +982,14 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - // Save transaction state - await this.transactionStore.save(res.cookies, transactionState); + // Save transaction state; pass req.cookies so save() can apply maxSizeBytes eviction. + // isPrefetch encodes "p:" prefix in value so eviction and cleanup can classify O(1). + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies, + req ? isNonNavigationalRequest(req) : false + ); return res; } @@ -1256,7 +1297,7 @@ export class AuthClient { session ); - await this.transactionStore.delete(res.cookies, state); + await this.cleanupTransactionCookies(req, res.cookies, state); return res; } @@ -1466,7 +1507,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.transactionStore.delete(popupResponse.cookies, state); + await this.cleanupTransactionCookies(req, popupResponse.cookies, state); return popupResponse; } else { // No existing session (edge case: session expired during popup flow) @@ -1536,7 +1577,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.transactionStore.delete(popupResponse.cookies, state); + await this.cleanupTransactionCookies(req, popupResponse.cookies, state); return popupResponse; } } @@ -1598,8 +1639,7 @@ export class AuthClient { await this.sessionStore.set(req.cookies, res.cookies, session, true); addCacheControlHeadersForSession(res); - // Clean up the current transaction cookie after successful authentication - await this.transactionStore.delete(res.cookies, state); + await this.cleanupTransactionCookies(req, res.cookies, state); return res; } @@ -4177,7 +4217,12 @@ export class AuthClient { `${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}` ); - await this.transactionStore.save(res.cookies, transactionState); + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies, + false // connect account — always a real user-initiated flow, never prefetch + ); return [null, res]; } @@ -5819,7 +5864,12 @@ export class AuthClient { "Pass the NextResponse cookies (App Router: next/headers cookies; Pages Router: res.cookies)." ); } - await this.transactionStore.save(resCookies, magicLinkTransactionState); + await this.transactionStore.save( + resCookies, + magicLinkTransactionState, + req?.cookies, + false // magic link — always a real user-initiated flow, never prefetch + ); } } diff --git a/src/server/client.ts b/src/server/client.ts index 7e3b3c848..2901ddbd2 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -278,6 +278,21 @@ export interface Auth0ClientOptions { enableParallelTransactions?: boolean; + /** + * When `false` (default), the SDK returns a `401` on prefetch requests to the + * login route (`/auth/login`), preventing `__txn_*` transaction cookies from + * being created for OAuth flows that will never complete. + * + * The standard login route immediately redirects to Auth0's hosted login page — + * there is no page content to prefetch, so blocking prefetch has no user-visible cost. + * + * Set to `true` only if you have overridden the login route to render custom + * page content (e.g. an embedded login form) that is worth prefetching. + * + * @default false + */ + dangerouslyAllowLoginPrefetch?: boolean; + /** * If true, the `/auth/connect` endpoint will be mounted to enable users to connect additional accounts. */ @@ -617,7 +632,8 @@ export class Auth0Client { path: options.transactionCookie?.path ?? basePath ?? "/", maxAge: options.transactionCookie?.maxAge ?? 3600, domain: - options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN + options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN, + maxSizeBytes: options.transactionCookie?.maxSizeBytes }; if (appBaseUrl) { @@ -710,7 +726,9 @@ export class Auth0Client { this.transactionStore = new TransactionStore({ secret, cookieOptions: transactionCookieOptions, - enableParallelTransactions: options.enableParallelTransactions ?? true + enableParallelTransactions: options.enableParallelTransactions ?? true, + dangerouslyAllowLoginPrefetch: + options.dangerouslyAllowLoginPrefetch ?? false }); this.sessionStore = options.sessionStore @@ -798,6 +816,8 @@ export class Auth0Client { fetch: options.customFetch, mfaTokenTtl, cspNonce: options.cspNonce, + dangerouslyAllowLoginPrefetch: + options.dangerouslyAllowLoginPrefetch ?? false, discoveryCache, provider: this.provider diff --git a/src/server/mfa-popup.test.ts b/src/server/mfa-popup.test.ts index 773772ddd..3782bb63e 100644 --- a/src/server/mfa-popup.test.ts +++ b/src/server/mfa-popup.test.ts @@ -4,7 +4,7 @@ import * as oauth from "oauth4webapi"; import { describe, expect, it, vi } from "vitest"; import { getDefaultRoutes } from "../test/defaults.js"; -import { generateSecret } from "../test/utils.js"; +import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js"; import { RESPONSE_TYPES, SessionData } from "../types/index.js"; import { createAuthCompletePostMessageResponse } from "../utils/html-helpers.js"; import { AuthClient } from "./auth-client.js"; @@ -166,7 +166,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => { const transactionCookie = response.cookies.get(`__txn_${state}`); expect(transactionCookie).toBeDefined(); const { payload: txn } = (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult; expect(txn.challengeMode).toBe("popup"); @@ -200,7 +200,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => { const state = authUrl.searchParams.get("state")!; const transactionCookie = response.cookies.get(`__txn_${state}`); const { payload: txn } = (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult; // When challengeMode is 'redirect' (default), it's not stored to minimize cookie size @@ -260,7 +260,7 @@ describe("MFA Popup (challengeMode + postMessage)", async () => { const state = authUrl.searchParams.get("state")!; const transactionCookie = response.cookies.get(`__txn_${state}`); const { payload: txn } = (await decrypt( - transactionCookie!.value, + stripTransactionValuePrefix(transactionCookie!.value), secret )) as jose.JWTDecryptResult; diff --git a/src/server/transaction-store.test.ts b/src/server/transaction-store.test.ts index a8446d9d2..bb72a62de 100644 --- a/src/server/transaction-store.test.ts +++ b/src/server/transaction-store.test.ts @@ -2,7 +2,7 @@ import * as jose from "jose"; import * as oauth from "oauth4webapi"; import { describe, expect, it } from "vitest"; -import { generateSecret } from "../test/utils.js"; +import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js"; import { RESPONSE_TYPES } from "../types/connected-accounts.js"; import { decrypt, @@ -108,8 +108,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -172,8 +176,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -211,8 +219,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -250,8 +262,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/custom-path"); }); @@ -285,8 +301,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); @@ -325,8 +345,12 @@ describe("Transaction Store", async () => { expect(cookie).toBeDefined(); expect( - ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) - .payload + ( + (await decrypt( + stripTransactionValuePrefix(cookie!.value), + secret + )) as jose.JWTDecryptResult + ).payload ).toEqual(expect.objectContaining(transactionState)); expect(cookie?.path).toEqual("/"); expect(cookie?.httpOnly).toEqual(true); diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index c0d59b23e..a24951f9b 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,6 +5,11 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; +// Value prefix for prefetch-created cookies — pure garbage, never leads to a +// real callback. Short maxAge (60s) further limits accumulation window. +const PREFETCH_VALUE_PREFIX = "p:"; +const PREFETCH_MAX_AGE = 60; // seconds + export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -52,6 +57,24 @@ export interface TransactionCookieOptions { * Default: `__txn_{state}`. */ prefix?: string; + /** + * Maximum total byte size of all transaction cookies combined. When the + * accumulated size meets or exceeds this limit, cookies are evicted before + * the new one is written using a two-phase strategy: + * + * Phase 1 — delete all prefetch cookies (value prefix `p:`). These are + * provably garbage and never lead to a completed OAuth flow. + * + * Phase 2 — if still over threshold after phase 1, evict real login cookies + * oldest-first by the timestamp encoded in their value prefix (`{ts}:`). + * Zero crypto decryption happens during eviction. + * + * One `__txn_*` JWE is ~450–555 bytes. Default `4096` allows ~7–9 cookies — + * well under the 8 KB request-header limit most servers enforce. + * + * @default 4096 + */ + maxSizeBytes?: number; /** * The sameSite attribute of the transaction cookie. * @@ -94,6 +117,13 @@ export interface TransactionStoreOptions { * @default true */ enableParallelTransactions?: boolean; + /** + * Mirrors the `dangerouslyAllowLoginPrefetch` flag from `Auth0ClientOptions`. + * Controls the eviction strategy when `maxSizeBytes` is exceeded. + * + * @default false + */ + dangerouslyAllowLoginPrefetch?: boolean; } /** @@ -106,11 +136,14 @@ export class TransactionStore { private readonly transactionCookiePrefix: string; private readonly cookieOptions: cookies.CookieOptions; private readonly enableParallelTransactions: boolean; + private readonly maxSizeBytes: number; + private readonly dangerouslyAllowLoginPrefetch: boolean; constructor({ secret, cookieOptions, - enableParallelTransactions + enableParallelTransactions, + dangerouslyAllowLoginPrefetch }: TransactionStoreOptions) { this.secret = secret; this.transactionCookiePrefix = @@ -124,6 +157,8 @@ export class TransactionStore { maxAge: cookieOptions?.maxAge || 60 * 60 // 1 hour in seconds }; this.enableParallelTransactions = enableParallelTransactions ?? true; + this.maxSizeBytes = cookieOptions?.maxSizeBytes ?? 4096; + this.dangerouslyAllowLoginPrefetch = dangerouslyAllowLoginPrefetch ?? false; } /** @@ -149,34 +184,97 @@ export class TransactionStore { * * @param resCookies - The response cookies object to set the transaction cookie on * @param transactionState - The transaction state to save - * @param reqCookies - Optional request cookies to check for existing transactions. - * When provided and `enableParallelTransactions` is false, - * will check for existing transaction cookies. When omitted, - * the existence check is skipped for performance optimization. + * @param reqCookies - Optional request cookies. When provided, enables maxSizeBytes + * eviction before writing the new cookie. + * @param isPrefetch - When true, the cookie value is prefixed with "p:" and gets a + * short maxAge (60s). Prefetch cookies are evicted first during + * eviction and never match a real callback. * @throws {Error} When transaction state is missing required state parameter */ async save( resCookies: cookies.ResponseCookies, transactionState: TransactionState, - reqCookies?: cookies.RequestCookies + reqCookies?: cookies.RequestCookies, + isPrefetch?: boolean ) { if (!transactionState.state) { throw new Error("Transaction state is required"); } - // When parallel transactions are disabled, check if a transaction already exists - if (reqCookies && !this.enableParallelTransactions) { - const cookieName = this.getTransactionCookieName(transactionState.state); - const existingCookie = reqCookies.get(cookieName); - if (existingCookie) { - console.warn( - "A transaction is already in progress. Only one transaction is allowed when parallel transactions are disabled." + // Evict accumulated transaction cookies when accumulated size meets the cap. + // Safety net for abandoned logins and silent prefetches that bypass Fix 1 + // (e.g. router.prefetch(), CDNs that strip sec-fetch-mode). + if (reqCookies) { + const existing = reqCookies + .getAll() + .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); + const totalBytes = existing.reduce( + (sum, c) => + sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + 0 + ); + if (totalBytes >= this.maxSizeBytes) { + // Two-phase eviction — zero crypto decryption. + // Phase 1: evict all prefetch cookies (value starts with "p:") — always garbage. + // Phase 2: if still over threshold, evict real login cookies oldest-first + // by timestamp encoded in value prefix ("{ts}:"). + const deleteOptions = { + domain: this.cookieOptions.domain, + path: this.cookieOptions.path, + secure: this.cookieOptions.secure, + sameSite: this.cookieOptions.sameSite, + httpOnly: this.cookieOptions.httpOnly + }; + + const prefetchCookies = existing.filter((c) => + c.value.startsWith(PREFETCH_VALUE_PREFIX) + ); + const realCookies = existing + .filter((c) => !c.value.startsWith(PREFETCH_VALUE_PREFIX)) + .sort((a, b) => { + // Parse timestamp from "{ts}:{jwe}" — legacy "{jwe}" gets timestamp 0 + const tsA = parseInt(a.value) || 0; + const tsB = parseInt(b.value) || 0; + return tsA - tsB; // ascending — oldest first + }); + + let freed = prefetchCookies.reduce( + (sum, c) => + sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + 0 ); - return; + + const toEvict = [...prefetchCookies]; + if (freed < totalBytes - this.maxSizeBytes + 1) { + // Phase 1 insufficient — evict oldest real login cookies until under threshold + for (const c of realCookies) { + toEvict.push(c); + freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; + if (freed >= totalBytes - this.maxSizeBytes + 1) break; + } + } + + if (toEvict.length > 0) { + const evictedPrefetch = toEvict.filter((c) => + c.value.startsWith(PREFETCH_VALUE_PREFIX) + ).length; + const evictedReal = toEvict.length - evictedPrefetch; + console.warn( + `[auth0] Evicting ${toEvict.length} transaction cookie(s) ` + + `(${totalBytes} bytes ≥ ${this.maxSizeBytes} byte limit): ` + + `${evictedPrefetch} prefetch, ${evictedReal} real login(s). ` + + `Increase transactionCookie.maxSizeBytes to reduce eviction of in-flight logins.` + ); + for (const c of toEvict) { + cookies.deleteCookie(resCookies, c.name, deleteOptions); + } + } } } - const expirationSeconds = this.cookieOptions.maxAge!; + const expirationSeconds = isPrefetch + ? PREFETCH_MAX_AGE + : this.cookieOptions.maxAge!; const expiration = Math.floor(Date.now() / 1000 + expirationSeconds); const jwe = await cookies.encrypt( transactionState, @@ -184,10 +282,23 @@ export class TransactionStore { expiration ); + // Encode type and creation timestamp in the value for O(1) classification + // during eviction — no cookie name change, no breaking change. + // "p:{jwe}" → prefetch cookie (60s TTL, evicted first) + // "{ts}:{jwe}" → real login cookie (FIFO by ts during phase-2 eviction) + const ts = Math.floor(Date.now() / 1000); + const encodedValue = isPrefetch + ? `${PREFETCH_VALUE_PREFIX}${jwe}` + : `${ts}:${jwe}`; + + const cookieOptions = isPrefetch + ? { ...this.cookieOptions, maxAge: PREFETCH_MAX_AGE } + : this.cookieOptions; + resCookies.set( this.getTransactionCookieName(transactionState.state), - jwe.toString(), - this.cookieOptions + encodedValue, + cookieOptions ); } @@ -199,7 +310,14 @@ export class TransactionStore { return null; } - return cookies.decrypt(cookieValue, this.secret); + // Strip value prefix before decryption — backward compatible with legacy "{jwe}" format. + // "p:{jwe}" → strip "p:" prefix + // "{ts}:{jwe}" → strip "{ts}:" prefix (find first colon) + // "{jwe}" → no prefix, decrypt as-is (legacy) + const colonIdx = cookieValue.indexOf(":"); + const jwe = colonIdx !== -1 ? cookieValue.slice(colonIdx + 1) : cookieValue; + + return cookies.decrypt(jwe, this.secret); } async delete(resCookies: cookies.ResponseCookies, state: string) { @@ -234,4 +352,35 @@ export class TransactionStore { } }); } + + /** + * Deletes all prefetch-created transaction cookies (value prefix "p:"). + * These are provably garbage — they were created by non-navigational requests + * and can never lead to a completed OAuth flow. + * + * Called on callback success to sweep accumulated prefetch cookies without + * touching real in-flight logins from other tabs. + */ + async deletePrefetchCookies( + reqCookies: cookies.RequestCookies, + resCookies: cookies.ResponseCookies + ) { + const txnPrefix = this.getCookiePrefix(); + const deleteOptions = { + domain: this.cookieOptions.domain, + path: this.cookieOptions.path, + secure: this.cookieOptions.secure, + sameSite: this.cookieOptions.sameSite, + httpOnly: this.cookieOptions.httpOnly + }; + + reqCookies.getAll().forEach((cookie) => { + if ( + cookie.name.startsWith(txnPrefix) && + cookie.value.startsWith(PREFETCH_VALUE_PREFIX) + ) { + cookies.deleteCookie(resCookies, cookie.name, deleteOptions); + } + }); + } } diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts new file mode 100644 index 000000000..d8bff602e --- /dev/null +++ b/src/server/txn-cookie-accumulation.test.ts @@ -0,0 +1,816 @@ +import { NextRequest } from "next/server.js"; +import * as jose from "jose"; +import * as oauth from "oauth4webapi"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; +import { RESPONSE_TYPES } from "../types/connected-accounts.js"; +import { isNonNavigationalRequest } from "../utils/request.js"; +import { AuthClient } from "./auth-client.js"; +import { RequestCookies, ResponseCookies } from "./cookies.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; +import { TransactionState, TransactionStore } from "./transaction-store.js"; + +vi.mock("oauth4webapi", async () => { + const actual = await vi.importActual("oauth4webapi"); + return { + ...actual, + generateRandomState: vi.fn(), + generateRandomNonce: vi.fn(), + generateRandomCodeVerifier: vi.fn(), + calculatePKCECodeChallenge: vi.fn(), + discoveryRequest: vi.fn(), + processDiscoveryResponse: vi.fn(), + validateAuthResponse: vi.fn(), + getValidatedIdTokenClaims: vi.fn(), + processAuthorizationCodeResponse: vi.fn(), + authorizationCodeGrantRequest: vi.fn() + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeTransactionState = ( + state: string, + overrides: Partial = {} +): TransactionState => ({ + nonce: "test-nonce", + codeVerifier: "test-cv", + responseType: RESPONSE_TYPES.CODE, + maxAge: 3600, + returnTo: "/", + state, + ...overrides +}); + +/** Build a RequestCookies instance pre-populated with the given name=value pairs. */ +const makeRequestCookies = (pairs: Record): RequestCookies => { + const headers = new Headers(); + const cookieHeader = Object.entries(pairs) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + headers.append("cookie", cookieHeader); + return new RequestCookies(headers); +}; + +/** Build an empty ResponseCookies. */ +const makeResponseCookies = (): ResponseCookies => { + return new ResponseCookies(new Headers()); +}; + +// --------------------------------------------------------------------------- +// Fix 1 — isNonNavigationalRequest +// --------------------------------------------------------------------------- + +describe("Fix 1 — isNonNavigationalRequest()", () => { + const makeReq = (headers: Record) => { + const req = new NextRequest("http://localhost:3000/auth/login"); + Object.entries(headers).forEach(([k, v]) => req.headers.set(k, v)); + return req; + }; + + describe("sec-fetch-mode (primary signal)", () => { + it("returns false for sec-fetch-mode: navigate (real navigation)", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) + ).toBe(false); + }); + + it("returns true for sec-fetch-mode: cors (Next.js prefetch)", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) + ).toBe(true); + }); + + it("returns true for sec-fetch-mode: no-cors", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "no-cors" })) + ).toBe(true); + }); + + it("returns true for sec-fetch-mode: same-origin (XHR / fetch)", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) + ).toBe(true); + }); + }); + + describe("fallback headers (sec-fetch-mode absent)", () => { + it("returns true when next-router-prefetch is 1", () => { + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "1" })) + ).toBe(true); + }); + + it("returns true when accept is text/x-component", () => { + expect( + isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) + ).toBe(true); + }); + + it("returns true when purpose is prefetch", () => { + expect(isNonNavigationalRequest(makeReq({ purpose: "prefetch" }))).toBe( + true + ); + }); + + it("returns true when sec-purpose is prefetch", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-purpose": "prefetch" })) + ).toBe(true); + }); + + it("returns true when x-middleware-prefetch is 1", () => { + expect( + isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) + ).toBe(true); + }); + + it("returns false when no prefetch headers are present (plain request)", () => { + expect(isNonNavigationalRequest(makeReq({ accept: "text/html" }))).toBe( + false + ); + }); + }); + + describe("sec-fetch-mode takes precedence over fallbacks", () => { + it("returns false when sec-fetch-mode is navigate even if next-router-prefetch is 1", () => { + expect( + isNonNavigationalRequest( + makeReq({ + "sec-fetch-mode": "navigate", + "next-router-prefetch": "1" + }) + ) + ).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 2 — maxSizeBytes eviction in TransactionStore.save() +// --------------------------------------------------------------------------- + +describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { + let secret: string; + + beforeEach(async () => { + secret = await generateSecret(32); + }); + + it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 10 } + }); + const resCookies = makeResponseCookies(); + const state = "state-no-evict"; + + // Even with a tiny maxSizeBytes, passing no reqCookies skips eviction + await expect( + store.save(resCookies, makeTransactionState(state)) + ).resolves.not.toThrow(); + + expect(resCookies.get(`__txn_${state}`)?.value).toBeTruthy(); + }); + + it("does not evict when accumulated bytes are below maxSizeBytes", async () => { + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 99999 } + }); + + const existingState = "existing-state"; + const reqCookies = makeRequestCookies({ + [`__txn_${existingState}`]: "short" + }); + const resCookies = makeResponseCookies(); + const newState = "new-state"; + + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Existing cookie was not evicted (no delete set on it) + const evicted = resCookies + .getAll() + .filter((c) => c.name === `__txn_${existingState}` && c.maxAge === 0); + expect(evicted).toHaveLength(0); + + // New cookie was written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + }); + + it("phase-1 evicts prefetch cookies first, leaves real login cookies untouched when phase-1 sufficient", async () => { + // Set maxSizeBytes just above the real login cookie size so that phase-1 + // (evicting only prefetch cookies) frees enough to get under the threshold, + // without needing to touch the real login cookie. + const pfState1 = "pf1"; + const pfState2 = "pf2"; + const realState = "real"; + const pfValue1 = "p:short_jwe_1"; + const pfValue2 = "p:short_jwe_2"; + const realValue = "1000000000:real_jwe_value"; + + // Calculate actual byte sizes so we can set maxSizeBytes precisely. + const enc = new TextEncoder(); + const pfBytes1 = enc.encode(`__txn_${pfState1}=${pfValue1}`).length; + const pfBytes2 = enc.encode(`__txn_${pfState2}=${pfValue2}`).length; + const realBytes = enc.encode(`__txn_${realState}=${realValue}`).length; + const totalBytes = pfBytes1 + pfBytes2 + realBytes; + + // maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1: + // triggers eviction, but phase-1 (freeing pfBytes1 + pfBytes2) is enough. + const maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1; + + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes } + }); + + const reqCookies = makeRequestCookies({ + [`__txn_${pfState1}`]: pfValue1, + [`__txn_${pfState2}`]: pfValue2, + [`__txn_${realState}`]: realValue + }); + const resCookies = makeResponseCookies(); + + const newState = "newstate"; + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Prefetch cookies evicted + expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); + expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); + + // Real login cookie untouched (phase-1 freed enough) + expect(resCookies.get(`__txn_${realState}`)?.maxAge).not.toBe(0); + + // New cookie written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + }); + + it("phase-2 evicts oldest real login cookies first when phase-1 insufficient", async () => { + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 1 } + }); + + const olderState = "older"; + const newerState = "newer"; + // Older timestamp should be evicted first + const reqCookies = makeRequestCookies({ + [`__txn_${olderState}`]: "1000:jwe_older", + [`__txn_${newerState}`]: "9999:jwe_newer" + }); + const resCookies = makeResponseCookies(); + + const newState = "latest"; + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Older cookie evicted first + expect(resCookies.get(`__txn_${olderState}`)?.maxAge).toBe(0); + // New cookie written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + }); + + it("evicts legacy cookies (no prefix) in phase-2 as oldest (timestamp=0)", async () => { + // Legacy format "{jwe}" has no prefix → gets timestamp 0 → oldest in FIFO + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 1 } + }); + + const legacyState = "legacy"; + const newerState = "newer"; + const reqCookies = makeRequestCookies({ + [`__txn_${legacyState}`]: "raw_jwe_no_prefix", + [`__txn_${newerState}`]: "9999:jwe_newer", + other_cookie: "keep_me" + }); + const resCookies = makeResponseCookies(); + + const newState = "newstate"; + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + // Legacy cookie evicted (ts=0, oldest) + expect(resCookies.get(`__txn_${legacyState}`)?.maxAge).toBe(0); + // New cookie written + expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); + // Non-txn cookie untouched + expect(resCookies.get("other_cookie")).toBeUndefined(); + }); + + it("only evicts cookies matching the configured prefix", async () => { + const customPrefix = "__my_txn_"; + const store = new TransactionStore({ + secret, + cookieOptions: { maxSizeBytes: 1, prefix: customPrefix } + }); + + const reqCookies = makeRequestCookies({ + [`${customPrefix}state1`]: "p:prefetch_jwe", + __txn_other: "1000:other_jwe" // different prefix — should NOT be evicted + }); + const resCookies = makeResponseCookies(); + resCookies.set(`${customPrefix}state1`, "p:prefetch_jwe"); + resCookies.set("__txn_other", "1000:other_jwe"); + + await store.save( + resCookies, + makeTransactionState("new", { state: "new" }), + reqCookies + ); + + expect(resCookies.get(`${customPrefix}state1`)?.maxAge).toBe(0); + // __txn_other has a different prefix — not touched by this store + expect(resCookies.get("__txn_other")?.value).toBe("1000:other_jwe"); + }); + + it("real login cookie value is encoded as '{ts}:{jwe}'", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "real-login-state"; + + await store.save(resCookies, makeTransactionState(state), undefined, false); + + const value = resCookies.get(`__txn_${state}`)?.value ?? ""; + const colonIdx = value.indexOf(":"); + expect(colonIdx).toBeGreaterThan(0); + const ts = parseInt(value.slice(0, colonIdx)); + expect(ts).toBeGreaterThan(0); // epoch timestamp + expect(value.slice(colonIdx + 1)).toBeTruthy(); // JWE after colon + }); + + it("prefetch cookie value is encoded as 'p:{jwe}'", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "prefetch-state"; + + await store.save(resCookies, makeTransactionState(state), undefined, true); + + const value = resCookies.get(`__txn_${state}`)?.value ?? ""; + expect(value.startsWith("p:")).toBe(true); + expect(value.slice(2)).toBeTruthy(); // JWE after "p:" + }); + + it("prefetch cookie gets maxAge of 60s", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "prefetch-short-ttl"; + + await store.save(resCookies, makeTransactionState(state), undefined, true); + + const cookie = resCookies.get(`__txn_${state}`); + expect(cookie?.maxAge).toBe(60); + }); + + it("real login cookie gets full maxAge (1h default)", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "real-full-ttl"; + + await store.save(resCookies, makeTransactionState(state), undefined, false); + + const cookie = resCookies.get(`__txn_${state}`); + expect(cookie?.maxAge).toBe(3600); + }); + + it("get() strips 'p:' prefix before decrypting prefetch cookie", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "pf-get-test"; + + await store.save(resCookies, makeTransactionState(state), undefined, true); + + const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; + expect(encodedValue.startsWith("p:")).toBe(true); + + const reqCookies = makeRequestCookies({ [`__txn_${state}`]: encodedValue }); + const result = await store.get(reqCookies, state); + + expect(result).not.toBeNull(); + expect(result?.payload?.state).toBe(state); + }); + + it("get() strips '{ts}:' prefix before decrypting real login cookie", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + const state = "real-get-test"; + + await store.save(resCookies, makeTransactionState(state), undefined, false); + + const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; + expect(encodedValue.match(/^\d+:/)).toBeTruthy(); + + const reqCookies = makeRequestCookies({ [`__txn_${state}`]: encodedValue }); + const result = await store.get(reqCookies, state); + + expect(result).not.toBeNull(); + expect(result?.payload?.state).toBe(state); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 3 — Dormant early-return removed for enableParallelTransactions: false +// --------------------------------------------------------------------------- + +describe("Fix 3 — No lock-out in single-transaction mode", () => { + let secret: string; + + beforeEach(async () => { + secret = await generateSecret(32); + }); + + it("overwrites stale __txn_ cookie when user retries login after abandonment", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false + }); + + // Simulate stale cookie from abandoned login sitting in browser + const reqCookies = makeRequestCookies({ __txn_: "stale_jwe_value" }); + const resCookies = makeResponseCookies(); + + const newState = "new-login-state"; + + // Before Fix 3 this would return early and skip writing — now it must overwrite + await store.save(resCookies, makeTransactionState(newState), reqCookies); + + const written = resCookies.get("__txn_"); + expect(written).toBeDefined(); + expect(written?.value).not.toBe("stale_jwe_value"); + expect(written?.value).toBeTruthy(); + expect(written?.maxAge).not.toBe(0); + }); + + it("uses fixed cookie name __txn_ regardless of state value", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false + }); + + const resCookies = makeResponseCookies(); + const state = "some-state-value"; + await store.save(resCookies, makeTransactionState(state)); + + // Cookie name must be "__txn_", not "__txn_{state}" + expect(resCookies.get("__txn_")).toBeDefined(); + expect(resCookies.get(`__txn_${state}`)).toBeUndefined(); + }); + + it("creates unique __txn_{state} cookies in parallel mode (baseline)", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: true + }); + + const resCookies = makeResponseCookies(); + const stateA = "stateA"; + const stateB = "stateB"; + + await store.save(resCookies, makeTransactionState(stateA)); + await store.save(resCookies, makeTransactionState(stateB)); + + expect(resCookies.get(`__txn_${stateA}`)?.value).toBeTruthy(); + expect(resCookies.get(`__txn_${stateB}`)?.value).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 4 — Targeted callback cleanup: sweep prefetch + delete only completing cookie +// --------------------------------------------------------------------------- + +describe("Fix 4 — targeted cleanup: deletePrefetchCookies + delete(state)", () => { + let secret: string; + + beforeEach(async () => { + secret = await generateSecret(32); + }); + + describe("deletePrefetchCookies()", () => { + it("deletes all 'p:' prefetch cookies, leaves real login cookies untouched", async () => { + const store = new TransactionStore({ secret }); + const reqCookies = makeRequestCookies({ + __txn_pf1: "p:jwe_prefetch_1", + __txn_pf2: "p:jwe_prefetch_2", + __txn_real: "1000000000:jwe_real_login" + }); + const resCookies = makeResponseCookies(); + + await store.deletePrefetchCookies(reqCookies, resCookies); + + expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); + expect(resCookies.get("__txn_pf2")?.maxAge).toBe(0); + // Real login cookie must NOT be touched + expect(resCookies.get("__txn_real")?.maxAge).not.toBe(0); + }); + + it("does not touch non-txn cookies", async () => { + const store = new TransactionStore({ secret }); + const reqCookies = makeRequestCookies({ + __txn_pf1: "p:jwe_pf", + __session: "session_value" + }); + const resCookies = makeResponseCookies(); + resCookies.set("__session", "session_value"); + + await store.deletePrefetchCookies(reqCookies, resCookies); + + expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); + expect(resCookies.get("__session")?.value).toBe("session_value"); + }); + + it("does not throw when no prefetch cookies exist", async () => { + const store = new TransactionStore({ secret }); + const reqCookies = makeRequestCookies({ + __txn_real: "1000000000:jwe_real" + }); + const resCookies = makeResponseCookies(); + + await expect( + store.deletePrefetchCookies(reqCookies, resCookies) + ).resolves.not.toThrow(); + }); + }); + + describe("delete(state)", () => { + it("deletes only the specific __txn_{state} cookie", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_stateA", "1000:jwe_a"); // completing flow + resCookies.set("__txn_stateB", "2000:jwe_b"); // Tab B — must survive + + await store.delete(resCookies, "stateA"); + + expect(resCookies.get("__txn_stateA")?.maxAge).toBe(0); + // Tab B's real login cookie must not be touched + expect(resCookies.get("__txn_stateB")?.value).toBe("2000:jwe_b"); + expect(resCookies.get("__txn_stateB")?.maxAge).not.toBe(0); + }); + + it("does not throw when deleting a non-existent state", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + + await expect( + store.delete(resCookies, "nonexistent-state") + ).resolves.not.toThrow(); + }); + }); + + describe("combined: sweep prefetch + delete completing cookie — multi-tab safe", () => { + it("sweeps prefetch cookies and deletes only the completing flow's cookie, leaving Tab B untouched", async () => { + const store = new TransactionStore({ secret }); + + // Tab A completing login + const completingState = "tabA-state"; + // Tab B mid-login under different account + const otherRealState = "tabB-state"; + // Accumulated prefetch garbage + const pfState1 = "pf-orphan-1"; + const pfState2 = "pf-orphan-2"; + + const reqCookies = makeRequestCookies({ + [`__txn_${completingState}`]: "1000:jwe_tabA", + [`__txn_${otherRealState}`]: "2000:jwe_tabB", + [`__txn_${pfState1}`]: "p:jwe_pf1", + [`__txn_${pfState2}`]: "p:jwe_pf2" + }); + const resCookies = makeResponseCookies(); + + await store.deletePrefetchCookies(reqCookies, resCookies); + await store.delete(resCookies, completingState); + + // Completing cookie deleted + expect(resCookies.get(`__txn_${completingState}`)?.maxAge).toBe(0); + // Prefetch cookies swept + expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); + expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); + // Tab B's real login cookie must be untouched + expect(resCookies.get(`__txn_${otherRealState}`)?.maxAge).not.toBe(0); + }); + + it("single-transaction mode: delete(state) resolves to __txn_ regardless of state value", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false + }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_", "1000:stale_jwe"); + + // state value is ignored in single mode — always resolves to "__txn_" + await store.delete(resCookies, "any-state-value"); + + expect(resCookies.get("__txn_")?.maxAge).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — handler() prefetch guard + handleCallback sweep +// These cover the three checklist items that require AuthClient + real flows. +// --------------------------------------------------------------------------- + +describe("Integration — prefetch guard and callback cleanup via AuthClient", () => { + const domain = "test.auth0.com"; + const clientId = "test-client-id"; + let keyPair: jose.GenerateKeyPairResult; + let secret: string; + + // Minimal mock authorization server used across all integration tests. + const makeFetch = () => + vi.fn(async (input: RequestInfo | URL): Promise => { + const url = new URL(input instanceof Request ? input.url : input); + if (url.pathname === "/.well-known/openid-configuration") { + return Response.json({ + issuer: `https://${domain}/`, + authorization_endpoint: `https://${domain}/authorize`, + token_endpoint: `https://${domain}/oauth/token`, + jwks_uri: `https://${domain}/.well-known/jwks.json` + }); + } + if (url.pathname === "/.well-known/jwks.json") { + return Response.json({ + keys: [ + { + ...(await jose.exportJWK(keyPair.publicKey)), + kid: "k1", + use: "sig" + } + ] + }); + } + if (url.pathname === "/oauth/token") { + const idToken = await new jose.SignJWT({ + sub: "user123", + sid: "sid123", + nonce: "test-nonce", + aud: clientId, + iss: `https://${domain}/` + }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime("2h") + .sign(keyPair.privateKey); + return Response.json({ + token_type: "Bearer", + access_token: "at_123", + id_token: idToken, + expires_in: 3600 + }); + } + return new Response(null, { status: 404 }); + }); + + const makeAuthClient = ( + opts: { dangerouslyAllowLoginPrefetch?: boolean } = {} + ) => { + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + return new AuthClient({ + domain, + clientId, + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + transactionStore, + sessionStore, + routes: getDefaultRoutes(), + fetch: makeFetch(), + ...opts + }); + }; + + beforeEach(async () => { + vi.clearAllMocks(); + secret = await generateSecret(32); + keyPair = await jose.generateKeyPair("RS256"); + + vi.mocked(oauth.generateRandomState).mockReturnValue("test-state"); + vi.mocked(oauth.generateRandomNonce).mockReturnValue("test-nonce"); + vi.mocked(oauth.generateRandomCodeVerifier).mockReturnValue("cv"); + vi.mocked(oauth.calculatePKCECodeChallenge).mockResolvedValue("cc"); + vi.mocked(oauth.validateAuthResponse).mockReturnValue( + new URLSearchParams("code=auth_code&state=test-state") + ); + vi.mocked(oauth.discoveryRequest).mockResolvedValue(new Response()); + vi.mocked(oauth.processDiscoveryResponse).mockResolvedValue({ + issuer: `https://${domain}/`, + authorization_endpoint: `https://${domain}/authorize`, + token_endpoint: `https://${domain}/oauth/token`, + jwks_uri: `https://${domain}/.well-known/jwks.json` + } as oauth.AuthorizationServer); + vi.mocked(oauth.authorizationCodeGrantRequest).mockResolvedValue( + new Response() + ); + vi.mocked(oauth.processAuthorizationCodeResponse).mockResolvedValue({ + token_type: "Bearer", + access_token: "at_123", + id_token: "id_token_placeholder", + expires_in: 3600 + } as oauth.TokenEndpointResponse); + vi.mocked(oauth.getValidatedIdTokenClaims).mockReturnValue({ + sub: "user123", + sid: "sid123", + nonce: "test-nonce", + aud: clientId, + iss: `https://${domain}/`, + iat: Math.floor(Date.now() / 1000) - 60, + exp: Math.floor(Date.now() / 1000) + 3600 + }); + }); + + // Checklist: "Load bugs/txn-accumulation while logged out → no __txn_* cookies created" + it("Fix 1 — prefetch request returns 401 and no __txn_* cookie is written (guard on, default)", async () => { + const authClient = makeAuthClient(); + const req = new NextRequest("http://localhost:3000/auth/login", { + headers: { "sec-fetch-mode": "cors" } // prefetch signal + }); + + const res = await authClient.handler(req); + + expect(res.status).toBe(401); + const txnCookies = res.cookies + .getAll() + .filter((c) => c.name.startsWith("__txn_") && c.maxAge !== 0); + expect(txnCookies).toHaveLength(0); + }); + + // Checklist: "Set dangerouslyAllowLoginPrefetch: true → 4 __txn_* cookies appear" + it("Fix 1 — prefetch request is allowed through and __txn_* cookie is written (guard off)", async () => { + const authClient = makeAuthClient({ dangerouslyAllowLoginPrefetch: true }); + const req = new NextRequest("http://localhost:3000/auth/login", { + headers: { "sec-fetch-mode": "cors" } // same prefetch signal + }); + + const res = await authClient.handler(req); + + // Should redirect to Auth0 (3xx), not return 401 + expect(res.status).toBeGreaterThanOrEqual(300); + expect(res.status).toBeLessThan(400); + const txnCookies = res.cookies + .getAll() + .filter((c) => c.name.startsWith("__txn_") && (c.maxAge ?? 0) > 0); + expect(txnCookies.length).toBeGreaterThan(0); + }); + + // Checklist: "Complete login → completing txn + prefetch orphans deleted, other real logins untouched" + it("Fix 4 — handleCallback deletes completing cookie + sweeps prefetch orphans, leaves real Tab B cookie", async () => { + const transactionStore = new TransactionStore({ secret }); + const sessionStore = new StatelessSessionStore({ secret }); + const authClient = new AuthClient({ + domain, + clientId, + clientSecret: "test-secret", + appBaseUrl: "http://localhost:3000", + secret, + transactionStore, + sessionStore, + routes: getDefaultRoutes(), + fetch: makeFetch() + // dangerouslyAllowLoginPrefetch: false (default) + }); + + // Step 1: login to get a real transaction cookie + const loginRes = await authClient.handleLogin( + new NextRequest("http://localhost:3000/auth/login") + ); + const state = new URL(loginRes.headers.get("Location")!).searchParams.get( + "state" + )!; + const txnCookie = loginRes.cookies.get(`__txn_${state}`); + expect(txnCookie).toBeDefined(); + // Verify login cookie has timestamp-prefixed value (real login, not prefetch) + expect(txnCookie!.value).toMatch(/^\d+:/); + + // Step 2: build callback request: + // - completing flow's cookie + // - two prefetch orphans (value prefix "p:") + // - one real in-flight login from Tab B (must survive) + const callbackReq = new NextRequest( + `http://localhost:3000/auth/callback?code=auth_code&state=${state}` + ); + callbackReq.cookies.set(`__txn_${state}`, txnCookie!.value); + callbackReq.cookies.set("__txn_orphan_pf1", "p:prefetch_jwe_1"); + callbackReq.cookies.set("__txn_orphan_pf2", "p:prefetch_jwe_2"); + callbackReq.cookies.set("__txn_tabB", "9999999999:tab_b_real_login_jwe"); + + const callbackRes = await authClient.handleCallback(callbackReq); + + expect(callbackRes.status).toBeGreaterThanOrEqual(300); + expect(callbackRes.status).toBeLessThan(400); + + // Completing cookie must be deleted + expect(callbackRes.cookies.get(`__txn_${state}`)?.maxAge).toBe(0); + // Prefetch orphans must be deleted + expect(callbackRes.cookies.get("__txn_orphan_pf1")?.maxAge).toBe(0); + expect(callbackRes.cookies.get("__txn_orphan_pf2")?.maxAge).toBe(0); + // Tab B real login cookie must NOT be deleted + const tabBCookie = callbackRes.cookies.get("__txn_tabB"); + expect(tabBCookie?.maxAge).not.toBe(0); + + // Session cookie written + expect(callbackRes.cookies.get("__session")?.value).toBeTruthy(); + }); +}); diff --git a/src/test/utils.ts b/src/test/utils.ts index 536212434..652cc5cd8 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -5,3 +5,16 @@ export async function generateSecret(length: number) { .map((b) => b.toString(16).padStart(2, "0")) .join(""); } + +/** + * Strip the value prefix that TransactionStore encodes in cookie values. + * "p:{jwe}" → prefetch cookie — strips "p:" prefix + * "{ts}:{jwe}" → real login — strips "{ts}:" prefix + * "{jwe}" → legacy (no prefix) — returned as-is + * + * Use this in tests that decrypt transaction cookie values directly. + */ +export function stripTransactionValuePrefix(value: string): string { + const colonIdx = value.indexOf(":"); + return colonIdx !== -1 ? value.slice(colonIdx + 1) : value; +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 545916eef..126f90ca0 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -16,3 +16,27 @@ export const isRequest = (req: Req): req is Request | NextRequest => { typeof (req as Request).bodyUsed === "boolean" ); }; + +/** + * Returns true if the request is non-navigational (e.g. a prefetch, fetch, or + * XHR) rather than a full browser navigation. Used to guard against Next.js + * prefetch requests triggering side-effectful handlers like handleLogin. + * + * Uses the W3C Fetch Metadata `sec-fetch-mode` header as the primary signal + * (supported in Chrome 76+, Firefox 90+, Safari 16.4+). Falls back to + * Next.js-specific and legacy prefetch headers for older environments. + */ +export const isNonNavigationalRequest = (req: NextRequest): boolean => { + const fetchMode = req.headers.get("sec-fetch-mode"); + if (fetchMode !== null) { + return fetchMode !== "navigate"; + } + + return ( + req.headers.get("next-router-prefetch") === "1" || + req.headers.get("accept") === "text/x-component" || + req.headers.get("purpose") === "prefetch" || + req.headers.get("sec-purpose") === "prefetch" || + req.headers.get("x-middleware-prefetch") === "1" + ); +}; From 5037282e4e9a7861e5410d9faec49935e51e6bc7 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Sat, 4 Jul 2026 14:46:17 +0530 Subject: [PATCH 18/36] docs: prevent __txn_* cookie accumulation via value-prefix encoding and targeted cleanup --- EXAMPLES.md | 49 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 72aad1a86..a4a2b13e1 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4129,20 +4129,49 @@ const authClient = new Auth0Client({ **Use Single Transaction Mode When:** -- You want to prevent cookie accumulation issues in applications with frequent login attempts -- You prefer simpler transaction management +- You want the simplest possible transaction management - Users typically don't need multiple concurrent login flows -- You're experiencing cookie header size limits due to abandoned transaction cookies edge cases ### Transaction Cookie Options -| Option | Type | Description | -| ---------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| cookieOptions.maxAge | `number` | The expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned transaction cookies will expire automatically. | -| cookieOptions.prefix | `string` | The prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`. In single mode, just `__txn_`. | -| cookieOptions.sameSite | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | -| cookieOptions.secure | `boolean` | When `true`, the cookie will only be sent over HTTPS connections. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | -| cookieOptions.path | `string` | Specifies the URL path for which the cookie is valid. Defaults to `"/"`. | +| Option | Type | Description | +| ----------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `transactionCookie.maxAge` | `number` | Expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned cookies expire automatically. | +| `transactionCookie.maxSizeBytes` | `number` | Maximum total byte size of all `__txn_*` cookies combined. Defaults to `4096`. When exceeded, the SDK evicts prefetch cookies first (phase 1), then oldest real login cookies (phase 2), before writing the new cookie. One JWE is ~450–555 bytes. | +| `transactionCookie.prefix` | `string` | Prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`; in single mode, just `__txn_`. | +| `transactionCookie.sameSite` | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | +| `transactionCookie.secure` | `boolean` | When `true`, the cookie is only sent over HTTPS. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | +| `transactionCookie.path` | `string` | URL path for which the cookie is valid. Defaults to `"/"`. | +| `dangerouslyAllowLoginPrefetch` | `boolean` | Defaults to `false`. When `false`, the SDK returns a `401` on non-navigational requests to `/auth/login` (Next.js prefetch, XHR), preventing prefetch cookies from accumulating. Set to `true` only for apps with custom login pages worth caching. | + +### Troubleshooting: 431 / cookie header too large + +If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookies have grown beyond your server's header size limit. + +**This is fixed in the current SDK version.** The SDK now: + +1. Returns `401` on Next.js prefetch requests to `/auth/login` so no cookie is written (`dangerouslyAllowLoginPrefetch: false` by default). +2. Automatically evicts accumulated cookies when the `maxSizeBytes` limit is reached — prefetch cookies first, then oldest real login cookies. + +If you are running an older version, adding `prefetch={false}` to `` components pointing to your login route is a safe fallback: + +```tsx +// Optional safety net — not required in current SDK versions + + Sign In + +``` + +If accumulation persists after upgrading, increase the byte limit or reduce `maxAge`: + +```ts +export const auth0 = new Auth0Client({ + transactionCookie: { + maxSizeBytes: 8192, // raise the ceiling (default 4096) + maxAge: 600, // shorten TTL to 10 minutes (default 3600) + }, +}); +``` ## Database sessions From 5e6890097215560ac8dad65b78a6fe4c960a7169 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 10 Jul 2026 22:16:38 +0530 Subject: [PATCH 19/36] fix: remove prefetch flag, simplify txn cookie eviction to single-phase FIFO --- src/server/auth-client.ts | 48 +-- src/server/client.ts | 20 -- src/server/transaction-store.ts | 156 ++------- src/server/txn-cookie-accumulation.test.ts | 360 +++++---------------- src/utils/request.ts | 15 +- 5 files changed, 127 insertions(+), 472 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 26c9fc420..f06b4ab0e 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -371,18 +371,6 @@ export interface AuthClientOptions { */ cspNonce?: string; - /** - * When `false` (default), the SDK returns a `401` on prefetch requests to the - * login route, preventing `__txn_*` cookies from being created for OAuth flows - * that will never complete. - * - * Set to `true` only if your login route renders custom page content worth - * prefetching (i.e. it does not immediately redirect to Auth0). - * - * @default false - */ - dangerouslyAllowLoginPrefetch?: boolean; - /** * @future This option is reserved for future implementation. * Currently not used - placeholder for upcoming nonce persistence feature. @@ -441,7 +429,6 @@ export class AuthClient { private readonly mfaTokenTtl: number; private readonly cspNonce?: string; - private readonly dangerouslyAllowLoginPrefetch: boolean; private proxyDpopHandles: { [audience: string]: oauth.DPoPHandle } = {}; @@ -628,8 +615,6 @@ export class AuthClient { // CSP nonce for popup postMessage inline scripts this.cspNonce = options.cspNonce; - this.dangerouslyAllowLoginPrefetch = - options.dangerouslyAllowLoginPrefetch ?? false; // Store keypair if provided, but validate lazily to avoid crypto bundling this.dpopKeyPair = options.dpopKeyPair; @@ -677,11 +662,6 @@ export class AuthClient { resCookies: ResponseCookies, state: string ): Promise { - // Targeted cleanup — regardless of dangerouslyAllowLoginPrefetch flag: - // 1. Sweep all accumulated "p:" prefetch cookies — provably garbage, never match a callback - // 2. Delete only the single __txn_{state} that belongs to this completing flow - // All other real login cookies (e.g. Tab B mid-login, prompt:login multi-account) are untouched. - await this.transactionStore.deletePrefetchCookies(req.cookies, resCookies); await this.transactionStore.delete(resCookies, state); } @@ -701,10 +681,7 @@ export class AuthClient { const method = req.method; if (method === "GET" && sanitizedPathname === this.routes.login) { - if ( - !this.dangerouslyAllowLoginPrefetch && - isNonNavigationalRequest(req) - ) { + if (isNonNavigationalRequest(req)) { return new NextResponse(null, { status: 401 }); } return this.handleLogin(req); @@ -982,14 +959,7 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - // Save transaction state; pass req.cookies so save() can apply maxSizeBytes eviction. - // isPrefetch encodes "p:" prefix in value so eviction and cleanup can classify O(1). - await this.transactionStore.save( - res.cookies, - transactionState, - req?.cookies, - req ? isNonNavigationalRequest(req) : false - ); + await this.transactionStore.save(res.cookies, transactionState, req?.cookies); return res; } @@ -4217,12 +4187,7 @@ export class AuthClient { `${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}` ); - await this.transactionStore.save( - res.cookies, - transactionState, - req?.cookies, - false // connect account — always a real user-initiated flow, never prefetch - ); + await this.transactionStore.save(res.cookies, transactionState, req?.cookies); return [null, res]; } @@ -5864,12 +5829,7 @@ export class AuthClient { "Pass the NextResponse cookies (App Router: next/headers cookies; Pages Router: res.cookies)." ); } - await this.transactionStore.save( - resCookies, - magicLinkTransactionState, - req?.cookies, - false // magic link — always a real user-initiated flow, never prefetch - ); + await this.transactionStore.save(resCookies, magicLinkTransactionState, req?.cookies); } } diff --git a/src/server/client.ts b/src/server/client.ts index 2901ddbd2..6273fb47d 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -278,21 +278,6 @@ export interface Auth0ClientOptions { enableParallelTransactions?: boolean; - /** - * When `false` (default), the SDK returns a `401` on prefetch requests to the - * login route (`/auth/login`), preventing `__txn_*` transaction cookies from - * being created for OAuth flows that will never complete. - * - * The standard login route immediately redirects to Auth0's hosted login page — - * there is no page content to prefetch, so blocking prefetch has no user-visible cost. - * - * Set to `true` only if you have overridden the login route to render custom - * page content (e.g. an embedded login form) that is worth prefetching. - * - * @default false - */ - dangerouslyAllowLoginPrefetch?: boolean; - /** * If true, the `/auth/connect` endpoint will be mounted to enable users to connect additional accounts. */ @@ -727,8 +712,6 @@ export class Auth0Client { secret, cookieOptions: transactionCookieOptions, enableParallelTransactions: options.enableParallelTransactions ?? true, - dangerouslyAllowLoginPrefetch: - options.dangerouslyAllowLoginPrefetch ?? false }); this.sessionStore = options.sessionStore @@ -816,9 +799,6 @@ export class Auth0Client { fetch: options.customFetch, mfaTokenTtl, cspNonce: options.cspNonce, - dangerouslyAllowLoginPrefetch: - options.dangerouslyAllowLoginPrefetch ?? false, - discoveryCache, provider: this.provider }); diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index a24951f9b..a3b3cf547 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,10 +5,6 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; -// Value prefix for prefetch-created cookies — pure garbage, never leads to a -// real callback. Short maxAge (60s) further limits accumulation window. -const PREFETCH_VALUE_PREFIX = "p:"; -const PREFETCH_MAX_AGE = 60; // seconds export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; @@ -117,13 +113,6 @@ export interface TransactionStoreOptions { * @default true */ enableParallelTransactions?: boolean; - /** - * Mirrors the `dangerouslyAllowLoginPrefetch` flag from `Auth0ClientOptions`. - * Controls the eviction strategy when `maxSizeBytes` is exceeded. - * - * @default false - */ - dangerouslyAllowLoginPrefetch?: boolean; } /** @@ -137,13 +126,11 @@ export class TransactionStore { private readonly cookieOptions: cookies.CookieOptions; private readonly enableParallelTransactions: boolean; private readonly maxSizeBytes: number; - private readonly dangerouslyAllowLoginPrefetch: boolean; constructor({ secret, cookieOptions, - enableParallelTransactions, - dangerouslyAllowLoginPrefetch + enableParallelTransactions }: TransactionStoreOptions) { this.secret = secret; this.transactionCookiePrefix = @@ -158,7 +145,6 @@ export class TransactionStore { }; this.enableParallelTransactions = enableParallelTransactions ?? true; this.maxSizeBytes = cookieOptions?.maxSizeBytes ?? 4096; - this.dangerouslyAllowLoginPrefetch = dangerouslyAllowLoginPrefetch ?? false; } /** @@ -186,24 +172,20 @@ export class TransactionStore { * @param transactionState - The transaction state to save * @param reqCookies - Optional request cookies. When provided, enables maxSizeBytes * eviction before writing the new cookie. - * @param isPrefetch - When true, the cookie value is prefixed with "p:" and gets a - * short maxAge (60s). Prefetch cookies are evicted first during - * eviction and never match a real callback. * @throws {Error} When transaction state is missing required state parameter */ async save( resCookies: cookies.ResponseCookies, transactionState: TransactionState, - reqCookies?: cookies.RequestCookies, - isPrefetch?: boolean + reqCookies?: cookies.RequestCookies ) { if (!transactionState.state) { throw new Error("Transaction state is required"); } - // Evict accumulated transaction cookies when accumulated size meets the cap. - // Safety net for abandoned logins and silent prefetches that bypass Fix 1 - // (e.g. router.prefetch(), CDNs that strip sec-fetch-mode). + // Evict oldest transaction cookies FIFO when total size exceeds the cap. + // Safety net for abandoned logins and silent prefetches not caught by the + // prefetch guard (e.g. router.prefetch(), CDN-stripped headers). if (reqCookies) { const existing = reqCookies .getAll() @@ -214,10 +196,6 @@ export class TransactionStore { 0 ); if (totalBytes >= this.maxSizeBytes) { - // Two-phase eviction — zero crypto decryption. - // Phase 1: evict all prefetch cookies (value starts with "p:") — always garbage. - // Phase 2: if still over threshold, evict real login cookies oldest-first - // by timestamp encoded in value prefix ("{ts}:"). const deleteOptions = { domain: this.cookieOptions.domain, path: this.cookieOptions.path, @@ -226,79 +204,40 @@ export class TransactionStore { httpOnly: this.cookieOptions.httpOnly }; - const prefetchCookies = existing.filter((c) => - c.value.startsWith(PREFETCH_VALUE_PREFIX) - ); - const realCookies = existing - .filter((c) => !c.value.startsWith(PREFETCH_VALUE_PREFIX)) - .sort((a, b) => { - // Parse timestamp from "{ts}:{jwe}" — legacy "{jwe}" gets timestamp 0 - const tsA = parseInt(a.value) || 0; - const tsB = parseInt(b.value) || 0; - return tsA - tsB; // ascending — oldest first - }); - - let freed = prefetchCookies.reduce( - (sum, c) => - sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, - 0 - ); - - const toEvict = [...prefetchCookies]; - if (freed < totalBytes - this.maxSizeBytes + 1) { - // Phase 1 insufficient — evict oldest real login cookies until under threshold - for (const c of realCookies) { - toEvict.push(c); - freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; - if (freed >= totalBytes - this.maxSizeBytes + 1) break; - } + // Sort by timestamp encoded in value prefix "{ts}:{jwe}". + // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. + const sorted = [...existing].sort((a, b) => { + const tsA = parseInt(a.value) || 0; + const tsB = parseInt(b.value) || 0; + return tsA - tsB; + }); + + let freed = 0; + const target = totalBytes - this.maxSizeBytes + 1; + for (const c of sorted) { + cookies.deleteCookie(resCookies, c.name, deleteOptions); + freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; + if (freed >= target) break; } - if (toEvict.length > 0) { - const evictedPrefetch = toEvict.filter((c) => - c.value.startsWith(PREFETCH_VALUE_PREFIX) - ).length; - const evictedReal = toEvict.length - evictedPrefetch; - console.warn( - `[auth0] Evicting ${toEvict.length} transaction cookie(s) ` + - `(${totalBytes} bytes ≥ ${this.maxSizeBytes} byte limit): ` + - `${evictedPrefetch} prefetch, ${evictedReal} real login(s). ` + - `Increase transactionCookie.maxSizeBytes to reduce eviction of in-flight logins.` - ); - for (const c of toEvict) { - cookies.deleteCookie(resCookies, c.name, deleteOptions); - } - } + console.warn( + `[auth0] Evicted transaction cookie(s) — total size ${totalBytes} bytes exceeded ` + + `${this.maxSizeBytes} byte limit. Increase transactionCookie.maxSizeBytes to ` + + `reduce eviction of in-flight logins.` + ); } } - const expirationSeconds = isPrefetch - ? PREFETCH_MAX_AGE - : this.cookieOptions.maxAge!; - const expiration = Math.floor(Date.now() / 1000 + expirationSeconds); - const jwe = await cookies.encrypt( - transactionState, - this.secret, - expiration - ); + const expiration = Math.floor(Date.now() / 1000 + this.cookieOptions.maxAge!); + const jwe = await cookies.encrypt(transactionState, this.secret, expiration); - // Encode type and creation timestamp in the value for O(1) classification - // during eviction — no cookie name change, no breaking change. - // "p:{jwe}" → prefetch cookie (60s TTL, evicted first) - // "{ts}:{jwe}" → real login cookie (FIFO by ts during phase-2 eviction) + // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. + // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". const ts = Math.floor(Date.now() / 1000); - const encodedValue = isPrefetch - ? `${PREFETCH_VALUE_PREFIX}${jwe}` - : `${ts}:${jwe}`; - - const cookieOptions = isPrefetch - ? { ...this.cookieOptions, maxAge: PREFETCH_MAX_AGE } - : this.cookieOptions; - resCookies.set( this.getTransactionCookieName(transactionState.state), - encodedValue, - cookieOptions + `${ts}:${jwe}`, + this.cookieOptions ); } @@ -310,10 +249,7 @@ export class TransactionStore { return null; } - // Strip value prefix before decryption — backward compatible with legacy "{jwe}" format. - // "p:{jwe}" → strip "p:" prefix - // "{ts}:{jwe}" → strip "{ts}:" prefix (find first colon) - // "{jwe}" → no prefix, decrypt as-is (legacy) + // Strip "{ts}:" prefix before decryption — backward compatible with legacy bare "{jwe}". const colonIdx = cookieValue.indexOf(":"); const jwe = colonIdx !== -1 ? cookieValue.slice(colonIdx + 1) : cookieValue; @@ -353,34 +289,4 @@ export class TransactionStore { }); } - /** - * Deletes all prefetch-created transaction cookies (value prefix "p:"). - * These are provably garbage — they were created by non-navigational requests - * and can never lead to a completed OAuth flow. - * - * Called on callback success to sweep accumulated prefetch cookies without - * touching real in-flight logins from other tabs. - */ - async deletePrefetchCookies( - reqCookies: cookies.RequestCookies, - resCookies: cookies.ResponseCookies - ) { - const txnPrefix = this.getCookiePrefix(); - const deleteOptions = { - domain: this.cookieOptions.domain, - path: this.cookieOptions.path, - secure: this.cookieOptions.secure, - sameSite: this.cookieOptions.sameSite, - httpOnly: this.cookieOptions.httpOnly - }; - - reqCookies.getAll().forEach((cookie) => { - if ( - cookie.name.startsWith(txnPrefix) && - cookie.value.startsWith(PREFETCH_VALUE_PREFIX) - ) { - cookies.deleteCookie(resCookies, cookie.name, deleteOptions); - } - }); - } } diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index d8bff602e..fd012359c 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -72,33 +72,7 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { return req; }; - describe("sec-fetch-mode (primary signal)", () => { - it("returns false for sec-fetch-mode: navigate (real navigation)", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) - ).toBe(false); - }); - - it("returns true for sec-fetch-mode: cors (Next.js prefetch)", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) - ).toBe(true); - }); - - it("returns true for sec-fetch-mode: no-cors", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "no-cors" })) - ).toBe(true); - }); - - it("returns true for sec-fetch-mode: same-origin (XHR / fetch)", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) - ).toBe(true); - }); - }); - - describe("fallback headers (sec-fetch-mode absent)", () => { + describe("known prefetch headers — positive detection only", () => { it("returns true when next-router-prefetch is 1", () => { expect( isNonNavigationalRequest(makeReq({ "next-router-prefetch": "1" })) @@ -128,25 +102,36 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) ).toBe(true); }); + }); - it("returns false when no prefetch headers are present (plain request)", () => { + describe("requests that must not be blocked", () => { + it("returns false for plain navigation with no prefetch headers", () => { expect(isNonNavigationalRequest(makeReq({ accept: "text/html" }))).toBe( false ); }); - }); - describe("sec-fetch-mode takes precedence over fallbacks", () => { - it("returns false when sec-fetch-mode is navigate even if next-router-prefetch is 1", () => { + it("returns false for sec-fetch-mode: navigate", () => { expect( - isNonNavigationalRequest( - makeReq({ - "sec-fetch-mode": "navigate", - "next-router-prefetch": "1" - }) - ) + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) + ).toBe(false); + }); + + it("returns false for sec-fetch-mode: cors — legitimate fetch()/XHR must not be blocked", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) ).toBe(false); }); + + it("returns false for sec-fetch-mode: same-origin — legitimate fetch()/XHR must not be blocked", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) + ).toBe(false); + }); + + it("returns false when no headers present", () => { + expect(isNonNavigationalRequest(makeReq({}))).toBe(false); + }); }); }); @@ -202,27 +187,19 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("phase-1 evicts prefetch cookies first, leaves real login cookies untouched when phase-1 sufficient", async () => { - // Set maxSizeBytes just above the real login cookie size so that phase-1 - // (evicting only prefetch cookies) frees enough to get under the threshold, - // without needing to touch the real login cookie. - const pfState1 = "pf1"; - const pfState2 = "pf2"; - const realState = "real"; - const pfValue1 = "p:short_jwe_1"; - const pfValue2 = "p:short_jwe_2"; - const realValue = "1000000000:real_jwe_value"; - - // Calculate actual byte sizes so we can set maxSizeBytes precisely. + it("evicts oldest cookie first when threshold exceeded with mixed timestamps", async () => { + const olderState = "older"; + const newerState = "newer"; + const olderValue = "1000:jwe_older"; + const newerValue = "9999:jwe_newer"; + const enc = new TextEncoder(); - const pfBytes1 = enc.encode(`__txn_${pfState1}=${pfValue1}`).length; - const pfBytes2 = enc.encode(`__txn_${pfState2}=${pfValue2}`).length; - const realBytes = enc.encode(`__txn_${realState}=${realValue}`).length; - const totalBytes = pfBytes1 + pfBytes2 + realBytes; + const olderBytes = enc.encode(`__txn_${olderState}=${olderValue}`).length; + const newerBytes = enc.encode(`__txn_${newerState}=${newerValue}`).length; + const totalBytes = olderBytes + newerBytes; - // maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1: - // triggers eviction, but phase-1 (freeing pfBytes1 + pfBytes2) is enough. - const maxSizeBytes = totalBytes - pfBytes1 - pfBytes2 + 1; + // maxSizeBytes just below total — eviction fires but only needs to remove one + const maxSizeBytes = totalBytes - olderBytes + 1; const store = new TransactionStore({ secret, @@ -230,22 +207,18 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { }); const reqCookies = makeRequestCookies({ - [`__txn_${pfState1}`]: pfValue1, - [`__txn_${pfState2}`]: pfValue2, - [`__txn_${realState}`]: realValue + [`__txn_${olderState}`]: olderValue, + [`__txn_${newerState}`]: newerValue }); const resCookies = makeResponseCookies(); const newState = "newstate"; await store.save(resCookies, makeTransactionState(newState), reqCookies); - // Prefetch cookies evicted - expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); - expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); - - // Real login cookie untouched (phase-1 freed enough) - expect(resCookies.get(`__txn_${realState}`)?.maxAge).not.toBe(0); - + // Older cookie evicted first + expect(resCookies.get(`__txn_${olderState}`)?.maxAge).toBe(0); + // Newer cookie untouched — eviction stopped after freeing enough + expect(resCookies.get(`__txn_${newerState}`)?.maxAge).not.toBe(0); // New cookie written expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); @@ -327,78 +300,37 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get("__txn_other")?.value).toBe("1000:other_jwe"); }); - it("real login cookie value is encoded as '{ts}:{jwe}'", async () => { + it("cookie value is encoded as '{ts}:{jwe}'", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); - const state = "real-login-state"; + const state = "login-state"; - await store.save(resCookies, makeTransactionState(state), undefined, false); + await store.save(resCookies, makeTransactionState(state)); const value = resCookies.get(`__txn_${state}`)?.value ?? ""; const colonIdx = value.indexOf(":"); expect(colonIdx).toBeGreaterThan(0); const ts = parseInt(value.slice(0, colonIdx)); - expect(ts).toBeGreaterThan(0); // epoch timestamp - expect(value.slice(colonIdx + 1)).toBeTruthy(); // JWE after colon - }); - - it("prefetch cookie value is encoded as 'p:{jwe}'", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - const state = "prefetch-state"; - - await store.save(resCookies, makeTransactionState(state), undefined, true); - - const value = resCookies.get(`__txn_${state}`)?.value ?? ""; - expect(value.startsWith("p:")).toBe(true); - expect(value.slice(2)).toBeTruthy(); // JWE after "p:" - }); - - it("prefetch cookie gets maxAge of 60s", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - const state = "prefetch-short-ttl"; - - await store.save(resCookies, makeTransactionState(state), undefined, true); - - const cookie = resCookies.get(`__txn_${state}`); - expect(cookie?.maxAge).toBe(60); - }); - - it("real login cookie gets full maxAge (1h default)", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - const state = "real-full-ttl"; - - await store.save(resCookies, makeTransactionState(state), undefined, false); - - const cookie = resCookies.get(`__txn_${state}`); - expect(cookie?.maxAge).toBe(3600); + expect(ts).toBeGreaterThan(0); + expect(value.slice(colonIdx + 1)).toBeTruthy(); }); - it("get() strips 'p:' prefix before decrypting prefetch cookie", async () => { + it("cookie gets full maxAge (1h default)", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); - const state = "pf-get-test"; + const state = "full-ttl"; - await store.save(resCookies, makeTransactionState(state), undefined, true); - - const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; - expect(encodedValue.startsWith("p:")).toBe(true); - - const reqCookies = makeRequestCookies({ [`__txn_${state}`]: encodedValue }); - const result = await store.get(reqCookies, state); + await store.save(resCookies, makeTransactionState(state)); - expect(result).not.toBeNull(); - expect(result?.payload?.state).toBe(state); + expect(resCookies.get(`__txn_${state}`)?.maxAge).toBe(3600); }); - it("get() strips '{ts}:' prefix before decrypting real login cookie", async () => { + it("get() strips '{ts}:' prefix before decrypting", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); - const state = "real-get-test"; + const state = "get-test"; - await store.save(resCookies, makeTransactionState(state), undefined, false); + await store.save(resCookies, makeTransactionState(state)); const encodedValue = resCookies.get(`__txn_${state}`)?.value ?? ""; expect(encodedValue.match(/^\d+:/)).toBeTruthy(); @@ -478,132 +410,49 @@ describe("Fix 3 — No lock-out in single-transaction mode", () => { }); // --------------------------------------------------------------------------- -// Fix 4 — Targeted callback cleanup: sweep prefetch + delete only completing cookie +// Fix 4 — Callback cleanup: delete only the completing flow's cookie // --------------------------------------------------------------------------- -describe("Fix 4 — targeted cleanup: deletePrefetchCookies + delete(state)", () => { +describe("Fix 4 — callback cleanup: delete(state)", () => { let secret: string; beforeEach(async () => { secret = await generateSecret(32); }); - describe("deletePrefetchCookies()", () => { - it("deletes all 'p:' prefetch cookies, leaves real login cookies untouched", async () => { - const store = new TransactionStore({ secret }); - const reqCookies = makeRequestCookies({ - __txn_pf1: "p:jwe_prefetch_1", - __txn_pf2: "p:jwe_prefetch_2", - __txn_real: "1000000000:jwe_real_login" - }); - const resCookies = makeResponseCookies(); - - await store.deletePrefetchCookies(reqCookies, resCookies); - - expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); - expect(resCookies.get("__txn_pf2")?.maxAge).toBe(0); - // Real login cookie must NOT be touched - expect(resCookies.get("__txn_real")?.maxAge).not.toBe(0); - }); - - it("does not touch non-txn cookies", async () => { - const store = new TransactionStore({ secret }); - const reqCookies = makeRequestCookies({ - __txn_pf1: "p:jwe_pf", - __session: "session_value" - }); - const resCookies = makeResponseCookies(); - resCookies.set("__session", "session_value"); - - await store.deletePrefetchCookies(reqCookies, resCookies); - - expect(resCookies.get("__txn_pf1")?.maxAge).toBe(0); - expect(resCookies.get("__session")?.value).toBe("session_value"); - }); + it("deletes only the completing flow's cookie, leaves other real login cookies untouched", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_stateA", "1000:jwe_a"); + resCookies.set("__txn_stateB", "2000:jwe_b"); - it("does not throw when no prefetch cookies exist", async () => { - const store = new TransactionStore({ secret }); - const reqCookies = makeRequestCookies({ - __txn_real: "1000000000:jwe_real" - }); - const resCookies = makeResponseCookies(); + await store.delete(resCookies, "stateA"); - await expect( - store.deletePrefetchCookies(reqCookies, resCookies) - ).resolves.not.toThrow(); - }); + expect(resCookies.get("__txn_stateA")?.maxAge).toBe(0); + expect(resCookies.get("__txn_stateB")?.value).toBe("2000:jwe_b"); + expect(resCookies.get("__txn_stateB")?.maxAge).not.toBe(0); }); - describe("delete(state)", () => { - it("deletes only the specific __txn_{state} cookie", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); - resCookies.set("__txn_stateA", "1000:jwe_a"); // completing flow - resCookies.set("__txn_stateB", "2000:jwe_b"); // Tab B — must survive - - await store.delete(resCookies, "stateA"); - - expect(resCookies.get("__txn_stateA")?.maxAge).toBe(0); - // Tab B's real login cookie must not be touched - expect(resCookies.get("__txn_stateB")?.value).toBe("2000:jwe_b"); - expect(resCookies.get("__txn_stateB")?.maxAge).not.toBe(0); - }); - - it("does not throw when deleting a non-existent state", async () => { - const store = new TransactionStore({ secret }); - const resCookies = makeResponseCookies(); + it("does not throw when deleting a non-existent state", async () => { + const store = new TransactionStore({ secret }); + const resCookies = makeResponseCookies(); - await expect( - store.delete(resCookies, "nonexistent-state") - ).resolves.not.toThrow(); - }); + await expect( + store.delete(resCookies, "nonexistent-state") + ).resolves.not.toThrow(); }); - describe("combined: sweep prefetch + delete completing cookie — multi-tab safe", () => { - it("sweeps prefetch cookies and deletes only the completing flow's cookie, leaving Tab B untouched", async () => { - const store = new TransactionStore({ secret }); - - // Tab A completing login - const completingState = "tabA-state"; - // Tab B mid-login under different account - const otherRealState = "tabB-state"; - // Accumulated prefetch garbage - const pfState1 = "pf-orphan-1"; - const pfState2 = "pf-orphan-2"; - - const reqCookies = makeRequestCookies({ - [`__txn_${completingState}`]: "1000:jwe_tabA", - [`__txn_${otherRealState}`]: "2000:jwe_tabB", - [`__txn_${pfState1}`]: "p:jwe_pf1", - [`__txn_${pfState2}`]: "p:jwe_pf2" - }); - const resCookies = makeResponseCookies(); - - await store.deletePrefetchCookies(reqCookies, resCookies); - await store.delete(resCookies, completingState); - - // Completing cookie deleted - expect(resCookies.get(`__txn_${completingState}`)?.maxAge).toBe(0); - // Prefetch cookies swept - expect(resCookies.get(`__txn_${pfState1}`)?.maxAge).toBe(0); - expect(resCookies.get(`__txn_${pfState2}`)?.maxAge).toBe(0); - // Tab B's real login cookie must be untouched - expect(resCookies.get(`__txn_${otherRealState}`)?.maxAge).not.toBe(0); + it("single-transaction mode: delete(state) resolves to __txn_ regardless of state value", async () => { + const store = new TransactionStore({ + secret, + enableParallelTransactions: false }); + const resCookies = makeResponseCookies(); + resCookies.set("__txn_", "1000:stale_jwe"); - it("single-transaction mode: delete(state) resolves to __txn_ regardless of state value", async () => { - const store = new TransactionStore({ - secret, - enableParallelTransactions: false - }); - const resCookies = makeResponseCookies(); - resCookies.set("__txn_", "1000:stale_jwe"); - - // state value is ignored in single mode — always resolves to "__txn_" - await store.delete(resCookies, "any-state-value"); + await store.delete(resCookies, "any-state-value"); - expect(resCookies.get("__txn_")?.maxAge).toBe(0); - }); + expect(resCookies.get("__txn_")?.maxAge).toBe(0); }); }); @@ -663,9 +512,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( return new Response(null, { status: 404 }); }); - const makeAuthClient = ( - opts: { dangerouslyAllowLoginPrefetch?: boolean } = {} - ) => { + const makeAuthClient = () => { const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); return new AuthClient({ @@ -677,8 +524,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( transactionStore, sessionStore, routes: getDefaultRoutes(), - fetch: makeFetch(), - ...opts + fetch: makeFetch() }); }; @@ -721,11 +567,10 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( }); }); - // Checklist: "Load bugs/txn-accumulation while logged out → no __txn_* cookies created" - it("Fix 1 — prefetch request returns 401 and no __txn_* cookie is written (guard on, default)", async () => { + it("Fix 1 — known prefetch header returns 401 and no __txn_* cookie is written", async () => { const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { - headers: { "sec-fetch-mode": "cors" } // prefetch signal + headers: { "next-router-prefetch": "1" } }); const res = await authClient.handler(req); @@ -737,16 +582,14 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(txnCookies).toHaveLength(0); }); - // Checklist: "Set dangerouslyAllowLoginPrefetch: true → 4 __txn_* cookies appear" - it("Fix 1 — prefetch request is allowed through and __txn_* cookie is written (guard off)", async () => { - const authClient = makeAuthClient({ dangerouslyAllowLoginPrefetch: true }); + it("Fix 1 — real navigation is allowed through and __txn_* cookie is written", async () => { + const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { - headers: { "sec-fetch-mode": "cors" } // same prefetch signal + headers: { "sec-fetch-mode": "navigate" } }); const res = await authClient.handler(req); - // Should redirect to Auth0 (3xx), not return 401 expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); const txnCookies = res.cookies @@ -755,24 +598,9 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(txnCookies.length).toBeGreaterThan(0); }); - // Checklist: "Complete login → completing txn + prefetch orphans deleted, other real logins untouched" - it("Fix 4 — handleCallback deletes completing cookie + sweeps prefetch orphans, leaves real Tab B cookie", async () => { - const transactionStore = new TransactionStore({ secret }); - const sessionStore = new StatelessSessionStore({ secret }); - const authClient = new AuthClient({ - domain, - clientId, - clientSecret: "test-secret", - appBaseUrl: "http://localhost:3000", - secret, - transactionStore, - sessionStore, - routes: getDefaultRoutes(), - fetch: makeFetch() - // dangerouslyAllowLoginPrefetch: false (default) - }); + it("Fix 4 — handleCallback deletes only the completing cookie, leaves Tab B cookie untouched", async () => { + const authClient = makeAuthClient(); - // Step 1: login to get a real transaction cookie const loginRes = await authClient.handleLogin( new NextRequest("http://localhost:3000/auth/login") ); @@ -781,19 +609,12 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( )!; const txnCookie = loginRes.cookies.get(`__txn_${state}`); expect(txnCookie).toBeDefined(); - // Verify login cookie has timestamp-prefixed value (real login, not prefetch) expect(txnCookie!.value).toMatch(/^\d+:/); - // Step 2: build callback request: - // - completing flow's cookie - // - two prefetch orphans (value prefix "p:") - // - one real in-flight login from Tab B (must survive) const callbackReq = new NextRequest( `http://localhost:3000/auth/callback?code=auth_code&state=${state}` ); callbackReq.cookies.set(`__txn_${state}`, txnCookie!.value); - callbackReq.cookies.set("__txn_orphan_pf1", "p:prefetch_jwe_1"); - callbackReq.cookies.set("__txn_orphan_pf2", "p:prefetch_jwe_2"); callbackReq.cookies.set("__txn_tabB", "9999999999:tab_b_real_login_jwe"); const callbackRes = await authClient.handleCallback(callbackReq); @@ -801,16 +622,11 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(callbackRes.status).toBeGreaterThanOrEqual(300); expect(callbackRes.status).toBeLessThan(400); - // Completing cookie must be deleted + // Completing cookie deleted expect(callbackRes.cookies.get(`__txn_${state}`)?.maxAge).toBe(0); - // Prefetch orphans must be deleted - expect(callbackRes.cookies.get("__txn_orphan_pf1")?.maxAge).toBe(0); - expect(callbackRes.cookies.get("__txn_orphan_pf2")?.maxAge).toBe(0); // Tab B real login cookie must NOT be deleted - const tabBCookie = callbackRes.cookies.get("__txn_tabB"); - expect(tabBCookie?.maxAge).not.toBe(0); - - // Session cookie written + expect(callbackRes.cookies.get("__txn_tabB")?.maxAge).not.toBe(0); + // Session written expect(callbackRes.cookies.get("__session")?.value).toBeTruthy(); }); }); diff --git a/src/utils/request.ts b/src/utils/request.ts index 126f90ca0..e9a548447 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -18,20 +18,13 @@ export const isRequest = (req: Req): req is Request | NextRequest => { }; /** - * Returns true if the request is non-navigational (e.g. a prefetch, fetch, or - * XHR) rather than a full browser navigation. Used to guard against Next.js - * prefetch requests triggering side-effectful handlers like handleLogin. + * Returns true only when a request carries a known prefetch signal. + * Used to block Next.js prefetch requests from triggering handleLogin. * - * Uses the W3C Fetch Metadata `sec-fetch-mode` header as the primary signal - * (supported in Chrome 76+, Firefox 90+, Safari 16.4+). Falls back to - * Next.js-specific and legacy prefetch headers for older environments. + * Intentionally excludes `sec-fetch-mode` — that header also matches + * legitimate fetch()/XHR calls to /auth/login which must not be blocked. */ export const isNonNavigationalRequest = (req: NextRequest): boolean => { - const fetchMode = req.headers.get("sec-fetch-mode"); - if (fetchMode !== null) { - return fetchMode !== "navigate"; - } - return ( req.headers.get("next-router-prefetch") === "1" || req.headers.get("accept") === "text/x-component" || From d2a75cf95272a7c0d641a27b5dce6862260989ea Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 10 Jul 2026 22:17:07 +0530 Subject: [PATCH 20/36] fix: lint fix --- src/server/auth-client.ts | 18 +++++++++++++++--- src/server/client.ts | 2 +- src/server/transaction-store.ts | 12 ++++++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index f06b4ab0e..25d907eae 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -959,7 +959,11 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - await this.transactionStore.save(res.cookies, transactionState, req?.cookies); + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies + ); return res; } @@ -4187,7 +4191,11 @@ export class AuthClient { `${connectAccountResponse.connectUri}?ticket=${encodeURIComponent(connectAccountResponse.connectParams.ticket)}` ); - await this.transactionStore.save(res.cookies, transactionState, req?.cookies); + await this.transactionStore.save( + res.cookies, + transactionState, + req?.cookies + ); return [null, res]; } @@ -5829,7 +5837,11 @@ export class AuthClient { "Pass the NextResponse cookies (App Router: next/headers cookies; Pages Router: res.cookies)." ); } - await this.transactionStore.save(resCookies, magicLinkTransactionState, req?.cookies); + await this.transactionStore.save( + resCookies, + magicLinkTransactionState, + req?.cookies + ); } } diff --git a/src/server/client.ts b/src/server/client.ts index 6273fb47d..f2b9908a7 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -711,7 +711,7 @@ export class Auth0Client { this.transactionStore = new TransactionStore({ secret, cookieOptions: transactionCookieOptions, - enableParallelTransactions: options.enableParallelTransactions ?? true, + enableParallelTransactions: options.enableParallelTransactions ?? true }); this.sessionStore = options.sessionStore diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index a3b3cf547..928aa116d 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,7 +5,6 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; - export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -228,8 +227,14 @@ export class TransactionStore { } } - const expiration = Math.floor(Date.now() / 1000 + this.cookieOptions.maxAge!); - const jwe = await cookies.encrypt(transactionState, this.secret, expiration); + const expiration = Math.floor( + Date.now() / 1000 + this.cookieOptions.maxAge! + ); + const jwe = await cookies.encrypt( + transactionState, + this.secret, + expiration + ); // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". @@ -288,5 +293,4 @@ export class TransactionStore { } }); } - } From 2bcffd2cb30ab1b8bccd5553af7ac55367a40aa5 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 13 Jul 2026 19:42:06 +0530 Subject: [PATCH 21/36] fix: fix txn cookie eviction limit and add session size warning --- EXAMPLES.md | 9 +- src/server/client.ts | 3 +- .../session/stateless-session-store.test.ts | 61 ++++++++++ src/server/session/stateless-session-store.ts | 56 ++++++--- src/server/transaction-store.ts | 106 +++++++++--------- src/server/txn-cookie-accumulation.test.ts | 101 ++++++++--------- 6 files changed, 209 insertions(+), 127 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index a4a2b13e1..e8486c9fb 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4137,12 +4137,10 @@ const authClient = new Auth0Client({ | Option | Type | Description | | ----------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `transactionCookie.maxAge` | `number` | Expiration time for transaction cookies in seconds. Defaults to `3600` (1 hour). After this time, abandoned cookies expire automatically. | -| `transactionCookie.maxSizeBytes` | `number` | Maximum total byte size of all `__txn_*` cookies combined. Defaults to `4096`. When exceeded, the SDK evicts prefetch cookies first (phase 1), then oldest real login cookies (phase 2), before writing the new cookie. One JWE is ~450–555 bytes. | | `transactionCookie.prefix` | `string` | Prefix for transaction cookie names. Defaults to `__txn_`. In parallel mode, cookies are named `__txn_{state}`; in single mode, just `__txn_`. | | `transactionCookie.sameSite` | `"strict" \| "lax" \| "none"` | Controls when the cookie is sent with cross-site requests. Defaults to `"lax"`. | | `transactionCookie.secure` | `boolean` | When `true`, the cookie is only sent over HTTPS. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | | `transactionCookie.path` | `string` | URL path for which the cookie is valid. Defaults to `"/"`. | -| `dangerouslyAllowLoginPrefetch` | `boolean` | Defaults to `false`. When `false`, the SDK returns a `401` on non-navigational requests to `/auth/login` (Next.js prefetch, XHR), preventing prefetch cookies from accumulating. Set to `true` only for apps with custom login pages worth caching. | ### Troubleshooting: 431 / cookie header too large @@ -4150,8 +4148,8 @@ If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookie **This is fixed in the current SDK version.** The SDK now: -1. Returns `401` on Next.js prefetch requests to `/auth/login` so no cookie is written (`dangerouslyAllowLoginPrefetch: false` by default). -2. Automatically evicts accumulated cookies when the `maxSizeBytes` limit is reached — prefetch cookies first, then oldest real login cookies. +1. Returns `401` on Next.js prefetch requests to `/auth/login` (detected via prefetch headers such as `next-router-prefetch`, `purpose`, `sec-purpose`, and `x-middleware-prefetch`), so no `__txn_*` cookie is written for a flow that will never complete. +2. Automatically evicts accumulated `__txn_*` cookies once their combined size reaches a fixed internal limit (3500 bytes, roughly six concurrent in-flight logins) — oldest-first (FIFO) by creation timestamp — before writing the new cookie. Only transaction cookies are measured and evicted; the session and other cookies are never touched. This limit is not configurable. If you are running an older version, adding `prefetch={false}` to `` components pointing to your login route is a safe fallback: @@ -4162,12 +4160,11 @@ If you are running an older version, adding `prefetch={false}` to `` compo ``` -If accumulation persists after upgrading, increase the byte limit or reduce `maxAge`: +If accumulation persists after upgrading, shorten the transaction cookie lifetime so abandoned logins expire sooner: ```ts export const auth0 = new Auth0Client({ transactionCookie: { - maxSizeBytes: 8192, // raise the ceiling (default 4096) maxAge: 600, // shorten TTL to 10 minutes (default 3600) }, }); diff --git a/src/server/client.ts b/src/server/client.ts index f2b9908a7..0dac5cd79 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -617,8 +617,7 @@ export class Auth0Client { path: options.transactionCookie?.path ?? basePath ?? "/", maxAge: options.transactionCookie?.maxAge ?? 3600, domain: - options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN, - maxSizeBytes: options.transactionCookie?.maxSizeBytes + options.transactionCookie?.domain ?? process.env.AUTH0_COOKIE_DOMAIN }; if (appBaseUrl) { diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 52586042f..332122614 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -949,6 +949,67 @@ describe("Stateless Session Store", async () => { } ); }); + + describe("session cookie size warning", async () => { + const baseSession = (createdAt: number): SessionData => ({ + user: { sub: "user_123" }, + tokenSet: { + accessToken: "at_123", + refreshToken: "rt_123", + expiresAt: 123456 + }, + internal: { sid: "auth0-sid", createdAt } + }); + + it("warns when the session cookie exceeds the size threshold", async () => { + const secret = await generateSecret(32); + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + try { + const session = baseSession(Math.floor(Date.now() / 1000)); + // Large custom claim pushes the encoded session well past 4096 bytes + // (and across multiple __session chunks). + (session.user as Record).bigClaim = "x".repeat(6000); + + const sessionStore = new StatelessSessionStore({ secret }); + await sessionStore.set( + new RequestCookies(new Headers()), + new ResponseCookies(new Headers()), + session + ); + + const warned = consoleWarnSpy.mock.calls.some((c) => + String(c[0]).includes("__session cookie size") + ); + expect(warned).toBe(true); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it("does not warn for a small session cookie", async () => { + const secret = await generateSecret(32); + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + try { + const sessionStore = new StatelessSessionStore({ secret }); + await sessionStore.set( + new RequestCookies(new Headers()), + new ResponseCookies(new Headers()), + baseSession(Math.floor(Date.now() / 1000)) + ); + + const warned = consoleWarnSpy.mock.calls.some((c) => + String(c[0]).includes("__session cookie size") + ); + expect(warned).toBe(false); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + }); }); describe("delete", async () => { diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index e8c0a5077..4d6b83c2d 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -17,6 +17,14 @@ import { normalizeStatelessSession } from "./normalize-session.js"; +// Total encoded session-cookie size (across all `__session` chunks) above which +// we warn. A large session is the main remaining cause of `431 Request Header +// Fields Too Large`, since — unlike transaction cookies — the session is not +// evicted. 4096 bytes mirrors the per-cookie limit browsers guarantee and is a +// good "trim your claims or go stateful" signal well before typical 8 KB proxy +// header limits are hit. +const SESSION_COOKIE_SIZE_WARN_BYTES = 4096; + interface StatelessSessionStoreOptions { secret: string; @@ -125,6 +133,33 @@ export class StatelessSessionStore extends AbstractSessionStore { resCookies ); + // Warn when the session cookie is large. This is the main remaining cause of + // 431 errors: the session (unlike transaction cookies) is never evicted, so + // an oversized session can overflow the request-header limit on its own. + // Measure the total bytes of all `__session` chunks written to the response. + const sessionCookieBytes = resCookies + .getAll() + .filter( + (c) => + c.name === this.sessionCookieName || + c.name.startsWith(`${this.sessionCookieName}__`) + ) + .reduce( + (sum, c) => + sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + 0 + ); + + if (sessionCookieBytes >= SESSION_COOKIE_SIZE_WARN_BYTES) { + console.warn( + `The ${this.sessionCookieName} cookie size is ${sessionCookieBytes} bytes, which may ` + + "exceed request header size limits and cause 431 Request Header Fields Too Large errors " + + "on some servers, proxies, or CDNs. Consider removing unnecessary custom claims from the " + + "access token or the user profile, or use a stateful session implementation to store the " + + "session data in a data store." + ); + } + // Store connection access tokens, each in its own cookie if (connectionTokenSets?.length) { await Promise.all( @@ -228,20 +263,15 @@ export class StatelessSessionStore extends AbstractSessionStore { maxAge }); + // storeInCookie only ever writes connection-token (`__FC_*`) cookies — the + // session cookie is written (and size-checked) separately in set(). Warn if + // an individual connection-token cookie is large enough to risk browser or + // header limits. if (new TextEncoder().encode(cookieJarSizeTest.toString()).length >= 4096) { - // if the cookie is the session cookie, log a warning with additional information about the claims and user profile. - if (cookieName === this.sessionCookieName) { - console.warn( - `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + - "Consider removing any unnecessary custom claims from the access token or the user profile. " + - "Alternatively, you can use a stateful session implementation to store the session data in a data store." - ); - } else { - console.warn( - `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + - "You can use a stateful session implementation to store the session data in a data store." - ); - } + console.warn( + `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + + "You can use a stateful session implementation to store the session data in a data store." + ); } } diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 928aa116d..3d42141eb 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,6 +5,16 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; +// Maximum total byte size of all transaction (`__txn_*`) cookies combined. +// When the accumulated size meets or exceeds this limit, the oldest cookies are +// evicted (FIFO by creation timestamp) before a new one is written. One JWE is +// ~450–555 bytes, so this allows ~6 concurrent in-flight logins — enough for +// multi-tab use while staying well under the request-header limits enforced by +// browsers (~4 KB per cookie) and servers/proxies. Intentionally fixed and not +// configurable: it caps transaction-cookie accumulation regardless of the +// deployment's header limit, which the SDK cannot know. +const MAX_TRANSACTION_COOKIE_BYTES = 3500; + export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -52,24 +62,6 @@ export interface TransactionCookieOptions { * Default: `__txn_{state}`. */ prefix?: string; - /** - * Maximum total byte size of all transaction cookies combined. When the - * accumulated size meets or exceeds this limit, cookies are evicted before - * the new one is written using a two-phase strategy: - * - * Phase 1 — delete all prefetch cookies (value prefix `p:`). These are - * provably garbage and never lead to a completed OAuth flow. - * - * Phase 2 — if still over threshold after phase 1, evict real login cookies - * oldest-first by the timestamp encoded in their value prefix (`{ts}:`). - * Zero crypto decryption happens during eviction. - * - * One `__txn_*` JWE is ~450–555 bytes. Default `4096` allows ~7–9 cookies — - * well under the 8 KB request-header limit most servers enforce. - * - * @default 4096 - */ - maxSizeBytes?: number; /** * The sameSite attribute of the transaction cookie. * @@ -124,7 +116,6 @@ export class TransactionStore { private readonly transactionCookiePrefix: string; private readonly cookieOptions: cookies.CookieOptions; private readonly enableParallelTransactions: boolean; - private readonly maxSizeBytes: number; constructor({ secret, @@ -143,7 +134,6 @@ export class TransactionStore { maxAge: cookieOptions?.maxAge || 60 * 60 // 1 hour in seconds }; this.enableParallelTransactions = enableParallelTransactions ?? true; - this.maxSizeBytes = cookieOptions?.maxSizeBytes ?? 4096; } /** @@ -169,8 +159,9 @@ export class TransactionStore { * * @param resCookies - The response cookies object to set the transaction cookie on * @param transactionState - The transaction state to save - * @param reqCookies - Optional request cookies. When provided, enables maxSizeBytes - * eviction before writing the new cookie. + * @param reqCookies - Optional request cookies. When provided, enables FIFO + * eviction of accumulated transaction cookies (capped at + * {@link MAX_TRANSACTION_COOKIE_BYTES}) before writing the new cookie. * @throws {Error} When transaction state is missing required state parameter */ async save( @@ -182,19 +173,41 @@ export class TransactionStore { throw new Error("Transaction state is required"); } - // Evict oldest transaction cookies FIFO when total size exceeds the cap. - // Safety net for abandoned logins and silent prefetches not caught by the - // prefetch guard (e.g. router.prefetch(), CDN-stripped headers). + const expiration = Math.floor( + Date.now() / 1000 + this.cookieOptions.maxAge! + ); + const jwe = await cookies.encrypt( + transactionState, + this.secret, + expiration + ); + + // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. + // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". + const ts = Math.floor(Date.now() / 1000); + const newCookieName = this.getTransactionCookieName(transactionState.state); + const newCookieValue = `${ts}:${jwe}`; + + // Evict oldest transaction cookies FIFO before writing the new one, so the + // accumulated `__txn_*` cookies stay under MAX_TRANSACTION_COOKIE_BYTES. + // Only transaction cookies are ever measured or deleted here — the session + // and other cookies are left untouched, and the platform's own request-header + // limit is not second-guessed (the SDK cannot know it, and guessing risks + // evicting in-flight logins that would otherwise have fit). if (reqCookies) { - const existing = reqCookies + const enc = new TextEncoder(); + const sizeOf = (name: string, value: string) => + enc.encode(`${name}=${value}`).length; + + const txnCookies = reqCookies .getAll() .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); - const totalBytes = existing.reduce( - (sum, c) => - sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, + const txnBytes = txnCookies.reduce( + (sum, c) => sum + sizeOf(c.name, c.value), 0 ); - if (totalBytes >= this.maxSizeBytes) { + + if (txnBytes >= MAX_TRANSACTION_COOKIE_BYTES) { const deleteOptions = { domain: this.cookieOptions.domain, path: this.cookieOptions.path, @@ -205,45 +218,32 @@ export class TransactionStore { // Sort by timestamp encoded in value prefix "{ts}:{jwe}". // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. - const sorted = [...existing].sort((a, b) => { + const sorted = [...txnCookies].sort((a, b) => { const tsA = parseInt(a.value) || 0; const tsB = parseInt(b.value) || 0; return tsA - tsB; }); let freed = 0; - const target = totalBytes - this.maxSizeBytes + 1; + const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; for (const c of sorted) { + // Never evict the cookie we are about to (re)write for this state. + if (c.name === newCookieName) continue; cookies.deleteCookie(resCookies, c.name, deleteOptions); - freed += new TextEncoder().encode(`${c.name}=${c.value}`).length; + freed += sizeOf(c.name, c.value); if (freed >= target) break; } console.warn( - `[auth0] Evicted transaction cookie(s) — total size ${totalBytes} bytes exceeded ` + - `${this.maxSizeBytes} byte limit. Increase transactionCookie.maxSizeBytes to ` + - `reduce eviction of in-flight logins.` + `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + + `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` ); } } - const expiration = Math.floor( - Date.now() / 1000 + this.cookieOptions.maxAge! - ); - const jwe = await cookies.encrypt( - transactionState, - this.secret, - expiration - ); - - // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. - // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". - const ts = Math.floor(Date.now() / 1000); - resCookies.set( - this.getTransactionCookieName(transactionState.state), - `${ts}:${jwe}`, - this.cookieOptions - ); + resCookies.set(newCookieName, newCookieValue, this.cookieOptions); } async get(reqCookies: cookies.RequestCookies, state: string) { diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index fd012359c..bf6c16560 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -136,10 +136,16 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { }); // --------------------------------------------------------------------------- -// Fix 2 — maxSizeBytes eviction in TransactionStore.save() +// Fix 2 — transaction cookie eviction in TransactionStore.save() +// The byte limit is fixed at 3500 bytes and not configurable. Tests exercise it +// by building transaction cookies whose combined size crosses that threshold. // --------------------------------------------------------------------------- -describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { +// A single transaction cookie value large enough that two of them exceed the +// fixed 3500-byte limit but one does not (~1900 bytes of value each). +const BIG_VALUE = (ts: number) => `${ts}:${"j".repeat(1900)}`; + +describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () => { let secret: string; beforeEach(async () => { @@ -147,14 +153,11 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { }); it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 10 } - }); + const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); const state = "state-no-evict"; - // Even with a tiny maxSizeBytes, passing no reqCookies skips eviction + // With no reqCookies snapshot, eviction is skipped entirely. await expect( store.save(resCookies, makeTransactionState(state)) ).resolves.not.toThrow(); @@ -162,15 +165,12 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${state}`)?.value).toBeTruthy(); }); - it("does not evict when accumulated bytes are below maxSizeBytes", async () => { - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 99999 } - }); + it("does not evict when accumulated bytes are below the limit", async () => { + const store = new TransactionStore({ secret }); const existingState = "existing-state"; const reqCookies = makeRequestCookies({ - [`__txn_${existingState}`]: "short" + [`__txn_${existingState}`]: "1000:short" }); const resCookies = makeResponseCookies(); const newState = "new-state"; @@ -187,28 +187,16 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("evicts oldest cookie first when threshold exceeded with mixed timestamps", async () => { + it("evicts oldest cookie first when the 3500 byte limit is exceeded", async () => { + const store = new TransactionStore({ secret }); + const olderState = "older"; const newerState = "newer"; - const olderValue = "1000:jwe_older"; - const newerValue = "9999:jwe_newer"; - - const enc = new TextEncoder(); - const olderBytes = enc.encode(`__txn_${olderState}=${olderValue}`).length; - const newerBytes = enc.encode(`__txn_${newerState}=${newerValue}`).length; - const totalBytes = olderBytes + newerBytes; - - // maxSizeBytes just below total — eviction fires but only needs to remove one - const maxSizeBytes = totalBytes - olderBytes + 1; - - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes } - }); - + // Two big cookies together exceed 3500 bytes → eviction fires and only needs + // to remove the single oldest to get back under the limit. const reqCookies = makeRequestCookies({ - [`__txn_${olderState}`]: olderValue, - [`__txn_${newerState}`]: newerValue + [`__txn_${olderState}`]: BIG_VALUE(1000), + [`__txn_${newerState}`]: BIG_VALUE(9999) }); const resCookies = makeResponseCookies(); @@ -223,18 +211,15 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("phase-2 evicts oldest real login cookies first when phase-1 insufficient", async () => { - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 1 } - }); + it("evicts oldest login cookies first (FIFO by timestamp)", async () => { + const store = new TransactionStore({ secret }); const olderState = "older"; const newerState = "newer"; - // Older timestamp should be evicted first + // Older timestamp should be evicted first once the limit is crossed. const reqCookies = makeRequestCookies({ - [`__txn_${olderState}`]: "1000:jwe_older", - [`__txn_${newerState}`]: "9999:jwe_newer" + [`__txn_${olderState}`]: BIG_VALUE(1000), + [`__txn_${newerState}`]: BIG_VALUE(9999) }); const resCookies = makeResponseCookies(); @@ -247,18 +232,15 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("evicts legacy cookies (no prefix) in phase-2 as oldest (timestamp=0)", async () => { + it("evicts legacy cookies (no timestamp prefix) as oldest (timestamp=0)", async () => { // Legacy format "{jwe}" has no prefix → gets timestamp 0 → oldest in FIFO - const store = new TransactionStore({ - secret, - cookieOptions: { maxSizeBytes: 1 } - }); + const store = new TransactionStore({ secret }); const legacyState = "legacy"; const newerState = "newer"; const reqCookies = makeRequestCookies({ - [`__txn_${legacyState}`]: "raw_jwe_no_prefix", - [`__txn_${newerState}`]: "9999:jwe_newer", + [`__txn_${legacyState}`]: "r".repeat(1900), // legacy bare value, no "{ts}:" + [`__txn_${newerState}`]: BIG_VALUE(9999), other_cookie: "keep_me" }); const resCookies = makeResponseCookies(); @@ -278,16 +260,17 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { const customPrefix = "__my_txn_"; const store = new TransactionStore({ secret, - cookieOptions: { maxSizeBytes: 1, prefix: customPrefix } + cookieOptions: { prefix: customPrefix } }); + // Two big custom-prefix cookies exceed the limit; a same-sized cookie with a + // different prefix must not be counted toward the budget or evicted. const reqCookies = makeRequestCookies({ - [`${customPrefix}state1`]: "p:prefetch_jwe", - __txn_other: "1000:other_jwe" // different prefix — should NOT be evicted + [`${customPrefix}state1`]: BIG_VALUE(1000), + [`${customPrefix}state2`]: BIG_VALUE(2000), + __txn_other: BIG_VALUE(1000) // different prefix — should NOT be evicted }); const resCookies = makeResponseCookies(); - resCookies.set(`${customPrefix}state1`, "p:prefetch_jwe"); - resCookies.set("__txn_other", "1000:other_jwe"); await store.save( resCookies, @@ -295,9 +278,21 @@ describe("Fix 2 — maxSizeBytes eviction in TransactionStore.save()", () => { reqCookies ); + // Oldest custom-prefix cookie evicted expect(resCookies.get(`${customPrefix}state1`)?.maxAge).toBe(0); // __txn_other has a different prefix — not touched by this store - expect(resCookies.get("__txn_other")?.value).toBe("1000:other_jwe"); + expect(resCookies.get("__txn_other")).toBeUndefined(); + }); + + it("does not expose maxSizeBytes as a configurable option", () => { + // Type-level guarantee that the option was removed; passing it is a no-op + // and the fixed limit still governs eviction. + const store = new TransactionStore({ + secret, + // @ts-expect-error maxSizeBytes is no longer a supported option + cookieOptions: { maxSizeBytes: 1 } + }); + expect(store).toBeInstanceOf(TransactionStore); }); it("cookie value is encoded as '{ts}:{jwe}'", async () => { From 591e8c2a578bca54f695dd567281f81dc945e0ae Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 13 Jul 2026 20:13:15 +0530 Subject: [PATCH 22/36] fix: consolidate 431 docs and inline transaction cookie cleanup --- EXAMPLES.md | 53 ++++++++++++++++++++++++++++++++++++--- README.md | 2 +- src/server/auth-client.ts | 16 +++--------- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index e8486c9fb..6ac405ce2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -151,6 +151,7 @@ - [Customizing Transaction Cookie Expiration](#customizing-transaction-cookie-expiration) - [Transaction Management Modes](#transaction-management-modes) - [Transaction Cookie Options](#transaction-cookie-options) + - [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors) - [Database sessions](#database-sessions) - [Using Client-Initiated Backchannel Authentication](#using-client-initiated-backchannel-authentication) - [Connected Accounts](#connected-accounts) @@ -240,6 +241,9 @@ The second option is through the query parameters to the `/auth/login` endpoint Login ``` +> [!NOTE] +> Link to your login route with a plain `` tag (as shown above) or `` — never ``. A prefetched `` starts a login flow that never completes, accumulating transaction cookies. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). + ### Social Login To skip the Universal Login page and send users directly to a social provider, pass the `connection` parameter with the Auth0 connection name: @@ -573,6 +577,9 @@ export async function middleware(request: NextRequest) { ## Protecting a Server-Side Rendered (SSR) Page +> [!TIP] +> Prefer `withPageAuthRequired` (below) over redirecting to `/auth/login` from middleware. Its redirect happens inside the render and is not followed during a Next.js prefetch, so no transaction cookie is written for prefetched protected pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). + #### Page Router Requests to `/pages/profile` without a valid session cookie will be redirected to the login page. @@ -615,6 +622,9 @@ export default auth0.withPageAuthRequired( To protect a Client-Side Rendered (CSR) page, you can use the `withPageAuthRequired` higher-order function. Requests to `/profile` without a valid session cookie will be redirected to the login page. +> [!TIP] +> Using `withPageAuthRequired` (rather than a middleware redirect to `/auth/login`) also avoids transaction-cookie accumulation on prefetched pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). + ```tsx // app/profile/page.tsx "use client"; @@ -4142,7 +4152,7 @@ const authClient = new Auth0Client({ | `transactionCookie.secure` | `boolean` | When `true`, the cookie is only sent over HTTPS. Derived from `appBaseUrl` when available; enforced in production when `appBaseUrl` is omitted. | | `transactionCookie.path` | `string` | URL path for which the cookie is valid. Defaults to `"/"`. | -### Troubleshooting: 431 / cookie header too large +### Preventing "431 Request Header Fields Too Large" Errors If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookies have grown beyond your server's header size limit. @@ -4151,15 +4161,52 @@ If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookie 1. Returns `401` on Next.js prefetch requests to `/auth/login` (detected via prefetch headers such as `next-router-prefetch`, `purpose`, `sec-purpose`, and `x-middleware-prefetch`), so no `__txn_*` cookie is written for a flow that will never complete. 2. Automatically evicts accumulated `__txn_*` cookies once their combined size reaches a fixed internal limit (3500 bytes, roughly six concurrent in-flight logins) — oldest-first (FIFO) by creation timestamp — before writing the new cookie. Only transaction cookies are measured and evicted; the session and other cookies are never touched. This limit is not configurable. -If you are running an older version, adding `prefetch={false}` to `` components pointing to your login route is a safe fallback: +#### Recommended practices to avoid transaction cookie accumulation + +Even with the automatic protections above, follow these two practices so login flows are only started by real user navigation: + +**1. Do not use ``. Use a plain `` tag or ``.** + +Next.js prefetches `` targets on hover or when they scroll into view. A prefetch of `/auth/login` starts a login flow (writing a `__txn_*` cookie) that the user never completes, since the prefetched response is discarded. Prevent it by not prefetching the login route: ```tsx -// Optional safety net — not required in current SDK versions +// ✅ Do — a plain anchor never prefetches +Sign In + +// ✅ Do — Link with prefetch disabled Sign In + +// ❌ Don't — this prefetches /auth/login and writes a __txn_* cookie on hover/scroll +Sign In ``` +**2. Prefer `withPageAuthRequired` over middleware redirects to protect pages.** + +`withPageAuthRequired` redirects to the login route from inside the React Server Component render. Next.js does **not** follow that redirect during a prefetch, so `handleLogin` is never called and no `__txn_*` cookie is written for prefetched protected pages. A middleware redirect to `/auth/login`, by contrast, is followed on prefetch of a protected page while the user is logged out — each prefetch then writes a transaction cookie. + +```tsx +// ✅ Preferred — redirect happens in RSC render, not followed on prefetch +export default auth0.withPageAuthRequired(async function Page() { + return
Protected content
; +}, { returnTo: "/protected" }); +``` + +```ts +// ⚠️ Middleware redirect — followed on prefetch of a protected page while +// logged out, writing a __txn_* cookie for a flow that never completes. +export async function middleware(request: NextRequest) { + const session = await auth0.getSession(request); + if (!session) { + return NextResponse.redirect(new URL("/auth/login", request.nextUrl.origin)); + } + return NextResponse.next(); +} +``` + +If you are running an older SDK version without the automatic protections above, adding `prefetch={false}` to `` components pointing to your login route is the key fallback. + If accumulation persists after upgrading, shorten the transaction cookie lifetime so abandoned logins expire sooner: ```ts diff --git a/README.md b/README.md index 92089e80a..8dc363ee6 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ export default async function Home() { ``` > [!IMPORTANT] -> You must use `` tags instead of the `` component to ensure that the routing is not done client-side as that may result in some unexpected behavior. +> You must use `` tags instead of the `` component to ensure that the routing is not done client-side as that may result in some unexpected behavior. Prefetching a `` also starts login flows that never complete, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. ## Customizing the client diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 25d907eae..8e6150791 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -657,14 +657,6 @@ export class AuthClient { this.dpopValidated = true; } - private async cleanupTransactionCookies( - req: NextRequest, - resCookies: ResponseCookies, - state: string - ): Promise { - await this.transactionStore.delete(resCookies, state); - } - async handler(req: NextRequest): Promise { let { pathname } = req.nextUrl; @@ -1271,7 +1263,7 @@ export class AuthClient { session ); - await this.cleanupTransactionCookies(req, res.cookies, state); + await this.transactionStore.delete(res.cookies, state); return res; } @@ -1481,7 +1473,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.cleanupTransactionCookies(req, popupResponse.cookies, state); + await this.transactionStore.delete(popupResponse.cookies, state); return popupResponse; } else { // No existing session (edge case: session expired during popup flow) @@ -1551,7 +1543,7 @@ export class AuthClient { true ); addCacheControlHeadersForSession(popupResponse); - await this.cleanupTransactionCookies(req, popupResponse.cookies, state); + await this.transactionStore.delete(popupResponse.cookies, state); return popupResponse; } } @@ -1613,7 +1605,7 @@ export class AuthClient { await this.sessionStore.set(req.cookies, res.cookies, session, true); addCacheControlHeadersForSession(res); - await this.cleanupTransactionCookies(req, res.cookies, state); + await this.transactionStore.delete(res.cookies, state); return res; } From f964ab4e9b2711e0f300bd6dae8391546cd9ba77 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 13 Jul 2026 21:06:01 +0530 Subject: [PATCH 23/36] fix: extract eviction logic into evictOldestTransactionCookies private func --- src/server/transaction-store.ts | 119 ++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 3d42141eb..c69df51a5 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -189,63 +189,80 @@ export class TransactionStore { const newCookieValue = `${ts}:${jwe}`; // Evict oldest transaction cookies FIFO before writing the new one, so the - // accumulated `__txn_*` cookies stay under MAX_TRANSACTION_COOKIE_BYTES. - // Only transaction cookies are ever measured or deleted here — the session - // and other cookies are left untouched, and the platform's own request-header - // limit is not second-guessed (the SDK cannot know it, and guessing risks - // evicting in-flight logins that would otherwise have fit). + // accumulated `__txn_*` cookies stay under the fixed byte limit. Only + // transaction cookies are measured/deleted — the session and other cookies + // are left untouched, and the cookie about to be written is never evicted. if (reqCookies) { - const enc = new TextEncoder(); - const sizeOf = (name: string, value: string) => - enc.encode(`${name}=${value}`).length; - - const txnCookies = reqCookies - .getAll() - .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); - const txnBytes = txnCookies.reduce( - (sum, c) => sum + sizeOf(c.name, c.value), - 0 - ); - - if (txnBytes >= MAX_TRANSACTION_COOKIE_BYTES) { - const deleteOptions = { - domain: this.cookieOptions.domain, - path: this.cookieOptions.path, - secure: this.cookieOptions.secure, - sameSite: this.cookieOptions.sameSite, - httpOnly: this.cookieOptions.httpOnly - }; - - // Sort by timestamp encoded in value prefix "{ts}:{jwe}". - // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. - const sorted = [...txnCookies].sort((a, b) => { - const tsA = parseInt(a.value) || 0; - const tsB = parseInt(b.value) || 0; - return tsA - tsB; - }); - - let freed = 0; - const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; - for (const c of sorted) { - // Never evict the cookie we are about to (re)write for this state. - if (c.name === newCookieName) continue; - cookies.deleteCookie(resCookies, c.name, deleteOptions); - freed += sizeOf(c.name, c.value); - if (freed >= target) break; - } - - console.warn( - `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + - `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + - `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + - `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` - ); - } + this.evictOldestTransactionCookies(reqCookies, resCookies, newCookieName); } resCookies.set(newCookieName, newCookieValue, this.cookieOptions); } + /** + * Evicts the oldest transaction cookies (FIFO by the `{ts}:` value prefix) from + * the response so the accumulated `__txn_*` cookies stay under + * {@link MAX_TRANSACTION_COOKIE_BYTES} before a new one is written. + * + * Only cookies matching the transaction prefix are measured and deleted — the + * session, connection-token, and application cookies are never touched. The + * cookie about to be (re)written for the current transaction (`skipCookieName`) + * is never evicted. No-op when the accumulated size is under the limit. + */ + private evictOldestTransactionCookies( + reqCookies: cookies.RequestCookies, + resCookies: cookies.ResponseCookies, + skipCookieName: string + ) { + const sizeOf = (name: string, value: string) => + new TextEncoder().encode(`${name}=${value}`).length; + + const txnCookies = reqCookies + .getAll() + .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); + const txnBytes = txnCookies.reduce( + (sum, c) => sum + sizeOf(c.name, c.value), + 0 + ); + + if (txnBytes < MAX_TRANSACTION_COOKIE_BYTES) { + return; + } + + const deleteOptions = { + domain: this.cookieOptions.domain, + path: this.cookieOptions.path, + secure: this.cookieOptions.secure, + sameSite: this.cookieOptions.sameSite, + httpOnly: this.cookieOptions.httpOnly + }; + + // Sort by timestamp encoded in value prefix "{ts}:{jwe}". + // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. + const sorted = [...txnCookies].sort((a, b) => { + const tsA = parseInt(a.value) || 0; + const tsB = parseInt(b.value) || 0; + return tsA - tsB; + }); + + let freed = 0; + const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; + for (const c of sorted) { + // Never evict the cookie we are about to (re)write for this state. + if (c.name === skipCookieName) continue; + cookies.deleteCookie(resCookies, c.name, deleteOptions); + freed += sizeOf(c.name, c.value); + if (freed >= target) break; + } + + console.warn( + `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + + `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` + ); + } + async get(reqCookies: cookies.RequestCookies, state: string) { const cookieName = this.getTransactionCookieName(state); const cookieValue = reqCookies.get(cookieName)?.value; From ef6b6afecf10bdc690f8be6e20fb07c46b8ea9b4 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 17 Jul 2026 18:22:46 +0530 Subject: [PATCH 24/36] fix: addressing coderabbit review comments --- README.md | 2 +- src/server/transaction-store.ts | 43 +++++++++++++++------- src/server/txn-cookie-accumulation.test.ts | 35 +++++++++++++++--- src/utils/request.ts | 14 +++++-- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8dc363ee6..149df3440 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ export default async function Home() { ``` > [!IMPORTANT] -> You must use `` tags instead of the `` component to ensure that the routing is not done client-side as that may result in some unexpected behavior. Prefetching a `` also starts login flows that never complete, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. +> Link to the login route with a plain `` tag or `` — do not use ``. A prefetched `` starts a login flow that never completes, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. ## Customizing the client diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index c69df51a5..50e75aa63 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -193,7 +193,12 @@ export class TransactionStore { // transaction cookies are measured/deleted — the session and other cookies // are left untouched, and the cookie about to be written is never evicted. if (reqCookies) { - this.evictOldestTransactionCookies(reqCookies, resCookies, newCookieName); + this.evictOldestTransactionCookies( + reqCookies, + resCookies, + newCookieName, + newCookieValue + ); } resCookies.set(newCookieName, newCookieValue, this.cookieOptions); @@ -201,18 +206,23 @@ export class TransactionStore { /** * Evicts the oldest transaction cookies (FIFO by the `{ts}:` value prefix) from - * the response so the accumulated `__txn_*` cookies stay under - * {@link MAX_TRANSACTION_COOKIE_BYTES} before a new one is written. + * the response so that the accumulated `__txn_*` cookies — including the one + * about to be written — stay under {@link MAX_TRANSACTION_COOKIE_BYTES}. * * Only cookies matching the transaction prefix are measured and deleted — the * session, connection-token, and application cookies are never touched. The - * cookie about to be (re)written for the current transaction (`skipCookieName`) - * is never evicted. No-op when the accumulated size is under the limit. + * cookie about to be (re)written for the current transaction (`newCookieName`) + * is never evicted. No-op when the projected total is under the limit. + * + * @param newCookieName - Name of the cookie about to be written (never evicted). + * @param newCookieValue - Value of that cookie; its size is included in the cap + * so a large new cookie can still trigger eviction. */ private evictOldestTransactionCookies( reqCookies: cookies.RequestCookies, resCookies: cookies.ResponseCookies, - skipCookieName: string + newCookieName: string, + newCookieValue: string ) { const sizeOf = (name: string, value: string) => new TextEncoder().encode(`${name}=${value}`).length; @@ -220,12 +230,19 @@ export class TransactionStore { const txnCookies = reqCookies .getAll() .filter((c) => c.name.startsWith(this.transactionCookiePrefix)); - const txnBytes = txnCookies.reduce( - (sum, c) => sum + sizeOf(c.name, c.value), + + // Existing transaction-cookie bytes, excluding any cookie with the same name + // as the one we're about to write — its old bytes are replaced, not added. + const existingBytes = txnCookies.reduce( + (sum, c) => + c.name === newCookieName ? sum : sum + sizeOf(c.name, c.value), 0 ); + // Project the total that will be on the request header after this write. + const projectedBytes = + existingBytes + sizeOf(newCookieName, newCookieValue); - if (txnBytes < MAX_TRANSACTION_COOKIE_BYTES) { + if (projectedBytes < MAX_TRANSACTION_COOKIE_BYTES) { return; } @@ -246,18 +263,18 @@ export class TransactionStore { }); let freed = 0; - const target = txnBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; + const target = projectedBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; for (const c of sorted) { // Never evict the cookie we are about to (re)write for this state. - if (c.name === skipCookieName) continue; + if (c.name === newCookieName) continue; cookies.deleteCookie(resCookies, c.name, deleteOptions); freed += sizeOf(c.name, c.value); if (freed >= target) break; } console.warn( - `[auth0] Evicted the oldest transaction cookie(s) — total size ${txnBytes} bytes ` + - `exceeded the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `[auth0] Evicted the oldest transaction cookie(s) — projected total size ${projectedBytes} bytes ` + + `reached the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` ); diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index bf6c16560..7530d2594 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -79,12 +79,6 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { ).toBe(true); }); - it("returns true when accept is text/x-component", () => { - expect( - isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) - ).toBe(true); - }); - it("returns true when purpose is prefetch", () => { expect(isNonNavigationalRequest(makeReq({ purpose: "prefetch" }))).toBe( true @@ -111,6 +105,14 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { ); }); + it("returns false for accept: text/x-component — real RSC navigation must not be blocked", () => { + // text/x-component is sent by ALL App Router RSC requests, including a + // genuine client-side click — not just prefetches. + expect( + isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) + ).toBe(false); + }); + it("returns false for sec-fetch-mode: navigate", () => { expect( isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) @@ -187,6 +189,27 @@ describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); + it("counts the new cookie in the cap: evicts when existing is under-limit but projected total reaches it", async () => { + const store = new TransactionStore({ secret }); + + // One existing cookie sized just under 3500 bytes on its own — no eviction + // would fire if only existing bytes were counted. The ~500-byte new cookie + // pushes the projected total over the limit, so eviction MUST fire. + const existingState = "existing"; + const nearLimitValue = `1000:${"j".repeat(3400)}`; // ~3413 bytes with name + const reqCookies = makeRequestCookies({ + [`__txn_${existingState}`]: nearLimitValue + }); + const resCookies = makeResponseCookies(); + + await store.save(resCookies, makeTransactionState("newstate"), reqCookies); + + // The existing (older) cookie is evicted so the projected total stays bounded + expect(resCookies.get(`__txn_${existingState}`)?.maxAge).toBe(0); + // New cookie still written + expect(resCookies.get("__txn_newstate")?.value).toBeTruthy(); + }); + it("evicts oldest cookie first when the 3500 byte limit is exceeded", async () => { const store = new TransactionStore({ secret }); diff --git a/src/utils/request.ts b/src/utils/request.ts index e9a548447..80db68a6b 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -18,16 +18,22 @@ export const isRequest = (req: Req): req is Request | NextRequest => { }; /** - * Returns true only when a request carries a known prefetch signal. + * Returns true only when a request carries an unambiguous prefetch signal. * Used to block Next.js prefetch requests from triggering handleLogin. * - * Intentionally excludes `sec-fetch-mode` — that header also matches - * legitimate fetch()/XHR calls to /auth/login which must not be blocked. + * Only headers that are exclusive to prefetches are checked: + * - `next-router-prefetch` / `x-middleware-prefetch` — Next.js prefetch markers + * - `purpose` / `sec-purpose` = `prefetch` — W3C/browser prefetch hints + * + * Intentionally excludes: + * - `sec-fetch-mode` — also set on legitimate fetch()/XHR calls to /auth/login. + * - `accept: text/x-component` — sent by ALL App Router RSC requests, including + * real client-side `` navigations (e.g. ``), so + * matching it would 401 genuine login clicks, not just prefetches. */ export const isNonNavigationalRequest = (req: NextRequest): boolean => { return ( req.headers.get("next-router-prefetch") === "1" || - req.headers.get("accept") === "text/x-component" || req.headers.get("purpose") === "prefetch" || req.headers.get("sec-purpose") === "prefetch" || req.headers.get("x-middleware-prefetch") === "1" From 59852202cdf1fa7617bd103196d033961a670679 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 27 Jul 2026 21:00:20 +0530 Subject: [PATCH 25/36] fix: address code review findings on txn cookie accumulation PR --- EXAMPLES.md | 3 ++ src/server/auth-client.ts | 5 ++- src/server/client.test.ts | 37 +++++++++++++++++++ src/server/client.ts | 9 ++++- src/server/cookies.ts | 17 +++++++-- src/server/session/stateless-session-store.ts | 24 +++--------- src/server/transaction-store.ts | 29 +++++++++++---- 7 files changed, 93 insertions(+), 31 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 6ac405ce2..2dc311ec6 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4142,6 +4142,9 @@ const authClient = new Auth0Client({ - You want the simplest possible transaction management - Users typically don't need multiple concurrent login flows +> [!NOTE] +> In single transaction mode, starting a new login while one is already in progress overwrites the existing `__txn_` cookie rather than rejecting the new attempt. If a user has two tabs open and starts a login in both, only the most recently started login can complete; the other tab's callback will fail because its transaction state was overwritten. This is expected in single transaction mode — use the default parallel mode if concurrent logins across tabs need to succeed. + ### Transaction Cookie Options | Option | Type | Description | diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 8e6150791..0769f09ed 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -803,7 +803,8 @@ export class AuthClient { async startInteractiveLogin( options: StartInteractiveLoginOptions = {}, - req?: NextRequest + req?: NextRequest, + reqCookies?: RequestCookies | ReadonlyRequestCookies ): Promise { await this.ensureDpopValidated(); const appBaseUrl = resolveAppBaseUrl(this.appBaseUrl, req); @@ -954,7 +955,7 @@ export class AuthClient { await this.transactionStore.save( res.cookies, transactionState, - req?.cookies + req?.cookies ?? reqCookies ); return res; diff --git a/src/server/client.test.ts b/src/server/client.test.ts index e92c16629..cb58288f1 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -1750,6 +1750,43 @@ describe("Auth0Client", () => { }); }); }); + + describe("startInteractiveLogin", () => { + it("forwards request cookies to AuthClient.startInteractiveLogin so transaction-cookie eviction can run", async () => { + process.env[ENV_VARS.DOMAIN] = "env.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "env_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "env_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.com"; + process.env[ENV_VARS.SECRET] = "env_secret"; + + const client = new Auth0Client(); + + const mockCookieJar = { getAll: () => [] }; + const nextHeaders = await import("next/headers.js"); + vi.mocked(nextHeaders.cookies).mockResolvedValue(mockCookieJar as any); + + const mockAuthClient = { + startInteractiveLogin: vi + .fn() + .mockResolvedValue(NextResponse.redirect("https://example.com")) + }; + vi.spyOn(client["provider"] as any, "forRequest").mockResolvedValue( + mockAuthClient + ); + + await client.startInteractiveLogin(); + + expect(mockAuthClient.startInteractiveLogin).toHaveBeenCalledTimes(1); + const [, req, reqCookies] = + mockAuthClient.startInteractiveLogin.mock.calls[0]; + // No NextRequest is available from Server Components/Actions. + expect(req).toBeUndefined(); + // Cookies must be forwarded — otherwise TransactionStore.save() never + // runs eviction, and __txn_* cookies accumulate unbounded for logins + // started this way (e.g. from a Server Action). + expect(reqCookies).toBe(mockCookieJar); + }); + }); }); export type GetAccessTokenOptions = { diff --git a/src/server/client.ts b/src/server/client.ts index 0dac5cd79..23426f2f5 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -1706,7 +1706,14 @@ export class Auth0Client { ): Promise { const reqHeaders = await getHeaders(); const authClient = await this.provider.forRequest(reqHeaders, undefined); - return authClient.startInteractiveLogin(options); + // Pass request cookies so the transaction store can evict accumulated + // `__txn_*` cookies before writing the new one — otherwise logins started + // from Server Components/Actions never trigger eviction. + return authClient.startInteractiveLogin( + options, + undefined, + await cookies() + ); } /** diff --git a/src/server/cookies.ts b/src/server/cookies.ts index 961f30b70..c960cef8b 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -211,6 +211,9 @@ const getAllChunkedCookies = ( * @param options - Options for setting the cookie. * @param reqCookies - The request cookies object, used to enable read-after-write in the same request for middleware. * @param resCookies - The response cookies object, used to set the cookies in the response. + * @returns The total encoded `name=value` byte size of the cookie(s) written — + * lets callers check against header-size limits without re-scanning + * `resCookies` afterwards. */ export function setChunkedCookie( name: string, @@ -218,7 +221,7 @@ export function setChunkedCookie( options: CookieOptions, reqCookies: RequestCookies, resCookies: ResponseCookies -): void { +): number { const { transient, ...restOptions } = options; const finalOptions = { ...restOptions }; @@ -226,7 +229,11 @@ export function setChunkedCookie( delete finalOptions.maxAge; } - const valueBytes = new TextEncoder().encode(value).length; + const encoder = new TextEncoder(); + const sizeOf = (cookieName: string, cookieValue: string) => + encoder.encode(`${cookieName}=${cookieValue}`).length; + + const valueBytes = encoder.encode(value).length; // If value fits in a single cookie, set it directly if (valueBytes <= MAX_CHUNK_SIZE) { @@ -247,12 +254,13 @@ export function setChunkedCookie( reqCookies.delete(cookieChunk.name); }); - return; + return sizeOf(name, value); } // Split value into chunks let position = 0; let chunkIndex = 0; + let totalBytes = 0; while (position < value.length) { const chunk = value.slice(position, position + MAX_CHUNK_SIZE); @@ -261,6 +269,7 @@ export function setChunkedCookie( resCookies.set(chunkName, chunk, finalOptions); // to enable read-after-write in the same request for middleware reqCookies.set(chunkName, chunk); + totalBytes += sizeOf(chunkName, chunk); position += MAX_CHUNK_SIZE; chunkIndex++; } @@ -292,6 +301,8 @@ export function setChunkedCookie( httpOnly: finalOptions.httpOnly }); reqCookies.delete(name); + + return totalBytes; } /** diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index 4d6b83c2d..f53fba6b9 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -125,7 +125,12 @@ export class StatelessSessionStore extends AbstractSessionStore { maxAge }; - cookies.setChunkedCookie( + // Warn when the session cookie is large. This is the main remaining cause of + // 431 errors: the session (unlike transaction cookies) is never evicted, so + // an oversized session can overflow the request-header limit on its own. + // setChunkedCookie returns the total bytes of the chunk(s) it wrote, so no + // separate re-scan of resCookies is needed. + const sessionCookieBytes = cookies.setChunkedCookie( this.sessionCookieName, cookieValue, options, @@ -133,23 +138,6 @@ export class StatelessSessionStore extends AbstractSessionStore { resCookies ); - // Warn when the session cookie is large. This is the main remaining cause of - // 431 errors: the session (unlike transaction cookies) is never evicted, so - // an oversized session can overflow the request-header limit on its own. - // Measure the total bytes of all `__session` chunks written to the response. - const sessionCookieBytes = resCookies - .getAll() - .filter( - (c) => - c.name === this.sessionCookieName || - c.name.startsWith(`${this.sessionCookieName}__`) - ) - .reduce( - (sum, c) => - sum + new TextEncoder().encode(`${c.name}=${c.value}`).length, - 0 - ); - if (sessionCookieBytes >= SESSION_COOKIE_SIZE_WARN_BYTES) { console.warn( `The ${this.sessionCookieName} cookie size is ${sessionCookieBytes} bytes, which may ` + diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 50e75aa63..4b22ff716 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -167,7 +167,7 @@ export class TransactionStore { async save( resCookies: cookies.ResponseCookies, transactionState: TransactionState, - reqCookies?: cookies.RequestCookies + reqCookies?: cookies.RequestCookies | cookies.ReadonlyRequestCookies ) { if (!transactionState.state) { throw new Error("Transaction state is required"); @@ -219,7 +219,7 @@ export class TransactionStore { * so a large new cookie can still trigger eviction. */ private evictOldestTransactionCookies( - reqCookies: cookies.RequestCookies, + reqCookies: cookies.RequestCookies | cookies.ReadonlyRequestCookies, resCookies: cookies.ResponseCookies, newCookieName: string, newCookieValue: string @@ -256,11 +256,10 @@ export class TransactionStore { // Sort by timestamp encoded in value prefix "{ts}:{jwe}". // Legacy bare "{jwe}" values (no colon) get timestamp 0 — evicted first. - const sorted = [...txnCookies].sort((a, b) => { - const tsA = parseInt(a.value) || 0; - const tsB = parseInt(b.value) || 0; - return tsA - tsB; - }); + const sorted = [...txnCookies].sort( + (a, b) => + this.parseCookieTimestamp(a.value) - this.parseCookieTimestamp(b.value) + ); let freed = 0; const target = projectedBytes - MAX_TRANSACTION_COOKIE_BYTES + 1; @@ -280,6 +279,22 @@ export class TransactionStore { ); } + /** + * Extracts the creation timestamp from a cookie value shaped "{ts}:{jwe}". + * Legacy bare "{jwe}" values (no colon) have no timestamp and sort first (0). + * Uses an explicit split on the first colon rather than `parseInt`, so a + * legacy JWE that happens to start with digits is never mistaken for a + * timestamp — consistent with the split used in {@link get}. + */ + private parseCookieTimestamp(value: string): number { + const colonIdx = value.indexOf(":"); + if (colonIdx === -1) { + return 0; + } + const ts = Number(value.slice(0, colonIdx)); + return Number.isFinite(ts) ? ts : 0; + } + async get(reqCookies: cookies.RequestCookies, state: string) { const cookieName = this.getTransactionCookieName(state); const cookieValue = reqCookies.get(cookieName)?.value; From 95baeee8a94e5b9787f873e58ae45680e826fcef Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Tue, 28 Jul 2026 10:33:07 +0530 Subject: [PATCH 26/36] fix: failing test with passwordless nonce --- src/server/passwordless-server.flow.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/server/passwordless-server.flow.test.ts b/src/server/passwordless-server.flow.test.ts index ad29d1cff..df02bbd37 100644 --- a/src/server/passwordless-server.flow.test.ts +++ b/src/server/passwordless-server.flow.test.ts @@ -268,8 +268,14 @@ describe("AuthClient passwordless methods", () => { const state = authParams.state as string; const txnCookie = resCookies.get(`__txn_${state}`); expect(txnCookie).toBeDefined(); + // Strip the {ts}: prefix added by the transaction store before decrypting + const colonIdx = txnCookie!.value.indexOf(":"); + const jweValue = + colonIdx !== -1 + ? txnCookie!.value.slice(colonIdx + 1) + : txnCookie!.value; const { payload } = (await decrypt( - txnCookie!.value, + jweValue, secret )) as jose.JWTDecryptResult; expect(payload.nonce).toBe(authParams.nonce); From 69a4ad665d5045e396baf355a289b63be72e7b15 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 29 Jul 2026 11:50:01 +0530 Subject: [PATCH 27/36] fix: detect Sec-Purpose prefetch;prerender in isNonNavigationalRequest to prevent orphaned txn cookies --- src/server/txn-cookie-accumulation.test.ts | 8 ++++++++ src/utils/request.ts | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index 7530d2594..bbdd8d6d6 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -91,6 +91,14 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { ).toBe(true); }); + it("returns true when sec-purpose is prefetch;prerender (Speculation Rules)", () => { + expect( + isNonNavigationalRequest( + makeReq({ "sec-purpose": "prefetch;prerender" }) + ) + ).toBe(true); + }); + it("returns true when x-middleware-prefetch is 1", () => { expect( isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) diff --git a/src/utils/request.ts b/src/utils/request.ts index 80db68a6b..e7cfa36cb 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -25,6 +25,11 @@ export const isRequest = (req: Req): req is Request | NextRequest => { * - `next-router-prefetch` / `x-middleware-prefetch` — Next.js prefetch markers * - `purpose` / `sec-purpose` = `prefetch` — W3C/browser prefetch hints * + * `sec-purpose` is matched with `includes("prefetch")` rather than an exact + * equality: Chromium's Speculation Rules API sends `Sec-Purpose: prefetch;prerender` + * for prerender hints, which is still a machine request that never completes OAuth. + * `prefetch` only appears as a structured purpose token, so the substring match is safe. + * * Intentionally excludes: * - `sec-fetch-mode` — also set on legitimate fetch()/XHR calls to /auth/login. * - `accept: text/x-component` — sent by ALL App Router RSC requests, including @@ -35,7 +40,7 @@ export const isNonNavigationalRequest = (req: NextRequest): boolean => { return ( req.headers.get("next-router-prefetch") === "1" || req.headers.get("purpose") === "prefetch" || - req.headers.get("sec-purpose") === "prefetch" || + (req.headers.get("sec-purpose")?.includes("prefetch") ?? false) || req.headers.get("x-middleware-prefetch") === "1" ); }; From 1a5dfa7e51baaa9d861407d9471098b4e47f6f72 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 30 Jul 2026 00:33:26 +0530 Subject: [PATCH 28/36] fix: address txn-accumulation review findings --- src/server/auth-client.ts | 6 +++- src/server/session/stateless-session-store.ts | 12 ++++++- src/server/transaction-store.ts | 19 +++++------ src/server/txn-cookie-accumulation.test.ts | 32 +++++++++++++------ 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 0769f09ed..b3137f3d2 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -674,7 +674,11 @@ export class AuthClient { if (method === "GET" && sanitizedPathname === this.routes.login) { if (isNonNavigationalRequest(req)) { - return new NextResponse(null, { status: 401 }); + // 204 No Content signals "intentionally did nothing" for prefetch/ + // non-navigational requests, avoiding polluting auth-failure telemetry + // and access logs. Next.js discards prefetch responses regardless, so + // behavior is unaffected. + return new NextResponse(null, { status: 204 }); } return this.handleLogin(req); } else if (method === "GET" && sanitizedPathname === this.routes.logout) { diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index f53fba6b9..3d82bf773 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -25,6 +25,12 @@ import { // header limits are hit. const SESSION_COOKIE_SIZE_WARN_BYTES = 4096; +// Under rolling sessions, set() runs on ~every authenticated request, so a +// legitimately large-but-working session would otherwise log the size warning +// on every request. Emit it once per process to keep the diagnostic without +// spamming logs. +let sessionSizeWarningEmitted = false; + interface StatelessSessionStoreOptions { secret: string; @@ -138,7 +144,11 @@ export class StatelessSessionStore extends AbstractSessionStore { resCookies ); - if (sessionCookieBytes >= SESSION_COOKIE_SIZE_WARN_BYTES) { + if ( + sessionCookieBytes >= SESSION_COOKIE_SIZE_WARN_BYTES && + !sessionSizeWarningEmitted + ) { + sessionSizeWarningEmitted = true; console.warn( `The ${this.sessionCookieName} cookie size is ${sessionCookieBytes} bytes, which may ` + "exceed request header size limits and cause 431 Request Header Fields Too Large errors " + diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 4b22ff716..3bc2c0c2b 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -5,14 +5,14 @@ import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; -// Maximum total byte size of all transaction (`__txn_*`) cookies combined. -// When the accumulated size meets or exceeds this limit, the oldest cookies are -// evicted (FIFO by creation timestamp) before a new one is written. One JWE is -// ~450–555 bytes, so this allows ~6 concurrent in-flight logins — enough for -// multi-tab use while staying well under the request-header limits enforced by -// browsers (~4 KB per cookie) and servers/proxies. Intentionally fixed and not -// configurable: it caps transaction-cookie accumulation regardless of the -// deployment's header limit, which the SDK cannot know. +// Default maximum total byte size of all transaction (`__txn_*`) cookies +// combined. When the accumulated size meets or exceeds this limit, the oldest +// cookies are evicted (FIFO by creation timestamp) before a new one is written. +// One JWE is ~450–555 bytes, so this allows ~6 concurrent in-flight logins — +// enough for multi-tab use while staying well under the request-header limits +// enforced by browsers (~4 KB per cookie) and servers/proxies. Intentionally +// fixed and not configurable: it caps transaction-cookie accumulation regardless +// of the deployment's header limit, which the SDK cannot know. const MAX_TRANSACTION_COOKIE_BYTES = 3500; export interface TransactionState extends jose.JWTPayload { @@ -161,7 +161,8 @@ export class TransactionStore { * @param transactionState - The transaction state to save * @param reqCookies - Optional request cookies. When provided, enables FIFO * eviction of accumulated transaction cookies (capped at - * {@link MAX_TRANSACTION_COOKIE_BYTES}) before writing the new cookie. + * {@link MAX_TRANSACTION_COOKIE_BYTES}) before writing the + * new cookie. * @throws {Error} When transaction state is missing required state parameter */ async save( diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index bbdd8d6d6..8b3df891f 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -234,10 +234,15 @@ describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () const newState = "newstate"; await store.save(resCookies, makeTransactionState(newState), reqCookies); - // Older cookie evicted first + // Older cookie evicted first — present as a deletion tombstone (maxAge 0). expect(resCookies.get(`__txn_${olderState}`)?.maxAge).toBe(0); - // Newer cookie untouched — eviction stopped after freeing enough - expect(resCookies.get(`__txn_${newerState}`)?.maxAge).not.toBe(0); + // Newer cookie untouched — eviction stopped after freeing enough. Since it + // was never deleted it must NOT appear as a tombstone on the response; it + // may be absent (untouched) but must never be present with maxAge 0. + const newerCookie = resCookies.get(`__txn_${newerState}`); + if (newerCookie !== undefined) { + expect(newerCookie.maxAge).not.toBe(0); + } // New cookie written expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); @@ -455,8 +460,12 @@ describe("Fix 4 — callback cleanup: delete(state)", () => { await store.delete(resCookies, "stateA"); expect(resCookies.get("__txn_stateA")?.maxAge).toBe(0); - expect(resCookies.get("__txn_stateB")?.value).toBe("2000:jwe_b"); - expect(resCookies.get("__txn_stateB")?.maxAge).not.toBe(0); + // stateB must still be present with its original value and not a deletion + // tombstone (maxAge 0). + const stateB = resCookies.get("__txn_stateB"); + expect(stateB).toBeDefined(); + expect(stateB?.value).toBe("2000:jwe_b"); + expect(stateB?.maxAge).not.toBe(0); }); it("does not throw when deleting a non-existent state", async () => { @@ -593,7 +602,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( }); }); - it("Fix 1 — known prefetch header returns 401 and no __txn_* cookie is written", async () => { + it("Fix 1 — known prefetch header returns 204 and no __txn_* cookie is written", async () => { const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { headers: { "next-router-prefetch": "1" } @@ -601,7 +610,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( const res = await authClient.handler(req); - expect(res.status).toBe(401); + expect(res.status).toBe(204); const txnCookies = res.cookies .getAll() .filter((c) => c.name.startsWith("__txn_") && c.maxAge !== 0); @@ -650,8 +659,13 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( // Completing cookie deleted expect(callbackRes.cookies.get(`__txn_${state}`)?.maxAge).toBe(0); - // Tab B real login cookie must NOT be deleted - expect(callbackRes.cookies.get("__txn_tabB")?.maxAge).not.toBe(0); + // Tab B real login cookie must NOT be deleted — it may be absent from the + // response (never touched) but must never appear as a deletion tombstone + // (present with maxAge 0 and an empty value). + const tabB = callbackRes.cookies.get("__txn_tabB"); + if (tabB !== undefined) { + expect(tabB.maxAge).not.toBe(0); + } // Session written expect(callbackRes.cookies.get("__session")?.value).toBeTruthy(); }); From 7ef34be33efc7e526ca2e9b1a38c33e79da1858d Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 30 Jul 2026 00:56:51 +0530 Subject: [PATCH 29/36] docs: update 401 to 204 on prefetch restriction --- EXAMPLES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 2dc311ec6..2fc6d7fb8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4161,7 +4161,7 @@ If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookie **This is fixed in the current SDK version.** The SDK now: -1. Returns `401` on Next.js prefetch requests to `/auth/login` (detected via prefetch headers such as `next-router-prefetch`, `purpose`, `sec-purpose`, and `x-middleware-prefetch`), so no `__txn_*` cookie is written for a flow that will never complete. +1. Returns `204 No Content` on Next.js prefetch requests to `/auth/login` (detected via prefetch headers such as `next-router-prefetch`, `purpose`, `sec-purpose`, and `x-middleware-prefetch`), so no `__txn_*` cookie is written for a flow that will never complete. 2. Automatically evicts accumulated `__txn_*` cookies once their combined size reaches a fixed internal limit (3500 bytes, roughly six concurrent in-flight logins) — oldest-first (FIFO) by creation timestamp — before writing the new cookie. Only transaction cookies are measured and evicted; the session and other cookies are never touched. This limit is not configurable. #### Recommended practices to avoid transaction cookie accumulation From 33aae3864d8565e82eaffa15ba8c4ca523ee1719 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 10 Aug 2026 23:38:55 +0530 Subject: [PATCH 30/36] fix: addressing review comments --- src/server/txn-cookie-accumulation.test.ts | 61 ++++++++++++++-------- src/test/utils.ts | 1 - 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index 8b3df891f..bf289415a 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -162,6 +162,23 @@ describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () secret = await generateSecret(32); }); + it("logs a console.warn when eviction fires", async () => { + const store = new TransactionStore({ secret }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const reqCookies = makeRequestCookies({ + __txn_old: BIG_VALUE(1000), + __txn_newer: BIG_VALUE(9999) + }); + const resCookies = makeResponseCookies(); + + await store.save(resCookies, makeTransactionState("newstate"), reqCookies); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0][0]).toMatch(/\[auth0\] Evicted/); + warnSpy.mockRestore(); + }); + it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); @@ -236,35 +253,37 @@ describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () // Older cookie evicted first — present as a deletion tombstone (maxAge 0). expect(resCookies.get(`__txn_${olderState}`)?.maxAge).toBe(0); - // Newer cookie untouched — eviction stopped after freeing enough. Since it - // was never deleted it must NOT appear as a tombstone on the response; it - // may be absent (untouched) but must never be present with maxAge 0. - const newerCookie = resCookies.get(`__txn_${newerState}`); - if (newerCookie !== undefined) { - expect(newerCookie.maxAge).not.toBe(0); - } + // Newer cookie untouched — eviction stopped after freeing enough. It must + // not appear on the response at all (not even as a deletion tombstone). + expect(resCookies.get(`__txn_${newerState}`)).toBeUndefined(); // New cookie written expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); - it("evicts oldest login cookies first (FIFO by timestamp)", async () => { + it("evicts the two oldest first when three cookies must be freed (FIFO order)", async () => { const store = new TransactionStore({ secret }); - const olderState = "older"; - const newerState = "newer"; - // Older timestamp should be evicted first once the limit is crossed. + // Three cookies all sized so the total exceeds 3500 bytes and two must be + // evicted before the new one can be written within the cap. + const oldestState = "oldest"; + const middleState = "middle"; + const newestState = "newest"; const reqCookies = makeRequestCookies({ - [`__txn_${olderState}`]: BIG_VALUE(1000), - [`__txn_${newerState}`]: BIG_VALUE(9999) + [`__txn_${oldestState}`]: BIG_VALUE(1000), // ts=1000 — evicted first + [`__txn_${middleState}`]: BIG_VALUE(5000), // ts=5000 — evicted second + [`__txn_${newestState}`]: BIG_VALUE(9999) // ts=9999 — must survive }); const resCookies = makeResponseCookies(); const newState = "latest"; await store.save(resCookies, makeTransactionState(newState), reqCookies); - // Older cookie evicted first - expect(resCookies.get(`__txn_${olderState}`)?.maxAge).toBe(0); - // New cookie written + // Oldest two evicted in timestamp order. + expect(resCookies.get(`__txn_${oldestState}`)?.maxAge).toBe(0); + expect(resCookies.get(`__txn_${middleState}`)?.maxAge).toBe(0); + // Newest existing cookie untouched — must not appear on the response. + expect(resCookies.get(`__txn_${newestState}`)).toBeUndefined(); + // New cookie written. expect(resCookies.get(`__txn_${newState}`)?.value).toBeTruthy(); }); @@ -659,13 +678,9 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( // Completing cookie deleted expect(callbackRes.cookies.get(`__txn_${state}`)?.maxAge).toBe(0); - // Tab B real login cookie must NOT be deleted — it may be absent from the - // response (never touched) but must never appear as a deletion tombstone - // (present with maxAge 0 and an empty value). - const tabB = callbackRes.cookies.get("__txn_tabB"); - if (tabB !== undefined) { - expect(tabB.maxAge).not.toBe(0); - } + // Tab B real login cookie must NOT be deleted — it must not appear on the + // response at all (not even as a deletion tombstone). + expect(callbackRes.cookies.get("__txn_tabB")).toBeUndefined(); // Session written expect(callbackRes.cookies.get("__session")?.value).toBeTruthy(); }); diff --git a/src/test/utils.ts b/src/test/utils.ts index 652cc5cd8..d41700ce0 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -8,7 +8,6 @@ export async function generateSecret(length: number) { /** * Strip the value prefix that TransactionStore encodes in cookie values. - * "p:{jwe}" → prefetch cookie — strips "p:" prefix * "{ts}:{jwe}" → real login — strips "{ts}:" prefix * "{jwe}" → legacy (no prefix) — returned as-is * From 169026d7a7e00dcce6b1de82dc01688dea374b7e Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Wed, 12 Aug 2026 12:57:21 +0530 Subject: [PATCH 31/36] fix: improving test coverage on stateless session --- .../session/stateless-session-store.test.ts | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 332122614..1964cd717 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -1012,6 +1012,210 @@ describe("Stateless Session Store", async () => { }); }); + describe("set — connection token sets (__FC_N cookies)", async () => { + const baseSession = (createdAt: number): SessionData => ({ + user: { sub: "user_123" }, + tokenSet: { + accessToken: "at_123", + refreshToken: "rt_123", + expiresAt: 9999999999 + }, + internal: { sid: "sid", createdAt } + }); + + it("writes one __FC_N cookie per connection token set", async () => { + const secret = await generateSecret(32); + const session: SessionData = { + ...baseSession(Math.floor(Date.now() / 1000)), + connectionTokenSets: [ + { + connection: "google-oauth2", + accessToken: "fc_g", + expiresAt: 9999999999 + }, + { connection: "github", accessToken: "fc_gh", expiresAt: 9999999999 } + ] + }; + const reqCookies = new RequestCookies(new Headers()); + const resCookies = new ResponseCookies(new Headers()); + const store = new StatelessSessionStore({ secret }); + + await store.set(reqCookies, resCookies, session); + + expect(resCookies.get("__FC_0")?.value).toBeTruthy(); + expect(resCookies.get("__FC_1")?.value).toBeTruthy(); + // Session cookie must not contain the connectionTokenSets payload. + expect(resCookies.get("__FC_2")).toBeUndefined(); + }); + + it("round-trips: get() reads back what set() wrote", async () => { + const secret = await generateSecret(32); + const createdAt = Math.floor(Date.now() / 1000); + const googleTokenSet = { + connection: "google-oauth2", + accessToken: "fc_g", + expiresAt: 9999999999 + }; + const session: SessionData = { + ...baseSession(createdAt), + connectionTokenSets: [googleTokenSet] + }; + const reqCookies = new RequestCookies(new Headers()); + const resCookies = new ResponseCookies(new Headers()); + const store = new StatelessSessionStore({ secret }); + + await store.set(reqCookies, resCookies, session); + + // Promote the response cookies into the next request's cookies. + const nextHeaders = new Headers(); + resCookies + .getAll() + .filter((c) => (c.maxAge ?? 1) > 0) + .forEach((c) => nextHeaders.append("cookie", `${c.name}=${c.value}`)); + const nextReqCookies = new RequestCookies(nextHeaders); + + const result = await store.get(nextReqCookies); + expect(result?.connectionTokenSets).toEqual([ + expect.objectContaining(googleTokenSet) + ]); + }); + + it("does not write __FC_N cookies when connectionTokenSets is absent", async () => { + const secret = await generateSecret(32); + const session: SessionData = baseSession(Math.floor(Date.now() / 1000)); + const reqCookies = new RequestCookies(new Headers()); + const resCookies = new ResponseCookies(new Headers()); + const store = new StatelessSessionStore({ secret }); + + await store.set(reqCookies, resCookies, session); + + const fcCookies = resCookies + .getAll() + .filter((c) => c.name.startsWith("__FC_")); + expect(fcCookies).toHaveLength(0); + }); + + it("warns when an individual __FC_N cookie exceeds 4096 bytes", async () => { + const secret = await generateSecret(32); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const session: SessionData = { + ...baseSession(Math.floor(Date.now() / 1000)), + connectionTokenSets: [ + // A 4000-char accessToken produces a JWE well over 4096 encoded bytes. + { + connection: "google-oauth2", + accessToken: "x".repeat(4000), + expiresAt: 9999999999 + } + ] + }; + const store = new StatelessSessionStore({ secret }); + await store.set( + new RequestCookies(new Headers()), + new ResponseCookies(new Headers()), + session + ); + + const warned = warnSpy.mock.calls.some((c) => + String(c[0]).includes("__FC_0 cookie size exceeds") + ); + expect(warned).toBe(true); + } finally { + warnSpy.mockRestore(); + } + }); + }); + + describe("set — session size warning emitted only once per process", async () => { + it("warns on the first oversized set() and suppresses the second", async () => { + // Reset the module-level flag by re-importing a fresh module instance. + vi.resetModules(); + const { StatelessSessionStore: FreshStore } = + await import("./stateless-session-store.js"); + const secret = await generateSecret(32); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const store = new FreshStore({ secret }); + const bigSession = (createdAt: number): SessionData => { + const s: SessionData = { + user: { sub: "user_123" }, + tokenSet: { accessToken: "at", expiresAt: 9999999999 }, + internal: { sid: "sid", createdAt } + }; + (s.user as Record).bigClaim = "x".repeat(6000); + return s; + }; + + await store.set( + new RequestCookies(new Headers()), + new ResponseCookies(new Headers()), + bigSession(Math.floor(Date.now() / 1000)) + ); + await store.set( + new RequestCookies(new Headers()), + new ResponseCookies(new Headers()), + bigSession(Math.floor(Date.now() / 1000)) + ); + + const sessionSizeWarns = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("cookie size") + ); + expect(sessionSizeWarns).toHaveLength(1); + } finally { + warnSpy.mockRestore(); + vi.resetModules(); + } + }); + }); + + describe("get — corrupted __FC cookie is silently skipped", async () => { + it("excludes an __FC cookie whose JWE cannot be decrypted", async () => { + const secret = await generateSecret(32); + const session: SessionData = { + user: { sub: "user_123" }, + tokenSet: { accessToken: "at_123", expiresAt: 9999999999 }, + internal: { sid: "sid", createdAt: Math.floor(Date.now() / 1000) } + }; + const expiration = Math.floor(Date.now() / 1000 + 3600); + const validJwe = await encrypt(session, secret, expiration); + + const headers = new Headers(); + headers.append("cookie", `__session=${validJwe};__FC_0=not-a-valid-jwe`); + const reqCookies = new RequestCookies(headers); + const store = new StatelessSessionStore({ secret }); + + const result = await store.get(reqCookies); + + // Session itself is intact; the bad FC cookie is dropped, not throwing. + expect(result?.user.sub).toBe("user_123"); + expect(result?.connectionTokenSets).toBeUndefined(); + }); + }); + + describe("delete — clears __FC_N connection-token cookies", async () => { + it("deletes all __FC_N cookies present in the request", async () => { + const secret = await generateSecret(32); + const expiration = Math.floor(Date.now() / 1000 + 3600); + const fakeJwe = await encrypt( + { connection: "google-oauth2", accessToken: "fc_g", expiresAt: 1 }, + secret, + expiration + ); + + const headers = new Headers(); + headers.append("cookie", `__FC_0=${fakeJwe};__FC_1=${fakeJwe}`); + const reqCookies = new RequestCookies(headers); + const resCookies = new ResponseCookies(new Headers()); + const store = new StatelessSessionStore({ secret }); + + await store.delete(reqCookies, resCookies); + + expect(resCookies.get("__FC_0")?.maxAge).toBe(0); + expect(resCookies.get("__FC_1")?.maxAge).toBe(0); + }); + }); + describe("delete", async () => { it("should remove the cookie", async () => { const secret = await generateSecret(32); From a8484c4f3ae7b4a7641a19a574874fc6ac77ae4f Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 20 Aug 2026 11:17:32 +0530 Subject: [PATCH 32/36] fix: addressing review comments --- EXAMPLES.md | 30 ++-- README.md | 2 +- src/server/auth-client.ts | 8 +- src/server/passwordless-server.flow.test.ts | 9 +- src/server/session/stateless-session-store.ts | 8 +- src/server/transaction-store.ts | 40 ++++- src/server/txn-cookie-accumulation.test.ts | 155 +++++------------- src/utils/request.test.ts | 91 +++++++++- src/utils/request.ts | 21 ++- 9 files changed, 213 insertions(+), 151 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 2fc6d7fb8..57820454b 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -242,7 +242,7 @@ The second option is through the query parameters to the `/auth/login` endpoint ``` > [!NOTE] -> Link to your login route with a plain `` tag (as shown above) or `` — never ``. A prefetched `` starts a login flow that never completes, accumulating transaction cookies. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). +> A default `` is safe — the SDK returns `204 No Content` on AUTO prefetches without writing a transaction cookie. Avoid `` (FULL prefetch), which is indistinguishable from a real navigation server-side. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). ### Social Login @@ -578,7 +578,7 @@ export async function middleware(request: NextRequest) { ## Protecting a Server-Side Rendered (SSR) Page > [!TIP] -> Prefer `withPageAuthRequired` (below) over redirecting to `/auth/login` from middleware. Its redirect happens inside the render and is not followed during a Next.js prefetch, so no transaction cookie is written for prefetched protected pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). +> Prefer `withPageAuthRequired` (below) over redirecting to `/auth/login` from middleware for protected pages. When a prefetch follows the redirect to `/auth/login`, the SDK returns `204 No Content` (no transaction cookie written). A middleware redirect achieves the same result, but `withPageAuthRequired` keeps the auth logic co-located with the page. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). #### Page Router @@ -623,7 +623,7 @@ export default auth0.withPageAuthRequired( To protect a Client-Side Rendered (CSR) page, you can use the `withPageAuthRequired` higher-order function. Requests to `/profile` without a valid session cookie will be redirected to the login page. > [!TIP] -> Using `withPageAuthRequired` (rather than a middleware redirect to `/auth/login`) also avoids transaction-cookie accumulation on prefetched pages. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). +> Using `withPageAuthRequired` (rather than a middleware redirect to `/auth/login`) keeps auth logic co-located with the page. Prefetches that follow the redirect to `/auth/login` are handled by the SDK's `204` guard, so no transaction cookie is written. See [Preventing "431 Request Header Fields Too Large" Errors](#preventing-431-request-header-fields-too-large-errors). ```tsx // app/profile/page.tsx @@ -4168,37 +4168,41 @@ If your app shows `431 Request Header Fields Too Large` errors, `__txn_*` cookie Even with the automatic protections above, follow these two practices so login flows are only started by real user navigation: -**1. Do not use ``. Use a plain `` tag or ``.** +**1. Avoid ``.** -Next.js prefetches `` targets on hover or when they scroll into view. A prefetch of `/auth/login` starts a login flow (writing a `__txn_*` cookie) that the user never completes, since the prefetched response is discarded. Prevent it by not prefetching the login route: +A default `` (AUTO prefetch) is safe — the SDK detects the prefetch header and returns `204 No Content` without writing a transaction cookie. However, `` triggers a FULL prefetch which sends no detectable prefetch header, so the SDK cannot distinguish it from a real navigation and will start a login flow. Use a plain `` tag or `` if you need to be explicit: ```tsx -// ✅ Do — a plain anchor never prefetches +// ✅ Safe — default Link, AUTO prefetch is caught by the 204 guard +Sign In + +// ✅ Safe — plain anchor never prefetches Sign In -// ✅ Do — Link with prefetch disabled +// ✅ Safe — prefetch explicitly disabled Sign In -// ❌ Don't — this prefetches /auth/login and writes a __txn_* cookie on hover/scroll -Sign In +// ❌ Avoid — FULL prefetch is indistinguishable from a real navigation server-side + + Sign In + ``` **2. Prefer `withPageAuthRequired` over middleware redirects to protect pages.** -`withPageAuthRequired` redirects to the login route from inside the React Server Component render. Next.js does **not** follow that redirect during a prefetch, so `handleLogin` is never called and no `__txn_*` cookie is written for prefetched protected pages. A middleware redirect to `/auth/login`, by contrast, is followed on prefetch of a protected page while the user is logged out — each prefetch then writes a transaction cookie. +`withPageAuthRequired` redirects to the login route from inside the React Server Component render. When a prefetch follows that redirect to `/auth/login`, the SDK returns `204 No Content` — so `handleLogin` is never called and no `__txn_*` cookie is written. A middleware redirect to `/auth/login` behaves the same way: prefetches that follow it are also caught by the `204` guard. The preference for `withPageAuthRequired` is about keeping auth logic co-located with the page, not a difference in prefetch behaviour. ```tsx -// ✅ Preferred — redirect happens in RSC render, not followed on prefetch +// ✅ Preferred — auth logic co-located with the page; prefetches caught by the 204 guard export default auth0.withPageAuthRequired(async function Page() { return
Protected content
; }, { returnTo: "/protected" }); ``` ```ts -// ⚠️ Middleware redirect — followed on prefetch of a protected page while -// logged out, writing a __txn_* cookie for a flow that never completes. +// ✅ Also safe — prefetches following this redirect are caught by the 204 guard export async function middleware(request: NextRequest) { const session = await auth0.getSession(request); if (!session) { diff --git a/README.md b/README.md index 149df3440..4291ef160 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ export default async function Home() { ``` > [!IMPORTANT] -> Link to the login route with a plain `` tag or `` — do not use ``. A prefetched `` starts a login flow that never completes, accumulating transaction cookies until requests fail with `431 Request Header Fields Too Large`. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. +> A default `` is safe — the SDK detects the AUTO prefetch header and returns `204 No Content` without writing a transaction cookie. Avoid `` (FULL prefetch): it sends no detectable prefetch header, so the SDK cannot distinguish it from a real navigation and will start a login flow. Use a plain `` tag or `` if you need to be safe across all prefetch modes. See [Preventing "431 Request Header Fields Too Large" Errors](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#preventing-431-request-header-fields-too-large-errors) for details. ## Customizing the client diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index b3137f3d2..5050184f6 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -678,7 +678,13 @@ export class AuthClient { // non-navigational requests, avoiding polluting auth-failure telemetry // and access logs. Next.js discards prefetch responses regardless, so // behavior is unaffected. - return new NextResponse(null, { status: 204 }); + // Cache-Control: no-store prevents CDNs and reverse proxies from caching + // this 204 — RFC 9111 makes 204 heuristically cacheable without an explicit + // directive, so a cached 204 would silently break real login navigations. + return new NextResponse(null, { + status: 204, + headers: { "Cache-Control": "no-store" } + }); } return this.handleLogin(req); } else if (method === "GET" && sanitizedPathname === this.routes.logout) { diff --git a/src/server/passwordless-server.flow.test.ts b/src/server/passwordless-server.flow.test.ts index df02bbd37..da7b9b36f 100644 --- a/src/server/passwordless-server.flow.test.ts +++ b/src/server/passwordless-server.flow.test.ts @@ -10,7 +10,7 @@ import { getDefaultRoutes, setupMswLifecycle } from "../test/defaults.js"; -import { generateSecret } from "../test/utils.js"; +import { generateSecret, stripTransactionValuePrefix } from "../test/utils.js"; import type { SessionData } from "../types/index.js"; import { generateDpopKeyPair } from "../utils/dpopRetry.js"; import { AuthClientProvider } from "./auth-client-provider.js"; @@ -268,12 +268,7 @@ describe("AuthClient passwordless methods", () => { const state = authParams.state as string; const txnCookie = resCookies.get(`__txn_${state}`); expect(txnCookie).toBeDefined(); - // Strip the {ts}: prefix added by the transaction store before decrypting - const colonIdx = txnCookie!.value.indexOf(":"); - const jweValue = - colonIdx !== -1 - ? txnCookie!.value.slice(colonIdx + 1) - : txnCookie!.value; + const jweValue = stripTransactionValuePrefix(txnCookie!.value); const { payload } = (await decrypt( jweValue, secret diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index 3d82bf773..5a0589cd8 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -20,9 +20,11 @@ import { // Total encoded session-cookie size (across all `__session` chunks) above which // we warn. A large session is the main remaining cause of `431 Request Header // Fields Too Large`, since — unlike transaction cookies — the session is not -// evicted. 4096 bytes mirrors the per-cookie limit browsers guarantee and is a -// good "trim your claims or go stateful" signal well before typical 8 KB proxy -// header limits are hit. +// evicted. 4096 bytes is a conservative threshold: at this size the session +// alone is large, and combined with transaction, connection-token, and +// application cookies the total `Cookie` header can exceed typical 8 KB proxy +// limits. Firing early gives developers a clear "trim your claims or go +// stateful" signal before requests start failing. const SESSION_COOKIE_SIZE_WARN_BYTES = 4096; // Under rolling sessions, set() runs on ~every authenticated request, so a diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 3bc2c0c2b..8a6715d7f 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -1,11 +1,12 @@ import type * as jose from "jose"; +import { InvalidConfigurationError } from "../errors/index.js"; import { RESPONSE_TYPES } from "../types/index.js"; import * as cookies from "./cookies.js"; const TRANSACTION_COOKIE_PREFIX = "__txn_"; -// Default maximum total byte size of all transaction (`__txn_*`) cookies +// Maximum total byte size of all transaction (`__txn_*`) cookies // combined. When the accumulated size meets or exceeds this limit, the oldest // cookies are evicted (FIFO by creation timestamp) before a new one is written. // One JWE is ~450–555 bytes, so this allows ~6 concurrent in-flight logins — @@ -15,6 +16,11 @@ const TRANSACTION_COOKIE_PREFIX = "__txn_"; // of the deployment's header limit, which the SDK cannot know. const MAX_TRANSACTION_COOKIE_BYTES = 3500; +// Emit once per process — same reasoning as sessionSizeWarningEmitted in +// stateless-session-store.ts: prefetch/bot traffic hits this path repeatedly, +// so a per-eviction warn would spam logs. +let txnEvictionWarningEmitted = false; + export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -184,11 +190,26 @@ export class TransactionStore { ); // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. - // "{ts}:{jwe}" — no cookie name change, backward compatible with legacy bare "{jwe}". + // Format: "{ts}:{jwe}" — cookie name is unchanged. + // + // Rolling-deploy and rollback safety: get() ships in a prior backfill release + // that strips the "{ts}:" prefix before decrypting, so all pods can read both + // the old bare "{jwe}" and the new "{ts}:{jwe}" format before this write-side + // change is deployed. const ts = Math.floor(Date.now() / 1000); const newCookieName = this.getTransactionCookieName(transactionState.state); const newCookieValue = `${ts}:${jwe}`; + const newCookieBytes = new TextEncoder().encode( + `${newCookieName}=${newCookieValue}` + ).length; + if (newCookieBytes >= MAX_TRANSACTION_COOKIE_BYTES) { + throw new InvalidConfigurationError( + `The transaction cookie is ${newCookieBytes} bytes, which exceeds the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. ` + + `This is usually caused by a very long returnTo URL. Shorten the returnTo value.` + ); + } + // Evict oldest transaction cookies FIFO before writing the new one, so the // accumulated `__txn_*` cookies stay under the fixed byte limit. Only // transaction cookies are measured/deleted — the session and other cookies @@ -272,12 +293,15 @@ export class TransactionStore { if (freed >= target) break; } - console.warn( - `[auth0] Evicted the oldest transaction cookie(s) — projected total size ${projectedBytes} bytes ` + - `reached the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + - `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + - `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` - ); + if (!txnEvictionWarningEmitted) { + txnEvictionWarningEmitted = true; + console.warn( + `[auth0] Evicted the oldest transaction cookie(s) — projected total size ${projectedBytes} bytes ` + + `reached the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` + ); + } } /** diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index bf289415a..2b0753e27 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -3,10 +3,10 @@ import * as jose from "jose"; import * as oauth from "oauth4webapi"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InvalidConfigurationError } from "../errors/index.js"; import { getDefaultRoutes } from "../test/defaults.js"; import { generateSecret } from "../test/utils.js"; import { RESPONSE_TYPES } from "../types/connected-accounts.js"; -import { isNonNavigationalRequest } from "../utils/request.js"; import { AuthClient } from "./auth-client.js"; import { RequestCookies, ResponseCookies } from "./cookies.js"; import { StatelessSessionStore } from "./session/stateless-session-store.js"; @@ -62,91 +62,7 @@ const makeResponseCookies = (): ResponseCookies => { }; // --------------------------------------------------------------------------- -// Fix 1 — isNonNavigationalRequest -// --------------------------------------------------------------------------- - -describe("Fix 1 — isNonNavigationalRequest()", () => { - const makeReq = (headers: Record) => { - const req = new NextRequest("http://localhost:3000/auth/login"); - Object.entries(headers).forEach(([k, v]) => req.headers.set(k, v)); - return req; - }; - - describe("known prefetch headers — positive detection only", () => { - it("returns true when next-router-prefetch is 1", () => { - expect( - isNonNavigationalRequest(makeReq({ "next-router-prefetch": "1" })) - ).toBe(true); - }); - - it("returns true when purpose is prefetch", () => { - expect(isNonNavigationalRequest(makeReq({ purpose: "prefetch" }))).toBe( - true - ); - }); - - it("returns true when sec-purpose is prefetch", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-purpose": "prefetch" })) - ).toBe(true); - }); - - it("returns true when sec-purpose is prefetch;prerender (Speculation Rules)", () => { - expect( - isNonNavigationalRequest( - makeReq({ "sec-purpose": "prefetch;prerender" }) - ) - ).toBe(true); - }); - - it("returns true when x-middleware-prefetch is 1", () => { - expect( - isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) - ).toBe(true); - }); - }); - - describe("requests that must not be blocked", () => { - it("returns false for plain navigation with no prefetch headers", () => { - expect(isNonNavigationalRequest(makeReq({ accept: "text/html" }))).toBe( - false - ); - }); - - it("returns false for accept: text/x-component — real RSC navigation must not be blocked", () => { - // text/x-component is sent by ALL App Router RSC requests, including a - // genuine client-side click — not just prefetches. - expect( - isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) - ).toBe(false); - }); - - it("returns false for sec-fetch-mode: navigate", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) - ).toBe(false); - }); - - it("returns false for sec-fetch-mode: cors — legitimate fetch()/XHR must not be blocked", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) - ).toBe(false); - }); - - it("returns false for sec-fetch-mode: same-origin — legitimate fetch()/XHR must not be blocked", () => { - expect( - isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) - ).toBe(false); - }); - - it("returns false when no headers present", () => { - expect(isNonNavigationalRequest(makeReq({}))).toBe(false); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Fix 2 — transaction cookie eviction in TransactionStore.save() +// Transaction cookie eviction in TransactionStore.save() // The byte limit is fixed at 3500 bytes and not configurable. Tests exercise it // by building transaction cookies whose combined size crosses that threshold. // --------------------------------------------------------------------------- @@ -155,28 +71,53 @@ describe("Fix 1 — isNonNavigationalRequest()", () => { // fixed 3500-byte limit but one does not (~1900 bytes of value each). const BIG_VALUE = (ts: number) => `${ts}:${"j".repeat(1900)}`; -describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () => { +describe("transaction cookie eviction in TransactionStore.save()", () => { let secret: string; beforeEach(async () => { secret = await generateSecret(32); }); - it("logs a console.warn when eviction fires", async () => { - const store = new TransactionStore({ secret }); + it("logs a console.warn once per process when eviction fires", async () => { + vi.resetModules(); + const { TransactionStore: FreshStore } = + await import("./transaction-store.js"); + const store = new FreshStore({ secret }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const reqCookies = makeRequestCookies({ - __txn_old: BIG_VALUE(1000), - __txn_newer: BIG_VALUE(9999) - }); - const resCookies = makeResponseCookies(); + const bigCookies = () => + makeRequestCookies({ + __txn_old: BIG_VALUE(1000), + __txn_newer: BIG_VALUE(9999) + }); - await store.save(resCookies, makeTransactionState("newstate"), reqCookies); + await store.save( + makeResponseCookies(), + makeTransactionState("s1"), + bigCookies() + ); + await store.save( + makeResponseCookies(), + makeTransactionState("s2"), + bigCookies() + ); - expect(warnSpy).toHaveBeenCalledOnce(); - expect(warnSpy.mock.calls[0][0]).toMatch(/\[auth0\] Evicted/); + const evictionWarns = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("[auth0] Evicted") + ); + expect(evictionWarns).toHaveLength(1); warnSpy.mockRestore(); + vi.resetModules(); + }); + + it("throws InvalidConfigurationError when the new cookie alone exceeds the cap", async () => { + const store = new TransactionStore({ secret }); + await expect( + store.save( + makeResponseCookies(), + makeTransactionState("bigstate", { returnTo: "/?" + "x".repeat(4000) }) + ) + ).rejects.toThrow(InvalidConfigurationError); }); it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { @@ -339,17 +280,6 @@ describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () expect(resCookies.get("__txn_other")).toBeUndefined(); }); - it("does not expose maxSizeBytes as a configurable option", () => { - // Type-level guarantee that the option was removed; passing it is a no-op - // and the fixed limit still governs eviction. - const store = new TransactionStore({ - secret, - // @ts-expect-error maxSizeBytes is no longer a supported option - cookieOptions: { maxSizeBytes: 1 } - }); - expect(store).toBeInstanceOf(TransactionStore); - }); - it("cookie value is encoded as '{ts}:{jwe}'", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); @@ -397,7 +327,7 @@ describe("Fix 2 — transaction cookie eviction in TransactionStore.save()", () // Fix 3 — Dormant early-return removed for enableParallelTransactions: false // --------------------------------------------------------------------------- -describe("Fix 3 — No lock-out in single-transaction mode", () => { +describe("single-transaction mode does not lock out concurrent logins", () => { let secret: string; beforeEach(async () => { @@ -463,7 +393,7 @@ describe("Fix 3 — No lock-out in single-transaction mode", () => { // Fix 4 — Callback cleanup: delete only the completing flow's cookie // --------------------------------------------------------------------------- -describe("Fix 4 — callback cleanup: delete(state)", () => { +describe("callback cleanup: delete(state) removes only the completing cookie", () => { let secret: string; beforeEach(async () => { @@ -621,7 +551,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( }); }); - it("Fix 1 — known prefetch header returns 204 and no __txn_* cookie is written", async () => { + it("Fix 1 — known prefetch header returns 204 with no-store and no __txn_* cookie is written", async () => { const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { headers: { "next-router-prefetch": "1" } @@ -630,6 +560,9 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( const res = await authClient.handler(req); expect(res.status).toBe(204); + // Cache-Control: no-store prevents CDNs/proxies from caching this 204 and + // serving it for real login navigations. + expect(res.headers.get("cache-control")).toBe("no-store"); const txnCookies = res.cookies .getAll() .filter((c) => c.name.startsWith("__txn_") && c.maxAge !== 0); diff --git a/src/utils/request.test.ts b/src/utils/request.test.ts index 2074ad3a5..5094b8d3c 100644 --- a/src/utils/request.test.ts +++ b/src/utils/request.test.ts @@ -1,6 +1,95 @@ +import { NextRequest } from "next/server.js"; import { describe, expect, it } from "vitest"; -import { isRequest } from "./request.js"; +import { isNonNavigationalRequest, isRequest } from "./request.js"; + +describe("isNonNavigationalRequest", () => { + const makeReq = (headers: Record) => { + const req = new NextRequest("http://localhost:3000/auth/login"); + Object.entries(headers).forEach(([k, v]) => req.headers.set(k, v)); + return req; + }; + + describe("known prefetch headers — positive detection only", () => { + it("returns true when next-router-prefetch is 1 (Next 15 AUTO prefetch)", () => { + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "1" })) + ).toBe(true); + }); + + it("returns true when next-router-prefetch is 2 (Next 16 runtime prefetch)", () => { + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "2" })) + ).toBe(true); + }); + + it("returns true when purpose is prefetch", () => { + expect(isNonNavigationalRequest(makeReq({ purpose: "prefetch" }))).toBe( + true + ); + }); + + it("returns true when sec-purpose is prefetch", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-purpose": "prefetch" })) + ).toBe(true); + }); + + it("returns true when x-middleware-prefetch is 1 (Pages Router)", () => { + expect( + isNonNavigationalRequest(makeReq({ "x-middleware-prefetch": "1" })) + ).toBe(true); + }); + }); + + describe("requests that must not be blocked", () => { + it("returns false for plain navigation with no prefetch headers", () => { + expect(isNonNavigationalRequest(makeReq({ accept: "text/html" }))).toBe( + false + ); + }); + + it("returns false for accept: text/x-component — real RSC navigation must not be blocked", () => { + // text/x-component is sent by ALL App Router RSC requests, including a + // genuine client-side click — not just prefetches. + expect( + isNonNavigationalRequest(makeReq({ accept: "text/x-component" })) + ).toBe(false); + }); + + it("returns false for sec-fetch-mode: navigate", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "navigate" })) + ).toBe(false); + }); + + it("returns false for sec-fetch-mode: cors — legitimate fetch()/XHR must not be blocked", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "cors" })) + ).toBe(false); + }); + + it("returns false for sec-fetch-mode: same-origin — legitimate fetch()/XHR must not be blocked", () => { + expect( + isNonNavigationalRequest(makeReq({ "sec-fetch-mode": "same-origin" })) + ).toBe(false); + }); + + it("returns false for sec-purpose: prefetch;prerender — Speculation Rules prerender must not be blocked", () => { + // A prerender is the real navigation executed ahead of time. Blocking it + // with 204 would silently swallow the login click when the user navigates. + expect( + isNonNavigationalRequest( + makeReq({ "sec-purpose": "prefetch;prerender" }) + ) + ).toBe(false); + }); + + it("returns false when no headers present", () => { + expect(isNonNavigationalRequest(makeReq({}))).toBe(false); + }); + }); +}); describe("isRequest", () => { it("returns true for a Fetch Request instance", () => { diff --git a/src/utils/request.ts b/src/utils/request.ts index e7cfa36cb..b466fedf3 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -25,22 +25,31 @@ export const isRequest = (req: Req): req is Request | NextRequest => { * - `next-router-prefetch` / `x-middleware-prefetch` — Next.js prefetch markers * - `purpose` / `sec-purpose` = `prefetch` — W3C/browser prefetch hints * - * `sec-purpose` is matched with `includes("prefetch")` rather than an exact - * equality: Chromium's Speculation Rules API sends `Sec-Purpose: prefetch;prerender` - * for prerender hints, which is still a machine request that never completes OAuth. - * `prefetch` only appears as a structured purpose token, so the substring match is safe. + * `sec-purpose` matching: + * - `prefetch` alone → prefetch, block it. + * - `prefetch;prerender` → Speculation Rules prerender. The browser activates + * this as the real navigation when the user clicks, so blocking it with 204 + * would silently swallow the login click. Excluded by requiring the value + * does not contain `prerender`. * * Intentionally excludes: * - `sec-fetch-mode` — also set on legitimate fetch()/XHR calls to /auth/login. * - `accept: text/x-component` — sent by ALL App Router RSC requests, including * real client-side `` navigations (e.g. ``), so * matching it would 401 genuine login clicks, not just prefetches. + * + * Note: `x-middleware-prefetch` is a Pages Router signal only + * (`shared/lib/router/router.js`). It never fires on App Router prefetch paths, + * but is kept for Pages Router coverage. */ export const isNonNavigationalRequest = (req: NextRequest): boolean => { + const secPurpose = req.headers.get("sec-purpose") ?? ""; return ( - req.headers.get("next-router-prefetch") === "1" || + // next-router-prefetch: "1" = AUTO prefetch (Next 15); "2" = runtime + // prefetch (Next 16+). has() covers both values. + req.headers.has("next-router-prefetch") || req.headers.get("purpose") === "prefetch" || - (req.headers.get("sec-purpose")?.includes("prefetch") ?? false) || + (secPurpose.includes("prefetch") && !secPurpose.includes("prerender")) || req.headers.get("x-middleware-prefetch") === "1" ); }; From dda2ea6284319490966b5a970c91e71fc33a41f8 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Thu, 20 Aug 2026 21:30:32 +0530 Subject: [PATCH 33/36] fix: address session-cookie accumulation review findings --- src/server/auth-client.ts | 7 ++-- src/server/chunked-cookies.test.ts | 43 +++++++++++++++++++++++++ src/server/cookies.ts | 51 ++++++++++++++++++++---------- 3 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 259d52054..d0c0ff1f3 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -4940,8 +4940,11 @@ export class AuthClient { newAccessTokenSet.requestedScope ?? newAccessTokenSet.scope ); // Remove ALL existing entries for this audience + scope (not just the first) - // so sessions that accumulated duplicates before this fix deployed are fully - // compacted on the next step-up, not left with N-1 stale entries. + // so sessions that accumulated duplicates before this fix deployed are + // compacted on the next step-up. Best-effort for legacy entries: those have + // no `requestedScope` and fall back to the granted `scope`, so if the server + // reduced scope, a legacy entry's fallback key may not match a new request's + // requestedScope key and it will be retained alongside the new entry. session.accessTokens = session.accessTokens.filter( (t) => !( diff --git a/src/server/chunked-cookies.test.ts b/src/server/chunked-cookies.test.ts index c050bca7a..ac8252067 100644 --- a/src/server/chunked-cookies.test.ts +++ b/src/server/chunked-cookies.test.ts @@ -369,6 +369,33 @@ describe("Chunked Cookie Utils", () => { expect(reqCookies.delete).toHaveBeenCalledWith(name); }); + it("clears high-index chunks (>= MAX_CHUNKS) when a session that grew past MAX_CHUNKS shrinks", () => { + // Regression: a session that once wrote __session__5 (or higher) and + // then shrinks would otherwise leave the high-index chunk orphaned, + // because the deterministic sweep only covers __0..__4. The snapshot + // must extend the sweep to cover indices seen in the request. + const name = "__session"; + const options = { path: "/" } as CookieOptions; + + // Seed a 6-chunk state (as if a prior write produced them). + for (let i = 0; i < 6; i++) { + cookieStore.set(`${name}__${i}`, `chunk${i}`); + } + + // Shrink: write a value that fits in ~2 chunks. + const shrunkValue = "a".repeat(7000); + setChunkedCookie(name, shrunkValue, options, reqCookies, resCookies); + + // The high-index chunk written before the shrink must be deleted, + // otherwise it stays in the browser and poisons subsequent reads. + expect(resCookies.set).toHaveBeenCalledWith( + `${name}__5`, + "", + expect.objectContaining({ maxAge: 0 }) + ); + expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__5`); + }); + // New tests for domain and transient options it("should set the domain property for a single cookie", () => { const name = "domainCookie"; @@ -740,6 +767,22 @@ describe("Chunked Cookie Utils", () => { maxAge: 0 }); }); + + it("deletes high-index chunks (>= MAX_CHUNKS) present in the request snapshot", () => { + // Regression: a session that once grew past MAX_CHUNKS would leave + // __session__5+ orphaned on logout, because the deterministic sweep + // only covers __0..__4. The snapshot must extend the sweep. + const name = "__session"; + for (let i = 0; i < 6; i++) { + cookieStore.set(`${name}__${i}`, `chunk${i}`); + } + + deleteChunkedCookie(name, reqCookies, resCookies); + + expect(resCookies.set).toHaveBeenCalledWith(`${name}__5`, "", { + maxAge: 0 + }); + }); }); describe("Edge Cases", () => { diff --git a/src/server/cookies.ts b/src/server/cookies.ts index f30b1b213..a58b12d4e 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -192,6 +192,23 @@ const getChunkedCookieIndex = ( * @param name - The base name of the cookies to retrieve. * @returns An array of cookies that have names starting with the specified prefix. */ +/** + * Returns the exclusive upper bound for chunk-cleanup loops: `max(MAX_CHUNKS, + * highestSeenIndex + 1)`. Guarantees the deterministic `__0..MAX_CHUNKS-1` sweep + * (needed for concurrency safety when a concurrent tab wrote a higher chunk not + * in this snapshot), while also clearing anything the current snapshot reveals + * above that range — so a session that once grew past `MAX_CHUNKS` and later + * shrinks does not leave orphaned high-index chunks in the browser. + */ +const getClearUpTo = (reqCookies: RequestCookies, name: string): number => { + let highestSeen = -1; + for (const cookie of getAllChunkedCookies(reqCookies, name)) { + const idx = getChunkedCookieIndex(cookie.name); + if (idx !== undefined && idx > highestSeen) highestSeen = idx; + } + return Math.max(MAX_CHUNKS, highestSeen + 1); +}; + const getAllChunkedCookies = ( reqCookies: RequestCookies, name: string, @@ -249,12 +266,12 @@ export function setChunkedCookie( reqCookies.set(name, value); // When we are writing a non-chunked cookie, remove any previously stored - // chunks for this cookie name. Delete a deterministic index range rather - // than scanning `reqCookies` — a concurrent request/tab may have written a - // higher-index chunk that this request's cookie snapshot does not include, - // which a snapshot-based scan would leave orphaned. The browser ignores - // deletions for cookies that do not exist. - for (let i = 0; i < MAX_CHUNKS; i++) { + // chunks for this cookie name. Sweep at least `__0..MAX_CHUNKS-1` (covers + // concurrent-tab writes not in this snapshot) and also anything higher the + // snapshot reveals (covers sessions that once grew past MAX_CHUNKS). The + // browser ignores deletions for cookies that do not exist. + const clearUpTo = getClearUpTo(reqCookies, name); + for (let i = 0; i < clearUpTo; i++) { const chunkName = `${name}${CHUNK_PREFIX}${i}`; deleteCookie(resCookies, chunkName, { path: finalOptions.path, @@ -286,12 +303,12 @@ export function setChunkedCookie( chunkIndex++; } - // Clear any now-unused higher-index chunks. Delete a deterministic range - // (`chunkIndex .. MAX_CHUNKS-1`) rather than scanning `reqCookies`: a - // concurrent request/tab may have written a higher-index chunk that this - // request's cookie snapshot does not include, which a snapshot-based scan - // would leave orphaned. The browser ignores deletions for absent cookies. - for (let i = chunkIndex; i < MAX_CHUNKS; i++) { + // Clear any now-unused higher-index chunks. Sweep at least up to + // `MAX_CHUNKS-1` (covers concurrent-tab writes not in this snapshot) and also + // anything higher the snapshot reveals (covers sessions that once grew past + // MAX_CHUNKS). The browser ignores deletions for absent cookies. + const clearUpTo = getClearUpTo(reqCookies, name); + for (let i = chunkIndex; i < clearUpTo; i++) { const chunkName = `${name}${CHUNK_PREFIX}${i}`; deleteCookie(resCookies, chunkName, { path: finalOptions.path, @@ -396,10 +413,12 @@ export function deleteChunkedCookie( return; } - // Delete a deterministic index range instead of scanning `reqCookies`, so a - // chunk written by a concurrent request/tab (absent from this request's - // snapshot) is still removed. The browser ignores deletions for absent cookies. - for (let i = 0; i < MAX_CHUNKS; i++) { + // Sweep at least `__0..MAX_CHUNKS-1` (covers concurrent-tab writes not in + // this snapshot) and also anything higher the snapshot reveals (covers + // sessions that once grew past MAX_CHUNKS). The browser ignores deletions + // for absent cookies. + const clearUpTo = getClearUpTo(reqCookies, name); + for (let i = 0; i < clearUpTo; i++) { deleteCookie(resCookies, `${name}${CHUNK_PREFIX}${i}`, options); } } From 47f573220929645beaaa9595df015fdb49787578 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Fri, 21 Aug 2026 21:50:37 +0530 Subject: [PATCH 34/36] fix: addressing few nit comments --- src/server/chunked-cookies.test.ts | 145 +++++++++++++++++++++++++++++ src/server/cookies.ts | 138 ++++++++++++++++----------- 2 files changed, 228 insertions(+), 55 deletions(-) diff --git a/src/server/chunked-cookies.test.ts b/src/server/chunked-cookies.test.ts index ac8252067..74acc757c 100644 --- a/src/server/chunked-cookies.test.ts +++ b/src/server/chunked-cookies.test.ts @@ -1189,3 +1189,148 @@ describe("Regression #2595: MCD backfill session chunking at boundary", () => { expect(cookieStore.has("__session__1")).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// End-to-end jar tests. These use the real RequestCookies / ResponseCookies +// implementations and apply Set-Cookie headers to a browser-like jar between +// writes, then assert on read via getChunkedCookie. This exercises the whole +// write → apply → read cycle, which is what a real user hits — as opposed to +// the mock-based tests above which only assert on `resCookies.set` spy calls. +// --------------------------------------------------------------------------- + +/** + * Minimal browser cookie jar: applies Set-Cookie writes from a `ResponseCookies` + * to an in-memory store (honouring `maxAge: 0` as deletion), and produces a + * fresh `RequestCookies` reflecting the current jar state for the next request. + */ +class BrowserJar { + private store = new Map(); + + apply(res: ResponseCookies) { + for (const c of res.getAll()) { + if (c.maxAge === 0 || c.value === "") { + this.store.delete(c.name); + } else { + this.store.set(c.name, c.value); + } + } + } + + req(): RequestCookies { + const headers = new Headers(); + const cookieHeader = Array.from(this.store) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + if (cookieHeader) headers.append("cookie", cookieHeader); + return new RequestCookies(headers); + } + + names(): string[] { + return Array.from(this.store.keys()).sort(); + } +} + +describe("Chunked Cookie — end-to-end jar", () => { + const OPTS: CookieOptions = { + path: "/", + httpOnly: true, + secure: true, + sameSite: "lax" as const, + maxAge: 3600 + }; + + const write = (jar: BrowserJar, name: string, value: string) => { + const res = new ResponseCookies(new Headers()); + setChunkedCookie(name, value, OPTS, jar.req(), res); + jar.apply(res); + return res; + }; + + it("shrinking a 6-chunk session leaves no high-index orphan and the value reads back", () => { + // Regression: before the getClearUpTo fix, a session that grew past + // MAX_CHUNKS (5) then shrank would leave __session__5 orphaned in the + // browser and getChunkedCookie would return undefined forever. + const jar = new BrowserJar(); + + // Write a value that requires 6 chunks (6 × 3500 = 21000 bytes). + const big = "a".repeat(21000); + write(jar, "__session", big); + expect(jar.names()).toEqual([ + "__session__0", + "__session__1", + "__session__2", + "__session__3", + "__session__4", + "__session__5" + ]); + + // Shrink to a value that fits in 2 chunks (~7000 bytes). + const small = "b".repeat(7000); + write(jar, "__session", small); + + // No high-index orphan. + expect(jar.names()).toEqual(["__session__0", "__session__1"]); + + // Read returns the full new value. + const read = getChunkedCookie("__session", jar.req()); + expect(read).toBe(small); + }); + + it("logout of a 6-chunk session clears every chunk", () => { + const jar = new BrowserJar(); + write(jar, "__session", "a".repeat(21000)); + expect(jar.names().length).toBe(6); + + const res = new ResponseCookies(new Headers()); + deleteChunkedCookie("__session", jar.req(), res, false, { + path: "/", + domain: undefined + }); + jar.apply(res); + + expect(jar.names()).toEqual([]); + expect(getChunkedCookie("__session", jar.req())).toBeUndefined(); + }); + + it("residual case: concurrent-tab write above MAX_CHUNKS not in snapshot self-heals on next write", () => { + // Tab A writes 6 chunks. Tab B starts from an older browser state showing + // only __0 and __1 (Tab A's __2..__5 haven't reached Tab B's request yet). + // Tab B writes a 2-chunk value. Because Tab B's snapshot doesn't reveal + // __session__5, the sweep stops at MAX_CHUNKS (5), and __5 survives. + // Read on THAT response is broken (getChunkedCookie sees indices [0,1,5], + // highestIndex=5, count mismatch → undefined). The next write on this + // connection sees __5 in its snapshot and cleans up. One bad response, + // self-healing — not the permanent login loop from before. + + const browserJar = new BrowserJar(); + // Simulate the eventual browser state after Tab A finished. + write(browserJar, "__session", "a".repeat(21000)); + + // Tab B's stale snapshot: only __0/__1 as seen when Tab B's request started. + const staleReqHeaders = new Headers(); + staleReqHeaders.append( + "cookie", + `__session__0=${browserJar.req().get("__session__0")!.value};` + + `__session__1=${browserJar.req().get("__session__1")!.value}` + ); + const staleReq = new RequestCookies(staleReqHeaders); + + // Tab B writes 2 chunks using its stale snapshot. + const tabBRes = new ResponseCookies(new Headers()); + setChunkedCookie("__session", "b".repeat(7000), OPTS, staleReq, tabBRes); + browserJar.apply(tabBRes); + + // __session__5 survives — Tab B never saw it. Confirmed orphan. + expect(browserJar.names()).toContain("__session__5"); + // The immediate read is broken. + expect(getChunkedCookie("__session", browserJar.req())).toBeUndefined(); + + // Next write on this connection SEES __5 in the snapshot and cleans up. + write(browserJar, "__session", "c".repeat(7000)); + + expect(browserJar.names()).toEqual(["__session__0", "__session__1"]); + expect(getChunkedCookie("__session", browserJar.req())).toBe( + "c".repeat(7000) + ); + }); +}); diff --git a/src/server/cookies.ts b/src/server/cookies.ts index a58b12d4e..b5f0c435c 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -157,12 +157,18 @@ const MAX_CHUNK_SIZE = 3500; // Slightly under 4KB const CHUNK_PREFIX = "__"; const CHUNK_INDEX_REGEX = new RegExp(`${CHUNK_PREFIX}(\\d+)$`); const LEGACY_CHUNK_INDEX_REGEX = /\.(\d+)$/; -// Upper bound on chunk indices to clear when a chunked cookie shrinks (or is -// replaced by a single cookie). 5 × 3500 = 17,500 bytes — far beyond any real -// session, so no valid chunk is ever missed. Deleting a deterministic index -// range instead of scanning `reqCookies` avoids leaving orphaned chunks when a -// concurrent request/tab wrote a higher-index chunk not present in this -// request's (stale) cookie snapshot. +// Minimum deterministic sweep range for chunk cleanup. Cleanup uses +// `getClearUpTo`, which sweeps at least `__0..MAX_CHUNKS-1` (this constant) and +// also anything higher the current request's snapshot reveals. The deterministic +// minimum covers the concurrent-write case within this range: a tab that wrote a +// chunk absent from this request's stale snapshot is still cleaned. The snapshot +// extension covers the shrink case: a session that once grew past this range +// and later shrinks has its high-index chunks removed instead of orphaned. +// Values above `MAX_CHUNKS` written by a concurrent tab and absent from this +// snapshot are the residual case — the next write on this connection sees them +// and cleans up (self-healing after one bad response). 5 × 3500 = 17,500 bytes +// covers the typical session envelope; sessions larger than that surface the +// oversized-session warning in `stateless-session-store.ts`. const MAX_CHUNKS = 5; /** @@ -186,19 +192,62 @@ const getChunkedCookieIndex = ( }; /** - * Retrieves all cookies from the request that have names starting with a specific prefix. + * Retrieves all cookies from the request that have names matching the exact + * `{name}{CHUNK_PREFIX}{digits}` shape (or `{name}.{digits}` for the legacy + * format). The regex is built once per shape at module load rather than per + * call — `setChunkedCookie` runs on every authenticated request under rolling + * sessions, so avoiding a fresh RegExp per call matters. * * @param reqCookies - The cookies from the request. * @param name - The base name of the cookies to retrieve. - * @returns An array of cookies that have names starting with the specified prefix. + * @returns An array of cookies matching the chunked-cookie shape. */ +const getAllChunkedCookies = ( + reqCookies: RequestCookies, + name: string, + isLegacyCookie?: boolean +): RequestCookie[] => { + const regex = isLegacyCookie + ? getLegacyChunkedCookieRegex(name) + : getChunkedCookieRegex(name); + return reqCookies.getAll().filter((cookie) => regex.test(cookie.name)); +}; + +// Per-name regex caches, populated on first use. Prevents rebuilding the same +// RegExp on every hot-path call. Names are bounded (session, legacy session, +// __FC cookies), so unbounded growth is not a concern. +const chunkedCookieRegexCache = new Map(); +const legacyChunkedCookieRegexCache = new Map(); + +const getChunkedCookieRegex = (name: string): RegExp => { + const cached = chunkedCookieRegexCache.get(name); + if (cached) return cached; + const regex = new RegExp(`^${name}${CHUNK_PREFIX}\\d+$`); + chunkedCookieRegexCache.set(name, regex); + return regex; +}; + +const getLegacyChunkedCookieRegex = (name: string): RegExp => { + const cached = legacyChunkedCookieRegexCache.get(name); + if (cached) return cached; + const regex = new RegExp(`^${name}${LEGACY_CHUNK_INDEX_REGEX.source}$`); + legacyChunkedCookieRegexCache.set(name, regex); + return regex; +}; + /** * Returns the exclusive upper bound for chunk-cleanup loops: `max(MAX_CHUNKS, - * highestSeenIndex + 1)`. Guarantees the deterministic `__0..MAX_CHUNKS-1` sweep - * (needed for concurrency safety when a concurrent tab wrote a higher chunk not - * in this snapshot), while also clearing anything the current snapshot reveals - * above that range — so a session that once grew past `MAX_CHUNKS` and later - * shrinks does not leave orphaned high-index chunks in the browser. + * highestSeenIndex + 1)`. Guarantees the deterministic `__0..MAX_CHUNKS-1` + * sweep (which covers concurrent-tab writes below `MAX_CHUNKS` that this + * request's snapshot missed), while also clearing anything the current + * snapshot reveals above that range — so a session that once grew past + * `MAX_CHUNKS` and later shrinks does not leave orphaned high-index chunks. + * + * Residual case: a concurrent tab that wrote `__session__N` with `N >= + * MAX_CHUNKS`, absent from THIS request's snapshot, is not cleared here. + * That single request's read returns `undefined` for the session. The next + * write on this connection sees `__N` in its snapshot and cleans it up + * (self-healing). Bounded to at most one bad response per orphan. */ const getClearUpTo = (reqCookies: RequestCookies, name: string): number => { let highestSeen = -1; @@ -209,21 +258,6 @@ const getClearUpTo = (reqCookies: RequestCookies, name: string): number => { return Math.max(MAX_CHUNKS, highestSeen + 1); }; -const getAllChunkedCookies = ( - reqCookies: RequestCookies, - name: string, - isLegacyCookie?: boolean -): RequestCookie[] => { - const chunkedCookieRegex = new RegExp( - isLegacyCookie - ? `^${name}${LEGACY_CHUNK_INDEX_REGEX.source}$` - : `^${name}${CHUNK_PREFIX}\\d+$` - ); - return reqCookies - .getAll() - .filter((cookie) => chunkedCookieRegex.test(cookie.name)); -}; - /** * Sets a cookie with the given name and value, splitting it into chunks if necessary. * @@ -259,6 +293,17 @@ export function setChunkedCookie( const valueBytes = encoder.encode(value).length; + // Hoist the delete-options object out of the loops. `setChunkedCookie` runs + // on every authenticated request under rolling sessions, so allocating one + // object per iteration adds up. + const deleteOptions = { + path: finalOptions.path, + domain: finalOptions.domain, + secure: finalOptions.secure, + sameSite: finalOptions.sameSite, + httpOnly: finalOptions.httpOnly + }; + // If value fits in a single cookie, set it directly if (valueBytes <= MAX_CHUNK_SIZE) { resCookies.set(name, value, finalOptions); @@ -267,19 +312,13 @@ export function setChunkedCookie( // When we are writing a non-chunked cookie, remove any previously stored // chunks for this cookie name. Sweep at least `__0..MAX_CHUNKS-1` (covers - // concurrent-tab writes not in this snapshot) and also anything higher the - // snapshot reveals (covers sessions that once grew past MAX_CHUNKS). The - // browser ignores deletions for cookies that do not exist. + // concurrent-tab writes below MAX_CHUNKS not in this snapshot) and also + // anything higher the snapshot reveals (covers sessions that once grew past + // MAX_CHUNKS). The browser ignores deletions for cookies that do not exist. const clearUpTo = getClearUpTo(reqCookies, name); for (let i = 0; i < clearUpTo; i++) { const chunkName = `${name}${CHUNK_PREFIX}${i}`; - deleteCookie(resCookies, chunkName, { - path: finalOptions.path, - domain: finalOptions.domain, - secure: finalOptions.secure, - sameSite: finalOptions.sameSite, - httpOnly: finalOptions.httpOnly - }); + deleteCookie(resCookies, chunkName, deleteOptions); reqCookies.delete(chunkName); } @@ -304,30 +343,19 @@ export function setChunkedCookie( } // Clear any now-unused higher-index chunks. Sweep at least up to - // `MAX_CHUNKS-1` (covers concurrent-tab writes not in this snapshot) and also - // anything higher the snapshot reveals (covers sessions that once grew past - // MAX_CHUNKS). The browser ignores deletions for absent cookies. + // `MAX_CHUNKS-1` (covers concurrent-tab writes below MAX_CHUNKS not in this + // snapshot) and also anything higher the snapshot reveals (covers sessions + // that once grew past MAX_CHUNKS). The browser ignores deletions for absent + // cookies. const clearUpTo = getClearUpTo(reqCookies, name); for (let i = chunkIndex; i < clearUpTo; i++) { const chunkName = `${name}${CHUNK_PREFIX}${i}`; - deleteCookie(resCookies, chunkName, { - path: finalOptions.path, - domain: finalOptions.domain, - secure: finalOptions.secure, - sameSite: finalOptions.sameSite, - httpOnly: finalOptions.httpOnly - }); + deleteCookie(resCookies, chunkName, deleteOptions); reqCookies.delete(chunkName); } // When we have written chunked cookies, we should remove the non-chunked cookie - deleteCookie(resCookies, name, { - path: finalOptions.path, - domain: finalOptions.domain, - secure: finalOptions.secure, - sameSite: finalOptions.sameSite, - httpOnly: finalOptions.httpOnly - }); + deleteCookie(resCookies, name, deleteOptions); reqCookies.delete(name); return totalBytes; From c5c51f7d6a54427380e5cfd7ca55ba05814af6e5 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Mon, 24 Aug 2026 18:04:45 +0530 Subject: [PATCH 35/36] fix: addressing review comments --- src/server/auth-client.ts | 22 +++-- src/server/session/stateless-session-store.ts | 21 +++-- src/server/transaction-store.ts | 69 +++++++++++---- src/server/txn-cookie-accumulation.test.ts | 88 +++++++++++++++---- src/utils/request.test.ts | 20 +++++ src/utils/request.ts | 13 ++- 6 files changed, 188 insertions(+), 45 deletions(-) diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 2beedcabf..2955556bd 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -199,7 +199,11 @@ import { FetcherMinimalConfig } from "./fetcher.js"; import { AbstractSessionStore } from "./session/abstract-session-store.js"; -import { TransactionState, TransactionStore } from "./transaction-store.js"; +import { + clampReturnTo, + TransactionState, + TransactionStore +} from "./transaction-store.js"; import { filterDefaultIdTokenClaims } from "./user.js"; export type BeforeSessionSavedHook = ( @@ -836,10 +840,12 @@ export class AuthClient { const sanitizedReturnTo = toSafeRedirect(options.returnTo, safeBaseUrl); if (sanitizedReturnTo) { - returnTo = + returnTo = clampReturnTo( sanitizedReturnTo.pathname + - sanitizedReturnTo.search + - sanitizedReturnTo.hash; + sanitizedReturnTo.search + + sanitizedReturnTo.hash, + this.signInReturnToPath + ); } } @@ -4157,10 +4163,12 @@ export class AuthClient { const sanitizedReturnTo = toSafeRedirect(options.returnTo, safeBaseUrl); if (sanitizedReturnTo) { - returnTo = + returnTo = clampReturnTo( sanitizedReturnTo.pathname + - sanitizedReturnTo.search + - sanitizedReturnTo.hash; + sanitizedReturnTo.search + + sanitizedReturnTo.hash, + this.signInReturnToPath + ); } } diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index a30c56e2b..a6f6ae457 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -27,6 +27,14 @@ import { // stateful" signal before requests start failing. const SESSION_COOKIE_SIZE_WARN_BYTES = 4096; +// Per-cookie size above which we warn for a single `__FC_*` connection-token +// cookie. Numerically the same as the session-total threshold above, but the +// meaning is different: 4096 bytes here is the per-cookie limit browsers are +// documented to guarantee. A single `__FC_*` cookie exceeding this may be +// rejected outright by some browsers, so the warning is per-cookie rather than +// per-request. +const FC_COOKIE_SIZE_WARN_BYTES = 4096; + // Under rolling sessions, set() runs on ~every authenticated request, so a // legitimately large-but-working session would otherwise log the size warning // on every request. Emit it once per process to keep the diagnostic without @@ -288,7 +296,7 @@ export class StatelessSessionStore extends AbstractSessionStore { // to enable read-after-write in the same request for middleware reqCookies.set(cookieName, cookieValue); - // check if the session cookie size exceeds 4096 bytes, and if so, log a warning + // Measure the encoded `Set-Cookie` string for the per-cookie size check. const cookieJarSizeTest = new cookies.ResponseCookies(new Headers()); cookieJarSizeTest.set(cookieName, cookieValue, { ...this.cookieConfig, @@ -297,11 +305,14 @@ export class StatelessSessionStore extends AbstractSessionStore { // storeInCookie only ever writes connection-token (`__FC_*`) cookies — the // session cookie is written (and size-checked) separately in set(). Warn if - // an individual connection-token cookie is large enough to risk browser or - // header limits. - if (new TextEncoder().encode(cookieJarSizeTest.toString()).length >= 4096) { + // an individual connection-token cookie exceeds the per-cookie limit + // browsers are documented to guarantee. + if ( + new TextEncoder().encode(cookieJarSizeTest.toString()).length >= + FC_COOKIE_SIZE_WARN_BYTES + ) { console.warn( - `The ${cookieName} cookie size exceeds 4096 bytes, which may cause issues in some browsers. ` + + `The ${cookieName} cookie size exceeds ${FC_COOKIE_SIZE_WARN_BYTES} bytes, which may cause issues in some browsers. ` + "You can use a stateful session implementation to store the session data in a data store." ); } diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 8a6715d7f..5157f685d 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -1,6 +1,5 @@ import type * as jose from "jose"; -import { InvalidConfigurationError } from "../errors/index.js"; import { RESPONSE_TYPES } from "../types/index.js"; import * as cookies from "./cookies.js"; @@ -16,11 +15,47 @@ const TRANSACTION_COOKIE_PREFIX = "__txn_"; // of the deployment's header limit, which the SDK cannot know. const MAX_TRANSACTION_COOKIE_BYTES = 3500; +// Ceiling on `returnTo` length, in bytes of the encoded URL. `returnTo` is the +// only user-influenced field in `TransactionState` that can grow arbitrarily +// long, and a very long value can push the resulting transaction cookie past +// `MAX_TRANSACTION_COOKIE_BYTES` on its own — which the FIFO eviction below +// cannot fix (there's nothing to evict that would make room). Anything longer +// than this is clamped back to `signInReturnToPath` by `clampReturnTo` and +// warned once. 2 KB is well above any realistic post-login path and well under +// the ~3 KB budget available for `returnTo` inside the JWE + cookie envelope. +export const MAX_RETURN_TO_BYTES = 2048; + // Emit once per process — same reasoning as sessionSizeWarningEmitted in // stateless-session-store.ts: prefetch/bot traffic hits this path repeatedly, // so a per-eviction warn would spam logs. let txnEvictionWarningEmitted = false; +// Also once-per-process for the same reason. +let returnToClampWarningEmitted = false; + +/** + * If `returnTo` would push the transaction cookie past + * `MAX_TRANSACTION_COOKIE_BYTES`, clamp it back to `fallback` and warn once. + * Returning `fallback` instead of throwing preserves the login flow — the user + * lands on the default post-login path instead of hitting a 500. This is safe + * because `toSafeRedirect` has already validated `returnTo` is same-origin; + * silently clamping cannot leak the user to an attacker-controlled URL. + */ +export function clampReturnTo(returnTo: string, fallback: string): string { + if (new TextEncoder().encode(returnTo).length <= MAX_RETURN_TO_BYTES) { + return returnTo; + } + if (!returnToClampWarningEmitted) { + returnToClampWarningEmitted = true; + console.warn( + `[auth0] returnTo value exceeds ${MAX_RETURN_TO_BYTES} bytes; clamping to ` + + `${fallback} to keep the transaction cookie under the header size limit. ` + + `Shorten the returnTo URL in your application.` + ); + } + return fallback; +} + export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -192,24 +227,28 @@ export class TransactionStore { // Encode creation timestamp in the value for O(1) FIFO ordering during eviction. // Format: "{ts}:{jwe}" — cookie name is unchanged. // - // Rolling-deploy and rollback safety: get() ships in a prior backfill release - // that strips the "{ts}:" prefix before decrypting, so all pods can read both - // the old bare "{jwe}" and the new "{ts}:{jwe}" format before this write-side - // change is deployed. + // Compatibility notes: + // - Forward (new code reads old cookie): get() strips a "{ts}:" prefix + // before decrypting, so legacy bare "{jwe}" values still read correctly. + // See `get()` below. + // - Backward (old code reads new cookie): a version of get() that predates + // this change passes the full "{ts}:{jwe}" string to decrypt(), which + // returns null (ERR_JWE_INVALID is swallowed). Any in-flight login + // started against a pod with this write-side change but completed against + // a pod without the read-side prefix stripping will fail once — the user + // sees "state parameter is invalid" and must re-initiate login. This + // affects rolling deploys where old and new pods coexist, and rollbacks. + // Transaction cookies are short-lived (default maxAge 1h), so the window + // is bounded to one hour after the deploy/rollback boundary. + // + // To eliminate the window, ship the get() prefix-stripping in a prior + // backfill release; then every pod can read the new format before the + // first pod writes it. If that is not feasible, call out the one-time + // in-flight login failure in the release notes. const ts = Math.floor(Date.now() / 1000); const newCookieName = this.getTransactionCookieName(transactionState.state); const newCookieValue = `${ts}:${jwe}`; - const newCookieBytes = new TextEncoder().encode( - `${newCookieName}=${newCookieValue}` - ).length; - if (newCookieBytes >= MAX_TRANSACTION_COOKIE_BYTES) { - throw new InvalidConfigurationError( - `The transaction cookie is ${newCookieBytes} bytes, which exceeds the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. ` + - `This is usually caused by a very long returnTo URL. Shorten the returnTo value.` - ); - } - // Evict oldest transaction cookies FIFO before writing the new one, so the // accumulated `__txn_*` cookies stay under the fixed byte limit. Only // transaction cookies are measured/deleted — the session and other cookies diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index 2b0753e27..d127c1bb0 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -3,14 +3,18 @@ import * as jose from "jose"; import * as oauth from "oauth4webapi"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { InvalidConfigurationError } from "../errors/index.js"; import { getDefaultRoutes } from "../test/defaults.js"; import { generateSecret } from "../test/utils.js"; import { RESPONSE_TYPES } from "../types/connected-accounts.js"; import { AuthClient } from "./auth-client.js"; import { RequestCookies, ResponseCookies } from "./cookies.js"; import { StatelessSessionStore } from "./session/stateless-session-store.js"; -import { TransactionState, TransactionStore } from "./transaction-store.js"; +import { + clampReturnTo, + MAX_RETURN_TO_BYTES, + TransactionState, + TransactionStore +} from "./transaction-store.js"; vi.mock("oauth4webapi", async () => { const actual = await vi.importActual("oauth4webapi"); @@ -110,14 +114,36 @@ describe("transaction cookie eviction in TransactionStore.save()", () => { vi.resetModules(); }); - it("throws InvalidConfigurationError when the new cookie alone exceeds the cap", async () => { - const store = new TransactionStore({ secret }); - await expect( - store.save( - makeResponseCookies(), - makeTransactionState("bigstate", { returnTo: "/?" + "x".repeat(4000) }) - ) - ).rejects.toThrow(InvalidConfigurationError); + it("clampReturnTo returns the input unchanged when it fits under the ceiling", () => { + expect(clampReturnTo("/dash", "/")).toBe("/dash"); + expect(clampReturnTo("/a?q=" + "x".repeat(1000), "/")).toBe( + "/a?q=" + "x".repeat(1000) + ); + }); + + it("clampReturnTo returns the fallback and warns once when input exceeds the ceiling", async () => { + vi.resetModules(); + const { clampReturnTo: freshClamp, MAX_RETURN_TO_BYTES: fresh } = + await import("./transaction-store.js"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const oversize = "/?" + "x".repeat(fresh + 10); + expect(freshClamp(oversize, "/dashboard")).toBe("/dashboard"); + // Second call also clamps but does not re-warn (once-per-process guard). + expect(freshClamp(oversize, "/dashboard")).toBe("/dashboard"); + + const clampWarns = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("returnTo value exceeds") + ); + expect(clampWarns).toHaveLength(1); + warnSpy.mockRestore(); + vi.resetModules(); + }); + + it("clampReturnTo boundary: exactly MAX_RETURN_TO_BYTES is accepted", () => { + // Encoded length equals MAX_RETURN_TO_BYTES → still <= ceiling, accepted. + const atLimit = "x".repeat(MAX_RETURN_TO_BYTES); + expect(clampReturnTo(atLimit, "/")).toBe(atLimit); }); it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { @@ -324,7 +350,7 @@ describe("transaction cookie eviction in TransactionStore.save()", () => { }); // --------------------------------------------------------------------------- -// Fix 3 — Dormant early-return removed for enableParallelTransactions: false +// Single-transaction mode: overwrite the fixed __txn_ cookie on repeated login // --------------------------------------------------------------------------- describe("single-transaction mode does not lock out concurrent logins", () => { @@ -346,7 +372,8 @@ describe("single-transaction mode does not lock out concurrent logins", () => { const newState = "new-login-state"; - // Before Fix 3 this would return early and skip writing — now it must overwrite + // Previously the single-transaction path returned early and skipped writing. + // A stale in-request cookie must not block a fresh login — save() must overwrite. await store.save(resCookies, makeTransactionState(newState), reqCookies); const written = resCookies.get("__txn_"); @@ -390,7 +417,7 @@ describe("single-transaction mode does not lock out concurrent logins", () => { }); // --------------------------------------------------------------------------- -// Fix 4 — Callback cleanup: delete only the completing flow's cookie +// Callback cleanup: delete only the completing flow's cookie // --------------------------------------------------------------------------- describe("callback cleanup: delete(state) removes only the completing cookie", () => { @@ -551,7 +578,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( }); }); - it("Fix 1 — known prefetch header returns 204 with no-store and no __txn_* cookie is written", async () => { + it("known prefetch header returns 204 with no-store and no __txn_* cookie is written", async () => { const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { headers: { "next-router-prefetch": "1" } @@ -569,7 +596,7 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(txnCookies).toHaveLength(0); }); - it("Fix 1 — real navigation is allowed through and __txn_* cookie is written", async () => { + it("real navigation is allowed through and __txn_* cookie is written", async () => { const authClient = makeAuthClient(); const req = new NextRequest("http://localhost:3000/auth/login", { headers: { "sec-fetch-mode": "navigate" } @@ -585,7 +612,36 @@ describe("Integration — prefetch guard and callback cleanup via AuthClient", ( expect(txnCookies.length).toBeGreaterThan(0); }); - it("Fix 4 — handleCallback deletes only the completing cookie, leaves Tab B cookie untouched", async () => { + it("oversized returnTo does not 500 the handler — request completes and cookie is bounded", async () => { + // Regression: a very long user-supplied returnTo used to throw + // InvalidConfigurationError out of save(), which surfaced as an unhandled + // 500 from the /auth/login route. Now clamped back to signInReturnToPath + // at the input boundary, so the login flow proceeds normally. + const authClient = makeAuthClient(); + const bigReturnTo = "/dashboard?data=" + "x".repeat(4000); + const req = new NextRequest( + `http://localhost:3000/auth/login?returnTo=${encodeURIComponent(bigReturnTo)}`, + { headers: { "sec-fetch-mode": "navigate" } } + ); + + const res = await authClient.handler(req); + + // Login proceeds as a normal redirect, not a 500. + expect(res.status).toBeGreaterThanOrEqual(300); + expect(res.status).toBeLessThan(400); + + // The written __txn_* cookie stays under the byte cap. + const written = res.cookies + .getAll() + .find((c) => c.name.startsWith("__txn_") && (c.maxAge ?? 0) > 0); + expect(written).toBeDefined(); + const cookieBytes = new TextEncoder().encode( + `${written!.name}=${written!.value}` + ).length; + expect(cookieBytes).toBeLessThan(3500); + }); + + it("handleCallback deletes only the completing cookie, leaves Tab B cookie untouched", async () => { const authClient = makeAuthClient(); const loginRes = await authClient.handleLogin( diff --git a/src/utils/request.test.ts b/src/utils/request.test.ts index 5094b8d3c..7b484b5ee 100644 --- a/src/utils/request.test.ts +++ b/src/utils/request.test.ts @@ -88,6 +88,26 @@ describe("isNonNavigationalRequest", () => { it("returns false when no headers present", () => { expect(isNonNavigationalRequest(makeReq({}))).toBe(false); }); + + it("returns false when next-router-prefetch has a falsy value — proxies or clients setting it explicitly must not disable login", () => { + // Guard against a proxy or misconfigured client sending an explicit + // falsy value. Only genuine truthy prefetch values should trigger 204. + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "" })) + ).toBe(false); + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "0" })) + ).toBe(false); + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "false" })) + ).toBe(false); + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": "FALSE" })) + ).toBe(false); + expect( + isNonNavigationalRequest(makeReq({ "next-router-prefetch": " " })) + ).toBe(false); + }); }); }); diff --git a/src/utils/request.ts b/src/utils/request.ts index b466fedf3..832f2a4a8 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -44,12 +44,21 @@ export const isRequest = (req: Req): req is Request | NextRequest => { */ export const isNonNavigationalRequest = (req: NextRequest): boolean => { const secPurpose = req.headers.get("sec-purpose") ?? ""; + const routerPrefetch = req.headers.get("next-router-prefetch"); return ( // next-router-prefetch: "1" = AUTO prefetch (Next 15); "2" = runtime - // prefetch (Next 16+). has() covers both values. - req.headers.has("next-router-prefetch") || + // prefetch (Next 16+). Accept any truthy non-"0"/non-"false" value to + // cover future Next versions, but reject falsy values a proxy or client + // might set explicitly — matching those would silently 204 real logins. + isTruthyHeaderValue(routerPrefetch) || req.headers.get("purpose") === "prefetch" || (secPurpose.includes("prefetch") && !secPurpose.includes("prerender")) || req.headers.get("x-middleware-prefetch") === "1" ); }; + +const isTruthyHeaderValue = (value: string | null): boolean => { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized !== "" && normalized !== "0" && normalized !== "false"; +}; From 5c2bdc07d6ff76589caf7ad493ef19f855eaf7a0 Mon Sep 17 00:00:00 2001 From: Piyush Kumar Date: Tue, 25 Aug 2026 14:28:11 +0530 Subject: [PATCH 36/36] fix: clamp scope/audience, split eviction warn, document dedup divergences --- EXAMPLES.md | 3 + src/server/auth-client.ts | 39 ++++++- src/server/transaction-store.ts | 119 +++++++++++++++------ src/server/txn-cookie-accumulation.test.ts | 92 ++++++++++++++++ src/utils/session-helpers.ts | 9 +- 5 files changed, 223 insertions(+), 39 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index e01142a90..a656908e0 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -333,6 +333,9 @@ For example: `/auth/login?returnTo=/dashboard` would redirect the user to the `/ > [!NOTE] > The URL specified as `returnTo` parameters must be registered in your client's **Allowed Callback URLs**. +> [!IMPORTANT] +> `returnTo`, `scope`, and `audience` query parameters on `/auth/login` are stored inside the encrypted transaction cookie. Any single field longer than 2 KB is silently clamped back to its default value and a one-time warning is logged, to keep the transaction cookie under the browser and proxy header limits (~4 KB per cookie). Keep these values short. For `returnTo` specifically, the fallback is `signInReturnToPath` (the SDK-configured default post-login path). + ### Redirecting the user after logging out The `returnTo` parameter can be appended to the logout to specify where you would like to redirect the user after they have logged out. diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 2955556bd..a82a217c7 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -201,6 +201,7 @@ import { import { AbstractSessionStore } from "./session/abstract-session-store.js"; import { clampReturnTo, + clampTransactionField, TransactionState, TransactionStore } from "./transaction-store.js"; @@ -941,6 +942,11 @@ export class AuthClient { } resolvedMaxAge = parsed; } + // scope and audience come from user-controllable query params on + // /auth/login, so clamp each independently to keep the resulting cookie + // under the byte cap. A ridiculous value gets replaced with `undefined` + // (equivalent to not passing the field), and the authorization server + // will reject any invalid value at /authorize. const transactionState: TransactionState = { nonce, maxAge: resolvedMaxAge, @@ -948,8 +954,16 @@ export class AuthClient { responseType: RESPONSE_TYPES.CODE, state, returnTo, - scope: authorizationParams.get("scope") || undefined, - audience: authorizationParams.get("audience") || undefined, + scope: clampTransactionField( + "scope", + authorizationParams.get("scope") || undefined, + undefined + ), + audience: clampTransactionField( + "audience", + authorizationParams.get("audience") || undefined, + undefined + ), challengeMode: challengeMode !== "redirect" ? challengeMode : undefined, // Store origin domain and issuer for callback delegation in resolver mode originDomain: this.provider?.isResolverMode ? this.domain : undefined, @@ -5149,9 +5163,24 @@ export class AuthClient { // Replace an existing token for the same audience AND scope, or append a // new one. Without this, each MFA step-up appends another full token set — // growing the session cookie unbounded (and eventually a 431). The key is - // audience + scope (not audience alone) to match findAccessTokenSet, which - // deliberately holds multiple same-audience token sets distinguished by - // scope; keying on audience alone would evict a differently-scoped token. + // audience + normalized scope (not audience alone), so a differently-scoped + // step-up for the same audience does not evict a still-useful entry. + // + // The match is exact normalized-set equality on requestedScope (with a + // fallback to granted `scope` for legacy entries). `findAccessTokenSet` + // does a looser superset match via `compareScopes`; the two rules do NOT + // agree. Consequences of the divergence: + // - `findAccessTokenSet` may return a wider entry to satisfy a narrower + // request (superset match on read), while this dedup keeps them as + // separate entries (exact match on write). + // - Per audience, the session can accumulate up to one entry per distinct + // normalized-scope set. Bounded per session, but looser than + // `findAccessTokenSet` would suggest. + // Aligning to `compareScopes` here would be a behaviour change (wide + // entries would evict narrower ones), which is out of scope for this fix. + // See also `mergePopupTokenIntoSession` (session-helpers.ts) which uses a + // third rule — keyed on audience alone — pre-existing on main. + // // Key on the requested scope (always present, with a fallback to the // granted scope for legacy entries): the granted `scope` may be reduced or // omitted by the server, which would otherwise collide distinct requests. diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 5157f685d..c1d4def5e 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -15,47 +15,80 @@ const TRANSACTION_COOKIE_PREFIX = "__txn_"; // of the deployment's header limit, which the SDK cannot know. const MAX_TRANSACTION_COOKIE_BYTES = 3500; -// Ceiling on `returnTo` length, in bytes of the encoded URL. `returnTo` is the -// only user-influenced field in `TransactionState` that can grow arbitrarily -// long, and a very long value can push the resulting transaction cookie past -// `MAX_TRANSACTION_COOKIE_BYTES` on its own — which the FIFO eviction below -// cannot fix (there's nothing to evict that would make room). Anything longer -// than this is clamped back to `signInReturnToPath` by `clampReturnTo` and -// warned once. 2 KB is well above any realistic post-login path and well under -// the ~3 KB budget available for `returnTo` inside the JWE + cookie envelope. -export const MAX_RETURN_TO_BYTES = 2048; +// Ceiling on any single user-influenced string field in `TransactionState` +// (returnTo, scope, audience). A very long value in any of these can push the +// transaction cookie past `MAX_TRANSACTION_COOKIE_BYTES` on its own, which the +// FIFO eviction below cannot fix (there is nothing to evict that would make +// room). Anything longer is clamped back to a safe fallback by +// `clampTransactionField` and warned once. 2 KB is well above any realistic +// value for these fields and well under the ~3 KB budget available inside the +// JWE + cookie envelope. +export const MAX_TRANSACTION_FIELD_BYTES = 2048; + +// Backward-compatible alias — the export name callers migrated to first. +export const MAX_RETURN_TO_BYTES = MAX_TRANSACTION_FIELD_BYTES; // Emit once per process — same reasoning as sessionSizeWarningEmitted in // stateless-session-store.ts: prefetch/bot traffic hits this path repeatedly, // so a per-eviction warn would spam logs. let txnEvictionWarningEmitted = false; +// Separate flag for the "single cookie exceeds cap on its own" case. Distinct +// from `txnEvictionWarningEmitted` so an operator sees the right diagnostic +// even if the other warn has already fired earlier in the process. +let txnOversizeWarningEmitted = false; -// Also once-per-process for the same reason. -let returnToClampWarningEmitted = false; +// One flag per field so a scope clamp does not silence a later returnTo clamp +// (each field surfaces independently to the developer). +const clampWarnEmittedByField = new Set(); /** - * If `returnTo` would push the transaction cookie past - * `MAX_TRANSACTION_COOKIE_BYTES`, clamp it back to `fallback` and warn once. - * Returning `fallback` instead of throwing preserves the login flow — the user - * lands on the default post-login path instead of hitting a 500. This is safe - * because `toSafeRedirect` has already validated `returnTo` is same-origin; - * silently clamping cannot leak the user to an attacker-controlled URL. + * If a user-influenced transaction field would push the transaction cookie past + * `MAX_TRANSACTION_COOKIE_BYTES`, clamp it back to `fallback` and warn once per + * field. Returning `fallback` instead of throwing preserves the login flow so + * the user lands on the default post-login path (or an unspecified value if + * `fallback` is `undefined`) instead of hitting a 500. For `returnTo`, this is + * safe because `toSafeRedirect` has already validated same-origin; for `scope` + * and `audience`, the authorization server will reject any invalid value at + * `/authorize`, so silently clamping cannot escalate to something worse than a + * failed authorize call. + * + * @param fieldName - Which field is being clamped ("returnTo" | "scope" | "audience"). + * Used in the warning text and to guard the once-per-field emit. + * @param value - Current value of the field. + * @param fallback - Value to substitute when `value` exceeds the ceiling. */ -export function clampReturnTo(returnTo: string, fallback: string): string { - if (new TextEncoder().encode(returnTo).length <= MAX_RETURN_TO_BYTES) { - return returnTo; +export function clampTransactionField( + fieldName: string, + value: T, + fallback: T +): T { + if ( + value === undefined || + new TextEncoder().encode(value).length <= MAX_TRANSACTION_FIELD_BYTES + ) { + return value; } - if (!returnToClampWarningEmitted) { - returnToClampWarningEmitted = true; + if (!clampWarnEmittedByField.has(fieldName)) { + clampWarnEmittedByField.add(fieldName); console.warn( - `[auth0] returnTo value exceeds ${MAX_RETURN_TO_BYTES} bytes; clamping to ` + - `${fallback} to keep the transaction cookie under the header size limit. ` + - `Shorten the returnTo URL in your application.` + `[auth0] ${fieldName} value exceeds ${MAX_TRANSACTION_FIELD_BYTES} bytes; ` + + `clamping to ${fallback ?? "undefined"} to keep the transaction cookie ` + + `under the header size limit. Shorten the ${fieldName} value in your ` + + `application.` ); } return fallback; } +/** + * Backward-compatible wrapper: same behaviour as + * `clampTransactionField("returnTo", returnTo, fallback)` but with the + * original name callers already imported. + */ +export function clampReturnTo(returnTo: string, fallback: string): string { + return clampTransactionField("returnTo", returnTo, fallback); +} + export interface TransactionState extends jose.JWTPayload { codeVerifier?: string; responseType: RESPONSE_TYPES; @@ -332,14 +365,34 @@ export class TransactionStore { if (freed >= target) break; } - if (!txnEvictionWarningEmitted) { - txnEvictionWarningEmitted = true; - console.warn( - `[auth0] Evicted the oldest transaction cookie(s) — projected total size ${projectedBytes} bytes ` + - `reached the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + - `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + - `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` - ); + if (freed > 0) { + // Something was actually evicted — the accumulation-of-abandoned-logins + // diagnostic is the right one to surface. Guarded by `freed > 0` so we + // do not send an operator hunting for phantom abandoned logins when the + // real issue is a single oversized cookie (see the else branch below). + if (!txnEvictionWarningEmitted) { + txnEvictionWarningEmitted = true; + console.warn( + `[auth0] Evicted the oldest transaction cookie(s) — projected total size ${projectedBytes} bytes ` + + `reached the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit. This usually means many ` + + `login flows were started but never completed (e.g. prefetches or abandoned logins); ` + + `reduce transactionCookie.maxAge if in-flight logins are being evicted too aggressively.` + ); + } + } else { + // Nothing was evicted but the cap is still exceeded — the new cookie + // itself is bigger than the cap on its own. Usually means a very long + // `returnTo`, `scope`, or `audience` slipped past the field clamps. + // Different message so the operator does not go looking for accumulation. + if (!txnOversizeWarningEmitted) { + txnOversizeWarningEmitted = true; + console.warn( + `[auth0] Transaction cookie exceeds the ${MAX_TRANSACTION_COOKIE_BYTES} byte limit ` + + `on its own (projected size ${projectedBytes} bytes). This is usually caused by a very ` + + `long returnTo, scope, or audience value on /auth/login. Check the field clamps in ` + + `handleLogin; the cookie was still written but may be rejected by the browser or a proxy.` + ); + } } } diff --git a/src/server/txn-cookie-accumulation.test.ts b/src/server/txn-cookie-accumulation.test.ts index d127c1bb0..e7113fd8b 100644 --- a/src/server/txn-cookie-accumulation.test.ts +++ b/src/server/txn-cookie-accumulation.test.ts @@ -11,7 +11,9 @@ import { RequestCookies, ResponseCookies } from "./cookies.js"; import { StatelessSessionStore } from "./session/stateless-session-store.js"; import { clampReturnTo, + clampTransactionField, MAX_RETURN_TO_BYTES, + MAX_TRANSACTION_FIELD_BYTES, TransactionState, TransactionStore } from "./transaction-store.js"; @@ -111,6 +113,46 @@ describe("transaction cookie eviction in TransactionStore.save()", () => { ); expect(evictionWarns).toHaveLength(1); warnSpy.mockRestore(); + }); + + it("does NOT log the 'Evicted' warning when nothing was freed (freed === 0)", async () => { + // Regression: previously the "Evicted the oldest transaction cookie(s)" + // warn fired whenever projectedBytes >= cap, even when `freed === 0` + // (nothing was evicted — e.g. only the same-name cookie exists in the + // snapshot, so it is exempt from eviction, but the new cookie itself is + // large enough to exceed the cap). That sent operators debugging a 431 + // hunting for phantom abandoned logins. + // + // The eviction warn is now gated on `freed > 0`, and a distinct warn + // ("Transaction cookie exceeds the … byte limit on its own") covers the + // case where nothing evictable exists. + vi.resetModules(); + const { TransactionStore: FreshStore } = + await import("./transaction-store.js"); + const store = new FreshStore({ secret }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // A TransactionState with a returnTo large enough that the resulting JWE + // pushes the new cookie past 3500 bytes on its own. The snapshot is empty, + // so `existingBytes = 0`, `projectedBytes = new cookie size >= cap`, and + // no cookies are available to evict (`freed === 0`). + const bigState = makeTransactionState("only", { + returnTo: "/" + "x".repeat(3200) + }); + + await store.save(makeResponseCookies(), bigState, makeRequestCookies({})); + + const evictionWarns = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("[auth0] Evicted the oldest") + ); + expect(evictionWarns).toHaveLength(0); + + const oversizeWarns = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("Transaction cookie exceeds") + ); + expect(oversizeWarns).toHaveLength(1); + + warnSpy.mockRestore(); vi.resetModules(); }); @@ -146,6 +188,56 @@ describe("transaction cookie eviction in TransactionStore.save()", () => { expect(clampReturnTo(atLimit, "/")).toBe(atLimit); }); + it("MAX_RETURN_TO_BYTES stays aliased to MAX_TRANSACTION_FIELD_BYTES for back-compat", () => { + expect(MAX_RETURN_TO_BYTES).toBe(MAX_TRANSACTION_FIELD_BYTES); + }); + + it("clampTransactionField clamps scope and audience independently", async () => { + vi.resetModules(); + const { + clampTransactionField: freshClamp, + MAX_TRANSACTION_FIELD_BYTES: fresh + } = await import("./transaction-store.js"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const oversizeScope = "read:" + "x".repeat(fresh); + const oversizeAudience = "urn:" + "x".repeat(fresh); + expect(freshClamp("scope", oversizeScope, undefined)).toBeUndefined(); + expect(freshClamp("audience", oversizeAudience, undefined)).toBeUndefined(); + + // Each field warns once — a scope clamp does not silence a later audience clamp. + const scopeWarns = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("scope value exceeds") + ); + const audienceWarns = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("audience value exceeds") + ); + expect(scopeWarns).toHaveLength(1); + expect(audienceWarns).toHaveLength(1); + + // Second call for the same field does not re-warn. + freshClamp("scope", oversizeScope, undefined); + const scopeWarnsAfter = warnSpy.mock.calls.filter((c) => + String(c[0]).includes("scope value exceeds") + ); + expect(scopeWarnsAfter).toHaveLength(1); + + warnSpy.mockRestore(); + vi.resetModules(); + }); + + it("clampTransactionField passes through undefined and short values", () => { + expect( + clampTransactionField("scope", undefined, undefined) + ).toBeUndefined(); + expect(clampTransactionField("scope", "openid profile", undefined)).toBe( + "openid profile" + ); + expect(clampTransactionField("audience", "urn:api", undefined)).toBe( + "urn:api" + ); + }); + it("does not evict when no reqCookies passed (no eviction without snapshot)", async () => { const store = new TransactionStore({ secret }); const resCookies = makeResponseCookies(); diff --git a/src/utils/session-helpers.ts b/src/utils/session-helpers.ts index 9083d7ab3..91b16105d 100644 --- a/src/utils/session-helpers.ts +++ b/src/utils/session-helpers.ts @@ -98,7 +98,14 @@ export function mergePopupTokenIntoSession( token_type: oidcRes.token_type }; - // Replace existing token for same audience, or append new one + // Replace existing token for same audience, or append new one. + // + // NOTE: This dedup keys on audience alone — differently-scoped tokens for + // the same audience are evicted here. The MFA step-up dedup in + // `cacheTokenFromMfaVerify` (auth-client.ts) uses a stricter audience+scope + // key that preserves distinct-scope entries. Both rules are pre-existing on + // main and ship side by side. Unifying them into one shared helper is a + // separate refactor. const existingIdx = session.accessTokens.findIndex( (t) => t.audience === transactionState.audience );