378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
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<string, unknown>): 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<StorageState | null> {
|
|
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<void> {
|
|
this.cachedState = state;
|
|
await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8');
|
|
}
|
|
|
|
async delete(): Promise<void> {
|
|
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<Date | null> {
|
|
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<Cookie[]> {
|
|
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<Cookie[]> {
|
|
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 [];
|
|
}
|
|
}
|
|
} |