From 3655f3dbdfda3130794557d73377cfd7005adf2d Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 16 Jul 2025 18:39:16 +0530 Subject: [PATCH 01/10] feat: txn cookies cleanup --- src/server/auth-client.ts | 137 ++++- src/server/transaction-store.ts | 16 +- tests/v4-infinitely-stacking-cookies.test.ts | 579 +++++++++++++++++++ 3 files changed, 716 insertions(+), 16 deletions(-) create mode 100644 tests/v4-infinitely-stacking-cookies.test.ts diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 34df3f72..53724c11 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -1,4 +1,5 @@ import { NextResponse, type NextRequest } from "next/server.js"; +import type { RequestCookies, ResponseCookies } from "@edge-runtime/cookies"; import * as jose from "jose"; import * as oauth from "oauth4webapi"; @@ -141,6 +142,33 @@ export interface AuthClientOptions { enableTelemetry?: boolean; enableAccessTokenEndpoint?: boolean; noContentProfileResponseWhenUnauthenticated?: boolean; + + // Transaction cookie management options + /** + * Controls whether multiple parallel login transactions are allowed. + * When false, only one transaction cookie is maintained at a time. + * When true (default), multiple transaction cookies can coexist for multi-tab support. + * + * @default true + */ + enableParallelTransactions?: boolean; + + /** + * The expiration time for transaction cookies in seconds. + * If not provided, defaults to 1 hour (3600 seconds). + * + * @default 3600 + */ + txnCookieExpiration?: number; + + /** + * The maximum number of transaction cookies to maintain before cleanup. + * Only used when enableParallelTransactions is true. + * If not provided, defaults to 2 for basic multi-tab support. + * + * @default 2 + */ + maxTxnCookieCount?: number; } function createRouteUrl(path: string, baseUrl: string) { @@ -181,6 +209,11 @@ export class AuthClient { private readonly enableAccessTokenEndpoint: boolean; private readonly noContentProfileResponseWhenUnauthenticated: boolean; + // Transaction cookie management configuration + private readonly enableParallelTransactions: boolean; + private readonly txnCookieExpiration: number; + private readonly maxTxnCookieCount: number; + constructor(options: AuthClientOptions) { // dependencies this.fetch = options.fetch || fetch; @@ -282,6 +315,12 @@ export class AuthClient { this.enableAccessTokenEndpoint = options.enableAccessTokenEndpoint ?? true; this.noContentProfileResponseWhenUnauthenticated = options.noContentProfileResponseWhenUnauthenticated ?? false; + + // Transaction cookie management configuration + this.enableParallelTransactions = + options.enableParallelTransactions ?? true; + this.txnCookieExpiration = options.txnCookieExpiration ?? 3600; // 1 hour default + this.maxTxnCookieCount = options.maxTxnCookieCount ?? 2; // Default to 2 for basic multi-tab support } async handler(req: NextRequest): Promise { @@ -329,7 +368,8 @@ export class AuthClient { } async startInteractiveLogin( - options: StartInteractiveLoginOptions = {} + options: StartInteractiveLoginOptions = {}, + reqCookies?: RequestCookies ): Promise { const redirectUri = createRouteUrl(this.routes.callback, this.appBaseUrl); // must be registed with the authorization server let returnTo = this.signInReturnToPath; @@ -404,7 +444,24 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - await this.transactionStore.save(res.cookies, transactionState); + + // Apply transaction cookie management based on configuration + if (reqCookies) { + if (!this.enableParallelTransactions) { + // When parallel transactions are disabled, delete all existing transaction cookies + await this.transactionStore.deleteAll(reqCookies, res.cookies); + } else { + // When parallel transactions are enabled, cleanup excess cookies based on maxTxnCookieCount + await this.cleanupExcessTransactionCookies(reqCookies, res.cookies); + } + } + + // Save transaction with custom expiration + await this.transactionStore.save( + res.cookies, + transactionState, + this.txnCookieExpiration + ); return res; } @@ -418,7 +475,7 @@ export class AuthClient { : {}, returnTo: searchParams.returnTo }; - return this.startInteractiveLogin(options); + return this.startInteractiveLogin(options, req.cookies); } async handleLogout(req: NextRequest): Promise { @@ -514,7 +571,7 @@ export class AuthClient { async handleCallback(req: NextRequest): Promise { const state = req.nextUrl.searchParams.get("state"); if (!state) { - return this.onCallback(new MissingStateError(), {}, null); + return this.handleCallbackError(new MissingStateError(), {}, req); } const transactionStateCookie = await this.transactionStore.get( @@ -534,7 +591,12 @@ export class AuthClient { await this.discoverAuthorizationServerMetadata(); if (discoveryError) { - return this.onCallback(discoveryError, onCallbackCtx, null); + return this.handleCallbackError( + discoveryError, + onCallbackCtx, + req, + state + ); } let codeGrantParams: URLSearchParams; @@ -546,7 +608,7 @@ export class AuthClient { transactionState.state ); } catch (e: any) { - return this.onCallback( + return this.handleCallbackError( new AuthorizationError({ cause: new OAuth2Error({ code: e.error, @@ -554,7 +616,8 @@ export class AuthClient { }) }), onCallbackCtx, - null + req, + state ); } @@ -575,10 +638,11 @@ export class AuthClient { } ); } catch (e: any) { - return this.onCallback( + return this.handleCallbackError( new AuthorizationCodeGrantRequestError(e.message), onCallbackCtx, - null + req, + state ); } @@ -595,7 +659,7 @@ export class AuthClient { } ); } catch (e: any) { - return this.onCallback( + return this.handleCallbackError( new AuthorizationCodeGrantError({ cause: new OAuth2Error({ code: e.error, @@ -603,7 +667,8 @@ export class AuthClient { }) }), onCallbackCtx, - null + req, + state ); } @@ -912,6 +977,25 @@ export class AuthClient { return res; } + /** + * Handle callback errors with transaction cleanup + */ + private async handleCallbackError( + error: SdkError, + ctx: OnCallbackContext, + req: NextRequest, + state?: string + ): Promise { + const response = await this.onCallback(error, ctx, null); + + // Clean up the transaction cookie on error to prevent accumulation + if (state) { + await this.transactionStore.delete(response.cookies, state); + } + + return response; + } + private async verifyLogoutToken( logoutToken: string ): Promise<[null, LogoutToken] | [SdkError, null]> { @@ -1251,6 +1335,37 @@ export class AuthClient { } return session; } + + /** + * Cleans up excess transaction cookies to prevent infinite stacking + * while preserving multi-tab support by keeping recent cookies. + * This implements a threshold-based cleanup strategy using the configured maxTxnCookieCount. + */ + private async cleanupExcessTransactionCookies( + reqCookies: RequestCookies, + resCookies: ResponseCookies + ): Promise { + const txnPrefix = this.transactionStore.getCookiePrefix(); + const transactionCookies = reqCookies + .getAll() + .filter((cookie) => cookie.name.startsWith(txnPrefix)); + + // Apply threshold-based cleanup using the configured maxTxnCookieCount + // This prevents infinite accumulation while supporting multi-tab scenarios + const threshold = this.maxTxnCookieCount; + + if (transactionCookies.length > threshold) { + // Sort by name (which includes timestamp-based state) to get oldest first + // Since we can't reliably sort by creation time, we use count-based cleanup + const cookiesToDelete = transactionCookies.slice(0, -threshold); + + for (const cookie of cookiesToDelete) { + // Extract state from cookie name to delete via transaction store + const state = cookie.name.substring(txnPrefix.length); + await this.transactionStore.delete(resCookies, state); + } + } + } } const encodeBase64 = (input: string) => { diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 3c520ed8..2ce8a692 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -84,11 +84,11 @@ export class TransactionStore { async save( resCookies: cookies.ResponseCookies, - transactionState: TransactionState + transactionState: TransactionState, + customExpiration?: number ) { - const expiration = Math.floor( - Date.now() / 1000 + this.cookieConfig.maxAge! - ); + const expirationSeconds = customExpiration ?? this.cookieConfig.maxAge!; + const expiration = Math.floor(Date.now() / 1000 + expirationSeconds); const jwe = await cookies.encrypt( transactionState, this.secret, @@ -99,10 +99,16 @@ export class TransactionStore { throw new Error("Transaction state is required"); } + // Create a copy of cookie config with the custom expiration + const cookieOptions = { + ...this.cookieConfig, + maxAge: expirationSeconds + }; + resCookies.set( this.getTransactionCookieName(transactionState.state), jwe.toString(), - this.cookieConfig + cookieOptions ); } diff --git a/tests/v4-infinitely-stacking-cookies.test.ts b/tests/v4-infinitely-stacking-cookies.test.ts new file mode 100644 index 00000000..8cf1c6ae --- /dev/null +++ b/tests/v4-infinitely-stacking-cookies.test.ts @@ -0,0 +1,579 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AuthClient } from "../src/server/auth-client.js"; + +// Mock dependencies +vi.mock("../src/server/transaction-store.js"); +vi.mock("../src/server/session/abstract-session-store.js"); + +describe("v4-infinitely-stacking-cookies - v4: Infinitely stacking cookies regression", () => { + let authClient: AuthClient; + let mockTransactionStore: any; + let mockSessionStore: any; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + + // Create mocks + mockTransactionStore = { + save: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + deleteAll: vi.fn(), + getCookiePrefix: vi.fn().mockReturnValue("__txn_") + }; + + mockSessionStore = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn() + }; + + // Create AuthClient instance with mocked dependencies + authClient = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore + } as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Happy Path", () => { + it("should cleanup excess transaction cookies when starting interactive login", async () => { + // Arrange: Mock request with multiple existing transaction cookies + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "value1" }, + { name: "__txn_state2", value: "value2" }, + { name: "__txn_state3", value: "value3" }, + { name: "other_cookie", value: "other_value" } + ]) + }; + + // Mock the authorization URL generation + vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Act: Start interactive login with request cookies + await authClient.startInteractiveLogin({}, mockCookies as any); + + // Assert: Should have called delete for excess cookies (keeping threshold of 2) + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "state1" + ); + expect(mockTransactionStore.delete).toHaveBeenCalledTimes(1); // Only 1 cookie should be deleted (3 total - 2 threshold = 1) + expect(mockTransactionStore.save).toHaveBeenCalledTimes(1); + }); + + it("should not cleanup when transaction cookies are below threshold", async () => { + // Arrange: Mock request with only 1 existing transaction cookie + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "value1" }, + { name: "other_cookie", value: "other_value" } + ]) + }; + + // Mock the authorization URL generation + vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Act: Start interactive login with request cookies + await authClient.startInteractiveLogin({}, mockCookies as any); + + // Assert: Should not have called delete (only 1 cookie, below threshold of 2) + expect(mockTransactionStore.delete).not.toHaveBeenCalled(); + expect(mockTransactionStore.save).toHaveBeenCalledTimes(1); + }); + + it("should cleanup transaction cookie on callback error", async () => { + // Arrange: Mock callback error scenario + const mockRequest = new NextRequest( + "http://localhost:3000/auth/callback?state=test-state&error=access_denied" + ); + + mockTransactionStore.get.mockResolvedValue({ + payload: { + state: "test-state", + returnTo: "/", + nonce: "test-nonce", + codeVerifier: "test-verifier", + responseType: "code" + } + }); + + // Mock discovery to return error + vi.spyOn( + authClient as any, + "discoverAuthorizationServerMetadata" + ).mockResolvedValue([new Error("Discovery failed"), null]); + + // Act: Handle callback with error + const response = await authClient.handleCallback(mockRequest); + + // Assert: Should cleanup the transaction cookie on error + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "test-state" + ); + expect(response.status).toBe(500); // Default error response + }); + }); + + describe("Edge Cases", () => { + it("should handle cleanup when no request cookies provided", async () => { + // Arrange: Start login without request cookies + vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Act: Start interactive login without request cookies + await authClient.startInteractiveLogin({}); + + // Assert: Should not attempt cleanup but should still save transaction + expect(mockTransactionStore.delete).not.toHaveBeenCalled(); + expect(mockTransactionStore.save).toHaveBeenCalledTimes(1); + }); + + it("should extract state correctly from cookie names", async () => { + // Arrange: Mock cookies with specific state patterns + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { + name: "__txn_RaYuKTZuJbZ-10NrYwmh8sE5Eb-rClUcD3Xr25ea4Jk", + value: "value1" + }, + { name: "__txn_another-long-state-value", value: "value2" }, + { name: "__txn_simple", value: "value3" } + ]) + }; + + vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Act + await authClient.startInteractiveLogin({}, mockCookies as any); + + // Assert: Should extract state correctly (delete oldest which is first in array) + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "RaYuKTZuJbZ-10NrYwmh8sE5Eb-rClUcD3Xr25ea4Jk" + ); + }); + + it("should handle multiple error scenarios in callback", async () => { + // Test missing state error + const requestMissingState = new NextRequest( + "http://localhost:3000/auth/callback" + ); + + const responseMissingState = + await authClient.handleCallback(requestMissingState); + expect(responseMissingState.status).toBe(500); + + // Test invalid state error (transaction not found) + const requestInvalidState = new NextRequest( + "http://localhost:3000/auth/callback?state=invalid-state" + ); + mockTransactionStore.get.mockResolvedValue(null); + + const responseInvalidState = + await authClient.handleCallback(requestInvalidState); + expect(responseInvalidState.status).toBe(500); + // Note: Invalid state error does NOT delete cookie as it may not exist + // This is consistent with existing behavior and prevents double-deletion + }); + }); + + describe("Configuration Options", () => { + describe("enableParallelTransactions option", () => { + it("should delete all transaction cookies when enableParallelTransactions is false", async () => { + // Create AuthClient with parallel transactions disabled + const authClientSingleTxn = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore, + enableParallelTransactions: false + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientSingleTxn as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Mock request with multiple existing transaction cookies + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "txn1" }, + { name: "__txn_state2", value: "txn2" }, + { name: "__txn_state3", value: "txn3" } + ]) + }; + + // Call startInteractiveLogin directly to avoid network calls + await authClientSingleTxn.startInteractiveLogin({}, mockCookies as any); + + // Verify deleteAll was called (for single transaction mode) + expect(mockTransactionStore.deleteAll).toHaveBeenCalledTimes(1); + + // Verify save was called with custom expiration (default 3600) + expect(mockTransactionStore.save).toHaveBeenCalledWith( + expect.anything(), + expect.any(Object), + 3600 + ); + }); + + it("should use threshold-based cleanup when enableParallelTransactions is true", async () => { + // Create AuthClient with parallel transactions enabled (default) + const authClientParallelTxn = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore, + enableParallelTransactions: true, + maxTxnCookieCount: 2 + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientParallelTxn as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Mock 4 existing transaction cookies (above threshold of 2) + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "txn1" }, + { name: "__txn_state2", value: "txn2" }, + { name: "__txn_state3", value: "txn3" }, + { name: "__txn_state4", value: "txn4" } + ]) + }; + + // Call startInteractiveLogin directly to avoid network calls + await authClientParallelTxn.startInteractiveLogin( + {}, + mockCookies as any + ); + + // Verify deleteAll was NOT called + expect(mockTransactionStore.deleteAll).not.toHaveBeenCalled(); + + // Verify individual delete was called for excess cookies (4 - 2 = 2 deletes) + expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "state1" + ); + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "state2" + ); + }); + }); + + describe("txnCookieExpiration option", () => { + it("should use custom expiration when provided", async () => { + const customExpiration = 7200; // 2 hours + + const authClientCustomExp = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore, + txnCookieExpiration: customExpiration + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientCustomExp as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + const mockCookies = { + getAll: vi.fn().mockReturnValue([]) + }; + + await authClientCustomExp.startInteractiveLogin({}, mockCookies as any); + + // Verify save was called with custom expiration + expect(mockTransactionStore.save).toHaveBeenCalledWith( + expect.anything(), + expect.any(Object), + customExpiration + ); + }); + + it("should use default expiration (3600) when not provided", async () => { + const authClientDefaultExp = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore + // txnCookieExpiration not provided + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientDefaultExp as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + const mockCookies = { + getAll: vi.fn().mockReturnValue([]) + }; + + await authClientDefaultExp.startInteractiveLogin( + {}, + mockCookies as any + ); + + // Verify save was called with default expiration + expect(mockTransactionStore.save).toHaveBeenCalledWith( + expect.anything(), + expect.any(Object), + 3600 + ); + }); + }); + + describe("maxTxnCookieCount option", () => { + it("should use custom maxTxnCookieCount for cleanup threshold", async () => { + const customMaxCount = 5; + + const authClientCustomMax = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore, + enableParallelTransactions: true, + maxTxnCookieCount: customMaxCount + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientCustomMax as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Mock 7 existing transaction cookies (above threshold of 5) + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "txn1" }, + { name: "__txn_state2", value: "txn2" }, + { name: "__txn_state3", value: "txn3" }, + { name: "__txn_state4", value: "txn4" }, + { name: "__txn_state5", value: "txn5" }, + { name: "__txn_state6", value: "txn6" }, + { name: "__txn_state7", value: "txn7" } + ]) + }; + + await authClientCustomMax.startInteractiveLogin({}, mockCookies as any); + + // Verify delete was called for excess cookies (7 - 5 = 2 deletes) + expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "state1" + ); + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "state2" + ); + }); + + it("should use default maxTxnCookieCount (2) when not provided", async () => { + const authClientDefaultMax = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore, + enableParallelTransactions: true + // maxTxnCookieCount not provided (default should be 2) + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientDefaultMax as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Mock 4 existing transaction cookies (above default threshold of 2) + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "txn1" }, + { name: "__txn_state2", value: "txn2" }, + { name: "__txn_state3", value: "txn3" }, + { name: "__txn_state4", value: "txn4" } + ]) + }; + + await authClientDefaultMax.startInteractiveLogin( + {}, + mockCookies as any + ); + + // Verify delete was called for excess cookies (4 - 2 = 2 deletes) + expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "state1" + ); + expect(mockTransactionStore.delete).toHaveBeenCalledWith( + expect.anything(), + "state2" + ); + }); + + it("should not cleanup when transaction count is below threshold", async () => { + const authClientBelowThreshold = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore, + enableParallelTransactions: true, + maxTxnCookieCount: 5 + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientBelowThreshold as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Mock 3 existing transaction cookies (below threshold of 5) + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "txn1" }, + { name: "__txn_state2", value: "txn2" }, + { name: "__txn_state3", value: "txn3" } + ]) + }; + + await authClientBelowThreshold.startInteractiveLogin( + {}, + mockCookies as any + ); + + // Verify no cleanup occurred + expect(mockTransactionStore.delete).not.toHaveBeenCalled(); + expect(mockTransactionStore.deleteAll).not.toHaveBeenCalled(); + }); + }); + + describe("Combined options behavior", () => { + it("should respect all configuration options together", async () => { + const authClientCombined = new AuthClient({ + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret: "test-secret", + transactionStore: mockTransactionStore, + sessionStore: mockSessionStore, + enableParallelTransactions: true, + txnCookieExpiration: 1800, // 30 minutes + maxTxnCookieCount: 3 + } as any); + + // Mock the authorization URL generation + vi.spyOn( + authClientCombined as any, + "authorizationUrl" + ).mockResolvedValue([ + null, + new URL("https://test.auth0.com/authorize") + ]); + + // Mock 5 existing transaction cookies (above threshold of 3) + const mockCookies = { + getAll: vi.fn().mockReturnValue([ + { name: "__txn_state1", value: "txn1" }, + { name: "__txn_state2", value: "txn2" }, + { name: "__txn_state3", value: "txn3" }, + { name: "__txn_state4", value: "txn4" }, + { name: "__txn_state5", value: "txn5" } + ]) + }; + + await authClientCombined.startInteractiveLogin({}, mockCookies as any); + + // Verify cleanup used custom threshold (5 - 3 = 2 deletes) + expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); + + // Verify save used custom expiration + expect(mockTransactionStore.save).toHaveBeenCalledWith( + expect.anything(), + expect.any(Object), + 1800 + ); + + // Verify deleteAll was not called (parallel transactions enabled) + expect(mockTransactionStore.deleteAll).not.toHaveBeenCalled(); + }); + }); + }); +}); From caaac23c68f97bcf43d734b7172693870fdf1914 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 23 Jul 2025 22:03:56 +0530 Subject: [PATCH 02/10] feat: add flag enableParallelTransactions for txn cookies and associated logic --- src/server/auth-client.test.ts | 78 +- src/server/auth-client.ts | 96 +-- src/server/client.ts | 5 +- src/server/transaction-store.ts | 82 +- tests/v4-infinitely-stacking-cookies.test.ts | 806 +++++++------------ 5 files changed, 400 insertions(+), 667 deletions(-) diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index f7bfd428..ed814227 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -4826,14 +4826,17 @@ ca/T0LLtgmbMmxSv/MmzIg== // Mock the transactionStore.save method to verify the saved state const originalSave = authClient["transactionStore"].save; - authClient["transactionStore"].save = vi.fn(async (cookies, state) => { - expect(state.returnTo).toBe(defaultReturnTo); - return originalSave.call( - authClient["transactionStore"], - cookies, - state - ); - }); + authClient["transactionStore"].save = vi.fn( + async (cookies, state, reqCookies) => { + expect(state.returnTo).toBe(defaultReturnTo); + return originalSave.call( + authClient["transactionStore"], + cookies, + state, + reqCookies + ); + } + ); await authClient.startInteractiveLogin(); @@ -4846,14 +4849,17 @@ ca/T0LLtgmbMmxSv/MmzIg== // Mock the transactionStore.save method to verify the saved state const originalSave = authClient["transactionStore"].save; - authClient["transactionStore"].save = vi.fn(async (cookies, state) => { - expect(state.returnTo).toBe("/custom-return-path"); - return originalSave.call( - authClient["transactionStore"], - cookies, - state - ); - }); + authClient["transactionStore"].save = vi.fn( + async (cookies, state, reqCookies) => { + expect(state.returnTo).toBe("/custom-return-path"); + return originalSave.call( + authClient["transactionStore"], + cookies, + state, + reqCookies + ); + } + ); await authClient.startInteractiveLogin({ returnTo }); @@ -4866,14 +4872,17 @@ ca/T0LLtgmbMmxSv/MmzIg== DEFAULT.appBaseUrl + "/custom-return-path?query=param#hash"; const originalSave = authClient["transactionStore"].save; - authClient["transactionStore"].save = vi.fn(async (cookies, state) => { - expect(state.returnTo).toBe("/custom-return-path?query=param#hash"); - return originalSave.call( - authClient["transactionStore"], - cookies, - state - ); - }); + authClient["transactionStore"].save = vi.fn( + async (cookies, state, reqCookies) => { + expect(state.returnTo).toBe("/custom-return-path?query=param#hash"); + return originalSave.call( + authClient["transactionStore"], + cookies, + state, + reqCookies + ); + } + ); await authClient.startInteractiveLogin({ returnTo }); @@ -4888,15 +4897,18 @@ ca/T0LLtgmbMmxSv/MmzIg== // Mock the transactionStore.save method to verify the saved state const originalSave = authClient["transactionStore"].save; - authClient["transactionStore"].save = vi.fn(async (cookies, state) => { - // Should use the default safe path instead of the malicious one - expect(state.returnTo).toBe("/safe-path"); - return originalSave.call( - authClient["transactionStore"], - cookies, - state - ); - }); + authClient["transactionStore"].save = vi.fn( + async (cookies, state, reqCookies) => { + // Should use the default safe path instead of the malicious one + expect(state.returnTo).toBe("/safe-path"); + return originalSave.call( + authClient["transactionStore"], + cookies, + state, + reqCookies + ); + } + ); await authClient.startInteractiveLogin({ returnTo: unsafeReturnTo }); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 53724c11..3ab1c8d7 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -1,5 +1,4 @@ import { NextResponse, type NextRequest } from "next/server.js"; -import type { RequestCookies, ResponseCookies } from "@edge-runtime/cookies"; import * as jose from "jose"; import * as oauth from "oauth4webapi"; @@ -142,33 +141,6 @@ export interface AuthClientOptions { enableTelemetry?: boolean; enableAccessTokenEndpoint?: boolean; noContentProfileResponseWhenUnauthenticated?: boolean; - - // Transaction cookie management options - /** - * Controls whether multiple parallel login transactions are allowed. - * When false, only one transaction cookie is maintained at a time. - * When true (default), multiple transaction cookies can coexist for multi-tab support. - * - * @default true - */ - enableParallelTransactions?: boolean; - - /** - * The expiration time for transaction cookies in seconds. - * If not provided, defaults to 1 hour (3600 seconds). - * - * @default 3600 - */ - txnCookieExpiration?: number; - - /** - * The maximum number of transaction cookies to maintain before cleanup. - * Only used when enableParallelTransactions is true. - * If not provided, defaults to 2 for basic multi-tab support. - * - * @default 2 - */ - maxTxnCookieCount?: number; } function createRouteUrl(path: string, baseUrl: string) { @@ -209,11 +181,6 @@ export class AuthClient { private readonly enableAccessTokenEndpoint: boolean; private readonly noContentProfileResponseWhenUnauthenticated: boolean; - // Transaction cookie management configuration - private readonly enableParallelTransactions: boolean; - private readonly txnCookieExpiration: number; - private readonly maxTxnCookieCount: number; - constructor(options: AuthClientOptions) { // dependencies this.fetch = options.fetch || fetch; @@ -315,12 +282,6 @@ export class AuthClient { this.enableAccessTokenEndpoint = options.enableAccessTokenEndpoint ?? true; this.noContentProfileResponseWhenUnauthenticated = options.noContentProfileResponseWhenUnauthenticated ?? false; - - // Transaction cookie management configuration - this.enableParallelTransactions = - options.enableParallelTransactions ?? true; - this.txnCookieExpiration = options.txnCookieExpiration ?? 3600; // 1 hour default - this.maxTxnCookieCount = options.maxTxnCookieCount ?? 2; // Default to 2 for basic multi-tab support } async handler(req: NextRequest): Promise { @@ -368,8 +329,7 @@ export class AuthClient { } async startInteractiveLogin( - options: StartInteractiveLoginOptions = {}, - reqCookies?: RequestCookies + options: StartInteractiveLoginOptions = {} ): Promise { const redirectUri = createRouteUrl(this.routes.callback, this.appBaseUrl); // must be registed with the authorization server let returnTo = this.signInReturnToPath; @@ -445,23 +405,8 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); - // Apply transaction cookie management based on configuration - if (reqCookies) { - if (!this.enableParallelTransactions) { - // When parallel transactions are disabled, delete all existing transaction cookies - await this.transactionStore.deleteAll(reqCookies, res.cookies); - } else { - // When parallel transactions are enabled, cleanup excess cookies based on maxTxnCookieCount - await this.cleanupExcessTransactionCookies(reqCookies, res.cookies); - } - } - - // Save transaction with custom expiration - await this.transactionStore.save( - res.cookies, - transactionState, - this.txnCookieExpiration - ); + // Save transaction state + await this.transactionStore.save(res.cookies, transactionState); return res; } @@ -475,7 +420,7 @@ export class AuthClient { : {}, returnTo: searchParams.returnTo }; - return this.startInteractiveLogin(options, req.cookies); + return this.startInteractiveLogin(options); } async handleLogout(req: NextRequest): Promise { @@ -696,6 +641,8 @@ 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); return res; @@ -1335,37 +1282,6 @@ export class AuthClient { } return session; } - - /** - * Cleans up excess transaction cookies to prevent infinite stacking - * while preserving multi-tab support by keeping recent cookies. - * This implements a threshold-based cleanup strategy using the configured maxTxnCookieCount. - */ - private async cleanupExcessTransactionCookies( - reqCookies: RequestCookies, - resCookies: ResponseCookies - ): Promise { - const txnPrefix = this.transactionStore.getCookiePrefix(); - const transactionCookies = reqCookies - .getAll() - .filter((cookie) => cookie.name.startsWith(txnPrefix)); - - // Apply threshold-based cleanup using the configured maxTxnCookieCount - // This prevents infinite accumulation while supporting multi-tab scenarios - const threshold = this.maxTxnCookieCount; - - if (transactionCookies.length > threshold) { - // Sort by name (which includes timestamp-based state) to get oldest first - // Since we can't reliably sort by creation time, we use count-based cleanup - const cookiesToDelete = transactionCookies.slice(0, -threshold); - - for (const cookie of cookiesToDelete) { - // Extract state from cookie name to delete via transaction store - const state = cookie.name.substring(txnPrefix.length); - await this.transactionStore.delete(resCookies, state); - } - } - } } const encodeBase64 = (input: string) => { diff --git a/src/server/client.ts b/src/server/client.ts index 451007a3..c0bd2498 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -188,6 +188,8 @@ export interface Auth0ClientOptions { * Defaults to `false`. */ noContentProfileResponseWhenUnauthenticated?: boolean; + + enableParallelTransactions?: boolean; } export type PagesRouterRequest = IncomingMessage | NextApiRequest; @@ -250,7 +252,8 @@ export class Auth0Client { this.transactionStore = new TransactionStore({ ...options.session, secret, - cookieOptions: transactionCookieOptions + cookieOptions: transactionCookieOptions, + enableParallelTransactions: options.enableParallelTransactions || true }); this.sessionStore = options.sessionStore diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 2ce8a692..251d0720 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -36,11 +36,26 @@ export interface TransactionCookieOptions { * The path attribute of the transaction cookie. Will be set to '/' by default. */ path?: string; + /** + * The expiration time for transaction cookies in seconds. + * If not provided, defaults to 1 hour (3600 seconds). + * + * @default 3600 + */ + maxAge?: number; } export interface TransactionStoreOptions { secret: string; cookieOptions?: TransactionCookieOptions; + /** + * Controls whether multiple parallel login transactions are allowed. + * When false, only one transaction cookie is maintained at a time. + * When true (default), multiple transaction cookies can coexist for multi-tab support. + * + * @default true + */ + enableParallelTransactions?: boolean; } /** @@ -49,21 +64,27 @@ export interface TransactionStoreOptions { * the transaction state. */ export class TransactionStore { - private secret: string; - private transactionCookiePrefix: string; - private cookieConfig: cookies.CookieOptions; - - constructor({ secret, cookieOptions }: TransactionStoreOptions) { + private readonly secret: string; + private readonly transactionCookiePrefix: string; + private readonly cookieOptions: cookies.CookieOptions; + private readonly enableParallelTransactions: boolean; + + constructor({ + secret, + cookieOptions, + enableParallelTransactions + }: TransactionStoreOptions) { this.secret = secret; this.transactionCookiePrefix = cookieOptions?.prefix ?? TRANSACTION_COOKIE_PREFIX; - this.cookieConfig = { + this.cookieOptions = { httpOnly: true, sameSite: cookieOptions?.sameSite ?? "lax", // required to allow the cookie to be sent on the callback request secure: cookieOptions?.secure ?? false, path: cookieOptions?.path ?? "/", - maxAge: 60 * 60 // 1 hour in seconds + maxAge: cookieOptions?.maxAge || 60 * 60 // 1 hour in seconds }; + this.enableParallelTransactions = enableParallelTransactions ?? true; } /** @@ -72,7 +93,9 @@ export class TransactionStore { * between different transactions. */ private getTransactionCookieName(state: string) { - return `${this.transactionCookiePrefix}${state}`; + return this.enableParallelTransactions + ? `${this.transactionCookiePrefix}${state}` + : `${this.transactionCookiePrefix}`; } /** @@ -82,12 +105,39 @@ export class TransactionStore { return this.transactionCookiePrefix; } + /** + * Saves the transaction state to an encrypted cookie. + * + * @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. + * @throws {Error} When transaction state is missing required state parameter + */ async save( resCookies: cookies.ResponseCookies, transactionState: TransactionState, - customExpiration?: number + reqCookies?: cookies.RequestCookies ) { - const expirationSeconds = customExpiration ?? this.cookieConfig.maxAge!; + 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) { + // TODO: make this not throw, simply exit with warning log + throw new Error( + "A transaction is already in progress. Only one transaction is allowed when parallel transactions are disabled." + ); + } + } + + const expirationSeconds = this.cookieOptions.maxAge!; const expiration = Math.floor(Date.now() / 1000 + expirationSeconds); const jwe = await cookies.encrypt( transactionState, @@ -95,20 +145,10 @@ export class TransactionStore { expiration ); - if (!transactionState.state) { - throw new Error("Transaction state is required"); - } - - // Create a copy of cookie config with the custom expiration - const cookieOptions = { - ...this.cookieConfig, - maxAge: expirationSeconds - }; - resCookies.set( this.getTransactionCookieName(transactionState.state), jwe.toString(), - cookieOptions + this.cookieOptions ); } diff --git a/tests/v4-infinitely-stacking-cookies.test.ts b/tests/v4-infinitely-stacking-cookies.test.ts index 8cf1c6ae..74c2b53e 100644 --- a/tests/v4-infinitely-stacking-cookies.test.ts +++ b/tests/v4-infinitely-stacking-cookies.test.ts @@ -1,46 +1,164 @@ -import { NextRequest } from "next/server"; +import { NextRequest } from "next/server.js"; +import * as oauth from "oauth4webapi"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AuthClient } from "../src/server/auth-client.js"; +import { StatelessSessionStore } from "../src/server/session/stateless-session-store.js"; +import { TransactionStore } from "../src/server/transaction-store.js"; +import { generateSecret } from "../src/test/utils.js"; -// Mock dependencies -vi.mock("../src/server/transaction-store.js"); -vi.mock("../src/server/session/abstract-session-store.js"); +// Mock oauth4webapi module +vi.mock("oauth4webapi"); -describe("v4-infinitely-stacking-cookies - v4: Infinitely stacking cookies regression", () => { +describe(`v4-infinitely-stacking-cookies - v4: Infinitely stacking cookies regression`, () => { let authClient: AuthClient; - let mockTransactionStore: any; - let mockSessionStore: any; + let transactionStore: TransactionStore; + let sessionStore: StatelessSessionStore; + let secret: string; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); - // Create mocks - mockTransactionStore = { - save: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - deleteAll: vi.fn(), - getCookiePrefix: vi.fn().mockReturnValue("__txn_") - }; - - mockSessionStore = { - get: vi.fn(), - set: vi.fn(), - delete: vi.fn() - }; - - // Create AuthClient instance with mocked dependencies + secret = await generateSecret(32); + transactionStore = new TransactionStore({ + secret, + enableParallelTransactions: true + }); + sessionStore = new StatelessSessionStore({ secret }); + authClient = new AuthClient({ + transactionStore, + sessionStore, domain: "test.auth0.com", clientId: "test-client-id", clientSecret: "test-client-secret", appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore - } as any); + secret, + fetch: vi.fn().mockImplementation((url) => { + // Mock the token endpoint + if (url.includes("/oauth/token")) { + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: "access_token_123", + id_token: + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwibm9uY2UiOiJ0ZXN0LW5vbmNlIiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJpc3MiOiJodHRwczovL3Rlc3QuYXV0aDAuY29tLyIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDA3MjAwfQ.mock_signature", + refresh_token: "refresh_token_123", + token_type: "Bearer", + expires_in: 3600, + scope: "openid profile email" + }), + { + headers: { "Content-Type": "application/json" } + } + ) + ); + } + + // Mock the JWKS endpoint + if (url.includes("/.well-known/jwks.json")) { + return Promise.resolve( + new Response( + JSON.stringify({ + keys: [ + { + kty: "RSA", + kid: "test-key-id", + use: "sig", + n: "mock_n_value", + e: "AQAB" + } + ] + }), + { + headers: { "Content-Type": "application/json" } + } + ) + ); + } + + // Mock the discovery endpoint + if (url.includes("/.well-known/openid_configuration")) { + return Promise.resolve( + new Response( + JSON.stringify({ + issuer: "https://test.auth0.com/", + authorization_endpoint: "https://test.auth0.com/authorize", + token_endpoint: "https://test.auth0.com/oauth/token", + jwks_uri: "https://test.auth0.com/.well-known/jwks.json", + end_session_endpoint: "https://test.auth0.com/v2/logout" + }), + { + headers: { "Content-Type": "application/json" } + } + ) + ); + } + + return Promise.resolve(new Response("Not Found", { status: 404 })); + }) + }); + + // Mock oauth4webapi functions + const mockDiscoveryResponse = new Response( + JSON.stringify({ + issuer: "https://test.auth0.com/", + authorization_endpoint: "https://test.auth0.com/authorize", + token_endpoint: "https://test.auth0.com/oauth/token", + jwks_uri: "https://test.auth0.com/.well-known/jwks.json", + end_session_endpoint: "https://test.auth0.com/v2/logout" + }), + { + headers: { "Content-Type": "application/json" } + } + ); + + const mockAuthServerMetadata = { + issuer: "https://test.auth0.com/", + authorization_endpoint: "https://test.auth0.com/authorize", + token_endpoint: "https://test.auth0.com/oauth/token", + jwks_uri: "https://test.auth0.com/.well-known/jwks.json", + end_session_endpoint: "https://test.auth0.com/v2/logout" + } as oauth.AuthorizationServer; + + vi.mocked(oauth.discoveryRequest).mockResolvedValue(mockDiscoveryResponse); + vi.mocked(oauth.processDiscoveryResponse).mockResolvedValue( + mockAuthServerMetadata + ); + + // Mock PKCE and state generation functions + vi.mocked(oauth.generateRandomState).mockReturnValue("mock-state-123"); + vi.mocked(oauth.generateRandomNonce).mockReturnValue("mock-nonce-123"); + vi.mocked(oauth.generateRandomCodeVerifier).mockReturnValue( + "mock-code-verifier-123" + ); + vi.mocked(oauth.calculatePKCECodeChallenge).mockResolvedValue( + "mock-code-challenge-123" + ); + + vi.mocked(oauth.validateAuthResponse).mockReturnValue( + new URLSearchParams("code=auth_code") + ); + vi.mocked(oauth.authorizationCodeGrantRequest).mockResolvedValue( + new Response() + ); + vi.mocked(oauth.processAuthorizationCodeResponse).mockResolvedValue({ + token_type: "Bearer", + access_token: "access_token_123", + id_token: "mock_id_token", + refresh_token: "refresh_token_123", + expires_in: 3600, + scope: "openid profile email" + } as oauth.TokenEndpointResponse); + vi.mocked(oauth.getValidatedIdTokenClaims).mockReturnValue({ + sub: "user123", + nonce: "test-nonce", + aud: "test-client-id", + iss: "https://test.auth0.com/", + iat: Math.floor(Date.now() / 1000) - 60, + exp: Math.floor(Date.now() / 1000) + 3600 + }); }); afterEach(() => { @@ -48,532 +166,176 @@ describe("v4-infinitely-stacking-cookies - v4: Infinitely stacking cookies regre }); describe("Happy Path", () => { - it("should cleanup excess transaction cookies when starting interactive login", async () => { - // Arrange: Mock request with multiple existing transaction cookies - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "value1" }, - { name: "__txn_state2", value: "value2" }, - { name: "__txn_state3", value: "value3" }, - { name: "other_cookie", value: "other_value" } - ]) - }; - - // Mock the authorization URL generation - vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Act: Start interactive login with request cookies - await authClient.startInteractiveLogin({}, mockCookies as any); - - // Assert: Should have called delete for excess cookies (keeping threshold of 2) - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "state1" + it("should clean up all transaction cookies after successful authentication", async () => { + // Arrange: Create a login + const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); + const loginRes = await authClient.handleLogin(loginReq); + + // Extract the state from the redirect URL + const redirectUrl = new URL(loginRes.headers.get("Location")!); + const state = redirectUrl.searchParams.get("state")!; + + // Get the transaction cookie that was set + const newTxnCookie = loginRes.cookies.get(`__txn_${state}`); + expect(newTxnCookie).toBeDefined(); + + // Simulate callback request with multiple existing transaction cookies + const callbackReq = new NextRequest( + `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` ); - expect(mockTransactionStore.delete).toHaveBeenCalledTimes(1); // Only 1 cookie should be deleted (3 total - 2 threshold = 1) - expect(mockTransactionStore.save).toHaveBeenCalledTimes(1); - }); - it("should not cleanup when transaction cookies are below threshold", async () => { - // Arrange: Mock request with only 1 existing transaction cookie - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "value1" }, - { name: "other_cookie", value: "other_value" } - ]) - }; - - // Mock the authorization URL generation - vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Act: Start interactive login with request cookies - await authClient.startInteractiveLogin({}, mockCookies as any); - - // Assert: Should not have called delete (only 1 cookie, below threshold of 2) - expect(mockTransactionStore.delete).not.toHaveBeenCalled(); - expect(mockTransactionStore.save).toHaveBeenCalledTimes(1); - }); - - it("should cleanup transaction cookie on callback error", async () => { - // Arrange: Mock callback error scenario - const mockRequest = new NextRequest( - "http://localhost:3000/auth/callback?state=test-state&error=access_denied" - ); - - mockTransactionStore.get.mockResolvedValue({ - payload: { - state: "test-state", - returnTo: "/", - nonce: "test-nonce", - codeVerifier: "test-verifier", - responseType: "code" - } - }); - - // Mock discovery to return error - vi.spyOn( - authClient as any, - "discoverAuthorizationServerMetadata" - ).mockResolvedValue([new Error("Discovery failed"), null]); + // Add the stale cookies to the callback request + callbackReq.cookies.set("__txn_old_state_1", "old_value_1"); + callbackReq.cookies.set("__txn_old_state_2", "old_value_2"); + if (newTxnCookie) { + callbackReq.cookies.set(`__txn_${state}`, newTxnCookie.value); + } + + // Act: Handle the callback + const callbackRes = await authClient.handleCallback(callbackReq); + + // Assert: Verify that ALL transaction cookies are cleaned up + expect(callbackRes.status).toBeGreaterThanOrEqual(300); // Should redirect + expect(callbackRes.status).toBeLessThan(400); + + // Check that all transaction cookies are being deleted (set to empty with maxAge 0) + const deletedCookies = callbackRes.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith("__txn_") && + cookie.value === "" && + cookie.maxAge === 0 + ); - // Act: Handle callback with error - const response = await authClient.handleCallback(mockRequest); + // Should have cleaned up all transaction cookies + expect(deletedCookies.length).toBeGreaterThan(0); - // Assert: Should cleanup the transaction cookie on error - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "test-state" - ); - expect(response.status).toBe(500); // Default error response + // Verify a session cookie was set + const sessionCookie = callbackRes.cookies.get("__session"); + expect(sessionCookie).toBeDefined(); + expect(sessionCookie?.value).not.toBe(""); }); }); describe("Edge Cases", () => { - it("should handle cleanup when no request cookies provided", async () => { - // Arrange: Start login without request cookies - vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Act: Start interactive login without request cookies - await authClient.startInteractiveLogin({}); - - // Assert: Should not attempt cleanup but should still save transaction - expect(mockTransactionStore.delete).not.toHaveBeenCalled(); - expect(mockTransactionStore.save).toHaveBeenCalledTimes(1); - }); + it("should handle callback with no existing transaction cookies gracefully", async () => { + // Create a login and get the state + const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); + const loginRes = await authClient.handleLogin(loginReq); - it("should extract state correctly from cookie names", async () => { - // Arrange: Mock cookies with specific state patterns - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { - name: "__txn_RaYuKTZuJbZ-10NrYwmh8sE5Eb-rClUcD3Xr25ea4Jk", - value: "value1" - }, - { name: "__txn_another-long-state-value", value: "value2" }, - { name: "__txn_simple", value: "value3" } - ]) - }; - - vi.spyOn(authClient as any, "authorizationUrl").mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Act - await authClient.startInteractiveLogin({}, mockCookies as any); - - // Assert: Should extract state correctly (delete oldest which is first in array) - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "RaYuKTZuJbZ-10NrYwmh8sE5Eb-rClUcD3Xr25ea4Jk" - ); - }); + const redirectUrl = new URL(loginRes.headers.get("Location")!); + const state = redirectUrl.searchParams.get("state")!; - it("should handle multiple error scenarios in callback", async () => { - // Test missing state error - const requestMissingState = new NextRequest( - "http://localhost:3000/auth/callback" + // Handle callback with only the current transaction cookie + const callbackReq = new NextRequest( + `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` ); - const responseMissingState = - await authClient.handleCallback(requestMissingState); - expect(responseMissingState.status).toBe(500); + const txnCookie = loginRes.cookies.get(`__txn_${state}`); + if (txnCookie) { + callbackReq.cookies.set(`__txn_${state}`, txnCookie.value); + } - // Test invalid state error (transaction not found) - const requestInvalidState = new NextRequest( - "http://localhost:3000/auth/callback?state=invalid-state" - ); - mockTransactionStore.get.mockResolvedValue(null); + const callbackRes = await authClient.handleCallback(callbackReq); - const responseInvalidState = - await authClient.handleCallback(requestInvalidState); - expect(responseInvalidState.status).toBe(500); - // Note: Invalid state error does NOT delete cookie as it may not exist - // This is consistent with existing behavior and prevents double-deletion + // Should still work normally + expect(callbackRes.status).toBeGreaterThanOrEqual(300); + expect(callbackRes.status).toBeLessThan(400); }); - }); - describe("Configuration Options", () => { - describe("enableParallelTransactions option", () => { - it("should delete all transaction cookies when enableParallelTransactions is false", async () => { - // Create AuthClient with parallel transactions disabled - const authClientSingleTxn = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore, - enableParallelTransactions: false - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientSingleTxn as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Mock request with multiple existing transaction cookies - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "txn1" }, - { name: "__txn_state2", value: "txn2" }, - { name: "__txn_state3", value: "txn3" } - ]) - }; - - // Call startInteractiveLogin directly to avoid network calls - await authClientSingleTxn.startInteractiveLogin({}, mockCookies as any); - - // Verify deleteAll was called (for single transaction mode) - expect(mockTransactionStore.deleteAll).toHaveBeenCalledTimes(1); - - // Verify save was called with custom expiration (default 3600) - expect(mockTransactionStore.save).toHaveBeenCalledWith( - expect.anything(), - expect.any(Object), - 3600 - ); - }); + it("should not interfere with non-transaction cookies", async () => { + // Create a login + const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); + const loginRes = await authClient.handleLogin(loginReq); - it("should use threshold-based cleanup when enableParallelTransactions is true", async () => { - // Create AuthClient with parallel transactions enabled (default) - const authClientParallelTxn = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore, - enableParallelTransactions: true, - maxTxnCookieCount: 2 - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientParallelTxn as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Mock 4 existing transaction cookies (above threshold of 2) - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "txn1" }, - { name: "__txn_state2", value: "txn2" }, - { name: "__txn_state3", value: "txn3" }, - { name: "__txn_state4", value: "txn4" } - ]) - }; - - // Call startInteractiveLogin directly to avoid network calls - await authClientParallelTxn.startInteractiveLogin( - {}, - mockCookies as any - ); + const redirectUrl = new URL(loginRes.headers.get("Location")!); + const state = redirectUrl.searchParams.get("state")!; - // Verify deleteAll was NOT called - expect(mockTransactionStore.deleteAll).not.toHaveBeenCalled(); + // Handle callback with mixed cookies + const callbackReq = new NextRequest( + `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` + ); - // Verify individual delete was called for excess cookies (4 - 2 = 2 deletes) - expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "state1" - ); - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "state2" - ); - }); - }); + const txnCookie = loginRes.cookies.get(`__txn_${state}`); + if (txnCookie) { + callbackReq.cookies.set(`__txn_${state}`, txnCookie.value); + } + callbackReq.cookies.set("other_cookie", "should_not_be_deleted"); + callbackReq.cookies.set("user_pref", "also_should_remain"); - describe("txnCookieExpiration option", () => { - it("should use custom expiration when provided", async () => { - const customExpiration = 7200; // 2 hours - - const authClientCustomExp = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore, - txnCookieExpiration: customExpiration - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientCustomExp as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - const mockCookies = { - getAll: vi.fn().mockReturnValue([]) - }; - - await authClientCustomExp.startInteractiveLogin({}, mockCookies as any); - - // Verify save was called with custom expiration - expect(mockTransactionStore.save).toHaveBeenCalledWith( - expect.anything(), - expect.any(Object), - customExpiration - ); - }); + const callbackRes = await authClient.handleCallback(callbackReq); - it("should use default expiration (3600) when not provided", async () => { - const authClientDefaultExp = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore - // txnCookieExpiration not provided - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientDefaultExp as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - const mockCookies = { - getAll: vi.fn().mockReturnValue([]) - }; - - await authClientDefaultExp.startInteractiveLogin( - {}, - mockCookies as any - ); + // Check that only transaction cookies are deleted + const deletedCookies = callbackRes.cookies + .getAll() + .filter((cookie) => cookie.value === "" && cookie.maxAge === 0); - // Verify save was called with default expiration - expect(mockTransactionStore.save).toHaveBeenCalledWith( - expect.anything(), - expect.any(Object), - 3600 - ); - }); + const deletedTxnCookies = deletedCookies.filter((cookie) => + cookie.name.startsWith("__txn_") + ); + const deletedOtherCookies = deletedCookies.filter( + (cookie) => + !cookie.name.startsWith("__txn_") && + !cookie.name.startsWith("__session") && + cookie.name !== "appSession" // Ignore session-related cookies + ); + + expect(deletedTxnCookies.length).toBeGreaterThan(0); + expect(deletedOtherCookies.length).toBe(0); }); + }); - describe("maxTxnCookieCount option", () => { - it("should use custom maxTxnCookieCount for cleanup threshold", async () => { - const customMaxCount = 5; - - const authClientCustomMax = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore, - enableParallelTransactions: true, - maxTxnCookieCount: customMaxCount - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientCustomMax as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Mock 7 existing transaction cookies (above threshold of 5) - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "txn1" }, - { name: "__txn_state2", value: "txn2" }, - { name: "__txn_state3", value: "txn3" }, - { name: "__txn_state4", value: "txn4" }, - { name: "__txn_state5", value: "txn5" }, - { name: "__txn_state6", value: "txn6" }, - { name: "__txn_state7", value: "txn7" } - ]) - }; - - await authClientCustomMax.startInteractiveLogin({}, mockCookies as any); - - // Verify delete was called for excess cookies (7 - 5 = 2 deletes) - expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "state1" - ); - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "state2" - ); + describe("enableParallelTransactions: false", () => { + it("should use single transaction cookie without state suffix", async () => { + // Arrange: Create auth client with parallel transactions disabled + const singleTxnTransactionStore = new TransactionStore({ + secret, + enableParallelTransactions: false }); - it("should use default maxTxnCookieCount (2) when not provided", async () => { - const authClientDefaultMax = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore, - enableParallelTransactions: true - // maxTxnCookieCount not provided (default should be 2) - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientDefaultMax as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Mock 4 existing transaction cookies (above default threshold of 2) - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "txn1" }, - { name: "__txn_state2", value: "txn2" }, - { name: "__txn_state3", value: "txn3" }, - { name: "__txn_state4", value: "txn4" } - ]) - }; - - await authClientDefaultMax.startInteractiveLogin( - {}, - mockCookies as any - ); - - // Verify delete was called for excess cookies (4 - 2 = 2 deletes) - expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "state1" - ); - expect(mockTransactionStore.delete).toHaveBeenCalledWith( - expect.anything(), - "state2" - ); + const singleTxnAuthClient = new AuthClient({ + transactionStore: singleTxnTransactionStore, + sessionStore, + domain: "test.auth0.com", + clientId: "test-client-id", + clientSecret: "test-client-secret", + appBaseUrl: "http://localhost:3000", + secret, + fetch: authClient["fetch"] }); - it("should not cleanup when transaction count is below threshold", async () => { - const authClientBelowThreshold = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore, - enableParallelTransactions: true, - maxTxnCookieCount: 5 - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientBelowThreshold as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Mock 3 existing transaction cookies (below threshold of 5) - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "txn1" }, - { name: "__txn_state2", value: "txn2" }, - { name: "__txn_state3", value: "txn3" } - ]) - }; - - await authClientBelowThreshold.startInteractiveLogin( - {}, - mockCookies as any - ); + // Act: Create a login + const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); + const loginRes = await singleTxnAuthClient.handleLogin(loginReq); - // Verify no cleanup occurred - expect(mockTransactionStore.delete).not.toHaveBeenCalled(); - expect(mockTransactionStore.deleteAll).not.toHaveBeenCalled(); - }); + // Assert: Should use __txn_ without state suffix + const txnCookies = loginRes.cookies + .getAll() + .filter((cookie) => cookie.name.startsWith("__txn_")); + + expect(txnCookies).toHaveLength(1); + expect(txnCookies[0].name).toBe("__txn_"); // No state suffix when parallel transactions disabled }); + }); - describe("Combined options behavior", () => { - it("should respect all configuration options together", async () => { - const authClientCombined = new AuthClient({ - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret: "test-secret", - transactionStore: mockTransactionStore, - sessionStore: mockSessionStore, - enableParallelTransactions: true, - txnCookieExpiration: 1800, // 30 minutes - maxTxnCookieCount: 3 - } as any); - - // Mock the authorization URL generation - vi.spyOn( - authClientCombined as any, - "authorizationUrl" - ).mockResolvedValue([ - null, - new URL("https://test.auth0.com/authorize") - ]); - - // Mock 5 existing transaction cookies (above threshold of 3) - const mockCookies = { - getAll: vi.fn().mockReturnValue([ - { name: "__txn_state1", value: "txn1" }, - { name: "__txn_state2", value: "txn2" }, - { name: "__txn_state3", value: "txn3" }, - { name: "__txn_state4", value: "txn4" }, - { name: "__txn_state5", value: "txn5" } - ]) - }; - - await authClientCombined.startInteractiveLogin({}, mockCookies as any); - - // Verify cleanup used custom threshold (5 - 3 = 2 deletes) - expect(mockTransactionStore.delete).toHaveBeenCalledTimes(2); - - // Verify save used custom expiration - expect(mockTransactionStore.save).toHaveBeenCalledWith( - expect.anything(), - expect.any(Object), - 1800 - ); + describe("Transaction Store Integration", () => { + it("should skip existence check when reqCookies is not provided in startInteractiveLogin", async () => { + // This is an integration test to verify that startInteractiveLogin + // calls save() without reqCookies, thus skipping the existence check - // Verify deleteAll was not called (parallel transactions enabled) - expect(mockTransactionStore.deleteAll).not.toHaveBeenCalled(); - }); + // Arrange: Spy on the transaction store save method + const saveSpy = vi.spyOn(transactionStore, "save"); + + // Act: Call startInteractiveLogin + await authClient.startInteractiveLogin(); + + // Assert: Verify save was called with only 2 parameters (no reqCookies) + expect(saveSpy).toHaveBeenCalledTimes(1); + const [resCookies, transactionState, reqCookies] = saveSpy.mock.calls[0]; + expect(resCookies).toBeDefined(); + expect(transactionState).toBeDefined(); + expect(reqCookies).toBeUndefined(); // Should be undefined for performance }); }); }); From 902b1853e21348c9306da4b852ea80479043b8ce Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 23 Jul 2025 22:13:55 +0530 Subject: [PATCH 03/10] fix: fix tests --- tests/v4-infinitely-stacking-cookies.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/v4-infinitely-stacking-cookies.test.ts b/tests/v4-infinitely-stacking-cookies.test.ts index 74c2b53e..e126e366 100644 --- a/tests/v4-infinitely-stacking-cookies.test.ts +++ b/tests/v4-infinitely-stacking-cookies.test.ts @@ -35,6 +35,11 @@ describe(`v4-infinitely-stacking-cookies - v4: Infinitely stacking cookies regre clientSecret: "test-client-secret", appBaseUrl: "http://localhost:3000", secret, + routes: { + login: "/api/auth/login", + logout: "/api/auth/logout", + callback: "/api/auth/callback" + }, fetch: vi.fn().mockImplementation((url) => { // Mock the token endpoint if (url.includes("/oauth/token")) { From 9d850690d51df17c989ccad367317e7e2d5e0905 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 23 Jul 2025 22:23:57 +0530 Subject: [PATCH 04/10] fix: fix tests --- tests/v4-infinitely-stacking-cookies.test.ts | 71 +++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/v4-infinitely-stacking-cookies.test.ts b/tests/v4-infinitely-stacking-cookies.test.ts index e126e366..a60d189a 100644 --- a/tests/v4-infinitely-stacking-cookies.test.ts +++ b/tests/v4-infinitely-stacking-cookies.test.ts @@ -307,7 +307,76 @@ describe(`v4-infinitely-stacking-cookies - v4: Infinitely stacking cookies regre clientSecret: "test-client-secret", appBaseUrl: "http://localhost:3000", secret, - fetch: authClient["fetch"] + routes: { + login: "/api/auth/login", + logout: "/api/auth/logout", + callback: "/api/auth/callback" + }, + fetch: vi.fn().mockImplementation((url) => { + // Mock the token endpoint + if (url.includes("/oauth/token")) { + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: "access_token_123", + id_token: + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwibm9uY2UiOiJ0ZXN0LW5vbmNlIiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJpc3MiOiJodHRwczovL3Rlc3QuYXV0aDAuY29tLyIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDA3MjAwfQ.mock_signature", + refresh_token: "refresh_token_123", + token_type: "Bearer", + expires_in: 3600, + scope: "openid profile email" + }), + { + headers: { "Content-Type": "application/json" } + } + ) + ); + } + + // Mock discovery endpoint + if (url.includes("/.well-known/openid_configuration")) { + return Promise.resolve( + new Response( + JSON.stringify({ + issuer: "https://test.auth0.com/", + authorization_endpoint: "https://test.auth0.com/authorize", + token_endpoint: "https://test.auth0.com/oauth/token", + userinfo_endpoint: "https://test.auth0.com/userinfo", + jwks_uri: "https://test.auth0.com/.well-known/jwks.json", + end_session_endpoint: "https://test.auth0.com/v2/logout" + }), + { + headers: { "Content-Type": "application/json" } + } + ) + ); + } + + // Mock the JWKS endpoint + if (url.includes("/.well-known/jwks.json")) { + return Promise.resolve( + new Response( + JSON.stringify({ + keys: [ + { + kty: "RSA", + kid: "test-key-id", + use: "sig", + n: "mock_n_value", + e: "AQAB" + } + ] + }), + { + headers: { "Content-Type": "application/json" } + } + ) + ); + } + + // Default mock response + return Promise.resolve(new Response("Not Found", { status: 404 })); + }) }); // Act: Create a login From bd4bba88b2d4be17178d738180b2040ea5d9efbb Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 23 Jul 2025 22:44:35 +0530 Subject: [PATCH 05/10] fix: fix keylike linting errors Cherry-picked and combined commits: - 2c0a2f29: chore: fix linting - 157189ca: fix: fix keylike errors in lint - d0297874: simplify lint changes This fixes TypeScript linting errors related to jose.KeyLike type usage by properly defining the supported key types for client assertion signing. --- src/server/auth-client.test.ts | 2 +- src/server/auth-client.ts | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index adc11758..87c6050f 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -160,7 +160,7 @@ ca/T0LLtgmbMmxSv/MmzIg== audience?: string; issuer?: string; alg?: string; - privateKey?: CryptoKey; + privateKey?: jose.CryptoKey; }): Promise { return await new jose.SignJWT({ events: { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index b451323d..7390fdf0 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -118,7 +118,7 @@ export interface AuthClientOptions { domain: string; clientId: string; clientSecret?: string; - clientAssertionSigningKey?: string | CryptoKey; + clientAssertionSigningKey?: string | jose.CryptoKey; clientAssertionSigningAlg?: string; authorizationParameters?: AuthorizationParameters; pushedAuthorizationRequests?: boolean; @@ -156,7 +156,7 @@ export class AuthClient { private clientMetadata: oauth.Client; private clientSecret?: string; - private clientAssertionSigningKey?: string | CryptoKey; + private clientAssertionSigningKey?: string | jose.CryptoKey; private clientAssertionSigningAlg: string; private domain: string; private authorizationParameters: AuthorizationParameters; @@ -1110,11 +1110,10 @@ export class AuthClient { ); } - let clientPrivateKey = this.clientAssertionSigningKey as - | CryptoKey - | undefined; + let clientPrivateKey: jose.CryptoKey | undefined = this + .clientAssertionSigningKey as jose.CryptoKey | undefined; - if (clientPrivateKey && !(clientPrivateKey instanceof CryptoKey)) { + if (clientPrivateKey && typeof clientPrivateKey === "string") { clientPrivateKey = await jose.importPKCS8( clientPrivateKey, this.clientAssertionSigningAlg @@ -1122,7 +1121,7 @@ export class AuthClient { } return clientPrivateKey - ? oauth.PrivateKeyJwt(clientPrivateKey) + ? oauth.PrivateKeyJwt(clientPrivateKey as CryptoKey) : oauth.ClientSecretPost(this.clientSecret!); } From 90b7b21413587b3500cdbf13a7d8247fcddae172 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Jul 2025 00:02:56 +0530 Subject: [PATCH 06/10] fix: make sure custom maxage for txn cookie works, update tests --- src/server/client.test.ts | 101 +++++++++++++++++++++++++++ src/server/client.ts | 5 +- src/server/transaction-store.test.ts | 40 +++++++++++ src/server/transaction-store.ts | 4 +- 4 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/server/client.test.ts b/src/server/client.test.ts index cc3a4aa7..6da2a423 100644 --- a/src/server/client.test.ts +++ b/src/server/client.test.ts @@ -214,4 +214,105 @@ describe("Auth0Client", () => { expect(mockSaveToSession).not.toHaveBeenCalled(); }); }); + + describe("constructor configuration", () => { + beforeEach(() => { + // Set necessary environment variables + process.env[ENV_VARS.DOMAIN] = "test.auth0.com"; + process.env[ENV_VARS.CLIENT_ID] = "test_client_id"; + process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret"; + process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test"; + process.env[ENV_VARS.SECRET] = "test_secret"; + }); + + it("should pass transactionCookie.maxAge to TransactionStore", () => { + const customMaxAge = 1800; // 30 minutes + + const client = new Auth0Client({ + transactionCookie: { + maxAge: customMaxAge + } + }); + + // Verify that the TransactionStore was created with the correct maxAge + // We need to access the private property for testing + const transactionStore = (client as any).transactionStore; + expect(transactionStore).toBeDefined(); + + // Check the cookieOptions maxAge - we need to verify it was set correctly + const cookieOptions = (transactionStore as any).cookieOptions; + expect(cookieOptions.maxAge).toBe(customMaxAge); + }); + + it("should use default maxAge of 3600 when not specified", () => { + const client = new Auth0Client(); + + // Verify that the TransactionStore was created with the default maxAge + const transactionStore = (client as any).transactionStore; + expect(transactionStore).toBeDefined(); + + // Check the cookieOptions maxAge + const cookieOptions = (transactionStore as any).cookieOptions; + expect(cookieOptions.maxAge).toBe(3600); + }); + + it("should pass other transactionCookie options to TransactionStore", () => { + const customOptions = { + prefix: "__custom_txn_", + secure: true, + sameSite: "strict" as const, + path: "/auth", + maxAge: 2700 + }; + + const client = new Auth0Client({ + transactionCookie: customOptions + }); + + // Verify that the TransactionStore was created with the correct options + const transactionStore = (client as any).transactionStore; + expect(transactionStore).toBeDefined(); + + const cookieOptions = (transactionStore as any).cookieOptions; + expect(cookieOptions.maxAge).toBe(customOptions.maxAge); + expect((transactionStore as any).transactionCookiePrefix).toBe( + customOptions.prefix + ); + + // Note: secure and sameSite are stored in cookieOptions + expect(cookieOptions.secure).toBe(customOptions.secure); + expect(cookieOptions.sameSite).toBe(customOptions.sameSite); + expect(cookieOptions.path).toBe(customOptions.path); + }); + + it("should pass enableParallelTransactions to TransactionStore", () => { + const client = new Auth0Client({ + enableParallelTransactions: false + }); + + // Verify that the TransactionStore was created with the correct enableParallelTransactions + const transactionStore = (client as any).transactionStore; + expect(transactionStore).toBeDefined(); + + const enableParallelTransactions = (transactionStore as any) + .enableParallelTransactions; + expect(enableParallelTransactions).toBe(false); + }); + + it("should default enableParallelTransactions to true when not specified", () => { + const client = new Auth0Client(); + + // Verify that the TransactionStore was created with the default enableParallelTransactions + const transactionStore = (client as any).transactionStore; + expect(transactionStore).toBeDefined(); + + const enableParallelTransactions = (transactionStore as any) + .enableParallelTransactions; + expect(enableParallelTransactions).toBe(true); + }); + }); }); + +export type GetAccessTokenOptions = { + refresh?: boolean; +}; diff --git a/src/server/client.ts b/src/server/client.ts index 1142ec9f..6a36ab8d 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -249,7 +249,8 @@ export class Auth0Client { prefix: options.transactionCookie?.prefix ?? "__txn_", secure: options.transactionCookie?.secure ?? false, sameSite: options.transactionCookie?.sameSite ?? "lax", - path: options.transactionCookie?.path ?? "/" + path: options.transactionCookie?.path ?? "/", + maxAge: options.transactionCookie?.maxAge ?? 3600 }; if (appBaseUrl) { @@ -275,7 +276,7 @@ export class Auth0Client { ...options.session, secret, cookieOptions: transactionCookieOptions, - enableParallelTransactions: options.enableParallelTransactions || true + enableParallelTransactions: options.enableParallelTransactions ?? true }); this.sessionStore = options.sessionStore diff --git a/src/server/transaction-store.test.ts b/src/server/transaction-store.test.ts index 403f798c..bcdcd7b0 100644 --- a/src/server/transaction-store.test.ts +++ b/src/server/transaction-store.test.ts @@ -293,6 +293,46 @@ describe("Transaction Store", async () => { expect(cookie?.maxAge).toEqual(3600); expect(cookie?.secure).toEqual(false); }); + + it("should apply custom maxAge to the cookie", async () => { + const secret = await generateSecret(32); + const codeVerifier = oauth.generateRandomCodeVerifier(); + const nonce = oauth.generateRandomNonce(); + const state = oauth.generateRandomState(); + const transactionState: TransactionState = { + nonce, + maxAge: 3600, + codeVerifier: codeVerifier, + responseType: "code", + state, + returnTo: "/dashboard" + }; + const headers = new Headers(); + const responseCookies = new ResponseCookies(headers); + + const customMaxAge = 1800; // 30 minutes + const transactionStore = new TransactionStore({ + secret, + cookieOptions: { + maxAge: customMaxAge + } + }); + await transactionStore.save(responseCookies, transactionState); + + const cookieName = `__txn_${state}`; + const cookie = responseCookies.get(cookieName); + + expect(cookie).toBeDefined(); + expect( + ((await decrypt(cookie!.value, secret)) as jose.JWTDecryptResult) + .payload + ).toEqual(expect.objectContaining(transactionState)); + expect(cookie?.path).toEqual("/"); + expect(cookie?.httpOnly).toEqual(true); + expect(cookie?.sameSite).toEqual("lax"); + expect(cookie?.maxAge).toEqual(customMaxAge); + expect(cookie?.secure).toEqual(false); + }); }); }); diff --git a/src/server/transaction-store.ts b/src/server/transaction-store.ts index 251d0720..81ccc204 100644 --- a/src/server/transaction-store.ts +++ b/src/server/transaction-store.ts @@ -130,10 +130,10 @@ export class TransactionStore { const cookieName = this.getTransactionCookieName(transactionState.state); const existingCookie = reqCookies.get(cookieName); if (existingCookie) { - // TODO: make this not throw, simply exit with warning log - throw new Error( + console.warn( "A transaction is already in progress. Only one transaction is allowed when parallel transactions are disabled." ); + return; } } From 16149d098085fad36ab279a6fc158e22f96ce13e Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Jul 2025 00:03:23 +0530 Subject: [PATCH 07/10] docs: update docs for txn cookie changes --- EXAMPLES.md | 133 ++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ V4_MIGRATION_GUIDE.md | 37 ++++++++++++ 3 files changed, 174 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index f2dab698..651a82a1 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -30,6 +30,7 @@ - [`onCallback`](#oncallback) - [Session configuration](#session-configuration) - [Cookie Configuration](#cookie-configuration) +- [Transaction Cookie Configuration](#transaction-cookie-configuration) - [Database sessions](#database-sessions) - [Back-Channel Logout](#back-channel-logout) - [Combining middleware](#combining-middleware) @@ -49,6 +50,7 @@ - [Customizing Auth Handlers](#customizing-auth-handlers) - [Run custom code before Auth Handlers](#run-custom-code-before-auth-handlers) - [Run code after callback](#run-code-after-callback) +- [Troubleshooting Cookie Issues](#troubleshooting-cookie-issues) ## Passing authorization parameters @@ -880,6 +882,66 @@ export const auth0 = new Auth0Client({ }); ``` +## Troubleshooting Cookie Issues + +### HTTP 413 Request Entity Too Large Errors + +If you're experiencing HTTP 413 errors during authentication, this is likely due to transaction cookie accumulation. Transaction cookies (`__txn_*`) are created for each authentication attempt and can accumulate if not properly cleaned up. + +**Solution**: Configure single transaction mode to prevent cookie accumulation: + +```ts +import { TransactionStore } from "@auth0/nextjs-auth0/server"; + +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: false, // Prevents cookie accumulation + cookieOptions: { + maxAge: 1800 // 30 minutes + } +}); + +export const auth0 = new Auth0Client({ + transactionStore, + // ... other options +}); +``` + +### Too Many Transaction Cookies + +If you see multiple `__txn_*` cookies in your browser's developer tools: + +1. **For parallel transactions (default)**: This is normal behavior that supports multi-tab login flows +2. **To reduce cookie count**: Switch to single transaction mode as shown above +3. **Automatic cleanup**: Cookies will be cleaned up automatically after successful authentication or when they expire + +### Cookie Expiration Issues + +Transaction cookies expire after 1 hour by default. If you need different expiration times: + +```ts +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + cookieOptions: { + maxAge: 3600 // Customize expiration time in seconds + } +}); +``` + +### Development vs Production Cookie Settings + +Different cookie settings may be needed for development and production: + +```ts +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + cookieOptions: { + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "lax" : "lax" + } +}); +``` + ## Session configuration The session configuration can be managed by specifying a `session` object when configuring the Auth0 client, like so: @@ -961,6 +1023,77 @@ export const auth0 = new Auth0Client({ > [!INFO] > The `httpOnly` attribute for the session cookie is always set to `true` for security reasons and cannot be configured via options or environment variables. +## Transaction Cookie Configuration + +Transaction cookies are used to maintain state during authentication flows. The SDK provides several configuration options to manage transaction cookie behavior and prevent cookie accumulation issues. + +You can configure transaction cookies by providing a custom `TransactionStore` when initializing the Auth0 client: + +```ts +import { TransactionStore } from "@auth0/nextjs-auth0/server"; + +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: false, // Single transaction mode + cookieOptions: { + maxAge: 1800, // 30 minutes (in seconds) + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/" + } +}); + +export const auth0 = new Auth0Client({ + transactionStore, + // ... other options +}); +``` + +### Transaction Management Modes + +**Parallel Transactions (Default)** +```ts +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: true // Default: allows multiple concurrent logins +}); +``` + +**Single Transaction Mode** +```ts +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: false // Only one active transaction at a time +}); +``` + +### Transaction Cookie Options + +| Option | Type | Description | +| -------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| enableParallelTransactions | `boolean` | When `true` (default), allows multiple parallel login transactions for multi-tab support. When `false`, only one transaction cookie is maintained at a time. | +| 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. Automatically determined based on your application's base URL protocol if not specified. | +| cookieOptions.path | `string` | Specifies the URL path for which the cookie is valid. Defaults to `"/"`. | + +### When to Use Single vs Parallel Transactions + +**Use Parallel Transactions (Default) When:** +- Users might open multiple tabs and attempt to log in simultaneously +- You want maximum compatibility with typical user behavior +- Your application supports multiple concurrent authentication flows + +**Use Single Transaction Mode When:** +- You want to prevent cookie accumulation issues in applications with frequent login attempts +- You prefer simpler transaction management +- Users typically don't need multiple concurrent login flows +- You're experiencing cookie header size limits due to abandoned transaction cookies + +> [!NOTE] +> The SDK automatically cleans up transaction cookies after successful authentication or logout. The `maxAge` setting provides additional protection against abandoned cookies from incomplete authentication flows. + ## Database sessions By default, the user's sessions are stored in encrypted cookies. You may choose to persist the sessions in your data store of choice. diff --git a/README.md b/README.md index 7f0cde2d..7cbd1254 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,9 @@ import { Auth0Client } from "@auth0/nextjs-auth0/server"; export const auth0 = new Auth0Client(); ``` +> [!NOTE] +> The Auth0Client automatically creates a `TransactionStore` with safe defaults to manage authentication cookies. For advanced use cases, you can customize transaction cookie behavior by providing your own `TransactionStore` configuration. See [Transaction Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#transaction-cookie-configuration) for details. + ### 4. Add the authentication middleware Create a `middleware.ts` file in the root of your project's directory: @@ -139,6 +142,7 @@ You can customize the client by using the options below: | secret | `string` | A 32-byte, hex-encoded secret used for encrypting cookies. If it's not specified, it will be loaded from the `AUTH0_SECRET` environment variable. | | signInReturnToPath | `string` | The path to redirect the user to after successfully authenticating. Defaults to `/`. | | session | `SessionConfiguration` | Configure the session timeouts and whether to use rolling sessions or not. See [Session configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#session-configuration) for additional details. Also allows configuration of cookie attributes like `domain`, `path`, `secure`, `sameSite`, and `transient`. If not specified, these can be configured using `AUTH0_COOKIE_*` environment variables. Note: `httpOnly` is always `true`. See [Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#cookie-configuration) for details. | +| transactionStore | `TransactionStore` | Configure transaction cookie management for authentication flows. You can control parallel transaction support and cookie expiration. See [Transaction Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#transaction-cookie-configuration) for details. | | beforeSessionSaved | `BeforeSessionSavedHook` | A method to manipulate the session before persisting it. See [beforeSessionSaved](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#beforesessionsaved) for additional details. | | onCallback | `OnCallbackHook` | A method to handle errors or manage redirects after attempting to authenticate. See [onCallback](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#oncallback) for additional details. | | sessionStore | `SessionStore` | A custom session store implementation used to persist sessions to a data store. See [Database sessions](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#database-sessions) for additional details. | diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index 187c3f2c..a6ffa5f9 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -375,8 +375,45 @@ export const auth0 = new Auth0Client({ - **Login parameters**: Use query parameters (`/auth/login?audience=...`) or static configuration - **Session data**: Use the `beforeSessionSaved` hook to modify session data - **Logout redirects**: Use query parameters (`/auth/logout?returnTo=...`) +- **Transaction cookies**: Configure transaction cookie behavior with `TransactionStore` options. See [Transaction Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#transaction-cookie-configuration) for details. > [!IMPORTANT] > Always validate redirect URLs to prevent open redirect attacks. Use relative URLs when possible. For detailed examples and implementation patterns, see [Customizing Auth Handlers](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#customizing-auth-handlers) in the Examples guide. + +## Transaction Cookie Management in V4 + +V4 introduces improved transaction cookie management to prevent cookie accumulation issues that could cause HTTP 413 errors. The `TransactionStore` now supports: + +- **Configurable parallel transactions**: Control whether multiple login flows can run simultaneously +- **Automatic cookie cleanup**: Transaction cookies expire automatically after 1 hour by default +- **Customizable expiration**: Configure transaction cookie `maxAge` to suit your application needs + +**Default Behavior (No Changes Required):** +```ts +// V4 automatically creates a TransactionStore with safe defaults +export const auth0 = new Auth0Client({ + // All existing V3 options work the same way +}); +``` + +**Custom Transaction Configuration:** +```ts +import { TransactionStore } from "@auth0/nextjs-auth0/server"; + +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: false, // Single-transaction mode + cookieOptions: { + maxAge: 1800 // 30 minutes instead of default 1 hour + } +}); + +export const auth0 = new Auth0Client({ + transactionStore, + // ... other options +}); +``` + +In contrast, V3 did not support parallel transactions. From 4f78851b18b87d6f349cd9548c483225cc842a31 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Jul 2025 00:04:14 +0530 Subject: [PATCH 08/10] chore: add example for txn cookie settings --- examples/transaction-cookie-config/README.md | 173 ++++++++++++++++++ .../transaction-cookie-config/lib/auth0.ts | 74 ++++++++ .../transaction-cookie-config/middleware.ts | 18 ++ .../transaction-cookie-config/package.json | 23 +++ 4 files changed, 288 insertions(+) create mode 100644 examples/transaction-cookie-config/README.md create mode 100644 examples/transaction-cookie-config/lib/auth0.ts create mode 100644 examples/transaction-cookie-config/middleware.ts create mode 100644 examples/transaction-cookie-config/package.json diff --git a/examples/transaction-cookie-config/README.md b/examples/transaction-cookie-config/README.md new file mode 100644 index 00000000..0fce5468 --- /dev/null +++ b/examples/transaction-cookie-config/README.md @@ -0,0 +1,173 @@ +# Transaction Cookie Configuration Example + +This example demonstrates how to configure transaction cookies in the Auth0 Next.js SDK to prevent cookie accumulation and manage authentication flows. + +## Problem Solved + +Prior to these configuration options, transaction cookies (`__txn_*`) would accumulate when users repeatedly navigated to protected routes or started multiple authentication flows. This could eventually cause HTTP 413 (Request Entity Too Large) errors when cookie headers exceeded server limits. + +## Configuration Options + +### Default Configuration (Parallel Transactions) + +```typescript +// lib/auth0.ts +import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; + +// Default configuration - allows multiple parallel transactions +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: true, // Default + cookieOptions: { + maxAge: 3600, // 1 hour (default) + sameSite: "lax", + secure: process.env.NODE_ENV === "production" + } +}); + +export const auth0 = new Auth0Client({ + transactionStore, + // ... other options +}); +``` + +### Single Transaction Mode + +```typescript +// lib/auth0-single-transaction.ts +import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; + +// Single transaction mode - prevents cookie accumulation +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: false, + cookieOptions: { + maxAge: 1800, // 30 minutes + sameSite: "lax", + secure: process.env.NODE_ENV === "production" + } +}); + +export const auth0 = new Auth0Client({ + transactionStore, + // ... other options +}); +``` + +### Custom Cookie Prefix and Settings + +```typescript +// lib/auth0-custom.ts +import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; + +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: true, + cookieOptions: { + prefix: "__myapp_auth_", // Custom prefix instead of __txn_ + maxAge: 2700, // 45 minutes + sameSite: "strict", + secure: true, + path: "/app" // Limit to specific path + } +}); + +export const auth0 = new Auth0Client({ + transactionStore, + // ... other options +}); +``` + +## When to Use Each Mode + +### Parallel Transactions (Default) +✅ **Use when:** +- Users might open multiple tabs and log in simultaneously +- You want maximum compatibility with user behavior +- Your application supports concurrent authentication flows + +### Single Transaction Mode +✅ **Use when:** +- You want to prevent cookie accumulation issues +- Users typically don't need multiple concurrent login flows +- You're experiencing cookie header size limits +- You prefer simpler transaction management + +## Environment Variables + +The basic Auth0 configuration still uses the same environment variables: + +```env +# .env.local +AUTH0_DOMAIN=your-domain.auth0.com +AUTH0_CLIENT_ID=your-client-id +AUTH0_CLIENT_SECRET=your-client-secret +AUTH0_SECRET=your-32-character-secret +APP_BASE_URL=http://localhost:3000 +``` + +## Middleware Setup + +The middleware setup remains the same regardless of transaction cookie configuration: + +```typescript +// middleware.ts +import type { NextRequest } from "next/server"; +import { auth0 } from "./lib/auth0"; // Use your configured auth0 instance + +export async function middleware(request: NextRequest) { + return await auth0.middleware(request); +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)" + ] +}; +``` + +## Testing the Configuration + +You can test your transaction cookie configuration by: + +1. **Multi-tab testing**: Open multiple tabs and try logging in simultaneously +2. **Cookie inspection**: Check browser dev tools to see transaction cookies +3. **Abandoned flow testing**: Start login flows and navigate away to see cleanup + +### Expected Cookie Behavior + +**Parallel Mode:** +- Multiple `__txn_{state}` cookies during concurrent logins +- Automatic cleanup after successful authentication +- Cookies expire after `maxAge` seconds + +**Single Mode:** +- Only one `__txn_` cookie at a time +- New logins replace existing transaction cookies +- Simpler cookie management + +## Migration from Default Configuration + +If you're experiencing cookie accumulation issues, you can migrate to single transaction mode: + +```typescript +// Before (using defaults) +export const auth0 = new Auth0Client({ + // ... your existing config +}); + +// After (single transaction mode) +import { TransactionStore } from "@auth0/nextjs-auth0/server"; + +const transactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: false +}); + +export const auth0 = new Auth0Client({ + transactionStore, + // ... your existing config +}); +``` + +This change is backward compatible and won't affect existing user sessions. diff --git a/examples/transaction-cookie-config/lib/auth0.ts b/examples/transaction-cookie-config/lib/auth0.ts new file mode 100644 index 00000000..1bb0365d --- /dev/null +++ b/examples/transaction-cookie-config/lib/auth0.ts @@ -0,0 +1,74 @@ +import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; + +// Example 1: Single Transaction Mode (Prevents Cookie Accumulation) +const singleTransactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: false, + cookieOptions: { + maxAge: 1800, // 30 minutes + sameSite: "lax", + secure: process.env.NODE_ENV === "production" + } +}); + +export const auth0Single = new Auth0Client({ + transactionStore: singleTransactionStore, + domain: process.env.AUTH0_DOMAIN!, + clientId: process.env.AUTH0_CLIENT_ID!, + clientSecret: process.env.AUTH0_CLIENT_SECRET!, + appBaseUrl: process.env.APP_BASE_URL!, + secret: process.env.AUTH0_SECRET!, + routes: { + login: "/auth/login", + logout: "/auth/logout", + callback: "/auth/callback" + } +}); + +// Example 2: Parallel Transactions with Custom Settings +const parallelTransactionStore = new TransactionStore({ + secret: process.env.AUTH0_SECRET!, + enableParallelTransactions: true, // Default + cookieOptions: { + maxAge: 2700, // 45 minutes + prefix: "__myapp_txn_", // Custom prefix + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/" + } +}); + +export const auth0Parallel = new Auth0Client({ + transactionStore: parallelTransactionStore, + domain: process.env.AUTH0_DOMAIN!, + clientId: process.env.AUTH0_CLIENT_ID!, + clientSecret: process.env.AUTH0_CLIENT_SECRET!, + appBaseUrl: process.env.APP_BASE_URL!, + secret: process.env.AUTH0_SECRET!, + routes: { + login: "/auth/login", + logout: "/auth/logout", + callback: "/auth/callback" + } +}); + +// Example 3: Using Default Configuration (No Custom TransactionStore) +export const auth0Default = new Auth0Client({ + // TransactionStore will be created automatically with default settings: + // - enableParallelTransactions: true + // - maxAge: 3600 (1 hour) + // - prefix: "__txn_" + domain: process.env.AUTH0_DOMAIN!, + clientId: process.env.AUTH0_CLIENT_ID!, + clientSecret: process.env.AUTH0_CLIENT_SECRET!, + appBaseUrl: process.env.APP_BASE_URL!, + secret: process.env.AUTH0_SECRET!, + routes: { + login: "/auth/login", + logout: "/auth/logout", + callback: "/auth/callback" + } +}); + +// Use the appropriate client based on your needs +export const auth0 = auth0Single; // or auth0Parallel, auth0Default diff --git a/examples/transaction-cookie-config/middleware.ts b/examples/transaction-cookie-config/middleware.ts new file mode 100644 index 00000000..7d9117b6 --- /dev/null +++ b/examples/transaction-cookie-config/middleware.ts @@ -0,0 +1,18 @@ +import type { NextRequest } from "next/server"; +import { auth0 } from "./lib/auth0"; + +export async function middleware(request: NextRequest) { + return await auth0.middleware(request); +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico, sitemap.xml, robots.txt (metadata files) + */ + "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)" + ] +}; diff --git a/examples/transaction-cookie-config/package.json b/examples/transaction-cookie-config/package.json new file mode 100644 index 00000000..619b9837 --- /dev/null +++ b/examples/transaction-cookie-config/package.json @@ -0,0 +1,23 @@ +{ + "name": "transaction-cookie-config-example", + "version": "1.0.0", + "description": "Example demonstrating transaction cookie configuration in Auth0 Next.js SDK", + "main": "index.js", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@auth0/nextjs-auth0": "^4.0.0", + "next": "^14.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "keywords": [ + "auth0", + "nextjs", + "transaction-cookies", + "authentication" + ] +} From ea756009d206b694053a5123711d97c7aa770fd8 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Jul 2025 00:37:14 +0530 Subject: [PATCH 09/10] fix: fix tests --- .../redundant-txn-cookie-deletion.test.ts | 583 +++++++++++++++--- tests/v4-infinitely-stacking-cookies.test.ts | 415 ------------- 2 files changed, 496 insertions(+), 502 deletions(-) delete mode 100644 tests/v4-infinitely-stacking-cookies.test.ts diff --git a/src/server/redundant-txn-cookie-deletion.test.ts b/src/server/redundant-txn-cookie-deletion.test.ts index 3a2b7bef..aaf8d190 100644 --- a/src/server/redundant-txn-cookie-deletion.test.ts +++ b/src/server/redundant-txn-cookie-deletion.test.ts @@ -1,10 +1,23 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { NextRequest } from "next/server.js"; +import * as jose from "jose"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; import * as oauth from "oauth4webapi"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from "vitest"; import { InvalidStateError, MissingStateError } from "../errors/index.js"; import { getDefaultRoutes } from "../test/defaults.js"; +import { generateSecret } from "../test/utils.js"; import { SessionData } from "../types/index.js"; import { AuthClient, AuthClientOptions } from "./auth-client.js"; import { @@ -16,13 +29,111 @@ import { AbstractSessionStore, SessionStoreOptions } from "./session/abstract-session-store.js"; +import { StatelessSessionStore } from "./session/stateless-session-store.js"; import { TransactionStore } from "./transaction-store.js"; -vi.mock("./transaction-store"); -vi.mock("oauth4webapi"); -vi.mock("jose"); +// Only mock specific oauth4webapi functions that need predictable values +vi.mock("oauth4webapi", async () => { + const actual = await vi.importActual("oauth4webapi"); + return { + ...actual, + // Mock PKCE generation functions for predictable test values + generateRandomState: vi.fn(), + generateRandomNonce: vi.fn(), + generateRandomCodeVerifier: vi.fn(), + calculatePKCECodeChallenge: vi.fn(), + // Mock HTTP-related functions for MSW integration + discoveryRequest: vi.fn(), + processDiscoveryResponse: vi.fn(), + // Mock response validation since it's pure function processing + validateAuthResponse: vi.fn(), + // Mock ID token validation for predictable claims + getValidatedIdTokenClaims: vi.fn(), + // Mock token processing to avoid complex JWT validation + processAuthorizationCodeResponse: vi.fn(), + // Mock additional functions for full callback support + authorizationCodeGrantRequest: vi.fn() + }; +}); + +// Test constants +const domain = "test.auth0.com"; +const clientId = "test-client-id"; + +// Generate test keys for JWT signing +let keyPair: jose.GenerateKeyPairResult; + +// Helper function to create a valid ID token +const createValidIdToken = async (claims: any = {}) => { + if (!keyPair) { + keyPair = await jose.generateKeyPair("RS256"); + } -const MockTransactionStore = TransactionStore; + return await new jose.SignJWT({ + 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, + ...claims + }) + .setProtectedHeader({ alg: "RS256" }) + .sign(keyPair.privateKey); +}; + +// MSW handlers for mocking HTTP requests +const handlers = [ + // OIDC Discovery Endpoint + http.get(`https://${domain}/.well-known/openid-configuration`, () => { + return HttpResponse.json({ + issuer: `https://${domain}/`, + authorization_endpoint: `https://${domain}/authorize`, + token_endpoint: `https://${domain}/oauth/token`, + jwks_uri: `https://${domain}/.well-known/jwks.json`, + end_session_endpoint: `https://${domain}/v2/logout` + }); + }), + // JWKS Endpoint + http.get(`https://${domain}/.well-known/jwks.json`, async () => { + if (!keyPair) { + keyPair = await jose.generateKeyPair("RS256"); + } + const jwk = await jose.exportJWK(keyPair.publicKey); + return HttpResponse.json({ + keys: [{ ...jwk, kid: "test-key-id", use: "sig" }] + }); + }), + // Token Endpoint + http.post(`https://${domain}/oauth/token`, async () => { + const idToken = await createValidIdToken(); + return HttpResponse.json({ + access_token: "access_token_123", + id_token: idToken, + refresh_token: "refresh_token_123", + token_type: "Bearer", + expires_in: 3600, + scope: "openid profile email" + }); + }) +]; + +const server = setupServer(...handlers); + +beforeAll(async () => { + // Initialize key pair for JWT signing + keyPair = await jose.generateKeyPair("RS256"); + server.listen({ onUnhandledRequest: "error" }); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); class TestSessionStore extends AbstractSessionStore { constructor(config: SessionStoreOptions) { @@ -38,16 +149,20 @@ class TestSessionStore extends AbstractSessionStore { _resCookies: ResponseCookies, _session: SessionData, _isNew?: boolean | undefined - ): Promise {} + ): Promise { + // Empty implementation for testing + } async delete( _reqCookies: RequestCookies | ReadonlyRequestCookies, _resCookies: ResponseCookies - ): Promise {} + ): Promise { + // Empty implementation for testing + } } const baseOptions: Partial = { - domain: "test.auth0.com", - clientId: "test-client-id", + domain, + clientId, clientSecret: "test-client-secret", appBaseUrl: "http://localhost:3000", secret: "a-sufficiently-long-secret-for-testing", @@ -58,36 +173,27 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client let authClient: AuthClient; let mockTransactionStoreInstance: TransactionStore; let mockSessionStoreInstance: TestSessionStore; + let secret: string; beforeEach(async () => { vi.clearAllMocks(); + vi.restoreAllMocks(); + + secret = await generateSecret(32); - mockTransactionStoreInstance = new MockTransactionStore({ - secret: "a-sufficiently-long-secret-for-testing" + // Create real transaction store for integration testing + mockTransactionStoreInstance = new TransactionStore({ + secret, + enableParallelTransactions: true }); + const testSessionStoreOptions: SessionStoreOptions = { secret: "test-secret", cookieOptions: { name: "__session", path: "/", sameSite: "lax" } }; mockSessionStoreInstance = new TestSessionStore(testSessionStoreOptions); - mockTransactionStoreInstance.getCookiePrefix = vi - .fn() - .mockReturnValue("__txn_"); - mockTransactionStoreInstance.delete = vi.fn().mockResolvedValue(undefined); - mockTransactionStoreInstance.deleteAll = vi - .fn() - .mockResolvedValue(undefined); - mockTransactionStoreInstance.get = vi.fn().mockResolvedValue({ - payload: { - state: "test-state", - nonce: "test-nonce", - codeVerifier: "cv", - responseType: "code", - returnTo: "/" - } - }); - + // Mock session store methods for controlled testing mockSessionStoreInstance.get = vi.fn().mockResolvedValue({ user: { sub: "user123" }, internal: { sid: "sid123" }, @@ -98,48 +204,66 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client authClient = new AuthClient({ ...baseOptions, + secret, sessionStore: mockSessionStoreInstance as any, transactionStore: mockTransactionStoreInstance } as AuthClientOptions); - (authClient as any).discoverAuthorizationServerMetadata = vi - .fn() - .mockResolvedValue([ - null, - { - issuer: "https://test.auth0.com/", - authorization_endpoint: "https://test.auth0.com/authorize", - token_endpoint: "https://test.auth0.com/oauth/token", - jwks_uri: "https://test.auth0.com/.well-known/jwks.json", - end_session_endpoint: "https://test.auth0.com/v2/logout" // Mock RP-Initiated Logout endpoint - } - ]); + // Only mock functions that need predictable values for testing + // HTTP requests will be handled by MSW handlers above + 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.spyOn(oauth, "validateAuthResponse").mockReturnValue( - new URLSearchParams("code=auth_code") + // Restore all oauth4webapi mocks with proper return values + vi.mocked(oauth.validateAuthResponse).mockReturnValue( + new URLSearchParams("code=auth_code&state=test-state") ); - vi.spyOn(oauth, "authorizationCodeGrantRequest").mockResolvedValue( + + // Mock discovery for MSW integration + 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`, + end_session_endpoint: `https://${domain}/v2/logout` + } as any); + + // Mock token request for MSW integration + vi.mocked(oauth.authorizationCodeGrantRequest).mockResolvedValue( new Response() ); - vi.spyOn(oauth, "processAuthorizationCodeResponse").mockResolvedValue({ + vi.mocked(oauth.processAuthorizationCodeResponse).mockResolvedValue({ token_type: "Bearer", access_token: "access_token_123", - id_token: "id_token_456", + id_token: await createValidIdToken(), refresh_token: "refresh_token_789", expires_in: 3600, scope: "openid profile email" - } as oauth.TokenEndpointResponse); + } as any); - const clientId = baseOptions.clientId ?? "test-client-id"; - vi.spyOn(oauth, "getValidatedIdTokenClaims").mockReturnValue({ + // We still need to mock these since JWT validation is complex and we want predictable results + vi.mocked(oauth.getValidatedIdTokenClaims).mockReturnValue({ sub: "user123", sid: "sid123", nonce: "test-nonce", aud: clientId, - iss: `https://${baseOptions.domain}/`, + iss: `https://${domain}/`, iat: Math.floor(Date.now() / 1000) - 60, exp: Math.floor(Date.now() / 1000) + 3600 }); + + // Mock the token processing response to avoid JWT validation complexity + vi.mocked(oauth.processAuthorizationCodeResponse).mockResolvedValue({ + token_type: "Bearer", + access_token: "access_token_123", + id_token: await createValidIdToken(), + refresh_token: "refresh_token_789", + expires_in: 3600, + scope: "openid profile email" + } as oauth.TokenEndpointResponse); }); describe("handleLogout", () => { @@ -150,13 +274,21 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client const res = await authClient.handleLogout(req); expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith( - req.cookies, - res.cookies - ); - expect(res.status).toBeGreaterThanOrEqual(300); // Accept 302 or 307 + // Check that transaction cookies were cleaned up (by checking response cookies) + const deletedTxnCookies = res.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith("__txn_") && + cookie.value === "" && + cookie.maxAge === 0 + ); + + // No transaction cookies to delete, but deleteAll should still be called + expect(deletedTxnCookies.length).toBe(0); + + expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); }); @@ -170,12 +302,18 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client const res = await authClient.handleLogout(req); expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith( - req.cookies, - res.cookies - ); + // Check that transaction cookies were deleted + const deletedTxnCookies = res.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith("__txn_") && + cookie.value === "" && + cookie.maxAge === 0 + ); + + expect(deletedTxnCookies.length).toBeGreaterThan(0); expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); }); @@ -188,29 +326,36 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client const res = await authClient.handleLogout(req); expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith( - req.cookies, - res.cookies - ); + // Check that transaction cookies were deleted + const deletedTxnCookies = res.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith("__txn_") && + cookie.value === "" && + cookie.maxAge === 0 + ); + + expect(deletedTxnCookies.length).toBeGreaterThan(0); expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); }); it("should respect custom transaction cookie prefix when calling deleteAll", async () => { const customPrefix = "__my_txn_"; - mockTransactionStoreInstance.getCookiePrefix = vi - .fn() - .mockReturnValue(customPrefix); + const customTxnStore = new TransactionStore({ + secret, + enableParallelTransactions: true, + cookieOptions: { prefix: customPrefix } + }); + authClient = new AuthClient({ ...baseOptions, + secret, sessionStore: mockSessionStoreInstance as any, - transactionStore: mockTransactionStoreInstance + transactionStore: customTxnStore } as AuthClientOptions); - (authClient as any).discoverAuthorizationServerMetadata = vi - .fn() - .mockResolvedValue([null, { end_session_endpoint: "http://..." }]); const req = new NextRequest("http://localhost:3000/api/auth/logout"); req.cookies.set("__session", "session-value"); @@ -220,44 +365,90 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client const res = await authClient.handleLogout(req); expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith( - req.cookies, - res.cookies - ); + // Should only delete cookies with the custom prefix + const deletedCustomTxnCookies = res.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith(customPrefix) && + cookie.value === "" && + cookie.maxAge === 0 + ); + + expect(deletedCustomTxnCookies.length).toBeGreaterThan(0); expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); }); }); describe("handleCallback", () => { + beforeEach(() => { + // Mock the transaction store get method to return valid transaction state + vi.spyOn(mockTransactionStoreInstance, "get").mockResolvedValue({ + payload: { + state: "test-state", + nonce: "test-nonce", + codeVerifier: "cv", + responseType: "code", + returnTo: "/" + }, + protectedHeader: {} + } as any); + }); + it("should delete the correct transaction cookie on success", async () => { - const state = "test-state"; + // First, do a login to get proper state + const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); + const loginRes = await authClient.handleLogin(loginReq); + + // Extract the state from the redirect URL + const redirectUrl = new URL(loginRes.headers.get("Location")!); + const state = redirectUrl.searchParams.get("state")!; + + // Get the transaction cookie that was set + const txnCookie = loginRes.cookies.get(`__txn_${state}`); + expect(txnCookie).toBeDefined(); + + // Now create the callback request const req = new NextRequest( `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` ); + // Add the transaction cookie to the callback request + if (txnCookie) { + req.cookies.set(`__txn_${state}`, txnCookie.value); + } + + // Now test the callback const res = await authClient.handleCallback(req); + // Verify transaction was retrieved and deleted expect(mockTransactionStoreInstance.get).toHaveBeenCalledWith( req.cookies, state ); - expect(mockTransactionStoreInstance.delete).toHaveBeenCalledTimes(1); - expect(mockTransactionStoreInstance.delete).toHaveBeenCalledWith( - res.cookies, - state - ); + + // Check that transaction cookie was deleted + const deletedTxnCookies = res.cookies + .getAll() + .filter( + (cookie) => + cookie.name === `__txn_${state}` && + cookie.value === "" && + cookie.maxAge === 0 + ); + + expect(deletedTxnCookies.length).toBe(1); expect(mockSessionStoreInstance.set).toHaveBeenCalledTimes(1); - expect(res.status).toBeGreaterThanOrEqual(300); // Accept redirects + expect(res.status).toBeGreaterThanOrEqual(300); expect(res.status).toBeLessThan(400); expect(res.headers.get("location")).toBe("http://localhost:3000/"); }); it("should NOT delete transaction cookie on InvalidStateError", async () => { const state = "invalid-state"; - mockTransactionStoreInstance.get = vi.fn().mockResolvedValue(null); + vi.spyOn(mockTransactionStoreInstance, "get").mockResolvedValue(null); const req = new NextRequest( `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` ); @@ -268,7 +459,18 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client req.cookies, state ); - expect(mockTransactionStoreInstance.delete).not.toHaveBeenCalled(); + + // Check that no transaction cookies were deleted + const deletedTxnCookies = res.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith("__txn_") && + cookie.value === "" && + cookie.maxAge === 0 + ); + + expect(deletedTxnCookies.length).toBe(0); expect(mockSessionStoreInstance.set).not.toHaveBeenCalled(); expect(res.status).toBe(500); const body = await res.text(); @@ -283,11 +485,218 @@ describe("Ensure that redundant transaction cookies are deleted from auth-client const res = await authClient.handleCallback(req); expect(mockTransactionStoreInstance.get).not.toHaveBeenCalled(); - expect(mockTransactionStoreInstance.delete).not.toHaveBeenCalled(); + + // Check that no transaction cookies were deleted + const deletedTxnCookies = res.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith("__txn_") && + cookie.value === "" && + cookie.maxAge === 0 + ); + + expect(deletedTxnCookies.length).toBe(0); expect(mockSessionStoreInstance.set).not.toHaveBeenCalled(); expect(res.status).toBe(500); const body = await res.text(); expect(body).toContain(new MissingStateError().message); }); }); + + // Integration tests for the v4 infinitely stacking cookies issue + describe("v4 Infinitely Stacking Cookies - Integration Tests", () => { + let statelessSessionStore: StatelessSessionStore; + + beforeEach(async () => { + // Use real stateless session store for these integration tests + statelessSessionStore = new StatelessSessionStore({ secret }); + + authClient = new AuthClient({ + ...baseOptions, + secret, + sessionStore: statelessSessionStore, + transactionStore: mockTransactionStoreInstance + } as AuthClientOptions); + }); + + describe("Happy Path", () => { + it("should clean up all transaction cookies after successful authentication", async () => { + // Arrange: Create a login + const loginReq = new NextRequest( + "http://localhost:3000/api/auth/login" + ); + const loginRes = await authClient.handleLogin(loginReq); + + // Extract the state from the redirect URL + const redirectUrl = new URL(loginRes.headers.get("Location")!); + const state = redirectUrl.searchParams.get("state")!; + + // Get the transaction cookie that was set + const newTxnCookie = loginRes.cookies.get(`__txn_${state}`); + expect(newTxnCookie).toBeDefined(); + + // Simulate callback request with multiple existing transaction cookies + const callbackReq = new NextRequest( + `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` + ); + + // Add the stale cookies to the callback request + callbackReq.cookies.set("__txn_old_state_1", "old_value_1"); + callbackReq.cookies.set("__txn_old_state_2", "old_value_2"); + if (newTxnCookie) { + callbackReq.cookies.set(`__txn_${state}`, newTxnCookie.value); + } + + // Act: Handle the callback + const callbackRes = await authClient.handleCallback(callbackReq); + + // Assert: Verify that ALL transaction cookies are cleaned up + expect(callbackRes.status).toBeGreaterThanOrEqual(300); // Should redirect + expect(callbackRes.status).toBeLessThan(400); + + // Check that all transaction cookies are being deleted (set to empty with maxAge 0) + const deletedCookies = callbackRes.cookies + .getAll() + .filter( + (cookie) => + cookie.name.startsWith("__txn_") && + cookie.value === "" && + cookie.maxAge === 0 + ); + + // Should have cleaned up all transaction cookies + expect(deletedCookies.length).toBeGreaterThan(0); + + // Verify a session cookie was set + const sessionCookie = callbackRes.cookies.get("__session"); + expect(sessionCookie).toBeDefined(); + expect(sessionCookie?.value).not.toBe(""); + }); + }); + + describe("Edge Cases", () => { + it("should handle callback with no existing transaction cookies gracefully", async () => { + // Create a login and get the state + const loginReq = new NextRequest( + "http://localhost:3000/api/auth/login" + ); + const loginRes = await authClient.handleLogin(loginReq); + + const redirectUrl = new URL(loginRes.headers.get("Location")!); + const state = redirectUrl.searchParams.get("state")!; + + // Handle callback with only the current transaction cookie + const callbackReq = new NextRequest( + `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` + ); + + const txnCookie = loginRes.cookies.get(`__txn_${state}`); + if (txnCookie) { + callbackReq.cookies.set(`__txn_${state}`, txnCookie.value); + } + + const callbackRes = await authClient.handleCallback(callbackReq); + + // Should still work normally + expect(callbackRes.status).toBeGreaterThanOrEqual(300); + expect(callbackRes.status).toBeLessThan(400); + }); + + it("should not interfere with non-transaction cookies", async () => { + // Create a login + const loginReq = new NextRequest( + "http://localhost:3000/api/auth/login" + ); + const loginRes = await authClient.handleLogin(loginReq); + + const redirectUrl = new URL(loginRes.headers.get("Location")!); + const state = redirectUrl.searchParams.get("state")!; + + // Handle callback with mixed cookies + const callbackReq = new NextRequest( + `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` + ); + + const txnCookie = loginRes.cookies.get(`__txn_${state}`); + if (txnCookie) { + callbackReq.cookies.set(`__txn_${state}`, txnCookie.value); + } + callbackReq.cookies.set("other_cookie", "should_not_be_deleted"); + callbackReq.cookies.set("user_pref", "also_should_remain"); + + const callbackRes = await authClient.handleCallback(callbackReq); + + // Check that only transaction cookies are deleted + const deletedCookies = callbackRes.cookies + .getAll() + .filter((cookie) => cookie.value === "" && cookie.maxAge === 0); + + const deletedTxnCookies = deletedCookies.filter((cookie) => + cookie.name.startsWith("__txn_") + ); + const deletedOtherCookies = deletedCookies.filter( + (cookie) => + !cookie.name.startsWith("__txn_") && + !cookie.name.startsWith("__session") && + cookie.name !== "appSession" // Ignore session-related cookies + ); + + expect(deletedTxnCookies.length).toBeGreaterThan(0); + expect(deletedOtherCookies.length).toBe(0); + }); + }); + + describe("enableParallelTransactions: false", () => { + it("should use single transaction cookie without state suffix", async () => { + // Arrange: Create auth client with parallel transactions disabled + const singleTxnTransactionStore = new TransactionStore({ + secret, + enableParallelTransactions: false + }); + + const singleTxnAuthClient = new AuthClient({ + transactionStore: singleTxnTransactionStore, + sessionStore: statelessSessionStore, + ...baseOptions, + secret + } as AuthClientOptions); + + // Act: Create a login + const loginReq = new NextRequest( + "http://localhost:3000/api/auth/login" + ); + const loginRes = await singleTxnAuthClient.handleLogin(loginReq); + + // Assert: Should use __txn_ without state suffix + const txnCookies = loginRes.cookies + .getAll() + .filter((cookie) => cookie.name.startsWith("__txn_")); + + expect(txnCookies).toHaveLength(1); + expect(txnCookies[0].name).toBe("__txn_"); // No state suffix when parallel transactions disabled + }); + }); + + describe("Transaction Store Integration", () => { + it("should skip existence check when reqCookies is not provided in startInteractiveLogin", async () => { + // This is an integration test to verify that startInteractiveLogin + // calls save() without reqCookies, thus skipping the existence check + + // Arrange: Spy on the transaction store save method + const saveSpy = vi.spyOn(mockTransactionStoreInstance, "save"); + + // Act: Call startInteractiveLogin + await authClient.startInteractiveLogin(); + + // Assert: Verify save was called with only 2 parameters (no reqCookies) + expect(saveSpy).toHaveBeenCalledTimes(1); + const [resCookies, transactionState, reqCookies] = + saveSpy.mock.calls[0]; + expect(resCookies).toBeDefined(); + expect(transactionState).toBeDefined(); + expect(reqCookies).toBeUndefined(); // Should be undefined for performance + }); + }); + }); }); diff --git a/tests/v4-infinitely-stacking-cookies.test.ts b/tests/v4-infinitely-stacking-cookies.test.ts deleted file mode 100644 index a60d189a..00000000 --- a/tests/v4-infinitely-stacking-cookies.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { NextRequest } from "next/server.js"; -import * as oauth from "oauth4webapi"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { AuthClient } from "../src/server/auth-client.js"; -import { StatelessSessionStore } from "../src/server/session/stateless-session-store.js"; -import { TransactionStore } from "../src/server/transaction-store.js"; -import { generateSecret } from "../src/test/utils.js"; - -// Mock oauth4webapi module -vi.mock("oauth4webapi"); - -describe(`v4-infinitely-stacking-cookies - v4: Infinitely stacking cookies regression`, () => { - let authClient: AuthClient; - let transactionStore: TransactionStore; - let sessionStore: StatelessSessionStore; - let secret: string; - - beforeEach(async () => { - vi.clearAllMocks(); - vi.resetModules(); - - secret = await generateSecret(32); - transactionStore = new TransactionStore({ - secret, - enableParallelTransactions: true - }); - sessionStore = new StatelessSessionStore({ secret }); - - authClient = new AuthClient({ - transactionStore, - sessionStore, - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret, - routes: { - login: "/api/auth/login", - logout: "/api/auth/logout", - callback: "/api/auth/callback" - }, - fetch: vi.fn().mockImplementation((url) => { - // Mock the token endpoint - if (url.includes("/oauth/token")) { - return Promise.resolve( - new Response( - JSON.stringify({ - access_token: "access_token_123", - id_token: - "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwibm9uY2UiOiJ0ZXN0LW5vbmNlIiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJpc3MiOiJodHRwczovL3Rlc3QuYXV0aDAuY29tLyIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDA3MjAwfQ.mock_signature", - refresh_token: "refresh_token_123", - token_type: "Bearer", - expires_in: 3600, - scope: "openid profile email" - }), - { - headers: { "Content-Type": "application/json" } - } - ) - ); - } - - // Mock the JWKS endpoint - if (url.includes("/.well-known/jwks.json")) { - return Promise.resolve( - new Response( - JSON.stringify({ - keys: [ - { - kty: "RSA", - kid: "test-key-id", - use: "sig", - n: "mock_n_value", - e: "AQAB" - } - ] - }), - { - headers: { "Content-Type": "application/json" } - } - ) - ); - } - - // Mock the discovery endpoint - if (url.includes("/.well-known/openid_configuration")) { - return Promise.resolve( - new Response( - JSON.stringify({ - issuer: "https://test.auth0.com/", - authorization_endpoint: "https://test.auth0.com/authorize", - token_endpoint: "https://test.auth0.com/oauth/token", - jwks_uri: "https://test.auth0.com/.well-known/jwks.json", - end_session_endpoint: "https://test.auth0.com/v2/logout" - }), - { - headers: { "Content-Type": "application/json" } - } - ) - ); - } - - return Promise.resolve(new Response("Not Found", { status: 404 })); - }) - }); - - // Mock oauth4webapi functions - const mockDiscoveryResponse = new Response( - JSON.stringify({ - issuer: "https://test.auth0.com/", - authorization_endpoint: "https://test.auth0.com/authorize", - token_endpoint: "https://test.auth0.com/oauth/token", - jwks_uri: "https://test.auth0.com/.well-known/jwks.json", - end_session_endpoint: "https://test.auth0.com/v2/logout" - }), - { - headers: { "Content-Type": "application/json" } - } - ); - - const mockAuthServerMetadata = { - issuer: "https://test.auth0.com/", - authorization_endpoint: "https://test.auth0.com/authorize", - token_endpoint: "https://test.auth0.com/oauth/token", - jwks_uri: "https://test.auth0.com/.well-known/jwks.json", - end_session_endpoint: "https://test.auth0.com/v2/logout" - } as oauth.AuthorizationServer; - - vi.mocked(oauth.discoveryRequest).mockResolvedValue(mockDiscoveryResponse); - vi.mocked(oauth.processDiscoveryResponse).mockResolvedValue( - mockAuthServerMetadata - ); - - // Mock PKCE and state generation functions - vi.mocked(oauth.generateRandomState).mockReturnValue("mock-state-123"); - vi.mocked(oauth.generateRandomNonce).mockReturnValue("mock-nonce-123"); - vi.mocked(oauth.generateRandomCodeVerifier).mockReturnValue( - "mock-code-verifier-123" - ); - vi.mocked(oauth.calculatePKCECodeChallenge).mockResolvedValue( - "mock-code-challenge-123" - ); - - vi.mocked(oauth.validateAuthResponse).mockReturnValue( - new URLSearchParams("code=auth_code") - ); - vi.mocked(oauth.authorizationCodeGrantRequest).mockResolvedValue( - new Response() - ); - vi.mocked(oauth.processAuthorizationCodeResponse).mockResolvedValue({ - token_type: "Bearer", - access_token: "access_token_123", - id_token: "mock_id_token", - refresh_token: "refresh_token_123", - expires_in: 3600, - scope: "openid profile email" - } as oauth.TokenEndpointResponse); - vi.mocked(oauth.getValidatedIdTokenClaims).mockReturnValue({ - sub: "user123", - nonce: "test-nonce", - aud: "test-client-id", - iss: "https://test.auth0.com/", - iat: Math.floor(Date.now() / 1000) - 60, - exp: Math.floor(Date.now() / 1000) + 3600 - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("Happy Path", () => { - it("should clean up all transaction cookies after successful authentication", async () => { - // Arrange: Create a login - const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); - const loginRes = await authClient.handleLogin(loginReq); - - // Extract the state from the redirect URL - const redirectUrl = new URL(loginRes.headers.get("Location")!); - const state = redirectUrl.searchParams.get("state")!; - - // Get the transaction cookie that was set - const newTxnCookie = loginRes.cookies.get(`__txn_${state}`); - expect(newTxnCookie).toBeDefined(); - - // Simulate callback request with multiple existing transaction cookies - const callbackReq = new NextRequest( - `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` - ); - - // Add the stale cookies to the callback request - callbackReq.cookies.set("__txn_old_state_1", "old_value_1"); - callbackReq.cookies.set("__txn_old_state_2", "old_value_2"); - if (newTxnCookie) { - callbackReq.cookies.set(`__txn_${state}`, newTxnCookie.value); - } - - // Act: Handle the callback - const callbackRes = await authClient.handleCallback(callbackReq); - - // Assert: Verify that ALL transaction cookies are cleaned up - expect(callbackRes.status).toBeGreaterThanOrEqual(300); // Should redirect - expect(callbackRes.status).toBeLessThan(400); - - // Check that all transaction cookies are being deleted (set to empty with maxAge 0) - const deletedCookies = callbackRes.cookies - .getAll() - .filter( - (cookie) => - cookie.name.startsWith("__txn_") && - cookie.value === "" && - cookie.maxAge === 0 - ); - - // Should have cleaned up all transaction cookies - expect(deletedCookies.length).toBeGreaterThan(0); - - // Verify a session cookie was set - const sessionCookie = callbackRes.cookies.get("__session"); - expect(sessionCookie).toBeDefined(); - expect(sessionCookie?.value).not.toBe(""); - }); - }); - - describe("Edge Cases", () => { - it("should handle callback with no existing transaction cookies gracefully", async () => { - // Create a login and get the state - const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); - const loginRes = await authClient.handleLogin(loginReq); - - const redirectUrl = new URL(loginRes.headers.get("Location")!); - const state = redirectUrl.searchParams.get("state")!; - - // Handle callback with only the current transaction cookie - const callbackReq = new NextRequest( - `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` - ); - - const txnCookie = loginRes.cookies.get(`__txn_${state}`); - if (txnCookie) { - callbackReq.cookies.set(`__txn_${state}`, txnCookie.value); - } - - const callbackRes = await authClient.handleCallback(callbackReq); - - // Should still work normally - expect(callbackRes.status).toBeGreaterThanOrEqual(300); - expect(callbackRes.status).toBeLessThan(400); - }); - - it("should not interfere with non-transaction cookies", async () => { - // Create a login - const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); - const loginRes = await authClient.handleLogin(loginReq); - - const redirectUrl = new URL(loginRes.headers.get("Location")!); - const state = redirectUrl.searchParams.get("state")!; - - // Handle callback with mixed cookies - const callbackReq = new NextRequest( - `http://localhost:3000/api/auth/callback?code=auth_code&state=${state}` - ); - - const txnCookie = loginRes.cookies.get(`__txn_${state}`); - if (txnCookie) { - callbackReq.cookies.set(`__txn_${state}`, txnCookie.value); - } - callbackReq.cookies.set("other_cookie", "should_not_be_deleted"); - callbackReq.cookies.set("user_pref", "also_should_remain"); - - const callbackRes = await authClient.handleCallback(callbackReq); - - // Check that only transaction cookies are deleted - const deletedCookies = callbackRes.cookies - .getAll() - .filter((cookie) => cookie.value === "" && cookie.maxAge === 0); - - const deletedTxnCookies = deletedCookies.filter((cookie) => - cookie.name.startsWith("__txn_") - ); - const deletedOtherCookies = deletedCookies.filter( - (cookie) => - !cookie.name.startsWith("__txn_") && - !cookie.name.startsWith("__session") && - cookie.name !== "appSession" // Ignore session-related cookies - ); - - expect(deletedTxnCookies.length).toBeGreaterThan(0); - expect(deletedOtherCookies.length).toBe(0); - }); - }); - - describe("enableParallelTransactions: false", () => { - it("should use single transaction cookie without state suffix", async () => { - // Arrange: Create auth client with parallel transactions disabled - const singleTxnTransactionStore = new TransactionStore({ - secret, - enableParallelTransactions: false - }); - - const singleTxnAuthClient = new AuthClient({ - transactionStore: singleTxnTransactionStore, - sessionStore, - domain: "test.auth0.com", - clientId: "test-client-id", - clientSecret: "test-client-secret", - appBaseUrl: "http://localhost:3000", - secret, - routes: { - login: "/api/auth/login", - logout: "/api/auth/logout", - callback: "/api/auth/callback" - }, - fetch: vi.fn().mockImplementation((url) => { - // Mock the token endpoint - if (url.includes("/oauth/token")) { - return Promise.resolve( - new Response( - JSON.stringify({ - access_token: "access_token_123", - id_token: - "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwibm9uY2UiOiJ0ZXN0LW5vbmNlIiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJpc3MiOiJodHRwczovL3Rlc3QuYXV0aDAuY29tLyIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDA3MjAwfQ.mock_signature", - refresh_token: "refresh_token_123", - token_type: "Bearer", - expires_in: 3600, - scope: "openid profile email" - }), - { - headers: { "Content-Type": "application/json" } - } - ) - ); - } - - // Mock discovery endpoint - if (url.includes("/.well-known/openid_configuration")) { - return Promise.resolve( - new Response( - JSON.stringify({ - issuer: "https://test.auth0.com/", - authorization_endpoint: "https://test.auth0.com/authorize", - token_endpoint: "https://test.auth0.com/oauth/token", - userinfo_endpoint: "https://test.auth0.com/userinfo", - jwks_uri: "https://test.auth0.com/.well-known/jwks.json", - end_session_endpoint: "https://test.auth0.com/v2/logout" - }), - { - headers: { "Content-Type": "application/json" } - } - ) - ); - } - - // Mock the JWKS endpoint - if (url.includes("/.well-known/jwks.json")) { - return Promise.resolve( - new Response( - JSON.stringify({ - keys: [ - { - kty: "RSA", - kid: "test-key-id", - use: "sig", - n: "mock_n_value", - e: "AQAB" - } - ] - }), - { - headers: { "Content-Type": "application/json" } - } - ) - ); - } - - // Default mock response - return Promise.resolve(new Response("Not Found", { status: 404 })); - }) - }); - - // Act: Create a login - const loginReq = new NextRequest("http://localhost:3000/api/auth/login"); - const loginRes = await singleTxnAuthClient.handleLogin(loginReq); - - // Assert: Should use __txn_ without state suffix - const txnCookies = loginRes.cookies - .getAll() - .filter((cookie) => cookie.name.startsWith("__txn_")); - - expect(txnCookies).toHaveLength(1); - expect(txnCookies[0].name).toBe("__txn_"); // No state suffix when parallel transactions disabled - }); - }); - - describe("Transaction Store Integration", () => { - it("should skip existence check when reqCookies is not provided in startInteractiveLogin", async () => { - // This is an integration test to verify that startInteractiveLogin - // calls save() without reqCookies, thus skipping the existence check - - // Arrange: Spy on the transaction store save method - const saveSpy = vi.spyOn(transactionStore, "save"); - - // Act: Call startInteractiveLogin - await authClient.startInteractiveLogin(); - - // Assert: Verify save was called with only 2 parameters (no reqCookies) - expect(saveSpy).toHaveBeenCalledTimes(1); - const [resCookies, transactionState, reqCookies] = saveSpy.mock.calls[0]; - expect(resCookies).toBeDefined(); - expect(transactionState).toBeDefined(); - expect(reqCookies).toBeUndefined(); // Should be undefined for performance - }); - }); -}); From 8049055f7f361fa14829dc9f07508b970bb7daad Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Thu, 24 Jul 2025 00:41:50 +0530 Subject: [PATCH 10/10] chore: remove sample --- examples/transaction-cookie-config/README.md | 173 ------------------ .../transaction-cookie-config/lib/auth0.ts | 74 -------- .../transaction-cookie-config/middleware.ts | 18 -- .../transaction-cookie-config/package.json | 23 --- 4 files changed, 288 deletions(-) delete mode 100644 examples/transaction-cookie-config/README.md delete mode 100644 examples/transaction-cookie-config/lib/auth0.ts delete mode 100644 examples/transaction-cookie-config/middleware.ts delete mode 100644 examples/transaction-cookie-config/package.json diff --git a/examples/transaction-cookie-config/README.md b/examples/transaction-cookie-config/README.md deleted file mode 100644 index 0fce5468..00000000 --- a/examples/transaction-cookie-config/README.md +++ /dev/null @@ -1,173 +0,0 @@ -# Transaction Cookie Configuration Example - -This example demonstrates how to configure transaction cookies in the Auth0 Next.js SDK to prevent cookie accumulation and manage authentication flows. - -## Problem Solved - -Prior to these configuration options, transaction cookies (`__txn_*`) would accumulate when users repeatedly navigated to protected routes or started multiple authentication flows. This could eventually cause HTTP 413 (Request Entity Too Large) errors when cookie headers exceeded server limits. - -## Configuration Options - -### Default Configuration (Parallel Transactions) - -```typescript -// lib/auth0.ts -import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; - -// Default configuration - allows multiple parallel transactions -const transactionStore = new TransactionStore({ - secret: process.env.AUTH0_SECRET!, - enableParallelTransactions: true, // Default - cookieOptions: { - maxAge: 3600, // 1 hour (default) - sameSite: "lax", - secure: process.env.NODE_ENV === "production" - } -}); - -export const auth0 = new Auth0Client({ - transactionStore, - // ... other options -}); -``` - -### Single Transaction Mode - -```typescript -// lib/auth0-single-transaction.ts -import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; - -// Single transaction mode - prevents cookie accumulation -const transactionStore = new TransactionStore({ - secret: process.env.AUTH0_SECRET!, - enableParallelTransactions: false, - cookieOptions: { - maxAge: 1800, // 30 minutes - sameSite: "lax", - secure: process.env.NODE_ENV === "production" - } -}); - -export const auth0 = new Auth0Client({ - transactionStore, - // ... other options -}); -``` - -### Custom Cookie Prefix and Settings - -```typescript -// lib/auth0-custom.ts -import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; - -const transactionStore = new TransactionStore({ - secret: process.env.AUTH0_SECRET!, - enableParallelTransactions: true, - cookieOptions: { - prefix: "__myapp_auth_", // Custom prefix instead of __txn_ - maxAge: 2700, // 45 minutes - sameSite: "strict", - secure: true, - path: "/app" // Limit to specific path - } -}); - -export const auth0 = new Auth0Client({ - transactionStore, - // ... other options -}); -``` - -## When to Use Each Mode - -### Parallel Transactions (Default) -✅ **Use when:** -- Users might open multiple tabs and log in simultaneously -- You want maximum compatibility with user behavior -- Your application supports concurrent authentication flows - -### Single Transaction Mode -✅ **Use when:** -- You want to prevent cookie accumulation issues -- Users typically don't need multiple concurrent login flows -- You're experiencing cookie header size limits -- You prefer simpler transaction management - -## Environment Variables - -The basic Auth0 configuration still uses the same environment variables: - -```env -# .env.local -AUTH0_DOMAIN=your-domain.auth0.com -AUTH0_CLIENT_ID=your-client-id -AUTH0_CLIENT_SECRET=your-client-secret -AUTH0_SECRET=your-32-character-secret -APP_BASE_URL=http://localhost:3000 -``` - -## Middleware Setup - -The middleware setup remains the same regardless of transaction cookie configuration: - -```typescript -// middleware.ts -import type { NextRequest } from "next/server"; -import { auth0 } from "./lib/auth0"; // Use your configured auth0 instance - -export async function middleware(request: NextRequest) { - return await auth0.middleware(request); -} - -export const config = { - matcher: [ - "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)" - ] -}; -``` - -## Testing the Configuration - -You can test your transaction cookie configuration by: - -1. **Multi-tab testing**: Open multiple tabs and try logging in simultaneously -2. **Cookie inspection**: Check browser dev tools to see transaction cookies -3. **Abandoned flow testing**: Start login flows and navigate away to see cleanup - -### Expected Cookie Behavior - -**Parallel Mode:** -- Multiple `__txn_{state}` cookies during concurrent logins -- Automatic cleanup after successful authentication -- Cookies expire after `maxAge` seconds - -**Single Mode:** -- Only one `__txn_` cookie at a time -- New logins replace existing transaction cookies -- Simpler cookie management - -## Migration from Default Configuration - -If you're experiencing cookie accumulation issues, you can migrate to single transaction mode: - -```typescript -// Before (using defaults) -export const auth0 = new Auth0Client({ - // ... your existing config -}); - -// After (single transaction mode) -import { TransactionStore } from "@auth0/nextjs-auth0/server"; - -const transactionStore = new TransactionStore({ - secret: process.env.AUTH0_SECRET!, - enableParallelTransactions: false -}); - -export const auth0 = new Auth0Client({ - transactionStore, - // ... your existing config -}); -``` - -This change is backward compatible and won't affect existing user sessions. diff --git a/examples/transaction-cookie-config/lib/auth0.ts b/examples/transaction-cookie-config/lib/auth0.ts deleted file mode 100644 index 1bb0365d..00000000 --- a/examples/transaction-cookie-config/lib/auth0.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Auth0Client, TransactionStore } from "@auth0/nextjs-auth0/server"; - -// Example 1: Single Transaction Mode (Prevents Cookie Accumulation) -const singleTransactionStore = new TransactionStore({ - secret: process.env.AUTH0_SECRET!, - enableParallelTransactions: false, - cookieOptions: { - maxAge: 1800, // 30 minutes - sameSite: "lax", - secure: process.env.NODE_ENV === "production" - } -}); - -export const auth0Single = new Auth0Client({ - transactionStore: singleTransactionStore, - domain: process.env.AUTH0_DOMAIN!, - clientId: process.env.AUTH0_CLIENT_ID!, - clientSecret: process.env.AUTH0_CLIENT_SECRET!, - appBaseUrl: process.env.APP_BASE_URL!, - secret: process.env.AUTH0_SECRET!, - routes: { - login: "/auth/login", - logout: "/auth/logout", - callback: "/auth/callback" - } -}); - -// Example 2: Parallel Transactions with Custom Settings -const parallelTransactionStore = new TransactionStore({ - secret: process.env.AUTH0_SECRET!, - enableParallelTransactions: true, // Default - cookieOptions: { - maxAge: 2700, // 45 minutes - prefix: "__myapp_txn_", // Custom prefix - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - path: "/" - } -}); - -export const auth0Parallel = new Auth0Client({ - transactionStore: parallelTransactionStore, - domain: process.env.AUTH0_DOMAIN!, - clientId: process.env.AUTH0_CLIENT_ID!, - clientSecret: process.env.AUTH0_CLIENT_SECRET!, - appBaseUrl: process.env.APP_BASE_URL!, - secret: process.env.AUTH0_SECRET!, - routes: { - login: "/auth/login", - logout: "/auth/logout", - callback: "/auth/callback" - } -}); - -// Example 3: Using Default Configuration (No Custom TransactionStore) -export const auth0Default = new Auth0Client({ - // TransactionStore will be created automatically with default settings: - // - enableParallelTransactions: true - // - maxAge: 3600 (1 hour) - // - prefix: "__txn_" - domain: process.env.AUTH0_DOMAIN!, - clientId: process.env.AUTH0_CLIENT_ID!, - clientSecret: process.env.AUTH0_CLIENT_SECRET!, - appBaseUrl: process.env.APP_BASE_URL!, - secret: process.env.AUTH0_SECRET!, - routes: { - login: "/auth/login", - logout: "/auth/logout", - callback: "/auth/callback" - } -}); - -// Use the appropriate client based on your needs -export const auth0 = auth0Single; // or auth0Parallel, auth0Default diff --git a/examples/transaction-cookie-config/middleware.ts b/examples/transaction-cookie-config/middleware.ts deleted file mode 100644 index 7d9117b6..00000000 --- a/examples/transaction-cookie-config/middleware.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { NextRequest } from "next/server"; -import { auth0 } from "./lib/auth0"; - -export async function middleware(request: NextRequest) { - return await auth0.middleware(request); -} - -export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico, sitemap.xml, robots.txt (metadata files) - */ - "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)" - ] -}; diff --git a/examples/transaction-cookie-config/package.json b/examples/transaction-cookie-config/package.json deleted file mode 100644 index 619b9837..00000000 --- a/examples/transaction-cookie-config/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "transaction-cookie-config-example", - "version": "1.0.0", - "description": "Example demonstrating transaction cookie configuration in Auth0 Next.js SDK", - "main": "index.js", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start" - }, - "dependencies": { - "@auth0/nextjs-auth0": "^4.0.0", - "next": "^14.0.0", - "react": "^18.0.0", - "react-dom": "^18.0.0" - }, - "keywords": [ - "auth0", - "nextjs", - "transaction-cookies", - "authentication" - ] -}