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