From 8204361923c0fa4a15c34a0d0f9b710602b0a520 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:15:56 +0100 Subject: [PATCH 01/12] feat(ui_oauth_twitter)!: replace twitter_login with signInWithProvider twitter_login has not published since July 2023 and ships an Android build.gradle with no namespace that pins AGP 4.1.0, which capped this repo at AGP 8.7.3 and blocked #693 from using flutter_web_auth_2. TwitterProvider now signs in through auth.signInWithProvider on Android and iOS, mirroring AppleProvider, so Firebase performs the OAuth dance and the Twitter API key and secret move out of the app binary into the Firebase console. macOS and Windows keep the vendored OAuth 1.0a flow, which is why apiKey and apiSecretKey survive as optional parameters rather than being removed. macOS stays on the desktop flow because signInWithProvider is not available to it: FLTFirebaseAuthPlugin.swift carves out Apple and Game Center, then fails every other provider under `#if os(macOS)` with unsupported-platform. Android has no equivalent restriction. - AuthAction.none throws UnsupportedError on Android and iOS, since signInWithProvider cannot return a credential without also creating a session. - Anonymous users are upgraded with linkWithProvider so the anonymous uid survives sign in. - A debug-only diagnostic warns once when apiKey or apiSecretKey are passed on a platform that now ignores them. - Restores compileSdk to flutter.compileSdkVersion and bumps AGP to 8.9.1, now that nothing pins it. BREAKING CHANGE: consumers must set the Twitter app callback URL to the Firebase auth handler, add the Encoded App ID URL scheme on iOS, and register their SHA-1 on Android. AuthAction.none now throws on Android and iOS, and the credential passed to onCredentialLinked is a plain AuthCredential rather than an OAuthCredential. --- .github/workflows/e2e.yml | 3 - .../firebase_ui_auth/example/pubspec.yaml | 1 - .../lib/firebase_ui_oauth_twitter.dart | 16 +- .../lib/src/provider.dart | 149 +++++++++++----- .../firebase_ui_oauth_twitter/pubspec.yaml | 1 - scripts/patch-twitter-login.sh | 51 ------ tests/android/app/build.gradle | 3 +- tests/android/settings.gradle | 2 +- .../twitter_sign_in_test.dart | 167 +++++++++++++----- tests/pubspec.yaml | 1 - 10 files changed, 238 insertions(+), 156 deletions(-) delete mode 100755 scripts/patch-twitter-login.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3887ce65..309dbb03 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -75,9 +75,6 @@ jobs: melos-version: "^7.5.0" - name: "Bootstrap package" run: melos bootstrap --scope tests - # needed because twitter_login plugin doesn't have a namespace defined and he hasn't released a new version yet: https://github.com/0maru/twitter_login/issues/139 - - name: Patch twitter_login plugin - run: ./scripts/patch-twitter-login.sh - name: Start Firebase Emulator run: | cd functions/ diff --git a/packages/firebase_ui_auth/example/pubspec.yaml b/packages/firebase_ui_auth/example/pubspec.yaml index b1f47897..7ae211d2 100644 --- a/packages/firebase_ui_auth/example/pubspec.yaml +++ b/packages/firebase_ui_auth/example/pubspec.yaml @@ -39,7 +39,6 @@ dependencies: firebase_ui_oauth_google: ^2.1.1 firebase_ui_oauth_twitter: ^2.1.1 # This and twitter oauth package need to depend on git main directly due to namespace build error on android. - twitter_login: ^4.4.2 dev_dependencies: drive: ^1.0.0-1.0.nullsafety.5 firebase_ui_shared: ^1.5.0 diff --git a/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart b/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart index 3b9bbb5a..36f894b2 100644 --- a/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart +++ b/packages/firebase_ui_oauth_twitter/lib/firebase_ui_oauth_twitter.dart @@ -15,8 +15,8 @@ class TwitterSignInButton extends _TwitterSignInButton { const TwitterSignInButton({ super.key, required super.loadingIndicator, - required super.apiKey, - required super.apiSecretKey, + super.apiKey, + super.apiSecretKey, super.redirectUri, super.action = null, super.auth, @@ -35,8 +35,8 @@ class TwitterSignInButton extends _TwitterSignInButton { class TwitterSignInIconButton extends _TwitterSignInButton { const TwitterSignInIconButton({ super.key, - required super.apiKey, - required super.apiSecretKey, + super.apiKey, + super.apiSecretKey, required super.loadingIndicator, super.action = null, super.auth, @@ -74,16 +74,16 @@ class _TwitterSignInButton extends StatelessWidget { final DifferentProvidersFoundCallback? onDifferentProvidersFound; final SignedInCallback? onSignedIn; final double size; - final String apiKey; - final String apiSecretKey; + final String? apiKey; + final String? apiSecretKey; final String? redirectUri; final void Function(Exception exception)? onError; final VoidCallback? onCanceled; const _TwitterSignInButton({ super.key, - required this.apiKey, - required this.apiSecretKey, + this.apiKey, + this.apiSecretKey, required this.loadingIndicator, String? label, bool? overrideDefaultTapAction, diff --git a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart index 10d5420c..88606b29 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -5,65 +5,119 @@ import 'package:firebase_auth/firebase_auth.dart' hide OAuthProvider; import 'package:flutter/foundation.dart'; import 'package:firebase_ui_oauth/firebase_ui_oauth.dart'; -import 'package:twitter_login/twitter_login.dart'; import 'theme.dart'; +/// A Firebase UI Auth provider which should be used to add Twitter Sign In +/// to your app. +/// +/// On Android and iOS the sign in flow is handled by Firebase itself via +/// [FirebaseAuth.signInWithProvider], so [apiKey] and [apiSecretKey] are not +/// needed: configure the Twitter provider in the Firebase console instead. +/// +/// macOS and Windows still perform the OAuth 1.0a flow in-process and do +/// require [apiKey] and [apiSecretKey]. `signInWithProvider` rejects every +/// provider except Apple and Game Center on macOS, so the desktop flow stays +/// in place there. class TwitterProvider extends OAuthProvider { @override final providerId = 'twitter.com'; - final String apiKey; - final String apiSecretKey; + + /// The Twitter API key. + /// + /// Only required on macOS and Windows, which perform the OAuth 1.0a flow + /// in-process. + final String? apiKey; + + /// The Twitter API secret key. + /// + /// Only required on macOS and Windows, which perform the OAuth 1.0a flow + /// in-process. + final String? apiSecretKey; + final String? redirectUri; @override final style = const TwitterProviderButtonStyle(); @override - late final desktopSignInArgs = TwitterSignInArgs( - apiKey: apiKey, - apiSecretKey: apiSecretKey, - redirectUri: redirectUri ?? defaultRedirectUri, - ); - - late TwitterLogin provider = TwitterLogin( - apiKey: apiKey, - apiSecretKey: apiSecretKey, - redirectURI: redirectUri ?? defaultRedirectUri, - ); - - TwitterProvider({ - required this.apiKey, - required this.apiSecretKey, - this.redirectUri, - }); + TwitterAuthProvider firebaseAuthProvider = TwitterAuthProvider(); + + @override + TwitterSignInArgs get desktopSignInArgs { + final apiKey = this.apiKey; + final apiSecretKey = this.apiSecretKey; + + if (apiKey == null || apiSecretKey == null) { + throw ArgumentError( + 'TwitterProvider.apiKey and TwitterProvider.apiSecretKey are required ' + 'on $defaultTargetPlatform, which signs in using the OAuth 1.0a flow. ' + 'Android and iOS use the Firebase native provider flow and do not ' + 'need them.', + ); + } + + return TwitterSignInArgs( + apiKey: apiKey, + apiSecretKey: apiSecretKey, + redirectUri: redirectUri ?? defaultRedirectUri, + ); + } + + TwitterProvider({this.apiKey, this.apiSecretKey, this.redirectUri}); + + bool _warnedAboutIgnoredKeys = false; + + /// Warns once, in debug builds, that [apiKey] and [apiSecretKey] no longer + /// take part in sign in on the platforms that use the Firebase provider + /// flow. Without this the change is silent: the app still compiles, and the + /// first signal the developer gets is a sign in that fails in the browser. + void _warnIfKeysAreIgnored() { + if (!kDebugMode || _warnedAboutIgnoredKeys) return; + if (apiKey == null && apiSecretKey == null) return; + + _warnedAboutIgnoredKeys = true; + + debugPrint( + 'TwitterProvider: apiKey and apiSecretKey are ignored on ' + '$defaultTargetPlatform. Sign in is now performed by Firebase, which ' + 'reads the Twitter API key and secret from the Firebase console. They ' + 'are still used on macOS and Windows.\n' + 'If sign in fails, check that the Twitter app callback URL is ' + '"${redirectUri ?? defaultRedirectUri}", and that you have added the ' + 'Encoded App ID URL scheme (iOS) or your SHA-1 fingerprint ' + '(Android). See ' + 'https://github.com/firebase/FirebaseUI-Flutter/blob/main/docs/firebase-ui-auth/providers/oauth.md#twitter-login', + ); + } @override void mobileSignIn(AuthAction action) { - final result = provider.login(); - - result - .then((value) { - switch (value.status!) { - case TwitterLoginStatus.loggedIn: - final credential = TwitterAuthProvider.credential( - accessToken: value.authToken!, - secret: value.authTokenSecret!, - ); - - onCredentialReceived(credential, action); - break; - case TwitterLoginStatus.cancelledByUser: - authListener.onError(AuthCancelledException()); - break; - case TwitterLoginStatus.error: - authListener.onError(Exception(value.errorMessage)); - break; - } - }) - .catchError((err) { - authListener.onError(err); - }); + if (action == AuthAction.none) { + throw UnsupportedError( + 'AuthAction.none is not supported by TwitterProvider on ' + '$defaultTargetPlatform. Firebase signs the user in as part of ' + 'obtaining the credential, so the credential cannot be returned ' + 'without also creating a session.', + ); + } + + _warnIfKeysAreIgnored(); + + // 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) + .then(_onLinked) + .catchError(authListener.onError); + return; + } + + auth + .signInWithProvider(firebaseAuthProvider) + .then(authListener.onSignedIn) + .catchError(authListener.onError); } @override @@ -74,9 +128,6 @@ class TwitterProvider extends OAuthProvider { ); } - @override - TwitterAuthProvider get firebaseAuthProvider => TwitterAuthProvider(); - @override Future logOutProvider() { return SynchronousFuture(null); @@ -86,4 +137,8 @@ class TwitterProvider extends OAuthProvider { bool supportsPlatform(TargetPlatform platform) { return true; } + + void _onLinked(UserCredential userCredential) { + authListener.onCredentialLinked(userCredential.credential!); + } } diff --git a/packages/firebase_ui_oauth_twitter/pubspec.yaml b/packages/firebase_ui_oauth_twitter/pubspec.yaml index fefdf7d9..d27ce0c4 100644 --- a/packages/firebase_ui_oauth_twitter/pubspec.yaml +++ b/packages/firebase_ui_oauth_twitter/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: sdk: flutter firebase_auth: ^6.5.4 firebase_ui_oauth: ^2.1.1 - twitter_login: ^4.4.2 dev_dependencies: flutter_test: diff --git a/scripts/patch-twitter-login.sh b/scripts/patch-twitter-login.sh deleted file mode 100755 index 3acb2c79..00000000 --- a/scripts/patch-twitter-login.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash -set -e - -# Script to patch twitter_login plugin to add namespace for AGP 8.x compatibility -# This is required because twitter_login 4.4.2 doesn't have a namespace defined - -echo "Patching twitter_login plugin for AGP 8.x compatibility..." - -# Determine pub cache directory (supports both local and CI environments) -if [ -n "$PUB_CACHE" ]; then - PUB_CACHE_DIR="$PUB_CACHE" -elif [ -n "$FLUTTER_ROOT" ]; then - PUB_CACHE_DIR="$FLUTTER_ROOT/.pub-cache" -else - PUB_CACHE_DIR="$HOME/.pub-cache" -fi - -echo "Using pub cache directory: $PUB_CACHE_DIR" - -# Find the twitter_login plugin build.gradle file (not the example one) -TWITTER_LOGIN_BUILD_GRADLE=$(find "$PUB_CACHE_DIR/hosted" -name "build.gradle" -path "*/twitter_login-*/android/build.gradle" ! -path "*/example/*" 2>/dev/null | head -n 1) - -if [ -z "$TWITTER_LOGIN_BUILD_GRADLE" ]; then - echo "Error: Could not find twitter_login build.gradle file" - echo "Searched in: $PUB_CACHE_DIR/hosted" - echo "Available twitter_login directories:" - find "$PUB_CACHE_DIR/hosted" -type d -name "twitter_login-*" 2>/dev/null || echo "None found" - exit 1 -fi - -echo "Found twitter_login build.gradle at: $TWITTER_LOGIN_BUILD_GRADLE" - -# Check if namespace is already present -if grep -q "namespace" "$TWITTER_LOGIN_BUILD_GRADLE"; then - echo "Namespace already present in twitter_login build.gradle, skipping patch" - exit 0 -fi - -# Add namespace to android block -# Use different sed syntax for macOS vs Linux -if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - sed -i.bak '/^android {$/a\ - namespace '\''com.maru.twitter_login'\'' -' "$TWITTER_LOGIN_BUILD_GRADLE" -else - # Linux - sed -i '/^android {$/a\ namespace '\''com.maru.twitter_login'\''' "$TWITTER_LOGIN_BUILD_GRADLE" -fi - -echo "Successfully patched twitter_login build.gradle with namespace" diff --git a/tests/android/app/build.gradle b/tests/android/app/build.gradle index 326533d2..b495ca5b 100644 --- a/tests/android/app/build.gradle +++ b/tests/android/app/build.gradle @@ -8,8 +8,7 @@ plugins { android { namespace = "io.flutter.plugins.firebase.tests" - // use "flutter.compileSdkVersion" and bump AGP once twitter_login has released v4.4.3: https://github.com/0maru/twitter_login/issues/139 - compileSdk 36 + compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { diff --git a/tests/android/settings.gradle b/tests/android/settings.gradle index c211ea54..7d38085a 100644 --- a/tests/android/settings.gradle +++ b/tests/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.3" apply false + id "com.android.application" version "8.9.1" apply false id "org.jetbrains.kotlin.android" version "2.3.0" apply false id "com.google.gms.google-services" version "4.4.2" apply false } diff --git a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart index da5424dd..eb9cd95a 100644 --- a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart @@ -2,6 +2,8 @@ // 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:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,53 +12,54 @@ import 'package:firebase_ui_localizations/firebase_ui_localizations.dart'; import 'package:firebase_ui_oauth/firebase_ui_oauth.dart'; import 'package:firebase_ui_oauth_twitter/firebase_ui_oauth_twitter.dart'; import 'package:mockito/mockito.dart'; -import 'package:twitter_login/twitter_login.dart'; -import 'package:twitter_login/entity/auth_result.dart' as twe; import '../utils.dart'; void main() async { - late TwitterProvider provider = TwitterProvider( - apiKey: 'apiKey', - apiSecretKey: 'apiSecretKey', - ); + final provider = TwitterProvider(); + late MockAuth auth; + late MockProvider fbProvider; + + const labels = DefaultLocalizations(); setUp(() { - provider.provider = MockTwitterLogin(); + auth = MockAuth(); + fbProvider = MockProvider(); + provider.firebaseAuthProvider = fbProvider; setMockTwitterProvider(provider); }); - const labels = DefaultLocalizations(); - group( 'Sign in with Twitter button', () { testWidgets('has a correct button label', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); expect(find.text(labels.signInWithTwitterButtonText), findsOneWidget); }); testWidgets('calls sign in when tapped', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); final button = find.byType(OAuthProviderButtonBase); await tester.tap(button); await tester.pumpAndSettle(); - verify(provider.provider.login()).called(1); - - expect(true, isTrue); + verify(auth.signInWithProvider(fbProvider)).called(1); }); testWidgets('shows loading indicator when sign in is in progress', ( tester, ) async { - await render(tester, OAuthProviderButton(provider: provider)); - - when(provider.provider.login()).thenAnswer((realInvocation) async { - await Future.delayed(const Duration(milliseconds: 50)); - return MockAuthResult(); - }); + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); final button = find.byType(OAuthProviderButtonBase); await tester.tap(button); @@ -66,48 +69,130 @@ void main() async { }); testWidgets('signs the user in', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton(provider: provider, auth: auth), + ), + ); final button = find.byType(OAuthProviderButtonBase); await tester.tap(button); await tester.pumpAndSettle(); - final user = auth.currentUser!; + final result = verify(listener.call(captureAny)); + expect(result.captured[1], isA()); + final user = (result.captured[1] as SignedIn).user!; 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; + + await render( + tester, + OAuthProviderButton(provider: provider, auth: auth), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + verify(anonymousUser.linkWithProvider(fbProvider)).called(1); + verifyNever(auth.signInWithProvider(fbProvider)); + }); + + test('throws when AuthAction.none is used', () { + provider.auth = auth; + + expect( + () => provider.mobileSignIn(AuthAction.none), + throwsUnsupportedError, + ); + }); }, skip: !provider.supportsPlatform(defaultTargetPlatform), ); + + group('TwitterProvider', () { + test('throws from desktopSignInArgs when the API keys are missing', () { + expect(() => TwitterProvider().desktopSignInArgs, throwsArgumentError); + }); + }); +} + +class MockListener extends Mock { + void call(AuthState? state) { + super.noSuchMethod(Invocation.method(#call, [state])); + } } -// Mock JWT with the following payload: -// { -// "sub": "1234567890", -// "name": "Test User", -// "email": "test@test.com", -// "iat": 1516239022 -// } -const _jwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsImlhdCI6MTUxNjIzOTAyMn0.m5qYto_Vs5ELTURC8rkD-JAJuoosdQZeuUZ_qFrEiaE'; - -class MockAuthResult extends Mock implements twe.AuthResult { +class MockUser extends Mock implements fba.User { + @override + String? get displayName => 'Test User'; + + @override + String? get email => 'test@test.com'; + @override - TwitterLoginStatus? get status => TwitterLoginStatus.loggedIn; + bool get isAnonymous => false; +} + +class MockAnonymousUser extends Mock implements fba.User { @override - String? get authToken => _jwt; + bool get isAnonymous => true; + @override - String? get authTokenSecret => 'secret'; + Future linkWithProvider(Object provider) async { + return super.noSuchMethod( + Invocation.method(#linkWithProvider, [provider]), + returnValue: Future.value(MockCredential()), + returnValueForMissingStub: Future.value(MockCredential()), + ); + } } -class MockTwitterLogin extends Mock implements TwitterLogin { +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.TwitterAuthProvider {} + +class MockApp extends Mock implements FirebaseApp {} + +class MockAuth extends Mock implements fba.FirebaseAuth { + fba.User? currentUserOverride; + + @override + fba.User? get currentUser => currentUserOverride; + @override - Future login({bool? forceLogin}) async { + Future signInWithProvider(Object provider) async { return super.noSuchMethod( - Invocation.method(#signIn, []), - returnValue: MockAuthResult(), - returnValueForMissingStub: MockAuthResult(), + Invocation.method(#signInWithProvider, [provider]), + returnValue: Future.delayed( + const Duration(milliseconds: 500), + ).then((_) => MockCredential()), + returnValueForMissingStub: Future.delayed( + const Duration(milliseconds: 500), + ).then((_) => MockCredential()), ); } } diff --git a/tests/pubspec.yaml b/tests/pubspec.yaml index e8b94245..ff1fd576 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -21,7 +21,6 @@ dependencies: firebase_ui_oauth_google: ^2.1.1 firebase_ui_oauth: ^2.1.1 flutter_facebook_auth: ^7.1.2 - twitter_login: ^4.4.2 firebase_ui_oauth_twitter: ^2.1.1 cloud_firestore: 6.9.0 firebase_ui_firestore: ^2.1.0 From af474af49c0f8e32667505dbb50193cb744303fb Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:01:40 +0100 Subject: [PATCH 02/12] fix(ui_oauth_twitter): report an error when linking without a signed in user AuthAction.link with no FirebaseAuth.currentUser null-shorted the linkWithProvider call, so the flow neither completed nor reported an error and the UI stayed in its loading state. The credential path this replaced raised through auth.currentUser!, so a null user was at least surfaced. Reports a FirebaseAuthException instead, which reaches AuthFailed rather than escaping as an unhandled Error the way a StateError would. --- .../lib/src/provider.dart | 22 ++++++++++++-- .../twitter_sign_in_test.dart | 29 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart index 88606b29..717e0697 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -107,8 +107,26 @@ class TwitterProvider extends OAuthProvider { // 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( + FirebaseAuthException( + code: 'no-current-user', + message: + 'AuthAction.link requires a signed in user to link the ' + 'Twitter credential to, but FirebaseAuth.currentUser is null.', + ), + ); + return; + } + + currentUser + .linkWithProvider(firebaseAuthProvider) .then(_onLinked) .catchError(authListener.onError); return; diff --git a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart index eb9cd95a..7350a134 100644 --- a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart @@ -113,6 +113,35 @@ void main() async { 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)); + }); + test('throws when AuthAction.none is used', () { provider.auth = auth; From e487748ddff3487dc64ff24aebd2c46f169ab4ad Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:06:26 +0100 Subject: [PATCH 03/12] ci(e2e): correct stale version comment on the setup-gradle pin The pin comment claimed v6 while the pinned SHA is v6.2.0, and upstream has since moved the v6 tag to v6.3.0. zizmor flagged the mismatch as a medium severity finding, which blocks the workflow check. Corrects the comment rather than moving the pin, so the action version CI runs is unchanged. --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 309dbb03..525e0c01 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -48,7 +48,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Gradle cache - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: AVD cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: avd-cache From 438e445eae108de6c9eb4aa21ee93683dba124e0 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:16:53 +0100 Subject: [PATCH 04/12] docs(ui_oauth_twitter): document the signInWithProvider setup Rewrites the Twitter section around the flow Firebase now performs on Android and iOS: enable the provider in the console, point the X app callback URL at the Firebase auth handler, then add the encoded app ID URL scheme on iOS and the SHA-1 on Android. The twitter_login install step is gone, and the API key and secret move into their own macOS and Windows section, since those are the only platforms that still need them. Adds the encoded app ID scheme to the firebase_ui_auth example and drops the ffire:// redirect it passed for twitter_login, which no longer takes part in either flow. --- docs/firebase-ui-auth/providers/oauth.md | 72 ++++++++++++++----- .../example/ios/Runner/Info.plist | 8 +++ .../firebase_ui_auth/example/lib/config.dart | 1 - .../firebase_ui_auth/example/lib/main.dart | 3 +- .../firebase_ui_oauth/example/lib/main.dart | 2 +- 5 files changed, 67 insertions(+), 19 deletions(-) diff --git a/docs/firebase-ui-auth/providers/oauth.md b/docs/firebase-ui-auth/providers/oauth.md index 665e7691..9ea6f7e0 100644 --- a/docs/firebase-ui-auth/providers/oauth.md +++ b/docs/firebase-ui-auth/providers/oauth.md @@ -156,20 +156,27 @@ See [Custom screens section](#custom-screens) to learn how to use a button on yo ## Twitter Login -To support Twitter as a provider, first install the [`twitter_login`](https://pub.dev/packages/twitter_login) -plugin to your project and make sure you've performed necessary configuration as described on [README](https://pub.dev/packages/twitter_login). - -Next, enable the "Twitter" provider in the Firebase Console: +Enable the "Twitter" provider in the Firebase Console, and give it the API key and secret from your +app in the [X developer portal](https://developer.twitter.com/en/portal/projects-and-apps): ![Enable Twitter Provider](../images/ui-twitter-provider.jpg) -You will also need to install [`firebase_ui_oauth_twitter`](https://pub.dev/packages/firebase_ui_oauth_twitter): +![Twitter app id](../images/ui-twitter-app-id.png) + +Then set the "Callback URL" of that same X app to the Firebase auth handler, which the Firebase +Console shows when you enable the provider: + +``` +https://.firebaseapp.com/__/auth/handler +``` + +Install [`firebase_ui_oauth_twitter`](https://pub.dev/packages/firebase_ui_oauth_twitter): ```sh flutter pub add firebase_ui_oauth_twitter ``` -And add a provider to the configuration: +And add the provider to the configuration: ```dart Future main() async { @@ -177,30 +184,63 @@ Future main() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); FirebaseUIAuth.configureProviders([ - TwitterProvider( - apiKey: TWITTER_API_KEY, - apiSecretKey: TWITTER_API_SECRET_KEY, - ), + TwitterProvider(), ]); } ``` Now all pre-built screens that support multiple providers (such as `RegisterScreen`, `SignInScreen`, `ProfileScreen` and others) will have a themed button. -You can get the `apiKey` and `apiSecretKey` from the Firebase Console or [twitter developer portal](https://developer.twitter.com/en/portal/projects-and-apps). +### Android and iOS setup -![Twitter app id](../images/ui-twitter-app-id.png) +On Android and iOS, Firebase performs the sign in itself, so the API key and secret stay in the +Firebase Console and are never shipped in your app. -Providing the `apiSecretKey` directly is not advised if you are building for the web. Instead, you can use "dart-define" to ensure that the value is omitted from web builds: +On **iOS**, add your encoded app ID as a URL scheme in `ios/Runner/Info.plist`. You will find it in +the Firebase Console under Project settings, listed as the App ID for your iOS app, with `:` +replaced by `-`: + +```xml +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + app-1-1234567890-ios-0a1b2c3d4e5f6g7h8i9j + + + +``` + +On **Android**, add your app's SHA-1 fingerprint in the Firebase Console under Project settings, so +Firebase can verify the sign in request. + +### macOS and Windows setup + +Firebase does not support this sign in flow on macOS or Windows, so on those platforms +`firebase_ui_oauth_twitter` performs the OAuth 1.0a flow itself and does need the API key and +secret: + +```dart +TwitterProvider( + apiKey: TWITTER_API_KEY, + apiSecretKey: TWITTER_API_SECRET_KEY, +), +``` + +They are ignored on Android, iOS and the web, and `TwitterProvider` throws if they are missing on a +platform that needs them. + +Because the secret is embedded in desktop builds, pass it at build time rather than committing it: ```bash flutter run --dart-define TWITTER_SECRET= ``` -When building the app on platforms other than the web, the `TWITTER_SECRET` environment variable can be defined using: - ```dart -apiSecretKey: String.fromEnvironment('TWITTER_SECRET', ''), +apiSecretKey: String.fromEnvironment('TWITTER_SECRET'), ``` See [Custom screens section](#custom-screens) to learn how to use a button on your custom screen. diff --git a/packages/firebase_ui_auth/example/ios/Runner/Info.plist b/packages/firebase_ui_auth/example/ios/Runner/Info.plist index a2b5c4fc..891a4417 100644 --- a/packages/firebase_ui_auth/example/ios/Runner/Info.plist +++ b/packages/firebase_ui_auth/example/ios/Runner/Info.plist @@ -40,6 +40,14 @@ fb128693022464535 + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + app-1-406099696497-ios-24bb8dcaefc434a73574d0 + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) diff --git a/packages/firebase_ui_auth/example/lib/config.dart b/packages/firebase_ui_auth/example/lib/config.dart index 37ed2221..3553e75b 100644 --- a/packages/firebase_ui_auth/example/lib/config.dart +++ b/packages/firebase_ui_auth/example/lib/config.dart @@ -23,6 +23,5 @@ const GOOGLE_REDIRECT_URI = const TWITTER_API_KEY = String.fromEnvironment('TWITTER_API_KEY'); const TWITTER_API_SECRET_KEY = String.fromEnvironment('TWITTER_API_SECRET_KEY'); -const TWITTER_REDIRECT_URI = 'ffire://'; const FACEBOOK_CLIENT_ID = '128693022464535'; diff --git a/packages/firebase_ui_auth/example/lib/main.dart b/packages/firebase_ui_auth/example/lib/main.dart index ebcb22ca..a7293c7c 100644 --- a/packages/firebase_ui_auth/example/lib/main.dart +++ b/packages/firebase_ui_auth/example/lib/main.dart @@ -43,10 +43,11 @@ Future main() async { GoogleProvider(clientId: GOOGLE_CLIENT_ID), AppleProvider(), FacebookProvider(clientId: FACEBOOK_CLIENT_ID), + // apiKey and apiSecretKey are only used on macOS and Windows. Android and + // iOS sign in through Firebase, which holds the credentials itself. TwitterProvider( apiKey: TWITTER_API_KEY, apiSecretKey: TWITTER_API_SECRET_KEY, - redirectUri: TWITTER_REDIRECT_URI, ), ]); diff --git a/packages/firebase_ui_oauth/example/lib/main.dart b/packages/firebase_ui_oauth/example/lib/main.dart index e7cd367f..ae636a8f 100644 --- a/packages/firebase_ui_oauth/example/lib/main.dart +++ b/packages/firebase_ui_oauth/example/lib/main.dart @@ -109,7 +109,7 @@ class _ContentState extends State { 'Sign in with Google', ), _button( - TwitterProvider(apiKey: '', apiSecretKey: '', redirectUri: ''), + TwitterProvider(), 'Sign in with Twitter', ), ], From 6ab1da1632b89461f843ce593283139ab623ed50 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:38:31 +0100 Subject: [PATCH 05/12] fix(tests): bump the Gradle wrapper to 8.11.1 for AGP 8.9.1 AGP 8.9.1 requires Gradle 8.11.1 or later, and the tests app wrapper was on 8.10, so assembleDebug failed with a version-check error as soon as the AGP pin moved. Only the tests app is affected. The example apps keep their own lower AGP versions and wrappers. --- tests/android/gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/android/gradle/wrapper/gradle-wrapper.properties b/tests/android/gradle/wrapper/gradle-wrapper.properties index 6cb8454c..efdcc4ac 100644 --- a/tests/android/gradle/wrapper/gradle-wrapper.properties +++ b/tests/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip From 854588cdf3648476bb49786ce2b4ea2436d633b0 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:38:31 +0100 Subject: [PATCH 06/12] chore(ui_oauth): format the twitter example button call --- packages/firebase_ui_oauth/example/lib/main.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/firebase_ui_oauth/example/lib/main.dart b/packages/firebase_ui_oauth/example/lib/main.dart index ae636a8f..a8bffe72 100644 --- a/packages/firebase_ui_oauth/example/lib/main.dart +++ b/packages/firebase_ui_oauth/example/lib/main.dart @@ -108,10 +108,7 @@ class _ContentState extends State { GoogleProvider(clientId: '', redirectUri: '', scopes: []), 'Sign in with Google', ), - _button( - TwitterProvider(), - 'Sign in with Twitter', - ), + _button(TwitterProvider(), 'Sign in with Twitter'), ], ), ), From f61e9c060fb92440633030533c36b70ab68a2ff6 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:24:18 +0100 Subject: [PATCH 07/12] fix(ui_oauth_twitter): report auth failures instead of throwing Errors Independent review found that three of the new failure paths raised Error subtypes into the auth listener. defaultOnAuthError rethrows anything that is not a FirebaseAuthException, and both AuthFlow.onError and the button's handler catch only Exception, so those Errors escaped and left the flow in its loading state permanently. - AuthAction.none now reports a FirebaseAuthException rather than throwing UnsupportedError out of the tap handler. - Missing OAuth 1.0a credentials are checked in desktopSignIn, before the base flow reads desktopSignInArgs synchronously, so the ArgumentError is no longer reachable from the UI. - Empty strings count as missing credentials, since String.fromEnvironment yields an empty string and that is the documented way to supply them. The debug diagnostic no longer fires on them either. - User cancellation is mapped back to a flow reset. The native SDKs report dismissal as a FirebaseAuthException, which was being rendered as a sign in failure where the previous flow reset silently. - The linked credential is no longer force unwrapped, since it is nullable on every platform. Also makes the anonymous upgrade test pass AuthAction.signIn explicitly. The flow resolves to AuthAction.link whenever currentUser is non-null, so the test was passing without the shouldUpgradeAnonymous branch being consulted. --- docs/firebase-ui-auth/providers/oauth.md | 32 ++++- .../lib/src/provider.dart | 109 ++++++++++++++++-- .../twitter_sign_in_test.dart | 102 +++++++++++++++- 3 files changed, 225 insertions(+), 18 deletions(-) diff --git a/docs/firebase-ui-auth/providers/oauth.md b/docs/firebase-ui-auth/providers/oauth.md index 9ea6f7e0..1aa7fccd 100644 --- a/docs/firebase-ui-auth/providers/oauth.md +++ b/docs/firebase-ui-auth/providers/oauth.md @@ -230,8 +230,9 @@ TwitterProvider( ), ``` -They are ignored on Android, iOS and the web, and `TwitterProvider` throws if they are missing on a -platform that needs them. +They are ignored on Android, iOS and the web. On a platform that needs them, sign in fails with a +`FirebaseAuthException` rather than proceeding with empty credentials, so an absent +`--dart-define` surfaces as a clear error. Because the secret is embedded in desktop builds, pass it at build time rather than committing it: @@ -243,6 +244,33 @@ flutter run --dart-define TWITTER_SECRET= apiSecretKey: String.fromEnvironment('TWITTER_SECRET'), ``` +### Upgrading from 2.x + +Version 3.0.0 moved Android and iOS sign in from the `twitter_login` package to Firebase's own +provider flow. Your code will still compile unchanged, but sign in fails at runtime on those two +platforms until you update the configuration: + +1. In the [X developer portal](https://developer.twitter.com/en/portal/projects-and-apps), set the + app's Callback URL to `https://.firebaseapp.com/__/auth/handler`. X accepts + several callback URLs, so you can add it alongside the custom scheme you use today and keep an + older build of your app working while you roll out. +2. Add the encoded app ID URL scheme to `ios/Runner/Info.plist`, as described above. +3. Register your Android SHA-1 fingerprint in the Firebase Console. +4. Remove `twitter_login` from your `pubspec.yaml` if you depended on it directly, along with the + callback intent filter it needed in `AndroidManifest.xml`. + +`apiKey` and `apiSecretKey` are now optional. Drop them unless you ship for macOS or Windows. + +Two behaviours also changed on Android and iOS, both because Firebase signs the user in as part of +returning the credential: + +- `AuthAction.none` fails with a `FirebaseAuthException`, where it previously handed you a + credential without signing in. +- The credential passed to `onCredentialLinked` is a plain `AuthCredential` rather than an + `OAuthCredential`, so it carries no `secret` and cannot be cast to `OAuthCredential`. + +macOS, Windows and the web are unaffected. + See [Custom screens section](#custom-screens) to learn how to use a button on your custom screen. ## Custom screens diff --git a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart index 717e0697..c2c21ef4 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -43,12 +43,23 @@ class TwitterProvider extends OAuthProvider { @override TwitterAuthProvider firebaseAuthProvider = TwitterAuthProvider(); + /// Whether both OAuth 1.0a credentials are usable. + /// + /// `String.fromEnvironment` yields an empty string rather than null when the + /// define is absent, and that is the documented way to supply these, so an + /// empty value has to count as missing too. + bool get _hasDesktopCredentials => + (apiKey?.isNotEmpty ?? false) && (apiSecretKey?.isNotEmpty ?? false); + @override TwitterSignInArgs get desktopSignInArgs { final apiKey = this.apiKey; final apiSecretKey = this.apiSecretKey; - if (apiKey == null || apiSecretKey == null) { + if (apiKey == null || + apiKey.isEmpty || + apiSecretKey == null || + apiSecretKey.isEmpty) { throw ArgumentError( 'TwitterProvider.apiKey and TwitterProvider.apiSecretKey are required ' 'on $defaultTargetPlatform, which signs in using the OAuth 1.0a flow. ' @@ -74,7 +85,9 @@ class TwitterProvider extends OAuthProvider { /// first signal the developer gets is a sign in that fails in the browser. void _warnIfKeysAreIgnored() { if (!kDebugMode || _warnedAboutIgnoredKeys) return; - if (apiKey == null && apiSecretKey == null) return; + if (!(apiKey?.isNotEmpty ?? false) && !(apiSecretKey?.isNotEmpty ?? false)) { + return; + } _warnedAboutIgnoredKeys = true; @@ -91,15 +104,53 @@ class TwitterProvider extends OAuthProvider { ); } + /// Whether [error] is the user dismissing the sign in sheet. + /// + /// The native SDKs surface this as a `FirebaseAuthException` rather than a + /// cancellation, so it has to be recognised by code. Android reports the + /// underlying `ERROR_`-prefixed constant while Apple platforms report the + /// hyphenated form, and both spellings of "cancelled" are in use. + bool _isUserCancellation(Object error) { + if (error is! FirebaseAuthException) return false; + + var code = error.code.toLowerCase().replaceAll('_', '-'); + if (code.startsWith('error-')) code = code.substring('error-'.length); + + return const { + 'web-context-cancelled', + 'web-context-canceled', + 'user-cancelled', + 'user-canceled', + }.contains(code); + } + + void _onError(Object error) { + if (_isUserCancellation(error)) { + authListener.onCanceled(); + return; + } + + authListener.onError(error); + } + @override void mobileSignIn(AuthAction action) { if (action == AuthAction.none) { - throw UnsupportedError( - 'AuthAction.none is not supported by TwitterProvider on ' - '$defaultTargetPlatform. Firebase signs the user in as part of ' - 'obtaining the credential, so the credential cannot be returned ' - 'without also creating a session.', + // Reported rather than thrown: signIn() has already moved the flow into + // its loading state, and an Error raised here would escape both + // AuthFlow.onError and the button's handler, which catch only Exception, + // leaving the button spinning forever. + authListener.onError( + FirebaseAuthException( + code: 'unsupported-auth-action', + message: + 'AuthAction.none is not supported by TwitterProvider on ' + '$defaultTargetPlatform. Firebase signs the user in as part of ' + 'obtaining the credential, so the credential cannot be returned ' + 'without also creating a session.', + ), ); + return; } _warnIfKeysAreIgnored(); @@ -128,14 +179,36 @@ class TwitterProvider extends OAuthProvider { currentUser .linkWithProvider(firebaseAuthProvider) .then(_onLinked) - .catchError(authListener.onError); + .catchError(_onError); return; } auth .signInWithProvider(firebaseAuthProvider) .then(authListener.onSignedIn) - .catchError(authListener.onError); + .catchError(_onError); + } + + @override + void desktopSignIn(AuthAction action) { + // desktopSignInArgs is read synchronously by the desktop flow, outside any + // error handling, so a throw there would escape as an Error and hang the + // UI. Check first and report through the listener instead. + if (!_hasDesktopCredentials) { + authListener.onError( + FirebaseAuthException( + code: 'missing-oauth-credentials', + message: + 'TwitterProvider.apiKey and TwitterProvider.apiSecretKey are ' + 'required on $defaultTargetPlatform, which signs in using the ' + 'OAuth 1.0a flow. Android and iOS use the Firebase native ' + 'provider flow and do not need them.', + ), + ); + return; + } + + super.desktopSignIn(action); } @override @@ -157,6 +230,22 @@ class TwitterProvider extends OAuthProvider { } void _onLinked(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( + FirebaseAuthException( + code: 'missing-credential', + message: + 'The Twitter account was linked, but Firebase returned no ' + 'credential for it.', + ), + ); + return; + } + + authListener.onCredentialLinked(credential); } } diff --git a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart index 7350a134..1383af6a 100644 --- a/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_twitter/twitter_sign_in_test.dart @@ -100,9 +100,16 @@ void main() 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), + OAuthProviderButton( + provider: provider, + auth: auth, + action: AuthAction.signIn, + ), ); final button = find.byType(OAuthProviderButtonBase); @@ -142,14 +149,90 @@ void main() async { verifyNever(auth.signInWithProvider(fbProvider)); }); - test('throws when AuthAction.none is used', () { - provider.auth = auth; + testWidgets('reports an error rather than hanging for AuthAction.none', ( + 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.none, + ), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isA()); + expect(find.byType(CircularProgressIndicator), findsNothing); + verifyNever(auth.signInWithProvider(fbProvider)); + }); + + testWidgets('resets the flow when the user cancels', (tester) async { + auth.signInErrorOverride = fba.FirebaseAuthException( + code: 'web-context-cancelled', + ); - expect( - () => provider.mobileSignIn(AuthAction.none), - throwsUnsupportedError, + final listener = MockListener(); + + await render( + tester, + AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton(provider: provider, auth: auth), + ), ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isNot(isA())); + expect(find.byType(CircularProgressIndicator), findsNothing); }); + + testWidgets( + 'reports an error rather than hanging when desktop keys are missing', + (tester) async { + final listener = MockListener(); + + await render( + tester, + Theme( + data: ThemeData(platform: TargetPlatform.macOS), + child: AuthStateListener( + listener: (oldState, state, controller) { + listener(state); + return null; + }, + child: OAuthProviderButton(provider: provider, auth: auth), + ), + ), + ); + + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); + + final result = verify(listener.call(captureAny)); + expect(result.captured.last, isA()); + }, + ); }, skip: !provider.supportsPlatform(defaultTargetPlatform), ); @@ -208,12 +291,19 @@ class MockApp extends Mock implements FirebaseApp {} class MockAuth extends Mock implements fba.FirebaseAuth { fba.User? currentUserOverride; + Object? signInErrorOverride; @override fba.User? get currentUser => currentUserOverride; @override Future signInWithProvider(Object provider) async { + final error = signInErrorOverride; + if (error != null) { + await Future.delayed(const Duration(milliseconds: 50)); + throw error; + } + return super.noSuchMethod( Invocation.method(#signInWithProvider, [provider]), returnValue: Future.delayed( From c62181f499b882331374d349b697b6057d0f7087 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:39:13 +0100 Subject: [PATCH 08/12] fix(ui_oauth_twitter): correct the diagnostic callback URL and two stale comments The debug diagnostic told developers to set their Twitter app callback URL to redirectUri when one was supplied, but the native flow on Android and iOS ignores redirectUri and always completes through the Firebase auth handler, so the advice guaranteed failure for exactly the consumers migrating from the custom scheme twitter_login needed. It now names the handler. The desktop flow still honours redirectUri, so its own use of it is unchanged. Also corrects the class doc, which listed Game Center alongside Apple as exempt from the macOS restriction. Game Center is rejected on every platform, for its own reason, so Apple is the only exception. And removes a comment in the example that lost its subject when the twitter_login dependency was deleted. --- packages/firebase_ui_auth/example/pubspec.yaml | 1 - packages/firebase_ui_oauth_twitter/lib/src/provider.dart | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/firebase_ui_auth/example/pubspec.yaml b/packages/firebase_ui_auth/example/pubspec.yaml index 7ae211d2..618a45d1 100644 --- a/packages/firebase_ui_auth/example/pubspec.yaml +++ b/packages/firebase_ui_auth/example/pubspec.yaml @@ -38,7 +38,6 @@ dependencies: firebase_ui_oauth_facebook: ^2.1.1 firebase_ui_oauth_google: ^2.1.1 firebase_ui_oauth_twitter: ^2.1.1 - # This and twitter oauth package need to depend on git main directly due to namespace build error on android. dev_dependencies: drive: ^1.0.0-1.0.nullsafety.5 firebase_ui_shared: ^1.5.0 diff --git a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart index c2c21ef4..3d039ebf 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -17,8 +17,7 @@ import 'theme.dart'; /// /// macOS and Windows still perform the OAuth 1.0a flow in-process and do /// require [apiKey] and [apiSecretKey]. `signInWithProvider` rejects every -/// provider except Apple and Game Center on macOS, so the desktop flow stays -/// in place there. +/// provider except Apple on macOS, so the desktop flow stays in place there. class TwitterProvider extends OAuthProvider { @override final providerId = 'twitter.com'; @@ -97,7 +96,7 @@ class TwitterProvider extends OAuthProvider { 'reads the Twitter API key and secret from the Firebase console. They ' 'are still used on macOS and Windows.\n' 'If sign in fails, check that the Twitter app callback URL is ' - '"${redirectUri ?? defaultRedirectUri}", and that you have added the ' + '"$defaultRedirectUri", and that you have added the ' 'Encoded App ID URL scheme (iOS) or your SHA-1 fingerprint ' '(Android). See ' 'https://github.com/firebase/FirebaseUI-Flutter/blob/main/docs/firebase-ui-auth/providers/oauth.md#twitter-login', From 69c19133f26c0e7a62cb7b684be811303181fa2f Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:48:48 +0100 Subject: [PATCH 09/12] fix(ui_auth): navigate to the profile after a sign in that returns no email The example's sign in handler matched only emailVerified users and unverified users that have an email, so a user with neither matched no case and the screen stayed put after signing in successfully. Twitter only returns an email when the app asks for it, and such a user is never emailVerified, so Twitter sign in landed in that gap. The app's own initialRoute already treats those users as signed in via its catch all, so this brings the in flow navigation in line with what a relaunch does. --- packages/firebase_ui_auth/example/lib/main.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/firebase_ui_auth/example/lib/main.dart b/packages/firebase_ui_auth/example/lib/main.dart index a7293c7c..1fae4426 100644 --- a/packages/firebase_ui_auth/example/lib/main.dart +++ b/packages/firebase_ui_auth/example/lib/main.dart @@ -135,10 +135,16 @@ class FirebaseAuthUIExample extends StatelessWidget { }; switch (user) { - case User(emailVerified: true): - Navigator.pushReplacementNamed(context, '/profile'); case User(emailVerified: false, email: final String _): Navigator.pushNamed(context, '/verify-email'); + // Providers are not obliged to return an email. Twitter only + // does when the app requests it, and such a user is never + // emailVerified, so without this they matched no case and + // the screen sat there after a successful sign in. + case User(): + Navigator.pushReplacementNamed(context, '/profile'); + case null: + break; } }), mfaAction, From fded7e7585e09cde2333013dc842dc5ef5eff36a Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:50:20 +0100 Subject: [PATCH 10/12] fix(ui_auth): stop EmailVerificationController crashing on resume when signed out reload() read auth.currentUser! and runs on every app resume, so resuming with no signed in user threw an unhandled TypeError rather than an Exception, which escapes the flow's error handling entirely. Cancelling an OAuth sign in is the easiest way to hit it: the provider takes the app to the background, cancelling brings it back, and currentUser is still null because the sign in never completed. Any app switch while signed out does the same, so this is not specific to a provider. The constructor already guards currentUser for null, so this brings reload() in line with the rest of the class rather than changing its contract. --- .../lib/src/email_verification.dart | 7 +++++ .../test/email_verification_test.dart | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 packages/firebase_ui_auth/test/email_verification_test.dart diff --git a/packages/firebase_ui_auth/lib/src/email_verification.dart b/packages/firebase_ui_auth/lib/src/email_verification.dart index 731de045..15c8452d 100644 --- a/packages/firebase_ui_auth/lib/src/email_verification.dart +++ b/packages/firebase_ui_auth/lib/src/email_verification.dart @@ -90,7 +90,14 @@ class EmailVerificationController extends ValueNotifier } /// Reloads firebase user and updates the [state]. + /// + /// Does nothing when no user is signed in. This runs on every app resume, + /// which includes resuming with no user at all: returning from a cancelled + /// OAuth sign in brings the app back to the foreground while + /// [fba.FirebaseAuth.currentUser] is still null. Future reload() async { + if (auth.currentUser == null) return; + await user.reload(); if (user.email == null) { diff --git a/packages/firebase_ui_auth/test/email_verification_test.dart b/packages/firebase_ui_auth/test/email_verification_test.dart new file mode 100644 index 00000000..69219b97 --- /dev/null +++ b/packages/firebase_ui_auth/test/email_verification_test.dart @@ -0,0 +1,29 @@ +// 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_ui_auth/firebase_ui_auth.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +void main() { + // The controller registers a lifecycle observer in its constructor. + TestWidgetsFlutterBinding.ensureInitialized(); + + group('EmailVerificationController', () { + test('reload does nothing when no user is signed in', () async { + final auth = MockAuth(); + final controller = EmailVerificationController( + auth, + appLinks: MockAppLinks(), + ); + + // reload() runs on every app resume, including resumes that happen with + // no user at all, such as returning from a cancelled OAuth sign in. + // It used to read auth.currentUser! and throw an unhandled TypeError. + await expectLater(controller.reload(), completes); + expect(controller.state, EmailVerificationState.unresolved); + }); + }); +} From e7c7d62faa8be5e316f84783c2b0c4dfda455012 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:59:55 +0100 Subject: [PATCH 11/12] chore(ui_oauth_twitter): wrap the diagnostic guard condition --- packages/firebase_ui_oauth_twitter/lib/src/provider.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart index 3d039ebf..d4356326 100644 --- a/packages/firebase_ui_oauth_twitter/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_twitter/lib/src/provider.dart @@ -84,7 +84,8 @@ class TwitterProvider extends OAuthProvider { /// first signal the developer gets is a sign in that fails in the browser. void _warnIfKeysAreIgnored() { if (!kDebugMode || _warnedAboutIgnoredKeys) return; - if (!(apiKey?.isNotEmpty ?? false) && !(apiSecretKey?.isNotEmpty ?? false)) { + if (!(apiKey?.isNotEmpty ?? false) && + !(apiSecretKey?.isNotEmpty ?? false)) { return; } From 0749c38172eff51926c9c8f6f2af42ed8e8f73aa Mon Sep 17 00:00:00 2001 From: Ademola Fadumo <48495111+demolaf@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:16:21 +0100 Subject: [PATCH 12/12] docs(ui_oauth_twitter): expand the 2.x upgrade note with what testing found Device testing surfaced three things the original note could not have covered, because none of them is visible from the source. - Registering the Android SHA-1 is not sufficient on its own. The certificate hash is embedded in google-services.json at download time, so the file has to be re-downloaded afterwards. The note now carries the error string Firebase shows when either step is missed, since that is what consumers will actually search for. - Firebase occasionally opens the full browser rather than a Chrome Custom Tab, and abandoning the flow there leaves the operation pending until the app restarts. Recorded as a known limitation, since nothing in the Dart layer can resolve a future the SDK never completes. - Cancelling returns silently, which is worth stating because it is indistinguishable from nothing having happened. Also documents that redirectUri is ignored on Android and iOS, which was missing, and splits the section into what you must change, what behaviour changed, and the known limitation. --- docs/firebase-ui-auth/providers/oauth.md | 83 +++++++++++++++++------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/docs/firebase-ui-auth/providers/oauth.md b/docs/firebase-ui-auth/providers/oauth.md index 1aa7fccd..395dab9c 100644 --- a/docs/firebase-ui-auth/providers/oauth.md +++ b/docs/firebase-ui-auth/providers/oauth.md @@ -246,30 +246,69 @@ apiSecretKey: String.fromEnvironment('TWITTER_SECRET'), ### Upgrading from 2.x -Version 3.0.0 moved Android and iOS sign in from the `twitter_login` package to Firebase's own -provider flow. Your code will still compile unchanged, but sign in fails at runtime on those two -platforms until you update the configuration: - -1. In the [X developer portal](https://developer.twitter.com/en/portal/projects-and-apps), set the - app's Callback URL to `https://.firebaseapp.com/__/auth/handler`. X accepts - several callback URLs, so you can add it alongside the custom scheme you use today and keep an - older build of your app working while you roll out. -2. Add the encoded app ID URL scheme to `ios/Runner/Info.plist`, as described above. -3. Register your Android SHA-1 fingerprint in the Firebase Console. -4. Remove `twitter_login` from your `pubspec.yaml` if you depended on it directly, along with the - callback intent filter it needed in `AndroidManifest.xml`. - -`apiKey` and `apiSecretKey` are now optional. Drop them unless you ship for macOS or Windows. - -Two behaviours also changed on Android and iOS, both because Firebase signs the user in as part of -returning the credential: - -- `AuthAction.none` fails with a `FirebaseAuthException`, where it previously handed you a - credential without signing in. +Version 3.0.0 moves Twitter sign in on Android and iOS from the `twitter_login` package to +Firebase's own provider flow. Your code keeps compiling, but sign in fails at runtime on those two +platforms until you update the configuration below. macOS, Windows and the web are unaffected. + +#### What you must change + +1. **Callback URL.** In the [X developer portal](https://developer.twitter.com/en/portal/projects-and-apps), + set your app's Callback URL to the Firebase auth handler: + + ``` + https://.firebaseapp.com/__/auth/handler + ``` + + X accepts several callback URLs, so you can add this alongside the custom scheme you use today + and keep an older build of your app working while you roll out. + +2. **iOS.** Add your encoded app ID as a URL scheme in `ios/Runner/Info.plist`, as described in the + setup section above. Without it, the sign in sheet completes but never returns to your app. + +3. **Android.** Register your app's SHA-1 fingerprint in the Firebase Console, then **re-download + `google-services.json`**. Adding the fingerprint alone is not enough, because the certificate + hash is embedded in that file when you download it. If you skip either step, sign in fails with: + + ``` + There was an error while trying to get your package certificate hash. + ``` + +4. **Remove `twitter_login`** from your `pubspec.yaml` if you depended on it directly, along with + the callback intent filter it required in `AndroidManifest.xml`. + +5. **`apiKey` and `apiSecretKey` are now optional.** Remove them unless you ship for macOS or + Windows, which still perform the OAuth 1.0a flow in process and still need them. They are + ignored on Android, iOS and the web. + +#### Behaviour changes + +Firebase signs the user in as part of returning the credential, which changes three things on +Android and iOS: + +- `AuthAction.none` now fails with a `FirebaseAuthException` instead of handing you a credential + without signing in. There is no way to obtain the credential without also creating a session. - The credential passed to `onCredentialLinked` is a plain `AuthCredential` rather than an - `OAuthCredential`, so it carries no `secret` and cannot be cast to `OAuthCredential`. + `OAuthCredential`. It carries no `secret` and cannot be cast to `OAuthCredential`. +- `redirectUri` is ignored. Firebase always completes through its own auth handler. It is still + honoured on macOS and Windows. + +Cancelling sign in returns you to the app silently, with no error shown, which matches the previous +behaviour. + +#### Known limitation on Android + +Firebase usually opens the sign in link in a Chrome Custom Tab inside your app's task, and +dismissing it returns to your app. Occasionally it opens the full browser in its own task instead. +If the user abandons the flow there, the pending operation never resolves and further attempts fail +with: + +``` +A headful operation is already in progress. Please wait for that to finish. +``` -macOS, Windows and the web are unaffected. +Restarting the app clears it. This comes from the Firebase Android SDK rather than +`firebase_ui_oauth_twitter`, and there is nothing the Dart layer can do about a sign in the SDK +never completes. See [Custom screens section](#custom-screens) to learn how to use a button on your custom screen.