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": {
|
||||
"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",
|
||||
|
|
|
|||
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 { 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<void>;
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue