2026-01-29 23:01:19 -08:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-01-30 16:24:56 -08:00
|
|
|
success: boolean;
|
2026-01-29 23:01:19 -08:00
|
|
|
sessionId: string;
|
|
|
|
|
user: User;
|
2026-01-30 16:24:56 -08:00
|
|
|
mfaRequired?: boolean;
|
|
|
|
|
pendingSessionId?: string;
|
2026-01-29 23:01:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RegisterData {
|
|
|
|
|
email: string;
|
|
|
|
|
username: string;
|
|
|
|
|
password: string;
|
2026-01-30 16:24:56 -08:00
|
|
|
registrationSelection?: string;
|
2026-01-29 23:01:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
2026-01-30 16:24:56 -08:00
|
|
|
* Includes retry logic for rate-limited responses.
|
2026-01-29 23:01:19 -08:00
|
|
|
*/
|
|
|
|
|
async login(credentials: { email: string; password: string }): Promise<LoginResponse> {
|
2026-01-30 16:24:56 -08:00
|
|
|
return this.withRateLimitRetry(async () => {
|
|
|
|
|
const response = await this.fetch('/auth/login', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(credentials),
|
|
|
|
|
});
|
2026-01-29 23:01:19 -08:00
|
|
|
|
2026-01-30 16:24:56 -08:00
|
|
|
if (!response.ok) {
|
2026-03-20 04:49:36 -07:00
|
|
|
const error = await response.json().catch(() => ({ message: 'Login failed' })) as { message?: string };
|
2026-01-30 16:24:56 -08:00
|
|
|
throw new Error(`Login failed: ${error.message || response.statusText}`);
|
|
|
|
|
}
|
2026-01-29 23:01:19 -08:00
|
|
|
|
2026-03-20 04:49:36 -07:00
|
|
|
return response.json() as Promise<LoginResponse>;
|
2026-01-30 16:24:56 -08:00
|
|
|
});
|
2026-01-29 23:01:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register a new user.
|
|
|
|
|
*/
|
|
|
|
|
async register(data: RegisterData): Promise<LoginResponse> {
|
2026-01-30 16:24:56 -08:00
|
|
|
return this.withRateLimitRetry(async () => {
|
|
|
|
|
const response = await this.fetch('/auth/register', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify(data),
|
|
|
|
|
});
|
2026-01-29 23:01:19 -08:00
|
|
|
|
2026-01-30 16:24:56 -08:00
|
|
|
if (!response.ok) {
|
2026-03-20 04:49:36 -07:00
|
|
|
const error = await response.json().catch(() => ({ message: 'Registration failed' })) as { message?: string };
|
2026-01-30 16:24:56 -08:00
|
|
|
throw new Error(`Registration failed: ${error.message || response.statusText}`);
|
|
|
|
|
}
|
2026-01-29 23:01:19 -08:00
|
|
|
|
2026-03-20 04:49:36 -07:00
|
|
|
return response.json() as Promise<LoginResponse>;
|
2026-01-30 16:24:56 -08:00
|
|
|
});
|
2026-01-29 23:01:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Logout and invalidate session.
|
|
|
|
|
*/
|
|
|
|
|
async logout(sessionId: string): Promise<void> {
|
|
|
|
|
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.
|
|
|
|
|
*
|
2026-01-30 16:24:56 -08:00
|
|
|
* SSO returns { authenticated: true, user } or { authenticated: false }
|
2026-01-29 23:01:19 -08:00
|
|
|
*/
|
|
|
|
|
async getCurrentUser(sessionId: string): Promise<User | null> {
|
|
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 04:49:36 -07:00
|
|
|
const data = await response.json() as { authenticated: boolean; user: User };
|
2026-01-30 16:24:56 -08:00
|
|
|
if (!data.authenticated) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-29 23:01:19 -08:00
|
|
|
return data.user;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate a session token.
|
|
|
|
|
*/
|
|
|
|
|
async validateSession(sessionId: string): Promise<boolean> {
|
|
|
|
|
const user = await this.getCurrentUser(sessionId);
|
|
|
|
|
return user !== null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Request password reset.
|
2026-01-30 16:24:56 -08:00
|
|
|
* SSO always returns success to prevent email enumeration.
|
2026-01-29 23:01:19 -08:00
|
|
|
*/
|
|
|
|
|
async requestPasswordReset(email: string): Promise<void> {
|
2026-01-30 16:24:56 -08:00
|
|
|
await this.withRateLimitRetry(async () => {
|
|
|
|
|
const response = await this.fetch('/auth/password-reset/request', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ email }),
|
|
|
|
|
});
|
2026-01-29 23:01:19 -08:00
|
|
|
|
2026-01-30 16:24:56 -08:00
|
|
|
if (!response.ok) {
|
2026-03-20 04:49:36 -07:00
|
|
|
const error = await response.json().catch(() => ({ message: 'Request failed' })) as { message?: string };
|
2026-01-30 16:24:56 -08:00
|
|
|
throw new Error(`Password reset request failed: ${error.message || response.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 04:49:36 -07:00
|
|
|
await response.json();
|
2026-01-30 16:24:56 -08:00
|
|
|
});
|
2026-01-29 23:01:19 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Health check for SSO service.
|
|
|
|
|
*/
|
|
|
|
|
async healthCheck(): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
const response = await this.fetch('/health');
|
|
|
|
|
return response.ok;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 16:24:56 -08:00
|
|
|
/**
|
|
|
|
|
* Retry a request if rate limited (429), with exponential backoff.
|
|
|
|
|
*/
|
|
|
|
|
private async withRateLimitRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 23:01:19 -08:00
|
|
|
private async fetch(path: string, options: RequestInit = {}): Promise<Response> {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|