feat(teamsbot): MFA detection scraping and relay in auth flow

Made-with: Cursor
This commit is contained in:
ValueOn AG 2026-03-01 08:51:17 +01:00
parent dbcc53bed8
commit 49d0c26f00
3 changed files with 264 additions and 23 deletions

View file

@ -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;
}

View file

@ -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;

View file

@ -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 =