From 8729c8056ddfde2db291cafe2909647ad8915f04 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 16 Feb 2026 21:37:41 +0100
Subject: [PATCH] teams test auth bot
---
package.json | 3 +
src/bot/authTestProcedure.ts | 823 +++++++++++++++++++++++++++++++++++
src/server/httpServer.ts | 21 +
3 files changed, 847 insertions(+)
create mode 100644 src/bot/authTestProcedure.ts
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 });
+ }
+ });
}
}