From 49d0c26f0075bb2f04c81cb0c6ecc3904280b4e8 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 1 Mar 2026 08:51:17 +0100
Subject: [PATCH] feat(teamsbot): MFA detection scraping and relay in auth flow
Made-with: Cursor
---
src/bot/authProcedure.ts | 209 +++++++++++++++++++++++++++++++++++----
src/bot/orchestrator.ts | 47 ++++++++-
src/types/index.ts | 31 +++++-
3 files changed, 264 insertions(+), 23 deletions(-)
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 =