diff --git a/src/bot/authTestProcedure.ts b/src/bot/authTestProcedure.ts index ef6413b..40d6cab 100644 --- a/src/bot/authTestProcedure.ts +++ b/src/bot/authTestProcedure.ts @@ -43,9 +43,19 @@ export interface AuthTestResults { const _VARIANTS: AuthTestVariant[] = [ { - id: 'headfulDirect', - name: 'Headful + Direct /v2/', - description: 'Playwright headful, navigiert direkt zu /v2/ URL (umgeht launcher.html komplett)', + id: 'chromiumClean', + name: 'Chromium Headful Clean', + description: 'Playwright Chromium headful, enhanced stealth + realistic devices, keine fake-flags', + }, + { + id: 'chromiumNoAutomation', + name: 'Chromium No-Automation', + description: 'Chromium headful, zusaetzlich --disable-extensions, --no-first-run', + }, + { + id: 'rebrowserHeadful', + name: 'rebrowser-playwright Headful', + description: 'rebrowser-playwright headful, CDP-Leak-Fixes + realistic devices', }, ]; @@ -53,40 +63,49 @@ const _VARIANTS: AuthTestVariant[] = [ // CONSTANTS // ============================================================================ -const _VARIANT_TIMEOUT_MS = 45000; +const _VARIANT_TIMEOUT_MS = 60000; const _SCREENSHOT_QUALITY = 50; const _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0'; -const _BROWSER_ARGS = [ - '--use-fake-ui-for-media-stream', - '--use-fake-device-for-media-stream', +// Base args — NO fake device flags (those show "Fake Default Audio Input" to Teams) +const _BROWSER_ARGS_CLEAN = [ + '--use-fake-ui-for-media-stream', // Auto-accept media permissions (no popup) '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', '--autoplay-policy=no-user-gesture-required', '--disable-blink-features=AutomationControlled', ]; +// Extra args for the no-automation variant +const _BROWSER_ARGS_NO_AUTOMATION = [ + ..._BROWSER_ARGS_CLEAN, + '--disable-extensions', + '--no-first-run', + '--no-default-browser-check', + '--disable-component-update', +]; + // ============================================================================ // MAIN EXPORT // ============================================================================ /** - * Run all 6 auth detection test variants against a Teams meeting URL. - * Does NOT join the meeting — only checks which page Teams serves. + * Run auth detection test variants: login at teams.microsoft.com, screenshot where we land. + * Does NOT navigate to any meeting URL — only tests if Teams login works. */ export async function runAuthTests( meetingUrl: string, botAccountEmail?: string, botAccountPassword?: string, ): Promise { - logger.info(`[AuthTest] Starting auth detection tests for: ${meetingUrl}`); + logger.info(`[AuthTest] Starting auth tests for: ${meetingUrl} (email=${!!botAccountEmail})`); const results: AuthTestResult[] = []; for (const variant of _VARIANTS) { - // Skip auth variant if no credentials - if (variant.id === 'headfulAuth' && (!botAccountEmail || !botAccountPassword)) { - results.push(_createSkippedResult(variant, 'Keine System-Bot Credentials konfiguriert')); + // All variants require credentials + if (!botAccountEmail || !botAccountPassword) { + results.push(_createSkippedResult(variant, 'Keine Credentials angegeben')); continue; } @@ -162,87 +181,40 @@ async function _runVariant( } }); - // Auth state + // All variants follow the same flow: + // 1. Navigate to teams.microsoft.com (triggers Teams-specific login redirect) + // 2. Login (email → password → stay signed in) + // 3. Wait 20 seconds after login (NO meeting navigation) + // 4. Screenshot where we landed + let authAttempted = false; let authSuccess: boolean | null = null; - if (variant.id === 'headfulDirect') { - // Strategy: Login via Teams' own auth redirect (not generic MS login). - // Generic login.microsoftonline.com sets cookies for m365.cloud.microsoft, - // NOT for teams.microsoft.com. We must trigger the Teams-specific login flow. + _log('info', `Step 1: Navigate to teams.microsoft.com`); + await page.goto('https://teams.microsoft.com', { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + await page.waitForTimeout(3000); + _log('info', `After Teams redirect: ${page.url().substring(0, 120)}`); - if (botAccountEmail && botAccountPassword) { - authAttempted = true; - _log('info', `Step 1: Navigate to teams.microsoft.com to trigger Teams login redirect`); + // Step 2: Login + if (botAccountEmail && botAccountPassword) { + authAttempted = true; + const onLoginPage = page.url().includes('login.microsoftonline.com') || page.url().includes('login.live.com'); + _log('info', `On login page: ${onLoginPage}, authenticating as ${botAccountEmail}`); - // Navigate to Teams — this redirects to login.microsoftonline.com with - // Teams-specific client_id and redirect_uri=teams.microsoft.com - await page.goto('https://teams.microsoft.com', { - waitUntil: 'domcontentloaded', - timeout: 30000, - }); - await page.waitForTimeout(3000); - _log('info', `After Teams redirect: ${page.url().substring(0, 120)}`); - - // Now we should be on login.microsoftonline.com with Teams context. - // Use AuthProcedure with skipNavigation=true (we're already on the login page) - const authProcedure = new AuthProcedure(page, logger); - const onLoginPage = page.url().includes('login.microsoftonline.com') || page.url().includes('login.live.com'); - - if (onLoginPage) { - _log('info', `On MS login page, authenticating as ${botAccountEmail}`); - authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, false); - } else { - // Maybe already logged in, or Teams served directly - _log('info', `Not on login page (${page.url().substring(0, 80)}), trying direct auth`); - authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, false); - } - - _log(authSuccess ? 'info' : 'warn', `Auth result: ${authSuccess ? 'success' : 'failed'}`); - - if (authSuccess) { - // Wait for redirect chain to complete (MS → Teams) - await page.waitForTimeout(5000); - _log('info', `After auth settle: ${page.url().substring(0, 120)}`); - } - } else { - _log('info', 'No credentials provided — skipping auth'); - } - - // Step 2: Navigate to /v2/ meeting URL (with Teams auth cookies) - const directUrl = _buildDirectV2Url(meetingUrl); - _log('info', `Step 2: Direct /v2/ navigation: ${directUrl.substring(0, 120)}`); - - await page.goto(directUrl, { - waitUntil: 'domcontentloaded', - timeout: 30000, - }); - - // Wait for page to settle - await page.waitForTimeout(8000); - const currentUrl = page.url(); - _log('info', `After meeting load: ${currentUrl.substring(0, 120)}`); - - if (currentUrl.includes('light-meetings')) { - _log('warn', 'Teams redirected /v2/ to light-meetings (even after auth)'); - } else if (currentUrl.includes('/v2/')) { - _log('info', '/v2/ page loaded after auth!'); - } - } else { - // Standard flow: resolve URL, navigate, click launcher - const useAnon = variant.id === 'baseline'; - const launchUrl = await _resolveMeetingUrl(meetingUrl, useAnon); - _log('info', `Navigating to: ${launchUrl.substring(0, 100)}`); - - await page.goto(launchUrl, { - waitUntil: 'domcontentloaded', - timeout: 30000, - }); - - await _handleLauncher(page); - await page.waitForTimeout(5000); + const authProcedure = new AuthProcedure(page, logger); + // skipNavigation=true because we're already on the login page after teams.microsoft.com redirect + authSuccess = await authProcedure.authenticateWithMicrosoft(botAccountEmail, botAccountPassword, !onLoginPage); + _log(authSuccess ? 'info' : 'warn', `Auth result: ${authSuccess ? 'success' : 'failed'}`); } + // Step 3: Wait 20 seconds — NO further navigation + _log('info', 'Waiting 20s after login (no meeting navigation)...'); + await page.waitForTimeout(20000); + _log('info', `Final URL after 20s: ${page.url().substring(0, 150)}`); + // Detect page type and gather signals const detection = await _detectPageType(page); @@ -319,30 +291,20 @@ interface LaunchResult { } async function _launchBrowserForVariant(variant: AuthTestVariant): Promise { + _requireDisplay(); // All variants are headful + switch (variant.id) { - case 'baseline': - return _launchVanilla(true, 'basic'); + case 'chromiumClean': + return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN); - case 'rebrowser': - return _launchRebrowser(); + case 'chromiumNoAutomation': + return _launchChromiumHeadful(_BROWSER_ARGS_NO_AUTOMATION); - case 'playwrightExtra': - return _launchPlaywrightExtra(); - - case 'headful': - _requireDisplay(); - return _launchVanilla(false, 'enhanced'); - - case 'headfulAuth': - _requireDisplay(); - return _launchVanilla(false, 'enhanced'); - - case 'headfulDirect': - _requireDisplay(); - return _launchVanilla(false, 'enhanced'); + case 'rebrowserHeadful': + return _launchRebrowserHeadful(); default: - return _launchVanilla(true, 'basic'); + return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN); } } @@ -363,103 +325,58 @@ function _requireDisplay(): void { } /** - * Launch vanilla Playwright with configurable headless mode and stealth level. + * Launch Chromium headful with enhanced stealth + realistic device spoofing. */ -async function _launchVanilla(headless: boolean, stealthLevel: 'basic' | 'enhanced'): Promise { +async function _launchChromiumHeadful(args: string[]): Promise { const browser = await chromium.launch({ - headless, - args: _BROWSER_ARGS, + headless: false, + args, }); const context = await browser.newContext({ permissions: ['microphone', 'camera'], - viewport: { width: 1280, height: 720 }, + viewport: { width: 1920, height: 1080 }, userAgent: _USER_AGENT, }); const page = await context.newPage(); - - if (stealthLevel === 'basic') { - await page.addInitScript(_getBasicStealthScript); - } else { - await page.addInitScript(_getEnhancedStealthScript); - } + await page.addInitScript(_getEnhancedStealthScript); + await page.addInitScript(_getDeviceSpoofScript); return { browser, context, page }; } /** - * Launch rebrowser-playwright (patched Playwright with CDP leak fixes). - * Uses dynamic require to avoid import errors if package is not installed. - * - * IMPORTANT: rebrowser-playwright ships its own playwright-core which may - * expect a different Chromium version than what's installed in the Docker image. - * We solve this by passing `executablePath` from vanilla Playwright, so - * rebrowser uses its patched CDP handling with the available Chromium binary. + * Launch rebrowser-playwright headful with CDP leak fixes + realistic devices. */ -async function _launchRebrowser(): Promise { +async function _launchRebrowserHeadful(): Promise { let rbChromium: typeof chromium; try { const rb = require('rebrowser-playwright'); rbChromium = rb.chromium; } catch { logger.warn('[AuthTest] rebrowser-playwright not installed, falling back to vanilla'); - return _launchVanilla(true, 'enhanced'); + return _launchChromiumHeadful(_BROWSER_ARGS_CLEAN); } - // Use the Chromium binary from vanilla Playwright (matches Docker image) - // to avoid "Executable doesn't exist" errors from version mismatch const execPath = chromium.executablePath(); logger.info(`[AuthTest:rebrowser] Using Chromium binary: ${execPath}`); const browser = await rbChromium.launch({ - headless: true, + headless: false, executablePath: execPath, - args: _BROWSER_ARGS, + args: _BROWSER_ARGS_CLEAN, }); const context = await browser.newContext({ permissions: ['microphone', 'camera'], - viewport: { width: 1280, height: 720 }, + viewport: { width: 1920, height: 1080 }, userAgent: _USER_AGENT, }); const page = await context.newPage(); - - // rebrowser-playwright handles most stealth automatically, but add enhanced overrides too await page.addInitScript(_getEnhancedStealthScript); - - return { browser, context, page }; -} - -/** - * Launch playwright-extra with the stealth plugin. - * Uses dynamic require to avoid import errors if package is not installed. - */ -async function _launchPlaywrightExtra(): Promise { - let chromiumExtra: typeof chromium; - try { - const { chromium: pweChromium } = require('playwright-extra'); - const StealthPlugin = require('puppeteer-extra-plugin-stealth'); - pweChromium.use(StealthPlugin()); - chromiumExtra = pweChromium; - } catch { - logger.warn('[AuthTest] playwright-extra not installed, falling back to vanilla'); - return _launchVanilla(true, 'enhanced'); - } - - const browser = await chromiumExtra.launch({ - headless: true, - args: _BROWSER_ARGS, - }); - - const context = await browser.newContext({ - permissions: ['microphone', 'camera'], - viewport: { width: 1280, height: 720 }, - userAgent: _USER_AGENT, - }); - - const page = await context.newPage(); + await page.addInitScript(_getDeviceSpoofScript); return { browser, context, page }; } @@ -594,6 +511,63 @@ function _getEnhancedStealthScript(): void { Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }); } +/** + * Device spoofing script — replaces "Fake Default Audio Input" with realistic device names. + * Without --use-fake-device-for-media-stream, getUserMedia may fail, so we mock it too. + */ +function _getDeviceSpoofScript(): void { + // Override enumerateDevices to return realistic device names + if (navigator.mediaDevices) { + const _originalEnumerateDevices = navigator.mediaDevices.enumerateDevices?.bind(navigator.mediaDevices); + + navigator.mediaDevices.enumerateDevices = async () => { + // Return realistic Windows devices + return [ + { deviceId: 'default', kind: 'audioinput', label: 'Microphone (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'comm-default', kind: 'audioinput', label: 'Microphone (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'default', kind: 'audiooutput', label: 'Speakers (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'comm-default', kind: 'audiooutput', label: 'Speakers (Realtek(R) Audio)', groupId: 'a1b2c3d4', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'webcam-01', kind: 'videoinput', label: 'HD Webcam C270', groupId: 'e5f6g7h8', toJSON: () => ({}) } as MediaDeviceInfo, + ]; + }; + + // Override getUserMedia to return a valid (silent/black) MediaStream + const _originalGetUserMedia = navigator.mediaDevices.getUserMedia?.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => { + try { + // Try real getUserMedia first (works if actual devices exist) + if (_originalGetUserMedia) { + return await _originalGetUserMedia(constraints); + } + } catch { + // Fallback: return empty stream + } + // Create a silent audio track + black video track + const stream = new MediaStream(); + // @ts-ignore - AudioContext may not be available in all envs + try { + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const oscillator = audioCtx.createOscillator(); + const dest = audioCtx.createMediaStreamDestination(); + oscillator.connect(dest); + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.01); + dest.stream.getAudioTracks().forEach(t => stream.addTrack(t)); + } catch {} + try { + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext('2d'); + if (ctx) ctx.fillRect(0, 0, 640, 480); + const videoStream = canvas.captureStream(1); + videoStream.getVideoTracks().forEach(t => stream.addTrack(t)); + } catch {} + return stream; + }; + } +} + // ============================================================================ // URL RESOLUTION // ============================================================================