diff --git a/src/bot/authProcedure.ts b/src/bot/authProcedure.ts index bab795e..3068c53 100644 --- a/src/bot/authProcedure.ts +++ b/src/bot/authProcedure.ts @@ -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 { - const mfaSelectors = [ - '#idDiv_SAOTCAS_Description', - '#idDiv_SAOTCC_Description', - '#idDiv_SAASDS_Description', - '[data-tid="phoneVerification"]', + private async _detectMfaChallenge(): Promise { + // 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 { + 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 { + 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 { + 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; } diff --git a/src/bot/orchestrator.ts b/src/bot/orchestrator.ts index 0c37e50..176f742 100644 --- a/src/bot/orchestrator.ts +++ b/src/bot/orchestrator.ts @@ -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; diff --git a/src/types/index.ts b/src/types/index.ts index c97e461..7801a9b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 =