diff --git a/src/bot/authProcedure.ts b/src/bot/authProcedure.ts index 6992f41..713c135 100644 --- a/src/bot/authProcedure.ts +++ b/src/bot/authProcedure.ts @@ -4,18 +4,19 @@ 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. + * Supports TWO login flows: * - * 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 + * 1. **Inline modal flow** (when clicking "Sign in" on Teams pre-join page): + * - An inline modal appears on the same Teams page (no URL change) + * - Email input → Next → Password input → Sign in → Stay signed in? → Yes + * - Then redirect chain to teams.microsoft.com/v2/ with "Join now" + * + * 2. **Direct navigation flow** (standalone login): + * - Navigate to login.microsoftonline.com + * - Standard Microsoft login form */ const _LOGIN_URL = 'https://login.microsoftonline.com'; -const _TEAMS_URL = 'https://teams.microsoft.com'; export class AuthProcedure { private _page: Page; @@ -28,91 +29,67 @@ export class AuthProcedure { /** * Authenticate with Microsoft using email + password. - * Navigates to Microsoft login, enters credentials, and waits for successful sign-in. * + * @param skipNavigation - When true, assumes the login form is already loading + * (e.g. after clicking "Sign in" on Teams pre-join page). Does NOT navigate. * @returns true if authentication was successful, false otherwise */ async authenticateWithMicrosoft(email: string, password: string, skipNavigation = false): Promise { try { this._logger.info(`Authenticating as ${email}...`); - if (skipNavigation) { - // When called after clicking "Sign in" on Teams pre-join page, - // the browser is navigating to login.microsoftonline.com WITH a return URL - // embedded by Teams. We must NOT navigate ourselves - just wait for the - // email input to appear on whatever page we're redirected to. - const currentUrl = this._page.url(); - this._logger.info(`Skipping navigation (preserving return URL). Current URL: ${currentUrl.substring(0, 80)}`); - } else { + if (!skipNavigation) { this._logger.info('Navigating to Microsoft login...'); await this._page.goto(_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30000, }); + } else { + this._logger.info('Inline auth flow: waiting for login form to appear on current page...'); } - // Wait for email input (may take time if page is still redirecting) - const emailInput = await this._page.waitForSelector( - 'input[type="email"], input[name="loginfmt"]', - { timeout: 20000 } - ); + // Step 1: Wait for email input field. + // Works for both the Teams inline modal and the Microsoft login portal. + const emailInput = await this._waitForEmailInput(); if (!emailInput) { - this._logger.error('Could not find email input field'); return false; } - // Enter email + // Step 2: Enter email and click Next 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 { + const nextClicked = await this._clickNextButton(); + if (!nextClicked) { + this._logger.warn('Could not find Next button, pressing Enter'); 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 } - ); + // Step 3: Wait for password input + const passwordInput = await this._waitForPasswordInput(); if (!passwordInput) { - this._logger.error('Could not find password input field'); return false; } - // Enter password + // Step 4: Enter password and click Sign in 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 { + const signInClicked = await this._clickSignInButton(); + if (!signInClicked) { + this._logger.warn('Could not find Sign in button, pressing Enter'); await this._page.keyboard.press('Enter'); } await this._page.waitForTimeout(3000); - // Check for MFA prompt + // Step 5: Check for MFA const mfaDetected = await this._detectMfa(); if (mfaDetected) { - this._logger.error('MFA prompt detected - cannot authenticate automatically. Please disable MFA for the bot account.'); + this._logger.error('MFA prompt detected - cannot authenticate automatically.'); return false; } - // Check for password error + // Step 6: Check for password error const pwdError = await this._page.$('#passwordError, [data-tid="error"]'); if (pwdError) { const errorText = await pwdError.textContent(); @@ -120,38 +97,165 @@ export class AuthProcedure { return false; } - // Handle "Stay signed in?" prompt + // Step 7: 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; + // Auth succeeded - the redirect chain will be handled by the caller + this._logger.info(`Successfully authenticated as ${email}`); + return true; } catch (error) { + const errorMessage = String(error); + // "Execution context was destroyed" means page navigated (login redirect) + if (errorMessage.includes('Execution context was destroyed') || + errorMessage.includes('execution context')) { + this._logger.info('Page navigated during auth (execution context destroyed) - treating as success'); + return true; + } this._logger.error(`Authentication failed: ${error}`); return false; } } + /** + * Wait for an email/username input field to appear. + * Uses broad selectors that work on both the Teams inline modal + * and the Microsoft login portal. + */ + private async _waitForEmailInput() { + const selectors = [ + 'input[type="email"]', + 'input[name="loginfmt"]', + 'input[name="login"]', + 'input[name="email"]', + 'input[autocomplete="username"]', + ]; + const combinedSelector = selectors.join(', '); + + this._logger.info('Waiting for email input field...'); + try { + const element = await this._page.waitForSelector(combinedSelector, { + timeout: 30000, + state: 'visible', + }); + const url = this._page.url(); + const matchedTag = await element?.evaluate(el => `${el.tagName}[type=${el.getAttribute('type')}, name=${el.getAttribute('name')}]`); + this._logger.info(`Found email input: ${matchedTag} on URL: ${url.substring(0, 80)}`); + return element; + } catch { + // Fallback: look for any visible text input that could be an email field + this._logger.info('Primary email selectors failed, trying fallback (any visible text input)...'); + try { + const fallback = await this._page.waitForSelector( + 'input[type="text"]:visible, input:not([type]):visible', + { timeout: 10000, state: 'visible' } + ); + if (fallback) { + const url = this._page.url(); + this._logger.info(`Found fallback text input on URL: ${url.substring(0, 80)}`); + return fallback; + } + } catch { + // Log page state for debugging + await this._logPageState('email input not found'); + } + } + this._logger.error('Could not find email input field'); + return null; + } + + /** + * Wait for the password input field to appear. + */ + private async _waitForPasswordInput() { + this._logger.info('Waiting for password input field...'); + try { + const element = await this._page.waitForSelector( + 'input[type="password"], input[name="passwd"], input[name="password"]', + { timeout: 15000, state: 'visible' } + ); + const url = this._page.url(); + this._logger.info(`Found password input on URL: ${url.substring(0, 80)}`); + return element; + } catch { + await this._logPageState('password input not found'); + } + this._logger.error('Could not find password input field'); + return null; + } + + /** + * Click the "Next" button after entering email. + * Works on both Teams inline modal and Microsoft login portal. + */ + private async _clickNextButton(): Promise { + const selectors = [ + 'input[type="submit"]', + 'button[type="submit"]', + 'button:has-text("Next")', + 'button:has-text("Weiter")', + 'input[value="Next"]', + 'input[value="Weiter"]', + ]; + + for (const selector of selectors) { + try { + const button = await this._page.$(selector); + if (button && await button.isVisible()) { + await button.click(); + this._logger.info(`Clicked Next: ${selector}`); + await this._page.waitForTimeout(2000); + return true; + } + } catch { + // Continue + } + } + return false; + } + + /** + * Click the "Sign in" button after entering password. + * Works on both Teams inline modal and Microsoft login portal. + */ + private async _clickSignInButton(): Promise { + const selectors = [ + 'input[type="submit"]', + 'button[type="submit"]', + 'button:has-text("Sign in")', + 'button:has-text("Anmelden")', + 'input[value="Sign in"]', + 'input[value="Anmelden"]', + ]; + + for (const selector of selectors) { + try { + const button = await this._page.$(selector); + if (button && await button.isVisible()) { + await button.click(); + this._logger.info(`Clicked Sign in: ${selector}`); + return true; + } + } catch { + // Continue + } + } + 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 + '#idDiv_SAOTCAS_Description', + '#idDiv_SAOTCC_Description', + '#idDiv_SAASDS_Description', + '[data-tid="phoneVerification"]', + 'text=Approve sign in request', + 'text=Enter code', + 'text=Verify your identity', + 'text=Approve sign-in request', ]; for (const selector of mfaSelectors) { @@ -172,10 +276,9 @@ export class AuthProcedure { */ 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#idSIButton9', + 'button#idSIButton9', 'input[value="Yes"]', 'input[value="Ja"]', 'button:has-text("Yes")', @@ -196,7 +299,6 @@ export class AuthProcedure { } } - // 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}`); @@ -204,91 +306,26 @@ export class AuthProcedure { } /** - * 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). + * Log current page state for debugging when a selector is not found. */ - private async _verifyAuthentication(): Promise { + private async _logPageState(context: string): 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; + const title = await this._page.title(); + const bodyText = await this._page.evaluate(() => + document.body?.innerText?.substring(0, 300) || '(empty)' + ); + const inputs = await this._page.evaluate(() => { + const allInputs = document.querySelectorAll('input'); + return Array.from(allInputs).map(i => + `${i.tagName}[type=${i.type}, name=${i.name}, placeholder=${i.placeholder}]` + ).join(', '); + }); + this._logger.warn(`Page state (${context}): URL=${url.substring(0, 80)}, Title=${title}`); + this._logger.warn(`Inputs on page: ${inputs.substring(0, 300)}`); + this._logger.warn(`Page text: ${bodyText.substring(0, 200)}`); + } catch (err) { + this._logger.warn(`Could not log page state: ${err}`); } } } diff --git a/src/bot/orchestrator.ts b/src/bot/orchestrator.ts index 5ad8490..4c8ea76 100644 --- a/src/bot/orchestrator.ts +++ b/src/bot/orchestrator.ts @@ -200,11 +200,11 @@ export class BotOrchestrator { } if (signInClicked) { - // Clicking "Sign in" on the Teams pre-join page triggers a redirect chain - // to login.microsoftonline.com WITH a return URL embedded by Teams. - // We must NOT navigate to login.microsoftonline.com ourselves - that would - // destroy the return URL. Instead, pass skipNavigation=true and let - // authenticateWithMicrosoft wait for the email input to appear. + // Clicking "Sign in" on the Teams pre-join page opens an INLINE LOGIN MODAL + // directly on the same page (no URL change). The modal shows an email input, + // then password, then "Stay signed in?" — all on the light-meetings page. + // After completing login, Teams redirects to /v2/ with the "Join now" button. + // We pass skipNavigation=true so authProcedure does NOT navigate away. const { AuthProcedure } = await import('./authProcedure'); const authProcedure = new AuthProcedure(this._page!, this._logger); const authSuccess = await authProcedure.authenticateWithMicrosoft( @@ -216,57 +216,22 @@ export class BotOrchestrator { if (authSuccess) { this._logger.info('Authentication via "Sign in" link succeeded'); - // After login, the redirect chain goes automatically: - // login.microsoftonline.com → teams.microsoft.com/v2/?meetingjoin=true#/meet/... - // The return URL is embedded by Teams when clicking "Sign in". - // We just wait for the redirect to complete. - this._logger.info('Waiting for redirect chain to leave login.microsoftonline.com...'); + // After the inline login flow completes (email → password → stay signed in), + // Teams triggers a redirect chain that ends at teams.microsoft.com/v2/ + // with the authenticated pre-join page containing the "Join now" button. + // Simply wait for that button to appear — no manual navigation needed. + this._logger.info('Waiting for authenticated pre-join page (Join now button)...'); - // Step 1: Wait for URL to leave login.microsoftonline.com (poll up to 45s) - const maxWaitMs = 45000; - const pollIntervalMs = 1000; - const startTime = Date.now(); - let leftLoginPage = false; - - while (Date.now() - startTime < maxWaitMs) { - const currentUrl = this._page!.url(); - if (!currentUrl.includes('login.microsoftonline.com')) { - this._logger.info(`Left login page after ${Date.now() - startTime}ms. URL: ${currentUrl.substring(0, 100)}`); - leftLoginPage = true; - break; - } - await this._page!.waitForTimeout(pollIntervalMs); - } - - if (!leftLoginPage) { - const stuckUrl = this._page!.url(); - this._logger.warn(`Still on login page after ${maxWaitMs}ms. URL: ${stuckUrl.substring(0, 100)}`); - // Try navigating to meeting URL as fallback (auth cookies should be set) - this._logger.info('Fallback: navigating to meeting URL with auth cookies...'); - await this._page!.goto(this._meetingUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - await this._joinProcedure!.handleLauncherIfPresent(); - } - - // Step 2: Wait for the pre-join page to fully load - // The #prejoin-join-button appears on the Teams v2 authenticated pre-join page const joinButtonSelector = '#prejoin-join-button, button[data-tid="prejoin-join-button"]'; try { - await this._page!.waitForSelector(joinButtonSelector, { timeout: 30000, state: 'visible' }); + await this._page!.waitForSelector(joinButtonSelector, { timeout: 60000, state: 'visible' }); const finalUrl = this._page!.url(); this._logger.info(`On authenticated pre-join page: ${finalUrl.substring(0, 100)}`); } catch { const finalUrl = this._page!.url(); const pageContent = await this._page!.evaluate(() => document.body?.innerText?.substring(0, 300) || ''); - this._logger.warn(`Join button not found. URL: ${finalUrl.substring(0, 100)}`); + this._logger.warn(`Join button not found after 60s. URL: ${finalUrl.substring(0, 100)}`); this._logger.warn(`Page content: ${pageContent.substring(0, 200)}`); - - // If we ended up somewhere unexpected, try meeting URL as last resort - if (!finalUrl.includes('teams.microsoft.com')) { - this._logger.info('Not on Teams - navigating to meeting URL as last resort...'); - await this._page!.goto(this._meetingUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - await this._joinProcedure!.handleLauncherIfPresent(); - await this._page!.waitForTimeout(5000); - } } } else { this._logger.warn('Authentication via "Sign in" failed - continuing as anonymous');