diff --git a/src/bot/authProcedure.ts b/src/bot/authProcedure.ts index f699e3f..0e4d29b 100644 --- a/src/bot/authProcedure.ts +++ b/src/bot/authProcedure.ts @@ -3,21 +3,21 @@ import { Logger } from 'winston'; /** * AuthProcedure - Handles Microsoft account authentication in the browser. - * - * 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. + * + * The HYBRID login flow (when clicking "Sign in" on Teams pre-join page): + * + * 1. Teams shows an INLINE MODAL on the light-meetings page (no URL change) + * - Email input: input#emailOrPhonerm [data-testid="emailInput"] + * - Next button: button[data-testid="authLoginDialogNextButton"] (enabled after email entered) + * + * 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 + * + * 4. Redirect chain → teams.microsoft.com/v2/ with "Join now" button */ const _LOGIN_URL = 'https://login.microsoftonline.com'; @@ -31,6 +31,13 @@ export class AuthProcedure { this._logger = logger; } + /** + * Authenticate with Microsoft using email + password. + * + * @param skipNavigation - When true, handles the hybrid inline modal flow + * (email on Teams page, then redirect to MS login for password). + * @returns true if authentication was successful, false otherwise + */ async authenticateWithMicrosoft(email: string, password: string, skipNavigation = false): Promise { try { this._logger.info(`Authenticating as ${email}...`); @@ -42,41 +49,50 @@ export class AuthProcedure { timeout: 30000, }); } else { - this._logger.info('Inline auth flow: waiting for Teams login modal...'); + this._logger.info('Hybrid auth flow: waiting for Teams inline email modal...'); } - // Step 1: Wait for email input + // Step 1: Enter email + // In the hybrid flow, this is the Teams inline modal (input#emailOrPhonerm). + // In the direct flow, this is the MS login portal (input[name="loginfmt"]). const emailInput = await this._waitForEmailInput(skipNavigation); if (!emailInput) { return false; } - // Step 2: Enter email await emailInput.fill(email); this._logger.info('Email entered'); - // Step 3: Click Next + // Step 2: Click Next + // In the hybrid flow: button[data-testid="authLoginDialogNextButton"] + // The button is disabled until email is entered, so we wait for it to be enabled. + // In the direct flow: input[type="submit"] on the MS login page. await this._clickNextButton(skipNavigation); - // Step 4: Wait for password input (on login.microsoftonline.com after redirect) + // Step 3: Wait for password input + // After clicking Next in the Teams modal, it REDIRECTS to login.microsoftonline.com. + // The password field is on the MS login portal: input#i0118 [name="passwd"] + // We need a longer timeout because the redirect takes time. const passwordInput = await this._waitForPasswordInput(); if (!passwordInput) { return false; } - // Step 5: Enter password and click Sign in + // Step 4: Enter password and click Sign in await passwordInput.fill(password); this._logger.info('Password entered'); + await this._clickSignInButton(); await this._page.waitForTimeout(3000); - // Step 6: Check for MFA - if (await this._detectMfa()) { + // Step 5: Check for MFA + const mfaDetected = await this._detectMfa(); + if (mfaDetected) { this._logger.error('MFA prompt detected - cannot authenticate automatically.'); return false; } - // Step 7: Check for password error + // Step 6: Check for password error const pwdError = await this._page.$('#passwordError'); if (pwdError) { const errorText = await pwdError.textContent(); @@ -84,7 +100,7 @@ export class AuthProcedure { return false; } - // Step 8: Handle "Stay signed in?" prompt + // Step 7: Handle "Stay signed in?" prompt await this._handleStaySignedIn(); this._logger.info(`Successfully authenticated as ${email}`); @@ -94,7 +110,7 @@ export class AuthProcedure { const errorMessage = String(error); if (errorMessage.includes('Execution context was destroyed') || errorMessage.includes('execution context')) { - this._logger.info('Page navigated during auth (context destroyed) - treating as success'); + this._logger.info('Page navigated during auth (execution context destroyed) - treating as success'); return true; } this._logger.error(`Authentication failed: ${error}`); @@ -102,109 +118,136 @@ export class AuthProcedure { } } - private async _waitForEmailInput(isInlineModal: boolean) { + /** + * Wait for the email input field. + * + * Hybrid flow: Teams inline modal has input#emailOrPhonerm [data-testid="emailInput"] + * Direct flow: MS login portal has input[name="loginfmt"] + */ + private async _waitForEmailInput(isInlineFlow: boolean) { this._logger.info('Waiting for email input field...'); - if (isInlineModal) { - try { - const element = await this._page.waitForSelector( - 'input[data-testid="emailInput"], input#emailOrPhonerm', - { timeout: 20000, state: 'visible' } - ); - if (element) { - this._logger.info('Found Teams inline modal email input (data-testid="emailInput")'); - return element; - } - } catch { - this._logger.warn('Teams inline modal email input not found'); - await this._logPageState('inline modal email input not found'); - } - } + // Primary selectors — exact IDs/attributes from the real HTML + const selectors = isInlineFlow + ? [ + 'input#emailOrPhonerm', // Teams inline modal (exact ID) + 'input[data-testid="emailInput"]', // Teams inline modal (data-testid) + 'input[placeholder="Enter your email"]', // Teams inline modal (placeholder) + ] + : [ + 'input[name="loginfmt"]', // MS login portal + 'input[type="email"]', // MS login portal fallback + ]; + + const combinedSelector = selectors.join(', '); try { - const element = await this._page.waitForSelector( - 'input[name="loginfmt"], input[type="email"]', - { timeout: 15000, state: 'visible' } + const element = await this._page.waitForSelector(combinedSelector, { + timeout: 30000, + state: 'visible', + }); + const matchedInfo = await element?.evaluate(el => + `${el.tagName}[id=${el.id}, type=${el.getAttribute('type')}, placeholder=${el.getAttribute('placeholder')}]` ); - if (element) { - this._logger.info('Found MS login portal email input'); - return element; - } + this._logger.info(`Found email input: ${matchedInfo}`); + return element; } catch { - await this._logPageState('email input not found (all selectors failed)'); + await this._logPageState('email input not found'); + this._logger.error('Could not find email input field'); + return null; } - - this._logger.error('Could not find email input field'); - return null; } + /** + * Click the "Next" button after entering email. + * + * Hybrid flow: button[data-testid="authLoginDialogNextButton"] — initially disabled, + * becomes enabled after email is typed. We wait for it to be enabled. + * Direct flow: input[type="submit"] on the MS login portal. + */ + private async _clickNextButton(isInlineFlow: boolean): Promise { + if (isInlineFlow) { + // Teams inline modal: wait for the Next button to become enabled + const nextSelector = 'button[data-testid="authLoginDialogNextButton"]'; + this._logger.info('Waiting for Teams Next button to become enabled...'); + try { + await this._page.waitForSelector(`${nextSelector}:not([disabled])`, { + timeout: 10000, + state: 'visible', + }); + await this._page.click(nextSelector); + this._logger.info('Clicked Teams Next button'); + // After clicking Next, Teams redirects to login.microsoftonline.com + // Wait for the redirect to begin + await this._page.waitForTimeout(3000); + } catch { + this._logger.warn('Teams Next button not found or not enabled, pressing Enter'); + await this._page.keyboard.press('Enter'); + await this._page.waitForTimeout(3000); + } + } else { + // MS login portal: click submit button + const selectors = [ + 'input[type="submit"]', + 'button[type="submit"]', + ]; + 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; + } + } catch { + // Continue + } + } + this._logger.warn('No Next button found, pressing Enter'); + await this._page.keyboard.press('Enter'); + await this._page.waitForTimeout(2000); + } + } + + /** + * Wait for the password input field on login.microsoftonline.com. + * + * After entering email in the Teams inline modal and clicking Next, + * Teams redirects to login.microsoftonline.com where the password field is: + * input#i0118 [name="passwd", type="password"] + * + * Timeout is generous (30s) because the redirect can take time. + */ private async _waitForPasswordInput() { - this._logger.info('Waiting for password input field...'); + this._logger.info('Waiting for password input field (may involve redirect to login.microsoftonline.com)...'); try { const element = await this._page.waitForSelector( - 'input[name="passwd"], input#i0118, input[type="password"]', - { timeout: 20000, state: 'visible' } + 'input#i0118, input[name="passwd"], input[type="password"]', + { timeout: 30000, 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; } - this._logger.error('Could not find password input field'); - return null; - } - - 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")', - ]; - - 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; - } - } catch { - // Continue - } - } - - 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 on login.microsoftonline.com. + * + * The button is: input#idSIButton9 [type="submit", value="Sign in"] + */ private async _clickSignInButton(): Promise { const selectors = [ - 'input#idSIButton9', - 'input[type="submit"]', - 'button[type="submit"]', - 'button:has-text("Sign in")', - 'button:has-text("Anmelden")', + 'input#idSIButton9', // MS login portal (exact ID) + 'input[type="submit"][value="Sign in"]', // MS login portal (value) + 'input[type="submit"][value="Anmelden"]', // MS login portal (German) + 'input[type="submit"]', // Generic fallback + 'button[type="submit"]', // Alternative ]; for (const selector of selectors) { @@ -219,11 +262,13 @@ export class AuthProcedure { // Continue } } - 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', @@ -233,11 +278,15 @@ 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 { - if (await this._page.$(selector)) return true; + const element = await this._page.$(selector); + if (element) { + return true; + } } catch { // Continue } @@ -245,9 +294,13 @@ export class AuthProcedure { return false; } + /** + * Handle the "Stay signed in?" prompt. + * Button: input#idSIButton9 [value="Yes"] + */ private async _handleStaySignedIn(): Promise { try { - const selectors = [ + const staySignedInSelectors = [ 'input#idSIButton9', 'button#idSIButton9', 'input[value="Yes"]', @@ -256,7 +309,7 @@ export class AuthProcedure { 'button:has-text("Ja")', ]; - for (const selector of selectors) { + for (const selector of staySignedInSelectors) { try { const button = await this._page.$(selector); if (button) { @@ -275,19 +328,21 @@ export class AuthProcedure { } } + /** + * Log current page state for debugging. + */ private async _logPageState(context: string): Promise { try { const url = this._page.url(); const title = await this._page.title(); const inputs = await this._page.evaluate(() => { const allInputs = document.querySelectorAll('input'); - 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(', '); + return Array.from(allInputs).map(i => + `${i.tagName}[id=${i.id}, type=${i.type}, name=${i.name}, placeholder=${i.placeholder}]` + ).join(', '); }); this._logger.warn(`Page state (${context}): URL=${url.substring(0, 100)}, Title=${title}`); - this._logger.warn(`Inputs: ${inputs.substring(0, 400)}`); + this._logger.warn(`Inputs on page: ${inputs.substring(0, 500)}`); } catch (err) { this._logger.warn(`Could not log page state: ${err}`); }