diff --git a/packages/firebase_ui_auth/lib/src/mfa.dart b/packages/firebase_ui_auth/lib/src/mfa.dart index f086aa44..e6d1fa85 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'; @@ -23,12 +24,15 @@ Future startMFAVerification({ 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, + smsCodeInputScreenBuilder: smsCodeInputScreenBuilder, + onError: onError, ); } else { throw Exception('Unsupported MFA type'); @@ -40,10 +44,12 @@ Future startPhoneMFAVerification({ 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(); + final navigator = Navigator.of(context); final provider = PhoneAuthProvider(); @@ -60,14 +66,19 @@ Future startPhoneMFAVerification({ final flowKey = Object(); final actions = [ - AuthStateChangeAction((context, inner) { + 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); } }), ]; @@ -100,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)); + }, + ); + }); +} 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 {}