Keep/_bmad/bmm/testarch/knowledge/auth-session.md

9.6 KiB

Auth Session Utility

Principle

Persist authentication tokens to disk and reuse across test runs. Support multiple user identifiers, ephemeral authentication, and worker-specific accounts for parallel execution. Fetch tokens once, use everywhere.

Rationale

Playwright's built-in authentication works but has limitations:

  • Re-authenticates for every test run (slow)
  • Single user per project setup
  • No token expiration handling
  • Manual session management
  • Complex setup for multi-user scenarios

The auth-session utility provides:

  • Token persistence: Authenticate once, reuse across runs
  • Multi-user support: Different user identifiers in same test suite
  • Ephemeral auth: On-the-fly user authentication without disk persistence
  • Worker-specific accounts: Parallel execution with isolated user accounts
  • Automatic token management: Checks validity, renews if expired
  • Flexible provider pattern: Adapt to any auth system (OAuth2, JWT, custom)

Pattern Examples

Example 1: Basic Auth Session Setup

Context: Configure global authentication that persists across test runs.

Implementation:

// Step 1: Configure in global-setup.ts
import { authStorageInit, setAuthProvider, configureAuthSession, authGlobalInit } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './auth/custom-auth-provider';

async function globalSetup() {
  // Ensure storage directories exist
  authStorageInit();

  // Configure storage path
  configureAuthSession({
    authStoragePath: process.cwd() + '/playwright/auth-sessions',
    debug: true,
  });

  // Set custom provider (HOW to authenticate)
  setAuthProvider(myCustomProvider);

  // Optional: pre-fetch token for default user
  await authGlobalInit();
}

export default globalSetup;

// Step 2: Create auth fixture
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import myCustomProvider from './custom-auth-provider';

// Register provider early
setAuthProvider(myCustomProvider);

export const test = base.extend(createAuthFixtures());

// Step 3: Use in tests
test('authenticated request', async ({ authToken, request }) => {
  const response = await request.get('/api/protected', {
    headers: { Authorization: `Bearer ${authToken}` },
  });

  expect(response.ok()).toBeTruthy();
});

Key Points:

  • Global setup runs once before all tests
  • Token fetched once, reused across all tests
  • Custom provider defines your auth mechanism
  • Order matters: configure, then setProvider, then init

Example 2: Multi-User Authentication

Context: Testing with different user roles (admin, regular user, guest) in same test suite.

Implementation:

import { test } from '../support/auth/auth-fixture';

// Option 1: Per-test user override
test('admin actions', async ({ authToken, authOptions }) => {
  // Override default user
  authOptions.userIdentifier = 'admin';

  const { authToken: adminToken } = await test.step('Get admin token', async () => {
    return { authToken }; // Re-fetches with new identifier
  });

  // Use admin token
  const response = await request.get('/api/admin/users', {
    headers: { Authorization: `Bearer ${adminToken}` },
  });
});

// Option 2: Parallel execution with different users
test.describe.parallel('multi-user tests', () => {
  test('user 1 actions', async ({ authToken }) => {
    // Uses default user (e.g., 'user1')
  });

  test('user 2 actions', async ({ authToken, authOptions }) => {
    authOptions.userIdentifier = 'user2';
    // Uses different token for user2
  });
});

Key Points:

  • Override authOptions.userIdentifier per test
  • Tokens cached separately per user identifier
  • Parallel tests isolated with different users
  • Worker-specific accounts possible

Example 3: Ephemeral User Authentication

Context: Create temporary test users that don't persist to disk (e.g., testing user creation flow).

Implementation:

import { applyUserCookiesToBrowserContext } from '@seontechnologies/playwright-utils/auth-session';
import { createTestUser } from '../utils/user-factory';

test('ephemeral user test', async ({ context, page }) => {
  // Create temporary user (not persisted)
  const ephemeralUser = await createTestUser({
    role: 'admin',
    permissions: ['delete-users'],
  });

  // Apply auth directly to browser context
  await applyUserCookiesToBrowserContext(context, ephemeralUser);

  // Page now authenticated as ephemeral user
  await page.goto('/admin/users');

  await expect(page.getByTestId('delete-user-btn')).toBeVisible();

  // User and token cleaned up after test
});

Key Points:

  • No disk persistence (ephemeral)
  • Apply cookies directly to context
  • Useful for testing user lifecycle
  • Clean up automatic when test ends

Example 4: Testing Multiple Users in Single Test

Context: Testing interactions between users (messaging, sharing, collaboration features).

Implementation:

test('user interaction', async ({ browser }) => {
  // User 1 context
  const user1Context = await browser.newContext({
    storageState: './auth-sessions/local/user1/storage-state.json',
  });
  const user1Page = await user1Context.newPage();

  // User 2 context
  const user2Context = await browser.newContext({
    storageState: './auth-sessions/local/user2/storage-state.json',
  });
  const user2Page = await user2Context.newPage();

  // User 1 sends message
  await user1Page.goto('/messages');
  await user1Page.fill('#message', 'Hello from user 1');
  await user1Page.click('#send');

  // User 2 receives message
  await user2Page.goto('/messages');
  await expect(user2Page.getByText('Hello from user 1')).toBeVisible();

  // Cleanup
  await user1Context.close();
  await user2Context.close();
});

Key Points:

  • Each user has separate browser context
  • Reference storage state files directly
  • Test real-time interactions
  • Clean up contexts after test

Example 5: Worker-Specific Accounts (Parallel Testing)

Context: Running tests in parallel with isolated user accounts per worker to avoid conflicts.

Implementation:

// playwright.config.ts
export default defineConfig({
  workers: 4, // 4 parallel workers
  use: {
    // Each worker uses different user
    storageState: async ({}, use, testInfo) => {
      const workerIndex = testInfo.workerIndex;
      const userIdentifier = `worker-${workerIndex}`;

      await use(`./auth-sessions/local/${userIdentifier}/storage-state.json`);
    },
  },
});

// Tests run in parallel, each worker with its own user
test('parallel test 1', async ({ page }) => {
  // Worker 0 uses worker-0 account
  await page.goto('/dashboard');
});

test('parallel test 2', async ({ page }) => {
  // Worker 1 uses worker-1 account
  await page.goto('/dashboard');
});

Key Points:

  • Each worker has isolated user account
  • No conflicts in parallel execution
  • Token management automatic per worker
  • Scales to any number of workers

Custom Auth Provider Pattern

Context: Adapt auth-session to your authentication system (OAuth2, JWT, SAML, custom).

Minimal provider structure:

import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';

const myCustomProvider: AuthProvider = {
  getEnvironment: (options) => options.environment || 'local',

  getUserIdentifier: (options) => options.userIdentifier || 'default-user',

  extractToken: (storageState) => {
    // Extract token from your storage format
    return storageState.cookies.find((c) => c.name === 'auth_token')?.value;
  },

  extractCookies: (tokenData) => {
    // Convert token to cookies for browser context
    return [
      {
        name: 'auth_token',
        value: tokenData,
        domain: 'example.com',
        path: '/',
        httpOnly: true,
        secure: true,
      },
    ];
  },

  isTokenExpired: (storageState) => {
    // Check if token is expired
    const expiresAt = storageState.cookies.find((c) => c.name === 'expires_at');
    return Date.now() > parseInt(expiresAt?.value || '0');
  },

  manageAuthToken: async (request, options) => {
    // Main token acquisition logic
    // Return storage state with cookies/localStorage
  },
};

export default myCustomProvider;

Integration with API Request

import { test } from '@seontechnologies/playwright-utils/fixtures';

test('authenticated API call', async ({ apiRequest, authToken }) => {
  const { status, body } = await apiRequest({
    method: 'GET',
    path: '/api/protected',
    headers: { Authorization: `Bearer ${authToken}` },
  });

  expect(status).toBe(200);
});
  • overview.md - Installation and fixture composition
  • api-request.md - Authenticated API requests
  • fixtures-composition.md - Merging auth with other utilities

Anti-Patterns

Calling setAuthProvider after globalSetup:

async function globalSetup() {
  configureAuthSession(...)
  await authGlobalInit()  // Provider not set yet!
  setAuthProvider(provider)  // Too late
}

Register provider before init:

async function globalSetup() {
  authStorageInit()
  configureAuthSession(...)
  setAuthProvider(provider)  // First
  await authGlobalInit()     // Then init
}

Hardcoding storage paths:

const storageState = './auth-sessions/local/user1/storage-state.json'; // Brittle

Use helper functions:

import { getTokenFilePath } from '@seontechnologies/playwright-utils/auth-session';

const tokenPath = getTokenFilePath({
  environment: 'local',
  userIdentifier: 'user1',
  tokenFileName: 'storage-state.json',
});