Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

auth-session.md 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. # Auth Session Utility
  2. ## Principle
  3. 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. **Works for both API-only tests and browser tests.**
  4. ## Rationale
  5. Playwright's built-in authentication works but has limitations:
  6. - Re-authenticates for every test run (slow)
  7. - Single user per project setup
  8. - No token expiration handling
  9. - Manual session management
  10. - Complex setup for multi-user scenarios
  11. The `auth-session` utility provides:
  12. - **Token persistence**: Authenticate once, reuse across runs
  13. - **Multi-user support**: Different user identifiers in same test suite
  14. - **Ephemeral auth**: On-the-fly user authentication without disk persistence
  15. - **Worker-specific accounts**: Parallel execution with isolated user accounts
  16. - **Automatic token management**: Checks validity, renews if expired
  17. - **Flexible provider pattern**: Adapt to any auth system (OAuth2, JWT, custom)
  18. - **API-first design**: Get tokens for API tests without browser overhead
  19. ## Pattern Examples
  20. ### Example 1: Basic Auth Session Setup
  21. **Context**: Configure global authentication that persists across test runs.
  22. **Implementation**:
  23. ```typescript
  24. // Step 1: Configure in global-setup.ts
  25. import { authStorageInit, setAuthProvider, configureAuthSession, authGlobalInit } from '@seontechnologies/playwright-utils/auth-session';
  26. import myCustomProvider from './auth/custom-auth-provider';
  27. async function globalSetup() {
  28. // Ensure storage directories exist
  29. authStorageInit();
  30. // Configure storage path
  31. configureAuthSession({
  32. authStoragePath: process.cwd() + '/playwright/auth-sessions',
  33. debug: true,
  34. });
  35. // Set custom provider (HOW to authenticate)
  36. setAuthProvider(myCustomProvider);
  37. // Optional: pre-fetch token for default user
  38. await authGlobalInit();
  39. }
  40. export default globalSetup;
  41. // Step 2: Create auth fixture
  42. import { test as base } from '@playwright/test';
  43. import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
  44. import myCustomProvider from './custom-auth-provider';
  45. // Register provider early
  46. setAuthProvider(myCustomProvider);
  47. export const test = base.extend(createAuthFixtures());
  48. // Step 3: Use in tests
  49. test('authenticated request', async ({ authToken, request }) => {
  50. const response = await request.get('/api/protected', {
  51. headers: { Authorization: `Bearer ${authToken}` },
  52. });
  53. expect(response.ok()).toBeTruthy();
  54. });
  55. ```
  56. **Key Points**:
  57. - Global setup runs once before all tests
  58. - Token fetched once, reused across all tests
  59. - Custom provider defines your auth mechanism
  60. - Order matters: configure, then setProvider, then init
  61. ### Example 2: Multi-User Authentication
  62. **Context**: Testing with different user roles (admin, regular user, guest) in same test suite.
  63. **Implementation**:
  64. ```typescript
  65. import { test } from '../support/auth/auth-fixture';
  66. // Option 1: Per-test user override
  67. test('admin actions', async ({ authToken, authOptions }) => {
  68. // Override default user
  69. authOptions.userIdentifier = 'admin';
  70. const { authToken: adminToken } = await test.step('Get admin token', async () => {
  71. return { authToken }; // Re-fetches with new identifier
  72. });
  73. // Use admin token
  74. const response = await request.get('/api/admin/users', {
  75. headers: { Authorization: `Bearer ${adminToken}` },
  76. });
  77. });
  78. // Option 2: Parallel execution with different users
  79. test.describe.parallel('multi-user tests', () => {
  80. test('user 1 actions', async ({ authToken }) => {
  81. // Uses default user (e.g., 'user1')
  82. });
  83. test('user 2 actions', async ({ authToken, authOptions }) => {
  84. authOptions.userIdentifier = 'user2';
  85. // Uses different token for user2
  86. });
  87. });
  88. ```
  89. **Key Points**:
  90. - Override `authOptions.userIdentifier` per test
  91. - Tokens cached separately per user identifier
  92. - Parallel tests isolated with different users
  93. - Worker-specific accounts possible
  94. ### Example 3: Ephemeral User Authentication
  95. **Context**: Create temporary test users that don't persist to disk (e.g., testing user creation flow).
  96. **Implementation**:
  97. ```typescript
  98. import { applyUserCookiesToBrowserContext } from '@seontechnologies/playwright-utils/auth-session';
  99. import { createTestUser } from '../utils/user-factory';
  100. test('ephemeral user test', async ({ context, page }) => {
  101. // Create temporary user (not persisted)
  102. const ephemeralUser = await createTestUser({
  103. role: 'admin',
  104. permissions: ['delete-users'],
  105. });
  106. // Apply auth directly to browser context
  107. await applyUserCookiesToBrowserContext(context, ephemeralUser);
  108. // Page now authenticated as ephemeral user
  109. await page.goto('/admin/users');
  110. await expect(page.getByTestId('delete-user-btn')).toBeVisible();
  111. // User and token cleaned up after test
  112. });
  113. ```
  114. **Key Points**:
  115. - No disk persistence (ephemeral)
  116. - Apply cookies directly to context
  117. - Useful for testing user lifecycle
  118. - Clean up automatic when test ends
  119. ### Example 4: Testing Multiple Users in Single Test
  120. **Context**: Testing interactions between users (messaging, sharing, collaboration features).
  121. **Implementation**:
  122. ```typescript
  123. test('user interaction', async ({ browser }) => {
  124. // User 1 context
  125. const user1Context = await browser.newContext({
  126. storageState: './auth-sessions/local/user1/storage-state.json',
  127. });
  128. const user1Page = await user1Context.newPage();
  129. // User 2 context
  130. const user2Context = await browser.newContext({
  131. storageState: './auth-sessions/local/user2/storage-state.json',
  132. });
  133. const user2Page = await user2Context.newPage();
  134. // User 1 sends message
  135. await user1Page.goto('/messages');
  136. await user1Page.fill('#message', 'Hello from user 1');
  137. await user1Page.click('#send');
  138. // User 2 receives message
  139. await user2Page.goto('/messages');
  140. await expect(user2Page.getByText('Hello from user 1')).toBeVisible();
  141. // Cleanup
  142. await user1Context.close();
  143. await user2Context.close();
  144. });
  145. ```
  146. **Key Points**:
  147. - Each user has separate browser context
  148. - Reference storage state files directly
  149. - Test real-time interactions
  150. - Clean up contexts after test
  151. ### Example 5: Worker-Specific Accounts (Parallel Testing)
  152. **Context**: Running tests in parallel with isolated user accounts per worker to avoid conflicts.
  153. **Implementation**:
  154. ```typescript
  155. // playwright.config.ts
  156. export default defineConfig({
  157. workers: 4, // 4 parallel workers
  158. use: {
  159. // Each worker uses different user
  160. storageState: async ({}, use, testInfo) => {
  161. const workerIndex = testInfo.workerIndex;
  162. const userIdentifier = `worker-${workerIndex}`;
  163. await use(`./auth-sessions/local/${userIdentifier}/storage-state.json`);
  164. },
  165. },
  166. });
  167. // Tests run in parallel, each worker with its own user
  168. test('parallel test 1', async ({ page }) => {
  169. // Worker 0 uses worker-0 account
  170. await page.goto('/dashboard');
  171. });
  172. test('parallel test 2', async ({ page }) => {
  173. // Worker 1 uses worker-1 account
  174. await page.goto('/dashboard');
  175. });
  176. ```
  177. **Key Points**:
  178. - Each worker has isolated user account
  179. - No conflicts in parallel execution
  180. - Token management automatic per worker
  181. - Scales to any number of workers
  182. ### Example 6: Pure API Authentication (No Browser)
  183. **Context**: Get auth tokens for API-only tests using auth-session disk persistence.
  184. **Implementation**:
  185. ```typescript
  186. // Step 1: Create API-only auth provider (no browser needed)
  187. // playwright/support/api-auth-provider.ts
  188. import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
  189. const apiAuthProvider: AuthProvider = {
  190. getEnvironment: (options) => options.environment || 'local',
  191. getUserIdentifier: (options) => options.userIdentifier || 'api-user',
  192. extractToken: (storageState) => {
  193. // Token stored in localStorage format for disk persistence
  194. const tokenEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'auth_token');
  195. return tokenEntry?.value;
  196. },
  197. isTokenExpired: (storageState) => {
  198. const expiryEntry = storageState.origins?.[0]?.localStorage?.find((item) => item.name === 'token_expiry');
  199. if (!expiryEntry) return true;
  200. return Date.now() > parseInt(expiryEntry.value, 10);
  201. },
  202. manageAuthToken: async (request, options) => {
  203. const email = process.env.TEST_USER_EMAIL;
  204. const password = process.env.TEST_USER_PASSWORD;
  205. if (!email || !password) {
  206. throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
  207. }
  208. // Pure API login - no browser!
  209. const response = await request.post('/api/auth/login', {
  210. data: { email, password },
  211. });
  212. if (!response.ok()) {
  213. throw new Error(`Auth failed: ${response.status()}`);
  214. }
  215. const { token, expiresIn } = await response.json();
  216. const expiryTime = Date.now() + expiresIn * 1000;
  217. // Return storage state format for disk persistence
  218. return {
  219. cookies: [],
  220. origins: [
  221. {
  222. origin: process.env.API_BASE_URL || 'http://localhost:3000',
  223. localStorage: [
  224. { name: 'auth_token', value: token },
  225. { name: 'token_expiry', value: String(expiryTime) },
  226. ],
  227. },
  228. ],
  229. };
  230. },
  231. };
  232. export default apiAuthProvider;
  233. // Step 2: Create auth fixture
  234. // playwright/support/fixtures.ts
  235. import { test as base } from '@playwright/test';
  236. import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
  237. import apiAuthProvider from './api-auth-provider';
  238. setAuthProvider(apiAuthProvider);
  239. export const test = base.extend(createAuthFixtures());
  240. // Step 3: Use in tests - token persisted to disk!
  241. // tests/api/authenticated-api.spec.ts
  242. import { test } from '../support/fixtures';
  243. import { expect } from '@playwright/test';
  244. test('should access protected endpoint', async ({ authToken, apiRequest }) => {
  245. // authToken is automatically loaded from disk or fetched if expired
  246. const { status, body } = await apiRequest({
  247. method: 'GET',
  248. path: '/api/me',
  249. headers: { Authorization: `Bearer ${authToken}` },
  250. });
  251. expect(status).toBe(200);
  252. });
  253. test('should create resource with auth', async ({ authToken, apiRequest }) => {
  254. const { status, body } = await apiRequest({
  255. method: 'POST',
  256. path: '/api/orders',
  257. headers: { Authorization: `Bearer ${authToken}` },
  258. body: { items: [{ productId: 'prod-1', quantity: 2 }] },
  259. });
  260. expect(status).toBe(201);
  261. expect(body.id).toBeDefined();
  262. });
  263. ```
  264. **Key Points**:
  265. - Token persisted to disk (not in-memory) - survives test reruns
  266. - Provider fetches token once, reuses until expired
  267. - Pure API authentication - no browser context needed
  268. - `authToken` fixture handles disk read/write automatically
  269. - Environment variables validated with clear error message
  270. ### Example 7: Service-to-Service Authentication
  271. **Context**: Test microservice authentication patterns (API keys, service tokens) with proper environment validation.
  272. **Implementation**:
  273. ```typescript
  274. // tests/api/service-auth.spec.ts
  275. import { test as base, expect } from '@playwright/test';
  276. import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
  277. import { mergeTests } from '@playwright/test';
  278. // Validate environment variables at module load
  279. const SERVICE_API_KEY = process.env.SERVICE_API_KEY;
  280. const INTERNAL_SERVICE_URL = process.env.INTERNAL_SERVICE_URL;
  281. if (!SERVICE_API_KEY) {
  282. throw new Error('SERVICE_API_KEY environment variable is required');
  283. }
  284. if (!INTERNAL_SERVICE_URL) {
  285. throw new Error('INTERNAL_SERVICE_URL environment variable is required');
  286. }
  287. const test = mergeTests(base, apiFixture);
  288. test.describe('Service-to-Service Auth', () => {
  289. test('should authenticate with API key', async ({ apiRequest }) => {
  290. const { status, body } = await apiRequest({
  291. method: 'GET',
  292. path: '/internal/health',
  293. baseUrl: INTERNAL_SERVICE_URL,
  294. headers: { 'X-API-Key': SERVICE_API_KEY },
  295. });
  296. expect(status).toBe(200);
  297. expect(body.status).toBe('healthy');
  298. });
  299. test('should reject invalid API key', async ({ apiRequest }) => {
  300. const { status, body } = await apiRequest({
  301. method: 'GET',
  302. path: '/internal/health',
  303. baseUrl: INTERNAL_SERVICE_URL,
  304. headers: { 'X-API-Key': 'invalid-key' },
  305. });
  306. expect(status).toBe(401);
  307. expect(body.code).toBe('INVALID_API_KEY');
  308. });
  309. test('should call downstream service with propagated auth', async ({ apiRequest }) => {
  310. const { status, body } = await apiRequest({
  311. method: 'POST',
  312. path: '/internal/aggregate-data',
  313. baseUrl: INTERNAL_SERVICE_URL,
  314. headers: {
  315. 'X-API-Key': SERVICE_API_KEY,
  316. 'X-Request-ID': `test-${Date.now()}`,
  317. },
  318. body: { sources: ['users', 'orders', 'inventory'] },
  319. });
  320. expect(status).toBe(200);
  321. expect(body.aggregatedFrom).toHaveLength(3);
  322. });
  323. });
  324. ```
  325. **Key Points**:
  326. - Environment variables validated at module load with clear errors
  327. - API key authentication (simpler than OAuth - no disk persistence needed)
  328. - Test internal/service endpoints
  329. - Validate auth rejection scenarios
  330. - Correlation ID for request tracing
  331. > **Note**: API keys are typically static secrets that don't expire, so disk persistence (auth-session) isn't needed. For rotating service tokens, use the auth-session provider pattern from Example 6.
  332. ## Custom Auth Provider Pattern
  333. **Context**: Adapt auth-session to your authentication system (OAuth2, JWT, SAML, custom).
  334. **Minimal provider structure**:
  335. ```typescript
  336. import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
  337. const myCustomProvider: AuthProvider = {
  338. getEnvironment: (options) => options.environment || 'local',
  339. getUserIdentifier: (options) => options.userIdentifier || 'default-user',
  340. extractToken: (storageState) => {
  341. // Extract token from your storage format
  342. return storageState.cookies.find((c) => c.name === 'auth_token')?.value;
  343. },
  344. extractCookies: (tokenData) => {
  345. // Convert token to cookies for browser context
  346. return [
  347. {
  348. name: 'auth_token',
  349. value: tokenData,
  350. domain: 'example.com',
  351. path: '/',
  352. httpOnly: true,
  353. secure: true,
  354. },
  355. ];
  356. },
  357. isTokenExpired: (storageState) => {
  358. // Check if token is expired
  359. const expiresAt = storageState.cookies.find((c) => c.name === 'expires_at');
  360. return Date.now() > parseInt(expiresAt?.value || '0');
  361. },
  362. manageAuthToken: async (request, options) => {
  363. // Main token acquisition logic
  364. // Return storage state with cookies/localStorage
  365. },
  366. };
  367. export default myCustomProvider;
  368. ```
  369. ## Integration with API Request
  370. ```typescript
  371. import { test } from '@seontechnologies/playwright-utils/fixtures';
  372. test('authenticated API call', async ({ apiRequest, authToken }) => {
  373. const { status, body } = await apiRequest({
  374. method: 'GET',
  375. path: '/api/protected',
  376. headers: { Authorization: `Bearer ${authToken}` },
  377. });
  378. expect(status).toBe(200);
  379. });
  380. ```
  381. ## Related Fragments
  382. - `api-testing-patterns.md` - Pure API testing patterns (no browser)
  383. - `overview.md` - Installation and fixture composition
  384. - `api-request.md` - Authenticated API requests
  385. - `fixtures-composition.md` - Merging auth with other utilities
  386. ## Anti-Patterns
  387. **❌ Calling setAuthProvider after globalSetup:**
  388. ```typescript
  389. async function globalSetup() {
  390. configureAuthSession(...)
  391. await authGlobalInit() // Provider not set yet!
  392. setAuthProvider(provider) // Too late
  393. }
  394. ```
  395. **✅ Register provider before init:**
  396. ```typescript
  397. async function globalSetup() {
  398. authStorageInit()
  399. configureAuthSession(...)
  400. setAuthProvider(provider) // First
  401. await authGlobalInit() // Then init
  402. }
  403. ```
  404. **❌ Hardcoding storage paths:**
  405. ```typescript
  406. const storageState = './auth-sessions/local/user1/storage-state.json'; // Brittle
  407. ```
  408. **✅ Use helper functions:**
  409. ```typescript
  410. import { getTokenFilePath } from '@seontechnologies/playwright-utils/auth-session';
  411. const tokenPath = getTokenFilePath({
  412. environment: 'local',
  413. userIdentifier: 'user1',
  414. tokenFileName: 'storage-state.json',
  415. });
  416. ```