195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { AuthenticationState } from '../../../domain/value-objects/AuthenticationState';
|
|
import type { ILogger } from '../../../application/ports/ILogger';
|
|
|
|
interface Cookie {
|
|
name: string;
|
|
value: string;
|
|
domain: 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',
|
|
];
|
|
|
|
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');
|
|
return JSON.parse(content) as StorageState;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async write(state: StorageState): Promise<void> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
} |