refactoring
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user