295 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|