diff --git a/package.json b/package.json index edb5e20..b5c9cf7 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ }, "dependencies": { "playwright": "1.50.0", + "rebrowser-playwright": "^1.50.0", + "playwright-extra": "^4.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2", "ws": "^8.16.0", "uuid": "^9.0.1", "dotenv": "^16.4.1", diff --git a/src/bot/authTestProcedure.ts b/src/bot/authTestProcedure.ts new file mode 100644 index 0000000..410dd16 --- /dev/null +++ b/src/bot/authTestProcedure.ts @@ -0,0 +1,823 @@ +import { Browser, BrowserContext, Page, chromium } from 'playwright'; +import { logger } from '../utils/logger'; +import { AuthProcedure } from './authProcedure'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface AuthTestVariant { + id: string; + name: string; + description: string; +} + +export interface AuthTestResult { + variantId: string; + variantName: string; + success: boolean; + pageType: 'v2' | 'lightMeetings' | 'error' | 'unknown'; + finalUrl: string; + hasSignInLink: boolean; + hasNameInput: boolean; + hasJoinButton: boolean; + authAttempted: boolean; + authSuccess: boolean | null; + screenshot?: string; + durationMs: number; + error?: string; + detectedSignals: string[]; +} + +export interface AuthTestResults { + meetingUrl: string; + timestamp: string; + variants: AuthTestResult[]; + recommendation: string; +} + +// ============================================================================ +// VARIANT DEFINITIONS +// ============================================================================ + +const _VARIANTS: AuthTestVariant[] = [ + { + id: 'baseline', + name: 'Baseline (aktuell)', + description: 'Vanilla Playwright headless, basic stealth, anon=true', + }, + { + id: 'rebrowser', + name: 'rebrowser-playwright', + description: 'rebrowser-playwright headless, CDP-Leak-Fixes, ohne anon', + }, + { + id: 'playwrightExtra', + name: 'playwright-extra + stealth', + description: 'playwright-extra mit stealth plugin headless, ohne anon', + }, + { + id: 'headful', + name: 'Headful', + description: 'Playwright headful (headless: false), enhanced stealth, ohne anon', + }, + { + id: 'headfulAuth', + name: 'Headful + Auth', + description: 'Playwright headful mit vollstaendigem Auth-Flow (System-Bot Credentials)', + }, +]; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const _VARIANT_TIMEOUT_MS = 45000; +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', + '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + '--autoplay-policy=no-user-gesture-required', + '--disable-blink-features=AutomationControlled', +]; + +// ============================================================================ +// MAIN EXPORT +// ============================================================================ + +/** + * Run all 5 auth detection test variants against a Teams meeting URL. + * Does NOT join the meeting — only checks which page Teams serves. + */ +export async function runAuthTests( + meetingUrl: string, + botAccountEmail?: string, + botAccountPassword?: string, +): Promise { + logger.info(`[AuthTest] Starting auth detection tests for: ${meetingUrl}`); + + 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')); + continue; + } + + logger.info(`[AuthTest] Running variant: ${variant.name}`); + try { + const result = await _runVariant(variant, meetingUrl, botAccountEmail, botAccountPassword); + results.push(result); + logger.info(`[AuthTest] Variant ${variant.name}: pageType=${result.pageType}, url=${result.finalUrl.substring(0, 80)}`); + } catch (err) { + logger.error(`[AuthTest] Variant ${variant.name} failed:`, err); + results.push(_createErrorResult(variant, String(err))); + } + } + + const recommendation = _generateRecommendation(results); + logger.info(`[AuthTest] Recommendation: ${recommendation}`); + + return { + meetingUrl, + timestamp: new Date().toISOString(), + variants: results, + recommendation, + }; +} + +// ============================================================================ +// VARIANT RUNNER +// ============================================================================ + +async function _runVariant( + variant: AuthTestVariant, + meetingUrl: string, + botAccountEmail?: string, + botAccountPassword?: string, +): Promise { + const startTime = Date.now(); + let browser: Browser | null = null; + let context: BrowserContext | null = null; + + try { + // Launch browser based on variant + const launchResult = await _launchBrowserForVariant(variant); + browser = launchResult.browser; + context = launchResult.context; + const page = launchResult.page; + + // Resolve meeting URL (with or without anon=true) + const useAnon = variant.id === 'baseline'; + const launchUrl = await _resolveMeetingUrl(meetingUrl, useAnon); + logger.info(`[AuthTest:${variant.id}] Navigating to: ${launchUrl.substring(0, 100)}`); + + // Navigate to meeting + await page.goto(launchUrl, { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + + // Handle launcher dialog ("Continue on this browser") + await _handleLauncher(page); + + // Wait for the pre-join page to settle + await page.waitForTimeout(5000); + + // If this is the auth variant, attempt login + let authAttempted = false; + let authSuccess: boolean | null = null; + if (variant.id === 'headfulAuth' && botAccountEmail && botAccountPassword) { + authAttempted = true; + authSuccess = await _attemptAuth(page, botAccountEmail, botAccountPassword); + } + + // Detect page type and gather signals + const detection = await _detectPageType(page); + + // Take screenshot + const screenshot = await _takeScreenshot(page); + + return { + variantId: variant.id, + variantName: variant.name, + success: true, + pageType: detection.pageType, + finalUrl: page.url(), + hasSignInLink: detection.hasSignInLink, + hasNameInput: detection.hasNameInput, + hasJoinButton: detection.hasJoinButton, + authAttempted, + authSuccess, + screenshot, + durationMs: Date.now() - startTime, + detectedSignals: detection.signals, + }; + } catch (err) { + // Try to take an error screenshot + let screenshot: string | undefined; + try { + if (context) { + const pages = context.pages(); + if (pages.length > 0) { + screenshot = await _takeScreenshot(pages[0]); + } + } + } catch { + // Ignore screenshot errors + } + + return { + variantId: variant.id, + variantName: variant.name, + success: false, + pageType: 'error', + finalUrl: '', + hasSignInLink: false, + hasNameInput: false, + hasJoinButton: false, + authAttempted: false, + authSuccess: null, + screenshot, + durationMs: Date.now() - startTime, + error: String(err), + detectedSignals: [], + }; + } finally { + // Clean up browser + try { + if (context) await context.close(); + if (browser) await browser.close(); + } catch { + // Ignore cleanup errors + } + } +} + +// ============================================================================ +// BROWSER LAUNCHERS +// ============================================================================ + +interface LaunchResult { + browser: Browser; + context: BrowserContext; + page: Page; +} + +async function _launchBrowserForVariant(variant: AuthTestVariant): Promise { + switch (variant.id) { + case 'baseline': + return _launchVanilla(true, 'basic'); + + case 'rebrowser': + return _launchRebrowser(); + + case 'playwrightExtra': + return _launchPlaywrightExtra(); + + case 'headful': + return _launchVanilla(false, 'enhanced'); + + case 'headfulAuth': + return _launchVanilla(false, 'enhanced'); + + default: + return _launchVanilla(true, 'basic'); + } +} + +/** + * Launch vanilla Playwright with configurable headless mode and stealth level. + */ +async function _launchVanilla(headless: boolean, stealthLevel: 'basic' | 'enhanced'): Promise { + const browser = await chromium.launch({ + headless, + args: _BROWSER_ARGS, + }); + + const context = await browser.newContext({ + permissions: ['microphone', 'camera'], + viewport: { width: 1280, height: 720 }, + userAgent: _USER_AGENT, + }); + + const page = await context.newPage(); + + if (stealthLevel === 'basic') { + await page.addInitScript(_getBasicStealthScript); + } else { + await page.addInitScript(_getEnhancedStealthScript); + } + + 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. + */ +async function _launchRebrowser(): Promise { + let rbChromium: typeof chromium; + try { + // Dynamic require for the rebrowser-playwright package + const rb = require('rebrowser-playwright'); + rbChromium = rb.chromium; + } catch { + logger.warn('[AuthTest] rebrowser-playwright not installed, falling back to vanilla'); + return _launchVanilla(true, 'enhanced'); + } + + const browser = await rbChromium.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(); + + // 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(); + + return { browser, context, page }; +} + +// ============================================================================ +// STEALTH SCRIPTS +// ============================================================================ + +/** + * Basic stealth script — matches the current orchestrator.ts implementation. + */ +function _getBasicStealthScript(): void { + // 1. Remove navigator.webdriver flag + Object.defineProperty(navigator, 'webdriver', { get: () => false }); + + // 2. Add realistic plugins + Object.defineProperty(navigator, 'plugins', { + get: () => [ + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' }, + { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' }, + ], + }); + + // 3. Add realistic languages + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en', 'de'] }); + + // 4. Override permissions query + const originalQuery = window.navigator.permissions.query.bind(window.navigator.permissions); + // @ts-ignore + window.navigator.permissions.query = (parameters: any) => { + if (parameters.name === 'notifications') { + return Promise.resolve({ state: Notification.permission } as PermissionStatus); + } + return originalQuery(parameters); + }; + + // 5. Add chrome runtime + // @ts-ignore + if (!window.chrome) { (window as any).chrome = {}; } + // @ts-ignore + if (!(window as any).chrome.runtime) { (window as any).chrome.runtime = {}; } +} + +/** + * Enhanced stealth script — basic + WebGL, Canvas, screen, sec-ch-ua overrides. + */ +function _getEnhancedStealthScript(): void { + // --- Basic stealth (same as above) --- + Object.defineProperty(navigator, 'webdriver', { get: () => false }); + + Object.defineProperty(navigator, 'plugins', { + get: () => [ + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' }, + { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' }, + ], + }); + + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en', 'de'] }); + + const originalQuery = window.navigator.permissions.query.bind(window.navigator.permissions); + // @ts-ignore + window.navigator.permissions.query = (parameters: any) => { + if (parameters.name === 'notifications') { + return Promise.resolve({ state: Notification.permission } as PermissionStatus); + } + return originalQuery(parameters); + }; + + // @ts-ignore + if (!window.chrome) { (window as any).chrome = {}; } + // @ts-ignore + if (!(window as any).chrome.runtime) { (window as any).chrome.runtime = {}; } + + // --- Enhanced: Screen dimensions (headless reports 0 for outer dimensions) --- + Object.defineProperty(window, 'outerWidth', { get: () => 1280 }); + Object.defineProperty(window, 'outerHeight', { get: () => 720 }); + Object.defineProperty(screen, 'availWidth', { get: () => 1920 }); + Object.defineProperty(screen, 'availHeight', { get: () => 1040 }); + Object.defineProperty(screen, 'colorDepth', { get: () => 24 }); + + // --- Enhanced: WebGL vendor/renderer spoofing --- + const _getParameterOriginal = WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter = function (parameter: number) { + // UNMASKED_VENDOR_WEBGL + if (parameter === 0x9245) return 'Google Inc. (NVIDIA)'; + // UNMASKED_RENDERER_WEBGL + if (parameter === 0x9246) return 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)'; + return _getParameterOriginal.call(this, parameter); + }; + + // Also override for WebGL2 + if (typeof WebGL2RenderingContext !== 'undefined') { + const _getParameter2Original = WebGL2RenderingContext.prototype.getParameter; + WebGL2RenderingContext.prototype.getParameter = function (parameter: number) { + if (parameter === 0x9245) return 'Google Inc. (NVIDIA)'; + if (parameter === 0x9246) return 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)'; + return _getParameter2Original.call(this, parameter); + }; + } + + // --- Enhanced: Canvas fingerprint noise --- + const _toDataURLOriginal = HTMLCanvasElement.prototype.toDataURL; + HTMLCanvasElement.prototype.toDataURL = function (type?: string, quality?: any) { + const ctx = this.getContext('2d'); + if (ctx && this.width > 0 && this.height > 0) { + const imageData = ctx.getImageData(0, 0, 1, 1); + // Add minimal noise to a single pixel to alter the fingerprint + imageData.data[0] = (imageData.data[0] + 1) % 256; + ctx.putImageData(imageData, 0, 0); + } + return _toDataURLOriginal.call(this, type, quality); + }; + + // --- Enhanced: Override navigator.connection (missing in headless) --- + if (!(navigator as any).connection) { + Object.defineProperty(navigator, 'connection', { + get: () => ({ + effectiveType: '4g', + rtt: 50, + downlink: 10, + saveData: false, + }), + }); + } + + // --- Enhanced: Override navigator.hardwareConcurrency --- + Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); + + // --- Enhanced: Override navigator.deviceMemory --- + Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }); +} + +// ============================================================================ +// URL RESOLUTION +// ============================================================================ + +/** + * Resolve the meeting URL, optionally adding anon=true. + */ +async function _resolveMeetingUrl(meetingUrl: string, withAnon: boolean): Promise { + const trimmed = meetingUrl.trim(); + + try { + const response = await fetch(trimmed, { redirect: 'follow' }); + const resolvedUrlStr = response.url; + const resolvedUrl = new URL(resolvedUrlStr); + + resolvedUrl.searchParams.set('msLaunch', 'false'); + resolvedUrl.searchParams.set('type', 'meetup-join'); + resolvedUrl.searchParams.set('directDl', 'true'); + resolvedUrl.searchParams.set('enableMobilePage', 'true'); + resolvedUrl.searchParams.set('suppressPrompt', 'true'); + + if (withAnon) { + resolvedUrl.searchParams.set('anon', 'true'); + } + + return resolvedUrl.toString(); + } catch { + // Fallback: add params directly + const separator = trimmed.includes('?') ? '&' : '?'; + const anonParam = withAnon ? '&anon=true' : ''; + return `${trimmed}${separator}msLaunch=false&suppressPrompt=true&directDl=true&enableMobilePage=true${anonParam}`; + } +} + +// ============================================================================ +// LAUNCHER HANDLING +// ============================================================================ + +/** + * Handle the Teams launcher dialog ("Continue on this browser"). + */ +async function _handleLauncher(page: Page): Promise { + const launcherSelectors = [ + 'button[data-tid="joinOnWeb"]', + 'button:has-text("Continue on this browser")', + 'button:has-text("Join on the web instead")', + 'a:has-text("Continue on this browser")', + 'button:has-text("Use web app instead")', + ]; + + for (const selector of launcherSelectors) { + try { + const button = await page.waitForSelector(selector, { timeout: 10000, state: 'visible' }); + if (button) { + await button.click(); + logger.info(`[AuthTest] Clicked launcher: ${selector}`); + await page.waitForTimeout(3000); + return; + } + } catch { + // Continue to next selector + } + } + + logger.info('[AuthTest] No launcher dialog found — may already be on pre-join page'); +} + +// ============================================================================ +// PAGE TYPE DETECTION +// ============================================================================ + +interface PageDetection { + pageType: 'v2' | 'lightMeetings' | 'error' | 'unknown'; + hasSignInLink: boolean; + hasNameInput: boolean; + hasJoinButton: boolean; + signals: string[]; +} + +/** + * Detect whether Teams served the /v2/ (authenticated) or light-meetings (anonymous) page. + */ +async function _detectPageType(page: Page): Promise { + const signals: string[] = []; + const url = page.url().toLowerCase(); + + // URL-based detection + let pageType: 'v2' | 'lightMeetings' | 'error' | 'unknown' = 'unknown'; + + if (url.includes('/v2/')) { + pageType = 'v2'; + signals.push('URL contains /v2/'); + } else if (url.includes('light-meetings') || url.includes('lightmeetings')) { + pageType = 'lightMeetings'; + signals.push('URL contains light-meetings'); + } else if (url.includes('teams.microsoft.com')) { + signals.push('URL is on teams.microsoft.com'); + } + + if (url.includes('anon=true')) { + signals.push('URL has anon=true'); + } + + // DOM-based detection + const domDetection = await page.evaluate(() => { + const result = { + hasNameInput: false, + hasSignInLink: false, + hasJoinButton: false, + hasV2Indicators: false, + hasLightMeetingsIndicators: false, + bodyTextSnippet: '', + title: document.title, + }; + + // Name input (only present on light-meetings / anonymous page) + const nameInput = document.querySelector('input[placeholder="Type your name"]') || + document.querySelector('input[data-tid="prejoin-display-name-input"]') || + document.querySelector('input[placeholder*="name" i]'); + result.hasNameInput = !!nameInput; + + // Sign-in link (present on light-meetings, allows switching to auth) + const signInLink = document.querySelector('button[data-tid="auth-sign-in-link"]') || + document.querySelector('a[data-tid="auth-sign-in-link"]'); + result.hasSignInLink = !!signInLink; + + // Join button + const joinButton = document.querySelector('#prejoin-join-button') || + document.querySelector('button[data-tid="prejoin-join-button"]') || + document.querySelector('button'); + // Check if any button has "Join" text + const allButtons = document.querySelectorAll('button'); + let hasJoinText = false; + allButtons.forEach(btn => { + if (btn.textContent?.includes('Join')) hasJoinText = true; + }); + result.hasJoinButton = !!joinButton || hasJoinText; + + // v2 indicators + const v2Indicators = [ + document.querySelector('[data-tid="prejoin-join-button"]'), + document.querySelector('[data-tid="prejoin-screen"]'), + ]; + result.hasV2Indicators = v2Indicators.some(el => el !== null); + + // light-meetings indicators + const bodyText = document.body?.innerText || ''; + result.bodyTextSnippet = bodyText.substring(0, 300); + result.hasLightMeetingsIndicators = + bodyText.includes('Type your name') || + bodyText.includes('Join as a guest') || + bodyText.includes('Geben Sie Ihren Namen ein'); + + return result; + }); + + if (domDetection.hasNameInput) signals.push('Name input found (anonymous page)'); + if (domDetection.hasSignInLink) signals.push('Sign-in link found'); + if (domDetection.hasJoinButton) signals.push('Join button found'); + if (domDetection.hasV2Indicators) signals.push('/v2/ DOM indicators found'); + if (domDetection.hasLightMeetingsIndicators) signals.push('light-meetings DOM indicators found'); + if (domDetection.title) signals.push(`Page title: ${domDetection.title}`); + + // Refine page type based on DOM if URL detection was inconclusive + if (pageType === 'unknown') { + if (domDetection.hasLightMeetingsIndicators || domDetection.hasNameInput) { + pageType = 'lightMeetings'; + } else if (domDetection.hasV2Indicators) { + pageType = 'v2'; + } + } + + return { + pageType, + hasSignInLink: domDetection.hasSignInLink, + hasNameInput: domDetection.hasNameInput, + hasJoinButton: domDetection.hasJoinButton, + signals, + }; +} + +// ============================================================================ +// AUTH ATTEMPT (VARIANT 5 ONLY) +// ============================================================================ + +/** + * Attempt authentication using the existing AuthProcedure. + * Clicks "Sign in" on the pre-join page, then handles the hybrid auth flow. + */ +async function _attemptAuth(page: Page, email: string, password: string): Promise { + try { + // Click "Sign in" link on the pre-join page + const signInSelectors = [ + 'button[data-tid="auth-sign-in-link"]', + 'a[data-tid="auth-sign-in-link"]', + 'button:has-text("Sign in")', + 'a:has-text("Sign in")', + 'button:has-text("Anmelden")', + ]; + + let clicked = false; + for (const selector of signInSelectors) { + try { + const el = await page.$(selector); + if (el) { + await el.click(); + logger.info(`[AuthTest] Clicked sign-in link: ${selector}`); + clicked = true; + await page.waitForTimeout(3000); + break; + } + } catch { + // Continue + } + } + + if (!clicked) { + logger.warn('[AuthTest] No sign-in link found on page'); + return false; + } + + // Use AuthProcedure for the hybrid flow (inline modal → redirect → password) + const authProcedure = new AuthProcedure(page, logger); + const authResult = await authProcedure.authenticateWithMicrosoft(email, password, true); + + // Wait for redirect back to Teams after auth + if (authResult) { + await page.waitForTimeout(5000); + } + + return authResult; + } catch (err) { + logger.error(`[AuthTest] Auth attempt failed: ${err}`); + return false; + } +} + +// ============================================================================ +// SCREENSHOT +// ============================================================================ + +/** + * Take a JPEG screenshot and return as base64. + */ +async function _takeScreenshot(page: Page): Promise { + try { + const buffer = await page.screenshot({ + type: 'jpeg', + quality: _SCREENSHOT_QUALITY, + fullPage: false, + }); + return buffer.toString('base64'); + } catch { + return undefined; + } +} + +// ============================================================================ +// RESULT HELPERS +// ============================================================================ + +function _createSkippedResult(variant: AuthTestVariant, reason: string): AuthTestResult { + return { + variantId: variant.id, + variantName: variant.name, + success: false, + pageType: 'error', + finalUrl: '', + hasSignInLink: false, + hasNameInput: false, + hasJoinButton: false, + authAttempted: false, + authSuccess: null, + durationMs: 0, + error: `Uebersprungen: ${reason}`, + detectedSignals: [], + }; +} + +function _createErrorResult(variant: AuthTestVariant, error: string): AuthTestResult { + return { + variantId: variant.id, + variantName: variant.name, + success: false, + pageType: 'error', + finalUrl: '', + hasSignInLink: false, + hasNameInput: false, + hasJoinButton: false, + authAttempted: false, + authSuccess: null, + durationMs: 0, + error, + detectedSignals: [], + }; +} + +// ============================================================================ +// RECOMMENDATION ENGINE +// ============================================================================ + +/** + * Generate a recommendation based on test results. + */ +function _generateRecommendation(results: AuthTestResult[]): string { + const v2Variants = results.filter(r => r.pageType === 'v2'); + const authSuccess = results.find(r => r.authAttempted && r.authSuccess === true); + + if (authSuccess) { + return `Variante "${authSuccess.variantName}" hat Auth erfolgreich durchgefuehrt! ` + + `Auth-Flow kann mit dieser Konfiguration aktiviert werden.`; + } + + if (v2Variants.length > 0) { + const names = v2Variants.map(v => `"${v.variantName}"`).join(', '); + return `${v2Variants.length} Variante(n) haben /v2/ erhalten: ${names}. ` + + `Auth ist prinzipiell moeglich — Auth-Flow kann re-aktiviert werden.`; + } + + const successVariants = results.filter(r => r.success); + if (successVariants.every(r => r.pageType === 'lightMeetings')) { + return 'Alle Varianten wurden auf light-meetings umgeleitet. ' + + 'Teams blockiert den authentifizierten Join fuer alle getesteten Browser-Konfigurationen. ' + + 'Weitere Optionen: Graph API / Bot Framework oder andere Stealth-Ansaetze evaluieren.'; + } + + return 'Test abgeschlossen. Ergebnisse pruefen und naechste Schritte planen.'; +} diff --git a/src/server/httpServer.ts b/src/server/httpServer.ts index 1fb2d33..aa8e972 100644 --- a/src/server/httpServer.ts +++ b/src/server/httpServer.ts @@ -2,6 +2,7 @@ import express, { Express, Request, Response } from 'express'; import { Server } from 'http'; import { logger } from '../utils/logger'; import { config } from '../config'; +import { runAuthTests } from '../bot/authTestProcedure'; export interface HttpServerCallbacks { onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string, botAccountEmail?: string, botAccountPassword?: string, backgroundImageUrl?: string) => Promise; @@ -136,5 +137,25 @@ export class HttpServer { // This would need access to the session manager res.json({ message: 'Not implemented' }); }); + + // Run auth detection tests against a Teams meeting URL + this._app.post('/api/bot/test-auth', async (req: Request, res: Response) => { + try { + const { meetingUrl, botAccountEmail, botAccountPassword } = req.body; + + if (!meetingUrl) { + res.status(400).json({ error: 'Missing required field: meetingUrl' }); + return; + } + + logger.info(`Starting auth detection tests for: ${meetingUrl}`); + const results = await runAuthTests(meetingUrl, botAccountEmail, botAccountPassword); + + res.json(results); + } catch (error) { + logger.error('Error running auth tests:', error); + res.status(500).json({ error: (error as Error).message }); + } + }); } }