feat(teamsbot): MFA detection scraping and relay in auth flow
Made-with: Cursor
This commit is contained in:
parent
dbcc53bed8
commit
49d0c26f00
3 changed files with 264 additions and 23 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { Page } from 'playwright';
|
||||
import { Logger } from 'winston';
|
||||
import { MfaChallengeType } from '../types';
|
||||
|
||||
/**
|
||||
* AuthProcedure - Handles Microsoft account authentication in the browser.
|
||||
|
|
@ -22,15 +23,28 @@ import { Logger } from 'winston';
|
|||
|
||||
const _LOGIN_URL = 'https://login.microsoftonline.com';
|
||||
|
||||
export interface MfaChallenge {
|
||||
type: MfaChallengeType;
|
||||
displayNumber?: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export type MfaResponseCallback = (challenge: MfaChallenge) => Promise<{ action: string; code?: string }>;
|
||||
|
||||
export class AuthProcedure {
|
||||
private _page: Page;
|
||||
private _logger: Logger;
|
||||
private _onMfaChallenge: MfaResponseCallback | null = null;
|
||||
|
||||
constructor(page: Page, logger: Logger) {
|
||||
this._page = page;
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
setMfaCallback(callback: MfaResponseCallback): void {
|
||||
this._onMfaChallenge = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Microsoft using email + password.
|
||||
*
|
||||
|
|
@ -85,11 +99,15 @@ export class AuthProcedure {
|
|||
await this._clickSignInButton();
|
||||
await this._page.waitForTimeout(3000);
|
||||
|
||||
// Step 5: Check for MFA
|
||||
const mfaDetected = await this._detectMfa();
|
||||
if (mfaDetected) {
|
||||
this._logger.error('MFA prompt detected - cannot authenticate automatically.');
|
||||
return false;
|
||||
// Step 5: Check for MFA or password error
|
||||
const mfaChallenge = await this._detectMfaChallenge();
|
||||
if (mfaChallenge) {
|
||||
this._logger.info(`MFA detected: type=${mfaChallenge.type}, number=${mfaChallenge.displayNumber || 'N/A'}`);
|
||||
const mfaSuccess = await this._handleMfaChallenge(mfaChallenge);
|
||||
if (!mfaSuccess) {
|
||||
this._logger.error('MFA authentication failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Check for password error
|
||||
|
|
@ -249,30 +267,185 @@ export class AuthProcedure {
|
|||
}
|
||||
|
||||
/**
|
||||
* Detect MFA prompts (authenticator app, SMS, phone call).
|
||||
* Detect the specific MFA challenge type and scrape display info.
|
||||
* Returns null if no MFA is detected.
|
||||
*/
|
||||
private async _detectMfa(): Promise<boolean> {
|
||||
const mfaSelectors = [
|
||||
'#idDiv_SAOTCAS_Description',
|
||||
'#idDiv_SAOTCC_Description',
|
||||
'#idDiv_SAASDS_Description',
|
||||
'[data-tid="phoneVerification"]',
|
||||
private async _detectMfaChallenge(): Promise<MfaChallenge | null> {
|
||||
// Number matching (Authenticator app shows a number to enter)
|
||||
try {
|
||||
const numberEl = await this._page.$('#idRichContext_DisplaySign');
|
||||
if (numberEl) {
|
||||
const displayNumber = (await numberEl.textContent())?.trim() || '';
|
||||
const promptEl = await this._page.$('#idDiv_SAOTCAS_Description');
|
||||
const prompt = promptEl ? ((await promptEl.textContent())?.trim() || '') : '';
|
||||
return { type: 'numberMatch', displayNumber, prompt: prompt || 'Enter this number in your Authenticator app' };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Push approval (Authenticator app notification)
|
||||
try {
|
||||
const pushEl = await this._page.$('#idDiv_SAOTCAS_Description');
|
||||
if (pushEl) {
|
||||
const prompt = (await pushEl.textContent())?.trim() || '';
|
||||
return { type: 'pushApproval', prompt: prompt || 'Approve the sign-in request on your phone' };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
|
||||
// SMS/TOTP code entry
|
||||
try {
|
||||
const codeEl = await this._page.$('#idDiv_SAOTCC_Description');
|
||||
if (codeEl) {
|
||||
const prompt = (await codeEl.textContent())?.trim() || '';
|
||||
return { type: 'totpCode', prompt: prompt || 'Enter the code from your authenticator app or SMS' };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
|
||||
// SMS verify
|
||||
try {
|
||||
const smsEl = await this._page.$('#idDiv_SAASDS_Description');
|
||||
if (smsEl) {
|
||||
const prompt = (await smsEl.textContent())?.trim() || '';
|
||||
return { type: 'smsCode', prompt: prompt || 'Enter the verification code sent to your phone' };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Generic fallback detection
|
||||
const genericSelectors = [
|
||||
'text=Approve sign in request',
|
||||
'text=Enter code',
|
||||
'text=Verify your identity',
|
||||
'text=Approve sign-in request',
|
||||
'text=Anmeldeanforderung genehmigen',
|
||||
'text=Code eingeben',
|
||||
'[data-tid="phoneVerification"]',
|
||||
];
|
||||
for (const selector of genericSelectors) {
|
||||
try {
|
||||
const el = await this._page.$(selector);
|
||||
if (el) {
|
||||
const text = (await el.textContent())?.trim() || '';
|
||||
return { type: 'unknown', prompt: text || 'MFA verification required' };
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MFA challenge: relay to Gateway (via callback) and wait for resolution.
|
||||
* For code-based MFA: enter the code on the page.
|
||||
* For push/number-match: wait for MS to redirect after user approves on phone.
|
||||
*/
|
||||
private async _handleMfaChallenge(challenge: MfaChallenge): Promise<boolean> {
|
||||
if (!this._onMfaChallenge) {
|
||||
this._logger.error('MFA detected but no callback registered - cannot relay to user');
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await this._onMfaChallenge(challenge);
|
||||
if (response.action === 'timeout') {
|
||||
this._logger.warn('MFA timed out - no user response');
|
||||
return false;
|
||||
}
|
||||
|
||||
const needsCodeEntry = challenge.type === 'totpCode' || challenge.type === 'smsCode' ||
|
||||
(challenge.type === 'unknown' && response.action === 'code' && response.code);
|
||||
|
||||
if (needsCodeEntry && response.code) {
|
||||
return this._enterMfaCode(response.code);
|
||||
}
|
||||
|
||||
// For push/numberMatch: user confirms externally, MS redirects automatically
|
||||
return this._waitForMfaResolution();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a TOTP/SMS code into the MFA code input field.
|
||||
*/
|
||||
private async _enterMfaCode(code: string): Promise<boolean> {
|
||||
const codeInputSelectors = [
|
||||
'input#idTxtBx_SAOTCC_OTC',
|
||||
'input[name="otc"]',
|
||||
'input[type="tel"][autocomplete="one-time-code"]',
|
||||
'input[data-testid="idTxtBx_SAOTCC_OTC"]',
|
||||
];
|
||||
|
||||
for (const selector of mfaSelectors) {
|
||||
for (const selector of codeInputSelectors) {
|
||||
try {
|
||||
const element = await this._page.$(selector);
|
||||
if (element) {
|
||||
const input = await this._page.$(selector);
|
||||
if (input && await input.isVisible()) {
|
||||
await input.fill(code);
|
||||
this._logger.info('MFA code entered');
|
||||
|
||||
const verifySelectors = [
|
||||
'input#idSubmit_SAOTCC_Continue',
|
||||
'input[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
];
|
||||
for (const btnSel of verifySelectors) {
|
||||
try {
|
||||
const btn = await this._page.$(btnSel);
|
||||
if (btn && await btn.isVisible()) {
|
||||
await btn.click();
|
||||
this._logger.info('MFA verify button clicked');
|
||||
break;
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
|
||||
return this._waitForMfaResolution();
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
|
||||
this._logger.error('Could not find MFA code input field');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for MFA resolution: MS login page redirects away after successful approval.
|
||||
* Polls URL changes and checks for "Stay signed in?" or Teams redirect.
|
||||
*/
|
||||
private async _waitForMfaResolution(): Promise<boolean> {
|
||||
const startUrl = this._page.url();
|
||||
const maxWaitMs = 120000;
|
||||
const pollMs = 2000;
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
|
||||
this._logger.info('Waiting for MFA resolution...');
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await this._page.waitForTimeout(pollMs);
|
||||
const currentUrl = this._page.url();
|
||||
|
||||
// URL changed = MFA resolved
|
||||
if (currentUrl !== startUrl) {
|
||||
this._logger.info(`MFA resolved: URL changed to ${currentUrl.substring(0, 100)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for "Stay signed in?" prompt (means MFA passed)
|
||||
try {
|
||||
const staySignedIn = await this._page.$('input#idSIButton9, input[value="Yes"], input[value="Ja"]');
|
||||
if (staySignedIn && await staySignedIn.isVisible()) {
|
||||
this._logger.info('MFA resolved: "Stay signed in?" prompt detected');
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
} catch { /* continue polling */ }
|
||||
|
||||
// Check for error on MFA page
|
||||
try {
|
||||
const errorEl = await this._page.$('#idDiv_SAOTCAS_ErrorMsg, .alert-error, [role="alert"]');
|
||||
if (errorEl) {
|
||||
const errorText = (await errorEl.textContent())?.trim() || 'MFA error';
|
||||
this._logger.error(`MFA error detected: ${errorText}`);
|
||||
return false;
|
||||
}
|
||||
} catch { /* continue polling */ }
|
||||
}
|
||||
|
||||
this._logger.error('MFA resolution timeout (120s)');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import WebSocket from 'ws';
|
|||
|
||||
import { config } from '../config';
|
||||
import { createSessionLogger } from '../utils/logger';
|
||||
import { BotSession, BotState, TranscriptEntry, StatusMessage, TranscriptMessage, PlayAudioMessage, ChatMessage, SendChatMessage, AudioChunkMessage, TtsPlaybackAckMessage } from '../types';
|
||||
import { BotSession, BotState, TranscriptEntry, StatusMessage, TranscriptMessage, PlayAudioMessage, ChatMessage, SendChatMessage, AudioChunkMessage, TtsPlaybackAckMessage, MfaResponseMessage } from '../types';
|
||||
import { JoinProcedure } from './joinProcedure';
|
||||
import { CaptionsProcedure } from './captionsProcedure';
|
||||
import { AudioProcedure } from './audioProcedure';
|
||||
import { AudioCaptureProcedure } from './audioCaptureProcedure';
|
||||
import { ChatProcedure, ChatMessageEntry } from './chatProcedure';
|
||||
import { AuthProcedure } from './authProcedure';
|
||||
import { AuthProcedure, MfaChallenge } from './authProcedure';
|
||||
import { TeamsActionsService } from './teamsActionsService';
|
||||
import { isValidMeetingUrl, getMeetingLaunchUrl, resolveLaunchUrl } from './meetingUrlParser';
|
||||
|
||||
|
|
@ -74,6 +74,7 @@ export class BotOrchestrator {
|
|||
private _keepAliveInterval: NodeJS.Timeout | null = null;
|
||||
private _chatMessageQueue: string[] = [];
|
||||
private _chatQueueProcessing: boolean = false;
|
||||
private _mfaResolver: ((response: { action: string; code?: string }) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
sessionId: string,
|
||||
|
|
@ -266,19 +267,50 @@ export class BotOrchestrator {
|
|||
await this._takeScreenshot('step1-no-login-page', this._isDebugMode);
|
||||
}
|
||||
|
||||
// STEP 2: Microsoft Authentication
|
||||
// STEP 2: Microsoft Authentication (with MFA relay)
|
||||
this._logger.info(`STEP 2: authenticating as ${this._options.botAccountEmail}`);
|
||||
const authProcedure = new AuthProcedure(this._page!, this._logger);
|
||||
|
||||
authProcedure.setMfaCallback(async (challenge: MfaChallenge) => {
|
||||
this._logger.info(`MFA callback fired: type=${challenge.type}, number=${challenge.displayNumber || 'N/A'}`);
|
||||
this._sendToGateway({
|
||||
type: 'mfaChallenge',
|
||||
sessionId: this._sessionId,
|
||||
mfa: {
|
||||
type: challenge.type,
|
||||
displayNumber: challenge.displayNumber,
|
||||
prompt: challenge.prompt,
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise<{ action: string; code?: string }>((resolve) => {
|
||||
this._mfaResolver = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const authSuccess = await authProcedure.authenticateWithMicrosoft(
|
||||
this._options.botAccountEmail!,
|
||||
this._options.botAccountPassword!,
|
||||
true,
|
||||
);
|
||||
|
||||
this._mfaResolver = null;
|
||||
|
||||
if (!authSuccess) {
|
||||
await this._takeScreenshot('step2-auth-failed', this._isDebugMode);
|
||||
this._sendToGateway({
|
||||
type: 'mfaResolved',
|
||||
sessionId: this._sessionId,
|
||||
success: false,
|
||||
});
|
||||
throw new Error('Microsoft authentication failed');
|
||||
}
|
||||
|
||||
this._sendToGateway({
|
||||
type: 'mfaResolved',
|
||||
sessionId: this._sessionId,
|
||||
success: true,
|
||||
});
|
||||
this._logger.info('STEP 2: authentication successful');
|
||||
await this._takeScreenshot('step2-auth-done', this._isDebugMode);
|
||||
|
||||
|
|
@ -713,6 +745,15 @@ export class BotOrchestrator {
|
|||
}
|
||||
break;
|
||||
|
||||
case 'mfaResponse':
|
||||
const mfaMsg = message as MfaResponseMessage;
|
||||
this._logger.info(`Gateway mfaResponse received: action=${mfaMsg.mfa?.action}`);
|
||||
if (this._mfaResolver) {
|
||||
this._mfaResolver(mfaMsg.mfa);
|
||||
this._mfaResolver = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'botCommand':
|
||||
await this._handleBotCommand(message.command, message.params || {});
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -86,8 +86,35 @@ export interface TtsPlaybackAckMessage {
|
|||
};
|
||||
}
|
||||
|
||||
export type GatewayToBot = PlayAudioMessage | JoinMeetingMessage | LeaveMeetingMessage | SendChatMessage;
|
||||
export type BotToGateway = TranscriptMessage | StatusMessage | ChatMessage | AudioChunkMessage | TtsPlaybackAckMessage;
|
||||
export type MfaChallengeType = 'numberMatch' | 'pushApproval' | 'smsCode' | 'totpCode' | 'unknown';
|
||||
|
||||
export interface MfaChallengeMessage {
|
||||
type: 'mfaChallenge';
|
||||
sessionId: string;
|
||||
mfa: {
|
||||
type: MfaChallengeType;
|
||||
displayNumber?: string;
|
||||
prompt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MfaResponseMessage {
|
||||
type: 'mfaResponse';
|
||||
sessionId: string;
|
||||
mfa: {
|
||||
action: 'code' | 'confirmed' | 'timeout';
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MfaResolvedMessage {
|
||||
type: 'mfaResolved';
|
||||
sessionId: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export type GatewayToBot = PlayAudioMessage | JoinMeetingMessage | LeaveMeetingMessage | SendChatMessage | MfaResponseMessage;
|
||||
export type BotToGateway = TranscriptMessage | StatusMessage | ChatMessage | AudioChunkMessage | TtsPlaybackAckMessage | MfaChallengeMessage | MfaResolvedMessage;
|
||||
|
||||
// Bot State
|
||||
export type BotState =
|
||||
|
|
|
|||
Loading…
Reference in a new issue