fix: use exact selectors for hybrid auth flow (Teams inline modal + MS login portal)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1720735035
commit
f7dec141fc
1 changed files with 170 additions and 115 deletions
|
|
@ -3,21 +3,21 @@ import { Logger } from 'winston';
|
|||
|
||||
/**
|
||||
* AuthProcedure - Handles Microsoft account authentication in the browser.
|
||||
*
|
||||
* The login flow when clicking "Sign in" on the Teams pre-join page is a HYBRID:
|
||||
*
|
||||
* 1. Teams shows an INLINE MODAL on the light-meetings page (no URL change):
|
||||
* - Email input: input#emailOrPhonerm (type="text", data-testid="emailInput")
|
||||
* - Next button: button[data-testid="authLoginDialogNextButton"] (starts disabled!)
|
||||
*
|
||||
* 2. After clicking Next, Teams REDIRECTS to login.microsoftonline.com:
|
||||
* - Password input: input#i0118 (name="passwd", type="password")
|
||||
* - 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.
|
||||
*
|
||||
* The HYBRID login flow (when clicking "Sign in" on Teams pre-join page):
|
||||
*
|
||||
* 1. Teams shows an INLINE MODAL on the light-meetings page (no URL change)
|
||||
* - Email input: input#emailOrPhonerm [data-testid="emailInput"]
|
||||
* - Next button: button[data-testid="authLoginDialogNextButton"] (enabled after email entered)
|
||||
*
|
||||
* 2. After clicking Next, Teams REDIRECTS to login.microsoftonline.com
|
||||
* - Password input: input#i0118 [name="passwd", type="password"]
|
||||
* - Sign in button: input#idSIButton9 [type="submit", value="Sign in"]
|
||||
*
|
||||
* 3. "Stay signed in?" prompt on login.microsoftonline.com
|
||||
* - Yes button: input#idSIButton9
|
||||
*
|
||||
* 4. Redirect chain → teams.microsoft.com/v2/ with "Join now" button
|
||||
*/
|
||||
|
||||
const _LOGIN_URL = 'https://login.microsoftonline.com';
|
||||
|
|
@ -31,6 +31,13 @@ export class AuthProcedure {
|
|||
this._logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Microsoft using email + password.
|
||||
*
|
||||
* @param skipNavigation - When true, handles the hybrid inline modal flow
|
||||
* (email on Teams page, then redirect to MS login for password).
|
||||
* @returns true if authentication was successful, false otherwise
|
||||
*/
|
||||
async authenticateWithMicrosoft(email: string, password: string, skipNavigation = false): Promise<boolean> {
|
||||
try {
|
||||
this._logger.info(`Authenticating as ${email}...`);
|
||||
|
|
@ -42,41 +49,50 @@ export class AuthProcedure {
|
|||
timeout: 30000,
|
||||
});
|
||||
} else {
|
||||
this._logger.info('Inline auth flow: waiting for Teams login modal...');
|
||||
this._logger.info('Hybrid auth flow: waiting for Teams inline email modal...');
|
||||
}
|
||||
|
||||
// Step 1: Wait for email input
|
||||
// Step 1: Enter email
|
||||
// In the hybrid flow, this is the Teams inline modal (input#emailOrPhonerm).
|
||||
// In the direct flow, this is the MS login portal (input[name="loginfmt"]).
|
||||
const emailInput = await this._waitForEmailInput(skipNavigation);
|
||||
if (!emailInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Enter email
|
||||
await emailInput.fill(email);
|
||||
this._logger.info('Email entered');
|
||||
|
||||
// Step 3: Click Next
|
||||
// Step 2: Click Next
|
||||
// In the hybrid flow: button[data-testid="authLoginDialogNextButton"]
|
||||
// The button is disabled until email is entered, so we wait for it to be enabled.
|
||||
// In the direct flow: input[type="submit"] on the MS login page.
|
||||
await this._clickNextButton(skipNavigation);
|
||||
|
||||
// Step 4: Wait for password input (on login.microsoftonline.com after redirect)
|
||||
// Step 3: Wait for password input
|
||||
// After clicking Next in the Teams modal, it REDIRECTS to login.microsoftonline.com.
|
||||
// The password field is on the MS login portal: input#i0118 [name="passwd"]
|
||||
// We need a longer timeout because the redirect takes time.
|
||||
const passwordInput = await this._waitForPasswordInput();
|
||||
if (!passwordInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 5: Enter password and click Sign in
|
||||
// Step 4: Enter password and click Sign in
|
||||
await passwordInput.fill(password);
|
||||
this._logger.info('Password entered');
|
||||
|
||||
await this._clickSignInButton();
|
||||
await this._page.waitForTimeout(3000);
|
||||
|
||||
// Step 6: Check for MFA
|
||||
if (await this._detectMfa()) {
|
||||
// Step 5: Check for MFA
|
||||
const mfaDetected = await this._detectMfa();
|
||||
if (mfaDetected) {
|
||||
this._logger.error('MFA prompt detected - cannot authenticate automatically.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 7: Check for password error
|
||||
// Step 6: Check for password error
|
||||
const pwdError = await this._page.$('#passwordError');
|
||||
if (pwdError) {
|
||||
const errorText = await pwdError.textContent();
|
||||
|
|
@ -84,7 +100,7 @@ export class AuthProcedure {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Step 8: Handle "Stay signed in?" prompt
|
||||
// Step 7: Handle "Stay signed in?" prompt
|
||||
await this._handleStaySignedIn();
|
||||
|
||||
this._logger.info(`Successfully authenticated as ${email}`);
|
||||
|
|
@ -94,7 +110,7 @@ export class AuthProcedure {
|
|||
const errorMessage = String(error);
|
||||
if (errorMessage.includes('Execution context was destroyed') ||
|
||||
errorMessage.includes('execution context')) {
|
||||
this._logger.info('Page navigated during auth (context destroyed) - treating as success');
|
||||
this._logger.info('Page navigated during auth (execution context destroyed) - treating as success');
|
||||
return true;
|
||||
}
|
||||
this._logger.error(`Authentication failed: ${error}`);
|
||||
|
|
@ -102,109 +118,136 @@ export class AuthProcedure {
|
|||
}
|
||||
}
|
||||
|
||||
private async _waitForEmailInput(isInlineModal: boolean) {
|
||||
/**
|
||||
* Wait for the email input field.
|
||||
*
|
||||
* Hybrid flow: Teams inline modal has input#emailOrPhonerm [data-testid="emailInput"]
|
||||
* Direct flow: MS login portal has input[name="loginfmt"]
|
||||
*/
|
||||
private async _waitForEmailInput(isInlineFlow: boolean) {
|
||||
this._logger.info('Waiting for email input field...');
|
||||
|
||||
if (isInlineModal) {
|
||||
try {
|
||||
const element = await this._page.waitForSelector(
|
||||
'input[data-testid="emailInput"], input#emailOrPhonerm',
|
||||
{ timeout: 20000, state: 'visible' }
|
||||
);
|
||||
if (element) {
|
||||
this._logger.info('Found Teams inline modal email input (data-testid="emailInput")');
|
||||
return element;
|
||||
}
|
||||
} catch {
|
||||
this._logger.warn('Teams inline modal email input not found');
|
||||
await this._logPageState('inline modal email input not found');
|
||||
}
|
||||
}
|
||||
// Primary selectors — exact IDs/attributes from the real HTML
|
||||
const selectors = isInlineFlow
|
||||
? [
|
||||
'input#emailOrPhonerm', // Teams inline modal (exact ID)
|
||||
'input[data-testid="emailInput"]', // Teams inline modal (data-testid)
|
||||
'input[placeholder="Enter your email"]', // Teams inline modal (placeholder)
|
||||
]
|
||||
: [
|
||||
'input[name="loginfmt"]', // MS login portal
|
||||
'input[type="email"]', // MS login portal fallback
|
||||
];
|
||||
|
||||
const combinedSelector = selectors.join(', ');
|
||||
|
||||
try {
|
||||
const element = await this._page.waitForSelector(
|
||||
'input[name="loginfmt"], input[type="email"]',
|
||||
{ timeout: 15000, state: 'visible' }
|
||||
const element = await this._page.waitForSelector(combinedSelector, {
|
||||
timeout: 30000,
|
||||
state: 'visible',
|
||||
});
|
||||
const matchedInfo = await element?.evaluate(el =>
|
||||
`${el.tagName}[id=${el.id}, type=${el.getAttribute('type')}, placeholder=${el.getAttribute('placeholder')}]`
|
||||
);
|
||||
if (element) {
|
||||
this._logger.info('Found MS login portal email input');
|
||||
return element;
|
||||
}
|
||||
this._logger.info(`Found email input: ${matchedInfo}`);
|
||||
return element;
|
||||
} catch {
|
||||
await this._logPageState('email input not found (all selectors failed)');
|
||||
await this._logPageState('email input not found');
|
||||
this._logger.error('Could not find email input field');
|
||||
return null;
|
||||
}
|
||||
|
||||
this._logger.error('Could not find email input field');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Next" button after entering email.
|
||||
*
|
||||
* Hybrid flow: button[data-testid="authLoginDialogNextButton"] — initially disabled,
|
||||
* becomes enabled after email is typed. We wait for it to be enabled.
|
||||
* Direct flow: input[type="submit"] on the MS login portal.
|
||||
*/
|
||||
private async _clickNextButton(isInlineFlow: boolean): Promise<void> {
|
||||
if (isInlineFlow) {
|
||||
// Teams inline modal: wait for the Next button to become enabled
|
||||
const nextSelector = 'button[data-testid="authLoginDialogNextButton"]';
|
||||
this._logger.info('Waiting for Teams Next button to become enabled...');
|
||||
try {
|
||||
await this._page.waitForSelector(`${nextSelector}:not([disabled])`, {
|
||||
timeout: 10000,
|
||||
state: 'visible',
|
||||
});
|
||||
await this._page.click(nextSelector);
|
||||
this._logger.info('Clicked Teams Next button');
|
||||
// After clicking Next, Teams redirects to login.microsoftonline.com
|
||||
// Wait for the redirect to begin
|
||||
await this._page.waitForTimeout(3000);
|
||||
} catch {
|
||||
this._logger.warn('Teams Next button not found or not enabled, pressing Enter');
|
||||
await this._page.keyboard.press('Enter');
|
||||
await this._page.waitForTimeout(3000);
|
||||
}
|
||||
} else {
|
||||
// MS login portal: click submit button
|
||||
const selectors = [
|
||||
'input[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
];
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
this._logger.warn('No Next button found, pressing Enter');
|
||||
await this._page.keyboard.press('Enter');
|
||||
await this._page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the password input field on login.microsoftonline.com.
|
||||
*
|
||||
* After entering email in the Teams inline modal and clicking Next,
|
||||
* Teams redirects to login.microsoftonline.com where the password field is:
|
||||
* input#i0118 [name="passwd", type="password"]
|
||||
*
|
||||
* Timeout is generous (30s) because the redirect can take time.
|
||||
*/
|
||||
private async _waitForPasswordInput() {
|
||||
this._logger.info('Waiting for password input field...');
|
||||
this._logger.info('Waiting for password input field (may involve redirect to login.microsoftonline.com)...');
|
||||
try {
|
||||
const element = await this._page.waitForSelector(
|
||||
'input[name="passwd"], input#i0118, input[type="password"]',
|
||||
{ timeout: 20000, state: 'visible' }
|
||||
'input#i0118, input[name="passwd"], input[type="password"]',
|
||||
{ timeout: 30000, 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;
|
||||
}
|
||||
this._logger.error('Could not find password input field');
|
||||
return null;
|
||||
}
|
||||
|
||||
private async _clickNextButton(isInlineModal: boolean): Promise<void> {
|
||||
if (isInlineModal) {
|
||||
const teamsNextSelector = 'button[data-testid="authLoginDialogNextButton"]';
|
||||
try {
|
||||
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 = [
|
||||
'input[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
'button:has-text("Next")',
|
||||
'button:has-text("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;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
this._logger.warn('No Next button found, pressing Enter');
|
||||
await this._page.keyboard.press('Enter');
|
||||
await this._page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Sign in" button on login.microsoftonline.com.
|
||||
*
|
||||
* The button is: input#idSIButton9 [type="submit", value="Sign in"]
|
||||
*/
|
||||
private async _clickSignInButton(): Promise<void> {
|
||||
const selectors = [
|
||||
'input#idSIButton9',
|
||||
'input[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
'button:has-text("Sign in")',
|
||||
'button:has-text("Anmelden")',
|
||||
'input#idSIButton9', // MS login portal (exact ID)
|
||||
'input[type="submit"][value="Sign in"]', // MS login portal (value)
|
||||
'input[type="submit"][value="Anmelden"]', // MS login portal (German)
|
||||
'input[type="submit"]', // Generic fallback
|
||||
'button[type="submit"]', // Alternative
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
|
|
@ -219,11 +262,13 @@ export class AuthProcedure {
|
|||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
const mfaSelectors = [
|
||||
'#idDiv_SAOTCAS_Description',
|
||||
|
|
@ -233,11 +278,15 @@ export class AuthProcedure {
|
|||
'text=Approve sign in request',
|
||||
'text=Enter code',
|
||||
'text=Verify your identity',
|
||||
'text=Approve sign-in request',
|
||||
];
|
||||
|
||||
for (const selector of mfaSelectors) {
|
||||
try {
|
||||
if (await this._page.$(selector)) return true;
|
||||
const element = await this._page.$(selector);
|
||||
if (element) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
|
|
@ -245,9 +294,13 @@ export class AuthProcedure {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the "Stay signed in?" prompt.
|
||||
* Button: input#idSIButton9 [value="Yes"]
|
||||
*/
|
||||
private async _handleStaySignedIn(): Promise<void> {
|
||||
try {
|
||||
const selectors = [
|
||||
const staySignedInSelectors = [
|
||||
'input#idSIButton9',
|
||||
'button#idSIButton9',
|
||||
'input[value="Yes"]',
|
||||
|
|
@ -256,7 +309,7 @@ export class AuthProcedure {
|
|||
'button:has-text("Ja")',
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
for (const selector of staySignedInSelectors) {
|
||||
try {
|
||||
const button = await this._page.$(selector);
|
||||
if (button) {
|
||||
|
|
@ -275,19 +328,21 @@ export class AuthProcedure {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log current page state for debugging.
|
||||
*/
|
||||
private async _logPageState(context: string): Promise<void> {
|
||||
try {
|
||||
const url = this._page.url();
|
||||
const title = await this._page.title();
|
||||
const inputs = await this._page.evaluate(() => {
|
||||
const allInputs = document.querySelectorAll('input');
|
||||
return Array.from(allInputs).map(i => {
|
||||
const tid = i.getAttribute('data-testid') || '';
|
||||
return `[type=${i.type},name=${i.name},id=${i.id},ph=${i.placeholder},tid=${tid}]`;
|
||||
}).join(', ');
|
||||
return Array.from(allInputs).map(i =>
|
||||
`${i.tagName}[id=${i.id}, type=${i.type}, name=${i.name}, placeholder=${i.placeholder}]`
|
||||
).join(', ');
|
||||
});
|
||||
this._logger.warn(`Page state (${context}): URL=${url.substring(0, 100)}, Title=${title}`);
|
||||
this._logger.warn(`Inputs: ${inputs.substring(0, 400)}`);
|
||||
this._logger.warn(`Inputs on page: ${inputs.substring(0, 500)}`);
|
||||
} catch (err) {
|
||||
this._logger.warn(`Could not log page state: ${err}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue