refactoring
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { Page } from 'playwright';
|
||||
import { ILogger } from '../../../../application/ports/ILogger';
|
||||
|
||||
export class AuthenticationGuard {
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly logger?: ILogger
|
||||
) {}
|
||||
|
||||
async checkForLoginUI(): Promise<boolean> {
|
||||
const loginSelectors = [
|
||||
'text="You are not logged in"',
|
||||
':not(.chakra-menu):not([role="menu"]) button:has-text("Log in")',
|
||||
'button[aria-label="Log in"]',
|
||||
];
|
||||
|
||||
for (const selector of loginSelectors) {
|
||||
try {
|
||||
const element = this.page.locator(selector).first();
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
this.logger?.warn('Login UI detected - user not authenticated', {
|
||||
selector,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Selector not found, continue checking
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async failFastIfUnauthenticated(): Promise<void> {
|
||||
if (await this.checkForLoginUI()) {
|
||||
throw new Error('Authentication required: Login UI detected on page');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||
import { IRACING_URLS, IRACING_SELECTORS, IRACING_TIMEOUTS } from '../dom/IRacingSelectors';
|
||||
import { AuthenticationGuard } from './AuthenticationGuard';
|
||||
|
||||
export class IRacingPlaywrightAuthFlow implements IPlaywrightAuthFlow {
|
||||
constructor(private readonly logger?: ILogger) {}
|
||||
|
||||
getLoginUrl(): string {
|
||||
return IRACING_URLS.login;
|
||||
}
|
||||
|
||||
getPostLoginLandingUrl(): string {
|
||||
return IRACING_URLS.hostedSessions;
|
||||
}
|
||||
|
||||
isLoginUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return (
|
||||
lower.includes('oauth.iracing.com') ||
|
||||
lower.includes('/membersite/login') ||
|
||||
lower.includes('/login.jsp') ||
|
||||
lower.includes('/login')
|
||||
);
|
||||
}
|
||||
|
||||
isAuthenticatedUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return (
|
||||
lower.includes('/web/racing/hosted') ||
|
||||
lower.includes('/membersite/member') ||
|
||||
lower.includes('members-ng.iracing.com') ||
|
||||
lower.startsWith(IRACING_URLS.hostedSessions.toLowerCase()) ||
|
||||
lower.startsWith(IRACING_URLS.home.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
isLoginSuccessUrl(url: string): boolean {
|
||||
return this.isAuthenticatedUrl(url) && !this.isLoginUrl(url);
|
||||
}
|
||||
|
||||
async detectAuthenticatedUi(page: Page): Promise<boolean> {
|
||||
const authSelectors = [
|
||||
IRACING_SELECTORS.hostedRacing.createRaceButton,
|
||||
'[aria-label*="user menu" i]',
|
||||
'[aria-label*="account menu" i]',
|
||||
'.user-menu',
|
||||
'.account-menu',
|
||||
'nav a[href*="/membersite"]',
|
||||
'nav a[href*="/members"]',
|
||||
];
|
||||
|
||||
for (const selector of authSelectors) {
|
||||
try {
|
||||
const element = page.locator(selector).first();
|
||||
const isVisible = await element.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
this.logger?.info?.('Authenticated UI detected', { selector });
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore selector errors, try next selector
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async detectLoginUi(page: Page): Promise<boolean> {
|
||||
const guard = new AuthenticationGuard(page, this.logger);
|
||||
return guard.checkForLoginUI();
|
||||
}
|
||||
|
||||
async navigateToAuthenticatedArea(page: Page): Promise<void> {
|
||||
await page.goto(this.getPostLoginLandingUrl(), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: IRACING_TIMEOUTS.navigation,
|
||||
});
|
||||
}
|
||||
|
||||
async waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
this.logger?.info?.('Waiting for post-login redirect', { timeoutMs });
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
if (page.isClosed()) {
|
||||
this.logger?.warn?.('Page closed while waiting for post-login redirect');
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = page.url();
|
||||
|
||||
if (this.isLoginSuccessUrl(url)) {
|
||||
this.logger?.info?.('Login success detected by URL', { url });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: detect authenticated UI even if URL is not the canonical one
|
||||
const hasAuthUi = await this.detectAuthenticatedUi(page);
|
||||
if (hasAuthUi) {
|
||||
this.logger?.info?.('Login success detected by authenticated UI', { url });
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger?.debug?.('Error while waiting for post-login redirect', { error: message });
|
||||
|
||||
if (page.isClosed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
this.logger?.warn?.('Post-login redirect wait timed out', { timeoutMs });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
/**
|
||||
* Infra-level abstraction for Playwright-based authentication flows.
|
||||
*
|
||||
* Encapsulates game/site-specific URL patterns and UI detection so that
|
||||
* auth/session orchestration can remain generic and reusable.
|
||||
*/
|
||||
export interface IPlaywrightAuthFlow {
|
||||
/** Get the URL of the login page. */
|
||||
getLoginUrl(): string;
|
||||
|
||||
/**
|
||||
* Get a canonical URL that indicates the user is in an authenticated
|
||||
* area suitable for running automation (e.g. hosted sessions dashboard).
|
||||
*/
|
||||
getPostLoginLandingUrl(): string;
|
||||
|
||||
/** True if the given URL points at the login experience. */
|
||||
isLoginUrl(url: string): boolean;
|
||||
|
||||
/** True if the given URL is considered authenticated (members area). */
|
||||
isAuthenticatedUrl(url: string): boolean;
|
||||
|
||||
/**
|
||||
* True if the URL represents a successful login redirect, distinct from
|
||||
* the raw login form page or intermediate OAuth pages.
|
||||
*/
|
||||
isLoginSuccessUrl(url: string): boolean;
|
||||
|
||||
/** Detect whether an authenticated UI is currently rendered. */
|
||||
detectAuthenticatedUi(page: Page): Promise<boolean>;
|
||||
|
||||
/** Detect whether a login UI is currently rendered. */
|
||||
detectLoginUi(page: Page): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Navigate the given page into an authenticated area that the automation
|
||||
* engine can assume as a starting point after login.
|
||||
*/
|
||||
navigateToAuthenticatedArea(page: Page): Promise<void>;
|
||||
|
||||
/**
|
||||
* Wait for the browser to reach a post-login state within the timeout.
|
||||
*
|
||||
* Implementations may use URL changes, UI detection, or a combination of
|
||||
* both to determine success.
|
||||
*/
|
||||
waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import * as fs from 'fs';
|
||||
import type { BrowserContext, Page } from 'playwright';
|
||||
|
||||
import type { IAuthenticationService } from '../../../../application/ports/IAuthenticationService';
|
||||
import type { ILogger } from '../../../../application/ports/ILogger';
|
||||
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
|
||||
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
|
||||
import { Result } from '../../../../shared/result/Result';
|
||||
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
|
||||
import { SessionCookieStore } from './SessionCookieStore';
|
||||
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
|
||||
import { AuthenticationGuard } from './AuthenticationGuard';
|
||||
|
||||
interface PlaywrightAuthSessionConfig {
|
||||
navigationTimeoutMs?: number;
|
||||
loginWaitTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game-agnostic Playwright-based authentication/session service.
|
||||
*
|
||||
* All game/site-specific behavior (URLs, selectors, redirects) is delegated to
|
||||
* the injected IPlaywrightAuthFlow implementation. This class is responsible
|
||||
* only for:
|
||||
* - Browser/session orchestration via PlaywrightBrowserSession
|
||||
* - Cookie persistence via SessionCookieStore
|
||||
* - Exposing the IAuthenticationService port for application layer
|
||||
*/
|
||||
export class PlaywrightAuthSessionService implements IAuthenticationService {
|
||||
private readonly browserSession: PlaywrightBrowserSession;
|
||||
private readonly cookieStore: SessionCookieStore;
|
||||
private readonly authFlow: IPlaywrightAuthFlow;
|
||||
private readonly logger?: ILogger;
|
||||
|
||||
private readonly navigationTimeoutMs: number;
|
||||
private readonly loginWaitTimeoutMs: number;
|
||||
|
||||
private authState: AuthenticationState = AuthenticationState.UNKNOWN;
|
||||
|
||||
constructor(
|
||||
browserSession: PlaywrightBrowserSession,
|
||||
cookieStore: SessionCookieStore,
|
||||
authFlow: IPlaywrightAuthFlow,
|
||||
logger?: ILogger,
|
||||
config?: PlaywrightAuthSessionConfig,
|
||||
) {
|
||||
this.browserSession = browserSession;
|
||||
this.cookieStore = cookieStore;
|
||||
this.authFlow = authFlow;
|
||||
this.logger = logger;
|
||||
|
||||
this.navigationTimeoutMs = config?.navigationTimeoutMs ?? 30000;
|
||||
this.loginWaitTimeoutMs = config?.loginWaitTimeoutMs ?? 300000;
|
||||
}
|
||||
|
||||
// ===== Logging =====
|
||||
|
||||
private log(
|
||||
level: 'debug' | 'info' | 'warn' | 'error',
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!this.logger) {
|
||||
return;
|
||||
}
|
||||
const logger: any = this.logger;
|
||||
logger[level](message, context as any);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private getContext(): BrowserContext | null {
|
||||
return this.browserSession.getPersistentContext() ?? this.browserSession.getContext();
|
||||
}
|
||||
|
||||
private getPageOrError(): Result<Page> {
|
||||
const page = this.browserSession.getPage();
|
||||
if (!page) {
|
||||
return Result.err(new Error('Browser not connected'));
|
||||
}
|
||||
return Result.ok(page);
|
||||
}
|
||||
|
||||
private async injectCookiesBeforeNavigation(targetUrl: string): Promise<Result<void>> {
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
return Result.err(new Error('No browser context available'));
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state || state.cookies.length === 0) {
|
||||
return Result.err(new Error('No cookies found in session store'));
|
||||
}
|
||||
|
||||
const validCookies = this.cookieStore.getValidCookiesForUrl(targetUrl);
|
||||
if (validCookies.length === 0) {
|
||||
this.log('warn', 'No valid cookies found for target URL', {
|
||||
targetUrl,
|
||||
totalCookies: state.cookies.length,
|
||||
});
|
||||
return Result.err(new Error('No valid cookies found for target URL'));
|
||||
}
|
||||
|
||||
await context.addCookies(validCookies);
|
||||
|
||||
this.log('info', 'Cookies injected successfully', {
|
||||
count: validCookies.length,
|
||||
targetUrl,
|
||||
cookieNames: validCookies.map((c) => c.name),
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(new Error(`Cookie injection failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSessionState(): Promise<void> {
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
this.log('warn', 'No browser context available to save session state');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storageState = await context.storageState();
|
||||
await this.cookieStore.write(storageState);
|
||||
this.log('info', 'Session state saved to cookie store');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to save session state', { error: message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IAuthenticationService implementation =====
|
||||
|
||||
async checkSession(): Promise<Result<AuthenticationState>> {
|
||||
try {
|
||||
this.log('info', 'Checking session from cookie store');
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state) {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('info', 'No session state file found');
|
||||
return Result.ok(this.authState);
|
||||
}
|
||||
|
||||
this.authState = this.cookieStore.validateCookies(state.cookies);
|
||||
this.log('info', 'Session check complete', { state: this.authState });
|
||||
return Result.ok(this.authState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Session check failed', { error: message });
|
||||
return Result.err(new Error(`Session check failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
getLoginUrl(): string {
|
||||
return this.authFlow.getLoginUrl();
|
||||
}
|
||||
|
||||
async initiateLogin(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'Opening login in Playwright browser');
|
||||
|
||||
const connectResult = await this.browserSession.connect();
|
||||
if (!connectResult.success) {
|
||||
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
|
||||
}
|
||||
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
const loginUrl = this.authFlow.getLoginUrl();
|
||||
await page.goto(loginUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
this.log('info', 'Browser opened to login page, waiting for login...');
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
|
||||
const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
|
||||
page,
|
||||
this.loginWaitTimeoutMs,
|
||||
);
|
||||
|
||||
if (loginSuccess) {
|
||||
this.log('info', 'Login detected, saving session state');
|
||||
await this.saveSessionState();
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
|
||||
this.authState = AuthenticationState.AUTHENTICATED;
|
||||
this.log('info', 'Session saved and validated successfully');
|
||||
} else {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('warn', 'Session saved but validation unclear');
|
||||
}
|
||||
|
||||
this.log('info', 'Closing browser after successful login');
|
||||
await this.browserSession.disconnect();
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
this.log('warn', 'Login was not completed');
|
||||
await this.browserSession.disconnect();
|
||||
return Result.err(new Error('Login timeout - please try again'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed during login process', { error: message });
|
||||
|
||||
try {
|
||||
await this.browserSession.disconnect();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
|
||||
return Result.err(error instanceof Error ? error : new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
async confirmLoginComplete(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'User confirmed login complete');
|
||||
|
||||
await this.saveSessionState();
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
|
||||
this.authState = AuthenticationState.AUTHENTICATED;
|
||||
this.log('info', 'Login confirmed and session saved successfully');
|
||||
} else {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
this.log('warn', 'Login confirmation received but session state unclear');
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to confirm login', { error: message });
|
||||
return Result.err(error instanceof Error ? error : new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
async clearSession(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'Clearing session');
|
||||
|
||||
await this.cookieStore.delete();
|
||||
this.log('debug', 'Cookie store deleted');
|
||||
|
||||
const userDataDir = this.browserSession.getUserDataDir();
|
||||
if (userDataDir && fs.existsSync(userDataDir)) {
|
||||
this.log('debug', 'Removing user data directory', { path: userDataDir });
|
||||
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
this.authState = AuthenticationState.LOGGED_OUT;
|
||||
this.log('info', 'Session cleared successfully');
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to clear session', { error: message });
|
||||
return Result.err(new Error(`Failed to clear session: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
getState(): AuthenticationState {
|
||||
return this.authState;
|
||||
}
|
||||
|
||||
async validateServerSide(): Promise<Result<boolean>> {
|
||||
try {
|
||||
this.log('info', 'Performing server-side session validation');
|
||||
|
||||
const context = this.getContext();
|
||||
if (!context) {
|
||||
return Result.err(new Error('No browser context available'));
|
||||
}
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const response = await page.goto(this.authFlow.getPostLoginLandingUrl(), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return Result.ok(false);
|
||||
}
|
||||
|
||||
const finalUrl = page.url();
|
||||
const isOnLoginPage = this.authFlow.isLoginUrl(finalUrl);
|
||||
|
||||
const isValid = !isOnLoginPage;
|
||||
this.log('info', 'Server-side validation complete', { isValid, finalUrl });
|
||||
|
||||
return Result.ok(isValid);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('warn', 'Server-side validation failed', { error: message });
|
||||
return Result.err(new Error(`Server validation failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<Result<void>> {
|
||||
try {
|
||||
this.log('info', 'Refreshing session from cookie store');
|
||||
|
||||
const state = await this.cookieStore.read();
|
||||
if (!state) {
|
||||
this.authState = AuthenticationState.UNKNOWN;
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
this.authState = this.cookieStore.validateCookies(state.cookies);
|
||||
this.log('info', 'Session refreshed', { state: this.authState });
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Session refresh failed', { error: message });
|
||||
return Result.err(new Error(`Session refresh failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionExpiry(): Promise<Result<Date | null>> {
|
||||
try {
|
||||
const expiry = await this.cookieStore.getSessionExpiry();
|
||||
return Result.ok(expiry);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to get session expiry', { error: message });
|
||||
return Result.err(new Error(`Failed to get session expiry: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>> {
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
try {
|
||||
const url = page.url();
|
||||
|
||||
const isOnAuthenticatedPath = this.authFlow.isAuthenticatedUrl(url);
|
||||
const isOnLoginPath = this.authFlow.isLoginUrl(url);
|
||||
|
||||
const guard = new AuthenticationGuard(page, this.logger);
|
||||
const hasLoginUI = await guard.checkForLoginUI();
|
||||
|
||||
const hasAuthUI = await this.authFlow.detectAuthenticatedUi(page);
|
||||
|
||||
const cookieResult = await this.checkSession();
|
||||
const cookiesValid =
|
||||
cookieResult.isOk() &&
|
||||
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
|
||||
|
||||
const pageAuthenticated =
|
||||
(isOnAuthenticatedPath && !isOnLoginPath && cookiesValid) ||
|
||||
hasAuthUI ||
|
||||
(!hasLoginUI && !isOnLoginPath);
|
||||
|
||||
this.log('debug', 'Page authentication check', {
|
||||
url,
|
||||
isOnAuthenticatedPath,
|
||||
isOnLoginPath,
|
||||
hasLoginUI,
|
||||
hasAuthUI,
|
||||
cookiesValid,
|
||||
pageAuthenticated,
|
||||
});
|
||||
|
||||
return Result.ok(new BrowserAuthenticationState(cookiesValid, pageAuthenticated));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return Result.err(new Error(`Page verification failed: ${message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Public helper for navigation with cookie injection =====
|
||||
|
||||
/**
|
||||
* Navigate to an authenticated area using stored cookies when possible.
|
||||
* Not part of the IAuthenticationService port, but useful for internal
|
||||
* orchestration (e.g. within automation flows).
|
||||
*/
|
||||
async navigateWithExistingSession(forceHeaded: boolean = false): Promise<Result<void>> {
|
||||
try {
|
||||
const sessionResult = await this.checkSession();
|
||||
if (
|
||||
sessionResult.isOk() &&
|
||||
sessionResult.unwrap() === AuthenticationState.AUTHENTICATED
|
||||
) {
|
||||
this.log('info', 'Session cookies found, launching in configured browser mode');
|
||||
|
||||
await this.browserSession.ensureBrowserContext(forceHeaded);
|
||||
const pageResult = this.getPageOrError();
|
||||
if (pageResult.isErr()) {
|
||||
return Result.err(pageResult.unwrapErr());
|
||||
}
|
||||
const page = pageResult.unwrap();
|
||||
|
||||
const targetUrl = this.authFlow.getPostLoginLandingUrl();
|
||||
const injectResult = await this.injectCookiesBeforeNavigation(targetUrl);
|
||||
|
||||
if (injectResult.isErr()) {
|
||||
this.log('warn', 'Cookie injection failed, falling back to manual login', {
|
||||
error: injectResult.error?.message ?? 'unknown error',
|
||||
});
|
||||
return Result.err(injectResult.unwrapErr());
|
||||
}
|
||||
|
||||
await page.goto(targetUrl, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.navigationTimeoutMs,
|
||||
});
|
||||
|
||||
const verifyResult = await this.verifyPageAuthentication();
|
||||
if (verifyResult.isOk()) {
|
||||
const browserState = verifyResult.unwrap();
|
||||
if (browserState.isFullyAuthenticated()) {
|
||||
this.log('info', 'Authentication verified successfully after cookie navigation');
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
this.log('warn', 'Page shows unauthenticated state despite cookies');
|
||||
}
|
||||
|
||||
return Result.err(new Error('Page not authenticated after cookie navigation'));
|
||||
}
|
||||
|
||||
return Result.err(new Error('No valid session cookies found'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log('error', 'Failed to navigate with existing session', { error: message });
|
||||
return Result.err(new Error(`Failed to navigate with existing session: ${message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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