service-teams-browser-bot/src/bot/authProcedure.ts
2026-02-16 14:01:52 +01:00

295 lines
10 KiB
TypeScript

import { Page } from 'playwright';
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.
*
* 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
*/
const _LOGIN_URL = 'https://login.microsoftonline.com';
const _TEAMS_URL = 'https://teams.microsoft.com';
export class AuthProcedure {
private _page: Page;
private _logger: Logger;
constructor(page: Page, logger: Logger) {
this._page = page;
this._logger = logger;
}
/**
* Authenticate with Microsoft using email + password.
* Navigates to Microsoft login, enters credentials, and waits for successful sign-in.
*
* @returns true if authentication was successful, false otherwise
*/
async authenticateWithMicrosoft(email: string, password: string): Promise<boolean> {
try {
this._logger.info(`Authenticating as ${email}...`);
// Only navigate to login page if not already there.
// When called after clicking "Sign in" on Teams pre-join page,
// the browser is already on login.microsoftonline.com WITH a return URL
// embedded by Teams. Navigating again would destroy that return URL!
const currentUrl = this._page.url();
if (!currentUrl.includes('login.microsoftonline.com')) {
this._logger.info('Not on login page yet, navigating to Microsoft login...');
await this._page.goto(_LOGIN_URL, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
} else {
this._logger.info('Already on Microsoft login page - skipping navigation to preserve return URL');
await this._page.waitForLoadState('domcontentloaded', { timeout: 15000 });
}
// Wait for email input
const emailInput = await this._page.waitForSelector(
'input[type="email"], input[name="loginfmt"]',
{ timeout: 10000 }
);
if (!emailInput) {
this._logger.error('Could not find email input field');
return false;
}
// Enter email
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 {
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 }
);
if (!passwordInput) {
this._logger.error('Could not find password input field');
return false;
}
// Enter password
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 {
await this._page.keyboard.press('Enter');
}
await this._page.waitForTimeout(3000);
// Check for MFA prompt
const mfaDetected = await this._detectMfa();
if (mfaDetected) {
this._logger.error('MFA prompt detected - cannot authenticate automatically. Please disable MFA for the bot account.');
return false;
}
// Check for password error
const pwdError = await this._page.$('#passwordError, [data-tid="error"]');
if (pwdError) {
const errorText = await pwdError.textContent();
this._logger.error(`Login error after password: ${errorText}`);
return false;
}
// 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;
} catch (error) {
this._logger.error(`Authentication failed: ${error}`);
return false;
}
}
/**
* Detect MFA prompts (authenticator app, SMS, phone call).
*/
private async _detectMfa(): Promise<boolean> {
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
];
for (const selector of mfaSelectors) {
try {
const element = await this._page.$(selector);
if (element) {
return true;
}
} catch {
// Continue checking
}
}
return false;
}
/**
* Handle the "Stay signed in?" prompt after successful login.
*/
private async _handleStaySignedIn(): Promise<void> {
try {
// Look for "Stay signed in?" or "Angemeldet bleiben?" prompt
const staySignedInSelectors = [
'input#idSIButton9', // "Yes" button (Stay signed in)
'button#idSIButton9', // Alternative
'input[value="Yes"]',
'input[value="Ja"]',
'button:has-text("Yes")',
'button:has-text("Ja")',
];
for (const selector of staySignedInSelectors) {
try {
const button = await this._page.$(selector);
if (button) {
await button.click();
this._logger.info('Clicked "Stay signed in" - Yes');
await this._page.waitForTimeout(2000);
return;
}
} catch {
// Continue
}
}
// 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}`);
}
}
/**
* 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).
*/
private async _verifyAuthentication(): Promise<boolean> {
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;
}
}
}