import * as fs from 'fs/promises'; import * as path from 'path'; import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState'; import { CookieConfiguration } from '../../../../domain/value-objects/CookieConfiguration'; import { Result } from '../../../../shared/result/Result'; import type { ILogger } from '../../../../application/ports/ILogger'; interface Cookie { name: string; value: string; domain: string; path: string; expires: number; } interface StorageState { cookies: Cookie[]; origins: unknown[]; } /** * Known iRacing session cookie names to look for. * These are the primary authentication indicators. */ const IRACING_SESSION_COOKIES = [ 'irsso_members', 'authtoken_members', 'irsso', 'authtoken', ]; /** * iRacing domain patterns to match cookies against. */ const IRACING_DOMAINS = [ 'iracing.com', '.iracing.com', 'members.iracing.com', 'members-ng.iracing.com', ]; const EXPIRY_BUFFER_SECONDS = 300; export class SessionCookieStore { private readonly storagePath: string; private logger?: ILogger; constructor(userDataDir: string, logger?: ILogger) { this.storagePath = path.join(userDataDir, 'session-state.json'); this.logger = logger; } private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record): void { if (this.logger) { if (level === 'error') { this.logger.error(message, undefined, context); } else { this.logger[level](message, context); } } } getPath(): string { return this.storagePath; } async read(): Promise { try { const content = await fs.readFile(this.storagePath, 'utf-8'); const state = JSON.parse(content) as StorageState; // Ensure all cookies have path field (default to "/" for backward compatibility) state.cookies = state.cookies.map(cookie => ({ ...cookie, path: cookie.path || '/' })); this.cachedState = state; return state; } catch { return null; } } async write(state: StorageState): Promise { this.cachedState = state; await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8'); } async delete(): Promise { try { await fs.unlink(this.storagePath); } catch { // File may not exist, ignore } } /** * Get session expiry date from iRacing cookies. * Returns the earliest expiry date from valid session cookies. */ async getSessionExpiry(): Promise { try { const state = await this.read(); if (!state || state.cookies.length === 0) { return null; } // Filter to iRacing authentication cookies const authCookies = state.cookies.filter(c => IRACING_DOMAINS.some(domain => c.domain === domain || c.domain.endsWith(domain) ) && (IRACING_SESSION_COOKIES.some(name => c.name.toLowerCase().includes(name.toLowerCase()) ) || c.name.toLowerCase().includes('auth') || c.name.toLowerCase().includes('sso') || c.name.toLowerCase().includes('token')) ); if (authCookies.length === 0) { return null; } // Find the earliest expiry date (most restrictive) // Session cookies (expires = -1 or 0) are treated as never expiring const expiryDates = authCookies .filter(c => c.expires > 0) .map(c => { // Handle both formats: seconds (standard) and milliseconds (test fixtures) // If expires > year 2100 in seconds (33134745600), it's likely milliseconds const isMilliseconds = c.expires > 33134745600; return new Date(isMilliseconds ? c.expires : c.expires * 1000); }); if (expiryDates.length === 0) { // All session cookies, no expiry return null; } // Return earliest expiry const earliestExpiry = new Date(Math.min(...expiryDates.map(d => d.getTime()))); this.log('debug', 'Session expiry determined', { earliestExpiry: earliestExpiry.toISOString(), cookiesChecked: authCookies.length }); return earliestExpiry; } catch (error) { this.log('error', 'Failed to get session expiry', { error: String(error) }); return null; } } /** * Validate cookies and determine authentication state. * * Looks for iRacing session cookies by checking: * 1. Domain matches iRacing patterns * 2. Cookie name matches known session cookie names OR * 3. Any cookie from members.iracing.com domain (fallback) */ validateCookies(cookies: Cookie[]): AuthenticationState { // Log all cookies for debugging this.log('debug', 'Validating cookies', { totalCookies: cookies.length, cookieNames: cookies.map(c => ({ name: c.name, domain: c.domain })) }); // Filter cookies from iRacing domains const iracingDomainCookies = cookies.filter(c => IRACING_DOMAINS.some(domain => c.domain === domain || c.domain.endsWith(domain) ) ); this.log('debug', 'iRacing domain cookies found', { count: iracingDomainCookies.length, cookies: iracingDomainCookies.map(c => ({ name: c.name, domain: c.domain, expires: c.expires, expiresDate: new Date(c.expires * 1000).toISOString() })) }); // Look for known session cookies first const knownSessionCookies = iracingDomainCookies.filter(c => IRACING_SESSION_COOKIES.some(name => c.name.toLowerCase() === name.toLowerCase() || c.name.toLowerCase().includes(name.toLowerCase()) ) ); // If no known session cookies, check for any auth-like cookies from members domain const authCookies = knownSessionCookies.length > 0 ? knownSessionCookies : iracingDomainCookies.filter(c => c.domain.includes('members') && (c.name.toLowerCase().includes('auth') || c.name.toLowerCase().includes('sso') || c.name.toLowerCase().includes('session') || c.name.toLowerCase().includes('token')) ); this.log('debug', 'Authentication cookies identified', { knownSessionCookiesCount: knownSessionCookies.length, authCookiesCount: authCookies.length, cookies: authCookies.map(c => ({ name: c.name, domain: c.domain })) }); if (authCookies.length === 0) { // Last resort: if we have ANY cookies from members.iracing.com, consider it potentially valid const membersCookies = iracingDomainCookies.filter(c => c.domain.includes('members.iracing.com') || c.domain === '.iracing.com' ); if (membersCookies.length > 0) { this.log('info', 'No known auth cookies found, but members domain cookies exist', { count: membersCookies.length, cookies: membersCookies.map(c => c.name) }); // Check expiry on any of these cookies const now = Math.floor(Date.now() / 1000); const hasValidCookie = membersCookies.some(c => c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS) ); return hasValidCookie ? AuthenticationState.AUTHENTICATED : AuthenticationState.EXPIRED; } this.log('info', 'No iRacing authentication cookies found'); return AuthenticationState.UNKNOWN; } // Check if any auth cookie is still valid (not expired) const now = Math.floor(Date.now() / 1000); const validCookies = authCookies.filter(c => { // Handle session cookies (expires = -1 or 0) and persistent cookies const isSession = c.expires === -1 || c.expires === 0; const isNotExpired = c.expires > (now + EXPIRY_BUFFER_SECONDS); return isSession || isNotExpired; }); this.log('debug', 'Cookie expiry check', { now, validCookiesCount: validCookies.length, cookies: authCookies.map(c => ({ name: c.name, expires: c.expires, isValid: c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS) })) }); if (validCookies.length > 0) { this.log('info', 'Valid iRacing session cookies found', { count: validCookies.length }); return AuthenticationState.AUTHENTICATED; } this.log('info', 'iRacing session cookies found but all expired'); return AuthenticationState.EXPIRED; } private cachedState: StorageState | null = null; /** * Validate stored cookies for a target URL. * Note: This requires cookies to be written first via write(). * This is synchronous because tests expect it - uses cached state. * Validates domain/path compatibility AND checks for required authentication cookies. */ validateCookieConfiguration(targetUrl: string): Result { try { if (!this.cachedState || this.cachedState.cookies.length === 0) { return Result.err('No cookies found in session store'); } const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true); return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); return Result.err(`Cookie validation failed: ${message}`); } } /** * Validate a list of cookies for a target URL. * Returns only cookies that are valid for the target URL. * @param requireAuthCookies - If true, checks for required authentication cookies */ validateCookiesForUrl( cookies: Cookie[], targetUrl: string, requireAuthCookies = false ): Result { try { // Validate each cookie's domain/path const validatedCookies: Cookie[] = []; let firstValidationError: string | null = null; for (const cookie of cookies) { try { new CookieConfiguration(cookie, targetUrl); validatedCookies.push(cookie); } catch (error) { const message = error instanceof Error ? error.message : String(error); // Capture first validation error to return if all cookies fail if (!firstValidationError) { firstValidationError = message; } this.logger?.warn('Cookie validation failed', { name: cookie.name, error: message, }); // Skip invalid cookie, continue with others } } if (validatedCookies.length === 0) { // Return the specific validation error from the first failed cookie return Result.err(firstValidationError || 'No valid cookies found for target URL'); } // Check required cookies only if requested (for authentication validation) if (requireAuthCookies) { const cookieNames = validatedCookies.map((c) => c.name.toLowerCase()); // Check for irsso_members const hasIrssoMembers = cookieNames.some((name) => name.includes('irsso_members') || name.includes('irsso') ); // Check for authtoken_members const hasAuthtokenMembers = cookieNames.some((name) => name.includes('authtoken_members') || name.includes('authtoken') ); if (!hasIrssoMembers) { return Result.err('Required cookie missing: irsso_members'); } if (!hasAuthtokenMembers) { return Result.err('Required cookie missing: authtoken_members'); } } return Result.ok(validatedCookies); } catch (error) { const message = error instanceof Error ? error.message : String(error); return Result.err(`Cookie validation failed: ${message}`); } } /** * Get cookies that are valid for a target URL. * Returns array of cookies (empty if none valid). * Uses cached state from last write(). */ getValidCookiesForUrl(targetUrl: string): Cookie[] { try { if (!this.cachedState || this.cachedState.cookies.length === 0) { return []; } const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl); return result.isOk() ? result.unwrap() : []; } catch { return []; } } }