diff --git a/src/bot/authProcedure.ts b/src/bot/authProcedure.ts index 713c135..f699e3f 100644 --- a/src/bot/authProcedure.ts +++ b/src/bot/authProcedure.ts @@ -3,17 +3,21 @@ import { Logger } from 'winston'; /** * AuthProcedure - Handles Microsoft account authentication in the browser. - * - * Supports TWO login flows: - * - * 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 + * + * The login flow when clicking "Sign in" on the Teams pre-join page is a HYBRID: + * + * 1. Teams shows an INLINE MODAL on the light-meetings page (no URL change): + * - Email input: input#emailOrPhonerm (type="text", data-testid="emailInput") + * - Next button: button[data-testid="authLoginDialogNextButton"] (starts disabled!) + * + * 2. After clicking Next, Teams REDIRECTS to login.microsoftonline.com: + * - Password input: input#i0118 (name="passwd", type="password") + * - Sign in button: input#idSIButton9 (type="submit", value="Sign in") + * + * 3. "Stay signed in?" prompt on login.microsoftonline.com: + * - Yes button: input#idSIButton9 (value="Yes") + * + * 4. Redirect chain back to teams.microsoft.com/v2/ with "Join now" button. */ const _LOGIN_URL = 'https://login.microsoftonline.com'; @@ -27,13 +31,6 @@ export class AuthProcedure { this._logger = logger; } - /** - * Authenticate with Microsoft using email + password. - * - * @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}...`); @@ -45,71 +42,59 @@ export class AuthProcedure { timeout: 30000, }); } else { - this._logger.info('Inline auth flow: waiting for login form to appear on current page...'); + this._logger.info('Inline auth flow: waiting for Teams login modal...'); } - // Step 1: Wait for email input field. - // Works for both the Teams inline modal and the Microsoft login portal. - const emailInput = await this._waitForEmailInput(); + // Step 1: Wait for email input + const emailInput = await this._waitForEmailInput(skipNavigation); if (!emailInput) { return false; } - // Step 2: Enter email and click Next + // Step 2: Enter email await emailInput.fill(email); this._logger.info('Email entered'); - const nextClicked = await this._clickNextButton(); - if (!nextClicked) { - this._logger.warn('Could not find Next button, pressing Enter'); - await this._page.keyboard.press('Enter'); - } + // Step 3: Click Next + await this._clickNextButton(skipNavigation); - // Step 3: Wait for password input + // Step 4: Wait for password input (on login.microsoftonline.com after redirect) const passwordInput = await this._waitForPasswordInput(); if (!passwordInput) { return false; } - // Step 4: Enter password and click Sign in + // Step 5: Enter password and click Sign in await passwordInput.fill(password); this._logger.info('Password entered'); - - 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._clickSignInButton(); await this._page.waitForTimeout(3000); - // Step 5: Check for MFA - const mfaDetected = await this._detectMfa(); - if (mfaDetected) { + // Step 6: Check for MFA + if (await this._detectMfa()) { this._logger.error('MFA prompt detected - cannot authenticate automatically.'); return false; } - // Step 6: Check for password error - const pwdError = await this._page.$('#passwordError, [data-tid="error"]'); + // Step 7: Check for password error + const pwdError = await this._page.$('#passwordError'); if (pwdError) { const errorText = await pwdError.textContent(); this._logger.error(`Login error after password: ${errorText}`); return false; } - // Step 7: Handle "Stay signed in?" prompt + // Step 8: Handle "Stay signed in?" prompt await this._handleStaySignedIn(); - // 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'); + this._logger.info('Page navigated during auth (context destroyed) - treating as success'); return true; } this._logger.error(`Authentication failed: ${error}`); @@ -117,62 +102,48 @@ export class AuthProcedure { } } - /** - * 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(', '); - + private async _waitForEmailInput(isInlineModal: boolean) { 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)...'); + + if (isInlineModal) { try { - const fallback = await this._page.waitForSelector( - 'input[type="text"]:visible, input:not([type]):visible', - { timeout: 10000, state: 'visible' } + const element = await this._page.waitForSelector( + 'input[data-testid="emailInput"], input#emailOrPhonerm', + { timeout: 20000, state: 'visible' } ); - if (fallback) { - const url = this._page.url(); - this._logger.info(`Found fallback text input on URL: ${url.substring(0, 80)}`); - return fallback; + if (element) { + this._logger.info('Found Teams inline modal email input (data-testid="emailInput")'); + return element; } } catch { - // Log page state for debugging - await this._logPageState('email input not found'); + this._logger.warn('Teams inline modal email input not found'); + await this._logPageState('inline modal email input not found'); } } + + try { + const element = await this._page.waitForSelector( + 'input[name="loginfmt"], input[type="email"]', + { timeout: 15000, state: 'visible' } + ); + if (element) { + this._logger.info('Found MS login portal email input'); + return element; + } + } catch { + await this._logPageState('email input not found (all selectors failed)'); + } + 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' } + 'input[name="passwd"], input#i0118, input[type="password"]', + { timeout: 20000, state: 'visible' } ); const url = this._page.url(); this._logger.info(`Found password input on URL: ${url.substring(0, 80)}`); @@ -184,18 +155,28 @@ export class AuthProcedure { return null; } - /** - * Click the "Next" button after entering email. - * Works on both Teams inline modal and Microsoft login portal. - */ - private async _clickNextButton(): Promise { + private async _clickNextButton(isInlineModal: boolean): Promise { + if (isInlineModal) { + const teamsNextSelector = 'button[data-testid="authLoginDialogNextButton"]'; + try { + await this._page.waitForSelector( + `${teamsNextSelector}:not([disabled])`, + { timeout: 10000, state: 'visible' } + ); + await this._page.click(teamsNextSelector); + this._logger.info('Clicked Teams inline modal Next button'); + await this._page.waitForTimeout(3000); + return; + } catch { + this._logger.warn('Teams inline modal Next button not found/enabled, trying fallbacks...'); + } + } + 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) { @@ -205,27 +186,25 @@ export class AuthProcedure { await button.click(); this._logger.info(`Clicked Next: ${selector}`); await this._page.waitForTimeout(2000); - return true; + return; } } catch { // Continue } } - return false; + + this._logger.warn('No Next button found, pressing Enter'); + await this._page.keyboard.press('Enter'); + await this._page.waitForTimeout(2000); } - /** - * Click the "Sign in" button after entering password. - * Works on both Teams inline modal and Microsoft login portal. - */ - private async _clickSignInButton(): Promise { + private async _clickSignInButton(): Promise { const selectors = [ + 'input#idSIButton9', '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) { @@ -234,18 +213,17 @@ export class AuthProcedure { if (button && await button.isVisible()) { await button.click(); this._logger.info(`Clicked Sign in: ${selector}`); - return true; + return; } } catch { // Continue } } - return false; + + this._logger.warn('No Sign in button found, pressing Enter'); + await this._page.keyboard.press('Enter'); } - /** - * Detect MFA prompts (authenticator app, SMS, phone call). - */ private async _detectMfa(): Promise { const mfaSelectors = [ '#idDiv_SAOTCAS_Description', @@ -255,28 +233,21 @@ export class AuthProcedure { 'text=Approve sign in request', 'text=Enter code', 'text=Verify your identity', - 'text=Approve sign-in request', ]; for (const selector of mfaSelectors) { try { - const element = await this._page.$(selector); - if (element) { - return true; - } + if (await this._page.$(selector)) return true; } catch { - // Continue checking + // Continue } } return false; } - /** - * Handle the "Stay signed in?" prompt after successful login. - */ private async _handleStaySignedIn(): Promise { try { - const staySignedInSelectors = [ + const selectors = [ 'input#idSIButton9', 'button#idSIButton9', 'input[value="Yes"]', @@ -285,7 +256,7 @@ export class AuthProcedure { 'button:has-text("Ja")', ]; - for (const selector of staySignedInSelectors) { + for (const selector of selectors) { try { const button = await this._page.$(selector); if (button) { @@ -298,32 +269,25 @@ export class AuthProcedure { // Continue } } - this._logger.debug('No "Stay signed in" prompt detected'); } catch (error) { this._logger.debug(`Stay signed in handling: ${error}`); } } - /** - * Log current page state for debugging when a selector is not found. - */ private async _logPageState(context: string): Promise { try { const url = this._page.url(); 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(', '); + return Array.from(allInputs).map(i => { + const tid = i.getAttribute('data-testid') || ''; + return `[type=${i.type},name=${i.name},id=${i.id},ph=${i.placeholder},tid=${tid}]`; + }).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)}`); + this._logger.warn(`Page state (${context}): URL=${url.substring(0, 100)}, Title=${title}`); + this._logger.warn(`Inputs: ${inputs.substring(0, 400)}`); } catch (err) { this._logger.warn(`Could not log page state: ${err}`); }