diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 124b689e716..fb703941712 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,6 +1,13 @@ -## NEXT - -* Updates README to indicate that Andoid SDK <21 is no longer supported. +## 7.0.0 + +* **BREAKING CHANGE**: Many APIs have changed or been replaced to reflect the + current APIs and best practices of the underlying platform SDKs. For full + details, see the README and migration guide, but notable highlights include: + * The `GoogleSignIn` instance is now a singleton. + * Clients must call and await the new `initialize` method before calling any + other methods on the instance. + * Authentication and authorization are now separate steps. + * Access tokens and server auth codes are obtained via separate calls. ## 6.3.0 diff --git a/packages/google_sign_in/google_sign_in/MIGRATION.md b/packages/google_sign_in/google_sign_in/MIGRATION.md new file mode 100644 index 00000000000..1369767d102 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/MIGRATION.md @@ -0,0 +1,63 @@ +# Migrating from `google_sign_in` 6.x to 7.x + +The API of `google_sign_in` 6.x and earlier was designed for the Google Sign-In +SDK, which has been deprecated on both Android and Web, and replaced with new +SDKs that have significantly different structures. As a result, the +`google_sign_in` API surface has changed significantly. Notable differences +include: +* `GoogleSignIn` is now a singleton, which is obtained via + `GoogleSignIn.instance`. In practice, creating multiple `GoogleSignIn` + instances in 6.x would not work correctly, so this just enforces an existing + restriction. +* There is now an explicit `initialize` step that must be called exactly once, + before any other methods. On some platforms the future will complete almost + immediately, but on others (for example, web) it may take some time. +* The plugin no longer tracks a single "current" signed in user. Instead, + applications that assume a single signed in user should track this at the + application level using the `authenticationEvents` stream. +* Authentication (signing in) and authorization (allowing access to user data + in the form of scopes) are now separate steps. Recommended practice is to + authenticate as soon as it makes sense for a user to potentially be signed in, + but to delay authorization until the point where the data will actually be + used. + * In applications where these steps should happen at the same time, you can + pass a `scopeHint` during the authentication step. On platforms that support + it this allows for a combined authentication and authorization UI flow. + Not all platforms allow combining these flows, so your application should be + prepared to trigger a separate authorization prompt if necessary. + * Authorization is further separated into client and server authorization. + Applications that need a `serverAuthCode` must now call a separate method, + `authorizeServer`, to obtain that code. + * Client authorization is handled via two new methods: + * `authorizationForScopes`, which returns an access token if the requested + scopes are already authorized, or null if not, and + * `authorizeScopes`, which requests that the user authorize the scopes, and + is expected to show UI. + + Clients should generally attempt to get tokens via `authorizationForScopes`, + and if they are unable to do so, show some UI to request authoriaztion that + calls `authorizeScopes`. This is similar to the previously web-only flow + of calling `canAccessScopes` and then calling `addScopes` if necessary. +* `signInSilently` has been replaced with `attemptLightweightAuthentication`. + The intended usage is essentially the same, but the change reflects that it + is no longer guaranteed to be silent. For example, as of the publishing of + 7.0, on web this may show a floating sign-in card, and on Android it may show + an account selection sheet. + * This new method is no longer guaranteed to return a future. This allows + clients to distinguish, at runtime: + * platforms where a definitive "signed in" or "not signed in" response + can be returned quickly, and thus `await`-ing completion is reasonable, + in which case a `Future` is returned, and + * platforms (such as web) where it could take an arbitrary amount of time, + in which case no `Future` is returned, and clients should assume a + non-signed-in state until/unless a sign-in event is eventually posted to + the `authenticationEvents` stream. +* `authenticate` replaces the authentication portion of `signIn` on platforms + that support it (see below). +* The new `supportsAuthenticate` method allows clients to determine at runtime + whether the `authenticate` method is supported, as some platforms do not allow + custom UI to trigger explicit authentication. These platforms instead provide + some other platform-specific way of triggering authentication. As of + publishing, the only platform that does not support `authenticate` is web, + where `google_sign_in_web`'s `renderButton` is used to create a sign-in + button. diff --git a/packages/google_sign_in/google_sign_in/README.md b/packages/google_sign_in/google_sign_in/README.md index 2bcac7b3768..772977ca322 100644 --- a/packages/google_sign_in/google_sign_in/README.md +++ b/packages/google_sign_in/google_sign_in/README.md @@ -6,155 +6,104 @@ A Flutter plugin for [Google Sign In](https://developers.google.com/identity/). |-------------|---------|-------|--------|-----| | **Support** | SDK 21+ | 12.0+ | 10.15+ | Any | -## Platform integration +## Setup -### Android integration - -To access Google Sign-In, you'll need to make sure to -[register your application](https://firebase.google.com/docs/android/setup). - -You don't need to include the google-services.json file in your app unless you -are using Google services that require it. You do need to enable the OAuth APIs -that you want, using the -[Google Cloud Platform API manager](https://console.developers.google.com/). For -example, if you want to mimic the behavior of the Google Sign-In sample app, -you'll need to enable the -[Google People API](https://developers.google.com/people/). - -Make sure you've filled out all required fields in the console for -[OAuth consent screen](https://console.developers.google.com/apis/credentials/consent). -Otherwise, you may encounter `APIException` errors. - -### iOS integration - -Please see [instructions on integrating Google Sign-In for iOS](https://pub.dev/packages/google_sign_in_ios#ios-integration). - -#### iOS additional requirement - -Note that according to -https://developer.apple.com/sign-in-with-apple/get-started, starting June 30, -2020, apps that use login services must also offer a "Sign in with Apple" option -when submitting to the Apple App Store. - -Consider also using an Apple sign in plugin from pub.dev. - -The Flutter Favorite -[sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) plugin could -be an option. - -### macOS integration - -Please see [instructions on integrating Google Sign-In for macOS](https://pub.dev/packages/google_sign_in_ios#macos-setup). - -### Web integration +### Import the package -The new SDK used by the web has fully separated Authentication from Authorization, -so `signIn` and `signInSilently` no longer authorize OAuth `scopes`. +To use this plugin, follow the +[plugin installation instructions](https://pub.dev/packages/google_sign_in/install), +then follow the platform integration steps below for all platforms you support. -Flutter apps must be able to detect what scopes have been granted by their users, -and if the grants are still valid. +### Platform integration -Read below about **Working with scopes, and incremental authorization** for -general information about changes that may be needed on an app, and for more -specific web integration details, see the -[`google_sign_in_web` package](https://pub.dev/packages/google_sign_in_web). +* **Android**: Please see [the `google_sign_in_android` README](https://pub.dev/packages/google_sign_in_android#integration). +* **iOS**: Please see [the `google_sign_in_ios` README](https://pub.dev/packages/google_sign_in_ios#ios-integration). +* **macOS**: Please see [the `google_sign_in_ios` README](https://pub.dev/packages/google_sign_in_ios#macos-integration) (which also supports macOS). +* **Web**: Please see [the `google_sign_in_web` README](https://pub.dev/packages/google_sign_in_web#integration). ## Usage -### Import the package +### Initialization and authentication -To use this plugin, follow the -[plugin installation instructions](https://pub.dev/packages/google_sign_in/install). +Initialize the `GoogleSignIn` instance, and (optionally) start the lightweight +authentication process: -### Use the plugin - -Initialize `GoogleSignIn` with the scopes you want: - - + ```dart -const List scopes = [ - 'email', - 'https://www.googleapis.com/auth/contacts.readonly', -]; - -GoogleSignIn _googleSignIn = GoogleSignIn( - // Optional clientId - // clientId: 'your-client_id.apps.googleusercontent.com', - scopes: scopes, -); +final GoogleSignIn signIn = GoogleSignIn.instance; +unawaited(signIn + .initialize(clientId: clientId, serverClientId: serverClientId) + .then((_) { + signIn.authenticationEvents + .listen(_handleAuthenticationEvent) + .onError(_handleAuthenticationError); + + /// This example always uses the stream-based approach to determining + /// which UI state to show, rather than using the future returned here, + /// if any, to conditionally skip directly to the signed-in state. + signIn.attemptLightweightAuthentication(); +})); ``` -[Full list of available scopes](https://developers.google.com/identity/protocols/googlescopes). - -You can now use the `GoogleSignIn` class to authenticate in your Dart code, e.g. +If the user isn't signed in by the lightweight method, you can show UI to +start a sign-in flow. This uses `authenticate` on platforms that return true +for `supportsAuthenticate`, otherwise applications should fall back to a +platform-specific approach. For instance, user-initiated sign in on web must +use a button rendered by the sign in SDK, rather than application-provided +UI: - + ```dart -Future _handleSignIn() async { - try { - await _googleSignIn.signIn(); - } catch (error) { - print(error); - } -} +if (GoogleSignIn.instance.supportsAuthenticate()) + ElevatedButton( + onPressed: () async { + try { + await GoogleSignIn.instance.authenticate(); + } catch (e) { + // ··· + } + }, + child: const Text('SIGN IN'), + ) +else ...[ + if (kIsWeb) + web.renderButton() + // ··· +] ``` -In the web, you should use the **Google Sign In button** (and not the `signIn` method) -to guarantee that your user authentication contains a valid `idToken`. - -For more details, take a look at the -[`google_sign_in_web` package](https://pub.dev/packages/google_sign_in_web). - -## Working with scopes, and incremental authorization. - -If your app supports both mobile and web, read this section! +## Authorization ### Checking if scopes have been granted -Users may (or may *not*) grant all the scopes that an application requests at -Sign In. In fact, in the web, no scopes are granted by `signIn`, `silentSignIn` -or the `renderButton` widget anymore. - -Applications must be able to: - -* Detect if the authenticated user has authorized the scopes they need. -* Determine if the scopes that were granted a few minutes ago are still valid. - -There's a new method that enables the checks above, `canAccessScopes`: +If the user has previously authorized the scopes required by your application, +you can silently request an access token for those scopes: - + ```dart -// In mobile, being authenticated means being authorized... -bool isAuthorized = account != null; -// However, on web... -if (kIsWeb && account != null) { - isAuthorized = await _googleSignIn.canAccessScopes(scopes); -} +const List scopes = [ + 'https://www.googleapis.com/auth/contacts.readonly', +]; + final GoogleSignInAccount? user = // ... + final GoogleSignInClientAuthorization? authorization = + await user?.authorizationClient.authorizationForScopes(scopes); ``` -_(Only implemented in the web platform, from version 6.1.0 of this package)_ +[Full list of available scopes](https://developers.google.com/identity/protocols/googlescopes). ### Requesting more scopes when needed If an app determines that the user hasn't granted the scopes it requires, it -should initiate an Authorization request. (Remember that in the web platform, -this request **must be initiated from an user interaction**, like a button press). +should initiate an Authorization request. On platforms where +`authorizationRequiresUserInteraction()` returns true, +this request **must be initiated from a user interaction** like a button press. - + ```dart -Future _handleAuthorizeScopes() async { - final bool isAuthorized = await _googleSignIn.requestScopes(scopes); - if (isAuthorized) { - unawaited(_handleGetContact(_currentUser!)); - } +final GoogleSignInClientAuthorization authorization = + await user.authorizationClient.authorizeScopes(scopes); ``` -The `requestScopes` returns a `boolean` value that is `true` if the user has -granted all the requested scopes or `false` otherwise. - -Once your app determines that the current user `isAuthorized` to access the -services for which you need `scopes`, it can proceed normally. - ### Authorization expiration In the web, **the `accessToken` is no longer refreshed**. It expires after 3600 @@ -162,26 +111,40 @@ seconds (one hour), so your app needs to be able to handle failed REST requests, and update its UI to prompt the user for a new Authorization round. This can be done by combining the error responses from your REST requests with -the `canAccessScopes` and `requestScopes` methods described above. +the authorization methods described above. For more details, take a look at the [`google_sign_in_web` package](https://pub.dev/packages/google_sign_in_web). -### Does an app always need to check `canAccessScopes`? +### Requesting a server auth code -The new web SDK implicitly grant access to the `email`, `profile` and `openid` -scopes when users complete the sign-in process (either via the One Tap UX or the -Google Sign In button). +If your application needs to access user data from a backend server, you can +request a server auth code to send to the server: -If an app only needs an `idToken`, or only requests permissions to any/all of -the three scopes mentioned above -([OpenID Connect scopes](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect)), -it won't need to implement any additional scope handling. + +```dart +final GoogleSignInServerAuthorization? serverAuth = + await user.authorizationClient.authorizeServer(scopes); +``` + +Server auth codes are not always available on all platforms. For instance, on +some platforms they may only be returned when a user initially signs in, and +not for subsequent authentications via the lightweight process. If you +need a server auth code you should request it as soon as possible after initial +sign-in, and manage server tokens for that user entirely on the server side +unless the signed in user changes. -If an app needs any scope other than `email`, `profile` and `openid`, it **must** -implement a more complete scope handling, as described above. +On platforms where `authorizationRequiresUserInteraction()` returns true, +this request **must be initiated from a user interaction** like a button press. ## Example -Find the example wiring in the -[Google sign-in example application](https://github.com/flutter/packages/blob/main/packages/google_sign_in/google_sign_in/example/lib/main.dart). +The +[Google Sign-In example application](https://github.com/flutter/packages/blob/main/packages/google_sign_in/google_sign_in/example/lib/main.dart) demonstrates one approach to using this +package to sign a user in and authorize access to specific user data. + +## Migration from pre-7.0 versions + +If you used version 6.x or earlier of `google_sign_in`, see +[the migration guide](https://github.com/flutter/packages/blob/main/packages/google_sign_in/google_sign_in/MIGRATION.md) +for more information about the changes. diff --git a/packages/google_sign_in/google_sign_in/example/integration_test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in/example/integration_test/google_sign_in_test.dart index 54e454c28f4..303d17d3d2c 100644 --- a/packages/google_sign_in/google_sign_in/example/integration_test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in/example/integration_test/google_sign_in_test.dart @@ -10,7 +10,9 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Can initialize the plugin', (WidgetTester tester) async { - final GoogleSignIn signIn = GoogleSignIn(); + final GoogleSignIn signIn = GoogleSignIn.instance; expect(signIn, isNotNull); + + await signIn.initialize(); }); } diff --git a/packages/google_sign_in/google_sign_in/example/lib/main.dart b/packages/google_sign_in/google_sign_in/example/lib/main.dart index 576ec36feff..aeed2430dc6 100644 --- a/packages/google_sign_in/google_sign_in/example/lib/main.dart +++ b/packages/google_sign_in/google_sign_in/example/lib/main.dart @@ -12,21 +12,22 @@ import 'package:flutter/material.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:http/http.dart' as http; -import 'src/sign_in_button.dart'; +import 'src/web_wrapper.dart' as web; + +/// To run this example, replace this value with your client ID, and/or +/// update the relevant configuration files, as described in the README. +String? clientId; + +/// To run this example, replace this value with your server client ID, and/or +/// update the relevant configuration files, as described in the README. +String? serverClientId; /// The scopes required by this application. -// #docregion Initialize +// #docregion CheckAuthorization const List scopes = [ - 'email', 'https://www.googleapis.com/auth/contacts.readonly', ]; - -GoogleSignIn _googleSignIn = GoogleSignIn( - // Optional clientId - // clientId: 'your-client_id.apps.googleusercontent.com', - scopes: scopes, -); -// #enddocregion Initialize +// #enddocregion CheckAuthorization void main() { runApp( @@ -50,40 +51,67 @@ class _SignInDemoState extends State { GoogleSignInAccount? _currentUser; bool _isAuthorized = false; // has granted permissions? String _contactText = ''; + String _errorMessage = ''; + String _serverAuthCode = ''; @override void initState() { super.initState(); - _googleSignIn.onCurrentUserChanged - .listen((GoogleSignInAccount? account) async { -// #docregion CanAccessScopes - // In mobile, being authenticated means being authorized... - bool isAuthorized = account != null; - // However, on web... - if (kIsWeb && account != null) { - isAuthorized = await _googleSignIn.canAccessScopes(scopes); - } -// #enddocregion CanAccessScopes + // #docregion Setup + final GoogleSignIn signIn = GoogleSignIn.instance; + unawaited(signIn + .initialize(clientId: clientId, serverClientId: serverClientId) + .then((_) { + signIn.authenticationEvents + .listen(_handleAuthenticationEvent) + .onError(_handleAuthenticationError); - setState(() { - _currentUser = account; - _isAuthorized = isAuthorized; - }); + /// This example always uses the stream-based approach to determining + /// which UI state to show, rather than using the future returned here, + /// if any, to conditionally skip directly to the signed-in state. + signIn.attemptLightweightAuthentication(); + })); + // #enddocregion Setup + } - // Now that we know that the user can access the required scopes, the app - // can call the REST API. - if (isAuthorized) { - unawaited(_handleGetContact(account!)); - } + Future _handleAuthenticationEvent( + GoogleSignInAuthenticationEvent event) async { + // #docregion CheckAuthorization + final GoogleSignInAccount? user = // ... + // #enddocregion CheckAuthorization + switch (event) { + GoogleSignInAuthenticationEventSignIn() => event.user, + GoogleSignInAuthenticationEventSignOut() => null, + }; + + // Check for existing authorization. + // #docregion CheckAuthorization + final GoogleSignInClientAuthorization? authorization = + await user?.authorizationClient.authorizationForScopes(scopes); + // #enddocregion CheckAuthorization + + setState(() { + _currentUser = user; + _isAuthorized = authorization != null; + _errorMessage = ''; }); - // In the web, _googleSignIn.signInSilently() triggers the One Tap UX. - // - // It is recommended by Google Identity Services to render both the One Tap UX - // and the Google Sign In button together to "reduce friction and improve - // sign-in rates" ([docs](https://developers.google.com/identity/gsi/web/guides/display-button#html)). - _googleSignIn.signInSilently(); + // If the user has already granted access to the required scopes, call the + // REST API. + if (user != null && authorization != null) { + unawaited(_handleGetContact(user)); + } + } + + Future _handleAuthenticationError(Object e) async { + setState(() { + _currentUser = null; + _isAuthorized = false; + _errorMessage = e is GoogleSignInException + ? 'GoogleSignInException ${e.code}: ${e.description}' + : 'Unknown error: $e'; + }); } // Calls the People API REST endpoint for the signed-in user to retrieve information. @@ -91,17 +119,34 @@ class _SignInDemoState extends State { setState(() { _contactText = 'Loading contact info...'; }); + final Map? headers = + await user.authorizationClient.authorizationHeaders(scopes); + if (headers == null) { + setState(() { + _contactText = ''; + _errorMessage = 'Failed to construct authorization headers.'; + }); + return; + } final http.Response response = await http.get( Uri.parse('https://people.googleapis.com/v1/people/me/connections' '?requestMask.includeField=person.names'), - headers: await user.authHeaders, + headers: headers, ); if (response.statusCode != 200) { - setState(() { - _contactText = 'People API gave a ${response.statusCode} ' - 'response. Check logs for details.'; - }); - print('People API ${response.statusCode} response: ${response.body}'); + if (response.statusCode == 401 || response.statusCode == 403) { + setState(() { + _isAuthorized = false; + _errorMessage = 'People API gave a ${response.statusCode} response. ' + 'Please re-authorize access.'; + }); + } else { + print('People API ${response.statusCode} response: ${response.body}'); + setState(() { + _contactText = 'People API gave a ${response.statusCode} ' + 'response. Check logs for details.'; + }); + } return; } final Map data = @@ -136,94 +181,147 @@ class _SignInDemoState extends State { return null; } - // This is the on-click handler for the Sign In button that is rendered by Flutter. + // Prompts the user to authorize `scopes`. // - // On the web, the on-click handler of the Sign In button is owned by the JS - // SDK, so this method can be considered mobile only. - // #docregion SignIn - Future _handleSignIn() async { + // If authorizationRequiresUserInteraction() is true, this must be called from + // a user interaction (button click). In this example app, a button is used + // regardless, so authorizationRequiresUserInteraction() is not checked. + Future _handleAuthorizeScopes(GoogleSignInAccount user) async { try { - await _googleSignIn.signIn(); - } catch (error) { - print(error); + // #docregion RequestScopes + final GoogleSignInClientAuthorization authorization = + await user.authorizationClient.authorizeScopes(scopes); + // #enddocregion RequestScopes + + // The returned tokens are ignored since _handleGetContact uses the + // authorizationHeaders method to re-read the token cached by + // authorizeScopes. The code above is used as a README excerpt, so shows + // the simpler pattern of getting the authorization for immediate use. + // That results in an unused variable, which this statement suppresses + // (without adding an ignore: directive to the README excerpt). + // ignore: unnecessary_statements + authorization; + + setState(() { + _isAuthorized = true; + _errorMessage = ''; + }); + unawaited(_handleGetContact(_currentUser!)); + } on GoogleSignInException catch (e) { + _errorMessage = 'GoogleSignInException ${e.code}: ${e.description}'; } } - // #enddocregion SignIn - // Prompts the user to authorize `scopes`. + // Requests a server auth code for the authorized scopes. // - // This action is **required** in platforms that don't perform Authentication - // and Authorization at the same time (like the web). - // - // On the web, this must be called from an user interaction (button click). - // #docregion RequestScopes - Future _handleAuthorizeScopes() async { - final bool isAuthorized = await _googleSignIn.requestScopes(scopes); - // #enddocregion RequestScopes - setState(() { - _isAuthorized = isAuthorized; - }); - // #docregion RequestScopes - if (isAuthorized) { - unawaited(_handleGetContact(_currentUser!)); + // If authorizationRequiresUserInteraction() is true, this must be called from + // a user interaction (button click). In this example app, a button is used + // regardless, so authorizationRequiresUserInteraction() is not checked. + Future _handleGetAuthCode(GoogleSignInAccount user) async { + try { + // #docregion RequestServerAuth + final GoogleSignInServerAuthorization? serverAuth = + await user.authorizationClient.authorizeServer(scopes); + // #enddocregion RequestServerAuth + + setState(() { + _serverAuthCode = serverAuth == null ? '' : serverAuth.serverAuthCode; + }); + } on GoogleSignInException catch (e) { + _errorMessage = 'GoogleSignInException ${e.code}: ${e.description}'; } - // #enddocregion RequestScopes } - Future _handleSignOut() => _googleSignIn.disconnect(); + Future _handleSignOut() async { + // Disconnect instead of just signing out, to reset the example state as + // much as possible. + await GoogleSignIn.instance.disconnect(); + } Widget _buildBody() { final GoogleSignInAccount? user = _currentUser; - if (user != null) { - // The user is Authenticated - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - ListTile( - leading: GoogleUserCircleAvatar( - identity: user, - ), - title: Text(user.displayName ?? ''), - subtitle: Text(user.email), - ), - const Text('Signed in successfully.'), - if (_isAuthorized) ...[ - // The user has Authorized all required scopes - Text(_contactText), - ElevatedButton( - child: const Text('REFRESH'), - onPressed: () => _handleGetContact(user), - ), - ], - if (!_isAuthorized) ...[ - // The user has NOT Authorized all required scopes. - // (Mobile users may never see this button!) - const Text('Additional permissions needed to read your contacts.'), - ElevatedButton( - onPressed: _handleAuthorizeScopes, - child: const Text('REQUEST PERMISSIONS'), - ), - ], + return Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (user != null) + ..._buildAuthenticatedWidgets(user) + else + ..._buildUnauthenticatedWidgets(), + if (_errorMessage.isNotEmpty) Text(_errorMessage), + ], + ); + } + + /// Returns the list of widgets to include if the user is authenticated. + List _buildAuthenticatedWidgets(GoogleSignInAccount user) { + return [ + // The user is Authenticated. + ListTile( + leading: GoogleUserCircleAvatar( + identity: user, + ), + title: Text(user.displayName ?? ''), + subtitle: Text(user.email), + ), + const Text('Signed in successfully.'), + if (_isAuthorized) ...[ + // The user has Authorized all required scopes. + if (_contactText.isNotEmpty) Text(_contactText), + ElevatedButton( + child: const Text('REFRESH'), + onPressed: () => _handleGetContact(user), + ), + if (_serverAuthCode.isEmpty) ElevatedButton( - onPressed: _handleSignOut, - child: const Text('SIGN OUT'), - ), - ], - ); - } else { - // The user is NOT Authenticated - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const Text('You are not currently signed in.'), - // This method is used to separate mobile from web code with conditional exports. - // See: src/sign_in_button.dart - buildSignInButton( - onPressed: _handleSignIn, - ), - ], - ); - } + child: const Text('REQUEST SERVER CODE'), + onPressed: () => _handleGetAuthCode(user), + ) + else + Text('Server auth code:\n$_serverAuthCode'), + ] else ...[ + // The user has NOT Authorized all required scopes. + const Text('Authorization needed to read your contacts.'), + ElevatedButton( + onPressed: () => _handleAuthorizeScopes(user), + child: const Text('REQUEST PERMISSIONS'), + ), + ], + ElevatedButton( + onPressed: _handleSignOut, + child: const Text('SIGN OUT'), + ), + ]; + } + + /// Returns the list of widgets to include if the user is not authenticated. + List _buildUnauthenticatedWidgets() { + return [ + const Text('You are not currently signed in.'), + // #docregion ExplicitSignIn + if (GoogleSignIn.instance.supportsAuthenticate()) + ElevatedButton( + onPressed: () async { + try { + await GoogleSignIn.instance.authenticate(); + } catch (e) { + // #enddocregion ExplicitSignIn + _errorMessage = e.toString(); + // #docregion ExplicitSignIn + } + }, + child: const Text('SIGN IN'), + ) + else ...[ + if (kIsWeb) + web.renderButton() + // #enddocregion ExplicitSignIn + else + const Text( + 'This platform does not have a known authentication method') + // #docregion ExplicitSignIn + ] + // #enddocregion ExplicitSignIn + ]; } @override diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/mobile.dart b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/mobile.dart deleted file mode 100644 index 8d929d7ef83..00000000000 --- a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/mobile.dart +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2013 The Flutter Authors. 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:flutter/material.dart'; - -import 'stub.dart'; - -/// Renders a SIGN IN button that calls `handleSignIn` onclick. -Widget buildSignInButton({HandleSignInFn? onPressed}) { - return ElevatedButton( - onPressed: onPressed, - child: const Text('SIGN IN'), - ); -} diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/stub.dart b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/stub.dart deleted file mode 100644 index 85a54f0ac27..00000000000 --- a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/stub.dart +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; - -import 'package:flutter/material.dart'; - -/// The type of the onClick callback for the (mobile) Sign In Button. -typedef HandleSignInFn = Future Function(); - -/// Renders a SIGN IN button that (maybe) calls the `handleSignIn` onclick. -Widget buildSignInButton({HandleSignInFn? onPressed}) { - return Container(); -} diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button.dart b/packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper.dart similarity index 53% rename from packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button.dart rename to packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper.dart index c0a33966312..e96b03438c7 100644 --- a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button.dart +++ b/packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper.dart @@ -2,6 +2,4 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -export 'sign_in_button/stub.dart' - if (dart.library.js_util) 'sign_in_button/web.dart' - if (dart.library.io) 'sign_in_button/mobile.dart'; +export 'web_wrapper_stub.dart' if (dart.library.js_util) 'web_wrapper_web.dart'; diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/web.dart b/packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper_stub.dart similarity index 50% rename from packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/web.dart rename to packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper_stub.dart index 5c854fc470b..1e55df4a1fe 100644 --- a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/web.dart +++ b/packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper_stub.dart @@ -3,11 +3,9 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; -import 'package:google_sign_in_web/web_only.dart' as web; -import 'stub.dart'; - -/// Renders a web-only SIGN IN button. -Widget buildSignInButton({HandleSignInFn? onPressed}) { - return web.renderButton(); +/// Stub for the web-only renderButton method, since google_sign_in_web has to +/// be behind a conditional import. +Widget renderButton() { + throw StateError('This should only be called on web'); } diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper_web.dart b/packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper_web.dart new file mode 100644 index 00000000000..e60009b86ed --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/lib/src/web_wrapper_web.dart @@ -0,0 +1,5 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'package:google_sign_in_web/web_only.dart'; diff --git a/packages/google_sign_in/google_sign_in/example/pubspec.yaml b/packages/google_sign_in/google_sign_in/example/pubspec.yaml index a53bf037486..12198ea2808 100644 --- a/packages/google_sign_in/google_sign_in/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/example/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - google_sign_in_web: ^0.12.3 + google_sign_in_web: ^1.0.0 http: ">=0.13.0 <2.0.0" dev_dependencies: diff --git a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart index 93565ad052b..d272511bcf2 100644 --- a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart +++ b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart @@ -5,58 +5,37 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart' show PlatformException; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'src/common.dart'; +import 'src/event_types.dart'; +import 'src/identity_types.dart'; +import 'src/token_types.dart'; export 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' - show SignInOption; - -export 'src/common.dart'; + show GoogleSignInException; +export 'src/event_types.dart'; +export 'src/identity_types.dart'; +export 'src/token_types.dart'; export 'widgets.dart'; -/// Holds authentication tokens after sign in. -class GoogleSignInAuthentication { - GoogleSignInAuthentication._(this._data); - - final GoogleSignInTokenData _data; - - /// An OpenID Connect ID token that identifies the user. - String? get idToken => _data.idToken; - - /// The OAuth2 access token to access Google services. - String? get accessToken => _data.accessToken; - - /// Server auth code used to access Google Login - @Deprecated('Use the `GoogleSignInAccount.serverAuthCode` property instead') - String? get serverAuthCode => _data.serverAuthCode; - - @override - String toString() => 'GoogleSignInAuthentication:$_data'; -} - -/// Holds fields describing a signed in user's identity, following -/// [GoogleSignInUserData]. +/// Represents a signed-in Google account, providing account information as well +/// as utilities for obtaining authentication and authorization tokens. /// -/// [id] is guaranteed to be non-null. +/// Although the API of the plugin is structured to allow for the possibility +/// of multiple signed in users, the underlying Google Sign In SDKs on each +/// platform do not all currently support multiple users in practice. For best +/// cross-platform results, clients should not call [authenticate] to obtain a +/// new [GoogleSignInAccount] instance until after a call to [signOut]. @immutable class GoogleSignInAccount implements GoogleIdentity { - GoogleSignInAccount._(this._googleSignIn, GoogleSignInUserData data) - : displayName = data.displayName, - email = data.email, - id = data.id, - photoUrl = data.photoUrl, - serverAuthCode = data.serverAuthCode, - _idToken = data.idToken; - - // These error codes must match with ones declared on Android and iOS sides. - - /// Error code indicating there was a failed attempt to recover user authentication. - static const String kFailedToRecoverAuthError = 'failed_to_recover_auth'; - - /// Error indicating that authentication can be recovered with user action; - static const String kUserRecoverableAuthError = 'user_recoverable_auth'; + GoogleSignInAccount._( + GoogleSignInUserData userData, + AuthenticationTokenData tokenData, + ) : displayName = userData.displayName, + email = userData.email, + id = userData.id, + photoUrl = userData.photoUrl, + _authenticationTokens = tokenData; @override final String? displayName; @@ -70,61 +49,24 @@ class GoogleSignInAccount implements GoogleIdentity { @override final String? photoUrl; - @override - final String? serverAuthCode; - - final String? _idToken; - final GoogleSignIn _googleSignIn; + final AuthenticationTokenData _authenticationTokens; - /// Retrieve [GoogleSignInAuthentication] for this account. - /// - /// [shouldRecoverAuth] sets whether to attempt to recover authentication if - /// user action is needed. If an attempt to recover authentication fails a - /// [PlatformException] is thrown with possible error code - /// [kFailedToRecoverAuthError]. + /// Returns authentication tokens for this account. /// - /// Otherwise, if [shouldRecoverAuth] is false and the authentication can be - /// recovered by user action a [PlatformException] is thrown with error code - /// [kUserRecoverableAuthError]. - Future get authentication async { - if (_googleSignIn.currentUser != this) { - throw StateError('User is no longer signed in.'); - } - - final GoogleSignInTokenData response = - await GoogleSignInPlatform.instance.getTokens( - email: email, - shouldRecoverAuth: true, - ); - - // On Android, there isn't an API for refreshing the idToken, so re-use - // the one we obtained on login. - response.idToken ??= _idToken; - - return GoogleSignInAuthentication._(response); - } - - /// Convenience method returning a `` map of HTML Authorization - /// headers, containing the current `authentication.accessToken`. + /// This returns the authentication information that was returned at the time + /// of the initial authentication. /// - /// See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization. - Future> get authHeaders async { - final String? token = (await authentication).accessToken; - return { - 'Authorization': 'Bearer $token', - // TODO(kevmoo): Use the correct value once it's available from authentication - // See https://github.com/flutter/flutter/issues/80905 - 'X-Goog-AuthUser': '0', - }; + /// Clients are strongly encouraged to use this information immediately after + /// authentication, as tokens are subject to expiration, and obtaining new + /// tokens requires re-authenticating. + GoogleSignInAuthentication get authentication { + return GoogleSignInAuthentication(idToken: _authenticationTokens.idToken); } - /// Clears any client side cache that might be holding invalid tokens. - /// - /// If client runs into 401 errors using a token, it is expected to call - /// this method and grab `authHeaders` once again. - Future clearAuthCache() async { - final String token = (await authentication).accessToken!; - await GoogleSignInPlatform.instance.clearAuthCache(token: token); + /// Returns a client that can be used to request authorization tokens for + /// this user. + GoogleSignInAuthorizationClient get authorizationClient { + return GoogleSignInAuthorizationClient._(this); } @override @@ -140,13 +82,13 @@ class GoogleSignInAccount implements GoogleIdentity { email == otherAccount.email && id == otherAccount.id && photoUrl == otherAccount.photoUrl && - serverAuthCode == otherAccount.serverAuthCode && - _idToken == otherAccount._idToken; + _authenticationTokens.idToken == + otherAccount._authenticationTokens.idToken; } @override - int get hashCode => - Object.hash(displayName, email, id, photoUrl, _idToken, serverAuthCode); + int get hashCode => Object.hash( + displayName, email, id, photoUrl, _authenticationTokens.idToken); @override String toString() { @@ -155,321 +97,437 @@ class GoogleSignInAccount implements GoogleIdentity { 'email': email, 'id': id, 'photoUrl': photoUrl, - 'serverAuthCode': serverAuthCode }; return 'GoogleSignInAccount:$data'; } } -/// GoogleSignIn allows you to authenticate Google users. -class GoogleSignIn { - /// Initializes global sign-in configuration settings. +/// A utility for requesting authorization tokens. +/// +/// If the instance was obtained from a [GoogleSignInAccount], any requests +/// issued by this client will be for tokens for that account. +/// +/// If the instance was obtained directly from [GoogleSignIn], the request will +/// not be limited to a specific user, and the behavior will depend on the +/// platform and the current application state. Examples include: +/// - If there is an active authentication session in the application already, +/// the authorization tokens may be associated for that user. +/// - If no user has been authenticated, this may trigger a combined +/// authentication+authorization flow. In that case, whether +/// [GoogleSignIn]'s authenticationEvents stream will be informed of the +/// authentication depends on the platform implementation. You should not +/// assume the user information or authenication tokens will be available in +/// this case. +class GoogleSignInAuthorizationClient { + GoogleSignInAuthorizationClient._(GoogleIdentity? user) + : _userId = user?.id, + _userEmail = user?.email; + + final String? _userId; + final String? _userEmail; + + /// Requests client authorization tokens if they can be returned without user + /// interaction. /// - /// The [signInOption] determines the user experience. [SigninOption.games] - /// is only supported on Android. + /// If authorization would require user interaction, this returns null, in + /// which case [authorizeScopes] should be used instead. + Future authorizationForScopes( + List scopes) async { + return _authorizeClient(scopes, promptIfUnauthorized: false); + } + + /// Requests that the user authorize the given scopes, and either returns the + /// resulting client authorization tokens, or throws an exception with failure + /// details. /// - /// The list of [scopes] are OAuth scope codes to request when signing in. - /// These scope codes will determine the level of data access that is granted - /// to your application by the user. The full list of available scopes can - /// be found here: - /// + /// This should only be called from a context where user interaction is + /// allowed (for example, while the app is foregrounded on mobile), and if + /// [GoogleSignIn.authorizationRequiresUserInteraction] returns true this + /// should only be called from an user interaction handler. + Future authorizeScopes( + List scopes) async { + final GoogleSignInClientAuthorization? authz = + await _authorizeClient(scopes, promptIfUnauthorized: true); + // The platform interface documents that null should only be returned for + // cases where prompting isn't requested, so if this happens it's a bug + // in the platform implementation. + if (authz == null) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: 'Platform returned null unexpectedly.'); + } + return authz; + } + + /// Convenience method returning a `` map of HTML + /// authorization headers, containing the access token for the given scopes. /// - /// The [hostedDomain] argument specifies a hosted domain restriction. By - /// setting this, sign in will be restricted to accounts of the user in the - /// specified domain. By default, the list of accounts will not be restricted. + /// Returns null if the given scopes are not authorized, or there is no + /// currently valid authorization token available, and + /// [promptIfNecessary] is false. /// - /// The [forceCodeForRefreshToken] is used on Android to ensure the authentication - /// code can be exchanged for a refresh token after the first request. - GoogleSignIn({ - this.signInOption = SignInOption.standard, - this.scopes = const [], - this.hostedDomain, - this.clientId, - this.serverClientId, - this.forceCodeForRefreshToken = false, - this.forceAccountName, - }) { - // Start initializing. - if (kIsWeb) { - // Start initializing the plugin ASAP, so the `userDataEvents` Stream for - // the web can be used without calling any other methods of the plugin - // (like `silentSignIn` or `isSignedIn`). - unawaited(_ensureInitialized()); + /// See also https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization. + Future?> authorizationHeaders(List scopes, + {bool promptIfNecessary = false}) async { + final GoogleSignInClientAuthorization? authz = + await authorizationForScopes(scopes) ?? + (promptIfNecessary ? await authorizeScopes(scopes) : null); + if (authz == null) { + return null; } + return { + 'Authorization': 'Bearer ${authz.accessToken}', + 'X-Goog-AuthUser': '0', + }; } - /// Factory for creating default sign in user experience. - factory GoogleSignIn.standard({ - List scopes = const [], - String? hostedDomain, - }) { - return GoogleSignIn(scopes: scopes, hostedDomain: hostedDomain); + /// Requests that the user authorize the given scopes for server use. + /// + /// In addition to throwing an exception for authorization failures, this can + /// return null if the server authorization tokens are not available. For + /// intance, some platforms only provide a valid server auth token on initial + /// login. Clients requiring a server auth token should not rely on being able + /// to re-request server auth tokens at arbitrary times, and should instead + /// store the token when it is first available, and manage refreshes on + /// the server side using that token. + /// + /// This should only be called from a context where user interaction is + /// allowed (for example, while the app is foregrounded on mobile), and if + /// [GoogleSignIn.authorizationRequiresUserInteraction] returns true this + /// should only be called from an user interaction handler. + Future authorizeServer( + List scopes) async { + final ServerAuthorizationTokenData? tokens = + await GoogleSignInPlatform.instance.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _userId, + email: _userEmail, + promptIfUnauthorized: true))); + return tokens == null + ? null + : GoogleSignInServerAuthorization( + serverAuthCode: tokens.serverAuthCode); } - /// Factory for creating sign in suitable for games. This option is only - /// supported on Android. - factory GoogleSignIn.games() { - return GoogleSignIn(signInOption: SignInOption.games); + Future _authorizeClient(List scopes, + {required bool promptIfUnauthorized}) async { + final ClientAuthorizationTokenData? tokens = + await GoogleSignInPlatform.instance.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _userId, + email: _userEmail, + promptIfUnauthorized: promptIfUnauthorized))); + return tokens == null + ? null + : GoogleSignInClientAuthorization(accessToken: tokens.accessToken); } +} - // These error codes must match with ones declared on Android and iOS sides. - - /// Error code indicating there is no signed in user and interactive sign in - /// flow is required. - static const String kSignInRequiredError = 'sign_in_required'; - - /// Error code indicating that interactive sign in process was canceled by the - /// user. - static const String kSignInCanceledError = 'sign_in_canceled'; - - /// Error code indicating network error. Retrying should resolve the problem. - static const String kNetworkError = 'network_error'; - - /// Error code indicating that attempt to sign in failed. - static const String kSignInFailedError = 'sign_in_failed'; - - /// Option to determine the sign in user experience. [SignInOption.games] is - /// only supported on Android. - final SignInOption signInOption; - - /// The list of [scopes] are OAuth scope codes requested when signing in. - final List scopes; +/// GoogleSignIn allows you to authenticate Google users. +class GoogleSignIn { + GoogleSignIn._(); - /// Domain to restrict sign-in to. - final String? hostedDomain; + /// Returns the single [GoogleSignIn] instance. + /// + /// [initialize] must be called on this instance, and its future allowed to + /// complete, before any other methods on the object are called. + static final GoogleSignIn instance = GoogleSignIn._(); - /// Client ID being used to connect to google sign-in. + /// Initializes the sign in manager with the given configuration. /// - /// This option is not supported on all platforms (e.g. Android). It is - /// optional if file-based configuration is used. + /// Clients must call this method exactly once, and wait for its future to + /// complete, before calling any other methods on this object. /// - /// The value specified here has precedence over a value from a configuration - /// file. - final String? clientId; - - /// Client ID of the backend server to which the app needs to authenticate - /// itself. + /// [clientId] is the identifier for your client application, as provided by + /// the Google Sign In server configuration, if any. This does not need to be + /// provided on platforms that do not require a client identifier, or if it is + /// provided via application-level configuration files. See the README for + /// details. If provided, it will take precedence over any value in a + /// configuration file. /// - /// Optional and not supported on all platforms (e.g. web). By default, it - /// is initialized from a configuration file if available. + /// [serverClientId] is the identifier for your application's server-side + /// component, as provided by the Google Sign In server configuration, if any. + /// Depending on the platform, this value may be unused, optional, or + /// required. See the README for details. If provided, it will take precedence + /// over any value in a configuration file. /// - /// The value specified here has precedence over a value from a configuration - /// file. + /// If provided, [nonce] will be passed as part of any authentication + /// requests, to allow additional validation of the resulting ID token. /// - /// [GoogleSignInAuthentication.idToken] and - /// [GoogleSignInAccount.serverAuthCode] will be specific to the backend - /// server. - final String? serverClientId; - - /// Force the authorization code to be valid for a refresh token every time. Only needed on Android. - final bool forceCodeForRefreshToken; - - /// Explicitly specifies the account name to be used in sign-in. Must only be set on Android. - final String? forceAccountName; - - final StreamController _currentUserController = - StreamController.broadcast(); - - /// Subscribe to this stream to be notified when the current user changes. - Stream get onCurrentUserChanged { - return _currentUserController.stream; - } - - Future _callMethod( - Future Function() method) async { - await _ensureInitialized(); - - final dynamic response = await method(); + /// If provided, [hostedDomain] restricts account selection to accounts in + /// that domain. + Future initialize({ + String? clientId, + String? serverClientId, + String? nonce, + String? hostedDomain, + }) async { + await GoogleSignInPlatform.instance.init(InitParameters( + clientId: clientId, + serverClientId: serverClientId, + nonce: nonce, + hostedDomain: hostedDomain, + )); - return _setCurrentUser(response != null && response is GoogleSignInUserData - ? GoogleSignInAccount._(this, response) - : null); + final Stream? platformAuthEvents = + GoogleSignInPlatform.instance.authenticationEvents; + if (platformAuthEvents == null) { + _createAuthenticationStreamEvents = true; + } else { + unawaited(platformAuthEvents.forEach(_translateAuthenticationEvent)); + } } - // Sets the current user, and propagates it through the _currentUserController. - GoogleSignInAccount? _setCurrentUser(GoogleSignInAccount? currentUser) { - if (currentUser != _currentUser) { - _currentUser = currentUser; - _currentUserController.add(_currentUser); + /// Converts [event] into a corresponding event using the app-facing package + /// types. + /// + /// The platform interface types are intentionally not exposed to clients to + /// avoid platform interface package changes immediately transferring to the + /// public API without being able to control how they are exposed. + /// + /// This uses a convert-and-add approach rather than `map` so that new types + /// that don't have handlers yet can be dropped rather than causing errors. + void _translateAuthenticationEvent(AuthenticationEvent event) { + switch (event) { + case AuthenticationEventSignIn(): + _authenticationStreamController.add( + GoogleSignInAuthenticationEventSignIn( + user: GoogleSignInAccount._( + event.user, event.authenticationTokens))); + case AuthenticationEventSignOut(): + _authenticationStreamController + .add(GoogleSignInAuthenticationEventSignOut()); + case AuthenticationEventException(): + _authenticationStreamController.addError(event.exception); } - return _currentUser; } - // Future that completes when `init` has completed on the native side. - Future? _initialization; - - // Performs initialization, guarding it with the _initialization future. - Future _ensureInitialized() async { - _initialization ??= _doInitialization().catchError((Object e) { - // Invalidate initialization if it errors out. - _initialization = null; - // ignore: only_throw_errors - throw e; - }); - return _initialization; + /// Subscribe to this stream to be notified when sign in (authentication) and + /// sign out events happen. + Stream get authenticationEvents { + return _authenticationStreamController.stream; } - // Actually performs the initialization. - // - // This method calls initWithParams, and then, if the plugin instance has a - // userDataEvents Stream, connects it to the [_setCurrentUser] method. - Future _doInitialization() async { - await GoogleSignInPlatform.instance.initWithParams(SignInInitParameters( - signInOption: signInOption, - scopes: scopes, - hostedDomain: hostedDomain, - clientId: clientId, - serverClientId: serverClientId, - forceCodeForRefreshToken: forceCodeForRefreshToken, - forceAccountName: forceAccountName, - )); - - unawaited(GoogleSignInPlatform.instance.userDataEvents - ?.map((GoogleSignInUserData? userData) { - return userData != null ? GoogleSignInAccount._(this, userData) : null; - }).forEach(_setCurrentUser)); - } + final StreamController + _authenticationStreamController = + StreamController.broadcast(); - /// The most recently scheduled method call. - Future? _lastMethodCall; - - /// Returns a [Future] that completes with a success after [future], whether - /// it completed with a value or an error. - static Future _waitFor(Future future) { - final Completer completer = Completer(); - future.whenComplete(completer.complete).catchError((dynamic _) { - // Ignore if previous call completed with an error. - // TODO(ditman): Should we log errors here, if debug or similar? - }); - return completer.future; - } + // Whether this package is responsible for creating stream events from + // authentication calls. This is true iff the platform instance returns null + // for authenticationEvents. + bool _createAuthenticationStreamEvents = false; - /// Adds call to [method] in a queue for execution. + /// Attempts to sign in a previously authenticated user with minimal + /// interaction. /// - /// At most one in flight call is allowed to prevent concurrent (out of order) - /// updates to [currentUser] and [onCurrentUserChanged]. + /// The amount of allowable UI is up to the platform to determine, but it + /// should be minimal. Possible examples include FedCM on the web, and One Tap + /// on Android. Platforms may even show no UI, and only sign in if a previous + /// sign-in is being restored. This method is intended to be called as soon + /// as the application needs to know if the user is signed in, often at + /// initial launch. /// - /// The optional, named parameter [canSkipCall] lets the plugin know that the - /// method call may be skipped, if there's already [_currentUser] information. - /// This is used from the [signIn] and [signInSilently] methods. - Future _addMethodCall( - Future Function() method, { - bool canSkipCall = false, - }) async { - Future response; - if (_lastMethodCall == null) { - response = _callMethod(method); - } else { - response = _lastMethodCall!.then((_) { - // If after the last completed call `currentUser` is not `null` and requested - // method can be skipped (`canSkipCall`), re-use the same authenticated user - // instead of making extra call to the native side. - if (canSkipCall && _currentUser != null) { - return _currentUser; + /// Use [authenticate] instead to trigger a full interactive sign in process. + /// + /// There are two possible return modes: + /// - If a Future is returned, applications could reasonably `await` that + /// future before deciding whether to display UI in a signed in or signed + /// out mode. For example, a platform where this method only restores + /// existing sign-ins would return a future, as either way it will resolve + /// quickly. + /// - If null is returned, applications must rely on [authenticationEvents] to + /// know when a sign-in occurs, and cannot rely on receiving a notification + /// that this call has *not* resulted in a sign-in in any reasonable amount + /// of time. In this mode, applications should assume a signed out mode + /// until/unless a sign-in event arrives on the stream. FedCM on the web + /// would be an example of this mode. + /// + /// If a Future is returned, it resolves to an instance of + /// [GoogleSignInAccount] for a successful sign in or null if the attempt + /// implicitly did not result in any authentication. A [GoogleSignInException] + /// will be thrown if there was a failure (such as a client configuration + /// error). By default, this will not throw any of the following: + /// - [GoogleSignInExceptionCode.canceled] + /// - [GoogleSignInExceptionCode.interrupted] + /// - [GoogleSignInExceptionCode.uiUnavailable] + /// and will instead return null in those cases. To receive exceptions + /// for those cases instead, set [reportAllExceptions] to true. + Future? attemptLightweightAuthentication({ + bool reportAllExceptions = false, + }) { + try { + final Future? future = + GoogleSignInPlatform.instance.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + if (future == null) { + return null; + } + return _resolveLightweightAuthenticationAttempt(future, + reportAllExceptions: reportAllExceptions); + } catch (e, stack) { + if (e is GoogleSignInException) { + if (_createAuthenticationStreamEvents) { + _authenticationStreamController.addError(e, stack); } - return _callMethod(method); - }); + + // For exceptions that should not be reported out, just return null. + if (!_shouldRethrowLightweightAuthenticationException(e, + reportAllExceptions: reportAllExceptions)) { + return Future.value(); + } + } + return Future.error(e, stack); } - // Add the current response to the currently running Promise of all pending responses - _lastMethodCall = _waitFor(response); - return response; } - /// The currently signed in account, or null if the user is signed out. - GoogleSignInAccount? get currentUser => _currentUser; - GoogleSignInAccount? _currentUser; - - /// Attempts to sign in a previously authenticated user without interaction. - /// - /// Returned Future resolves to an instance of [GoogleSignInAccount] for a - /// successful sign in or `null` if there is no previously authenticated user. - /// Use [signIn] method to trigger interactive sign in process. - /// - /// Authentication is triggered if there is no currently signed in - /// user (that is when `currentUser == null`), otherwise this method returns - /// a Future which resolves to the same user instance. - /// - /// Re-authentication can be triggered after [signOut] or [disconnect]. It can - /// also be triggered by setting [reAuthenticate] to `true` if a new ID token - /// is required. + /// Resolves a future from the platform implementation's + /// attemptLightweightAuthentication. /// - /// When [suppressErrors] is set to `false` and an error occurred during sign in - /// returned Future completes with [PlatformException] whose `code` can be - /// one of [kSignInRequiredError] (when there is no authenticated user) , - /// [kNetworkError] (when a network error occurred) or [kSignInFailedError] - /// (when an unknown error occurred). - Future signInSilently({ - bool suppressErrors = true, - bool reAuthenticate = false, + /// This is a separate method from [attemptLightweightAuthentication] to allow + /// using async/await, since [attemptLightweightAuthentication] can't use + /// async without losing the ability to return a null future. + Future _resolveLightweightAuthenticationAttempt( + Future future, { + required bool reportAllExceptions, }) async { try { - return await _addMethodCall(GoogleSignInPlatform.instance.signInSilently, - canSkipCall: !reAuthenticate); - } catch (_) { - if (suppressErrors) { + final AuthenticationResults? result = await future; + if (result == null) { return null; - } else { + } + + final GoogleSignInAccount account = + GoogleSignInAccount._(result.user, result.authenticationTokens); + if (_createAuthenticationStreamEvents) { + _authenticationStreamController + .add(GoogleSignInAuthenticationEventSignIn(user: account)); + } + return account; + } on GoogleSignInException catch (e, stack) { + if (_createAuthenticationStreamEvents) { + _authenticationStreamController.addError(e, stack); + } + + if (_shouldRethrowLightweightAuthenticationException(e, + reportAllExceptions: reportAllExceptions)) { rethrow; } + return null; } } - /// Returns a future that resolves to whether a user is currently signed in. - Future isSignedIn() async { - await _ensureInitialized(); - return GoogleSignInPlatform.instance.isSignedIn(); + bool _shouldRethrowLightweightAuthenticationException( + GoogleSignInException e, { + required bool reportAllExceptions, + }) { + if (reportAllExceptions) { + return true; + } + switch (e.code) { + case GoogleSignInExceptionCode.canceled: + case GoogleSignInExceptionCode.interrupted: + case GoogleSignInExceptionCode.uiUnavailable: + return false; + // Only specific types are ignored, everything else should rethrow. + // ignore: no_default_cases + default: + return true; + } } - /// Starts the interactive sign-in process. - /// - /// Returned Future resolves to an instance of [GoogleSignInAccount] for a - /// successful sign in or `null` in case sign in process was aborted. + /// Whether or not the current platform supports the [authenticate] method. /// - /// Authentication process is triggered only if there is no currently signed in - /// user (that is when `currentUser == null`), otherwise this method returns - /// a Future which resolves to the same user instance. + /// If this returns false, [authenticate] will throw an [UnsupportedError] if + /// called. See the platform-specific documentation for the package to + /// determine how authentication is handled. For instance, the platform may + /// provide platform-controlled sign-in UI elements that must be used instead + /// of application-specific UI. + bool supportsAuthenticate() => + GoogleSignInPlatform.instance.supportsAuthenticate(); + + /// Whether or not authorization calls that could show UI must be called from + /// a user interaction, such as a button press, on the current platform. /// - /// Re-authentication can be triggered only after [signOut] or [disconnect]. - Future signIn() { - final Future result = - _addMethodCall(GoogleSignInPlatform.instance.signIn, canSkipCall: true); - bool isCanceled(dynamic error) => - error is PlatformException && error.code == kSignInCanceledError; - return result.catchError((dynamic _) => null, test: isCanceled); - } + /// For instance, this would return true on web if the sign in SDK uses popups + /// in its flow, since browsers may block popups that are not triggered + /// within the context of a user interaction. + bool authorizationRequiresUserInteraction() => + GoogleSignInPlatform.instance.authorizationRequiresUserInteraction(); - /// Marks current user as being in the signed out state. - Future signOut() => - _addMethodCall(GoogleSignInPlatform.instance.signOut); - - /// Disconnects the current user from the app and revokes previous - /// authentication. - Future disconnect() => - _addMethodCall(GoogleSignInPlatform.instance.disconnect); - - /// Requests the user grants additional Oauth [scopes]. - Future requestScopes(List scopes) async { - await _ensureInitialized(); - return GoogleSignInPlatform.instance.requestScopes(scopes); + /// Starts an interactive sign-in process. + /// + /// Returns a [GoogleSignInAccount] with valid authentication tokens for a + /// successful sign in, or throws a [GoogleSignInException] for any other + /// outcome, with details in the exception. + /// + /// If you will immediately be requesting authorization tokens, you can pass + /// [scopeHint] to indicate a preference for a combined + /// authentication+authorization flow on platforms that support it. Best + /// practice for Google Sign In flows is to separate authentication and + /// authorization, so not all platforms support a combined flow, and those + /// that do not will ignore [scopeHint]. You should always assume that + /// [GoogleSignInAuthorizationClient.authorizationForScopes] could return null + /// even if you pass a [scopeHint] here. + Future authenticate( + {List scopeHint = const []}) async { + try { + final AuthenticationResults result = await GoogleSignInPlatform.instance + .authenticate(AuthenticateParameters( + scopeHint: scopeHint, + )); + final GoogleSignInAccount account = + GoogleSignInAccount._(result.user, result.authenticationTokens); + if (_createAuthenticationStreamEvents) { + _authenticationStreamController + .add(GoogleSignInAuthenticationEventSignIn(user: account)); + } + return account; + } on GoogleSignInException catch (e, stack) { + if (_createAuthenticationStreamEvents) { + _authenticationStreamController.addError(e, stack); + } + rethrow; + } } - /// Checks if the current user has granted access to all the specified [scopes]. + /// Returns a client that can be used to request authorization tokens for + /// some user. /// - /// Optionally, an [accessToken] can be passed to perform this check. This - /// may be useful when an application holds on to a cached, potentially - /// long-lived [accessToken]. - Future canAccessScopes( - List scopes, { - String? accessToken, - }) async { - await _ensureInitialized(); + /// In most cases, authorization tokens should be obtained via + /// [GoogleSignInAccount.authorizationClient] rather than this method, as this + /// will provied only authorization tokens, without any corresponding user + /// information or authentication tokens. + /// + /// See [GoogleSignInAuthorizationClient] for details. + GoogleSignInAuthorizationClient get authorizationClient { + return GoogleSignInAuthorizationClient._(null); + } - final String? token = - accessToken ?? (await _currentUser?.authentication)?.accessToken; + /// Signs out any currently signed in user(s). + Future signOut() { + if (_createAuthenticationStreamEvents) { + _authenticationStreamController + .add(GoogleSignInAuthenticationEventSignOut()); + } + return GoogleSignInPlatform.instance.signOut(const SignOutParams()); + } - return GoogleSignInPlatform.instance.canAccessScopes( - scopes, - accessToken: token, - ); + /// Disconnects any currently authorized users from the app, revoking previous + /// authorization. + Future disconnect() async { + // Disconnecting also signs out, so synthesize a sign-out if necessary. + if (_createAuthenticationStreamEvents) { + _authenticationStreamController + .add(GoogleSignInAuthenticationEventSignOut()); + } + // TODO(stuartmorgan): Consider making a per-user disconnect option once + // the Android implementation is available so that we can see how it is + // structured. In practice, currently the plugin only fully supports a + // single user at a time, so the distinction is mostly theoretical for now. + await GoogleSignInPlatform.instance.disconnect(const DisconnectParams()); } } diff --git a/packages/google_sign_in/google_sign_in/lib/src/event_types.dart b/packages/google_sign_in/google_sign_in/lib/src/event_types.dart new file mode 100644 index 00000000000..b29a1f64a18 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/lib/src/event_types.dart @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. 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:flutter/foundation.dart'; + +import '../google_sign_in.dart'; + +export 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' + show GoogleSignInException; + +/// A base class for authentication event streams. +@immutable +sealed class GoogleSignInAuthenticationEvent { + const GoogleSignInAuthenticationEvent(); +} + +/// A sign-in event, corresponding to an authentication flow completing +/// successfully. +@immutable +class GoogleSignInAuthenticationEventSignIn + extends GoogleSignInAuthenticationEvent { + /// Creates an event for a successful sign in. + const GoogleSignInAuthenticationEventSignIn({required this.user}); + + /// The user that was authenticated. + final GoogleSignInAccount user; +} + +/// A sign-out event, corresponding to a user having been signed out. +/// +/// Implicit sign-outs (for example, due to server-side authentication +/// revocation, or timeouts) are not guaranteed to send events. +@immutable +class GoogleSignInAuthenticationEventSignOut + extends GoogleSignInAuthenticationEvent {} diff --git a/packages/google_sign_in/google_sign_in/lib/src/common.dart b/packages/google_sign_in/google_sign_in/lib/src/identity_types.dart similarity index 94% rename from packages/google_sign_in/google_sign_in/lib/src/common.dart rename to packages/google_sign_in/google_sign_in/lib/src/identity_types.dart index 8a1d4dcb354..068403e7462 100644 --- a/packages/google_sign_in/google_sign_in/lib/src/common.dart +++ b/packages/google_sign_in/google_sign_in/lib/src/identity_types.dart @@ -34,7 +34,4 @@ abstract class GoogleIdentity { /// /// Not guaranteed to be present for all users, even when configured. String? get photoUrl; - - /// Server auth code used to access Google Login - String? get serverAuthCode; } diff --git a/packages/google_sign_in/google_sign_in/lib/src/token_types.dart b/packages/google_sign_in/google_sign_in/lib/src/token_types.dart new file mode 100644 index 00000000000..79e05c8aec5 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/lib/src/token_types.dart @@ -0,0 +1,57 @@ +// Copyright 2013 The Flutter Authors. 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:flutter/foundation.dart'; + +/// Holds authentication tokens. +/// +/// Currently there is only an idToken, but this wrapper class allows for the +/// posibility of adding additional information in the future without breaking +/// changes. +@immutable +class GoogleSignInAuthentication { + /// Creates a new token container with the given tokens. + const GoogleSignInAuthentication({required this.idToken}); + + /// An OpenID Connect ID token that identifies the user. + final String? idToken; + + @override + String toString() => 'GoogleSignInAuthentication: $idToken'; +} + +/// Holds client authorization tokens. +/// +/// Currently there is only an accessToken, but this wrapper class allows for +/// the posibility of adding additional information in the future without +/// breaking changes. +@immutable +class GoogleSignInClientAuthorization { + /// Creates a new token container with the given tokens. + const GoogleSignInClientAuthorization({required this.accessToken}); + + /// The OAuth2 access token to access Google services. + final String accessToken; + + @override + String toString() => 'GoogleSignInClientAuthorization: $accessToken'; +} + +/// Holds server authorization tokens. +/// +/// Currently there is only a serverAuthCode, but this wrapper class allows for +/// the posibility of adding additional information in the future without +/// breaking changes. +@immutable +class GoogleSignInServerAuthorization { + /// Creates a new token container with the given tokens. + const GoogleSignInServerAuthorization({required this.serverAuthCode}); + + /// Auth code to provide to a backend server to exchange for access or + /// refresh tokens. + final String serverAuthCode; + + @override + String toString() => 'GoogleSignInServerAuthorization: $serverAuthCode'; +} diff --git a/packages/google_sign_in/google_sign_in/lib/widgets.dart b/packages/google_sign_in/google_sign_in/lib/widgets.dart index ab04de4319d..f5c210b7f57 100644 --- a/packages/google_sign_in/google_sign_in/lib/widgets.dart +++ b/packages/google_sign_in/google_sign_in/lib/widgets.dart @@ -6,8 +6,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'src/common.dart'; import 'src/fife.dart' as fife; +import 'src/identity_types.dart'; /// Builds a CircleAvatar profile image of the appropriate resolution class GoogleUserCircleAvatar extends StatelessWidget { diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index c256a4e6105..6c765213f09 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 6.3.0 +version: 7.0.0 environment: sdk: ^3.6.0 @@ -24,10 +24,10 @@ flutter: dependencies: flutter: sdk: flutter - google_sign_in_android: ^6.2.0 - google_sign_in_ios: ^5.8.1 - google_sign_in_platform_interface: ^2.5.0 - google_sign_in_web: ^0.12.4+4 + google_sign_in_android: ^7.0.0 + google_sign_in_ios: ^6.0.0 + google_sign_in_platform_interface: ^3.0.0 + google_sign_in_web: ^1.0.0 dev_dependencies: build_runner: ^2.1.10 @@ -35,6 +35,7 @@ dev_dependencies: sdk: flutter http: ">=0.13.0 <2.0.0" mockito: ^5.4.4 + plugin_platform_interface: ^2.1.8 topics: - authentication diff --git a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart index 0853691f6ba..7e13bd71203 100644 --- a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart @@ -9,6 +9,7 @@ import 'package:google_sign_in/google_sign_in.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'google_sign_in_test.mocks.dart'; @@ -16,486 +17,646 @@ import 'google_sign_in_test.mocks.dart'; // ignore: avoid_implementing_value_types, must_be_immutable, unreachable_from_main class MockGoogleSignInAccount extends Mock implements GoogleSignInAccount {} +// Add the mixin to make the platform interface accept the mock. +class TestMockGoogleSignInPlatform extends MockGoogleSignInPlatform + with MockPlatformInterfaceMixin {} + @GenerateMocks([GoogleSignInPlatform]) void main() { - late MockGoogleSignInPlatform mockPlatform; + const GoogleSignInUserData defaultUser = GoogleSignInUserData( + email: 'john.doe@gmail.com', + id: '8162538176523816253123', + photoUrl: 'https://lh5.googleusercontent.com/photo.jpg', + displayName: 'John Doe'); - group('GoogleSignIn', () { - final GoogleSignInUserData kDefaultUser = GoogleSignInUserData( - email: 'john.doe@gmail.com', - id: '8162538176523816253123', - photoUrl: 'https://lh5.googleusercontent.com/photo.jpg', - displayName: 'John Doe', - serverAuthCode: '789'); + late MockGoogleSignInPlatform mockPlatform; - setUp(() { - mockPlatform = MockGoogleSignInPlatform(); - when(mockPlatform.isMock).thenReturn(true); - when(mockPlatform.userDataEvents).thenReturn(null); - when(mockPlatform.signInSilently()) - .thenAnswer((Invocation _) async => kDefaultUser); - when(mockPlatform.signIn()) - .thenAnswer((Invocation _) async => kDefaultUser); + setUp(() { + mockPlatform = TestMockGoogleSignInPlatform(); + when(mockPlatform.authenticationEvents).thenReturn(null); - GoogleSignInPlatform.instance = mockPlatform; - }); + GoogleSignInPlatform.instance = mockPlatform; + }); - test('signInSilently', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + group('initialize', () { + test('passes nulls by default', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signInSilently(); + await googleSignIn.initialize(); - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); + final VerificationResult verification = + verify(mockPlatform.init(captureAny)); + final InitParameters params = verification.captured[0] as InitParameters; + expect(params.clientId, null); + expect(params.serverClientId, null); + expect(params.nonce, null); + expect(params.hostedDomain, null); }); - test('signIn', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - - await googleSignIn.signIn(); - - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform); - verify(mockPlatform.signIn()); + test('passes all paramaters', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const String clientId = 'clientId'; + const String serverClientId = 'serverClientId'; + const String nonce = 'nonce'; + const String hostedDomain = 'example.com'; + await googleSignIn.initialize( + clientId: clientId, + serverClientId: serverClientId, + nonce: nonce, + hostedDomain: hostedDomain); + + final VerificationResult verification = + verify(mockPlatform.init(captureAny)); + final InitParameters params = verification.captured[0] as InitParameters; + expect(params.clientId, clientId); + expect(params.serverClientId, serverClientId); + expect(params.nonce, nonce); + expect(params.hostedDomain, hostedDomain); }); + }); - test('clientId parameter is forwarded to implementation', () async { - const String fakeClientId = 'fakeClientId'; - final GoogleSignIn googleSignIn = GoogleSignIn(clientId: fakeClientId); - - await googleSignIn.signIn(); - - _verifyInit(mockPlatform, clientId: fakeClientId); - verify(mockPlatform.signIn()); + group('authenticationEvents', () { + test('reports success from attemptLightweightAuthentication', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const String idToken = 'idToken'; + when(mockPlatform.attemptLightweightAuthentication(any)).thenAnswer( + (_) async => const AuthenticationResults( + user: defaultUser, + authenticationTokens: AuthenticationTokenData(idToken: idToken))); + + final Future eventFuture = + googleSignIn.authenticationEvents.first; + await googleSignIn.initialize(); + await googleSignIn.attemptLightweightAuthentication(); + final GoogleSignInAuthenticationEvent event = await eventFuture; + + expect(event, isA()); + final GoogleSignInAuthenticationEventSignIn signIn = + event as GoogleSignInAuthenticationEventSignIn; + expect(signIn.user.id, defaultUser.id); + expect(signIn.user.authentication.idToken, idToken); }); - test('serverClientId parameter is forwarded to implementation', () async { - const String fakeServerClientId = 'fakeServerClientId'; - final GoogleSignIn googleSignIn = - GoogleSignIn(serverClientId: fakeServerClientId); - - await googleSignIn.signIn(); - - _verifyInit(mockPlatform, serverClientId: fakeServerClientId); - verify(mockPlatform.signIn()); + test('reports sync exceptions from attemptLightweightAuthentication', + () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.interrupted); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenThrow(exception); + + final Completer errorCompleter = Completer(); + final StreamSubscription subscription = + googleSignIn.authenticationEvents + .handleError((Object e) => errorCompleter.complete(e)) + .listen((_) => fail('The only event should be an error')); + await googleSignIn.initialize(); + // This doesn't throw, since reportAllExceptions is false. + await googleSignIn.attemptLightweightAuthentication(); + + final Object e = await errorCompleter.future; + expect(e, exception); + await subscription.cancel(); }); - test( - 'clientId and serverClientId parameters is forwarded to implementation', + test('reports async exceptions from attemptLightweightAuthentication', () async { - // #docregion GoogleSignIn - final GoogleSignIn googleSignIn = GoogleSignIn( - // The OAuth client id of your app. This is required. - clientId: 'Your Client ID', - // If you need to authenticate to a backend server, specify its OAuth client. This is optional. - serverClientId: 'Your Server ID', - ); - // #enddocregion GoogleSignIn - - await googleSignIn.signIn(); - - _verifyInit( - mockPlatform, - clientId: 'Your Client ID', - serverClientId: 'Your Server ID', - ); - verify(mockPlatform.signIn()); + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.interrupted); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenAnswer((_) async => throw exception); + + final Completer errorCompleter = Completer(); + final StreamSubscription subscription = + googleSignIn.authenticationEvents + .handleError((Object e) => errorCompleter.complete(e)) + .listen((_) => fail('The only event should be an error')); + await googleSignIn.initialize(); + // This doesn't throw, since reportAllExceptions is false. + await googleSignIn.attemptLightweightAuthentication(); + + final Object e = await errorCompleter.future; + expect(e, exception); + await subscription.cancel(); }); - test('forceCodeForRefreshToken sent with init method call', () async { - final GoogleSignIn googleSignIn = - GoogleSignIn(forceCodeForRefreshToken: true); - - await googleSignIn.signIn(); - - _verifyInit(mockPlatform, forceCodeForRefreshToken: true); - verify(mockPlatform.signIn()); + test('reports success from authenticate', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const String idToken = 'idToken'; + when(mockPlatform.authenticate(any)).thenAnswer((_) async => + const AuthenticationResults( + user: defaultUser, + authenticationTokens: AuthenticationTokenData(idToken: idToken))); + + final Future eventFuture = + googleSignIn.authenticationEvents.first; + await googleSignIn.initialize(); + await googleSignIn.authenticate(); + final GoogleSignInAuthenticationEvent event = await eventFuture; + + expect(event, isA()); + final GoogleSignInAuthenticationEventSignIn signIn = + event as GoogleSignInAuthenticationEventSignIn; + expect(signIn.user.id, defaultUser.id); + expect(signIn.user.authentication.idToken, idToken); }); - test('forceAccountName sent with init method call', () async { - final GoogleSignIn googleSignIn = - GoogleSignIn(forceAccountName: 'fakeEmailAddress@example.com'); - - await googleSignIn.signIn(); + test('reports sync exceptions from authenticate', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.interrupted); + when(mockPlatform.authenticate(any)).thenThrow(exception); + + final Completer errorCompleter = Completer(); + final StreamSubscription subscription = + googleSignIn.authenticationEvents + .handleError((Object e) => errorCompleter.complete(e)) + .listen((_) => fail('The only event should be an error')); + await googleSignIn.initialize(); + await expectLater( + googleSignIn.authenticate(), throwsA(isA())); + + final Object e = await errorCompleter.future; + expect(e, exception); + await subscription.cancel(); + }); - _verifyInit(mockPlatform, - forceAccountName: 'fakeEmailAddress@example.com'); - verify(mockPlatform.signIn()); + test('reports async exceptions from authenticate', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.interrupted); + when(mockPlatform.authenticate(any)) + .thenAnswer((_) async => throw exception); + + final Completer errorCompleter = Completer(); + final StreamSubscription subscription = + googleSignIn.authenticationEvents + .handleError((Object e) => errorCompleter.complete(e)) + .listen((_) => fail('The only event should be an error')); + await googleSignIn.initialize(); + await expectLater( + googleSignIn.authenticate(), throwsA(isA())); + + final Object e = await errorCompleter.future; + expect(e, exception); + await subscription.cancel(); }); - test('signOut', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + test('reports sign out from signOut', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + final Future eventFuture = + googleSignIn.authenticationEvents.first; + await googleSignIn.initialize(); await googleSignIn.signOut(); + final GoogleSignInAuthenticationEvent event = await eventFuture; - _verifyInit(mockPlatform); - verify(mockPlatform.signOut()); + expect(event, isA()); }); - test('disconnect; null response', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + test('reports sign out from disconnect', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + final Future eventFuture = + googleSignIn.authenticationEvents.first; + await googleSignIn.initialize(); await googleSignIn.disconnect(); + final GoogleSignInAuthenticationEvent event = await eventFuture; - expect(googleSignIn.currentUser, isNull); - _verifyInit(mockPlatform); - verify(mockPlatform.disconnect()); + expect(event, isA()); }); + }); - test('isSignedIn', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.isSignedIn()).thenAnswer((Invocation _) async => true); + group('supportsAuthenticate', () { + for (final bool support in [true, false]) { + test('reports $support from platform', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - final bool result = await googleSignIn.isSignedIn(); + when(mockPlatform.supportsAuthenticate()).thenReturn(support); - expect(result, isTrue); - _verifyInit(mockPlatform); - verify(mockPlatform.isSignedIn()); - }); + expect(googleSignIn.supportsAuthenticate(), support); + }); + } + }); - test('signIn works even if a previous call throws error in other zone', - () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + group('authorizationRequiresUserInteraction', () { + for (final bool support in [true, false]) { + test('reports $support from platform', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - when(mockPlatform.signInSilently()).thenThrow(Exception('Not a user')); - await runZonedGuarded(() async { - expect(await googleSignIn.signInSilently(), isNull); - }, (Object e, StackTrace st) {}); - expect(await googleSignIn.signIn(), isNotNull); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); - verify(mockPlatform.signIn()); - }); - - test('concurrent calls of the same method trigger sign in once', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - final List> futures = - >[ - googleSignIn.signInSilently(), - googleSignIn.signInSilently(), - ]; + when(mockPlatform.authorizationRequiresUserInteraction()) + .thenReturn(support); - expect(futures.first, isNot(futures.last), - reason: 'Must return new Future'); + expect(googleSignIn.authorizationRequiresUserInteraction(), support); + }); + } + }); - final List users = await Future.wait(futures); + group('attemptLightweightAuthentication', () { + test('returns successful authentication', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const String idToken = 'idToken'; + when(mockPlatform.attemptLightweightAuthentication(any)).thenAnswer( + (_) async => const AuthenticationResults( + user: defaultUser, + authenticationTokens: AuthenticationTokenData(idToken: idToken))); + + final Future? signInFuture = + googleSignIn.attemptLightweightAuthentication(); + expect(signInFuture, isNotNull); + final GoogleSignInAccount? signIn = await signInFuture; + expect(signIn?.displayName, defaultUser.displayName); + expect(signIn?.email, defaultUser.email); + expect(signIn?.id, defaultUser.id); + expect(signIn?.photoUrl, defaultUser.photoUrl); + expect(signIn?.authentication.idToken, idToken); + }); - expect(googleSignIn.currentUser, isNotNull); - expect(users, [ - googleSignIn.currentUser, - googleSignIn.currentUser - ]); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()).called(1); + test('reports all exceptions when requested - sync', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.canceled); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenThrow(exception); + + await googleSignIn.initialize(); + expect( + googleSignIn.attemptLightweightAuthentication( + reportAllExceptions: true), + throwsA(isA().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); }); - test('can sign in after previously failed attempt', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.signInSilently()).thenThrow(Exception('Not a user')); + test( + 'reports serious exceptions even when all exceptions are not requested - sync', + () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = GoogleSignInException( + code: GoogleSignInExceptionCode.clientConfigurationError); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenThrow(exception); + + await googleSignIn.initialize(); + expect( + googleSignIn.attemptLightweightAuthentication(), + throwsA(isA().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.clientConfigurationError))); + }); - expect(await googleSignIn.signInSilently(), isNull); - expect(await googleSignIn.signIn(), isNotNull); + test('reports all exceptions when requested - async', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.canceled); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenAnswer((_) async => throw exception); + + await googleSignIn.initialize(); + expect( + googleSignIn.attemptLightweightAuthentication( + reportAllExceptions: true), + throwsA(isA().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); + }); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); - verify(mockPlatform.signIn()); + test( + 'reports serious exceptions even when all exceptions are not requested - async', + () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = GoogleSignInException( + code: GoogleSignInExceptionCode.clientConfigurationError); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenAnswer((_) async => throw exception); + + await googleSignIn.initialize(); + expect( + googleSignIn.attemptLightweightAuthentication(), + throwsA(isA().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.clientConfigurationError))); }); - test('concurrent calls of different signIn methods', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - final List> futures = - >[ - googleSignIn.signInSilently(), - googleSignIn.signIn(), - ]; - expect(futures.first, isNot(futures.last)); + test('returns a null future from the platform', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - final List users = await Future.wait(futures); + when(mockPlatform.attemptLightweightAuthentication(any)).thenReturn(null); - expect(users.first, users.last, reason: 'Must return the same user'); - expect(googleSignIn.currentUser, users.last); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); - verifyNever(mockPlatform.signIn()); + final Future? signInFuture = + googleSignIn.attemptLightweightAuthentication(); + expect(signInFuture, isNull); }); - test('can sign in after aborted flow', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + test('returns a future that resolves to null from the platform', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - when(mockPlatform.signIn()).thenAnswer((Invocation _) async => null); - expect(await googleSignIn.signIn(), isNull); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenAnswer((_) async => null); - when(mockPlatform.signIn()) - .thenAnswer((Invocation _) async => kDefaultUser); - expect(await googleSignIn.signIn(), isNotNull); + final Future? signInFuture = + googleSignIn.attemptLightweightAuthentication(); + expect(signInFuture, isNotNull); + final GoogleSignInAccount? signIn = await signInFuture; + expect(signIn, isNull); }); + }); - test('signOut/disconnect methods always trigger native calls', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - final List> futures = - >[ - googleSignIn.signOut(), - googleSignIn.signOut(), - googleSignIn.disconnect(), - googleSignIn.disconnect(), - ]; - - await Future.wait(futures); + group('authenticate', () { + test('passes expected paramaters', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const List scopes = ['scope1', 'scope2']; + when(mockPlatform.authenticate(any)).thenAnswer((_) async => + const AuthenticationResults( + user: defaultUser, + authenticationTokens: + AuthenticationTokenData(idToken: 'idToken'))); + + await googleSignIn.initialize(); + await googleSignIn.authenticate(scopeHint: scopes); + + final VerificationResult verification = + verify(mockPlatform.authenticate(captureAny)); + final AuthenticateParameters params = + verification.captured[0] as AuthenticateParameters; + expect(params.scopeHint, scopes); + }); - _verifyInit(mockPlatform); - verify(mockPlatform.signOut()).called(2); - verify(mockPlatform.disconnect()).called(2); + test('returns successful authentication', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const String idToken = 'idToken'; + when(mockPlatform.authenticate(any)).thenAnswer((_) async => + const AuthenticationResults( + user: defaultUser, + authenticationTokens: AuthenticationTokenData(idToken: idToken))); + + final GoogleSignInAccount signIn = await googleSignIn.authenticate(); + expect(signIn.displayName, defaultUser.displayName); + expect(signIn.email, defaultUser.email); + expect(signIn.id, defaultUser.id); + expect(signIn.photoUrl, defaultUser.photoUrl); + expect(signIn.authentication.idToken, idToken); }); - test('queue of many concurrent calls', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - final List> futures = - >[ - googleSignIn.signInSilently(), - googleSignIn.signOut(), - googleSignIn.signIn(), - googleSignIn.disconnect(), - ]; + test('reports exceptions', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await Future.wait(futures); + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.interrupted); + when(mockPlatform.authenticate(any)).thenThrow(exception); - _verifyInit(mockPlatform); - verifyInOrder([ - mockPlatform.signInSilently(), - mockPlatform.signOut(), - mockPlatform.signIn(), - mockPlatform.disconnect(), - ]); + await googleSignIn.initialize(); + expect( + googleSignIn.authenticate(), + throwsA(isA().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.interrupted))); }); + }); - test('signInSilently suppresses errors by default', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.signInSilently()).thenThrow(Exception('I am an error')); - expect(await googleSignIn.signInSilently(), isNull); // should not throw + group('authorizationForScopes', () { + test('passes expected paramaters when called for a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + when(mockPlatform.authenticate(any)).thenAnswer((_) async => + const AuthenticationResults( + user: defaultUser, + authenticationTokens: + AuthenticationTokenData(idToken: 'idToken'))); + when(mockPlatform.clientAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); + + await googleSignIn.initialize(); + final GoogleSignInAccount authentication = + await googleSignIn.authenticate(); + const List scopes = ['scope1', 'scope2']; + await authentication.authorizationClient.authorizationForScopes(scopes); + + final VerificationResult verification = + verify(mockPlatform.clientAuthorizationTokensForScopes(captureAny)); + final ClientAuthorizationTokensForScopesParameters params = verification + .captured[0] as ClientAuthorizationTokensForScopesParameters; + expect(params.request.scopes, scopes); + expect(params.request.userId, defaultUser.id); + expect(params.request.email, defaultUser.email); + expect(params.request.promptIfUnauthorized, false); }); - test('signInSilently forwards exceptions', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.signInSilently()).thenThrow(Exception('I am an error')); - expect(googleSignIn.signInSilently(suppressErrors: false), - throwsA(isInstanceOf())); - }); + test('passes expected paramaters when called without a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - test('signInSilently allows re-authentication to be requested', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - await googleSignIn.signInSilently(); - expect(googleSignIn.currentUser, isNotNull); + when(mockPlatform.clientAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - await googleSignIn.signInSilently(reAuthenticate: true); + const List scopes = ['scope1', 'scope2']; + await googleSignIn.authorizationClient.authorizationForScopes(scopes); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()).called(2); + final VerificationResult verification = + verify(mockPlatform.clientAuthorizationTokensForScopes(captureAny)); + final ClientAuthorizationTokensForScopesParameters params = verification + .captured[0] as ClientAuthorizationTokensForScopesParameters; + expect(params.request.scopes, scopes); + expect(params.request.userId, null); + expect(params.request.email, null); + expect(params.request.promptIfUnauthorized, false); }); - test('can sign in after init failed before', () async { - // Web eagerly `initWithParams` when GoogleSignIn is created, so make sure - // the initWithParams is throwy ASAP. - when(mockPlatform.initWithParams(any)) - .thenThrow(Exception('First init fails')); - - final GoogleSignIn googleSignIn = GoogleSignIn(); + test('reports tokens', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - expect(googleSignIn.signIn(), throwsA(isInstanceOf())); + const String accessToken = 'accessToken'; + when(mockPlatform.clientAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ClientAuthorizationTokenData(accessToken: accessToken)); - when(mockPlatform.initWithParams(any)) - .thenAnswer((Invocation _) async {}); - expect(await googleSignIn.signIn(), isNotNull); + const List scopes = ['scope1', 'scope2']; + final GoogleSignInClientAuthorization? auth = + await googleSignIn.authorizationClient.authorizationForScopes(scopes); + expect(auth?.accessToken, accessToken); }); - test('created with standard factory uses correct options', () async { - final GoogleSignIn googleSignIn = GoogleSignIn.standard(); + test('reports null', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signInSilently(); - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); - }); + when(mockPlatform.clientAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - test('created with defaultGamesSignIn factory uses correct options', - () async { - final GoogleSignIn googleSignIn = GoogleSignIn.games(); + const List scopes = ['scope1', 'scope2']; + expect( + await googleSignIn.authorizationClient.authorizationForScopes(scopes), + null); + }); + }); - await googleSignIn.signInSilently(); - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform, signInOption: SignInOption.games); - verify(mockPlatform.signInSilently()); + group('authorizeScopes', () { + test('passes expected paramaters when called for a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + when(mockPlatform.authenticate(any)).thenAnswer((_) async => + const AuthenticationResults( + user: defaultUser, + authenticationTokens: + AuthenticationTokenData(idToken: 'idToken'))); + when(mockPlatform.clientAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ClientAuthorizationTokenData(accessToken: 'accessToken')); + + await googleSignIn.initialize(); + final GoogleSignInAccount authentication = + await googleSignIn.authenticate(); + const List scopes = ['scope1', 'scope2']; + await authentication.authorizationClient.authorizeScopes(scopes); + + final VerificationResult verification = + verify(mockPlatform.clientAuthorizationTokensForScopes(captureAny)); + final ClientAuthorizationTokensForScopesParameters params = verification + .captured[0] as ClientAuthorizationTokensForScopesParameters; + expect(params.request.scopes, scopes); + expect(params.request.userId, defaultUser.id); + expect(params.request.email, defaultUser.email); + expect(params.request.promptIfUnauthorized, true); }); - test('authentication', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.getTokens( - email: anyNamed('email'), - shouldRecoverAuth: anyNamed('shouldRecoverAuth'))) - .thenAnswer((Invocation _) async => GoogleSignInTokenData( - idToken: '123', - accessToken: '456', - serverAuthCode: '789', - )); + test('passes expected paramaters when called without a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signIn(); + when(mockPlatform.clientAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ClientAuthorizationTokenData(accessToken: 'accessToken')); - final GoogleSignInAccount user = googleSignIn.currentUser!; - final GoogleSignInAuthentication auth = await user.authentication; + const List scopes = ['scope1', 'scope2']; + await googleSignIn.authorizationClient.authorizeScopes(scopes); - expect(auth.accessToken, '456'); - expect(auth.idToken, '123'); - verify(mockPlatform.getTokens( - email: 'john.doe@gmail.com', shouldRecoverAuth: true)); + final VerificationResult verification = + verify(mockPlatform.clientAuthorizationTokensForScopes(captureAny)); + final ClientAuthorizationTokensForScopesParameters params = verification + .captured[0] as ClientAuthorizationTokensForScopesParameters; + expect(params.request.scopes, scopes); + expect(params.request.userId, null); + expect(params.request.email, null); + expect(params.request.promptIfUnauthorized, true); }); - test('requestScopes returns true once new scope is granted', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.requestScopes(any)) - .thenAnswer((Invocation _) async => true); + test('reports tokens', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signIn(); - final bool result = - await googleSignIn.requestScopes(['testScope']); + const String accessToken = 'accessToken'; + when(mockPlatform.clientAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ClientAuthorizationTokenData(accessToken: accessToken)); - expect(result, isTrue); - _verifyInit(mockPlatform); - verify(mockPlatform.signIn()); - verify(mockPlatform.requestScopes(['testScope'])); + const List scopes = ['scope1', 'scope2']; + final GoogleSignInClientAuthorization auth = + await googleSignIn.authorizationClient.authorizeScopes(scopes); + expect(auth.accessToken, accessToken); }); - test('canAccessScopes forwards calls to platform', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.canAccessScopes( - any, - accessToken: anyNamed('accessToken'), - )).thenAnswer((Invocation _) async => true); + test('throws for unexpected null', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signIn(); - final bool result = await googleSignIn.canAccessScopes( - ['testScope'], - accessToken: 'xyz', - ); + when(mockPlatform.clientAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - expect(result, isTrue); - _verifyInit(mockPlatform); - verify(mockPlatform.canAccessScopes( - ['testScope'], - accessToken: 'xyz', - )); + const List scopes = ['scope1', 'scope2']; + await expectLater( + googleSignIn.authorizationClient.authorizeScopes(scopes), + throwsA(isA().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); }); + }); - test('userDataEvents are forwarded through the onUserChanged stream', - () async { - final StreamController userDataController = - StreamController(); - - when(mockPlatform.userDataEvents) - .thenAnswer((Invocation _) => userDataController.stream); - when(mockPlatform.isSignedIn()).thenAnswer((Invocation _) async => false); - - final GoogleSignIn googleSignIn = GoogleSignIn(); - await googleSignIn.isSignedIn(); - - // This is needed to ensure `_ensureInitialized` is called! - final Future> nextTwoEvents = - googleSignIn.onCurrentUserChanged.take(2).toList(); - - // Dispatch two events - userDataController.add(kDefaultUser); - userDataController.add(null); - - final List events = await nextTwoEvents; + group('authorizeServer', () { + test('passes expected paramaters when called for a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + when(mockPlatform.authenticate(any)).thenAnswer((_) async => + const AuthenticationResults( + user: defaultUser, + authenticationTokens: + AuthenticationTokenData(idToken: 'idToken'))); + when(mockPlatform.serverAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); + + await googleSignIn.initialize(); + final GoogleSignInAccount authentication = + await googleSignIn.authenticate(); + const List scopes = ['scope1', 'scope2']; + await authentication.authorizationClient.authorizeServer(scopes); + + final VerificationResult verification = + verify(mockPlatform.serverAuthorizationTokensForScopes(captureAny)); + final ServerAuthorizationTokensForScopesParameters params = verification + .captured[0] as ServerAuthorizationTokensForScopesParameters; + expect(params.request.scopes, scopes); + expect(params.request.userId, defaultUser.id); + expect(params.request.email, defaultUser.email); + expect(params.request.promptIfUnauthorized, true); + }); - expect(events.first, isNotNull); + test('passes expected paramaters when called without a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - final GoogleSignInAccount user = events.first!; + when(mockPlatform.serverAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - expect(user.displayName, equals(kDefaultUser.displayName)); - expect(user.email, equals(kDefaultUser.email)); - expect(user.id, equals(kDefaultUser.id)); - expect(user.photoUrl, equals(kDefaultUser.photoUrl)); - expect(user.serverAuthCode, equals(kDefaultUser.serverAuthCode)); + const List scopes = ['scope1', 'scope2']; + await googleSignIn.authorizationClient.authorizeServer(scopes); - // The second event was a null... - expect(events.last, isNull); + final VerificationResult verification = + verify(mockPlatform.serverAuthorizationTokensForScopes(captureAny)); + final ServerAuthorizationTokensForScopesParameters params = verification + .captured[0] as ServerAuthorizationTokensForScopesParameters; + expect(params.request.scopes, scopes); + expect(params.request.userId, null); + expect(params.request.email, null); + expect(params.request.promptIfUnauthorized, true); }); - test('user starts as null', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - expect(googleSignIn.currentUser, isNull); - }); + test('reports tokens', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - test('can sign in and sign out', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - await googleSignIn.signIn(); + const String authCode = 'authCode'; + when(mockPlatform.serverAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ServerAuthorizationTokenData(serverAuthCode: authCode)); - final GoogleSignInAccount user = googleSignIn.currentUser!; + const List scopes = ['scope1', 'scope2']; + final GoogleSignInServerAuthorization? auth = + await googleSignIn.authorizationClient.authorizeServer(scopes); + expect(auth?.serverAuthCode, authCode); + }); - expect(user.displayName, equals(kDefaultUser.displayName)); - expect(user.email, equals(kDefaultUser.email)); - expect(user.id, equals(kDefaultUser.id)); - expect(user.photoUrl, equals(kDefaultUser.photoUrl)); - expect(user.serverAuthCode, equals(kDefaultUser.serverAuthCode)); + test('reports null', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.disconnect(); - expect(googleSignIn.currentUser, isNull); - }); + when(mockPlatform.serverAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - test('disconnect when signout already succeeds', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - await googleSignIn.disconnect(); - expect(googleSignIn.currentUser, isNull); + const List scopes = ['scope1', 'scope2']; + expect( + await googleSignIn.authorizationClient.authorizeServer(scopes), null); }); }); } - -void _verifyInit( - MockGoogleSignInPlatform mockSignIn, { - List scopes = const [], - SignInOption signInOption = SignInOption.standard, - String? hostedDomain, - String? clientId, - String? serverClientId, - bool forceCodeForRefreshToken = false, - String? forceAccountName, -}) { - verify(mockSignIn.initWithParams(argThat( - isA() - .having( - (SignInInitParameters p) => p.scopes, - 'scopes', - scopes, - ) - .having( - (SignInInitParameters p) => p.signInOption, - 'signInOption', - signInOption, - ) - .having( - (SignInInitParameters p) => p.hostedDomain, - 'hostedDomain', - hostedDomain, - ) - .having( - (SignInInitParameters p) => p.clientId, - 'clientId', - clientId, - ) - .having( - (SignInInitParameters p) => p.serverClientId, - 'serverClientId', - serverClientId, - ) - .having( - (SignInInitParameters p) => p.forceCodeForRefreshToken, - 'forceCodeForRefreshToken', - forceCodeForRefreshToken, - ) - .having( - (SignInInitParameters p) => p.forceAccountName, - 'forceAccountName', - forceAccountName, - ), - ))); -} diff --git a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart index e81094692b8..4a3b29c57e8 100644 --- a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in google_sign_in/test/google_sign_in_test.dart. // Do not manually edit this file. @@ -18,20 +18,16 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeGoogleSignInTokenData_0 extends _i1.SmartFake - implements _i2.GoogleSignInTokenData { - _FakeGoogleSignInTokenData_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeAuthenticationResults_0 extends _i1.SmartFake + implements _i2.AuthenticationResults { + _FakeAuthenticationResults_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [GoogleSignInPlatform]. @@ -44,151 +40,78 @@ class MockGoogleSignInPlatform extends _i1.Mock } @override - bool get isMock => (super.noSuchMethod( - Invocation.getter(#isMock), - returnValue: false, - ) as bool); - - @override - _i4.Future init({ - List? scopes = const [], - _i2.SignInOption? signInOption = _i2.SignInOption.standard, - String? hostedDomain, - String? clientId, - }) => - (super.noSuchMethod( - Invocation.method( - #init, - [], - { - #scopes: scopes, - #signInOption: signInOption, - #hostedDomain: hostedDomain, - #clientId: clientId, - }, - ), + _i4.Future init(_i2.InitParameters? params) => (super.noSuchMethod( + Invocation.method(#init, [params]), returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); @override - _i4.Future initWithParams(_i2.SignInInitParameters? params) => + _i4.Future<_i2.AuthenticationResults?>? attemptLightweightAuthentication( + _i2.AttemptLightweightAuthenticationParameters? params, + ) => (super.noSuchMethod( - Invocation.method( - #initWithParams, - [params], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + Invocation.method(#attemptLightweightAuthentication, [params]), + ) as _i4.Future<_i2.AuthenticationResults?>?); @override - _i4.Future<_i2.GoogleSignInUserData?> signInSilently() => (super.noSuchMethod( - Invocation.method( - #signInSilently, - [], + _i4.Future<_i2.AuthenticationResults> authenticate( + _i2.AuthenticateParameters? params, + ) => + (super.noSuchMethod( + Invocation.method(#authenticate, [params]), + returnValue: _i4.Future<_i2.AuthenticationResults>.value( + _FakeAuthenticationResults_0( + this, + Invocation.method(#authenticate, [params]), + ), ), - returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), - ) as _i4.Future<_i2.GoogleSignInUserData?>); + ) as _i4.Future<_i2.AuthenticationResults>); @override - _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( - Invocation.method( - #signIn, - [], - ), - returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), - ) as _i4.Future<_i2.GoogleSignInUserData?>); + bool supportsAuthenticate() => (super.noSuchMethod( + Invocation.method(#supportsAuthenticate, []), + returnValue: false, + ) as bool); @override - _i4.Future<_i2.GoogleSignInTokenData> getTokens({ - required String? email, - bool? shouldRecoverAuth, - }) => - (super.noSuchMethod( - Invocation.method( - #getTokens, - [], - { - #email: email, - #shouldRecoverAuth: shouldRecoverAuth, - }, - ), - returnValue: _i4.Future<_i2.GoogleSignInTokenData>.value( - _FakeGoogleSignInTokenData_0( - this, - Invocation.method( - #getTokens, - [], - { - #email: email, - #shouldRecoverAuth: shouldRecoverAuth, - }, - ), - )), - ) as _i4.Future<_i2.GoogleSignInTokenData>); + bool authorizationRequiresUserInteraction() => (super.noSuchMethod( + Invocation.method(#authorizationRequiresUserInteraction, []), + returnValue: false, + ) as bool); @override - _i4.Future signOut() => (super.noSuchMethod( - Invocation.method( - #signOut, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i4.Future<_i2.ClientAuthorizationTokenData?> + clientAuthorizationTokensForScopes( + _i2.ClientAuthorizationTokensForScopesParameters? params, + ) => + (super.noSuchMethod( + Invocation.method(#clientAuthorizationTokensForScopes, [params]), + returnValue: _i4.Future<_i2.ClientAuthorizationTokenData?>.value(), + ) as _i4.Future<_i2.ClientAuthorizationTokenData?>); @override - _i4.Future disconnect() => (super.noSuchMethod( - Invocation.method( - #disconnect, - [], - ), + _i4.Future<_i2.ServerAuthorizationTokenData?> + serverAuthorizationTokensForScopes( + _i2.ServerAuthorizationTokensForScopesParameters? params, + ) => + (super.noSuchMethod( + Invocation.method(#serverAuthorizationTokensForScopes, [params]), + returnValue: _i4.Future<_i2.ServerAuthorizationTokenData?>.value(), + ) as _i4.Future<_i2.ServerAuthorizationTokenData?>); + + @override + _i4.Future signOut(_i2.SignOutParams? params) => (super.noSuchMethod( + Invocation.method(#signOut, [params]), returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); @override - _i4.Future isSignedIn() => (super.noSuchMethod( - Invocation.method( - #isSignedIn, - [], - ), - returnValue: _i4.Future.value(false), - ) as _i4.Future); - - @override - _i4.Future clearAuthCache({required String? token}) => + _i4.Future disconnect(_i2.DisconnectParams? params) => (super.noSuchMethod( - Invocation.method( - #clearAuthCache, - [], - {#token: token}, - ), + Invocation.method(#disconnect, [params]), returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); - - @override - _i4.Future requestScopes(List? scopes) => (super.noSuchMethod( - Invocation.method( - #requestScopes, - [scopes], - ), - returnValue: _i4.Future.value(false), - ) as _i4.Future); - - @override - _i4.Future canAccessScopes( - List? scopes, { - String? accessToken, - }) => - (super.noSuchMethod( - Invocation.method( - #canAccessScopes, - [scopes], - {#accessToken: accessToken}, - ), - returnValue: _i4.Future.value(false), - ) as _i4.Future); } diff --git a/packages/google_sign_in/google_sign_in/test/widgets_test.dart b/packages/google_sign_in/google_sign_in/test/widgets_test.dart index 717edc3699e..ad34111b377 100644 --- a/packages/google_sign_in/google_sign_in/test/widgets_test.dart +++ b/packages/google_sign_in/google_sign_in/test/widgets_test.dart @@ -28,9 +28,6 @@ class _TestGoogleIdentity extends GoogleIdentity { @override String? get displayName => null; - - @override - String? get serverAuthCode => null; } /// A mocked [HttpClient] which always returns a [_MockHttpRequest]. diff --git a/script/configs/exclude_all_packages_app.yaml b/script/configs/exclude_all_packages_app.yaml index cd350b3878c..f9ac561b8d6 100644 --- a/script/configs/exclude_all_packages_app.yaml +++ b/script/configs/exclude_all_packages_app.yaml @@ -11,9 +11,6 @@ # This is a permanent entry, as it should never be a direct app dependency. - plugin_platform_interface -# Breaking change in the process of being landed. This will be removed -# once all the layers have landed. -- google_sign_in_platform_interface -- google_sign_in_android -- google_sign_in_ios -- google_sign_in_web +# Temporarily excluded since it hasn't been updated for the major version +# change in google_sign_in. https://github.com/flutter/flutter/issues/171048 +- extension_google_sign_in_as_googleapis_auth