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, ): 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 { 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> { 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 { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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}`)); } } }