From a2150b00fa6beb3c569c9bf56fd210d4cce06218 Mon Sep 17 00:00:00 2001 From: Roman Barzyczak Date: Thu, 15 Feb 2024 22:32:42 +0100 Subject: [PATCH 1/2] fix(ui_oauth_apple): upgrade anonymous users by linking the Apple credential Apple sign in ignored shouldUpgradeAnonymous and always called signInWithProvider, so an anonymous user's uid and data were lost. Link instead, matching the other providers. Split out of #288. Fixes #253. --- packages/firebase_ui_oauth_apple/lib/src/provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/firebase_ui_oauth_apple/lib/src/provider.dart b/packages/firebase_ui_oauth_apple/lib/src/provider.dart index fe5d28ee..aa662b22 100644 --- a/packages/firebase_ui_oauth_apple/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_apple/lib/src/provider.dart @@ -56,7 +56,7 @@ class AppleProvider extends OAuthProvider { void mobileSignIn(AuthAction action) { authListener.onBeforeSignIn(); - if (action == AuthAction.link) { + if (action == AuthAction.link || shouldUpgradeAnonymous) { auth.currentUser ?.linkWithProvider(firebaseAuthProvider) .then(_onLinked) From 304ee098c0c938d4dc89b5e27205839a83a442a6 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Tue, 20 Oct 2026 14:02:07 +0100 Subject: [PATCH 2/2] fix(ui_oauth_apple): report an error instead of hanging when there is nothing to link AuthAction.link with no signed in user null-shorted, and a null credential from linkWithProvider was force unwrapped inside .then. Both left the flow stuck loading. Report them through the listener, matching TwitterProvider, and cover anonymous upgrade and the no-user case in tests. --- .../lib/src/provider.dart | 42 +++++++++- .../apple_sign_in_test.dart | 84 ++++++++++++++++++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/packages/firebase_ui_oauth_apple/lib/src/provider.dart b/packages/firebase_ui_oauth_apple/lib/src/provider.dart index aa662b22..e4e50a16 100644 --- a/packages/firebase_ui_oauth_apple/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_apple/lib/src/provider.dart @@ -56,9 +56,29 @@ class AppleProvider extends OAuthProvider { void mobileSignIn(AuthAction action) { authListener.onBeforeSignIn(); + // Linking is also used to upgrade an anonymous user, so that the + // anonymous uid survives the sign in. if (action == AuthAction.link || shouldUpgradeAnonymous) { - auth.currentUser - ?.linkWithProvider(firebaseAuthProvider) + final currentUser = auth.currentUser; + + // Only AuthAction.link can reach this with no user, since + // shouldUpgradeAnonymous is false when currentUser is null. Reporting it + // matters because a null-shorting call would leave the flow stuck in its + // loading state with no error and no completion. + if (currentUser == null) { + authListener.onError( + fba.FirebaseAuthException( + code: 'no-current-user', + message: + 'AuthAction.link requires a signed in user to link the ' + 'Apple credential to, but FirebaseAuth.currentUser is null.', + ), + ); + return; + } + + currentUser + .linkWithProvider(firebaseAuthProvider) .then(_onLinked) .catchError(authListener.onError); return; @@ -97,6 +117,22 @@ class AppleProvider extends OAuthProvider { } void _onLinked(fba.UserCredential userCredential) { - authListener.onCredentialLinked(userCredential.credential!); + final credential = userCredential.credential; + + // Nullable on every platform, and a force unwrap here would throw inside + // .then, reaching onError as an Error and hanging the flow. + if (credential == null) { + authListener.onError( + fba.FirebaseAuthException( + code: 'missing-credential', + message: + 'The Apple account was linked, but Firebase returned no ' + 'credential for it.', + ), + ); + return; + } + + authListener.onCredentialLinked(credential); } } diff --git a/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart index fc232b3e..ca9c08f3 100644 --- a/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_apple/apple_sign_in_test.dart @@ -17,7 +17,7 @@ import '../utils.dart'; void main() async { final provider = AppleProvider(); - late fba.FirebaseAuth auth; + late MockAuth auth; late MockProvider fbProvider; const labels = DefaultLocalizations(); @@ -95,6 +95,61 @@ void main() async { expect(user.displayName, 'Test User'); expect(user.email, 'test@test.com'); }); + + testWidgets('links the credential when the user is anonymous', ( + tester, + ) async { + final anonymousUser = MockAnonymousUser(); + auth.currentUserOverride = anonymousUser; + + // AuthAction.signIn is explicit: the flow would otherwise resolve to + // AuthAction.link on its own whenever currentUser is non-null, and the + // test would pass without shouldUpgradeAnonymous being consulted. + await render( + tester, + OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.signIn, + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + verify(anonymousUser.linkWithProvider(fbProvider)).called(1); + verifyNever(auth.signInWithProvider(fbProvider)); + }); + + testWidgets('reports an error when linking with no signed in user', ( + tester, + ) async { + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.link, + ), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isA()); + verifyNever(auth.signInWithProvider(fbProvider)); + }); }, skip: !provider.supportsPlatform(defaultTargetPlatform), ); @@ -124,11 +179,33 @@ class MockUser extends Mock implements fba.User { @override String? get email => 'test@test.com'; + + @override + bool get isAnonymous => false; } +class MockAnonymousUser extends Mock implements fba.User { + @override + bool get isAnonymous => true; + + @override + Future linkWithProvider(Object provider) async { + return super.noSuchMethod( + Invocation.method(#linkWithProvider, [provider]), + returnValue: Future.value(MockCredential()), + returnValueForMissingStub: Future.value(MockCredential()), + ); + } +} + +class MockAuthCredential extends Mock implements fba.AuthCredential {} + class MockCredential extends Mock implements fba.UserCredential { @override fba.User? get user => MockUser(); + + @override + fba.AuthCredential? get credential => MockAuthCredential(); } class MockProvider extends Mock implements fba.AppleAuthProvider {} @@ -136,6 +213,11 @@ class MockProvider extends Mock implements fba.AppleAuthProvider {} class MockApp extends Mock implements FirebaseApp {} class MockAuth extends Mock implements fba.FirebaseAuth { + fba.User? currentUserOverride; + + @override + fba.User? get currentUser => currentUserOverride; + @override Future signInWithProvider(Object provider) async { return super.noSuchMethod(