From 8597f0eb5a95d99c6076b19c2b8136af5f5d7bad Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 16 Feb 2026 14:34:20 +0100
Subject: [PATCH] fix: handle Teams inline login modal - broad selectors, no
navigation, simplified post-auth
Co-authored-by: Cursor
---
src/bot/authProcedure.ts | 351 ++++++++++++++++++++++-----------------
src/bot/orchestrator.ts | 59 ++-----
2 files changed, 206 insertions(+), 204 deletions(-)
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');