diff --git a/packages/firebase_ui_auth/example/pubspec.yaml b/packages/firebase_ui_auth/example/pubspec.yaml index 4d354e42..a9f2ce8a 100644 --- a/packages/firebase_ui_auth/example/pubspec.yaml +++ b/packages/firebase_ui_auth/example/pubspec.yaml @@ -49,7 +49,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - google_sign_in: ^6.2.1 + google_sign_in: ^7.1.0 http: ^1.1.2 integration_test: sdk: flutter diff --git a/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart b/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart index 5d1b3a16..a8c3c55f 100644 --- a/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart +++ b/packages/firebase_ui_oauth_google/lib/firebase_ui_oauth_google.dart @@ -16,6 +16,7 @@ class GoogleSignInButton extends _GoogleSignInButton { super.key, required super.loadingIndicator, required super.clientId, + super.serverClientId, super.redirectUri, super.scopes, super.action = null, @@ -36,6 +37,7 @@ class GoogleSignInIconButton extends _GoogleSignInButton { const GoogleSignInIconButton({ super.key, required super.clientId, + super.serverClientId, required super.loadingIndicator, super.scopes, super.action = null, @@ -75,6 +77,7 @@ class _GoogleSignInButton extends StatelessWidget { final SignedInCallback? onSignedIn; final double size; final String clientId; + final String? serverClientId; final String? redirectUri; final List? scopes; final void Function(Exception exception)? onError; @@ -83,6 +86,7 @@ class _GoogleSignInButton extends StatelessWidget { const _GoogleSignInButton({ super.key, required this.clientId, + this.serverClientId, required this.loadingIndicator, this.scopes, String? label, @@ -106,6 +110,7 @@ class _GoogleSignInButton extends StatelessWidget { return GoogleProvider( clientId: clientId, + serverClientId: serverClientId, redirectUri: redirectUri, scopes: scopes ?? [], ); diff --git a/packages/firebase_ui_oauth_google/lib/src/provider.dart b/packages/firebase_ui_oauth_google/lib/src/provider.dart index 48e2f0dc..69c1a0e1 100644 --- a/packages/firebase_ui_oauth_google/lib/src/provider.dart +++ b/packages/firebase_ui_oauth_google/lib/src/provider.dart @@ -17,6 +17,14 @@ class GoogleProvider extends OAuthProvider { /// Ignored on Android and iOS (if `iOSPreferPlist` is true). final String clientId; + /// The client ID of the web OAuth client associated with the app's + /// server-side component, if any. + /// + /// On Android this is required to receive an ID token, unless the app uses + /// `google-services.json` and it contains a web OAuth client entry, in + /// which case the plugin reads the value from there. + final String? serverClientId; + /// When true, the Google Sign In plugin will use the GoogleService-Info.plist /// for configuration instead of the `clientId` parameter. final bool iOSPreferPlist; @@ -28,7 +36,23 @@ class GoogleProvider extends OAuthProvider { /// The list of requested authorization scopes requested when signing in. final List? scopes; - late GoogleSignIn provider; + /// The plugin instance. google_sign_in 7 exposes a single shared instance. + /// Assignable so that tests can inject a mock. + GoogleSignIn provider = GoogleSignIn.instance; + + // google_sign_in 7 requires initialize to be called exactly once, while + // multiple GoogleProvider instances may be created (for example one per + // GoogleSignInButton). The first instance to sign in configures the plugin. + static Future? _initialization; + + /// Resets the one-time initialization state. + /// + /// The plugin is initialized exactly once per process, so tests that inject + /// a fresh mock in `setUp` must clear the cached future to stay isolated. + @visibleForTesting + static void debugReset() { + _initialization = null; + } @override final fba.GoogleAuthProvider firebaseAuthProvider = fba.GoogleAuthProvider(); @@ -44,6 +68,7 @@ class GoogleProvider extends OAuthProvider { GoogleProvider({ required this.clientId, + this.serverClientId, this.redirectUri, this.scopes, this.iOSPreferPlist = false, @@ -51,12 +76,6 @@ class GoogleProvider extends OAuthProvider { firebaseAuthProvider.setCustomParameters(const { 'prompt': 'select_account', }); - - if (_ignoreClientId()) { - provider = GoogleSignIn(scopes: scopes ?? []); - } else { - provider = GoogleSignIn(clientId: clientId, scopes: scopes ?? []); - } } bool _ignoreClientId() { @@ -68,25 +87,55 @@ class GoogleProvider extends OAuthProvider { return false; } + Future _ensureInitialized() { + return _initialization ??= provider.initialize( + clientId: _ignoreClientId() ? null : clientId, + serverClientId: serverClientId, + ).catchError((Object err) { + // Allow a later sign-in attempt to retry initialization instead of + // rethrowing the same stale error forever. + _initialization = null; + throw err; + }); + } + @override void mobileSignIn(AuthAction action) async { - provider - .signIn() - .then((user) { - if (user == null) throw AuthCancelledException(); - return user.authentication; - }) - .then((auth) { - final credential = fba.GoogleAuthProvider.credential( - accessToken: auth.accessToken, - idToken: auth.idToken, - ); - - onCredentialReceived(credential, action); - }) - .catchError((err) { - authListener.onError(err); - }); + final requestedScopes = scopes ?? const []; + + try { + await _ensureInitialized(); + + final account = await provider.authenticate(scopeHint: requestedScopes); + + // Authentication and authorization are separate steps in + // google_sign_in 7. Reuse an existing authorization when one is + // available, otherwise prompt for the requested scopes so that the + // credential carries an access token, matching the previous behavior. + String? accessToken; + if (requestedScopes.isNotEmpty) { + final client = account.authorizationClient; + final authorization = + await client.authorizationForScopes(requestedScopes) ?? + await client.authorizeScopes(requestedScopes); + accessToken = authorization.accessToken; + } + + final credential = fba.GoogleAuthProvider.credential( + accessToken: accessToken, + idToken: account.authentication.idToken, + ); + + onCredentialReceived(credential, action); + } on GoogleSignInException catch (err) { + if (err.code == GoogleSignInExceptionCode.canceled) { + authListener.onError(AuthCancelledException()); + } else { + authListener.onError(err); + } + } catch (err) { + authListener.onError(err); + } } @override @@ -112,6 +161,7 @@ class GoogleProvider extends OAuthProvider { if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) { + await _ensureInitialized(); await provider.signOut(); } } diff --git a/packages/firebase_ui_oauth_google/pubspec.yaml b/packages/firebase_ui_oauth_google/pubspec.yaml index 30f292f1..e119086c 100644 --- a/packages/firebase_ui_oauth_google/pubspec.yaml +++ b/packages/firebase_ui_oauth_google/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: firebase_ui_oauth: ^2.1.0 flutter: sdk: flutter - google_sign_in: ^6.2.1 + google_sign_in: ^7.1.0 dev_dependencies: flutter_test: diff --git a/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart b/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart index a6880940..938516dd 100644 --- a/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart +++ b/tests/integration_test/firebase_ui_oauth_google/google_sign_in_test.dart @@ -14,86 +14,87 @@ import 'package:mockito/mockito.dart'; import '../utils.dart'; +const _scopes = ['scope1', 'scope2']; + void main() async { late GoogleProvider provider = GoogleProvider( clientId: 'clientId', redirectUri: 'redirectUri', - scopes: const ['scope1', 'scope2'], + scopes: _scopes, ); setUp(() { + GoogleProvider.debugReset(); provider.provider = MockGoogleSignIn(); setMockGoogleProvider(provider); }); const labels = DefaultLocalizations(); - group( - 'Sign in with Google button', - () { - testWidgets('has a correct button label', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); - expect(find.text(labels.signInWithGoogleButtonText), findsOneWidget); - }); - - testWidgets('calls sign in when tapped', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + group('Sign in with Google button', () { + testWidgets('has a correct button label', (tester) async { + await render(tester, OAuthProviderButton(provider: provider)); + expect(find.text(labels.signInWithGoogleButtonText), findsOneWidget); + }); - final button = find.byType(OAuthProviderButtonBase); - await tester.tap(button); + testWidgets('calls sign in when tapped', (tester) async { + await render(tester, OAuthProviderButton(provider: provider)); - await tester.pumpAndSettle(); - verify(provider.provider.signIn()).called(1); + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); - expect(true, isTrue); - }); + await tester.pumpAndSettle(); + verify(provider.provider.authenticate(scopeHint: _scopes)).called(1); - testWidgets('shows loading indicator when sign in is in progress', ( - tester, - ) async { - await render(tester, OAuthProviderButton(provider: provider)); + expect(true, isTrue); + }); - when(provider.provider.signIn()).thenAnswer((realInvocation) async { - await Future.delayed(const Duration(milliseconds: 50)); - return MockGoogleSignInAccount(); - }); + testWidgets('shows loading indicator when sign in is in progress', ( + tester, + ) async { + await render(tester, OAuthProviderButton(provider: provider)); - final button = find.byType(OAuthProviderButtonBase); - await tester.tap(button); - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); + when(provider.provider.authenticate(scopeHint: _scopes)).thenAnswer(( + realInvocation, + ) async { + await Future.delayed(const Duration(milliseconds: 50)); + return MockGoogleSignInAccount(); }); - testWidgets('signs the user in', (tester) async { - await render(tester, OAuthProviderButton(provider: provider)); + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pump(); - final button = find.byType(OAuthProviderButtonBase); - await tester.tap(button); - await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); - final user = auth.currentUser!; + testWidgets('signs the user in', (tester) async { + await render(tester, OAuthProviderButton(provider: provider)); - expect(user.displayName, 'Test User'); - expect(user.email, 'test@test.com'); - }); + final button = find.byType(OAuthProviderButtonBase); + await tester.tap(button); + await tester.pumpAndSettle(); - testWidgets('works standalone', (tester) async { - await render( - tester, - const GoogleSignInButton( - loadingIndicator: CircularProgressIndicator(), - clientId: 'test', - ), - ); + final user = auth.currentUser!; - final button = find.byType(GoogleSignInButton); - await tester.tap(button); - await tester.pump(); - }); - }, - skip: !provider.supportsPlatform(defaultTargetPlatform), - ); + expect(user.displayName, 'Test User'); + expect(user.email, 'test@test.com'); + }); + + testWidgets('works standalone', (tester) async { + await render( + tester, + const GoogleSignInButton( + loadingIndicator: CircularProgressIndicator(), + clientId: 'test', + ), + ); + + final button = find.byType(GoogleSignInButton); + await tester.tap(button); + await tester.pump(); + }); + }, skip: !provider.supportsPlatform(defaultTargetPlatform)); } // Mock JWT with the following payload: @@ -106,25 +107,49 @@ void main() async { const _jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImVtYWlsIjoidGVzdEB0ZXN0LmNvbSIsImlhdCI6MTUxNjIzOTAyMn0.m5qYto_Vs5ELTURC8rkD-JAJuoosdQZeuUZ_qFrEiaE'; -class MockAuthentication extends Mock implements GoogleSignInAuthentication { +class MockAuthorizationClient extends Mock + implements GoogleSignInAuthorizationClient { @override - final String accessToken = _jwt; + Future authorizationForScopes( + List scopes, + ) async { + return const GoogleSignInClientAuthorization(accessToken: _jwt); + } } // ignore: must_be_immutable class MockGoogleSignInAccount extends Mock implements GoogleSignInAccount { @override - Future get authentication async => - MockAuthentication(); + GoogleSignInAuthentication get authentication => + const GoogleSignInAuthentication(idToken: _jwt); + + @override + GoogleSignInAuthorizationClient get authorizationClient => + MockAuthorizationClient(); } class MockGoogleSignIn extends Mock implements GoogleSignIn { @override - Future signIn() async { + Future initialize({ + String? clientId, + String? serverClientId, + String? nonce, + String? hostedDomain, + }) async {} + + @override + Future authenticate({ + List scopeHint = const [], + }) { return super.noSuchMethod( - Invocation.method(#signIn, []), - returnValue: MockGoogleSignInAccount(), - returnValueForMissingStub: MockGoogleSignInAccount(), - ); + Invocation.method(#authenticate, [], {#scopeHint: scopeHint}), + returnValue: Future.value( + MockGoogleSignInAccount(), + ), + returnValueForMissingStub: Future.value( + MockGoogleSignInAccount(), + ), + ) + as Future; } } diff --git a/tests/pubspec.yaml b/tests/pubspec.yaml index 0c1a21fa..958a7aee 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: cloud_firestore: ^6.6.0 firebase_ui_firestore: ^2.1.0 http: ^1.1.2 - google_sign_in: ^6.2.1 + google_sign_in: ^7.1.0 firebase_ui_shared: ^1.5.0 firebase_database: ^12.4.4 firebase_ui_database: ^2.1.0