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. diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 094059dd..87c6050f 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -5039,14 +5039,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(); @@ -5059,14 +5062,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 }); @@ -5079,14 +5085,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 }); @@ -5101,15 +5110,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 6976533b..7390fdf0 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -395,6 +395,8 @@ export class AuthClient { // Set response and save transaction const res = NextResponse.redirect(authorizationUrl.toString()); + + // Save transaction state await this.transactionStore.save(res.cookies, transactionState); return res; @@ -505,7 +507,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( @@ -525,7 +527,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; @@ -537,7 +544,7 @@ export class AuthClient { transactionState.state ); } catch (e: any) { - return this.onCallback( + return this.handleCallbackError( new AuthorizationError({ cause: new OAuth2Error({ code: e.error, @@ -545,7 +552,8 @@ export class AuthClient { }) }), onCallbackCtx, - null + req, + state ); } @@ -566,10 +574,11 @@ export class AuthClient { } ); } catch (e: any) { - return this.onCallback( + return this.handleCallbackError( new AuthorizationCodeGrantRequestError(e.message), onCallbackCtx, - null + req, + state ); } @@ -586,7 +595,7 @@ export class AuthClient { } ); } catch (e: any) { - return this.onCallback( + return this.handleCallbackError( new AuthorizationCodeGrantError({ cause: new OAuth2Error({ code: e.error, @@ -594,7 +603,8 @@ export class AuthClient { }) }), onCallbackCtx, - null + req, + state ); } @@ -622,6 +632,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; @@ -903,6 +915,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]> { 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 bbbc6549..6a36ab8d 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -198,6 +198,8 @@ export interface Auth0ClientOptions { * Defaults to `false`. */ noContentProfileResponseWhenUnauthenticated?: boolean; + + enableParallelTransactions?: boolean; } export type PagesRouterRequest = IncomingMessage | NextApiRequest; @@ -247,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) { @@ -272,7 +275,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/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/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 3c520ed8..81ccc204 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,27 +105,50 @@ 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 + transactionState: TransactionState, + reqCookies?: cookies.RequestCookies ) { - const expiration = Math.floor( - Date.now() / 1000 + 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) { + console.warn( + "A transaction is already in progress. Only one transaction is allowed when parallel transactions are disabled." + ); + return; + } + } + + const expirationSeconds = this.cookieOptions.maxAge!; + const expiration = Math.floor(Date.now() / 1000 + expirationSeconds); const jwe = await cookies.encrypt( transactionState, this.secret, expiration ); - if (!transactionState.state) { - throw new Error("Transaction state is required"); - } - resCookies.set( this.getTransactionCookieName(transactionState.state), jwe.toString(), - this.cookieConfig + this.cookieOptions ); }