fix: use exact Teams inline modal selectors (emailInput, authLoginDialogNextButton) for hybrid auth flow
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8597f0eb5a
commit
1720735035
1 changed files with 95 additions and 131 deletions
|
|
@ -4,16 +4,20 @@ import { Logger } from 'winston';
|
||||||
/**
|
/**
|
||||||
* AuthProcedure - Handles Microsoft account authentication in the browser.
|
* AuthProcedure - Handles Microsoft account authentication in the browser.
|
||||||
*
|
*
|
||||||
* Supports TWO login flows:
|
* The login flow when clicking "Sign in" on the Teams pre-join page is a HYBRID:
|
||||||
*
|
*
|
||||||
* 1. **Inline modal flow** (when clicking "Sign in" on Teams pre-join page):
|
* 1. Teams shows an INLINE MODAL on the light-meetings page (no URL change):
|
||||||
* - An inline modal appears on the same Teams page (no URL change)
|
* - Email input: input#emailOrPhonerm (type="text", data-testid="emailInput")
|
||||||
* - Email input → Next → Password input → Sign in → Stay signed in? → Yes
|
* - Next button: button[data-testid="authLoginDialogNextButton"] (starts disabled!)
|
||||||
* - Then redirect chain to teams.microsoft.com/v2/ with "Join now"
|
|
||||||
*
|
*
|
||||||
* 2. **Direct navigation flow** (standalone login):
|
* 2. After clicking Next, Teams REDIRECTS to login.microsoftonline.com:
|
||||||
* - Navigate to login.microsoftonline.com
|
* - Password input: input#i0118 (name="passwd", type="password")
|
||||||
* - Standard Microsoft login form
|
* - 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const _LOGIN_URL = 'https://login.microsoftonline.com';
|
const _LOGIN_URL = 'https://login.microsoftonline.com';
|
||||||
|
|
@ -27,13 +31,6 @@ export class AuthProcedure {
|
||||||
this._logger = logger;
|
this._logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate with Microsoft using email + password.
|
|
||||||
*
|
|
||||||
* @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<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}...`);
|
||||||
|
|
@ -45,71 +42,59 @@ export class AuthProcedure {
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this._logger.info('Inline auth flow: waiting for login form to appear on current page...');
|
this._logger.info('Inline auth flow: waiting for Teams login modal...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Wait for email input field.
|
// Step 1: Wait for email input
|
||||||
// Works for both the Teams inline modal and the Microsoft login portal.
|
const emailInput = await this._waitForEmailInput(skipNavigation);
|
||||||
const emailInput = await this._waitForEmailInput();
|
|
||||||
if (!emailInput) {
|
if (!emailInput) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Enter email and click Next
|
// Step 2: Enter email
|
||||||
await emailInput.fill(email);
|
await emailInput.fill(email);
|
||||||
this._logger.info('Email entered');
|
this._logger.info('Email entered');
|
||||||
|
|
||||||
const nextClicked = await this._clickNextButton();
|
// Step 3: Click Next
|
||||||
if (!nextClicked) {
|
await this._clickNextButton(skipNavigation);
|
||||||
this._logger.warn('Could not find Next button, pressing Enter');
|
|
||||||
await this._page.keyboard.press('Enter');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Wait for password input
|
// Step 4: Wait for password input (on login.microsoftonline.com after redirect)
|
||||||
const passwordInput = await this._waitForPasswordInput();
|
const passwordInput = await this._waitForPasswordInput();
|
||||||
if (!passwordInput) {
|
if (!passwordInput) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Enter password and click Sign in
|
// Step 5: Enter password and click Sign in
|
||||||
await passwordInput.fill(password);
|
await passwordInput.fill(password);
|
||||||
this._logger.info('Password entered');
|
this._logger.info('Password entered');
|
||||||
|
await this._clickSignInButton();
|
||||||
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);
|
await this._page.waitForTimeout(3000);
|
||||||
|
|
||||||
// Step 5: Check for MFA
|
// Step 6: Check for MFA
|
||||||
const mfaDetected = await this._detectMfa();
|
if (await this._detectMfa()) {
|
||||||
if (mfaDetected) {
|
|
||||||
this._logger.error('MFA prompt detected - cannot authenticate automatically.');
|
this._logger.error('MFA prompt detected - cannot authenticate automatically.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Check for password error
|
// Step 7: Check for password error
|
||||||
const pwdError = await this._page.$('#passwordError, [data-tid="error"]');
|
const pwdError = await this._page.$('#passwordError');
|
||||||
if (pwdError) {
|
if (pwdError) {
|
||||||
const errorText = await pwdError.textContent();
|
const errorText = await pwdError.textContent();
|
||||||
this._logger.error(`Login error after password: ${errorText}`);
|
this._logger.error(`Login error after password: ${errorText}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 7: Handle "Stay signed in?" prompt
|
// Step 8: Handle "Stay signed in?" prompt
|
||||||
await this._handleStaySignedIn();
|
await this._handleStaySignedIn();
|
||||||
|
|
||||||
// Auth succeeded - the redirect chain will be handled by the caller
|
|
||||||
this._logger.info(`Successfully authenticated as ${email}`);
|
this._logger.info(`Successfully authenticated as ${email}`);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = String(error);
|
const errorMessage = String(error);
|
||||||
// "Execution context was destroyed" means page navigated (login redirect)
|
|
||||||
if (errorMessage.includes('Execution context was destroyed') ||
|
if (errorMessage.includes('Execution context was destroyed') ||
|
||||||
errorMessage.includes('execution context')) {
|
errorMessage.includes('execution context')) {
|
||||||
this._logger.info('Page navigated during auth (execution context destroyed) - treating as success');
|
this._logger.info('Page navigated during auth (context destroyed) - treating as success');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this._logger.error(`Authentication failed: ${error}`);
|
this._logger.error(`Authentication failed: ${error}`);
|
||||||
|
|
@ -117,62 +102,48 @@ export class AuthProcedure {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async _waitForEmailInput(isInlineModal: boolean) {
|
||||||
* 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...');
|
this._logger.info('Waiting for email input field...');
|
||||||
try {
|
|
||||||
const element = await this._page.waitForSelector(combinedSelector, {
|
if (isInlineModal) {
|
||||||
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 {
|
try {
|
||||||
const fallback = await this._page.waitForSelector(
|
const element = await this._page.waitForSelector(
|
||||||
'input[type="text"]:visible, input:not([type]):visible',
|
'input[data-testid="emailInput"], input#emailOrPhonerm',
|
||||||
{ timeout: 10000, state: 'visible' }
|
{ timeout: 20000, state: 'visible' }
|
||||||
);
|
);
|
||||||
if (fallback) {
|
if (element) {
|
||||||
const url = this._page.url();
|
this._logger.info('Found Teams inline modal email input (data-testid="emailInput")');
|
||||||
this._logger.info(`Found fallback text input on URL: ${url.substring(0, 80)}`);
|
return element;
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Log page state for debugging
|
this._logger.warn('Teams inline modal email input not found');
|
||||||
await this._logPageState('email input not found');
|
await this._logPageState('inline modal email input not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const element = await this._page.waitForSelector(
|
||||||
|
'input[name="loginfmt"], input[type="email"]',
|
||||||
|
{ timeout: 15000, state: 'visible' }
|
||||||
|
);
|
||||||
|
if (element) {
|
||||||
|
this._logger.info('Found MS login portal email input');
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await this._logPageState('email input not found (all selectors failed)');
|
||||||
|
}
|
||||||
|
|
||||||
this._logger.error('Could not find email input field');
|
this._logger.error('Could not find email input field');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for the password input field to appear.
|
|
||||||
*/
|
|
||||||
private async _waitForPasswordInput() {
|
private async _waitForPasswordInput() {
|
||||||
this._logger.info('Waiting for password input field...');
|
this._logger.info('Waiting for password input field...');
|
||||||
try {
|
try {
|
||||||
const element = await this._page.waitForSelector(
|
const element = await this._page.waitForSelector(
|
||||||
'input[type="password"], input[name="passwd"], input[name="password"]',
|
'input[name="passwd"], input#i0118, input[type="password"]',
|
||||||
{ timeout: 15000, state: 'visible' }
|
{ timeout: 20000, state: 'visible' }
|
||||||
);
|
);
|
||||||
const url = this._page.url();
|
const url = this._page.url();
|
||||||
this._logger.info(`Found password input on URL: ${url.substring(0, 80)}`);
|
this._logger.info(`Found password input on URL: ${url.substring(0, 80)}`);
|
||||||
|
|
@ -184,18 +155,28 @@ export class AuthProcedure {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async _clickNextButton(isInlineModal: boolean): Promise<void> {
|
||||||
* Click the "Next" button after entering email.
|
if (isInlineModal) {
|
||||||
* Works on both Teams inline modal and Microsoft login portal.
|
const teamsNextSelector = 'button[data-testid="authLoginDialogNextButton"]';
|
||||||
*/
|
try {
|
||||||
private async _clickNextButton(): Promise<boolean> {
|
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 = [
|
const selectors = [
|
||||||
'input[type="submit"]',
|
'input[type="submit"]',
|
||||||
'button[type="submit"]',
|
'button[type="submit"]',
|
||||||
'button:has-text("Next")',
|
'button:has-text("Next")',
|
||||||
'button:has-text("Weiter")',
|
'button:has-text("Weiter")',
|
||||||
'input[value="Next"]',
|
|
||||||
'input[value="Weiter"]',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of selectors) {
|
for (const selector of selectors) {
|
||||||
|
|
@ -205,27 +186,25 @@ export class AuthProcedure {
|
||||||
await button.click();
|
await button.click();
|
||||||
this._logger.info(`Clicked Next: ${selector}`);
|
this._logger.info(`Clicked Next: ${selector}`);
|
||||||
await this._page.waitForTimeout(2000);
|
await this._page.waitForTimeout(2000);
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Continue
|
// Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
this._logger.warn('No Next button found, pressing Enter');
|
||||||
|
await this._page.keyboard.press('Enter');
|
||||||
|
await this._page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async _clickSignInButton(): Promise<void> {
|
||||||
* 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 = [
|
const selectors = [
|
||||||
|
'input#idSIButton9',
|
||||||
'input[type="submit"]',
|
'input[type="submit"]',
|
||||||
'button[type="submit"]',
|
'button[type="submit"]',
|
||||||
'button:has-text("Sign in")',
|
'button:has-text("Sign in")',
|
||||||
'button:has-text("Anmelden")',
|
'button:has-text("Anmelden")',
|
||||||
'input[value="Sign in"]',
|
|
||||||
'input[value="Anmelden"]',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of selectors) {
|
for (const selector of selectors) {
|
||||||
|
|
@ -234,18 +213,17 @@ export class AuthProcedure {
|
||||||
if (button && await button.isVisible()) {
|
if (button && await button.isVisible()) {
|
||||||
await button.click();
|
await button.click();
|
||||||
this._logger.info(`Clicked Sign in: ${selector}`);
|
this._logger.info(`Clicked Sign in: ${selector}`);
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Continue
|
// Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
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<boolean> {
|
private async _detectMfa(): Promise<boolean> {
|
||||||
const mfaSelectors = [
|
const mfaSelectors = [
|
||||||
'#idDiv_SAOTCAS_Description',
|
'#idDiv_SAOTCAS_Description',
|
||||||
|
|
@ -255,28 +233,21 @@ export class AuthProcedure {
|
||||||
'text=Approve sign in request',
|
'text=Approve sign in request',
|
||||||
'text=Enter code',
|
'text=Enter code',
|
||||||
'text=Verify your identity',
|
'text=Verify your identity',
|
||||||
'text=Approve sign-in request',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of mfaSelectors) {
|
for (const selector of mfaSelectors) {
|
||||||
try {
|
try {
|
||||||
const element = await this._page.$(selector);
|
if (await this._page.$(selector)) return true;
|
||||||
if (element) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Continue checking
|
// Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the "Stay signed in?" prompt after successful login.
|
|
||||||
*/
|
|
||||||
private async _handleStaySignedIn(): Promise<void> {
|
private async _handleStaySignedIn(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const staySignedInSelectors = [
|
const selectors = [
|
||||||
'input#idSIButton9',
|
'input#idSIButton9',
|
||||||
'button#idSIButton9',
|
'button#idSIButton9',
|
||||||
'input[value="Yes"]',
|
'input[value="Yes"]',
|
||||||
|
|
@ -285,7 +256,7 @@ export class AuthProcedure {
|
||||||
'button:has-text("Ja")',
|
'button:has-text("Ja")',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const selector of staySignedInSelectors) {
|
for (const selector of selectors) {
|
||||||
try {
|
try {
|
||||||
const button = await this._page.$(selector);
|
const button = await this._page.$(selector);
|
||||||
if (button) {
|
if (button) {
|
||||||
|
|
@ -298,32 +269,25 @@ export class AuthProcedure {
|
||||||
// Continue
|
// Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Log current page state for debugging when a selector is not found.
|
|
||||||
*/
|
|
||||||
private async _logPageState(context: string): Promise<void> {
|
private async _logPageState(context: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const url = this._page.url();
|
const url = this._page.url();
|
||||||
const title = await this._page.title();
|
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 inputs = await this._page.evaluate(() => {
|
||||||
const allInputs = document.querySelectorAll('input');
|
const allInputs = document.querySelectorAll('input');
|
||||||
return Array.from(allInputs).map(i =>
|
return Array.from(allInputs).map(i => {
|
||||||
`${i.tagName}[type=${i.type}, name=${i.name}, placeholder=${i.placeholder}]`
|
const tid = i.getAttribute('data-testid') || '';
|
||||||
).join(', ');
|
return `[type=${i.type},name=${i.name},id=${i.id},ph=${i.placeholder},tid=${tid}]`;
|
||||||
|
}).join(', ');
|
||||||
});
|
});
|
||||||
this._logger.warn(`Page state (${context}): URL=${url.substring(0, 80)}, Title=${title}`);
|
this._logger.warn(`Page state (${context}): URL=${url.substring(0, 100)}, Title=${title}`);
|
||||||
this._logger.warn(`Inputs on page: ${inputs.substring(0, 300)}`);
|
this._logger.warn(`Inputs: ${inputs.substring(0, 400)}`);
|
||||||
this._logger.warn(`Page text: ${bodyText.substring(0, 200)}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._logger.warn(`Could not log page state: ${err}`);
|
this._logger.warn(`Could not log page state: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue