From cb337ec377769419e6627b03ce7b663113247fa7 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 1 Mar 2026 10:57:08 +0100 Subject: [PATCH] fix(mfa): race page polling with Gateway for push-based MFA types Made-with: Cursor --- src/bot/authProcedure.ts | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/bot/authProcedure.ts b/src/bot/authProcedure.ts index 3068c53..95c6c7d 100644 --- a/src/bot/authProcedure.ts +++ b/src/bot/authProcedure.ts @@ -335,7 +335,8 @@ export class AuthProcedure { /** * 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. + * For push/number-match: race Gateway response with page polling since the user + * approves on their phone and MS redirects the page automatically. */ private async _handleMfaChallenge(challenge: MfaChallenge): Promise { if (!this._onMfaChallenge) { @@ -343,6 +344,33 @@ export class AuthProcedure { return false; } + const isPushBased = challenge.type === 'numberMatch' || challenge.type === 'pushApproval'; + + if (isPushBased) { + const abortSignal = { aborted: false }; + const gatewayPromise = this._onMfaChallenge(challenge); + const pagePromise = this._waitForMfaResolution(abortSignal).then(resolved => + ({ action: resolved ? 'pageResolved' : 'timeout' } as { action: string; code?: string }), + ); + + const result = await Promise.race([gatewayPromise, pagePromise]); + abortSignal.aborted = true; + + if (result.action === 'pageResolved') { + this._logger.info('MFA resolved via page change (user approved on phone)'); + return true; + } + if (result.action === 'timeout') { + this._logger.warn('MFA timed out'); + return false; + } + if (result.action === 'code' && result.code) { + return this._enterMfaCode(result.code); + } + return false; + } + + // Code-based MFA: wait for Gateway response with the code const response = await this._onMfaChallenge(challenge); if (response.action === 'timeout') { this._logger.warn('MFA timed out - no user response'); @@ -356,7 +384,6 @@ export class AuthProcedure { return this._enterMfaCode(response.code); } - // For push/numberMatch: user confirms externally, MS redirects automatically return this._waitForMfaResolution(); } @@ -406,8 +433,9 @@ export class AuthProcedure { /** * Wait for MFA resolution: MS login page redirects away after successful approval. * Polls URL changes and checks for "Stay signed in?" or Teams redirect. + * @param abortSignal - optional signal to cancel polling early (used when racing with Gateway) */ - private async _waitForMfaResolution(): Promise { + private async _waitForMfaResolution(abortSignal?: { aborted: boolean }): Promise { const startUrl = this._page.url(); const maxWaitMs = 120000; const pollMs = 2000; @@ -416,7 +444,9 @@ export class AuthProcedure { this._logger.info('Waiting for MFA resolution...'); while (Date.now() < deadline) { + if (abortSignal?.aborted) return false; await this._page.waitForTimeout(pollMs); + if (abortSignal?.aborted) return false; const currentUrl = this._page.url(); // URL changed = MFA resolved