/** * SSO API Client for E2E testing. * * Direct HTTP client for SSO API operations. * Used by test fixtures to perform auth operations without going through the UI. */ export interface User { id: string; email: string; username: string; accessLevel: string; profiles: string[]; isActive: boolean; emailVerified: boolean; avatar?: string; bio?: string; createdAt: string; updatedAt: string; } export interface LoginResponse { success: boolean; sessionId: string; user: User; mfaRequired?: boolean; pendingSessionId?: string; } export interface RegisterData { email: string; username: string; password: string; registrationSelection?: string; } interface SSOApiClientOptions { baseUrl: string; timeout?: number; } /** * HTTP client for direct SSO API calls. * * This allows tests to perform auth operations without going through the UI, * making test setup faster and more reliable. */ export class SSOApiClient { private readonly baseUrl: string; private readonly timeout: number; constructor(options: SSOApiClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ''); this.timeout = options.timeout || 10000; } /** * Login with email and password. * Includes retry logic for rate-limited responses. */ async login(credentials: { email: string; password: string }): Promise { return this.withRateLimitRetry(async () => { const response = await this.fetch('/auth/login', { method: 'POST', body: JSON.stringify(credentials), }); if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Login failed' })) as { message?: string }; throw new Error(`Login failed: ${error.message || response.statusText}`); } return response.json() as Promise; }); } /** * Register a new user. */ async register(data: RegisterData): Promise { return this.withRateLimitRetry(async () => { const response = await this.fetch('/auth/register', { method: 'POST', body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Registration failed' })) as { message?: string }; throw new Error(`Registration failed: ${error.message || response.statusText}`); } return response.json() as Promise; }); } /** * Logout and invalidate session. */ async logout(sessionId: string): Promise { const response = await this.fetch('/auth/logout', { method: 'POST', headers: { Authorization: `Bearer ${sessionId}`, }, }); // Logout may return 401 if session already expired - that's fine if (!response.ok && response.status !== 401) { throw new Error(`Logout failed: ${response.statusText}`); } } /** * Get current user from session. * * SSO returns { authenticated: true, user } or { authenticated: false } */ async getCurrentUser(sessionId: string): Promise { const response = await this.fetch('/auth/me', { headers: { Authorization: `Bearer ${sessionId}`, }, }); if (response.status === 401) { return null; } if (!response.ok) { throw new Error(`Failed to get user: ${response.statusText}`); } const data = await response.json() as { authenticated: boolean; user: User }; if (!data.authenticated) { return null; } return data.user; } /** * Validate a session token. */ async validateSession(sessionId: string): Promise { const user = await this.getCurrentUser(sessionId); return user !== null; } /** * Request password reset. * SSO always returns success to prevent email enumeration. */ async requestPasswordReset(email: string): Promise { await this.withRateLimitRetry(async () => { const response = await this.fetch('/auth/password-reset/request', { method: 'POST', body: JSON.stringify({ email }), }); if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Request failed' })) as { message?: string }; throw new Error(`Password reset request failed: ${error.message || response.statusText}`); } await response.json(); }); } /** * Health check for SSO service. */ async healthCheck(): Promise { try { const response = await this.fetch('/health'); return response.ok; } catch { return false; } } /** * Retry a request if rate limited (429), with exponential backoff. */ private async withRateLimitRetry(fn: () => Promise, maxRetries = 3): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { const message = error instanceof Error ? error.message : String(error); const isRateLimited = message.includes('Too many requests') || message.includes('429'); if (isRateLimited && attempt < maxRetries) { // Wait with exponential backoff: 15s, 30s, 60s const delay = 15000 * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; } } throw new Error('Rate limit retry exhausted'); } private async fetch(path: string, options: RequestInit = {}): Promise { const url = `${this.baseUrl}${path}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { return await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...options.headers, }, }); } finally { clearTimeout(timeoutId); } } }