fix: handle Teams inline login modal - broad selectors, no navigation, simplified post-auth

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-02-16 14:34:20 +01:00
parent f27233f308
commit 8597f0eb5a
2 changed files with 206 additions and 204 deletions

View file

@ -4,18 +4,19 @@ import { Logger } from 'winston';
/** /**
* AuthProcedure - Handles Microsoft account authentication in the browser. * AuthProcedure - Handles Microsoft account authentication in the browser.
* *
* Used for dedicated bot accounts (e.g. bot@valueon.ch) to join Teams meetings * Supports TWO login flows:
* as an authenticated user instead of an anonymous guest.
* *
* Benefits of authenticated join: * 1. **Inline modal flow** (when clicking "Sign in" on Teams pre-join page):
* - Full access to Teams features (language settings, background effects) * - An inline modal appears on the same Teams page (no URL change)
* - No lobby wait (if user is member of the meeting org) * - Email input Next Password input Sign in Stay signed in? Yes
* - Bot name is the account display name * - Then redirect chain to teams.microsoft.com/v2/ with "Join now"
* - Can set spoken language for captions *
* 2. **Direct navigation flow** (standalone login):
* - Navigate to login.microsoftonline.com
* - Standard Microsoft login form
*/ */
const _LOGIN_URL = 'https://login.microsoftonline.com'; const _LOGIN_URL = 'https://login.microsoftonline.com';
const _TEAMS_URL = 'https://teams.microsoft.com';
export class AuthProcedure { export class AuthProcedure {
private _page: Page; private _page: Page;
@ -28,91 +29,67 @@ export class AuthProcedure {
/** /**
* Authenticate with Microsoft using email + password. * 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 * @returns true if authentication was successful, false otherwise
*/ */
async authenticateWithMicrosoft(email: string, password: string, skipNavigation = false): Promise<boolean> { async authenticateWithMicrosoft(email: string, password: string, skipNavigation = false): Promise<boolean> {
try { try {
this._logger.info(`Authenticating as ${email}...`); this._logger.info(`Authenticating as ${email}...`);
if (skipNavigation) { 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 {
this._logger.info('Navigating to Microsoft login...'); this._logger.info('Navigating to Microsoft login...');
await this._page.goto(_LOGIN_URL, { await this._page.goto(_LOGIN_URL, {
waitUntil: 'domcontentloaded', waitUntil: 'domcontentloaded',
timeout: 30000, 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) // Step 1: Wait for email input field.
const emailInput = await this._page.waitForSelector( // Works for both the Teams inline modal and the Microsoft login portal.
'input[type="email"], input[name="loginfmt"]', const emailInput = await this._waitForEmailInput();
{ timeout: 20000 }
);
if (!emailInput) { if (!emailInput) {
this._logger.error('Could not find email input field');
return false; return false;
} }
// Enter email // Step 2: Enter email and click Next
await emailInput.fill(email); await emailInput.fill(email);
this._logger.info('Email entered'); this._logger.info('Email entered');
// Click Next const nextClicked = await this._clickNextButton();
const nextButton = await this._page.$('input[type="submit"], button[type="submit"]'); if (!nextClicked) {
if (nextButton) { this._logger.warn('Could not find Next button, pressing Enter');
await nextButton.click();
} else {
await this._page.keyboard.press('Enter'); await this._page.keyboard.press('Enter');
} }
await this._page.waitForTimeout(2000);
// Check for "account not found" error // Step 3: Wait for password input
const errorElement = await this._page.$('#usernameError, [data-tid="error"]'); const passwordInput = await this._waitForPasswordInput();
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) { if (!passwordInput) {
this._logger.error('Could not find password input field');
return false; return false;
} }
// Enter password // Step 4: Enter password and click Sign in
await passwordInput.fill(password); await passwordInput.fill(password);
this._logger.info('Password entered'); this._logger.info('Password entered');
// Click Sign in const signInClicked = await this._clickSignInButton();
const signInButton = await this._page.$('input[type="submit"], button[type="submit"]'); if (!signInClicked) {
if (signInButton) { this._logger.warn('Could not find Sign in button, pressing Enter');
await signInButton.click();
} else {
await this._page.keyboard.press('Enter'); await this._page.keyboard.press('Enter');
} }
await this._page.waitForTimeout(3000); await this._page.waitForTimeout(3000);
// Check for MFA prompt // Step 5: Check for MFA
const mfaDetected = await this._detectMfa(); const mfaDetected = await this._detectMfa();
if (mfaDetected) { 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; return false;
} }
// Check for password error // Step 6: Check for password error
const pwdError = await this._page.$('#passwordError, [data-tid="error"]'); const pwdError = await this._page.$('#passwordError, [data-tid="error"]');
if (pwdError) { if (pwdError) {
const errorText = await pwdError.textContent(); const errorText = await pwdError.textContent();
@ -120,38 +97,165 @@ export class AuthProcedure {
return false; return false;
} }
// Handle "Stay signed in?" prompt // Step 7: Handle "Stay signed in?" prompt
await this._handleStaySignedIn(); await this._handleStaySignedIn();
// Verify authentication succeeded by checking for Teams or Microsoft landing // Auth succeeded - the redirect chain will be handled by the caller
const isAuthenticated = await this._verifyAuthentication(); this._logger.info(`Successfully authenticated as ${email}`);
if (isAuthenticated) { return true;
this._logger.info(`Successfully authenticated as ${email}`);
} else {
this._logger.error('Authentication verification failed - may not be signed in');
}
return isAuthenticated;
} catch (error) { } 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}`); this._logger.error(`Authentication failed: ${error}`);
return false; 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<boolean> {
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<boolean> {
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). * Detect MFA prompts (authenticator app, SMS, phone call).
*/ */
private async _detectMfa(): Promise<boolean> { private async _detectMfa(): Promise<boolean> {
const mfaSelectors = [ const mfaSelectors = [
'#idDiv_SAOTCAS_Description', // Authenticator app prompt '#idDiv_SAOTCAS_Description',
'#idDiv_SAOTCC_Description', // Code entry '#idDiv_SAOTCC_Description',
'#idDiv_SAASDS_Description', // SMS verification '#idDiv_SAASDS_Description',
'[data-tid="phoneVerification"]', // Phone verification '[data-tid="phoneVerification"]',
'text=Approve sign in request', // Authenticator approval 'text=Approve sign in request',
'text=Enter code', // Code entry 'text=Enter code',
'text=Verify your identity', // Generic MFA 'text=Verify your identity',
'text=Approve sign-in request', // Authenticator 'text=Approve sign-in request',
]; ];
for (const selector of mfaSelectors) { for (const selector of mfaSelectors) {
@ -172,10 +276,9 @@ export class AuthProcedure {
*/ */
private async _handleStaySignedIn(): Promise<void> { private async _handleStaySignedIn(): Promise<void> {
try { try {
// Look for "Stay signed in?" or "Angemeldet bleiben?" prompt
const staySignedInSelectors = [ const staySignedInSelectors = [
'input#idSIButton9', // "Yes" button (Stay signed in) 'input#idSIButton9',
'button#idSIButton9', // Alternative 'button#idSIButton9',
'input[value="Yes"]', 'input[value="Yes"]',
'input[value="Ja"]', 'input[value="Ja"]',
'button:has-text("Yes")', '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'); this._logger.debug('No "Stay signed in" prompt detected');
} catch (error) { } catch (error) {
this._logger.debug(`Stay signed in handling: ${error}`); this._logger.debug(`Stay signed in handling: ${error}`);
@ -204,91 +306,26 @@ export class AuthProcedure {
} }
/** /**
* Verify that authentication was successful. * Log current page state for debugging when a selector is not found.
* 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<boolean> { private async _logPageState(context: string): Promise<void> {
try { 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(); const url = this._page.url();
const title = await this._page.title();
// If we're on Teams or Microsoft portal, we're authenticated const bodyText = await this._page.evaluate(() =>
if (url.includes('teams.microsoft.com') || document.body?.innerText?.substring(0, 300) || '(empty)'
url.includes('office.com') || );
url.includes('microsoftonline.com/common/oauth2') || const inputs = await this._page.evaluate(() => {
url.includes('myapps.microsoft.com')) { const allInputs = document.querySelectorAll('input');
return true; return Array.from(allInputs).map(i =>
} `${i.tagName}[type=${i.type}, name=${i.name}, placeholder=${i.placeholder}]`
).join(', ');
// Wait a bit and check again (redirects may be in progress) });
await this._page.waitForTimeout(3000); this._logger.warn(`Page state (${context}): URL=${url.substring(0, 80)}, Title=${title}`);
const finalUrl = this._page.url(); this._logger.warn(`Inputs on page: ${inputs.substring(0, 300)}`);
this._logger.warn(`Page text: ${bodyText.substring(0, 200)}`);
if (finalUrl.includes('teams.microsoft.com') || } catch (err) {
finalUrl.includes('office.com') || this._logger.warn(`Could not log page state: ${err}`);
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;
} }
} }
} }

View file

@ -200,11 +200,11 @@ export class BotOrchestrator {
} }
if (signInClicked) { if (signInClicked) {
// Clicking "Sign in" on the Teams pre-join page triggers a redirect chain // Clicking "Sign in" on the Teams pre-join page opens an INLINE LOGIN MODAL
// to login.microsoftonline.com WITH a return URL embedded by Teams. // directly on the same page (no URL change). The modal shows an email input,
// We must NOT navigate to login.microsoftonline.com ourselves - that would // then password, then "Stay signed in?" — all on the light-meetings page.
// destroy the return URL. Instead, pass skipNavigation=true and let // After completing login, Teams redirects to /v2/ with the "Join now" button.
// authenticateWithMicrosoft wait for the email input to appear. // We pass skipNavigation=true so authProcedure does NOT navigate away.
const { AuthProcedure } = await import('./authProcedure'); const { AuthProcedure } = await import('./authProcedure');
const authProcedure = new AuthProcedure(this._page!, this._logger); const authProcedure = new AuthProcedure(this._page!, this._logger);
const authSuccess = await authProcedure.authenticateWithMicrosoft( const authSuccess = await authProcedure.authenticateWithMicrosoft(
@ -216,57 +216,22 @@ export class BotOrchestrator {
if (authSuccess) { if (authSuccess) {
this._logger.info('Authentication via "Sign in" link succeeded'); this._logger.info('Authentication via "Sign in" link succeeded');
// After login, the redirect chain goes automatically: // After the inline login flow completes (email → password → stay signed in),
// login.microsoftonline.com → teams.microsoft.com/v2/?meetingjoin=true#/meet/... // Teams triggers a redirect chain that ends at teams.microsoft.com/v2/
// The return URL is embedded by Teams when clicking "Sign in". // with the authenticated pre-join page containing the "Join now" button.
// We just wait for the redirect to complete. // Simply wait for that button to appear — no manual navigation needed.
this._logger.info('Waiting for redirect chain to leave login.microsoftonline.com...'); 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"]'; const joinButtonSelector = '#prejoin-join-button, button[data-tid="prejoin-join-button"]';
try { try {
await this._page!.waitForSelector(joinButtonSelector, { timeout: 30000, state: 'visible' }); await this._page!.waitForSelector(joinButtonSelector, { timeout: 60000, state: 'visible' });
const finalUrl = this._page!.url(); const finalUrl = this._page!.url();
this._logger.info(`On authenticated pre-join page: ${finalUrl.substring(0, 100)}`); this._logger.info(`On authenticated pre-join page: ${finalUrl.substring(0, 100)}`);
} catch { } catch {
const finalUrl = this._page!.url(); const finalUrl = this._page!.url();
const pageContent = await this._page!.evaluate(() => document.body?.innerText?.substring(0, 300) || ''); 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)}`); 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 { } else {
this._logger.warn('Authentication via "Sign in" failed - continuing as anonymous'); this._logger.warn('Authentication via "Sign in" failed - continuing as anonymous');