import { Page } from 'playwright'; import { Logger } from 'winston'; /** * AuthProcedure - Handles Microsoft account authentication in the browser. * * Used for dedicated bot accounts (e.g. bot@valueon.ch) to join Teams meetings * as an authenticated user instead of an anonymous guest. * * Benefits of authenticated join: * - Full access to Teams features (language settings, background effects) * - No lobby wait (if user is member of the meeting org) * - Bot name is the account display name * - Can set spoken language for captions */ const _LOGIN_URL = 'https://login.microsoftonline.com'; const _TEAMS_URL = 'https://teams.microsoft.com'; export class AuthProcedure { private _page: Page; private _logger: Logger; constructor(page: Page, logger: Logger) { this._page = page; this._logger = logger; } /** * Authenticate with Microsoft using email + password. * Navigates to Microsoft login, enters credentials, and waits for successful sign-in. * * @returns true if authentication was successful, false otherwise */ async authenticateWithMicrosoft(email: string, password: string): Promise { try { this._logger.info(`Authenticating as ${email}...`); // Only navigate to login page if not already there. // When called after clicking "Sign in" on Teams pre-join page, // the browser is already on login.microsoftonline.com WITH a return URL // embedded by Teams. Navigating again would destroy that return URL! const currentUrl = this._page.url(); if (!currentUrl.includes('login.microsoftonline.com')) { this._logger.info('Not on login page yet, navigating to Microsoft login...'); await this._page.goto(_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000, }); } else { this._logger.info('Already on Microsoft login page - skipping navigation to preserve return URL'); await this._page.waitForLoadState('domcontentloaded', { timeout: 15000 }); } // Wait for email input const emailInput = await this._page.waitForSelector( 'input[type="email"], input[name="loginfmt"]', { timeout: 10000 } ); if (!emailInput) { this._logger.error('Could not find email input field'); return false; } // Enter email await emailInput.fill(email); this._logger.info('Email entered'); // Click Next const nextButton = await this._page.$('input[type="submit"], button[type="submit"]'); if (nextButton) { await nextButton.click(); } else { await this._page.keyboard.press('Enter'); } await this._page.waitForTimeout(2000); // Check for "account not found" error const errorElement = await this._page.$('#usernameError, [data-tid="error"]'); if (errorElement) { const errorText = await errorElement.textContent(); this._logger.error(`Login error after email: ${errorText}`); return false; } // Wait for password input const passwordInput = await this._page.waitForSelector( 'input[type="password"], input[name="passwd"]', { timeout: 10000 } ); if (!passwordInput) { this._logger.error('Could not find password input field'); return false; } // Enter password await passwordInput.fill(password); this._logger.info('Password entered'); // Click Sign in const signInButton = await this._page.$('input[type="submit"], button[type="submit"]'); if (signInButton) { await signInButton.click(); } else { await this._page.keyboard.press('Enter'); } await this._page.waitForTimeout(3000); // Check for MFA prompt const mfaDetected = await this._detectMfa(); if (mfaDetected) { this._logger.error('MFA prompt detected - cannot authenticate automatically. Please disable MFA for the bot account.'); return false; } // Check for password error const pwdError = await this._page.$('#passwordError, [data-tid="error"]'); if (pwdError) { const errorText = await pwdError.textContent(); this._logger.error(`Login error after password: ${errorText}`); return false; } // Handle "Stay signed in?" prompt await this._handleStaySignedIn(); // Verify authentication succeeded by checking for Teams or Microsoft landing const isAuthenticated = await this._verifyAuthentication(); if (isAuthenticated) { this._logger.info(`Successfully authenticated as ${email}`); } else { this._logger.error('Authentication verification failed - may not be signed in'); } return isAuthenticated; } catch (error) { this._logger.error(`Authentication failed: ${error}`); return false; } } /** * Detect MFA prompts (authenticator app, SMS, phone call). */ private async _detectMfa(): Promise { const mfaSelectors = [ '#idDiv_SAOTCAS_Description', // Authenticator app prompt '#idDiv_SAOTCC_Description', // Code entry '#idDiv_SAASDS_Description', // SMS verification '[data-tid="phoneVerification"]', // Phone verification 'text=Approve sign in request', // Authenticator approval 'text=Enter code', // Code entry 'text=Verify your identity', // Generic MFA 'text=Approve sign-in request', // Authenticator ]; for (const selector of mfaSelectors) { try { const element = await this._page.$(selector); if (element) { return true; } } catch { // Continue checking } } return false; } /** * Handle the "Stay signed in?" prompt after successful login. */ private async _handleStaySignedIn(): Promise { try { // Look for "Stay signed in?" or "Angemeldet bleiben?" prompt const staySignedInSelectors = [ 'input#idSIButton9', // "Yes" button (Stay signed in) 'button#idSIButton9', // Alternative 'input[value="Yes"]', 'input[value="Ja"]', 'button:has-text("Yes")', 'button:has-text("Ja")', ]; for (const selector of staySignedInSelectors) { try { const button = await this._page.$(selector); if (button) { await button.click(); this._logger.info('Clicked "Stay signed in" - Yes'); await this._page.waitForTimeout(2000); return; } } catch { // Continue } } // No prompt found - that's OK, some tenants don't show it this._logger.debug('No "Stay signed in" prompt detected'); } catch (error) { this._logger.debug(`Stay signed in handling: ${error}`); } } /** * Verify that authentication was successful. * Checks if we're on a Microsoft/Teams page with an authenticated session. * * Note: After successful login, Microsoft often triggers navigation/redirects * which can destroy the execution context. An "Execution context was destroyed" * error is treated as a successful login (navigation = login worked). */ private async _verifyAuthentication(): Promise { try { // Wait for navigation that indicates login succeeded (redirect to Teams/Office) try { await this._page.waitForNavigation({ url: (url) => url.href.includes('teams.microsoft.com') || url.href.includes('office.com') || url.href.includes('myapps.microsoft.com') || url.href.includes('microsoftonline.com/common/oauth2'), timeout: 15000, }); this._logger.info('Navigation detected after login - authentication succeeded'); return true; } catch (navError) { const errorMessage = String(navError); // "Execution context was destroyed" means the page navigated away // from the login page, which indicates a successful login redirect if (errorMessage.includes('Execution context was destroyed') || errorMessage.includes('execution context') || errorMessage.includes('navigation')) { this._logger.info('Execution context destroyed during verification - treating as successful login (page navigated)'); // Give the page a moment to settle after navigation await this._page.waitForTimeout(2000); return true; } // Timeout - check where we ended up this._logger.debug(`waitForNavigation did not match expected URL: ${navError}`); } const url = this._page.url(); // If we're on Teams or Microsoft portal, we're authenticated if (url.includes('teams.microsoft.com') || url.includes('office.com') || url.includes('microsoftonline.com/common/oauth2') || url.includes('myapps.microsoft.com')) { return true; } // Wait a bit and check again (redirects may be in progress) await this._page.waitForTimeout(3000); const finalUrl = this._page.url(); if (finalUrl.includes('teams.microsoft.com') || finalUrl.includes('office.com') || finalUrl.includes('portal.office.com')) { return true; } // Check for any error states const loginPage = finalUrl.includes('login.microsoftonline.com'); if (loginPage) { // Still on login page - authentication may have failed const hasError = await this._page.$('[data-tid="error"], #passwordError, #usernameError'); if (hasError) { return false; } // Might still be processing await this._page.waitForTimeout(5000); const afterWaitUrl = this._page.url(); return !afterWaitUrl.includes('login.microsoftonline.com'); } // If we're somewhere else entirely, assume authenticated return true; } catch (error) { const errorMessage = String(error); // Catch "Execution context was destroyed" at the top level too if (errorMessage.includes('Execution context was destroyed') || errorMessage.includes('execution context')) { this._logger.info('Execution context destroyed during verification (top-level) - treating as successful login'); return true; } this._logger.error(`Authentication verification error: ${error}`); return false; } } }