456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
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 {
|
|
const forceHeaded = true;
|
|
this.log('info', 'Opening login in headed Playwright browser (forceHeaded=true)', {
|
|
forceHeaded,
|
|
});
|
|
|
|
const connectResult = await this.browserSession.connect(forceHeaded);
|
|
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', forceHeaded
|
|
? 'Browser opened to login page in headed mode, waiting for login...'
|
|
: '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 {
|
|
}
|
|
|
|
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 =
|
|
!hasLoginUI &&
|
|
!isOnLoginPath &&
|
|
((isOnAuthenticatedPath && cookiesValid) || hasAuthUI);
|
|
|
|
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}`));
|
|
}
|
|
}
|
|
} |