wip
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,6 +36,7 @@ const IRACING_DOMAINS = [
|
||||
'iracing.com',
|
||||
'.iracing.com',
|
||||
'members.iracing.com',
|
||||
'members-ng.iracing.com',
|
||||
];
|
||||
|
||||
const EXPIRY_BUFFER_SECONDS = 300;
|
||||
@@ -63,13 +67,23 @@ export class SessionCookieStore {
|
||||
async read(): Promise<StorageState | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.storagePath, 'utf-8');
|
||||
return JSON.parse(content) as StorageState;
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -81,6 +95,65 @@ export class SessionCookieStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -192,4 +265,114 @@ export class SessionCookieStore {
|
||||
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