From e8c62a71d3f395d334c528e50e15fd97d216f9d1 Mon Sep 17 00:00:00 2001 From: Louis-Michel Date: Fri, 14 Mar 2025 15:15:20 -0400 Subject: [PATCH 1/4] FIX - Completer already close --- packages/firebase_ui_auth/lib/src/mfa.dart | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/firebase_ui_auth/lib/src/mfa.dart b/packages/firebase_ui_auth/lib/src/mfa.dart index f086aa44..1b6d95bb 100644 --- a/packages/firebase_ui_auth/lib/src/mfa.dart +++ b/packages/firebase_ui_auth/lib/src/mfa.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart' as fba; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_ui_auth/firebase_ui_auth.dart'; import 'package:firebase_ui_auth/src/widgets/internal/universal_page_route.dart'; import 'package:flutter/scheduler.dart'; @@ -18,32 +19,41 @@ typedef SMSCodeInputScreenBuilder = AuthAction action, ); -Future startMFAVerification({ +Future startMFAVerification({ required BuildContext context, required fba.MultiFactorResolver resolver, fba.FirebaseAuth? auth, SMSCodeInputScreenBuilder? smsCodeInputScreenBuilder, + void Function(FirebaseException e)? onError, }) async { if (resolver.hints.first is fba.PhoneMultiFactorInfo) { return startPhoneMFAVerification( context: context, resolver: resolver, auth: auth, + onError: onError, ); } else { throw Exception('Unsupported MFA type'); } } -Future startPhoneMFAVerification({ +Future startPhoneMFAVerification({ required BuildContext context, required fba.MultiFactorResolver resolver, fba.FirebaseAuth? auth, SMSCodeInputScreenBuilder? smsCodeInputScreenBuilder, + void Function(FirebaseException e)? onError, }) async { final session = resolver.session; final hint = resolver.hints.first; - final completer = Completer(); + var completer = Completer(); + + completer.future.catchError((e) { + onError?.call(e as FirebaseException); + return null; + }); + final navigator = Navigator.of(context); final provider = PhoneAuthProvider(); @@ -61,6 +71,10 @@ Future startPhoneMFAVerification({ final actions = [ AuthStateChangeAction((context, inner) { + if (completer.isCompleted) { + completer = Completer(); + } + final cred = inner.credential as fba.PhoneAuthCredential; final assertion = fba.PhoneMultiFactorGenerator.getAssertion(cred); try { From 3db3629e4cb9ead1b44833722d8478d6c31f95c3 Mon Sep 17 00:00:00 2001 From: Louis-Michel Date: Fri, 14 Mar 2025 15:35:03 -0400 Subject: [PATCH 2/4] FIX - Display error under the pin widget --- packages/firebase_ui_auth/lib/src/mfa.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/firebase_ui_auth/lib/src/mfa.dart b/packages/firebase_ui_auth/lib/src/mfa.dart index 1b6d95bb..45da791c 100644 --- a/packages/firebase_ui_auth/lib/src/mfa.dart +++ b/packages/firebase_ui_auth/lib/src/mfa.dart @@ -49,11 +49,6 @@ Future startPhoneMFAVerification({ final hint = resolver.hints.first; var completer = Completer(); - completer.future.catchError((e) { - onError?.call(e as FirebaseException); - return null; - }); - final navigator = Navigator.of(context); final provider = PhoneAuthProvider(); @@ -67,12 +62,23 @@ Future startPhoneMFAVerification({ provider.authListener = flow; + completer.future.catchError((e) { + onError?.call(e as FirebaseException); + flow.onError(e); + return null; + }); + final flowKey = Object(); final actions = [ AuthStateChangeAction((context, inner) { if (completer.isCompleted) { completer = Completer(); + completer.future.catchError((e) { + onError?.call(e as FirebaseException); + flow.onError(e); + return null; + }); } final cred = inner.credential as fba.PhoneAuthCredential; From ea637db780669dea2e729e8883046845446e4ac9 Mon Sep 17 00:00:00 2001 From: Louis-Michel Date: Fri, 25 Sep 2026 09:32:46 -0400 Subject: [PATCH 3/4] fix(firebase_ui_auth): handle resolveSignIn error in action and complete on success --- packages/firebase_ui_auth/lib/src/mfa.dart | 36 ++-- packages/firebase_ui_auth/test/mfa_test.dart | 197 +++++++++++++++++++ 2 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 packages/firebase_ui_auth/test/mfa_test.dart diff --git a/packages/firebase_ui_auth/lib/src/mfa.dart b/packages/firebase_ui_auth/lib/src/mfa.dart index 45da791c..e6d1fa85 100644 --- a/packages/firebase_ui_auth/lib/src/mfa.dart +++ b/packages/firebase_ui_auth/lib/src/mfa.dart @@ -19,7 +19,7 @@ typedef SMSCodeInputScreenBuilder = AuthAction action, ); -Future startMFAVerification({ +Future startMFAVerification({ required BuildContext context, required fba.MultiFactorResolver resolver, fba.FirebaseAuth? auth, @@ -31,6 +31,7 @@ Future startMFAVerification({ context: context, resolver: resolver, auth: auth, + smsCodeInputScreenBuilder: smsCodeInputScreenBuilder, onError: onError, ); } else { @@ -38,7 +39,7 @@ Future startMFAVerification({ } } -Future startPhoneMFAVerification({ +Future startPhoneMFAVerification({ required BuildContext context, required fba.MultiFactorResolver resolver, fba.FirebaseAuth? auth, @@ -47,7 +48,7 @@ Future startPhoneMFAVerification({ }) async { final session = resolver.session; final hint = resolver.hints.first; - var completer = Completer(); + final completer = Completer(); final navigator = Navigator.of(context); @@ -62,32 +63,22 @@ Future startPhoneMFAVerification({ provider.authListener = flow; - completer.future.catchError((e) { - onError?.call(e as FirebaseException); - flow.onError(e); - return null; - }); - final flowKey = Object(); final actions = [ - AuthStateChangeAction((context, inner) { - if (completer.isCompleted) { - completer = Completer(); - completer.future.catchError((e) { - onError?.call(e as FirebaseException); - flow.onError(e); - return null; - }); - } - + AuthStateChangeAction((context, inner) async { final cred = inner.credential as fba.PhoneAuthCredential; final assertion = fba.PhoneMultiFactorGenerator.getAssertion(cred); try { - final cred = resolver.resolveSignIn(assertion); - completer.complete(cred); + final cred = await resolver.resolveSignIn(assertion); + if (!completer.isCompleted) { + completer.complete(cred); + } } catch (e) { - completer.completeError(e); + if (e is FirebaseException) { + onError?.call(e); + } + flow.onError(e); } }), ]; @@ -120,6 +111,7 @@ Future startPhoneMFAVerification({ } return AuthFlowBuilder( + auth: auth, flow: flow, flowKey: flowKey, child: child, diff --git a/packages/firebase_ui_auth/test/mfa_test.dart b/packages/firebase_ui_auth/test/mfa_test.dart new file mode 100644 index 00000000..3ef82336 --- /dev/null +++ b/packages/firebase_ui_auth/test/mfa_test.dart @@ -0,0 +1,197 @@ +// Copyright 2022, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:firebase_auth/firebase_auth.dart' as fba; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import 'test_utils.dart'; + +class MockPhoneMultiFactorInfo extends Mock + implements fba.PhoneMultiFactorInfo { + @override + String get phoneNumber => '+1234567890'; +} + +class MockTotpMultiFactorInfo extends Mock implements fba.TotpMultiFactorInfo {} + +class MockMultiFactorSession extends Mock implements fba.MultiFactorSession {} + +class MockMultiFactorResolver extends Mock implements fba.MultiFactorResolver { + final MockMultiFactorSession _session = MockMultiFactorSession(); + final List _hints = [MockPhoneMultiFactorInfo()]; + + @override + fba.MultiFactorSession get session => _session; + + @override + List get hints => _hints; + + @override + Future resolveSignIn( + fba.MultiFactorAssertion? assertion, + ) { + return super.noSuchMethod( + Invocation.method(#resolveSignIn, [assertion]), + returnValue: Future.value(MockCredential()), + returnValueForMissingStub: Future.value(MockCredential()), + ); + } +} + +class MockUnsupportedMultiFactorResolver extends Mock + implements fba.MultiFactorResolver { + @override + List get hints => [MockTotpMultiFactorInfo()]; +} + +class MockPhoneAuthCredential extends Mock implements fba.PhoneAuthCredential {} + +void main() { + group('startMFAVerification', () { + late MockAuth auth; + late MockMultiFactorResolver resolver; + + setUpAll(() { + setFirebaseUiIsTestMode(true); + }); + + setUp(() { + auth = MockAuth(); + resolver = MockMultiFactorResolver(); + }); + + testWidgets('throws on unsupported MFA type', (tester) async { + final unsupportedResolver = MockUnsupportedMultiFactorResolver(); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + expect( + () => startMFAVerification( + context: context, + resolver: unsupportedResolver, + auth: auth, + ), + throwsA(isA()), + ); + }, + child: const Text('Start MFA'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Start MFA')); + }); + + testWidgets( + 'handles resolveSignIn error, calls onError, propagates to flow, and succeeds on retry', + (tester) async { + final expectedCredential = MockCredential(); + final testException = fba.FirebaseAuthException( + code: 'invalid-verification-code', + message: 'Invalid code', + ); + + int resolveCallCount = 0; + when(resolver.resolveSignIn(any)).thenAnswer((_) async { + resolveCallCount++; + if (resolveCallCount == 1) { + throw testException; + } + return expectedCredential; + }); + + FirebaseException? capturedError; + late List capturedActions; + late Object capturedFlowKey; + + Future? verificationFuture; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + verificationFuture = startMFAVerification( + context: context, + resolver: resolver, + auth: auth, + onError: (e) { + capturedError = e; + }, + smsCodeInputScreenBuilder: + (context, actions, flowKey, action) { + capturedActions = actions; + capturedFlowKey = flowKey; + return const Scaffold( + body: Text('Custom SMS Code Input Screen'), + ); + }, + ); + }, + child: const Text('Start MFA'), + ); + }, + ), + ), + ); + + // Tap the button to start MFA verification + await tester.tap(find.text('Start MFA')); + await tester.pumpAndSettle(); + + expect(find.text('Custom SMS Code Input Screen'), findsOneWidget); + expect(capturedActions, isNotEmpty); + + final credentialAction = capturedActions + .whereType>() + .first; + + final mockPhoneCred = MockPhoneAuthCredential(); + + // 1st attempt: throws error + credentialAction.callback( + tester.element(find.text('Custom SMS Code Input Screen')), + CredentialReceived(mockPhoneCred), + ); + await tester.pumpAndSettle(); + + expect(resolveCallCount, 1); + expect(capturedError, equals(testException)); + + // Verify flow state transitioned to AuthFailed + final state = AuthFlowBuilder.getState(capturedFlowKey); + expect(state, isA()); + expect((state as AuthFailed).exception, equals(testException)); + + // Screen should still be visible because we did not pop on error + expect(find.text('Custom SMS Code Input Screen'), findsOneWidget); + + // 2nd attempt: succeeds + credentialAction.callback( + tester.element(find.text('Custom SMS Code Input Screen')), + CredentialReceived(mockPhoneCred), + ); + await tester.pumpAndSettle(); + + expect(resolveCallCount, 2); + // Screen should now be popped + expect(find.text('Custom SMS Code Input Screen'), findsNothing); + + expect(verificationFuture, isNotNull); + final result = await verificationFuture!; + expect(result, equals(expectedCredential)); + }, + ); + }); +} From 21685d363ed19e6891dd6f8e0993345f4ee37b46 Mon Sep 17 00:00:00 2001 From: Louis-Michel Date: Fri, 25 Sep 2026 10:15:25 -0400 Subject: [PATCH 4/4] test(firebase_ui_auth): stub signInWithPhoneNumber in MockAuth for web tests --- packages/firebase_ui_auth/test/test_utils.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/firebase_ui_auth/test/test_utils.dart b/packages/firebase_ui_auth/test/test_utils.dart index df0c7164..484671a3 100644 --- a/packages/firebase_ui_auth/test/test_utils.dart +++ b/packages/firebase_ui_auth/test/test_utils.dart @@ -198,6 +198,20 @@ class MockAuth extends Mock implements fba.FirebaseAuth { }), ); } + + @override + Future signInWithPhoneNumber( + String? phoneNumber, [ + fba.RecaptchaVerifier? verifier, + ]) async { + return super.noSuchMethod( + Invocation.method(#signInWithPhoneNumber, [phoneNumber, verifier]), + returnValue: Future.value(MockConfirmationResult()), + returnValueForMissingStub: Future.value(MockConfirmationResult()), + ); + } } +class MockConfirmationResult extends Mock implements fba.ConfirmationResult {} + class TestException implements Exception {}