@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
654 lines (653 loc) • 34.7 kB
JavaScript
/* 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 { 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 { AuthClient } from "./auth-client.js";
import { AbstractSessionStore } from "./session/abstract-session-store.js";
import { StatelessSessionStore } from "./session/stateless-session-store.js";
import { TransactionStore } from "./transaction-store.js";
// 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;
// Helper function to create a valid ID token
const createValidIdToken = async (claims = {}) => {
if (!keyPair) {
keyPair = await jose.generateKeyPair("RS256");
}
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) {
super(config);
}
async get(_reqCookies) {
return null;
}
async set(_reqCookies, _resCookies, _session, _isNew) {
// Empty implementation for testing
}
async delete(_reqCookies, _resCookies) {
// Empty implementation for testing
}
}
const baseOptions = {
domain,
clientId,
clientSecret: "test-client-secret",
appBaseUrl: "http://localhost:3000",
secret: "a-sufficiently-long-secret-for-testing",
routes: getDefaultRoutes()
};
describe("Ensure that redundant transaction cookies are deleted from auth-client methods", () => {
/**
* Test Suite Purpose: These tests ensure proper transaction cookie lifecycle management
* to prevent the "infinitely stacking cookies" bug that existed in v4.
*
* Background:
* - OAuth flows use transaction cookies to maintain state between login and callback
* - In v4, these cookies were not properly cleaned up, leading to accumulation
* - Multiple failed/abandoned auth attempts would create dozens/hundreds of cookies
* - This would eventually hit browser cookie limits and break authentication
*
* What we're testing:
* 1. Successful auth flows properly clean up transaction cookies
* 2. Failed auth flows don't corrupt existing transaction state
* 3. Logout cleans up all auth-related cookies (session + transactions)
* 4. Error conditions preserve other parallel authentication attempts
* 5. Integration scenarios with real cookie accumulation
*
* Key principles:
* - Clean up on success (remove used transaction cookies)
* - Preserve on failure (don't break other auth attempts)
* - Bulk cleanup on logout (clear all auth state)
*/
let authClient;
let mockTransactionStoreInstance;
let mockSessionStoreInstance;
let secret;
beforeEach(async () => {
vi.clearAllMocks();
vi.restoreAllMocks();
secret = await generateSecret(32);
// Create real transaction store for integration testing
mockTransactionStoreInstance = new TransactionStore({
secret,
enableParallelTransactions: true
});
const testSessionStoreOptions = {
secret: "test-secret",
cookieOptions: { name: "__session", path: "/", sameSite: "lax" }
};
mockSessionStoreInstance = new TestSessionStore(testSessionStoreOptions);
// Mock session store methods for controlled testing
mockSessionStoreInstance.get = vi.fn().mockResolvedValue({
user: { sub: "user123" },
internal: { sid: "sid123" },
tokenSet: { idToken: "idtoken123" }
});
mockSessionStoreInstance.delete = vi.fn().mockResolvedValue(undefined);
mockSessionStoreInstance.set = vi.fn().mockResolvedValue(undefined);
authClient = new AuthClient({
...baseOptions,
secret,
sessionStore: mockSessionStoreInstance,
transactionStore: mockTransactionStoreInstance
});
// 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");
// Restore all oauth4webapi mocks with proper return values
vi.mocked(oauth.validateAuthResponse).mockReturnValue(new URLSearchParams("code=auth_code&state=test-state"));
// 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`
});
// Mock token request for MSW integration
vi.mocked(oauth.authorizationCodeGrantRequest).mockResolvedValue(new Response());
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"
});
// 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://${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"
});
});
describe("handleLogout", () => {
it("should delete session cookie but no transaction cookies if none exist", async () => {
/**
* Test Purpose: Verify that logout properly cleans up session cookies and calls
* transaction cleanup even when no transaction cookies exist.
*
* Why this matters:
* - Logout should always attempt to clean up ALL auth-related state
* - Even if no transaction cookies exist, the cleanup method should still be called
* - This ensures consistent logout behavior regardless of current auth state
* - Validates that logout doesn't break when there are no pending transactions
*/
// Arrange: Create logout request with only a session cookie
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__session", "session-value");
// Act: Process the logout
const res = await authClient.handleLogout(req);
// Assert: Verify session cleanup occurred
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
// Check that transaction cookie cleanup was attempted (even if none exist)
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);
});
it("should delete session cookie AND call deleteAll for transaction cookies", async () => {
/**
* Test Purpose: Verify that logout cleans up both session and all transaction cookies
* when multiple transaction cookies exist.
*
* Why this matters:
* - This tests the main logout cleanup functionality with multiple pending transactions
* - Demonstrates that logout clears ALL auth state, not just the session
* - Important for security - ensures no auth state is left behind after logout
* - Validates the bulk transaction cookie cleanup mechanism
*/
// Arrange: Create logout request with session and multiple transaction cookies
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__session", "session-value");
req.cookies.set("__txn_state1", "txn-value1");
req.cookies.set("__txn_state2", "txn-value2");
req.cookies.set("other_cookie", "other-value"); // Non-auth cookie should be preserved
// Act: Process the logout
const res = await authClient.handleLogout(req);
// Assert: Verify all auth-related cleanup occurred
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
// 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 call deleteAll for transaction cookies even if no session exists", async () => {
mockSessionStoreInstance.get = vi.fn().mockResolvedValue(null);
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__txn_state1", "txn-value1");
const res = await authClient.handleLogout(req);
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
// 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_";
const customTxnStore = new TransactionStore({
secret,
enableParallelTransactions: true,
cookieOptions: { prefix: customPrefix }
});
authClient = new AuthClient({
...baseOptions,
secret,
sessionStore: mockSessionStoreInstance,
transactionStore: customTxnStore
});
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__session", "session-value");
req.cookies.set(`${customPrefix}state1`, "txn-value1");
req.cookies.set("__txn_state2", "default-prefix-value");
const res = await authClient.handleLogout(req);
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
// 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: {}
});
});
it("should delete the correct transaction cookie on success", async () => {
/**
* Test Purpose: Verify that when OAuth callback succeeds, only the specific transaction
* cookie for that authentication flow is deleted, not all transaction cookies.
*
* Why this matters:
* - In successful auth flows, we should clean up the used transaction cookie
* - But preserve other parallel authentication attempts that might be in progress
* - This is the "happy path" that demonstrates proper transaction cookie lifecycle
* - Validates that the state parameter correctly identifies which transaction to clean up
*/
// Arrange: First, do a login to get proper state and transaction cookie
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);
}
// Act: Process the successful callback
const res = await authClient.handleCallback(req);
// Assert: Verify transaction was retrieved and processed
expect(mockTransactionStoreInstance.get).toHaveBeenCalledWith(req.cookies, state);
// Check that the specific 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);
expect(res.status).toBeLessThan(400);
expect(res.headers.get("location")).toBe("http://localhost:3000/");
});
it("should NOT delete transaction cookie on InvalidStateError", async () => {
/**
* Test Purpose: Verify that when an OAuth callback has an invalid/unknown state parameter,
* the system does not delete transaction cookies to preserve other authentication flows.
*
* Why this matters:
* - Invalid state could indicate a stale/corrupted request or potential attack
* - The transaction store returns null when state is not found (expired or never existed)
* - We should NOT delete cookies on this error to avoid breaking other valid auth attempts
* - In parallel authentication scenarios, one invalid state shouldn't affect others
*/
// Arrange: Set up scenario where transaction store can't find the state
const state = "invalid-state";
vi.spyOn(mockTransactionStoreInstance, "get").mockResolvedValue(null);
const req = new NextRequest(`http://localhost:3000/api/auth/callback?code=auth_code&state=${state}`);
// Act: Handle callback with invalid state
const res = await authClient.handleCallback(req);
// Assert: Verify transaction store was queried but found nothing
expect(mockTransactionStoreInstance.get).toHaveBeenCalledWith(req.cookies, state);
// Check that no transaction cookies were deleted (preserve other auth flows)
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 InvalidStateError().message);
});
it("should NOT delete transaction cookie on MissingStateError", async () => {
/**
* Test Purpose: Verify that when an OAuth callback is missing the required 'state' parameter,
* the system fails gracefully without deleting any transaction cookies.
*
* Why this matters:
* - The 'state' parameter is critical for CSRF protection in OAuth flows
* - Missing state indicates a malformed request or potential attack
* - We should NOT delete transaction cookies on errors to preserve other valid auth attempts
* - This prevents breaking parallel authentication flows or user retry scenarios
*/
// Arrange: Create callback request WITHOUT state parameter (this triggers MissingStateError)
const req = new NextRequest(`http://localhost:3000/api/auth/callback?code=auth_code`
// Notice: deliberately missing &state=xyz parameter
);
// Act: Handle the malformed callback
const res = await authClient.handleCallback(req);
// Assert: Verify error handling behavior
// Should not attempt to retrieve transaction since no state to look up
expect(mockTransactionStoreInstance.get).not.toHaveBeenCalled();
// Check that no transaction cookies were deleted (preserve for other auth flows)
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", () => {
/**
* Test Suite Purpose: These integration tests address a critical bug in v4 where transaction
* cookies would accumulate infinitely, causing browser cookie limits to be exceeded.
*
* The Problem:
* - In v4, failed or abandoned auth attempts would leave transaction cookies in the browser
* - Each new auth attempt would create a new transaction cookie
* - Over time, this would lead to hundreds of transaction cookies accumulating
* - Eventually browsers would hit cookie limits and start dropping cookies randomly
* - This would break the authentication flow entirely
*
* The Solution:
* - Implement proper transaction cookie cleanup after successful authentication
* - Ensure failed authentications don't leave stale cookies
* - Add bulk cleanup methods for removing all transaction cookies when needed
* - Support both single and parallel transaction modes
*/
let 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
});
});
describe("Happy Path", () => {
it("should clean up all transaction cookies after successful authentication", async () => {
/**
* Test Purpose: This is the main integration test that validates the fix for the
* v4 infinitely stacking cookies bug in a realistic scenario.
*
* What this test simulates:
* 1. User starts an auth flow (gets a transaction cookie)
* 2. Browser has some stale transaction cookies from previous attempts
* 3. User completes the auth flow successfully
* 4. System should clean up ALL transaction cookies, not just the current one
*
* Why this is critical:
* - This prevents the infinite accumulation of transaction cookies
* - Ensures browsers don't hit cookie limits
* - Maintains clean auth state after successful authentication
* - Works with both current and legacy transaction cookies
*/
// 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", () => {
/**
* Edge Case Testing: These tests ensure the transaction cookie cleanup works
* correctly in various edge scenarios that might occur in production.
*/
it("should handle callback with no existing transaction cookies gracefully", async () => {
/**
* Test Purpose: Verify that the cleanup mechanism works correctly even when
* there are no stale transaction cookies to clean up.
*
* Why this matters:
* - Not all auth flows will have accumulated stale cookies
* - The cleanup logic should be robust and not fail when there's nothing to clean
* - This is a baseline test to ensure the happy path works in the simplest case
*/
// 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 () => {
/**
* Test Purpose: Verify that transaction cookie cleanup is surgical and only
* affects transaction cookies, leaving other application cookies untouched.
*
* Why this matters:
* - Applications often have other cookies for user preferences, analytics, etc.
* - The cleanup mechanism should be precise and not have side effects
* - This ensures the auth system doesn't interfere with other app functionality
* - Validates that our cookie filtering logic is working correctly
*/
// 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
});
// 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
});
});
});
});