teams test auth bot
This commit is contained in:
parent
b8bb5affa9
commit
8729c8056d
3 changed files with 847 additions and 0 deletions
|
|
@ -12,6 +12,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.50.0",
|
"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",
|
"ws": "^8.16.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
|
|
|
||||||
823
src/bot/authTestProcedure.ts
Normal file
823
src/bot/authTestProcedure.ts
Normal file
|
|
@ -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<AuthTestResults> {
|
||||||
|
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<AuthTestResult> {
|
||||||
|
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<LaunchResult> {
|
||||||
|
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<LaunchResult> {
|
||||||
|
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<LaunchResult> {
|
||||||
|
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<LaunchResult> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<PageDetection> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string | undefined> {
|
||||||
|
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.';
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import express, { Express, Request, Response } from 'express';
|
||||||
import { Server } from 'http';
|
import { Server } from 'http';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
|
import { runAuthTests } from '../bot/authTestProcedure';
|
||||||
|
|
||||||
export interface HttpServerCallbacks {
|
export interface HttpServerCallbacks {
|
||||||
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string, botAccountEmail?: string, botAccountPassword?: string, backgroundImageUrl?: string) => Promise<void>;
|
onJoinRequest: (sessionId: string, meetingUrl: string, botName?: string, instanceId?: string, gatewayWsUrl?: string, language?: string, botAccountEmail?: string, botAccountPassword?: string, backgroundImageUrl?: string) => Promise<void>;
|
||||||
|
|
@ -136,5 +137,25 @@ export class HttpServer {
|
||||||
// This would need access to the session manager
|
// This would need access to the session manager
|
||||||
res.json({ message: 'Not implemented' });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue