diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 124b689e7160..fb7039417129 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 000000000000..1369767d102c --- /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 2bcac7b37685..bde0918557ea 100644 --- a/packages/google_sign_in/google_sign_in/README.md +++ b/packages/google_sign_in/google_sign_in/README.md @@ -6,155 +6,107 @@ 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 - -To use this plugin, follow the -[plugin installation instructions](https://pub.dev/packages/google_sign_in/install). +### Initialization and authentication -### Use the plugin +Initialize the `GoogleSignIn` instance, and (optionally) start the lightweight +authentication process: -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); + + /// 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 = [ + 'email', + 'https://www.googleapis.com/auth/contacts.readonly', +]; +// ··· + GoogleSignInAccount? user; + // ··· + GoogleSignInClientAuthorization? authorization; + if (user != null) { + 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 some platforms, such as web, +this request **must be initiated from an 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 +114,37 @@ 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); +``` -If an app needs any scope other than `email`, `profile` and `openid`, it **must** -implement a more complete scope handling, as described above. +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. ## 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 54e454c28f4a..303d17d3d2c6 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 576ec36feffc..43a4f59e1fa4 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,23 @@ 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 +52,65 @@ 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); - 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 + GoogleSignInAccount? user; + // #enddocregion CheckAuthorization + String error = ''; + switch (event) { + case GoogleSignInAuthenticationEventSignIn(): + user = event.user; + case GoogleSignInAuthenticationEventSignOut(): + user = null; + case GoogleSignInAuthenticationEventException(): + user = null; + final GoogleSignInException e = event.exception; + error = 'GoogleSignInException ${e.code}: ${e.description}'; + } + + // Check for existing authorization. + // #docregion CheckAuthorization + GoogleSignInClientAuthorization? authorization; + if (user != null) { + authorization = + await user.authorizationClient.authorizationForScopes(scopes); + } + // #enddocregion CheckAuthorization + + setState(() { + _currentUser = user; + _isAuthorized = authorization != null; + _errorMessage = error; }); - // 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(); + // Now that we know that the user can access the required scopes, the app + // can call the REST API. + if (user != null && authorization != null) { + unawaited(_handleGetContact(user)); + } } // Calls the People API REST endpoint for the signed-in user to retrieve information. @@ -91,10 +118,18 @@ class _SignInDemoState extends State { setState(() { _contactText = 'Loading contact info...'; }); + final Map? headers = + await user.authorizationClient.authorizationHeaders(scopes); + if (headers == null) { + setState(() { + _contactText = '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(() { @@ -136,94 +171,139 @@ 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 { + // On the web, this must be called from an user interaction (button click). + 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 this call. + // 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`. - // - // This action is **required** in platforms that don't perform Authentication - // and Authorization at the same time (like the web). + // Requests a server auth code for the authorized scopes. // // 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!)); + 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 8d929d7ef835..000000000000 --- 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 85a54f0ac27e..000000000000 --- 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 c0a339663126..e96b03438c7f 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 5c854fc470b5..1e55df4a1fe0 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 000000000000..e60009b86edb --- /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 5335068734a8..6061b2dc8f98 100644 --- a/packages/google_sign_in/google_sign_in/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/example/pubspec.yaml @@ -28,3 +28,10 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_android: {path: ../../../../packages/google_sign_in/google_sign_in_android} + google_sign_in_ios: {path: ../../../../packages/google_sign_in/google_sign_in_ios} + google_sign_in_platform_interface: {path: ../../../../packages/google_sign_in/google_sign_in_platform_interface} + google_sign_in_web: {path: ../../../../packages/google_sign_in/google_sign_in_web} 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 93565ad052b5..2c5e09264e04 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,22 @@ 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. + /// Returns authentication tokens 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]. - /// - /// 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); + /// This returns the authentication information that was returned at the time + /// of the initial authentication. 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); } - /// Convenience method returning a `` map of HTML Authorization - /// headers, containing the current `authentication.accessToken`. - /// - /// 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', - }; - } - - /// 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 +80,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 +95,389 @@ 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, during a user event handler on web, or while the + /// app is foregrounded on mobile). + 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, during a user event handler on web, or while the + /// app is foregrounded on mobile). + 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(); - - return _setCurrentUser(response != null && response is GoogleSignInUserData - ? GoogleSignInAccount._(this, response) - : null); - } - - // Sets the current user, and propagates it through the _currentUserController. - GoogleSignInAccount? _setCurrentUser(GoogleSignInAccount? currentUser) { - if (currentUser != _currentUser) { - _currentUser = currentUser; - _currentUserController.add(_currentUser); - } - 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; - } - - // 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, + /// 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, - forceCodeForRefreshToken: forceCodeForRefreshToken, - forceAccountName: forceAccountName, + nonce: nonce, + hostedDomain: hostedDomain, )); - unawaited(GoogleSignInPlatform.instance.userDataEvents - ?.map((GoogleSignInUserData? userData) { - return userData != null ? GoogleSignInAccount._(this, userData) : null; - }).forEach(_setCurrentUser)); - } - - /// 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; + final Stream? platformAuthEvents = + GoogleSignInPlatform.instance.authenticationEvents; + if (platformAuthEvents == null) { + _createAuthenticationStreamEvents = true; + } else { + unawaited(platformAuthEvents.forEach(_translateAuthenticationEvent)); + } } - /// Adds call to [method] in a queue for execution. + /// Converts [event] into a corresponding event using the app-facing package + /// types. /// - /// At most one in flight call is allowed to prevent concurrent (out of order) - /// updates to [currentUser] and [onCurrentUserChanged]. + /// 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. /// - /// 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; - } - return _callMethod(method); - }); + /// 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 + .add(GoogleSignInAuthenticationEventException(event.exception)); } - // 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; + /// Subscribe to this stream to be notified when sign in (authentication) and + /// sign out events happen. + Stream get authenticationEvents { + return _authenticationStreamController.stream; + } + + final StreamController + _authenticationStreamController = + StreamController.broadcast(); - /// Attempts to sign in a previously authenticated user without interaction. + // 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; + + /// Attempts to sign in a previously authenticated user with minimal + /// 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. + /// 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. /// - /// 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. + /// Use [authenticate] instead to trigger a full interactive sign in process. /// - /// 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. + /// 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. /// - /// 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, + /// 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, }) async { try { - return await _addMethodCall(GoogleSignInPlatform.instance.signInSilently, - canSkipCall: !reAuthenticate); - } catch (_) { - if (suppressErrors) { + final Future? future = + GoogleSignInPlatform.instance.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + if (future == null) { return null; - } else { - rethrow; } - } - } + final AuthenticationResults? result = await future; + if (result == null) { + return Future.value(); + } - /// Returns a future that resolves to whether a user is currently signed in. - Future isSignedIn() async { - await _ensureInitialized(); - return GoogleSignInPlatform.instance.isSignedIn(); + final GoogleSignInAccount account = + GoogleSignInAccount._(result.user, result.authenticationTokens); + if (_createAuthenticationStreamEvents) { + _authenticationStreamController + .add(GoogleSignInAuthenticationEventSignIn(user: account)); + } + return account; + } on GoogleSignInException catch (e) { + if (_createAuthenticationStreamEvents) { + _authenticationStreamController + .add(GoogleSignInAuthenticationEventException(e)); + } + + if (!reportAllExceptions) { + switch (e.code) { + case GoogleSignInExceptionCode.canceled: + case GoogleSignInExceptionCode.interrupted: + case GoogleSignInExceptionCode.uiUnavailable: + return null; + // Only specific types are ignored, everything else should rethrow. + // ignore: no_default_cases + default: + rethrow; + } + } + rethrow; + } } - /// Starts the interactive sign-in process. + /// Whether or not the current platform supports the [authenticate] method. /// - /// Returned Future resolves to an instance of [GoogleSignInAccount] for a - /// successful sign in or `null` in case sign in process was aborted. + /// If this returns false, [authenticate] will throw an UnsupportedError if + /// called. See the platform-specific documentation for the package to + /// determine how authentication his 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(); + + /// Starts an interactive sign-in process. /// - /// 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. + /// 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. /// - /// 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); - } - - /// 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); + /// 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) { + if (_createAuthenticationStreamEvents) { + _authenticationStreamController + .add(GoogleSignInAuthenticationEventException(e)); + } + 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 000000000000..3c24d2f82df4 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/lib/src/event_types.dart @@ -0,0 +1,47 @@ +// 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 {} + +/// An authentication failure that resulted in an exception. +@immutable +class GoogleSignInAuthenticationEventException + extends GoogleSignInAuthenticationEvent { + /// Creates an exception event. + const GoogleSignInAuthenticationEventException(this.exception); + + /// The exception thrown during authentication. + final GoogleSignInException exception; +} 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 8a1d4dcb354f..068403e74629 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 000000000000..79e05c8aec5c --- /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 ab04de4319d5..f5c210b7f570 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 c256a4e6105a..a24eded31d3f 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 @@ -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 @@ -47,3 +48,10 @@ false_secrets: - /example/ios/RunnerTests/GoogleService-Info.plist - /example/ios/RunnerTests/GoogleSignInTests.m - /example/macos/Runner/Info.plist +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_android: {path: ../../../packages/google_sign_in/google_sign_in_android} + google_sign_in_ios: {path: ../../../packages/google_sign_in/google_sign_in_ios} + google_sign_in_platform_interface: {path: ../../../packages/google_sign_in/google_sign_in_platform_interface} + google_sign_in_web: {path: ../../../packages/google_sign_in/google_sign_in_web} 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 0853691f6ba0..ac4a8b887b6d 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 @@ -2,13 +2,12 @@ // 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_test/flutter_test.dart'; 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 +15,411 @@ 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'); - - 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); - - GoogleSignInPlatform.instance = mockPlatform; - }); - - test('signInSilently', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + late MockGoogleSignInPlatform mockPlatform; - await googleSignIn.signInSilently(); + setUp(() { + mockPlatform = TestMockGoogleSignInPlatform(); + when(mockPlatform.authenticationEvents).thenReturn(null); - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); - }); + GoogleSignInPlatform.instance = mockPlatform; + }); - test('signIn', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + group('initialize', () { + test('passes nulls by default', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signIn(); + await googleSignIn.initialize(); - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform); - verify(mockPlatform.signIn()); + 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('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()); + 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('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()); + 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( - 'clientId and serverClientId parameters is forwarded to implementation', - () 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()); + test('reports exceptions from attemptLightweightAuthentication', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; + + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.interrupted); + when(mockPlatform.attemptLightweightAuthentication(any)) + .thenThrow(exception); + + final Future eventFuture = + googleSignIn.authenticationEvents.first; + await googleSignIn.initialize(); + // This doesn't throw, since reportAllExceptions is false. + await googleSignIn.attemptLightweightAuthentication(); + final GoogleSignInAuthenticationEvent event = await eventFuture; + + expect(event, isA()); + final GoogleSignInAuthenticationEventException exceptionEvent = + event as GoogleSignInAuthenticationEventException; + expect(exceptionEvent.exception, exception); }); - 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'); + test('reports exceptions from authenticate', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signIn(); + const GoogleSignInException exception = + GoogleSignInException(code: GoogleSignInExceptionCode.interrupted); + when(mockPlatform.authenticate(any)).thenThrow(exception); - _verifyInit(mockPlatform, - forceAccountName: 'fakeEmailAddress@example.com'); - verify(mockPlatform.signIn()); + final Future eventFuture = + googleSignIn.authenticationEvents.first; + await googleSignIn.initialize(); + await expectLater( + googleSignIn.authenticate(), throwsA(isA())); + final GoogleSignInAuthenticationEvent event = await eventFuture; + + expect(event, isA()); + final GoogleSignInAuthenticationEventException exceptionEvent = + event as GoogleSignInAuthenticationEventException; + expect(exceptionEvent.exception, exception); }); - 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); - - final bool result = await googleSignIn.isSignedIn(); + group('supportsAuthenticate', () { + for (final bool support in [true, false]) { + test('reports $support from platform', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - expect(result, isTrue); - _verifyInit(mockPlatform); - verify(mockPlatform.isSignedIn()); - }); + when(mockPlatform.supportsAuthenticate()).thenReturn(support); - test('signIn works even if a previous call throws error in other zone', - () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - - 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()); - }); + expect(googleSignIn.supportsAuthenticate(), support); + }); + } + }); - test('concurrent calls of the same method trigger sign in once', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - final List> futures = - >[ - googleSignIn.signInSilently(), - googleSignIn.signInSilently(), - ]; - - expect(futures.first, isNot(futures.last), - reason: 'Must return new Future'); - - final List users = await Future.wait(futures); - - expect(googleSignIn.currentUser, isNotNull); - expect(users, [ - googleSignIn.currentUser, - googleSignIn.currentUser - ]); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()).called(1); + 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('can sign in after previously failed attempt', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.signInSilently()).thenThrow(Exception('Not a user')); + test('passes expected paramaters when called without a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - expect(await googleSignIn.signInSilently(), isNull); - expect(await googleSignIn.signIn(), isNotNull); + when(mockPlatform.clientAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); - verify(mockPlatform.signIn()); - }); + const List scopes = ['scope1', 'scope2']; + await googleSignIn.authorizationClient.authorizationForScopes(scopes); - 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)); - - final List users = await Future.wait(futures); - - 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 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 aborted flow', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); + test('reports tokens', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - when(mockPlatform.signIn()).thenAnswer((Invocation _) async => null); - expect(await googleSignIn.signIn(), isNull); + const String accessToken = 'accessToken'; + when(mockPlatform.clientAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ClientAuthorizationTokenData(accessToken: accessToken)); - when(mockPlatform.signIn()) - .thenAnswer((Invocation _) async => kDefaultUser); - expect(await googleSignIn.signIn(), isNotNull); + const List scopes = ['scope1', 'scope2']; + final GoogleSignInClientAuthorization? auth = + await googleSignIn.authorizationClient.authorizationForScopes(scopes); + expect(auth?.accessToken, accessToken); }); - 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); - - _verifyInit(mockPlatform); - verify(mockPlatform.signOut()).called(2); - verify(mockPlatform.disconnect()).called(2); - }); + test('reports null', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - test('queue of many concurrent calls', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - final List> futures = - >[ - googleSignIn.signInSilently(), - googleSignIn.signOut(), - googleSignIn.signIn(), - googleSignIn.disconnect(), - ]; - - await Future.wait(futures); - - _verifyInit(mockPlatform); - verifyInOrder([ - mockPlatform.signInSilently(), - mockPlatform.signOut(), - mockPlatform.signIn(), - mockPlatform.disconnect(), - ]); - }); + when(mockPlatform.clientAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - 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 + const List scopes = ['scope1', 'scope2']; + expect( + await googleSignIn.authorizationClient.authorizationForScopes(scopes), + null); }); + }); - 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('signInSilently allows re-authentication to be requested', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - await googleSignIn.signInSilently(); - expect(googleSignIn.currentUser, isNotNull); - - await googleSignIn.signInSilently(reAuthenticate: true); - - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()).called(2); + 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('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('passes expected paramaters when called without a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - expect(googleSignIn.signIn(), throwsA(isInstanceOf())); + when(mockPlatform.clientAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ClientAuthorizationTokenData(accessToken: 'accessToken')); - when(mockPlatform.initWithParams(any)) - .thenAnswer((Invocation _) async {}); - expect(await googleSignIn.signIn(), isNotNull); - }); - - test('created with standard factory uses correct options', () async { - final GoogleSignIn googleSignIn = GoogleSignIn.standard(); + const List scopes = ['scope1', 'scope2']; + await googleSignIn.authorizationClient.authorizeScopes(scopes); - await googleSignIn.signInSilently(); - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform); - verify(mockPlatform.signInSilently()); + 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('created with defaultGamesSignIn factory uses correct options', - () async { - final GoogleSignIn googleSignIn = GoogleSignIn.games(); + test('reports tokens', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signInSilently(); - expect(googleSignIn.currentUser, isNotNull); - _verifyInit(mockPlatform, signInOption: SignInOption.games); - verify(mockPlatform.signInSilently()); - }); + const String accessToken = 'accessToken'; + when(mockPlatform.clientAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ClientAuthorizationTokenData(accessToken: accessToken)); - 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', - )); - - await googleSignIn.signIn(); - - final GoogleSignInAccount user = googleSignIn.currentUser!; - final GoogleSignInAuthentication auth = await user.authentication; - - expect(auth.accessToken, '456'); - expect(auth.idToken, '123'); - verify(mockPlatform.getTokens( - email: 'john.doe@gmail.com', shouldRecoverAuth: true)); + const List scopes = ['scope1', 'scope2']; + final GoogleSignInClientAuthorization auth = + await googleSignIn.authorizationClient.authorizeScopes(scopes); + expect(auth.accessToken, accessToken); }); - test('requestScopes returns true once new scope is granted', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.requestScopes(any)) - .thenAnswer((Invocation _) async => true); + test('throws for unexpected null', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - await googleSignIn.signIn(); - final bool result = - await googleSignIn.requestScopes(['testScope']); + when(mockPlatform.clientAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - expect(result, isTrue); - _verifyInit(mockPlatform); - verify(mockPlatform.signIn()); - verify(mockPlatform.requestScopes(['testScope'])); + const List scopes = ['scope1', 'scope2']; + await expectLater( + googleSignIn.authorizationClient.authorizeScopes(scopes), + throwsA(isA().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); }); + }); - test('canAccessScopes forwards calls to platform', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - when(mockPlatform.canAccessScopes( - any, - accessToken: anyNamed('accessToken'), - )).thenAnswer((Invocation _) async => true); - - await googleSignIn.signIn(); - final bool result = await googleSignIn.canAccessScopes( - ['testScope'], - accessToken: 'xyz', - ); - - expect(result, isTrue); - _verifyInit(mockPlatform); - verify(mockPlatform.canAccessScopes( - ['testScope'], - accessToken: 'xyz', - )); + 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); }); - 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(); + test('passes expected paramaters when called without a user', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - // This is needed to ensure `_ensureInitialized` is called! - final Future> nextTwoEvents = - googleSignIn.onCurrentUserChanged.take(2).toList(); + when(mockPlatform.serverAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - // Dispatch two events - userDataController.add(kDefaultUser); - userDataController.add(null); + const List scopes = ['scope1', 'scope2']; + await googleSignIn.authorizationClient.authorizeServer(scopes); - final List events = await nextTwoEvents; - - expect(events.first, isNotNull); - - final GoogleSignInAccount user = events.first!; + 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); + }); - 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 tokens', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - // The second event was a null... - expect(events.last, isNull); - }); + const String authCode = 'authCode'; + when(mockPlatform.serverAuthorizationTokensForScopes(any)).thenAnswer( + (_) async => + const ServerAuthorizationTokenData(serverAuthCode: authCode)); - test('user starts as null', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - expect(googleSignIn.currentUser, isNull); + const List scopes = ['scope1', 'scope2']; + final GoogleSignInServerAuthorization? auth = + await googleSignIn.authorizationClient.authorizeServer(scopes); + expect(auth?.serverAuthCode, authCode); }); - test('can sign in and sign out', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - await googleSignIn.signIn(); - - final GoogleSignInAccount user = googleSignIn.currentUser!; + test('reports null', () async { + final GoogleSignIn googleSignIn = GoogleSignIn.instance; - 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)); + when(mockPlatform.serverAuthorizationTokensForScopes(any)) + .thenAnswer((_) async => null); - await googleSignIn.disconnect(); - expect(googleSignIn.currentUser, isNull); - }); - - 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 e81094692b86..f3a891d87963 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,72 @@ 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>); + _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 signOut() => (super.noSuchMethod( - Invocation.method( - #signOut, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _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 disconnect() => (super.noSuchMethod( - Invocation.method( - #disconnect, - [], - ), + _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 717edc3699ee..ad34111b3774 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/packages/google_sign_in/google_sign_in_android/CHANGELOG.md b/packages/google_sign_in/google_sign_in_android/CHANGELOG.md index 3d66c7b33fc9..5cc5b67e718d 100644 --- a/packages/google_sign_in/google_sign_in_android/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_android/CHANGELOG.md @@ -1,3 +1,10 @@ +## 7.0.0 + +* **BREAKING CHANGE**: Switches to implementing version 3.0 of the platform + interface package, rather than 2.x, significantly changing the API surface. +* Switches to Sign in with Google (`CredentialManager`) as the underlying + SDK, removing usage of the deprecated Google Sign In for Android SDK. + ## 6.2.1 * Removes obsolete code related to supporting SDK <21. diff --git a/packages/google_sign_in/google_sign_in_android/README.md b/packages/google_sign_in/google_sign_in_android/README.md index aeaeb9df6e93..bd91f0426828 100644 --- a/packages/google_sign_in/google_sign_in_android/README.md +++ b/packages/google_sign_in/google_sign_in_android/README.md @@ -13,3 +13,28 @@ should add it to your `pubspec.yaml` as usual. [1]: https://pub.dev/packages/google_sign_in [2]: https://flutter.dev/to/endorsed-federated-plugin + +## Integration + +To use Google Sign-In, you'll need to +[register your application](https://firebase.google.com/docs/android/setup). +If you are using Google Cloud Platform directly, rather than Firebase, you will +need to register both an Android application and a web application in the +[Google Cloud Platform API manager](https://console.developers.google.com/). + +* If you are use the `google-services.json` file and Gradle-based registration + system, no identifiers need to be provided in Dart when initializing the + `GoogleSignIn` instance. +* If you are not using `google-services.json`, you need to pass the client + ID of the *web* application you registered as the `serverClientId` when + initializing the `GoogleSignIn` instance. + +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. + +You will also need to enable any 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 example app, +you'll need to enable the +[Google People API](https://developers.google.com/people/). diff --git a/packages/google_sign_in/google_sign_in_android/android/build.gradle b/packages/google_sign_in/google_sign_in_android/android/build.gradle index 74e7499377b2..829160c5e0ef 100644 --- a/packages/google_sign_in/google_sign_in_android/android/build.gradle +++ b/packages/google_sign_in/google_sign_in_android/android/build.gradle @@ -2,6 +2,7 @@ group 'io.flutter.plugins.googlesignin' version '1.0-SNAPSHOT' buildscript { + ext.kotlin_version = '2.1.10' repositories { google() mavenCentral() @@ -9,6 +10,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.5.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -20,6 +22,7 @@ rootProject.allprojects { } apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' android { namespace 'io.flutter.plugins.googlesignin' @@ -35,6 +38,14 @@ android { targetCompatibility JavaVersion.VERSION_11 } + kotlinOptions { + jvmTarget = '11' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + lintOptions { checkAllWarnings true warningsAsErrors true @@ -56,7 +67,10 @@ android { } dependencies { - implementation 'com.google.android.gms:play-services-auth:21.0.0' + implementation 'androidx.credentials:credentials:1.5.0' + implementation 'androidx.credentials:credentials-play-services-auth:1.5.0' + implementation 'com.google.android.libraries.identity.googleid:googleid:1.1.1' + implementation 'com.google.android.gms:play-services-auth:21.3.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-inline:5.2.0' } diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java index 462942127036..53a1e48cc783 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInPlugin.java @@ -7,36 +7,50 @@ import android.accounts.Account; import android.annotation.SuppressLint; import android.app.Activity; +import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; +import android.content.IntentSender; +import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import com.google.android.gms.auth.GoogleAuthUtil; -import com.google.android.gms.auth.UserRecoverableAuthException; -import com.google.android.gms.auth.api.signin.GoogleSignIn; -import com.google.android.gms.auth.api.signin.GoogleSignInAccount; -import com.google.android.gms.auth.api.signin.GoogleSignInClient; -import com.google.android.gms.auth.api.signin.GoogleSignInOptions; -import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes; +import androidx.credentials.ClearCredentialStateRequest; +import androidx.credentials.Credential; +import androidx.credentials.CredentialManager; +import androidx.credentials.CredentialManagerCallback; +import androidx.credentials.CustomCredential; +import androidx.credentials.GetCredentialRequest; +import androidx.credentials.GetCredentialResponse; +import androidx.credentials.exceptions.ClearCredentialException; +import androidx.credentials.exceptions.GetCredentialCancellationException; +import androidx.credentials.exceptions.GetCredentialException; +import androidx.credentials.exceptions.GetCredentialInterruptedException; +import androidx.credentials.exceptions.GetCredentialProviderConfigurationException; +import androidx.credentials.exceptions.GetCredentialUnsupportedException; +import androidx.credentials.exceptions.NoCredentialException; +import com.google.android.gms.auth.api.identity.AuthorizationClient; +import com.google.android.gms.auth.api.identity.AuthorizationRequest; +import com.google.android.gms.auth.api.identity.AuthorizationResult; +import com.google.android.gms.auth.api.identity.Identity; import com.google.android.gms.common.api.ApiException; -import com.google.android.gms.common.api.CommonStatusCodes; import com.google.android.gms.common.api.Scope; -import com.google.android.gms.tasks.RuntimeExecutionException; -import com.google.android.gms.tasks.Task; +import com.google.android.libraries.identity.googleid.GetGoogleIdOption; +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption; +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential; +import io.flutter.Log; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.plugin.common.BinaryMessenger; import io.flutter.plugin.common.PluginRegistry; -import io.flutter.plugins.googlesignin.Messages.FlutterError; -import io.flutter.plugins.googlesignin.Messages.GoogleSignInApi; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.concurrent.Executors; +import kotlin.Result; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; /** Google sign-in plugin for Flutter. */ public class GoogleSignInPlugin implements FlutterPlugin, ActivityAware { @@ -45,19 +59,22 @@ public class GoogleSignInPlugin implements FlutterPlugin, ActivityAware { private ActivityPluginBinding activityPluginBinding; @VisibleForTesting - public void initInstance( - @NonNull BinaryMessenger messenger, - @NonNull Context context, - @NonNull GoogleSignInWrapper googleSignInWrapper) { + public void initInstance(@NonNull BinaryMessenger messenger, @NonNull Context context) { this.messenger = messenger; - delegate = new Delegate(context, googleSignInWrapper); - GoogleSignInApi.setUp(messenger, delegate); + delegate = + new Delegate( + context, + (@NonNull Context c) -> CredentialManager.create(c), + (@NonNull Context c) -> Identity.getAuthorizationClient(c), + (@Nullable Credential credential) -> + GoogleIdTokenCredential.createFrom(credential.getData())); + GoogleSignInApi.Companion.setUp(messenger, delegate); } private void dispose() { delegate = null; if (messenger != null) { - GoogleSignInApi.setUp(messenger, null); + GoogleSignInApi.Companion.setUp(messenger, null); messenger = null; } } @@ -76,8 +93,7 @@ private void disposeActivity() { @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { - initInstance( - binding.getBinaryMessenger(), binding.getApplicationContext(), new GoogleSignInWrapper()); + initInstance(binding.getBinaryMessenger(), binding.getApplicationContext()); } @Override @@ -106,42 +122,58 @@ public void onDetachedFromActivity() { disposeActivity(); } + // Creates CredentialManager instances. This is provided to be overridden for tests. + @VisibleForTesting + public interface CredentialManagerFactory { + @NonNull + CredentialManager create(@NonNull Context context); + } + + // Creates AuthorizationClient instances. This is provided to be overridden for tests. + @VisibleForTesting + public interface AuthorizationClientFactory { + @NonNull + AuthorizationClient create(@NonNull Context context); + } + + // Creates GoogleIdTokenCredential instances from Credential instances. This is provided + // to be overridden for tests. + @VisibleForTesting + public interface GoogleIdCredentialConverter { + @NonNull + GoogleIdTokenCredential createFrom(@NonNull Credential credential); + } + /** * Delegate class that does the work for the Google sign-in plugin. This is exposed as a dedicated * class for use in other plugins that wrap basic sign-in functionality. * *

All methods in this class assume that they are run to completion before any other method is - * invoked. In this context, "run to completion" means that their {@link Messages.Result} argument - * has been completed (either successfully or in error). This class provides no synchronization - * constructs to guarantee such behavior; callers are responsible for providing such guarantees. + * invoked. In this context, "run to completion" means that their callback argument has been + * completed (either successfully or in error). This class provides no synchronization constructs + * to guarantee such behavior; callers are responsible for providing such guarantees. */ public static class Delegate implements PluginRegistry.ActivityResultListener, GoogleSignInApi { - private static final int REQUEST_CODE_SIGNIN = 53293; - private static final int REQUEST_CODE_RECOVER_AUTH = 53294; - @VisibleForTesting static final int REQUEST_CODE_REQUEST_SCOPE = 53295; - - private static final String ERROR_REASON_EXCEPTION = "exception"; - private static final String ERROR_REASON_STATUS = "status"; - // These error codes must match with ones declared on iOS and Dart sides. - private static final String ERROR_REASON_SIGN_IN_CANCELED = "sign_in_canceled"; - private static final String ERROR_REASON_SIGN_IN_REQUIRED = "sign_in_required"; - private static final String ERROR_REASON_NETWORK_ERROR = "network_error"; - private static final String ERROR_REASON_SIGN_IN_FAILED = "sign_in_failed"; - private static final String ERROR_FAILURE_TO_RECOVER_AUTH = "failed_to_recover_auth"; - private static final String ERROR_USER_RECOVERABLE_AUTH = "user_recoverable_auth"; + @VisibleForTesting static final int REQUEST_CODE_AUTHORIZE = 53294; private final @NonNull Context context; - // Only set activity for v2 embedder. Always access activity from getActivity() method. + private final @NonNull CredentialManagerFactory credentialManagerFactory; + private final @NonNull AuthorizationClientFactory authorizationClientFactory; + final @NonNull GoogleIdCredentialConverter credentialConverter; + // Always access activity from getActivity() method. private @Nullable Activity activity; - private final GoogleSignInWrapper googleSignInWrapper; - private GoogleSignInClient signInClient; - private List requestedScopes; - private PendingOperation pendingOperation; + private Function1, Unit> pendingAuthorizationCallback; - public Delegate(@NonNull Context context, @NonNull GoogleSignInWrapper googleSignInWrapper) { + public Delegate( + @NonNull Context context, + @NonNull CredentialManagerFactory credentialManagerFactory, + @NonNull AuthorizationClientFactory authorizationClientFactory, + @NonNull GoogleIdCredentialConverter credentialConverter) { this.context = context; - this.googleSignInWrapper = googleSignInWrapper; + this.credentialManagerFactory = credentialManagerFactory; + this.authorizationClientFactory = authorizationClientFactory; + this.credentialConverter = credentialConverter; } public void setActivity(@Nullable Activity activity) { @@ -153,433 +185,263 @@ public void setActivity(@Nullable Activity activity) { return activity; } - private void checkAndSetPendingOperation( - String method, - Messages.Result userDataResult, - Messages.VoidResult voidResult, - Messages.Result boolResult, - Messages.Result stringResult, - Object data) { - if (pendingOperation != null) { - throw new IllegalStateException( - "Concurrent operations detected: " + pendingOperation.method + ", " + method); + @Override + public @Nullable String getGoogleServicesJsonServerClientId() { + @SuppressLint("DiscouragedApi") + int webClientIdIdentifier = + context + .getResources() + .getIdentifier("default_web_client_id", "string", context.getPackageName()); + if (webClientIdIdentifier != 0) { + return context.getString(webClientIdIdentifier); } - pendingOperation = - new PendingOperation(method, userDataResult, voidResult, boolResult, stringResult, data); + return null; } - private void checkAndSetPendingSignInOperation( - String method, @NonNull Messages.Result result) { - checkAndSetPendingOperation(method, result, null, null, null, null); - } - - private void checkAndSetPendingVoidOperation( - String method, @NonNull Messages.VoidResult result) { - checkAndSetPendingOperation(method, null, result, null, null, null); - } - - private void checkAndSetPendingBoolOperation( - String method, @NonNull Messages.Result result) { - checkAndSetPendingOperation(method, null, null, result, null, null); - } - - private void checkAndSetPendingStringOperation( - String method, @NonNull Messages.Result result, @Nullable Object data) { - checkAndSetPendingOperation(method, null, null, null, result, data); - } - - private void checkAndSetPendingAccessTokenOperation( - String method, Messages.Result result, @NonNull Object data) { - checkAndSetPendingStringOperation(method, result, data); - } - - /** - * Initializes this delegate so that it is ready to perform other operations. The Dart code - * guarantees that this will be called and completed before any other methods are invoked. - */ @Override - public void init(@NonNull Messages.InitParams params) { + public void getCredential( + @NonNull GetCredentialRequestParams params, + @NonNull Function1, Unit> callback) { try { - GoogleSignInOptions.Builder optionsBuilder; - - switch (params.getSignInType()) { - case GAMES: - optionsBuilder = - new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_GAMES_SIGN_IN); - break; - case STANDARD: - optionsBuilder = - new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN).requestEmail(); - break; - default: - throw new IllegalStateException("Unknown signInOption"); - } - - // The clientId parameter is not supported on Android. - // Android apps are identified by their package name and the SHA-1 of their signing key. - // https://developers.google.com/android/guides/client-auth - // https://developers.google.com/identity/sign-in/android/start#configure-a-google-api-project String serverClientId = params.getServerClientId(); - if (!isNullOrEmpty(params.getClientId()) && isNullOrEmpty(serverClientId)) { - Log.w( - "google_sign_in", - "clientId is not supported on Android and is interpreted as serverClientId. " - + "Use serverClientId instead to suppress this warning."); - serverClientId = params.getClientId(); + if (serverClientId == null || serverClientId.isEmpty()) { + ResultUtilsKt.completeWithGetCredentialFailure( + callback, + new GetCredentialFailure( + GetCredentialFailureType.MISSING_SERVER_CLIENT_ID, + "CredentialManager requires a serverClientId.", + null)); + return; } - if (isNullOrEmpty(serverClientId)) { - // Only requests a clientId if google-services.json was present and parsed - // by the google-services Gradle script. - // TODO(jackson): Perhaps we should provide a mechanism to override this - // behavior. - @SuppressLint("DiscouragedApi") - int webClientIdIdentifier = - context - .getResources() - .getIdentifier("default_web_client_id", "string", context.getPackageName()); - if (webClientIdIdentifier != 0) { - serverClientId = context.getString(webClientIdIdentifier); + String nonce = params.getNonce(); + GetCredentialRequest.Builder requestBuilder = new GetCredentialRequest.Builder(); + if (params.getUseButtonFlow()) { + GetSignInWithGoogleOption.Builder optionBuilder = + new GetSignInWithGoogleOption.Builder(serverClientId); + if (nonce != null) { + optionBuilder.setNonce(nonce); } + requestBuilder.addCredentialOption(optionBuilder.build()); + } else { + GetGoogleIdOption.Builder optionBuilder = + new GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(params.getFilterToAuthorized()) + .setAutoSelectEnabled(params.getAutoSelectEnabled()) + .setServerClientId(serverClientId); + if (nonce != null) { + optionBuilder.setNonce(nonce); + } + requestBuilder.addCredentialOption(optionBuilder.build()); } - if (!isNullOrEmpty(serverClientId)) { - optionsBuilder.requestIdToken(serverClientId); - optionsBuilder.requestServerAuthCode( - serverClientId, params.getForceCodeForRefreshToken()); - } - requestedScopes = params.getScopes(); - for (String scope : requestedScopes) { - optionsBuilder.requestScopes(new Scope(scope)); - } - if (!isNullOrEmpty(params.getHostedDomain())) { - optionsBuilder.setHostedDomain(params.getHostedDomain()); - } - - String forceAccountName = params.getForceAccountName(); - if (!isNullOrEmpty(forceAccountName)) { - optionsBuilder.setAccountName(forceAccountName); - } - - signInClient = googleSignInWrapper.getClient(context, optionsBuilder.build()); - } catch (Exception e) { - throw new FlutterError(ERROR_REASON_EXCEPTION, e.getMessage(), null); - } - } - - /** - * Returns the account information for the user who is signed in to this app. If no user is - * signed in, tries to sign the user in without displaying any user interface. - */ - @Override - public void signInSilently(@NonNull Messages.Result result) { - checkAndSetPendingSignInOperation("signInSilently", result); - Task task = signInClient.silentSignIn(); - if (task.isComplete()) { - // There's immediate result available. - onSignInResult(task); - } else { - task.addOnCompleteListener(this::onSignInResult); - } - } - - /** - * Signs the user in via the sign-in user interface, including the OAuth consent flow if scopes - * were requested. - */ - @Override - public void signIn(@NonNull Messages.Result result) { - if (getActivity() == null) { - throw new IllegalStateException("signIn needs a foreground activity"); - } - checkAndSetPendingSignInOperation("signIn", result); - - Intent signInIntent = signInClient.getSignInIntent(); - getActivity().startActivityForResult(signInIntent, REQUEST_CODE_SIGNIN); - } - /** - * Signs the user out. Their credentials may remain valid, meaning they'll be able to silently - * sign back in. - */ - @Override - public void signOut(@NonNull Messages.VoidResult result) { - checkAndSetPendingVoidOperation("signOut", result); - - signInClient - .signOut() - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - finishWithSuccess(); + CredentialManager credentialManager = credentialManagerFactory.create(context); + credentialManager.getCredentialAsync( + context, + requestBuilder.build(), + null, + Executors.newSingleThreadExecutor(), + new CredentialManagerCallback<>() { + @Override + public void onResult(GetCredentialResponse response) { + Credential credential = response.getCredential(); + if (credential instanceof CustomCredential + && credential + .getType() + .equals(GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL)) { + GoogleIdTokenCredential googleIdTokenCredential = + credentialConverter.createFrom(credential); + Uri profilePictureUri = googleIdTokenCredential.getProfilePictureUri(); + ResultUtilsKt.completeWithGetGetCredentialResult( + callback, + new GetCredentialSuccess( + new PlatformGoogleIdTokenCredential( + googleIdTokenCredential.getDisplayName(), + googleIdTokenCredential.getFamilyName(), + googleIdTokenCredential.getGivenName(), + googleIdTokenCredential.getId(), + googleIdTokenCredential.getIdToken(), + profilePictureUri == null ? null : profilePictureUri.toString()))); } else { - finishWithError(ERROR_REASON_STATUS, "Failed to signout."); + ResultUtilsKt.completeWithGetCredentialFailure( + callback, + new GetCredentialFailure( + GetCredentialFailureType.UNEXPECTED_CREDENTIAL_TYPE, + "Unexpected credential type: " + credential, + null)); } - }); - } - - /** Signs the user out, and revokes their credentials. */ - @Override - public void disconnect(@NonNull Messages.VoidResult result) { - checkAndSetPendingVoidOperation("disconnect", result); + } - signInClient - .revokeAccess() - .addOnCompleteListener( - task -> { - if (task.isSuccessful()) { - finishWithSuccess(); + @Override + public void onError(@NonNull GetCredentialException e) { + GetCredentialFailureType type; + if (e instanceof GetCredentialCancellationException) { + type = GetCredentialFailureType.CANCELED; + } else if (e instanceof GetCredentialInterruptedException) { + type = GetCredentialFailureType.INTERRUPTED; + } else if (e instanceof GetCredentialProviderConfigurationException) { + type = GetCredentialFailureType.PROVIDER_CONFIGURATION_ISSUE; + } else if (e instanceof GetCredentialUnsupportedException) { + type = GetCredentialFailureType.UNSUPPORTED; + } else if (e instanceof NoCredentialException) { + type = GetCredentialFailureType.NO_CREDENTIAL; } else { - finishWithError(ERROR_REASON_STATUS, "Failed to disconnect."); + type = GetCredentialFailureType.UNKNOWN; } - }); - } - - /** Checks if there is a signed in user. */ - @NonNull - @Override - public Boolean isSignedIn() { - return GoogleSignIn.getLastSignedInAccount(context) != null; - } - - @Override - public void requestScopes( - @NonNull List scopes, @NonNull Messages.Result result) { - checkAndSetPendingBoolOperation("requestScopes", result); - - GoogleSignInAccount account = googleSignInWrapper.getLastSignedInAccount(context); - if (account == null) { - finishWithError(ERROR_REASON_SIGN_IN_REQUIRED, "No account to grant scopes."); - return; - } - - List wrappedScopes = new ArrayList<>(); - - for (String scope : scopes) { - Scope wrappedScope = new Scope(scope); - if (!googleSignInWrapper.hasPermissions(account, wrappedScope)) { - wrappedScopes.add(wrappedScope); - } - } - - if (wrappedScopes.isEmpty()) { - finishWithBoolean(true); - return; - } - - googleSignInWrapper.requestPermissions( - getActivity(), REQUEST_CODE_REQUEST_SCOPE, account, wrappedScopes.toArray(new Scope[0])); - } - - private void onSignInResult(Task completedTask) { - try { - GoogleSignInAccount account = completedTask.getResult(ApiException.class); - onSignInAccount(account); - } catch (ApiException e) { - // Forward all errors and let Dart decide how to handle. - String errorCode = errorCodeForStatus(e.getStatusCode()); - finishWithError(errorCode, e.toString()); - } catch (RuntimeExecutionException e) { - finishWithError(ERROR_REASON_EXCEPTION, e.toString()); - } - } - - private void onSignInAccount(GoogleSignInAccount account) { - final Messages.UserData.Builder builder = - new Messages.UserData.Builder() - // TODO(stuartmorgan): Test with games sign-in; according to docs these could be null - // as the games login request is currently constructed, but the public Dart API - // assumes they are non-null, so the sign-in query may need to change to - // include requestEmail() and requestProfile(). - .setEmail(account.getEmail()) - .setId(account.getId()) - .setIdToken(account.getIdToken()) - .setServerAuthCode(account.getServerAuthCode()) - .setDisplayName(account.getDisplayName()); - if (account.getPhotoUrl() != null) { - builder.setPhotoUrl(account.getPhotoUrl().toString()); - } - finishWithUserData(builder.build()); - } - - private String errorCodeForStatus(int statusCode) { - switch (statusCode) { - case GoogleSignInStatusCodes.SIGN_IN_CANCELLED: - return ERROR_REASON_SIGN_IN_CANCELED; - case CommonStatusCodes.SIGN_IN_REQUIRED: - return ERROR_REASON_SIGN_IN_REQUIRED; - case CommonStatusCodes.NETWORK_ERROR: - return ERROR_REASON_NETWORK_ERROR; - case GoogleSignInStatusCodes.SIGN_IN_CURRENTLY_IN_PROGRESS: - case GoogleSignInStatusCodes.SIGN_IN_FAILED: - case CommonStatusCodes.INVALID_ACCOUNT: - case CommonStatusCodes.INTERNAL_ERROR: - default: - return ERROR_REASON_SIGN_IN_FAILED; - } - } - - private void finishWithSuccess() { - Objects.requireNonNull(pendingOperation.voidResult).success(); - pendingOperation = null; - } - - private void finishWithBoolean(Boolean value) { - Objects.requireNonNull(pendingOperation.boolResult).success(value); - pendingOperation = null; - } - - private void finishWithUserData(Messages.UserData data) { - Objects.requireNonNull(pendingOperation.userDataResult).success(data); - pendingOperation = null; - } - - private void finishWithError(String errorCode, String errorMessage) { - if (pendingOperation.voidResult != null) { - Objects.requireNonNull(pendingOperation.voidResult) - .error(new FlutterError(errorCode, errorMessage, null)); - } else { - Messages.Result result; - if (pendingOperation.userDataResult != null) { - result = pendingOperation.userDataResult; - } else if (pendingOperation.boolResult != null) { - result = pendingOperation.boolResult; - } else { - result = pendingOperation.stringResult; - } - Objects.requireNonNull(result).error(new FlutterError(errorCode, errorMessage, null)); - } - pendingOperation = null; - } - - private static boolean isNullOrEmpty(@Nullable String s) { - return s == null || s.isEmpty(); - } - - private static class PendingOperation { - final @NonNull String method; - final @Nullable Messages.Result userDataResult; - final @Nullable Messages.VoidResult voidResult; - final @Nullable Messages.Result boolResult; - final @Nullable Messages.Result stringResult; - final @Nullable Object data; - - PendingOperation( - @NonNull String method, - @Nullable Messages.Result userDataResult, - @Nullable Messages.VoidResult voidResult, - @Nullable Messages.Result boolResult, - @Nullable Messages.Result stringResult, - @Nullable Object data) { - assert (userDataResult != null - || voidResult != null - || boolResult != null - || stringResult != null); - this.method = method; - this.userDataResult = userDataResult; - this.voidResult = voidResult; - this.boolResult = boolResult; - this.stringResult = stringResult; - this.data = data; + // Errors are reported through the return value as structured data, rather than + // a Result error's PlatformException. + ResultUtilsKt.completeWithGetCredentialFailure( + callback, new GetCredentialFailure(type, e.getMessage(), null)); + } + }); + } catch (RuntimeException e) { + ResultUtilsKt.completeWithGetCredentialFailure( + callback, + new GetCredentialFailure( + GetCredentialFailureType.UNKNOWN, + e.getMessage(), + "Cause: " + e.getCause() + ", Stacktrace: " + Log.getStackTraceString(e))); } } - /** - * Clears the token kept in the client side cache. - * - *

Runs on a background task queue. - */ @Override - public void clearAuthCache(@NonNull String token) { - try { - GoogleAuthUtil.clearToken(context, token); - } catch (Exception e) { - throw new FlutterError(ERROR_REASON_EXCEPTION, e.getMessage(), null); - } + public void clearCredentialState(@NonNull Function1, Unit> callback) { + CredentialManager credentialManager = credentialManagerFactory.create(context); + credentialManager.clearCredentialStateAsync( + new ClearCredentialStateRequest(), + null, + Executors.newSingleThreadExecutor(), + new CredentialManagerCallback<>() { + @Override + public void onResult(Void result) { + ResultUtilsKt.completeWithClearCredentialStateSuccess(callback); + } + + @Override + public void onError(@NonNull ClearCredentialException e) { + ResultUtilsKt.completeWithClearCredentialStateError( + callback, new FlutterError("Clear Failed", e.getMessage(), null)); + } + }); } - /** - * Gets an OAuth access token with the scopes that were specified during initialization for the - * user with the specified email address. - * - *

Runs on a background task queue. - * - *

If shouldRecoverAuth is set to true and user needs to recover authentication for method to - * complete, the method will attempt to recover authentication and rerun method. - */ @Override - public void getAccessToken( - @NonNull String email, - @NonNull Boolean shouldRecoverAuth, - @NonNull Messages.Result result) { + public void authorize( + @NonNull PlatformAuthorizationRequest params, + boolean promptIfUnauthorized, + @NonNull Function1, Unit> callback) { try { - Account account = new Account(email, "com.google"); - String scopesStr = "oauth2:" + String.join(" ", requestedScopes); - String token = GoogleAuthUtil.getToken(context, account, scopesStr); - result.success(token); - } catch (UserRecoverableAuthException e) { - // This method runs in a background task queue; hop to the main thread for interactions with - // plugin state and activities. - final Handler handler = new Handler(Looper.getMainLooper()); - handler.post( - () -> { - if (shouldRecoverAuth && pendingOperation == null) { - Activity activity = getActivity(); - if (activity == null) { - result.error( - new FlutterError( - ERROR_USER_RECOVERABLE_AUTH, - "Cannot recover auth because app is not in foreground. " - + e.getLocalizedMessage(), - null)); - } else { - checkAndSetPendingAccessTokenOperation("getTokens", result, email); - Intent recoveryIntent = e.getIntent(); - activity.startActivityForResult(recoveryIntent, REQUEST_CODE_RECOVER_AUTH); - } - } else { - result.error( - new FlutterError(ERROR_USER_RECOVERABLE_AUTH, e.getLocalizedMessage(), null)); - } - }); - } catch (Exception e) { - result.error(new FlutterError(ERROR_REASON_EXCEPTION, e.getMessage(), null)); + List requestedScopes = new ArrayList<>(); + for (String scope : params.getScopes()) { + requestedScopes.add(new Scope(scope)); + } + AuthorizationRequest.Builder authorizationRequestBuilder = + AuthorizationRequest.builder().setRequestedScopes(requestedScopes); + if (params.getHostedDomain() != null) { + authorizationRequestBuilder.filterByHostedDomain(params.getHostedDomain()); + } + if (params.getServerClientIdForForcedRefreshToken() != null) { + authorizationRequestBuilder.requestOfflineAccess( + params.getServerClientIdForForcedRefreshToken(), true); + } + if (params.getAccountEmail() != null) { + authorizationRequestBuilder.setAccount( + new Account(params.getAccountEmail(), "com.google")); + } + AuthorizationRequest authorizationRequest = authorizationRequestBuilder.build(); + authorizationClientFactory + .create(context) + .authorize(authorizationRequest) + .addOnSuccessListener( + authorizationResult -> { + if (authorizationResult.hasResolution()) { + if (promptIfUnauthorized) { + Activity activity = getActivity(); + if (activity == null) { + ResultUtilsKt.completeWithAuthorizeFailure( + callback, + new AuthorizeFailure( + AuthorizeFailureType.NO_ACTIVITY, "No activity available", null)); + return; + } + // Prompt for access. `callback` will be resolved in onActivityResult. + // There must be a pending intent if hasResolution() was true. + PendingIntent pendingIntent = + Objects.requireNonNull(authorizationResult.getPendingIntent()); + try { + pendingAuthorizationCallback = callback; + activity.startIntentSenderForResult( + pendingIntent.getIntentSender(), + REQUEST_CODE_AUTHORIZE, + null, + 0, + 0, + 0, + null); + } catch (IntentSender.SendIntentException e) { + pendingAuthorizationCallback = null; + ResultUtilsKt.completeWithAuthorizeFailure( + callback, + new AuthorizeFailure( + AuthorizeFailureType.PENDING_INTENT_EXCEPTION, + e.getMessage(), + null)); + } + } else { + ResultUtilsKt.completeWithAuthorizeFailure( + callback, + new AuthorizeFailure(AuthorizeFailureType.UNAUTHORIZED, null, null)); + } + } else { + ResultUtilsKt.completeWithAuthorizationResult( + callback, + new PlatformAuthorizationResult( + authorizationResult.getAccessToken(), + authorizationResult.getServerAuthCode(), + authorizationResult.getGrantedScopes())); + } + }) + .addOnFailureListener( + e -> + ResultUtilsKt.completeWithAuthorizeFailure( + callback, + new AuthorizeFailure( + AuthorizeFailureType.AUTHORIZE_FAILURE, e.getMessage(), null))); + } catch (RuntimeException e) { + ResultUtilsKt.completeWithAuthorizeFailure( + callback, + new AuthorizeFailure( + AuthorizeFailureType.API_EXCEPTION, + e.getMessage(), + "Cause: " + e.getCause() + ", Stacktrace: " + Log.getStackTraceString(e))); } } @Override public boolean onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (pendingOperation == null) { - return false; - } - switch (requestCode) { - case REQUEST_CODE_RECOVER_AUTH: - if (resultCode == Activity.RESULT_OK) { - // Recover the previous result and data and attempt to get tokens again. - Messages.Result result = Objects.requireNonNull(pendingOperation.stringResult); - String email = (String) Objects.requireNonNull(pendingOperation.data); - pendingOperation = null; - getAccessToken(email, false, result); - } else { - finishWithError( - ERROR_FAILURE_TO_RECOVER_AUTH, "Failed attempt to recover authentication"); + if (requestCode == REQUEST_CODE_AUTHORIZE) { + if (pendingAuthorizationCallback != null) { + try { + AuthorizationResult authorizationResult = + authorizationClientFactory.create(context).getAuthorizationResultFromIntent(data); + ResultUtilsKt.completeWithAuthorizationResult( + pendingAuthorizationCallback, + new PlatformAuthorizationResult( + authorizationResult.getAccessToken(), + authorizationResult.getServerAuthCode(), + authorizationResult.getGrantedScopes())); + return true; + } catch (ApiException e) { + ResultUtilsKt.completeWithAuthorizeFailure( + pendingAuthorizationCallback, + new AuthorizeFailure(AuthorizeFailureType.API_EXCEPTION, e.getMessage(), null)); } - return true; - case REQUEST_CODE_SIGNIN: - // Whether resultCode is OK or not, the Task returned by GoogleSigIn will determine - // failure with better specifics which are extracted in onSignInResult method. - if (data != null) { - onSignInResult(GoogleSignIn.getSignedInAccountFromIntent(data)); - } else { - // data is null which is highly unusual for a sign in result. - finishWithError(ERROR_REASON_SIGN_IN_FAILED, "Signin failed"); - } - return true; - case REQUEST_CODE_REQUEST_SCOPE: - finishWithBoolean(resultCode == Activity.RESULT_OK); - return true; - default: - return false; + pendingAuthorizationCallback = null; + } else { + Log.e("google_sign_in", "Unexpected authorization result callback"); + } } + return false; } } } diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInWrapper.java b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInWrapper.java deleted file mode 100644 index c035329f8e96..000000000000 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/GoogleSignInWrapper.java +++ /dev/null @@ -1,42 +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. - -package io.flutter.plugins.googlesignin; - -import android.app.Activity; -import android.content.Context; -import com.google.android.gms.auth.api.signin.GoogleSignIn; -import com.google.android.gms.auth.api.signin.GoogleSignInAccount; -import com.google.android.gms.auth.api.signin.GoogleSignInClient; -import com.google.android.gms.auth.api.signin.GoogleSignInOptions; -import com.google.android.gms.common.api.Scope; - -/** - * A wrapper object that calls static method in GoogleSignIn. - * - *

Because GoogleSignIn uses static method mostly, which is hard for unit testing. We use this - * wrapper class to use instance method which calls the corresponding GoogleSignIn static methods. - * - *

Warning! This class should stay true that each method calls a GoogleSignIn static method with - * the same name and same parameters. - */ -public class GoogleSignInWrapper { - - GoogleSignInClient getClient(Context context, GoogleSignInOptions options) { - return GoogleSignIn.getClient(context, options); - } - - GoogleSignInAccount getLastSignedInAccount(Context context) { - return GoogleSignIn.getLastSignedInAccount(context); - } - - boolean hasPermissions(GoogleSignInAccount account, Scope scope) { - return GoogleSignIn.hasPermissions(account, scope); - } - - void requestPermissions( - Activity activity, int requestCode, GoogleSignInAccount account, Scope[] scopes) { - GoogleSignIn.requestPermissions(activity, requestCode, account, scopes); - } -} diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/Messages.java b/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/Messages.java deleted file mode 100644 index 56adfecf16ab..000000000000 --- a/packages/google_sign_in/google_sign_in_android/android/src/main/java/io/flutter/plugins/googlesignin/Messages.java +++ /dev/null @@ -1,874 +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. -// Autogenerated from Pigeon (v24.2.0), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -package io.flutter.plugins.googlesignin; - -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.CLASS; - -import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import io.flutter.plugin.common.BasicMessageChannel; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.MessageCodec; -import io.flutter.plugin.common.StandardMessageCodec; -import java.io.ByteArrayOutputStream; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** Generated class from Pigeon. */ -@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression", "serial"}) -public class Messages { - - /** Error class for passing custom error details to Flutter via a thrown PlatformException. */ - public static class FlutterError extends RuntimeException { - - /** The error code. */ - public final String code; - - /** The error details. Must be a datatype supported by the api codec. */ - public final Object details; - - public FlutterError(@NonNull String code, @Nullable String message, @Nullable Object details) { - super(message); - this.code = code; - this.details = details; - } - } - - @NonNull - protected static ArrayList wrapError(@NonNull Throwable exception) { - ArrayList errorList = new ArrayList<>(3); - if (exception instanceof FlutterError) { - FlutterError error = (FlutterError) exception; - errorList.add(error.code); - errorList.add(error.getMessage()); - errorList.add(error.details); - } else { - errorList.add(exception.toString()); - errorList.add(exception.getClass().getSimpleName()); - errorList.add( - "Cause: " + exception.getCause() + ", Stacktrace: " + Log.getStackTraceString(exception)); - } - return errorList; - } - - @Target(METHOD) - @Retention(CLASS) - @interface CanIgnoreReturnValue {} - - /** Pigeon version of SignInOption. */ - public enum SignInType { - /** Default configuration. */ - STANDARD(0), - /** Recommended configuration for game sign in. */ - GAMES(1); - - final int index; - - SignInType(final int index) { - this.index = index; - } - } - - /** - * Pigeon version of SignInInitParams. - * - *

See SignInInitParams for details. - * - *

Generated class from Pigeon that represents data sent in messages. - */ - public static final class InitParams { - private @NonNull List scopes; - - public @NonNull List getScopes() { - return scopes; - } - - public void setScopes(@NonNull List setterArg) { - if (setterArg == null) { - throw new IllegalStateException("Nonnull field \"scopes\" is null."); - } - this.scopes = setterArg; - } - - private @NonNull SignInType signInType; - - public @NonNull SignInType getSignInType() { - return signInType; - } - - public void setSignInType(@NonNull SignInType setterArg) { - if (setterArg == null) { - throw new IllegalStateException("Nonnull field \"signInType\" is null."); - } - this.signInType = setterArg; - } - - private @Nullable String hostedDomain; - - public @Nullable String getHostedDomain() { - return hostedDomain; - } - - public void setHostedDomain(@Nullable String setterArg) { - this.hostedDomain = setterArg; - } - - private @Nullable String clientId; - - public @Nullable String getClientId() { - return clientId; - } - - public void setClientId(@Nullable String setterArg) { - this.clientId = setterArg; - } - - private @Nullable String serverClientId; - - public @Nullable String getServerClientId() { - return serverClientId; - } - - public void setServerClientId(@Nullable String setterArg) { - this.serverClientId = setterArg; - } - - private @NonNull Boolean forceCodeForRefreshToken; - - public @NonNull Boolean getForceCodeForRefreshToken() { - return forceCodeForRefreshToken; - } - - public void setForceCodeForRefreshToken(@NonNull Boolean setterArg) { - if (setterArg == null) { - throw new IllegalStateException("Nonnull field \"forceCodeForRefreshToken\" is null."); - } - this.forceCodeForRefreshToken = setterArg; - } - - private @Nullable String forceAccountName; - - public @Nullable String getForceAccountName() { - return forceAccountName; - } - - public void setForceAccountName(@Nullable String setterArg) { - this.forceAccountName = setterArg; - } - - /** Constructor is non-public to enforce null safety; use Builder. */ - InitParams() {} - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - InitParams that = (InitParams) o; - return scopes.equals(that.scopes) - && signInType.equals(that.signInType) - && Objects.equals(hostedDomain, that.hostedDomain) - && Objects.equals(clientId, that.clientId) - && Objects.equals(serverClientId, that.serverClientId) - && forceCodeForRefreshToken.equals(that.forceCodeForRefreshToken) - && Objects.equals(forceAccountName, that.forceAccountName); - } - - @Override - public int hashCode() { - return Objects.hash( - scopes, - signInType, - hostedDomain, - clientId, - serverClientId, - forceCodeForRefreshToken, - forceAccountName); - } - - public static final class Builder { - - private @Nullable List scopes; - - @CanIgnoreReturnValue - public @NonNull Builder setScopes(@NonNull List setterArg) { - this.scopes = setterArg; - return this; - } - - private @Nullable SignInType signInType; - - @CanIgnoreReturnValue - public @NonNull Builder setSignInType(@NonNull SignInType setterArg) { - this.signInType = setterArg; - return this; - } - - private @Nullable String hostedDomain; - - @CanIgnoreReturnValue - public @NonNull Builder setHostedDomain(@Nullable String setterArg) { - this.hostedDomain = setterArg; - return this; - } - - private @Nullable String clientId; - - @CanIgnoreReturnValue - public @NonNull Builder setClientId(@Nullable String setterArg) { - this.clientId = setterArg; - return this; - } - - private @Nullable String serverClientId; - - @CanIgnoreReturnValue - public @NonNull Builder setServerClientId(@Nullable String setterArg) { - this.serverClientId = setterArg; - return this; - } - - private @Nullable Boolean forceCodeForRefreshToken; - - @CanIgnoreReturnValue - public @NonNull Builder setForceCodeForRefreshToken(@NonNull Boolean setterArg) { - this.forceCodeForRefreshToken = setterArg; - return this; - } - - private @Nullable String forceAccountName; - - @CanIgnoreReturnValue - public @NonNull Builder setForceAccountName(@Nullable String setterArg) { - this.forceAccountName = setterArg; - return this; - } - - public @NonNull InitParams build() { - InitParams pigeonReturn = new InitParams(); - pigeonReturn.setScopes(scopes); - pigeonReturn.setSignInType(signInType); - pigeonReturn.setHostedDomain(hostedDomain); - pigeonReturn.setClientId(clientId); - pigeonReturn.setServerClientId(serverClientId); - pigeonReturn.setForceCodeForRefreshToken(forceCodeForRefreshToken); - pigeonReturn.setForceAccountName(forceAccountName); - return pigeonReturn; - } - } - - @NonNull - ArrayList toList() { - ArrayList toListResult = new ArrayList<>(7); - toListResult.add(scopes); - toListResult.add(signInType); - toListResult.add(hostedDomain); - toListResult.add(clientId); - toListResult.add(serverClientId); - toListResult.add(forceCodeForRefreshToken); - toListResult.add(forceAccountName); - return toListResult; - } - - static @NonNull InitParams fromList(@NonNull ArrayList pigeonVar_list) { - InitParams pigeonResult = new InitParams(); - Object scopes = pigeonVar_list.get(0); - pigeonResult.setScopes((List) scopes); - Object signInType = pigeonVar_list.get(1); - pigeonResult.setSignInType((SignInType) signInType); - Object hostedDomain = pigeonVar_list.get(2); - pigeonResult.setHostedDomain((String) hostedDomain); - Object clientId = pigeonVar_list.get(3); - pigeonResult.setClientId((String) clientId); - Object serverClientId = pigeonVar_list.get(4); - pigeonResult.setServerClientId((String) serverClientId); - Object forceCodeForRefreshToken = pigeonVar_list.get(5); - pigeonResult.setForceCodeForRefreshToken((Boolean) forceCodeForRefreshToken); - Object forceAccountName = pigeonVar_list.get(6); - pigeonResult.setForceAccountName((String) forceAccountName); - return pigeonResult; - } - } - - /** - * Pigeon version of GoogleSignInUserData. - * - *

See GoogleSignInUserData for details. - * - *

Generated class from Pigeon that represents data sent in messages. - */ - public static final class UserData { - private @Nullable String displayName; - - public @Nullable String getDisplayName() { - return displayName; - } - - public void setDisplayName(@Nullable String setterArg) { - this.displayName = setterArg; - } - - private @NonNull String email; - - public @NonNull String getEmail() { - return email; - } - - public void setEmail(@NonNull String setterArg) { - if (setterArg == null) { - throw new IllegalStateException("Nonnull field \"email\" is null."); - } - this.email = setterArg; - } - - private @NonNull String id; - - public @NonNull String getId() { - return id; - } - - public void setId(@NonNull String setterArg) { - if (setterArg == null) { - throw new IllegalStateException("Nonnull field \"id\" is null."); - } - this.id = setterArg; - } - - private @Nullable String photoUrl; - - public @Nullable String getPhotoUrl() { - return photoUrl; - } - - public void setPhotoUrl(@Nullable String setterArg) { - this.photoUrl = setterArg; - } - - private @Nullable String idToken; - - public @Nullable String getIdToken() { - return idToken; - } - - public void setIdToken(@Nullable String setterArg) { - this.idToken = setterArg; - } - - private @Nullable String serverAuthCode; - - public @Nullable String getServerAuthCode() { - return serverAuthCode; - } - - public void setServerAuthCode(@Nullable String setterArg) { - this.serverAuthCode = setterArg; - } - - /** Constructor is non-public to enforce null safety; use Builder. */ - UserData() {} - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - UserData that = (UserData) o; - return Objects.equals(displayName, that.displayName) - && email.equals(that.email) - && id.equals(that.id) - && Objects.equals(photoUrl, that.photoUrl) - && Objects.equals(idToken, that.idToken) - && Objects.equals(serverAuthCode, that.serverAuthCode); - } - - @Override - public int hashCode() { - return Objects.hash(displayName, email, id, photoUrl, idToken, serverAuthCode); - } - - public static final class Builder { - - private @Nullable String displayName; - - @CanIgnoreReturnValue - public @NonNull Builder setDisplayName(@Nullable String setterArg) { - this.displayName = setterArg; - return this; - } - - private @Nullable String email; - - @CanIgnoreReturnValue - public @NonNull Builder setEmail(@NonNull String setterArg) { - this.email = setterArg; - return this; - } - - private @Nullable String id; - - @CanIgnoreReturnValue - public @NonNull Builder setId(@NonNull String setterArg) { - this.id = setterArg; - return this; - } - - private @Nullable String photoUrl; - - @CanIgnoreReturnValue - public @NonNull Builder setPhotoUrl(@Nullable String setterArg) { - this.photoUrl = setterArg; - return this; - } - - private @Nullable String idToken; - - @CanIgnoreReturnValue - public @NonNull Builder setIdToken(@Nullable String setterArg) { - this.idToken = setterArg; - return this; - } - - private @Nullable String serverAuthCode; - - @CanIgnoreReturnValue - public @NonNull Builder setServerAuthCode(@Nullable String setterArg) { - this.serverAuthCode = setterArg; - return this; - } - - public @NonNull UserData build() { - UserData pigeonReturn = new UserData(); - pigeonReturn.setDisplayName(displayName); - pigeonReturn.setEmail(email); - pigeonReturn.setId(id); - pigeonReturn.setPhotoUrl(photoUrl); - pigeonReturn.setIdToken(idToken); - pigeonReturn.setServerAuthCode(serverAuthCode); - return pigeonReturn; - } - } - - @NonNull - ArrayList toList() { - ArrayList toListResult = new ArrayList<>(6); - toListResult.add(displayName); - toListResult.add(email); - toListResult.add(id); - toListResult.add(photoUrl); - toListResult.add(idToken); - toListResult.add(serverAuthCode); - return toListResult; - } - - static @NonNull UserData fromList(@NonNull ArrayList pigeonVar_list) { - UserData pigeonResult = new UserData(); - Object displayName = pigeonVar_list.get(0); - pigeonResult.setDisplayName((String) displayName); - Object email = pigeonVar_list.get(1); - pigeonResult.setEmail((String) email); - Object id = pigeonVar_list.get(2); - pigeonResult.setId((String) id); - Object photoUrl = pigeonVar_list.get(3); - pigeonResult.setPhotoUrl((String) photoUrl); - Object idToken = pigeonVar_list.get(4); - pigeonResult.setIdToken((String) idToken); - Object serverAuthCode = pigeonVar_list.get(5); - pigeonResult.setServerAuthCode((String) serverAuthCode); - return pigeonResult; - } - } - - private static class PigeonCodec extends StandardMessageCodec { - public static final PigeonCodec INSTANCE = new PigeonCodec(); - - private PigeonCodec() {} - - @Override - protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { - switch (type) { - case (byte) 129: - { - Object value = readValue(buffer); - return value == null ? null : SignInType.values()[((Long) value).intValue()]; - } - case (byte) 130: - return InitParams.fromList((ArrayList) readValue(buffer)); - case (byte) 131: - return UserData.fromList((ArrayList) readValue(buffer)); - default: - return super.readValueOfType(type, buffer); - } - } - - @Override - protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { - if (value instanceof SignInType) { - stream.write(129); - writeValue(stream, value == null ? null : ((SignInType) value).index); - } else if (value instanceof InitParams) { - stream.write(130); - writeValue(stream, ((InitParams) value).toList()); - } else if (value instanceof UserData) { - stream.write(131); - writeValue(stream, ((UserData) value).toList()); - } else { - super.writeValue(stream, value); - } - } - } - - /** Asynchronous error handling return type for non-nullable API method returns. */ - public interface Result { - /** Success case callback method for handling returns. */ - void success(@NonNull T result); - - /** Failure case callback method for handling errors. */ - void error(@NonNull Throwable error); - } - - /** Asynchronous error handling return type for nullable API method returns. */ - public interface NullableResult { - /** Success case callback method for handling returns. */ - void success(@Nullable T result); - - /** Failure case callback method for handling errors. */ - void error(@NonNull Throwable error); - } - - /** Asynchronous error handling return type for void API method returns. */ - public interface VoidResult { - /** Success case callback method for handling returns. */ - void success(); - - /** Failure case callback method for handling errors. */ - void error(@NonNull Throwable error); - } - - /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ - public interface GoogleSignInApi { - /** Initializes a sign in request with the given parameters. */ - void init(@NonNull InitParams params); - - /** Starts a silent sign in. */ - void signInSilently(@NonNull Result result); - - /** Starts a sign in with user interaction. */ - void signIn(@NonNull Result result); - - /** Requests the access token for the current sign in. */ - void getAccessToken( - @NonNull String email, @NonNull Boolean shouldRecoverAuth, @NonNull Result result); - - /** Signs out the current user. */ - void signOut(@NonNull VoidResult result); - - /** Revokes scope grants to the application. */ - void disconnect(@NonNull VoidResult result); - - /** Returns whether the user is currently signed in. */ - @NonNull - Boolean isSignedIn(); - - /** Clears the authentication caching for the given token, requiring a new sign in. */ - void clearAuthCache(@NonNull String token); - - /** Requests access to the given scopes. */ - void requestScopes(@NonNull List scopes, @NonNull Result result); - - /** The codec used by GoogleSignInApi. */ - static @NonNull MessageCodec getCodec() { - return PigeonCodec.INSTANCE; - } - - /** - * Sets up an instance of `GoogleSignInApi` to handle messages through the `binaryMessenger`. - */ - static void setUp(@NonNull BinaryMessenger binaryMessenger, @Nullable GoogleSignInApi api) { - setUp(binaryMessenger, "", api); - } - - static void setUp( - @NonNull BinaryMessenger binaryMessenger, - @NonNull String messageChannelSuffix, - @Nullable GoogleSignInApi api) { - messageChannelSuffix = messageChannelSuffix.isEmpty() ? "" : "." + messageChannelSuffix; - BinaryMessenger.TaskQueue taskQueue = binaryMessenger.makeBackgroundTaskQueue(); - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.init" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - ArrayList args = (ArrayList) message; - InitParams paramsArg = (InitParams) args.get(0); - try { - api.init(paramsArg); - wrapped.add(0, null); - } catch (Throwable exception) { - wrapped = wrapError(exception); - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.signInSilently" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - Result resultCallback = - new Result() { - public void success(UserData result) { - wrapped.add(0, result); - reply.reply(wrapped); - } - - public void error(Throwable error) { - ArrayList wrappedError = wrapError(error); - reply.reply(wrappedError); - } - }; - - api.signInSilently(resultCallback); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.signIn" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - Result resultCallback = - new Result() { - public void success(UserData result) { - wrapped.add(0, result); - reply.reply(wrapped); - } - - public void error(Throwable error) { - ArrayList wrappedError = wrapError(error); - reply.reply(wrappedError); - } - }; - - api.signIn(resultCallback); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.getAccessToken" - + messageChannelSuffix, - getCodec(), - taskQueue); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - ArrayList args = (ArrayList) message; - String emailArg = (String) args.get(0); - Boolean shouldRecoverAuthArg = (Boolean) args.get(1); - Result resultCallback = - new Result() { - public void success(String result) { - wrapped.add(0, result); - reply.reply(wrapped); - } - - public void error(Throwable error) { - ArrayList wrappedError = wrapError(error); - reply.reply(wrappedError); - } - }; - - api.getAccessToken(emailArg, shouldRecoverAuthArg, resultCallback); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.signOut" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - VoidResult resultCallback = - new VoidResult() { - public void success() { - wrapped.add(0, null); - reply.reply(wrapped); - } - - public void error(Throwable error) { - ArrayList wrappedError = wrapError(error); - reply.reply(wrappedError); - } - }; - - api.signOut(resultCallback); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.disconnect" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - VoidResult resultCallback = - new VoidResult() { - public void success() { - wrapped.add(0, null); - reply.reply(wrapped); - } - - public void error(Throwable error) { - ArrayList wrappedError = wrapError(error); - reply.reply(wrappedError); - } - }; - - api.disconnect(resultCallback); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.isSignedIn" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - try { - Boolean output = api.isSignedIn(); - wrapped.add(0, output); - } catch (Throwable exception) { - wrapped = wrapError(exception); - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.clearAuthCache" - + messageChannelSuffix, - getCodec(), - taskQueue); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - ArrayList args = (ArrayList) message; - String tokenArg = (String) args.get(0); - try { - api.clearAuthCache(tokenArg); - wrapped.add(0, null); - } catch (Throwable exception) { - wrapped = wrapError(exception); - } - reply.reply(wrapped); - }); - } else { - channel.setMessageHandler(null); - } - } - { - BasicMessageChannel channel = - new BasicMessageChannel<>( - binaryMessenger, - "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.requestScopes" - + messageChannelSuffix, - getCodec()); - if (api != null) { - channel.setMessageHandler( - (message, reply) -> { - ArrayList wrapped = new ArrayList<>(); - ArrayList args = (ArrayList) message; - List scopesArg = (List) args.get(0); - Result resultCallback = - new Result() { - public void success(Boolean result) { - wrapped.add(0, result); - reply.reply(wrapped); - } - - public void error(Throwable error) { - ArrayList wrappedError = wrapError(error); - reply.reply(wrappedError); - } - }; - - api.requestScopes(scopesArg, resultCallback); - }); - } else { - channel.setMessageHandler(null); - } - } - } - } -} diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt new file mode 100644 index 000000000000..a90f740d658f --- /dev/null +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt @@ -0,0 +1,557 @@ +// 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. +// Autogenerated from Pigeon (v24.2.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package io.flutter.plugins.googlesignin + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf(exception.code, exception.message, exception.details) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)) + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +enum class GetCredentialFailureType(val raw: Int) { + /** Indicates that a credential was returned, but it was not of the expected type. */ + UNEXPECTED_CREDENTIAL_TYPE(0), + /** Indicates that a server client ID was not provided. */ + MISSING_SERVER_CLIENT_ID(1), + /** The request was internally interrupted. */ + INTERRUPTED(2), + /** The request was canceled by the user. */ + CANCELED(3), + /** No matching credential was found. */ + NO_CREDENTIAL(4), + /** The provider was not properly configured. */ + PROVIDER_CONFIGURATION_ISSUE(5), + /** The credential manager is not supported on this device. */ + UNSUPPORTED(6), + /** The request failed for an unknown reason. */ + UNKNOWN(7); + + companion object { + fun ofRaw(raw: Int): GetCredentialFailureType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class AuthorizeFailureType(val raw: Int) { + /** + * Indicates that the requested types are not currently authorized. + * + * This is returned only if promptIfUnauthorized is false, indicating that the user would need to + * be prompted for authorization. + */ + UNAUTHORIZED(0), + /** Indicates that the call to AuthorizationClient.authorize itself failed. */ + AUTHORIZE_FAILURE(1), + /** + * Corresponds to SendIntentException, indicating that the pending intent is no longer available. + */ + PENDING_INTENT_EXCEPTION(2), + /** + * Corresponds to an SendIntentException in onActivityResult, indicating that either authorization + * failed, or the result was not available for some reason. + */ + API_EXCEPTION(3), + /** + * Indicates that the user needs to be prompted for authorization, but there is no current + * activity to prompt in. + */ + NO_ACTIVITY(4); + + companion object { + fun ofRaw(raw: Int): AuthorizeFailureType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * The information necessary to build a an authorization request. + * + * Corresponds to the native AuthorizationRequest object, but only contains the fields used by this + * plugin. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PlatformAuthorizationRequest( + val scopes: List, + val hostedDomain: String? = null, + val accountEmail: String? = null, + /** If set, adds a call to requestOfflineAccess(this string, true); */ + val serverClientIdForForcedRefreshToken: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): PlatformAuthorizationRequest { + val scopes = pigeonVar_list[0] as List + val hostedDomain = pigeonVar_list[1] as String? + val accountEmail = pigeonVar_list[2] as String? + val serverClientIdForForcedRefreshToken = pigeonVar_list[3] as String? + return PlatformAuthorizationRequest( + scopes, hostedDomain, accountEmail, serverClientIdForForcedRefreshToken) + } + } + + fun toList(): List { + return listOf( + scopes, + hostedDomain, + accountEmail, + serverClientIdForForcedRefreshToken, + ) + } +} + +/** + * The information necessary to build a credential request. + * + * Combines the parts of the native GetCredentialRequest and CredentialOption classes that are used + * for this plugin. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class GetCredentialRequestParams( + /** + * Whether to use the Sign in with Google button flow (GetSignInWithGoogleOption), corresponding + * to an explicit sign-in request, or not (GetGoogleIdOption), corresponding to an implicit + * potential sign-in. + */ + val useButtonFlow: Boolean, + val filterToAuthorized: Boolean, + val autoSelectEnabled: Boolean, + val serverClientId: String? = null, + val nonce: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): GetCredentialRequestParams { + val useButtonFlow = pigeonVar_list[0] as Boolean + val filterToAuthorized = pigeonVar_list[1] as Boolean + val autoSelectEnabled = pigeonVar_list[2] as Boolean + val serverClientId = pigeonVar_list[3] as String? + val nonce = pigeonVar_list[4] as String? + return GetCredentialRequestParams( + useButtonFlow, filterToAuthorized, autoSelectEnabled, serverClientId, nonce) + } + } + + fun toList(): List { + return listOf( + useButtonFlow, + filterToAuthorized, + autoSelectEnabled, + serverClientId, + nonce, + ) + } +} + +/** + * Pigeon equivalent of the native GoogleIdTokenCredential. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PlatformGoogleIdTokenCredential( + val displayName: String? = null, + val familyName: String? = null, + val givenName: String? = null, + val id: String, + val idToken: String, + val profilePictureUri: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): PlatformGoogleIdTokenCredential { + val displayName = pigeonVar_list[0] as String? + val familyName = pigeonVar_list[1] as String? + val givenName = pigeonVar_list[2] as String? + val id = pigeonVar_list[3] as String + val idToken = pigeonVar_list[4] as String + val profilePictureUri = pigeonVar_list[5] as String? + return PlatformGoogleIdTokenCredential( + displayName, familyName, givenName, id, idToken, profilePictureUri) + } + } + + fun toList(): List { + return listOf( + displayName, + familyName, + givenName, + id, + idToken, + profilePictureUri, + ) + } +} + +/** + * The response from a `getCredential` call. + * + * This is not the same as a native GetCredentialResponse since modeling the response type hierarchy + * and two-part callback in this interface layer would add a lot of complexity that is not needed + * for the plugin's use case. It is instead a processed version of the results of those callbacks. + * + * Generated class from Pigeon that represents data sent in messages. This class should not be + * extended by any user class outside of the generated file. + */ +sealed class GetCredentialResult +/** + * An authentication failure. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class GetCredentialFailure( + /** The type of failure. */ + val type: GetCredentialFailureType, + /** The message associated with the failure, if any. */ + val message: String? = null, + /** Extra details about the failure, if any. */ + val details: String? = null +) : GetCredentialResult() { + companion object { + fun fromList(pigeonVar_list: List): GetCredentialFailure { + val type = pigeonVar_list[0] as GetCredentialFailureType + val message = pigeonVar_list[1] as String? + val details = pigeonVar_list[2] as String? + return GetCredentialFailure(type, message, details) + } + } + + fun toList(): List { + return listOf( + type, + message, + details, + ) + } +} + +/** + * A successful authentication result. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class GetCredentialSuccess(val credential: PlatformGoogleIdTokenCredential) : + GetCredentialResult() { + companion object { + fun fromList(pigeonVar_list: List): GetCredentialSuccess { + val credential = pigeonVar_list[0] as PlatformGoogleIdTokenCredential + return GetCredentialSuccess(credential) + } + } + + fun toList(): List { + return listOf( + credential, + ) + } +} + +/** + * The response from an `authorize` call. + * + * Generated class from Pigeon that represents data sent in messages. This class should not be + * extended by any user class outside of the generated file. + */ +sealed class AuthorizeResult +/** + * An authorization failure + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AuthorizeFailure( + /** The type of failure. */ + val type: AuthorizeFailureType, + /** The message associated with the failure, if any. */ + val message: String? = null, + /** Extra details about the failure, if any. */ + val details: String? = null +) : AuthorizeResult() { + companion object { + fun fromList(pigeonVar_list: List): AuthorizeFailure { + val type = pigeonVar_list[0] as AuthorizeFailureType + val message = pigeonVar_list[1] as String? + val details = pigeonVar_list[2] as String? + return AuthorizeFailure(type, message, details) + } + } + + fun toList(): List { + return listOf( + type, + message, + details, + ) + } +} + +/** + * A successful authorization result. + * + * Corresponds to a native AuthorizationResult. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PlatformAuthorizationResult( + val accessToken: String? = null, + val serverAuthCode: String? = null, + val grantedScopes: List +) : AuthorizeResult() { + companion object { + fun fromList(pigeonVar_list: List): PlatformAuthorizationResult { + val accessToken = pigeonVar_list[0] as String? + val serverAuthCode = pigeonVar_list[1] as String? + val grantedScopes = pigeonVar_list[2] as List + return PlatformAuthorizationResult(accessToken, serverAuthCode, grantedScopes) + } + } + + fun toList(): List { + return listOf( + accessToken, + serverAuthCode, + grantedScopes, + ) + } +} + +private open class MessagesPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { GetCredentialFailureType.ofRaw(it.toInt()) } + } + 130.toByte() -> { + return (readValue(buffer) as Long?)?.let { AuthorizeFailureType.ofRaw(it.toInt()) } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { PlatformAuthorizationRequest.fromList(it) } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { GetCredentialRequestParams.fromList(it) } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + PlatformGoogleIdTokenCredential.fromList(it) + } + } + 134.toByte() -> { + return (readValue(buffer) as? List)?.let { GetCredentialFailure.fromList(it) } + } + 135.toByte() -> { + return (readValue(buffer) as? List)?.let { GetCredentialSuccess.fromList(it) } + } + 136.toByte() -> { + return (readValue(buffer) as? List)?.let { AuthorizeFailure.fromList(it) } + } + 137.toByte() -> { + return (readValue(buffer) as? List)?.let { PlatformAuthorizationResult.fromList(it) } + } + else -> super.readValueOfType(type, buffer) + } + } + + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is GetCredentialFailureType -> { + stream.write(129) + writeValue(stream, value.raw) + } + is AuthorizeFailureType -> { + stream.write(130) + writeValue(stream, value.raw) + } + is PlatformAuthorizationRequest -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is GetCredentialRequestParams -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is PlatformGoogleIdTokenCredential -> { + stream.write(133) + writeValue(stream, value.toList()) + } + is GetCredentialFailure -> { + stream.write(134) + writeValue(stream, value.toList()) + } + is GetCredentialSuccess -> { + stream.write(135) + writeValue(stream, value.toList()) + } + is AuthorizeFailure -> { + stream.write(136) + writeValue(stream, value.toList()) + } + is PlatformAuthorizationResult -> { + stream.write(137) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface GoogleSignInApi { + /** + * Returns the server client ID parsed from google-services.json by the google-services Gradle + * script, if any. + */ + fun getGoogleServicesJsonServerClientId(): String? + /** Requests an authentication credential (sign in) via CredentialManager's getCredential. */ + fun getCredential( + params: GetCredentialRequestParams, + callback: (Result) -> Unit + ) + /** Clears CredentialManager credential state. */ + fun clearCredentialState(callback: (Result) -> Unit) + /** Requests authorization tokens via AuthorizationClient. */ + fun authorize( + params: PlatformAuthorizationRequest, + promptIfUnauthorized: Boolean, + callback: (Result) -> Unit + ) + + companion object { + /** The codec used by GoogleSignInApi. */ + val codec: MessageCodec by lazy { MessagesPigeonCodec() } + /** + * Sets up an instance of `GoogleSignInApi` to handle messages through the `binaryMessenger`. + */ + @JvmOverloads + fun setUp( + binaryMessenger: BinaryMessenger, + api: GoogleSignInApi?, + messageChannelSuffix: String = "" + ) { + val separatedMessageChannelSuffix = + if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.getGoogleServicesJsonServerClientId$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + listOf(api.getGoogleServicesJsonServerClientId()) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.getCredential$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val paramsArg = args[0] as GetCredentialRequestParams + api.getCredential(paramsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.clearCredentialState$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.clearCredentialState { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + reply.reply(wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.authorize$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val paramsArg = args[0] as PlatformAuthorizationRequest + val promptIfUnauthorizedArg = args[1] as Boolean + api.authorize(paramsArg, promptIfUnauthorizedArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/ResultUtils.kt b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/ResultUtils.kt new file mode 100644 index 000000000000..5e7b8f500987 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_android/android/src/main/kotlin/io/flutter/plugins/googlesignin/ResultUtils.kt @@ -0,0 +1,37 @@ +package io.flutter.plugins.googlesignin + +fun completeWithGetGetCredentialResult( + callback: (Result) -> Unit, + result: GetCredentialResult +) { + callback(Result.success(result)) +} + +fun completeWithGetCredentialFailure( + callback: (Result) -> Unit, + failure: GetCredentialFailure +) { + callback(Result.success(failure)) +} + +fun completeWithClearCredentialStateSuccess(callback: (Result) -> Unit) { + callback(Result.success(Unit)) +} + +fun completeWithClearCredentialStateError(callback: (Result) -> Unit, failure: FlutterError) { + callback(Result.failure(failure)) +} + +fun completeWithAuthorizationResult( + callback: (Result) -> Unit, + result: PlatformAuthorizationResult +) { + callback(Result.success(result)) +} + +fun completeWithAuthorizeFailure( + callback: (Result) -> Unit, + failure: AuthorizeFailure +) { + callback(Result.success(failure)) +} diff --git a/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java b/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java index 32ade75d0cb6..92868a89a364 100644 --- a/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java +++ b/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java @@ -4,51 +4,69 @@ package io.flutter.plugins.googlesignin; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import android.accounts.Account; import android.app.Activity; +import android.app.PendingIntent; import android.content.Context; -import android.content.Intent; +import android.content.IntentSender; import android.content.res.Resources; -import com.google.android.gms.auth.api.signin.GoogleSignInAccount; -import com.google.android.gms.auth.api.signin.GoogleSignInClient; -import com.google.android.gms.auth.api.signin.GoogleSignInOptions; +import androidx.credentials.ClearCredentialStateRequest; +import androidx.credentials.Credential; +import androidx.credentials.CredentialManager; +import androidx.credentials.CredentialManagerCallback; +import androidx.credentials.CustomCredential; +import androidx.credentials.GetCredentialRequest; +import androidx.credentials.GetCredentialResponse; +import androidx.credentials.PasswordCredential; +import androidx.credentials.exceptions.ClearCredentialException; +import androidx.credentials.exceptions.GetCredentialCancellationException; +import androidx.credentials.exceptions.GetCredentialException; +import androidx.credentials.exceptions.GetCredentialInterruptedException; +import androidx.credentials.exceptions.GetCredentialProviderConfigurationException; +import androidx.credentials.exceptions.GetCredentialUnknownException; +import androidx.credentials.exceptions.GetCredentialUnsupportedException; +import androidx.credentials.exceptions.NoCredentialException; +import com.google.android.gms.auth.api.identity.AuthorizationClient; +import com.google.android.gms.auth.api.identity.AuthorizationResult; import com.google.android.gms.common.api.ApiException; -import com.google.android.gms.common.api.CommonStatusCodes; -import com.google.android.gms.common.api.Scope; import com.google.android.gms.common.api.Status; +import com.google.android.gms.tasks.OnSuccessListener; import com.google.android.gms.tasks.Task; -import io.flutter.plugins.googlesignin.Messages.FlutterError; -import io.flutter.plugins.googlesignin.Messages.InitParams; -import java.util.Collections; +import com.google.android.libraries.identity.googleid.GetGoogleIdOption; +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption; +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.After; -import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.MockedConstruction; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import org.mockito.Spy; public class GoogleSignInTest { @Mock Context mockContext; @Mock Resources mockResources; @Mock Activity mockActivity; - @Spy Messages.VoidResult voidResult; - @Spy Messages.Result boolResult; - @Spy Messages.Result userDataResult; - @Mock GoogleSignInWrapper mockGoogleSignIn; - @Mock GoogleSignInAccount account; - @Mock GoogleSignInClient mockClient; - @Mock Task mockSignInTask; + @Mock PendingIntent mockAuthorizationIntent; + @Mock IntentSender mockAuthorizationIntentSender; + @Mock AuthorizeResult mockAuthorizeResult; + @Mock CredentialManager mockCredentialManager; + @Mock AuthorizationClient mockAuthorizationClient; + @Mock CustomCredential mockGenericCredential; + @Mock GoogleIdTokenCredential mockGoogleCredential; + @Mock Task mockAuthorizationTask; private GoogleSignInPlugin.Delegate plugin; private AutoCloseable mockCloseable; @@ -56,8 +74,21 @@ public class GoogleSignInTest { @Before public void setUp() { mockCloseable = MockitoAnnotations.openMocks(this); + + // Wire up basic mock functionality that is not test-specific. when(mockContext.getResources()).thenReturn(mockResources); - plugin = new GoogleSignInPlugin.Delegate(mockContext, mockGoogleSignIn); + when(mockGenericCredential.getType()) + .thenReturn(GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL); + when(mockAuthorizationTask.addOnSuccessListener(any())).thenReturn(mockAuthorizationTask); + when(mockAuthorizationTask.addOnFailureListener(any())).thenReturn(mockAuthorizationTask); + when(mockAuthorizationIntent.getIntentSender()).thenReturn(mockAuthorizationIntentSender); + + plugin = + new GoogleSignInPlugin.Delegate( + mockContext, + (Context c) -> mockCredentialManager, + (Context c) -> mockAuthorizationClient, + (Credential cred) -> mockGoogleCredential); } @After @@ -66,327 +97,806 @@ public void tearDown() throws Exception { } @Test - public void requestScopes_ResultErrorIfAccountIsNull() { - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(null); - - plugin.requestScopes(Collections.singletonList("requestedScope"), boolResult); + public void getGoogleServicesJsonServerClientId_loadsServerClientIdFromResources() { + final String packageName = "fakePackageName"; + final String serverClientId = "fakeServerClientId"; + final int resourceId = 1; + when(mockContext.getPackageName()).thenReturn(packageName); + when(mockResources.getIdentifier("default_web_client_id", "string", packageName)) + .thenReturn(resourceId); + when(mockContext.getString(resourceId)).thenReturn(serverClientId); - ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Throwable.class); - verify(boolResult).error(resultCaptor.capture()); - FlutterError error = (FlutterError) resultCaptor.getValue(); - Assert.assertEquals("sign_in_required", error.code); - Assert.assertEquals("No account to grant scopes.", error.getMessage()); + final String returnedId = plugin.getGoogleServicesJsonServerClientId(); + assertEquals(serverClientId, returnedId); } @Test - public void requestScopes_ResultTrueIfAlreadyGranted() { - Scope requestedScope = new Scope("requestedScope"); - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(true); - - plugin.requestScopes(Collections.singletonList("requestedScope"), boolResult); + public void getGoogleServicesJsonServerClientId_returnsNullIfNotFound() { + final String packageName = "fakePackageName"; + when(mockContext.getPackageName()).thenReturn(packageName); + when(mockResources.getIdentifier("default_web_client_id", "string", packageName)).thenReturn(0); - verify(boolResult).success(true); + final String returnedId = plugin.getGoogleServicesJsonServerClientId(); + assertNull(returnedId); } @Test - public void requestScopes_RequestsPermissionIfNotGranted() { - Scope requestedScope = new Scope("requestedScope"); - plugin.setActivity(mockActivity); - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.requestScopes(Collections.singletonList("requestedScope"), boolResult); - - verify(mockGoogleSignIn) - .requestPermissions(mockActivity, 53295, account, new Scope[] {requestedScope}); + public void getCredential_returnsAuthenticationInfo() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final String displayName = "Jane User"; + final String givenName = "Jane"; + final String familyName = "User"; + final String id = "someId"; + final String idToken = "idToken"; + when(mockGoogleCredential.getDisplayName()).thenReturn(displayName); + when(mockGoogleCredential.getGivenName()).thenReturn(givenName); + when(mockGoogleCredential.getFamilyName()).thenReturn(familyName); + when(mockGoogleCredential.getId()).thenReturn(id); + when(mockGoogleCredential.getIdToken()).thenReturn(idToken); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialSuccess); + PlatformGoogleIdTokenCredential credential = + ((GetCredentialSuccess) result).getCredential(); + assertEquals(displayName, credential.getDisplayName()); + assertEquals(givenName, credential.getGivenName()); + assertEquals(familyName, credential.getFamilyName()); + assertEquals(id, credential.getId()); + assertEquals(idToken, credential.getIdToken()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + callbackCaptor.getValue().onResult(new GetCredentialResponse(mockGenericCredential)); + assertTrue(callbackCalled[0]); } @Test - public void requestScopes_ReturnsFalseIfPermissionDenied() { - Scope requestedScope = new Scope("requestedScope"); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.requestScopes(Collections.singletonList("requestedScope"), boolResult); - plugin.onActivityResult( - GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, - Activity.RESULT_CANCELED, - new Intent()); - - verify(boolResult).success(false); + public void getCredential_usesGetSignInWithGoogleOptionForButtonFlow() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(true, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + // This is never called, since this test doesn't trigger the getCredentialsAsync callback. + return null; + })); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(GetCredentialRequest.class); + verify(mockCredentialManager) + .getCredentialAsync(eq(mockContext), captor.capture(), any(), any(), any()); + + assertEquals(1, captor.getValue().getCredentialOptions().size()); + assertTrue( + captor.getValue().getCredentialOptions().get(0) instanceof GetSignInWithGoogleOption); } @Test - public void requestScopes_ReturnsTrueIfPermissionGranted() { - Scope requestedScope = new Scope("requestedScope"); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.requestScopes(Collections.singletonList("requestedScope"), boolResult); - plugin.onActivityResult( - GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - - verify(boolResult).success(true); + public void getCredential_usesGetGoogleIdOptionForNonButtonFlow() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + // This is never called, since this test doesn't trigger the getCredentialsAsync callback. + return null; + })); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(GetCredentialRequest.class); + verify(mockCredentialManager) + .getCredentialAsync(eq(mockContext), captor.capture(), any(), any(), any()); + + assertEquals(1, captor.getValue().getCredentialOptions().size()); + assertTrue(captor.getValue().getCredentialOptions().get(0) instanceof GetGoogleIdOption); } @Test - public void requestScopes_mayBeCalledRepeatedly_ifAlreadyGranted() { - List requestedScopes = Collections.singletonList("requestedScope"); - Scope requestedScope = new Scope("requestedScope"); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(account); - when(account.getGrantedScopes()).thenReturn(Collections.singleton(requestedScope)); - when(mockGoogleSignIn.hasPermissions(account, requestedScope)).thenReturn(false); - - plugin.requestScopes(requestedScopes, boolResult); - plugin.onActivityResult( - GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - plugin.requestScopes(requestedScopes, boolResult); - plugin.onActivityResult( - GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - - verify(boolResult, times(2)).success(true); + public void getCredential_passesNonceInButtonFlow() { + final String nonce = "nonce"; + GetCredentialRequestParams params = + new GetCredentialRequestParams(true, false, false, "serverClientId", nonce); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + // This is never called, since this test doesn't trigger the getCredentialsAsync callback. + return null; + })); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(GetCredentialRequest.class); + verify(mockCredentialManager) + .getCredentialAsync(eq(mockContext), captor.capture(), any(), any(), any()); + + assertEquals(1, captor.getValue().getCredentialOptions().size()); + assertEquals( + nonce, + ((GetSignInWithGoogleOption) captor.getValue().getCredentialOptions().get(0)).getNonce()); } @Test - public void requestScopes_mayBeCalledRepeatedly_ifNotSignedIn() { - List requestedScopes = Collections.singletonList("requestedScope"); - - when(mockGoogleSignIn.getLastSignedInAccount(mockContext)).thenReturn(null); - - plugin.requestScopes(requestedScopes, boolResult); - plugin.onActivityResult( - GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - plugin.requestScopes(requestedScopes, boolResult); - plugin.onActivityResult( - GoogleSignInPlugin.Delegate.REQUEST_CODE_REQUEST_SCOPE, Activity.RESULT_OK, new Intent()); - - ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Throwable.class); - verify(boolResult, times(2)).error(resultCaptor.capture()); - List errors = resultCaptor.getAllValues(); - Assert.assertEquals(2, errors.size()); - FlutterError error = (FlutterError) errors.get(0); - Assert.assertEquals("sign_in_required", error.code); - Assert.assertEquals("No account to grant scopes.", error.getMessage()); - error = (FlutterError) errors.get(1); - Assert.assertEquals("sign_in_required", error.code); - Assert.assertEquals("No account to grant scopes.", error.getMessage()); + public void getCredential_passesNonceInNonButtonFlow() { + final String nonce = "nonce"; + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", nonce); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + // This is never called, since this test doesn't trigger the getCredentialsAsync callback. + return null; + })); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(GetCredentialRequest.class); + verify(mockCredentialManager) + .getCredentialAsync(eq(mockContext), captor.capture(), any(), any(), any()); + + assertEquals(1, captor.getValue().getCredentialOptions().size()); + assertEquals( + nonce, ((GetGoogleIdOption) captor.getValue().getCredentialOptions().get(0)).getNonce()); } - @Test(expected = IllegalStateException.class) - public void signInThrowsWithoutActivity() { - final GoogleSignInPlugin.Delegate plugin = - new GoogleSignInPlugin.Delegate(mock(Context.class), mock(GoogleSignInWrapper.class)); - - plugin.signIn(userDataResult); + @Test + public void getCredential_reportsMissingServerClientId() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, null, null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals(GetCredentialFailureType.MISSING_SERVER_CLIENT_ID, failure.getType()); + return null; + })); + assertTrue(callbackCalled[0]); } @Test - public void signInSilentlyThatImmediatelyCompletesWithoutResultFinishesWithError() - throws ApiException { - final String clientId = "fakeClientId"; - InitParams params = buildInitParams(clientId, null); - initAndAssertServerClientId(params, clientId); - - ApiException exception = - new ApiException(new Status(CommonStatusCodes.SIGN_IN_REQUIRED, "Error text")); - when(mockClient.silentSignIn()).thenReturn(mockSignInTask); - when(mockSignInTask.isComplete()).thenReturn(true); - when(mockSignInTask.getResult(ApiException.class)).thenThrow(exception); - - plugin.signInSilently(userDataResult); - ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(Throwable.class); - verify(userDataResult).error(resultCaptor.capture()); - FlutterError error = (FlutterError) resultCaptor.getValue(); - Assert.assertEquals("sign_in_required", error.code); - Assert.assertEquals( - "com.google.android.gms.common.api.ApiException: 4: Error text", error.getMessage()); + public void getCredential_reportsWrongCredentialType() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals(GetCredentialFailureType.UNEXPECTED_CREDENTIAL_TYPE, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + // PasswordCredential is used because it's easy to create without mocking; all that matters is + // that it's not a CustomCredential of type TYPE_GOOGLE_ID_TOKEN_CREDENTIAL. + callbackCaptor + .getValue() + .onResult(new GetCredentialResponse(new PasswordCredential("wrong", "type"))); + assertTrue(callbackCalled[0]); } @Test - public void init_LoadsServerClientIdFromResources() { - final String packageName = "fakePackageName"; - final String serverClientId = "fakeServerClientId"; - final int resourceId = 1; - InitParams params = buildInitParams(null, null); - when(mockContext.getPackageName()).thenReturn(packageName); - when(mockResources.getIdentifier("default_web_client_id", "string", packageName)) - .thenReturn(resourceId); - when(mockContext.getString(resourceId)).thenReturn(serverClientId); - initAndAssertServerClientId(params, serverClientId); + public void getCredential_reportsCancellation() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals(GetCredentialFailureType.CANCELED, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + callbackCaptor.getValue().onError(new GetCredentialCancellationException()); + assertTrue(callbackCalled[0]); } @Test - public void init_InterpretsClientIdAsServerClientId() { - final String clientId = "fakeClientId"; - InitParams params = buildInitParams(clientId, null); - initAndAssertServerClientId(params, clientId); + public void getCredential_reportsInterrupted() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals(GetCredentialFailureType.INTERRUPTED, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + callbackCaptor.getValue().onError(new GetCredentialInterruptedException()); + assertTrue(callbackCalled[0]); } @Test - public void init_ForwardsServerClientId() { - final String serverClientId = "fakeServerClientId"; - InitParams params = buildInitParams(null, serverClientId); - initAndAssertServerClientId(params, serverClientId); + public void getCredential_reportsProviderConfigurationIssue() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals( + GetCredentialFailureType.PROVIDER_CONFIGURATION_ISSUE, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + callbackCaptor.getValue().onError(new GetCredentialProviderConfigurationException()); + assertTrue(callbackCalled[0]); } @Test - public void init_IgnoresClientIdIfServerClientIdIsProvided() { - final String clientId = "fakeClientId"; - final String serverClientId = "fakeServerClientId"; - InitParams params = buildInitParams(clientId, serverClientId); - initAndAssertServerClientId(params, serverClientId); + public void getCredential_reportsUnsupported() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals(GetCredentialFailureType.UNSUPPORTED, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + callbackCaptor.getValue().onError(new GetCredentialUnsupportedException()); + assertTrue(callbackCalled[0]); } @Test - public void init_PassesForceCodeForRefreshTokenFalseWithServerClientIdParameter() { - InitParams params = buildInitParams("fakeClientId", "fakeServerClientId", false); - - initAndAssertForceCodeForRefreshToken(params, false); + public void getCredential_reportsNoCredential() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals(GetCredentialFailureType.NO_CREDENTIAL, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + callbackCaptor.getValue().onError(new NoCredentialException()); + assertTrue(callbackCalled[0]); } @Test - public void init_PassesForceCodeForRefreshTokenTrueWithServerClientIdParameter() { - InitParams params = buildInitParams("fakeClientId", "fakeServerClientId", true); - - initAndAssertForceCodeForRefreshToken(params, true); + public void getCredential_reportsUnknown() { + GetCredentialRequestParams params = + new GetCredentialRequestParams(false, false, false, "serverClientId", null); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.getCredential( + params, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + GetCredentialResult result = reply.getOrNull(); + assertTrue(result instanceof GetCredentialFailure); + GetCredentialFailure failure = (GetCredentialFailure) result; + assertEquals(GetCredentialFailureType.UNKNOWN, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> + callbackCaptor = ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .getCredentialAsync( + eq(mockContext), + any(GetCredentialRequest.class), + any(), + any(), + callbackCaptor.capture()); + + callbackCaptor.getValue().onError(new GetCredentialUnknownException()); + assertTrue(callbackCalled[0]); } @Test - public void init_PassesForceCodeForRefreshTokenFalseWithServerClientIdFromResources() { - final String packageName = "fakePackageName"; - final String serverClientId = "fakeServerClientId"; - final int resourceId = 1; - InitParams params = buildInitParams(null, null, false); - when(mockContext.getPackageName()).thenReturn(packageName); - when(mockResources.getIdentifier("default_web_client_id", "string", packageName)) - .thenReturn(resourceId); - when(mockContext.getString(resourceId)).thenReturn(serverClientId); - - initAndAssertForceCodeForRefreshToken(params, false); + public void authorize_returnsImmediateResult() { + final List scopes = new ArrayList<>(Arrays.asList("scope1", "scope1")); + PlatformAuthorizationRequest params = + new PlatformAuthorizationRequest(scopes, null, null, null); + + final String accessToken = "accessToken"; + final String serverAuthCode = "serverAuthCode"; + when(mockAuthorizationClient.authorize(any())).thenReturn(mockAuthorizationTask); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.authorize( + params, + false, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + assertTrue(reply.isSuccess()); + AuthorizeResult result = reply.getOrNull(); + assertTrue(result instanceof PlatformAuthorizationResult); + PlatformAuthorizationResult auth = (PlatformAuthorizationResult) result; + assertEquals(accessToken, auth.getAccessToken()); + assertEquals(serverAuthCode, auth.getServerAuthCode()); + assertEquals(scopes, auth.getGrantedScopes()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(OnSuccessListener.class); + verify(mockAuthorizationTask).addOnSuccessListener(callbackCaptor.capture()); + + callbackCaptor + .getValue() + .onSuccess( + new AuthorizationResult(serverAuthCode, accessToken, "idToken", scopes, null, null)); + assertTrue(callbackCalled[0]); } @Test - public void init_PassesForceCodeForRefreshTokenTrueWithServerClientIdFromResources() { - final String packageName = "fakePackageName"; - final String serverClientId = "fakeServerClientId"; - final int resourceId = 1; - InitParams params = buildInitParams(null, null, true); - when(mockContext.getPackageName()).thenReturn(packageName); - when(mockResources.getIdentifier("default_web_client_id", "string", packageName)) - .thenReturn(resourceId); - when(mockContext.getString(resourceId)).thenReturn(serverClientId); - - initAndAssertForceCodeForRefreshToken(params, true); + public void authorize_reportsImmediateException() { + final List scopes = new ArrayList<>(Arrays.asList("scope1", "scope1")); + PlatformAuthorizationRequest params = + new PlatformAuthorizationRequest(scopes, null, null, null); + + final String accessToken = "accessToken"; + final String serverAuthCode = "serverAuthCode"; + when(mockAuthorizationClient.authorize(any())).thenThrow(new RuntimeException()); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.authorize( + params, + false, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + AuthorizeResult result = reply.getOrNull(); + assertTrue(result instanceof AuthorizeFailure); + AuthorizeFailure failure = (AuthorizeFailure) result; + assertEquals(AuthorizeFailureType.API_EXCEPTION, failure.getType()); + return null; + })); + + assertTrue(callbackCalled[0]); } @Test - public void init_PassesForceAccountName() { - String fakeAccountName = "fakeEmailAddress@example.com"; + public void authorize_reportsFailureIfUnauthorizedAndNoPromptAllowed() { + final List scopes = new ArrayList<>(Arrays.asList("scope1", "scope1")); + PlatformAuthorizationRequest params = + new PlatformAuthorizationRequest(scopes, null, null, null); + + final String accessToken = "accessToken"; + final String serverAuthCode = "serverAuthCode"; + when(mockAuthorizationClient.authorize(any())).thenReturn(mockAuthorizationTask); + + final Boolean[] callbackCalled = new Boolean[1]; + plugin.authorize( + params, + false, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + AuthorizeResult result = reply.getOrNull(); + assertTrue(result instanceof AuthorizeFailure); + AuthorizeFailure failure = (AuthorizeFailure) result; + assertEquals(AuthorizeFailureType.UNAUTHORIZED, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(OnSuccessListener.class); + verify(mockAuthorizationTask).addOnSuccessListener(callbackCaptor.capture()); + + callbackCaptor + .getValue() + .onSuccess( + new AuthorizationResult(null, null, null, scopes, null, mockAuthorizationIntent)); + assertTrue(callbackCalled[0]); + } - try (MockedConstruction mocked = - Mockito.mockConstruction( - Account.class, - (mock, context) -> { - when(mock.toString()).thenReturn(fakeAccountName); - })) { - InitParams params = buildInitParams("fakeClientId", "fakeServerClientId2", fakeAccountName); + @Test + public void authorize_reportsFailureIfUnauthorizedAndNoActivity() { + final List scopes = new ArrayList<>(Arrays.asList("scope1", "scope1")); + PlatformAuthorizationRequest params = + new PlatformAuthorizationRequest(scopes, null, null, null); + + final String accessToken = "accessToken"; + final String serverAuthCode = "serverAuthCode"; + when(mockAuthorizationClient.authorize(any())).thenReturn(mockAuthorizationTask); + + plugin.setActivity(null); + final Boolean[] callbackCalled = new Boolean[1]; + plugin.authorize( + params, + true, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + AuthorizeResult result = reply.getOrNull(); + assertTrue(result instanceof AuthorizeFailure); + AuthorizeFailure failure = (AuthorizeFailure) result; + assertEquals(AuthorizeFailureType.NO_ACTIVITY, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(OnSuccessListener.class); + verify(mockAuthorizationTask).addOnSuccessListener(callbackCaptor.capture()); + + callbackCaptor + .getValue() + .onSuccess( + new AuthorizationResult(null, null, null, scopes, null, mockAuthorizationIntent)); + assertTrue(callbackCalled[0]); + } - initAndAssertForceAccountName(params, fakeAccountName); + @Test + public void authorize_returnsPostIntentResult() { + final List scopes = new ArrayList<>(Arrays.asList("scope1", "scope1")); + PlatformAuthorizationRequest params = + new PlatformAuthorizationRequest(scopes, null, null, null); + + final String accessToken = "accessToken"; + final String serverAuthCode = "serverAuthCode"; + when(mockAuthorizationClient.authorize(any())).thenReturn(mockAuthorizationTask); + try { + when(mockAuthorizationClient.getAuthorizationResultFromIntent(any())) + .thenReturn( + new AuthorizationResult(serverAuthCode, accessToken, "idToken", scopes, null, null)); + } catch (ApiException e) { + fail(); + } - List constructed = mocked.constructed(); - Assert.assertEquals(1, constructed.size()); + plugin.setActivity(mockActivity); + final Boolean[] callbackCalled = new Boolean[1]; + plugin.authorize( + params, + true, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + assertTrue(reply.isSuccess()); + AuthorizeResult result = reply.getOrNull(); + assertTrue(result instanceof PlatformAuthorizationResult); + PlatformAuthorizationResult auth = (PlatformAuthorizationResult) result; + assertEquals(accessToken, auth.getAccessToken()); + assertEquals(serverAuthCode, auth.getServerAuthCode()); + assertEquals(scopes, auth.getGrantedScopes()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(OnSuccessListener.class); + verify(mockAuthorizationTask).addOnSuccessListener(callbackCaptor.capture()); + callbackCaptor + .getValue() + .onSuccess( + new AuthorizationResult(null, null, null, scopes, null, mockAuthorizationIntent)); + try { + verify(mockActivity) + .startIntentSenderForResult( + mockAuthorizationIntent.getIntentSender(), + GoogleSignInPlugin.Delegate.REQUEST_CODE_AUTHORIZE, + null, + 0, + 0, + 0, + null); + } catch (IntentSender.SendIntentException e) { + fail(); } - } + // Simulate the UI flow completing. The intent data can be null here because the mock of + // mockAuthorizationClient.getAuthorizationResultFromIntent above ignores the parameter. + plugin.onActivityResult(GoogleSignInPlugin.Delegate.REQUEST_CODE_AUTHORIZE, 0, null); - public void initAndAssertServerClientId(InitParams params, String serverClientId) { - ArgumentCaptor optionsCaptor = - ArgumentCaptor.forClass(GoogleSignInOptions.class); - when(mockGoogleSignIn.getClient(any(Context.class), optionsCaptor.capture())) - .thenReturn(mockClient); - plugin.init(params); - Assert.assertEquals(serverClientId, optionsCaptor.getValue().getServerClientId()); + assertTrue(callbackCalled[0]); } - public void initAndAssertForceCodeForRefreshToken( - InitParams params, boolean forceCodeForRefreshToken) { - ArgumentCaptor optionsCaptor = - ArgumentCaptor.forClass(GoogleSignInOptions.class); - when(mockGoogleSignIn.getClient(any(Context.class), optionsCaptor.capture())) - .thenReturn(mockClient); - plugin.init(params); - Assert.assertEquals( - forceCodeForRefreshToken, optionsCaptor.getValue().isForceCodeForRefreshToken()); - } + @Test + public void authorize_reportsPendingIntentException() { + final List scopes = new ArrayList<>(Arrays.asList("scope1", "scope1")); + PlatformAuthorizationRequest params = + new PlatformAuthorizationRequest(scopes, null, null, null); + + final String accessToken = "accessToken"; + final String serverAuthCode = "serverAuthCode"; + when(mockAuthorizationClient.authorize(any())).thenReturn(mockAuthorizationTask); + try { + doThrow(new IntentSender.SendIntentException()) + .when(mockActivity) + .startIntentSenderForResult( + mockAuthorizationIntentSender, + GoogleSignInPlugin.Delegate.REQUEST_CODE_AUTHORIZE, + null, + 0, + 0, + 0, + null); + } catch (IntentSender.SendIntentException e) { + fail(); + } - public void initAndAssertForceAccountName(InitParams params, String forceAccountName) { - ArgumentCaptor optionsCaptor = - ArgumentCaptor.forClass(GoogleSignInOptions.class); - when(mockGoogleSignIn.getClient(any(Context.class), optionsCaptor.capture())) - .thenReturn(mockClient); - plugin.init(params); - Assert.assertEquals(forceAccountName, optionsCaptor.getValue().getAccount().toString()); + plugin.setActivity(mockActivity); + final Boolean[] callbackCalled = new Boolean[1]; + plugin.authorize( + params, + true, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + AuthorizeResult result = reply.getOrNull(); + assertTrue(result instanceof AuthorizeFailure); + AuthorizeFailure failure = (AuthorizeFailure) result; + assertEquals(AuthorizeFailureType.PENDING_INTENT_EXCEPTION, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(OnSuccessListener.class); + verify(mockAuthorizationTask).addOnSuccessListener(callbackCaptor.capture()); + callbackCaptor + .getValue() + .onSuccess( + new AuthorizationResult(null, null, null, scopes, null, mockAuthorizationIntent)); + + assertTrue(callbackCalled[0]); } - private static InitParams buildInitParams(String clientId, String serverClientId) { - return buildInitParams( - Messages.SignInType.STANDARD, - Collections.emptyList(), - clientId, - serverClientId, - false, - null); - } + @Test + public void authorize_reportsPostIntentException() { + final List scopes = new ArrayList<>(Arrays.asList("scope1", "scope1")); + PlatformAuthorizationRequest params = + new PlatformAuthorizationRequest(scopes, null, null, null); + + final String accessToken = "accessToken"; + final String serverAuthCode = "serverAuthCode"; + when(mockAuthorizationClient.authorize(any())).thenReturn(mockAuthorizationTask); + try { + when(mockAuthorizationClient.getAuthorizationResultFromIntent(any())) + .thenThrow(new ApiException(Status.RESULT_INTERNAL_ERROR)); + } catch (ApiException e) { + fail(); + } - private static InitParams buildInitParams( - String clientId, String serverClientId, boolean forceCodeForRefreshToken) { - return buildInitParams( - Messages.SignInType.STANDARD, - Collections.emptyList(), - clientId, - serverClientId, - forceCodeForRefreshToken, - null); + plugin.setActivity(mockActivity); + final Boolean[] callbackCalled = new Boolean[1]; + plugin.authorize( + params, + true, + ResultCompat.asCompatCallback( + reply -> { + callbackCalled[0] = true; + // This failure is a structured return value, not an exception. + assertTrue(reply.isSuccess()); + AuthorizeResult result = reply.getOrNull(); + assertTrue(result instanceof AuthorizeFailure); + AuthorizeFailure failure = (AuthorizeFailure) result; + assertEquals(AuthorizeFailureType.API_EXCEPTION, failure.getType()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(OnSuccessListener.class); + verify(mockAuthorizationTask).addOnSuccessListener(callbackCaptor.capture()); + callbackCaptor + .getValue() + .onSuccess( + new AuthorizationResult(null, null, null, scopes, null, mockAuthorizationIntent)); + try { + verify(mockActivity) + .startIntentSenderForResult( + mockAuthorizationIntent.getIntentSender(), + GoogleSignInPlugin.Delegate.REQUEST_CODE_AUTHORIZE, + null, + 0, + 0, + 0, + null); + } catch (IntentSender.SendIntentException e) { + fail(); + } + // Simulate the UI flow completing. The intent data can be null here because the mock of + // mockAuthorizationClient.getAuthorizationResultFromIntent above ignores the parameter. + plugin.onActivityResult(GoogleSignInPlugin.Delegate.REQUEST_CODE_AUTHORIZE, 0, null); + + assertTrue(callbackCalled[0]); } - private static InitParams buildInitParams( - String clientId, String serverClientId, String forceAccountName) { - return buildInitParams( - Messages.SignInType.STANDARD, - Collections.emptyList(), - clientId, - serverClientId, - false, - forceAccountName); + @Test + public void clearCredentialState_reportsSuccess() { + plugin.clearCredentialState( + ResultCompat.asCompatCallback( + reply -> { + assertTrue(reply.isSuccess()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .clearCredentialStateAsync( + any(ClearCredentialStateRequest.class), any(), any(), callbackCaptor.capture()); + + callbackCaptor.getValue().onResult(null); } - private static InitParams buildInitParams( - Messages.SignInType signInType, - List scopes, - String clientId, - String serverClientId, - boolean forceCodeForRefreshToken, - String forceAccountName) { - InitParams.Builder builder = new InitParams.Builder(); - builder.setSignInType(signInType); - builder.setScopes(scopes); - if (clientId != null) { - builder.setClientId(clientId); - } - if (serverClientId != null) { - builder.setServerClientId(serverClientId); - } - builder.setForceCodeForRefreshToken(forceCodeForRefreshToken); - if (forceAccountName != null) { - builder.setForceAccountName(forceAccountName); - } - return builder.build(); + @Test + public void clearCredentialState_reportsFailure() { + plugin.clearCredentialState( + ResultCompat.asCompatCallback( + reply -> { + assertTrue(reply.isFailure()); + return null; + })); + + @SuppressWarnings("unchecked") + ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(CredentialManagerCallback.class); + verify(mockCredentialManager) + .clearCredentialStateAsync( + any(ClearCredentialStateRequest.class), any(), any(), callbackCaptor.capture()); + + callbackCaptor.getValue().onError(mock(ClearCredentialException.class)); } } diff --git a/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/TestResultUtils.kt b/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/TestResultUtils.kt new file mode 100644 index 000000000000..e6b8e965b97e --- /dev/null +++ b/packages/google_sign_in/google_sign_in_android/android/src/test/java/io/flutter/plugins/googlesignin/TestResultUtils.kt @@ -0,0 +1,35 @@ +// 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. + +package io.flutter.plugins.googlesignin + +/** Wraps Kotlin Result for use in Java unit tests. */ +@Suppress("UNCHECKED_CAST") +class ResultCompat(private val result: Result) { + private val value: T? = result.getOrNull() + private val exception = result.exceptionOrNull() + val isSuccess = result.isSuccess + val isFailure = result.isFailure + + companion object { + @JvmStatic + fun success(value: T, callback: Any) { + val castedCallback: (Result) -> Unit = callback as (Result) -> Unit + castedCallback(Result.success(value)) + } + + @JvmStatic + fun asCompatCallback(result: (ResultCompat) -> Unit): (Result) -> Unit { + return { result(ResultCompat(it)) } + } + } + + fun getOrNull(): T? { + return value + } + + fun exceptionOrNull(): Throwable? { + return exception + } +} diff --git a/packages/google_sign_in/google_sign_in_android/example/android/app/build.gradle b/packages/google_sign_in/google_sign_in_android/example/android/app/build.gradle index 3c1e0dee00be..d4ecd0ece478 100644 --- a/packages/google_sign_in/google_sign_in_android/example/android/app/build.gradle +++ b/packages/google_sign_in/google_sign_in_android/example/android/app/build.gradle @@ -2,6 +2,7 @@ plugins { id "com.android.application" id "org.jetbrains.kotlin.android" id "dev.flutter.flutter-gradle-plugin" + id 'com.google.gms.google-services' } def localProperties = new Properties() diff --git a/packages/google_sign_in/google_sign_in_android/example/android/build.gradle b/packages/google_sign_in/google_sign_in_android/example/android/build.gradle index b812ba735678..92a39343e0d0 100644 --- a/packages/google_sign_in/google_sign_in_android/example/android/build.gradle +++ b/packages/google_sign_in/google_sign_in_android/example/android/build.gradle @@ -1,3 +1,7 @@ +plugins { + id 'com.google.gms.google-services' version '4.4.2' apply false +} + allprojects { repositories { // See https://github.com/flutter/flutter/blob/master/docs/ecosystem/Plugins-and-Packages-repository-structure.md#gradle-structure for more info. diff --git a/packages/google_sign_in/google_sign_in_android/example/integration_test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in_android/example/integration_test/google_sign_in_test.dart index f1388ce86d67..e6cca19c5096 100644 --- a/packages/google_sign_in/google_sign_in_android/example/integration_test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in_android/example/integration_test/google_sign_in_test.dart @@ -9,16 +9,14 @@ import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('Can initialize the plugin', (WidgetTester tester) async { + testWidgets('Can instantiate the plugin', (WidgetTester tester) async { final GoogleSignInPlatform signIn = GoogleSignInPlatform.instance; expect(signIn, isNotNull); }); testWidgets('Method channel handler is present', (WidgetTester tester) async { - // isSignedIn can be called without initialization, so use it to validate - // that the native method handler is present (e.g., that the channel name - // is correct). + // Validate that the native method handler is present and configured. final GoogleSignInPlatform signIn = GoogleSignInPlatform.instance; - await expectLater(signIn.isSignedIn(), completes); + await expectLater(signIn.signOut(const SignOutParams()), completes); }); } diff --git a/packages/google_sign_in/google_sign_in_android/example/lib/main.dart b/packages/google_sign_in/google_sign_in_android/example/lib/main.dart index 9403f62f619e..52b1c6b7ca25 100644 --- a/packages/google_sign_in/google_sign_in_android/example/lib/main.dart +++ b/packages/google_sign_in/google_sign_in_android/example/lib/main.dart @@ -8,10 +8,14 @@ import 'dart:async'; import 'dart:convert' show json; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:http/http.dart' as http; +const List _scopes = [ + 'email', + 'https://www.googleapis.com/auth/contacts.readonly', +]; + void main() { runApp( const MaterialApp( @@ -30,7 +34,9 @@ class SignInDemo extends StatefulWidget { class SignInDemoState extends State { GoogleSignInUserData? _currentUser; + bool _isAuthorized = false; String _contactText = ''; + String _errorMessage = ''; // Future that completes when `init` has completed on the sign in instance. Future? _initialization; @@ -41,13 +47,10 @@ class SignInDemoState extends State { } Future _ensureInitialized() { + // The example app uses the parsing of values from google-services.json + // to provide the serverClientId, otherwise it would be required here. return _initialization ??= - GoogleSignInPlatform.instance.initWithParams(const SignInInitParameters( - scopes: [ - 'email', - 'https://www.googleapis.com/auth/contacts.readonly', - ], - )) + GoogleSignInPlatform.instance.init(const InitParameters()) ..catchError((dynamic _) { _initialization = null; }); @@ -56,33 +59,71 @@ class SignInDemoState extends State { void _setUser(GoogleSignInUserData? user) { setState(() { _currentUser = user; - if (user != null) { - _handleGetContact(user); - } }); + if (user != null) { + // Try getting contacts, in case authorization is already granted. + _handleGetContact(user); + } } Future _signIn() async { await _ensureInitialized(); - final GoogleSignInUserData? newUser = - await GoogleSignInPlatform.instance.signInSilently(); - _setUser(newUser); + try { + final AuthenticationResults? result = await GoogleSignInPlatform.instance + .attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + _setUser(result?.user); + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = e.code == GoogleSignInExceptionCode.canceled + ? '' + : 'GoogleSignInException ${e.code}: ${e.description}'; + }); + } } - Future> _getAuthHeaders() async { - final GoogleSignInUserData? user = _currentUser; - if (user == null) { - throw StateError('No user signed in'); + Future _handleAuthorizeScopes(GoogleSignInUserData user) async { + try { + final ClientAuthorizationTokenData? tokens = await GoogleSignInPlatform + .instance + .clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: _scopes, + userId: user.id, + email: user.email, + promptIfUnauthorized: true))); + + setState(() { + _isAuthorized = tokens != null; + _errorMessage = ''; + }); + if (_isAuthorized) { + unawaited(_handleGetContact(user)); + } + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = 'GoogleSignInException ${e.code}: ${e.description}'; + }); } + } - final GoogleSignInTokenData response = - await GoogleSignInPlatform.instance.getTokens( - email: user.email, - shouldRecoverAuth: true, - ); + Future?> _getAuthHeaders( + GoogleSignInUserData user) async { + final ClientAuthorizationTokenData? tokens = + await GoogleSignInPlatform.instance.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: _scopes, + userId: user.id, + email: user.email, + promptIfUnauthorized: false))); + if (tokens == null) { + return null; + } return { - 'Authorization': 'Bearer ${response.accessToken}', + 'Authorization': 'Bearer ${tokens.accessToken}', // TODO(kevmoo): Use the correct value once it's available. // See https://github.com/flutter/flutter/issues/80905 'X-Goog-AuthUser': '0', @@ -93,10 +134,17 @@ class SignInDemoState extends State { setState(() { _contactText = 'Loading contact info...'; }); + final Map? headers = await _getAuthHeaders(user); + setState(() { + _isAuthorized = headers != null; + }); + if (headers == null) { + return; + } final http.Response response = await http.get( Uri.parse('https://people.googleapis.com/v1/people/me/connections' '?requestMask.includeField=person.names'), - headers: await _getAuthHeaders(), + headers: headers, ); if (response.statusCode != 200) { setState(() { @@ -118,55 +166,63 @@ class SignInDemoState extends State { Future _handleSignIn() async { try { await _ensureInitialized(); - _setUser(await GoogleSignInPlatform.instance.signIn()); - } catch (error) { - final bool canceled = - error is PlatformException && error.code == 'sign_in_canceled'; - if (!canceled) { - print(error); - } + final AuthenticationResults result = await GoogleSignInPlatform.instance + .authenticate(const AuthenticateParameters()); + _setUser(result.user); + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = e.code == GoogleSignInExceptionCode.canceled + ? '' + : 'GoogleSignInException ${e.code}: ${e.description}'; + }); } } Future _handleSignOut() async { await _ensureInitialized(); - await GoogleSignInPlatform.instance.disconnect(); + await GoogleSignInPlatform.instance.disconnect(const DisconnectParams()); } Widget _buildBody() { final GoogleSignInUserData? user = _currentUser; - if (user != null) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ + return Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (user != null) ...[ ListTile( title: Text(user.displayName ?? ''), subtitle: Text(user.email), ), const Text('Signed in successfully.'), - Text(_contactText), ElevatedButton( onPressed: _handleSignOut, child: const Text('SIGN OUT'), ), - ElevatedButton( - child: const Text('REFRESH'), - onPressed: () => _handleGetContact(user), - ), - ], - ); - } else { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ + if (_isAuthorized) ...[ + // The user has Authorized all required scopes. + if (_contactText.isNotEmpty) Text(_contactText), + ElevatedButton( + child: const Text('REFRESH'), + onPressed: () => _handleGetContact(user), + ), + ] 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'), + ), + ], + ] else ...[ const Text('You are not currently signed in.'), ElevatedButton( onPressed: _handleSignIn, child: const Text('SIGN IN'), ), ], - ); - } + if (_errorMessage.isNotEmpty) Text(_errorMessage), + ], + ); } @override diff --git a/packages/google_sign_in/google_sign_in_android/example/pubspec.yaml b/packages/google_sign_in/google_sign_in_android/example/pubspec.yaml index 0a2eb7e23585..7563978fbab1 100644 --- a/packages/google_sign_in/google_sign_in_android/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_android/example/pubspec.yaml @@ -28,3 +28,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_platform_interface: {path: ../../../../packages/google_sign_in/google_sign_in_platform_interface} diff --git a/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart b/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart index a064fba20de3..9b0844bd7cd9 100644 --- a/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart +++ b/packages/google_sign_in/google_sign_in_android/lib/google_sign_in_android.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; @@ -14,9 +15,13 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { /// Creates a new plugin implementation instance. GoogleSignInAndroid({ @visibleForTesting GoogleSignInApi? api, - }) : _api = api ?? GoogleSignInApi(); + }) : _hostApi = api ?? GoogleSignInApi(); - final GoogleSignInApi _api; + final GoogleSignInApi _hostApi; + + String? _serverClientId; + String? _hostedDomain; + String? _nonce; /// Registers this class as the default instance of [GoogleSignInPlatform]. static void registerWith() { @@ -24,101 +29,236 @@ class GoogleSignInAndroid extends GoogleSignInPlatform { } @override - Future init({ - List scopes = const [], - SignInOption signInOption = SignInOption.standard, - String? hostedDomain, - String? clientId, - String? forceAccountName, - }) { - return initWithParams(SignInInitParameters( - signInOption: signInOption, - scopes: scopes, - hostedDomain: hostedDomain, - clientId: clientId, - forceAccountName: forceAccountName, - )); + Future init(InitParameters params) async { + _hostedDomain = params.hostedDomain; + _serverClientId = params.serverClientId ?? + await _hostApi.getGoogleServicesJsonServerClientId(); + _nonce = params.nonce; + // The clientId parameter is not supported on Android. + // Android apps are identified by their package name and the SHA-1 of their signing key. } @override - Future initWithParams(SignInInitParameters params) { - return _api.init(InitParams( - signInType: _signInTypeForOption(params.signInOption), - scopes: params.scopes, - hostedDomain: params.hostedDomain, - clientId: params.clientId, - serverClientId: params.serverClientId, - forceCodeForRefreshToken: params.forceCodeForRefreshToken, - forceAccountName: params.forceAccountName, - )); - } - - @override - Future signInSilently() { - return _api.signInSilently().then(_signInUserDataFromChannelData); + Future attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params) async { + // Attempt to auto-sign-in, for single-account or returning users. + PlatformGoogleIdTokenCredential? credential = await _authenticate( + filterToAuthorized: true, + autoSelectEnabled: true, + useButtonFlow: false, + ); + // If no auto-sign-in is available, potentially prompt for an account via + // the bottom sheet flow. + credential ??= await _authenticate( + filterToAuthorized: false, + autoSelectEnabled: false, + useButtonFlow: false, + ); + return credential == null + ? null + : _authenticationResultFromPlatformCredential(credential); } @override - Future signIn() { - return _api.signIn().then(_signInUserDataFromChannelData); + Future authenticate( + AuthenticateParameters params) async { + // Attempt to authorize with minimal interaction. + final PlatformGoogleIdTokenCredential? credential = await _authenticate( + filterToAuthorized: false, + autoSelectEnabled: false, + useButtonFlow: true, + throwForNoAuth: true, + ); + // It's not clear from the documentation if this can happen; if it does, + // no information is available + if (credential == null) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: 'Authenticate returned no credential without an error'); + } + return _authenticationResultFromPlatformCredential(credential); } @override - Future getTokens( - {required String email, bool? shouldRecoverAuth = true}) { - return _api - .getAccessToken(email, shouldRecoverAuth ?? true) - .then((String result) => GoogleSignInTokenData( - accessToken: result, - )); + Future signOut(SignOutParams params) { + return _hostApi.clearCredentialState(); } @override - Future signOut() { - return _api.signOut(); - } + Future disconnect(DisconnectParams params) async { + // TODO(stuartmorgan): Implement this once Credential Manager adds the + // necessary API (or temporarily implement it with the deprecated SDK). - @override - Future disconnect() { - return _api.disconnect(); + await signOut(const SignOutParams()); } @override - Future isSignedIn() { - return _api.isSignedIn(); + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params) async { + final (:String? accessToken, :String? serverAuthCode) = + await _authorize(params.request, requestOfflineAccess: false); + return accessToken == null + ? null + : ClientAuthorizationTokenData(accessToken: accessToken); } @override - Future clearAuthCache({required String token}) { - return _api.clearAuthCache(token); + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params) async { + final (:String? accessToken, :String? serverAuthCode) = + await _authorize(params.request, requestOfflineAccess: true); + return serverAuthCode == null + ? null + : ServerAuthorizationTokenData(serverAuthCode: serverAuthCode); } - @override - Future requestScopes(List scopes) { - return _api.requestScopes(scopes); + Future _authenticate({ + required bool filterToAuthorized, + required bool autoSelectEnabled, + required bool useButtonFlow, + bool throwForNoAuth = false, + }) async { + final GetCredentialResult authnResult = await _hostApi.getCredential( + GetCredentialRequestParams( + filterToAuthorized: filterToAuthorized, + autoSelectEnabled: autoSelectEnabled, + useButtonFlow: useButtonFlow, + serverClientId: _serverClientId, + nonce: _nonce)); + switch (authnResult) { + case GetCredentialFailure(): + String? message = authnResult.message; + final GoogleSignInExceptionCode code; + switch (authnResult.type) { + case GetCredentialFailureType.noCredential: + if (throwForNoAuth) { + code = GoogleSignInExceptionCode.unknownError; + message = 'No credential available: $message'; + } else { + return null; + } + case GetCredentialFailureType.unexpectedCredentialType: + // This should not actually be possible in practice, so it is + // grouped under providerConfigurationError instead of given a + // distinct code. + code = GoogleSignInExceptionCode.providerConfigurationError; + message = 'Unexpected credential type: $message'; + case GetCredentialFailureType.interrupted: + code = GoogleSignInExceptionCode.interrupted; + case GetCredentialFailureType.providerConfigurationIssue: + code = GoogleSignInExceptionCode.providerConfigurationError; + case GetCredentialFailureType.unsupported: + code = GoogleSignInExceptionCode.providerConfigurationError; + message = 'Credential Manager not supported. $message'; + case GetCredentialFailureType.canceled: + code = GoogleSignInExceptionCode.canceled; + case GetCredentialFailureType.missingServerClientId: + code = GoogleSignInExceptionCode.clientConfigurationError; + message = 'serverClientId must be provided on Android'; + case GetCredentialFailureType.unknown: + code = GoogleSignInExceptionCode.unknownError; + } + throw GoogleSignInException( + code: code, description: message, details: authnResult.details); + case GetCredentialSuccess(): + return authnResult.credential; + } } - SignInType _signInTypeForOption(SignInOption option) { - switch (option) { - case SignInOption.standard: - return SignInType.standard; - case SignInOption.games: - return SignInType.games; + Future<({String? accessToken, String? serverAuthCode})> _authorize( + AuthorizationRequestDetails request, + {required bool requestOfflineAccess}) async { + final AuthorizeResult result = await _hostApi.authorize( + PlatformAuthorizationRequest( + scopes: request.scopes, + accountEmail: request.email, + hostedDomain: _hostedDomain, + serverClientIdForForcedRefreshToken: + requestOfflineAccess ? _serverClientId : null), + promptIfUnauthorized: request.promptIfUnauthorized); + switch (result) { + case AuthorizeFailure(): + String? message = result.message; + final GoogleSignInExceptionCode code; + switch (result.type) { + case AuthorizeFailureType.unauthorized: + // This indicates that there was no existing authorization and + // prompting wasn't allowed, so just return null. + return (accessToken: null, serverAuthCode: null); + case AuthorizeFailureType.pendingIntentException: + code = GoogleSignInExceptionCode.canceled; + case AuthorizeFailureType.authorizeFailure: + message = 'Authorization failed: $message'; + code = GoogleSignInExceptionCode.unknownError; + case AuthorizeFailureType.apiException: + message = 'SDK reported an exception: $message'; + code = GoogleSignInExceptionCode.unknownError; + case AuthorizeFailureType.noActivity: + code = GoogleSignInExceptionCode.uiUnavailable; + } + throw GoogleSignInException( + code: code, description: message, details: result.details); + case PlatformAuthorizationResult(): + final String? accessToken = result.accessToken; + if (accessToken == null) { + return (accessToken: null, serverAuthCode: null); + } + return ( + accessToken: accessToken, + serverAuthCode: result.serverAuthCode, + ); } - // Handle the case where a new type is added to the platform interface in - // the future, and this version of the package is used with it. - // ignore: dead_code - throw UnimplementedError('Unsupported sign in option: $option'); } - GoogleSignInUserData _signInUserDataFromChannelData(UserData data) { - return GoogleSignInUserData( - email: data.email, - id: data.id, - displayName: data.displayName, - photoUrl: data.photoUrl, - idToken: data.idToken, - serverAuthCode: data.serverAuthCode, + AuthenticationResults _authenticationResultFromPlatformCredential( + PlatformGoogleIdTokenCredential credential) { + // GoogleIdTokenCredential's ID field is documented to return the + // email address, not what the other platform SDKs call an ID. + // The account ID returned by other platform SDKs and the legacy + // Google Sign In for Android SDK is no longer directly exposed, so it + // need to be extracted from the token. See + // https://stackoverflow.com/a/78064720. + // The ID should always be availabe from the token, but if for some reason + // it can't be extracted, use the email address instead as a reasonable + // fallback method of identifying the account. + final String email = credential.id; + final String userId = _idFromIdToken(credential.idToken) ?? email; + + return AuthenticationResults( + user: GoogleSignInUserData( + email: email, + id: userId, + displayName: credential.displayName, + photoUrl: credential.profilePictureUri), + authenticationTokens: + AuthenticationTokenData(idToken: credential.idToken), ); } } + +/// A codec that can encode/decode JWT payloads. +/// +/// See https://www.rfc-editor.org/rfc/rfc7519#section-3 +final Codec _jwtCodec = json.fuse(utf8).fuse(base64); + +/// Extracts the user ID from an idToken. +/// +/// See https://stackoverflow.com/a/78064720 +String? _idFromIdToken(String idToken) { + final RegExp jwtTokenRegexp = RegExp( + r'^(?
[^\.\s]+)\.(?[^\.\s]+)\.(?[^\.\s]+)$'); + final RegExpMatch? match = jwtTokenRegexp.firstMatch(idToken); + final String? payload = match?.namedGroup('payload'); + if (payload != null) { + try { + final Map? contents = + _jwtCodec.decode(base64.normalize(payload)) as Map?; + if (contents != null) { + return contents['sub'] as String?; + } + } catch (_) { + return null; + } + } + return null; +} diff --git a/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart b/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart index 610741deb7b5..8bd8c6431b3a 100644 --- a/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart +++ b/packages/google_sign_in/google_sign_in_android/lib/src/messages.g.dart @@ -1,7 +1,7 @@ // 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. -// Autogenerated from Pigeon (v24.2.0), do not edit directly. +// Autogenerated from Pigeon (v24.2.2), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -18,114 +18,327 @@ PlatformException _createConnectionError(String channelName) { ); } -/// Pigeon version of SignInOption. -enum SignInType { - /// Default configuration. - standard, +enum GetCredentialFailureType { + /// Indicates that a credential was returned, but it was not of the expected + /// type. + unexpectedCredentialType, - /// Recommended configuration for game sign in. - games, + /// Indicates that a server client ID was not provided. + missingServerClientId, + + /// The request was internally interrupted. + interrupted, + + /// The request was canceled by the user. + canceled, + + /// No matching credential was found. + noCredential, + + /// The provider was not properly configured. + providerConfigurationIssue, + + /// The credential manager is not supported on this device. + unsupported, + + /// The request failed for an unknown reason. + unknown, } -/// Pigeon version of SignInInitParams. +enum AuthorizeFailureType { + /// Indicates that the requested types are not currently authorized. + /// + /// This is returned only if promptIfUnauthorized is false, indicating that + /// the user would need to be prompted for authorization. + unauthorized, + + /// Indicates that the call to AuthorizationClient.authorize itself failed. + authorizeFailure, + + /// Corresponds to SendIntentException, indicating that the pending intent is + /// no longer available. + pendingIntentException, + + /// Corresponds to an SendIntentException in onActivityResult, indicating that + /// either authorization failed, or the result was not available for some + /// reason. + apiException, + + /// Indicates that the user needs to be prompted for authorization, but there + /// is no current activity to prompt in. + noActivity, +} + +/// The information necessary to build a an authorization request. /// -/// See SignInInitParams for details. -class InitParams { - InitParams({ - this.scopes = const [], - this.signInType = SignInType.standard, +/// Corresponds to the native AuthorizationRequest object, but only contains +/// the fields used by this plugin. +class PlatformAuthorizationRequest { + PlatformAuthorizationRequest({ + required this.scopes, this.hostedDomain, - this.clientId, - this.serverClientId, - this.forceCodeForRefreshToken = false, - this.forceAccountName, + this.accountEmail, + this.serverClientIdForForcedRefreshToken, }); List scopes; - SignInType signInType; - String? hostedDomain; - String? clientId; - - String? serverClientId; - - bool forceCodeForRefreshToken; + String? accountEmail; - String? forceAccountName; + /// If set, adds a call to requestOfflineAccess(this string, true); + String? serverClientIdForForcedRefreshToken; Object encode() { return [ scopes, - signInType, hostedDomain, - clientId, - serverClientId, - forceCodeForRefreshToken, - forceAccountName, + accountEmail, + serverClientIdForForcedRefreshToken, ]; } - static InitParams decode(Object result) { + static PlatformAuthorizationRequest decode(Object result) { result as List; - return InitParams( + return PlatformAuthorizationRequest( scopes: (result[0] as List?)!.cast(), - signInType: result[1]! as SignInType, - hostedDomain: result[2] as String?, - clientId: result[3] as String?, - serverClientId: result[4] as String?, - forceCodeForRefreshToken: result[5]! as bool, - forceAccountName: result[6] as String?, + hostedDomain: result[1] as String?, + accountEmail: result[2] as String?, + serverClientIdForForcedRefreshToken: result[3] as String?, ); } } -/// Pigeon version of GoogleSignInUserData. +/// The information necessary to build a credential request. /// -/// See GoogleSignInUserData for details. -class UserData { - UserData({ +/// Combines the parts of the native GetCredentialRequest and CredentialOption +/// classes that are used for this plugin. +class GetCredentialRequestParams { + GetCredentialRequestParams({ + required this.useButtonFlow, + required this.filterToAuthorized, + required this.autoSelectEnabled, + this.serverClientId, + this.nonce, + }); + + /// Whether to use the Sign in with Google button flow + /// (GetSignInWithGoogleOption), corresponding to an explicit sign-in request, + /// or not (GetGoogleIdOption), corresponding to an implicit potential + /// sign-in. + bool useButtonFlow; + + bool filterToAuthorized; + + bool autoSelectEnabled; + + String? serverClientId; + + String? nonce; + + Object encode() { + return [ + useButtonFlow, + filterToAuthorized, + autoSelectEnabled, + serverClientId, + nonce, + ]; + } + + static GetCredentialRequestParams decode(Object result) { + result as List; + return GetCredentialRequestParams( + useButtonFlow: result[0]! as bool, + filterToAuthorized: result[1]! as bool, + autoSelectEnabled: result[2]! as bool, + serverClientId: result[3] as String?, + nonce: result[4] as String?, + ); + } +} + +/// Pigeon equivalent of the native GoogleIdTokenCredential. +class PlatformGoogleIdTokenCredential { + PlatformGoogleIdTokenCredential({ this.displayName, - required this.email, + this.familyName, + this.givenName, required this.id, - this.photoUrl, - this.idToken, - this.serverAuthCode, + required this.idToken, + this.profilePictureUri, }); String? displayName; - String email; + String? familyName; - String id; + String? givenName; - String? photoUrl; + String id; - String? idToken; + String idToken; - String? serverAuthCode; + String? profilePictureUri; Object encode() { return [ displayName, - email, + familyName, + givenName, id, - photoUrl, idToken, - serverAuthCode, + profilePictureUri, ]; } - static UserData decode(Object result) { + static PlatformGoogleIdTokenCredential decode(Object result) { result as List; - return UserData( + return PlatformGoogleIdTokenCredential( displayName: result[0] as String?, - email: result[1]! as String, - id: result[2]! as String, - photoUrl: result[3] as String?, - idToken: result[4] as String?, - serverAuthCode: result[5] as String?, + familyName: result[1] as String?, + givenName: result[2] as String?, + id: result[3]! as String, + idToken: result[4]! as String, + profilePictureUri: result[5] as String?, + ); + } +} + +/// The response from a `getCredential` call. +/// +/// This is not the same as a native GetCredentialResponse since modeling the +/// response type hierarchy and two-part callback in this interface layer would +/// add a lot of complexity that is not needed for the plugin's use case. It is +/// instead a processed version of the results of those callbacks. +sealed class GetCredentialResult {} + +/// An authentication failure. +class GetCredentialFailure extends GetCredentialResult { + GetCredentialFailure({ + required this.type, + this.message, + this.details, + }); + + /// The type of failure. + GetCredentialFailureType type; + + /// The message associated with the failure, if any. + String? message; + + /// Extra details about the failure, if any. + String? details; + + Object encode() { + return [ + type, + message, + details, + ]; + } + + static GetCredentialFailure decode(Object result) { + result as List; + return GetCredentialFailure( + type: result[0]! as GetCredentialFailureType, + message: result[1] as String?, + details: result[2] as String?, + ); + } +} + +/// A successful authentication result. +class GetCredentialSuccess extends GetCredentialResult { + GetCredentialSuccess({ + required this.credential, + }); + + PlatformGoogleIdTokenCredential credential; + + Object encode() { + return [ + credential, + ]; + } + + static GetCredentialSuccess decode(Object result) { + result as List; + return GetCredentialSuccess( + credential: result[0]! as PlatformGoogleIdTokenCredential, + ); + } +} + +/// The response from an `authorize` call. +sealed class AuthorizeResult {} + +/// An authorization failure +class AuthorizeFailure extends AuthorizeResult { + AuthorizeFailure({ + required this.type, + this.message, + this.details, + }); + + /// The type of failure. + AuthorizeFailureType type; + + /// The message associated with the failure, if any. + String? message; + + /// Extra details about the failure, if any. + String? details; + + Object encode() { + return [ + type, + message, + details, + ]; + } + + static AuthorizeFailure decode(Object result) { + result as List; + return AuthorizeFailure( + type: result[0]! as AuthorizeFailureType, + message: result[1] as String?, + details: result[2] as String?, + ); + } +} + +/// A successful authorization result. +/// +/// Corresponds to a native AuthorizationResult. +class PlatformAuthorizationResult extends AuthorizeResult { + PlatformAuthorizationResult({ + this.accessToken, + this.serverAuthCode, + required this.grantedScopes, + }); + + String? accessToken; + + String? serverAuthCode; + + List grantedScopes; + + Object encode() { + return [ + accessToken, + serverAuthCode, + grantedScopes, + ]; + } + + static PlatformAuthorizationResult decode(Object result) { + result as List; + return PlatformAuthorizationResult( + accessToken: result[0] as String?, + serverAuthCode: result[1] as String?, + grantedScopes: (result[2] as List?)!.cast(), ); } } @@ -137,15 +350,33 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is SignInType) { + } else if (value is GetCredentialFailureType) { buffer.putUint8(129); writeValue(buffer, value.index); - } else if (value is InitParams) { + } else if (value is AuthorizeFailureType) { buffer.putUint8(130); - writeValue(buffer, value.encode()); - } else if (value is UserData) { + writeValue(buffer, value.index); + } else if (value is PlatformAuthorizationRequest) { buffer.putUint8(131); writeValue(buffer, value.encode()); + } else if (value is GetCredentialRequestParams) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is PlatformGoogleIdTokenCredential) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is GetCredentialFailure) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is GetCredentialSuccess) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else if (value is AuthorizeFailure) { + buffer.putUint8(136); + writeValue(buffer, value.encode()); + } else if (value is PlatformAuthorizationResult) { + buffer.putUint8(137); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -156,11 +387,24 @@ class _PigeonCodec extends StandardMessageCodec { switch (type) { case 129: final int? value = readValue(buffer) as int?; - return value == null ? null : SignInType.values[value]; + return value == null ? null : GetCredentialFailureType.values[value]; case 130: - return InitParams.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null ? null : AuthorizeFailureType.values[value]; case 131: - return UserData.decode(readValue(buffer)!); + return PlatformAuthorizationRequest.decode(readValue(buffer)!); + case 132: + return GetCredentialRequestParams.decode(readValue(buffer)!); + case 133: + return PlatformGoogleIdTokenCredential.decode(readValue(buffer)!); + case 134: + return GetCredentialFailure.decode(readValue(buffer)!); + case 135: + return GetCredentialSuccess.decode(readValue(buffer)!); + case 136: + return AuthorizeFailure.decode(readValue(buffer)!); + case 137: + return PlatformAuthorizationResult.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -182,18 +426,20 @@ class GoogleSignInApi { final String pigeonVar_messageChannelSuffix; - /// Initializes a sign in request with the given parameters. - Future init(InitParams params) async { + /// Returns the server client ID parsed from google-services.json by the + /// google-services Gradle script, if any. + Future getGoogleServicesJsonServerClientId() async { final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.init$pigeonVar_messageChannelSuffix'; + 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.getGoogleServicesJsonServerClientId$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final List? pigeonVar_replyList = - await pigeonVar_channel.send([params]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -203,162 +449,26 @@ class GoogleSignInApi { details: pigeonVar_replyList[2], ); } else { - return; - } - } - - /// Starts a silent sign in. - Future signInSilently() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.signInSilently$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as UserData?)!; - } - } - - /// Starts a sign in with user interaction. - Future signIn() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.signIn$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as UserData?)!; - } - } - - /// Requests the access token for the current sign in. - Future getAccessToken(String email, bool shouldRecoverAuth) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.getAccessToken$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = await pigeonVar_channel - .send([email, shouldRecoverAuth]) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as String?)!; - } - } - - /// Signs out the current user. - Future signOut() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.signOut$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } - } - - /// Revokes scope grants to the application. - Future disconnect() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.disconnect$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; + return (pigeonVar_replyList[0] as String?); } } - /// Returns whether the user is currently signed in. - Future isSignedIn() async { + /// Requests an authentication credential (sign in) via CredentialManager's + /// getCredential. + Future getCredential( + GetCredentialRequestParams params) async { final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.isSignedIn$pigeonVar_messageChannelSuffix'; + 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.getCredential$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([params]); final List? pigeonVar_replyList = - await pigeonVar_channel.send(null) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -373,23 +483,23 @@ class GoogleSignInApi { message: 'Host platform returned null value for non-null return value.', ); } else { - return (pigeonVar_replyList[0] as bool?)!; + return (pigeonVar_replyList[0] as GetCredentialResult?)!; } } - /// Clears the authentication caching for the given token, requiring a - /// new sign in. - Future clearAuthCache(String token) async { + /// Clears CredentialManager credential state. + Future clearCredentialState() async { final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.clearAuthCache$pigeonVar_messageChannelSuffix'; + 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.clearCredentialState$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final List? pigeonVar_replyList = - await pigeonVar_channel.send([token]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -403,18 +513,21 @@ class GoogleSignInApi { } } - /// Requests access to the given scopes. - Future requestScopes(List scopes) async { + /// Requests authorization tokens via AuthorizationClient. + Future authorize(PlatformAuthorizationRequest params, + {required bool promptIfUnauthorized}) async { final String pigeonVar_channelName = - 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.requestScopes$pigeonVar_messageChannelSuffix'; + 'dev.flutter.pigeon.google_sign_in_android.GoogleSignInApi.authorize$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([params, promptIfUnauthorized]); final List? pigeonVar_replyList = - await pigeonVar_channel.send([scopes]) as List?; + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -429,7 +542,7 @@ class GoogleSignInApi { message: 'Host platform returned null value for non-null return value.', ); } else { - return (pigeonVar_replyList[0] as bool?)!; + return (pigeonVar_replyList[0] as AuthorizeResult?)!; } } } diff --git a/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart b/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart index cdb92ea3337e..af39cb3c2261 100644 --- a/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart +++ b/packages/google_sign_in/google_sign_in_android/pigeons/messages.dart @@ -6,101 +6,179 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon(PigeonOptions( dartOut: 'lib/src/messages.g.dart', - javaOut: - 'android/src/main/java/io/flutter/plugins/googlesignin/Messages.java', - javaOptions: JavaOptions(package: 'io.flutter.plugins.googlesignin'), + kotlinOut: + 'android/src/main/kotlin/io/flutter/plugins/googlesignin/Messages.kt', + kotlinOptions: KotlinOptions(package: 'io.flutter.plugins.googlesignin'), copyrightHeader: 'pigeons/copyright.txt', )) -/// Pigeon version of SignInOption. -enum SignInType { - /// Default configuration. - standard, - - /// Recommended configuration for game sign in. - games, +/// The information necessary to build a an authorization request. +/// +/// Corresponds to the native AuthorizationRequest object, but only contains +/// the fields used by this plugin. +class PlatformAuthorizationRequest { + PlatformAuthorizationRequest({required this.scopes, this.hostedDomain}); + List scopes; + String? hostedDomain; + String? accountEmail; + + /// If set, adds a call to requestOfflineAccess(this string, true); + String? serverClientIdForForcedRefreshToken; } -/// Pigeon version of SignInInitParams. +/// The information necessary to build a credential request. /// -/// See SignInInitParams for details. -class InitParams { - /// The parameters to use when initializing the sign in process. - const InitParams({ - this.scopes = const [], - this.signInType = SignInType.standard, - this.hostedDomain, - this.clientId, +/// Combines the parts of the native GetCredentialRequest and CredentialOption +/// classes that are used for this plugin. +class GetCredentialRequestParams { + GetCredentialRequestParams({ + required this.useButtonFlow, + required this.filterToAuthorized, + required this.autoSelectEnabled, this.serverClientId, - this.forceCodeForRefreshToken = false, - this.forceAccountName, + this.nonce, }); - final List scopes; - final SignInType signInType; - final String? hostedDomain; - final String? clientId; - final String? serverClientId; - final bool forceCodeForRefreshToken; - final String? forceAccountName; + /// Whether to use the Sign in with Google button flow + /// (GetSignInWithGoogleOption), corresponding to an explicit sign-in request, + /// or not (GetGoogleIdOption), corresponding to an implicit potential + /// sign-in. + bool useButtonFlow; + + bool filterToAuthorized; + bool autoSelectEnabled; + String? serverClientId; + String? nonce; +} + +/// Pigeon equivalent of the native GoogleIdTokenCredential. +class PlatformGoogleIdTokenCredential { + String? displayName; + String? familyName; + String? givenName; + late String id; + late String idToken; + String? profilePictureUri; +} + +enum GetCredentialFailureType { + /// Indicates that a credential was returned, but it was not of the expected + /// type. + unexpectedCredentialType, + + /// Indicates that a server client ID was not provided. + missingServerClientId, + + // Types from https://developer.android.com/reference/android/credentials/GetCredentialException + /// The request was internally interrupted. + interrupted, + + /// The request was canceled by the user. + canceled, + + /// No matching credential was found. + noCredential, + + /// The provider was not properly configured. + providerConfigurationIssue, + + /// The credential manager is not supported on this device. + unsupported, + + /// The request failed for an unknown reason. + unknown, } -/// Pigeon version of GoogleSignInUserData. +/// The response from a `getCredential` call. /// -/// See GoogleSignInUserData for details. -class UserData { - UserData({ - required this.email, - required this.id, - this.displayName, - this.photoUrl, - this.idToken, - this.serverAuthCode, - }); +/// This is not the same as a native GetCredentialResponse since modeling the +/// response type hierarchy and two-part callback in this interface layer would +/// add a lot of complexity that is not needed for the plugin's use case. It is +/// instead a processed version of the results of those callbacks. +sealed class GetCredentialResult {} + +/// An authentication failure. +class GetCredentialFailure extends GetCredentialResult { + /// The type of failure. + late GetCredentialFailureType type; + + /// The message associated with the failure, if any. + String? message; + + /// Extra details about the failure, if any. + String? details; +} - final String? displayName; - final String email; - final String id; - final String? photoUrl; - final String? idToken; - final String? serverAuthCode; +/// A successful authentication result. +class GetCredentialSuccess extends GetCredentialResult { + late PlatformGoogleIdTokenCredential credential; } -@HostApi() -abstract class GoogleSignInApi { - /// Initializes a sign in request with the given parameters. - void init(InitParams params); +enum AuthorizeFailureType { + /// Indicates that the requested types are not currently authorized. + /// + /// This is returned only if promptIfUnauthorized is false, indicating that + /// the user would need to be prompted for authorization. + unauthorized, - /// Starts a silent sign in. - @async - UserData signInSilently(); + /// Indicates that the call to AuthorizationClient.authorize itself failed. + authorizeFailure, - /// Starts a sign in with user interaction. - @async - UserData signIn(); + /// Corresponds to SendIntentException, indicating that the pending intent is + /// no longer available. + pendingIntentException, - /// Requests the access token for the current sign in. - @async - @TaskQueue(type: TaskQueueType.serialBackgroundThread) - String getAccessToken(String email, bool shouldRecoverAuth); + /// Corresponds to an SendIntentException in onActivityResult, indicating that + /// either authorization failed, or the result was not available for some + /// reason. + apiException, - /// Signs out the current user. - @async - void signOut(); + /// Indicates that the user needs to be prompted for authorization, but there + /// is no current activity to prompt in. + noActivity, +} - /// Revokes scope grants to the application. - @async - void disconnect(); +/// The response from an `authorize` call. +sealed class AuthorizeResult {} + +/// An authorization failure +class AuthorizeFailure extends AuthorizeResult { + /// The type of failure. + late AuthorizeFailureType type; + + /// The message associated with the failure, if any. + String? message; + + /// Extra details about the failure, if any. + String? details; +} - /// Returns whether the user is currently signed in. - bool isSignedIn(); +/// A successful authorization result. +/// +/// Corresponds to a native AuthorizationResult. +class PlatformAuthorizationResult extends AuthorizeResult { + String? accessToken; + String? serverAuthCode; + late List grantedScopes; +} - /// Clears the authentication caching for the given token, requiring a - /// new sign in. - @TaskQueue(type: TaskQueueType.serialBackgroundThread) - void clearAuthCache(String token); +@HostApi() +abstract class GoogleSignInApi { + /// Returns the server client ID parsed from google-services.json by the + /// google-services Gradle script, if any. + String? getGoogleServicesJsonServerClientId(); + + /// Requests an authentication credential (sign in) via CredentialManager's + /// getCredential. + @async + GetCredentialResult getCredential(GetCredentialRequestParams params); + + /// Clears CredentialManager credential state. + @async + void clearCredentialState(); - /// Requests access to the given scopes. + /// Requests authorization tokens via AuthorizationClient. @async - bool requestScopes(List scopes); + AuthorizeResult authorize(PlatformAuthorizationRequest params, + {required bool promptIfUnauthorized}); } diff --git a/packages/google_sign_in/google_sign_in_android/pubspec.yaml b/packages/google_sign_in/google_sign_in_android/pubspec.yaml index 05197dda1f0f..cfe6520b3dd9 100644 --- a/packages/google_sign_in/google_sign_in_android/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_android/pubspec.yaml @@ -2,7 +2,7 @@ name: google_sign_in_android description: Android implementation of the google_sign_in plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 6.2.1 +version: 7.0.0 environment: sdk: ^3.6.0 @@ -37,3 +37,7 @@ topics: false_secrets: - /example/android/app/google-services.json - /example/lib/main.dart +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_platform_interface: {path: ../../../packages/google_sign_in/google_sign_in_platform_interface} diff --git a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart index 20cfb6a746e0..f583fa50effe 100644 --- a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart +++ b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.dart @@ -2,7 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/services.dart'; +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:google_sign_in_android/google_sign_in_android.dart'; import 'package:google_sign_in_android/src/messages.g.dart'; @@ -12,28 +13,36 @@ import 'package:mockito/mockito.dart'; import 'google_sign_in_android_test.mocks.dart'; -final GoogleSignInUserData _user = GoogleSignInUserData( +const GoogleSignInUserData _testUser = GoogleSignInUserData( email: 'john.doe@gmail.com', id: '8162538176523816253123', photoUrl: 'https://lh5.googleusercontent.com/photo.jpg', displayName: 'John Doe', - idToken: '123', - serverAuthCode: '789', ); -final GoogleSignInTokenData _token = GoogleSignInTokenData( - accessToken: '456', +final AuthenticationTokenData _testAuthnToken = AuthenticationTokenData( + // This is just real enough to test the id-from-idToken extraction logic, with + // the middle (payload) section having an actual base-64 encoded JSON + // dictionary with only the "sub":"id" entry needed by the plugin code. + idToken: 'header.${base64UrlEncode(JsonUtf8Encoder().convert( + {'sub': _testUser.id}, + ))}.signatune', ); -@GenerateMocks([GoogleSignInApi]) +@GenerateNiceMocks(>[MockSpec()]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); late GoogleSignInAndroid googleSignIn; - late MockGoogleSignInApi api; + late MockGoogleSignInApi mockApi; setUp(() { - api = MockGoogleSignInApi(); - googleSignIn = GoogleSignInAndroid(api: api); + mockApi = MockGoogleSignInApi(); + googleSignIn = GoogleSignInAndroid(api: mockApi); + + provideDummy(GetCredentialSuccess( + credential: PlatformGoogleIdTokenCredential(id: '', idToken: ''))); + provideDummy( + PlatformAuthorizationResult(grantedScopes: [])); }); test('registered instance', () { @@ -41,163 +50,635 @@ void main() { expect(GoogleSignInPlatform.instance, isA()); }); - test('signInSilently transforms platform data to GoogleSignInUserData', - () async { - when(api.signInSilently()).thenAnswer((_) async => UserData( - email: _user.email, - id: _user.id, - photoUrl: _user.photoUrl, - displayName: _user.displayName, - idToken: _user.idToken, - serverAuthCode: _user.serverAuthCode, - )); - - final dynamic response = await googleSignIn.signInSilently(); - - expect(response, _user); - }); - - test('signInSilently Exceptions -> throws', () async { - when(api.signInSilently()) - .thenAnswer((_) async => throw PlatformException(code: 'fail')); - - expect(googleSignIn.signInSilently(), - throwsA(isInstanceOf())); - }); - - test('signIn transforms platform data to GoogleSignInUserData', () async { - when(api.signIn()).thenAnswer((_) async => UserData( - email: _user.email, - id: _user.id, - photoUrl: _user.photoUrl, - displayName: _user.displayName, - idToken: _user.idToken, - serverAuthCode: _user.serverAuthCode, - )); - - final dynamic response = await googleSignIn.signIn(); - - expect(response, _user); - }); - - test('signIn Exceptions -> throws', () async { - when(api.signIn()) - .thenAnswer((_) async => throw PlatformException(code: 'fail')); - - expect(googleSignIn.signIn(), throwsA(isInstanceOf())); - }); - - test('getTokens transforms platform data to GoogleSignInTokenData', () async { - const bool recoverAuth = false; - when(api.getAccessToken(_user.email, recoverAuth)) - .thenAnswer((_) async => _token.accessToken!); - - final GoogleSignInTokenData response = await googleSignIn.getTokens( - email: _user.email, shouldRecoverAuth: recoverAuth); - - expect(response, _token); + group('attemptLightweightAuthentication', () { + test('passes explicit server client ID', () async { + const String serverClientId = 'aServerClient'; + + await googleSignIn + .init(const InitParameters(serverClientId: serverClientId)); + await googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + + verifyNever(mockApi.getGoogleServicesJsonServerClientId()); + final VerificationResult verification = + verify(mockApi.getCredential(captureAny)); + final GetCredentialRequestParams hostParams = + verification.captured[0] as GetCredentialRequestParams; + expect(hostParams.serverClientId, serverClientId); + }); + + test('passes JSON server client ID if not overridden', () async { + const String serverClientId = 'aServerClient'; + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => serverClientId); + + // Passing no server client ID should cause it to be queried via + // getGoogleServicesJsonServerClientId(). + await googleSignIn.init(const InitParameters()); + await googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + + verify(mockApi.getGoogleServicesJsonServerClientId()); + final VerificationResult verification = + verify(mockApi.getCredential(captureAny)); + final GetCredentialRequestParams hostParams = + verification.captured[0] as GetCredentialRequestParams; + expect(hostParams.serverClientId, serverClientId); + }); + + test('passes nonce if provided', () async { + const String nonce = 'nonce'; + + await googleSignIn + .init(const InitParameters(nonce: nonce, serverClientId: 'id')); + await googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + + final VerificationResult verification = + verify(mockApi.getCredential(captureAny)); + final GetCredentialRequestParams hostParams = + verification.captured[0] as GetCredentialRequestParams; + expect(hostParams.nonce, nonce); + }); + + test('passes success data to caller', () async { + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialSuccess( + credential: PlatformGoogleIdTokenCredential( + displayName: _testUser.displayName, + profilePictureUri: _testUser.photoUrl, + id: _testUser.email, + idToken: _testAuthnToken.idToken!))); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + final AuthenticationResults? result = + await googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + + expect(result?.user, _testUser); + expect(result?.authenticationTokens, _testAuthnToken); + }); + + test('returns null for missing auth', () async { + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure(type: GetCredentialFailureType.noCredential)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + final AuthenticationResults? result = + await googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + + expect(result, null); + }); }); - test('getTokens will not pass null for shouldRecoverAuth', () async { - when(api.getAccessToken(_user.email, true)) - .thenAnswer((_) async => _token.accessToken!); - - final GoogleSignInTokenData response = await googleSignIn.getTokens( - email: _user.email, shouldRecoverAuth: null); - - expect(response, _token); + group('authenticate', () { + test('passes explicit server client ID', () async { + const String serverClientId = 'aServerClient'; + + await googleSignIn + .init(const InitParameters(serverClientId: serverClientId)); + await googleSignIn.authenticate(const AuthenticateParameters()); + + verifyNever(mockApi.getGoogleServicesJsonServerClientId()); + final VerificationResult verification = + verify(mockApi.getCredential(captureAny)); + final GetCredentialRequestParams hostParams = + verification.captured[0] as GetCredentialRequestParams; + expect(hostParams.serverClientId, serverClientId); + }); + + test('passes JSON server client ID if not overridden', () async { + const String serverClientId = 'aServerClient'; + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => serverClientId); + + // Passing no server client ID should cause it to be queried via + // getGoogleServicesJsonServerClientId(). + await googleSignIn.init(const InitParameters()); + await googleSignIn.authenticate(const AuthenticateParameters()); + + verify(mockApi.getGoogleServicesJsonServerClientId()); + final VerificationResult verification = + verify(mockApi.getCredential(captureAny)); + final GetCredentialRequestParams hostParams = + verification.captured[0] as GetCredentialRequestParams; + expect(hostParams.serverClientId, serverClientId); + }); + + test('passes nonce if provided', () async { + const String nonce = 'nonce'; + + await googleSignIn.init(const InitParameters(nonce: nonce)); + await googleSignIn.authenticate(const AuthenticateParameters()); + + final VerificationResult verification = + verify(mockApi.getCredential(captureAny)); + final GetCredentialRequestParams hostParams = + verification.captured[0] as GetCredentialRequestParams; + expect(hostParams.nonce, nonce); + }); + + test('passes success data to caller', () async { + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialSuccess( + credential: PlatformGoogleIdTokenCredential( + displayName: _testUser.displayName, + profilePictureUri: _testUser.photoUrl, + id: _testUser.email, + idToken: _testAuthnToken.idToken!))); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + final AuthenticationResults result = + await googleSignIn.authenticate(const AuthenticateParameters()); + + expect(result.user, _testUser); + expect(result.authenticationTokens, _testAuthnToken); + }); + + test('throws unknown for missing auth', () async { + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure(type: GetCredentialFailureType.noCredential)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); + }); + + test('throws client configuration error for missing server client ID', + () async { + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => null); + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure( + type: GetCredentialFailureType.missingServerClientId)); + + await googleSignIn.init(const InitParameters()); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf() + .having((GoogleSignInException e) => e.code, 'code', + GoogleSignInExceptionCode.clientConfigurationError) + .having((GoogleSignInException e) => e.description, 'description', + contains('serverClientId must be provided')))); + }); + + test('throws provider configuration error for wrong credential type', + () async { + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => null); + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure( + type: GetCredentialFailureType.unexpectedCredentialType)); + + await googleSignIn.init(const InitParameters()); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf() + .having((GoogleSignInException e) => e.code, 'code', + GoogleSignInExceptionCode.providerConfigurationError) + .having((GoogleSignInException e) => e.description, 'description', + contains('Unexpected credential type')))); + }); + + test( + 'throws provider configuration error if device does not ' + 'support Credential Manager', () async { + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => null); + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure(type: GetCredentialFailureType.unsupported)); + + await googleSignIn.init(const InitParameters()); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf() + .having((GoogleSignInException e) => e.code, 'code', + GoogleSignInExceptionCode.providerConfigurationError) + .having((GoogleSignInException e) => e.description, 'description', + contains('Credential Manager not supported')))); + }); + + test( + 'throws provider configuration error for SDK-reported ' + 'provider configuration error', () async { + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => null); + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure( + type: GetCredentialFailureType.providerConfigurationIssue)); + + await googleSignIn.init(const InitParameters()); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.providerConfigurationError))); + }); + + test('throws interrupted from SDK', () async { + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => null); + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure(type: GetCredentialFailureType.interrupted)); + + await googleSignIn.init(const InitParameters()); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.interrupted))); + }); + + test('throws canceled from SDK', () async { + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => null); + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure(type: GetCredentialFailureType.canceled)); + + await googleSignIn.init(const InitParameters()); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); + }); + + test('throws unknown from SDK', () async { + when(mockApi.getGoogleServicesJsonServerClientId()) + .thenAnswer((_) async => null); + when(mockApi.getCredential(any)).thenAnswer((_) async => + GetCredentialFailure(type: GetCredentialFailureType.unknown)); + + await googleSignIn.init(const InitParameters()); + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); + }); }); - test('initWithParams passes arguments', () async { - const SignInInitParameters initParams = SignInInitParameters( - hostedDomain: 'example.com', - scopes: ['two', 'scopes'], - signInOption: SignInOption.games, - clientId: 'fakeClientId', - ); - - await googleSignIn.init( - hostedDomain: initParams.hostedDomain, - scopes: initParams.scopes, - signInOption: initParams.signInOption, - clientId: initParams.clientId, + group('clientAuthorizationTokensForScopes', () { + // Request details used when the details of the request are not relevant to + // the test. + const AuthorizationRequestDetails defaultAuthRequest = + AuthorizationRequestDetails( + scopes: ['a'], + userId: null, + email: null, + promptIfUnauthorized: false, ); - final VerificationResult result = verify(api.init(captureAny)); - final InitParams passedParams = result.captured[0] as InitParams; - expect(passedParams.hostedDomain, initParams.hostedDomain); - expect(passedParams.scopes, initParams.scopes); - expect(passedParams.signInType, SignInType.games); - expect(passedParams.clientId, initParams.clientId); - // These should use whatever the SignInInitParameters defaults are. - expect(passedParams.serverClientId, initParams.serverClientId); - expect(passedParams.forceCodeForRefreshToken, - initParams.forceCodeForRefreshToken); + test('passes expected values', () async { + const List scopes = ['a', 'b']; + const String userId = '12345'; + const String userEmail = 'user@example.com'; + const bool promptIfUnauthorized = false; + const String hostedDomain = 'example.com'; + + when(mockApi.authorize(any, promptIfUnauthorized: promptIfUnauthorized)) + .thenAnswer((_) async => + PlatformAuthorizationResult(grantedScopes: [])); + + await googleSignIn.init(const InitParameters( + serverClientId: 'id', + hostedDomain: hostedDomain, + )); + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: userId, + email: userEmail, + promptIfUnauthorized: promptIfUnauthorized, + ))); + + final VerificationResult verification = verify(mockApi + .authorize(captureAny, promptIfUnauthorized: promptIfUnauthorized)); + final PlatformAuthorizationRequest hostParams = + verification.captured[0] as PlatformAuthorizationRequest; + expect(hostParams.scopes, scopes); + expect(hostParams.accountEmail, userEmail); + expect(hostParams.hostedDomain, hostedDomain); + expect(hostParams.serverClientIdForForcedRefreshToken, null); + }); + + test('passes true promptIfUnauthorized when requested', () async { + const List scopes = ['a', 'b']; + const bool promptIfUnauthorized = true; + + when(mockApi.authorize(any, promptIfUnauthorized: promptIfUnauthorized)) + .thenAnswer((_) async => + PlatformAuthorizationResult(grantedScopes: [])); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: promptIfUnauthorized, + ))); + + verify( + mockApi.authorize(any, promptIfUnauthorized: promptIfUnauthorized)); + }); + + test('passes success data to caller', () async { + const String accessToken = 'token'; + + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => PlatformAuthorizationResult( + grantedScopes: [], accessToken: accessToken)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + final ClientAuthorizationTokenData? result = + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)); + + expect(result?.accessToken, accessToken); + }); + + test('returns null when unauthorized', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => + AuthorizeFailure(type: AuthorizeFailureType.unauthorized)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + null); + }); + + test('thows canceled if pending intent fails', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => AuthorizeFailure( + type: AuthorizeFailureType.pendingIntentException)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); + }); + + test('throws unknown if authorization fails', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => + AuthorizeFailure(type: AuthorizeFailureType.authorizeFailure)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); + }); + + test('throws unknown for API exception', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => + AuthorizeFailure(type: AuthorizeFailureType.apiException)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf() + .having((GoogleSignInException e) => e.code, 'code', + GoogleSignInExceptionCode.unknownError) + .having((GoogleSignInException e) => e.description, 'description', + contains('SDK reported an exception')))); + }); + + test('throws UI unavailable if there is no activity available', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => AuthorizeFailure(type: AuthorizeFailureType.noActivity)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.uiUnavailable))); + }); }); - test('initWithParams passes arguments', () async { - const SignInInitParameters initParams = SignInInitParameters( - hostedDomain: 'example.com', - scopes: ['two', 'scopes'], - signInOption: SignInOption.games, - clientId: 'fakeClientId', - serverClientId: 'fakeServerClientId', - forceCodeForRefreshToken: true, - forceAccountName: 'fakeEmailAddress@example.com', + group('serverAuthorizationTokensForScopes', () { + // Request details used when the details of the request are not relevant to + // the test. + const AuthorizationRequestDetails defaultAuthRequest = + AuthorizationRequestDetails( + scopes: ['a'], + userId: null, + email: null, + promptIfUnauthorized: false, ); - await googleSignIn.initWithParams(initParams); - - final VerificationResult result = verify(api.init(captureAny)); - final InitParams passedParams = result.captured[0] as InitParams; - expect(passedParams.hostedDomain, initParams.hostedDomain); - expect(passedParams.scopes, initParams.scopes); - expect(passedParams.signInType, SignInType.games); - expect(passedParams.clientId, initParams.clientId); - expect(passedParams.serverClientId, initParams.serverClientId); - expect(passedParams.forceCodeForRefreshToken, - initParams.forceCodeForRefreshToken); - expect(passedParams.forceAccountName, initParams.forceAccountName); - }); - - test('clearAuthCache passes arguments', () async { - const String token = 'abc'; - - await googleSignIn.clearAuthCache(token: token); - - verify(api.clearAuthCache(token)); - }); - - test('requestScopens passes arguments', () async { - const List scopes = ['newScope', 'anotherScope']; - when(api.requestScopes(scopes)).thenAnswer((_) async => true); - - final bool response = await googleSignIn.requestScopes(scopes); - - expect(response, true); + test('serverAuthorizationTokensForScopes passes expected values', () async { + const List scopes = ['a', 'b']; + const String userId = '12345'; + const String userEmail = 'user@example.com'; + const bool promptIfUnauthorized = false; + const String hostedDomain = 'example.com'; + const String serverClientId = 'serverClientId'; + + when(mockApi.authorize(any, promptIfUnauthorized: promptIfUnauthorized)) + .thenAnswer((_) async => + PlatformAuthorizationResult(grantedScopes: [])); + + await googleSignIn.init(const InitParameters( + serverClientId: serverClientId, + hostedDomain: hostedDomain, + )); + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: userId, + email: userEmail, + promptIfUnauthorized: promptIfUnauthorized, + ))); + + final VerificationResult verification = verify(mockApi + .authorize(captureAny, promptIfUnauthorized: promptIfUnauthorized)); + final PlatformAuthorizationRequest hostParams = + verification.captured[0] as PlatformAuthorizationRequest; + expect(hostParams.scopes, scopes); + expect(hostParams.accountEmail, userEmail); + expect(hostParams.hostedDomain, hostedDomain); + expect(hostParams.serverClientIdForForcedRefreshToken, serverClientId); + }); + + test( + 'serverAuthorizationTokensForScopes passes true promptIfUnauthorized when requested', + () async { + const List scopes = ['a', 'b']; + const bool promptIfUnauthorized = true; + + when(mockApi.authorize(any, promptIfUnauthorized: promptIfUnauthorized)) + .thenAnswer((_) async => + PlatformAuthorizationResult(grantedScopes: [])); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: promptIfUnauthorized, + ))); + + verify( + mockApi.authorize(any, promptIfUnauthorized: promptIfUnauthorized)); + }); + + test('serverAuthorizationTokensForScopes passes success data to caller', + () async { + const List scopes = ['a', 'b']; + const String authCode = 'code'; + + when(mockApi.authorize(any, promptIfUnauthorized: false)) + .thenAnswer((_) async => PlatformAuthorizationResult( + grantedScopes: [], + accessToken: 'token', + serverAuthCode: authCode, + )); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + final ServerAuthorizationTokenData? result = + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: false, + ))); + + expect(result?.serverAuthCode, authCode); + }); + + test('returns null when unauthorized', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => + AuthorizeFailure(type: AuthorizeFailureType.unauthorized)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + null); + }); + + test('thows canceled if pending intent fails', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => AuthorizeFailure( + type: AuthorizeFailureType.pendingIntentException)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); + }); + + test('throws unknown if authorization fails', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => + AuthorizeFailure(type: AuthorizeFailureType.authorizeFailure)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); + }); + + test('throws unknown for API exception', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => + AuthorizeFailure(type: AuthorizeFailureType.apiException)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf() + .having((GoogleSignInException e) => e.code, 'code', + GoogleSignInExceptionCode.unknownError) + .having((GoogleSignInException e) => e.description, 'description', + contains('SDK reported an exception')))); + }); + + test('throws UI unavailable if there is no activity available', () async { + when(mockApi.authorize(any, promptIfUnauthorized: false)).thenAnswer( + (_) async => AuthorizeFailure(type: AuthorizeFailureType.noActivity)); + + await googleSignIn.init(const InitParameters(serverClientId: 'id')); + expect( + googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.uiUnavailable))); + }); }); test('signOut calls through', () async { - await googleSignIn.signOut(); + await googleSignIn.signOut(const SignOutParams()); - verify(api.signOut()); + verify(mockApi.clearCredentialState()); }); - test('disconnect calls through', () async { - await googleSignIn.disconnect(); + test('disconnect also signs out', () async { + await googleSignIn.disconnect(const DisconnectParams()); - verify(api.disconnect()); + verify(mockApi.clearCredentialState()); }); - test('isSignedIn passes true response', () async { - when(api.isSignedIn()).thenAnswer((_) async => true); - - expect(await googleSignIn.isSignedIn(), true); - }); - - test('isSignedIn passes false response', () async { - when(api.isSignedIn()).thenAnswer((_) async => false); - - expect(await googleSignIn.isSignedIn(), false); + // Returning null triggers the app-facing package to create stream events, + // per GoogleSignInPlatform docs, so it's important that this returns null + // unless the platform implementation is changed to create all necessary + // notifications. + test('authenticationEvents returns null', () async { + expect(googleSignIn.authenticationEvents, null); }); } diff --git a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart index 57c49c58a246..46f5cf978a59 100644 --- a/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in_android/test/google_sign_in_android_test.mocks.dart @@ -1,13 +1,13 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in google_sign_in_android/test/google_sign_in_android_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; +import 'dart:async' as _i4; import 'package:google_sign_in_android/src/messages.g.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i4; +import 'package:mockito/src/dummies.dart' as _i3; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -17,140 +17,104 @@ import 'package:mockito/src/dummies.dart' as _i4; // 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 _FakeUserData_0 extends _i1.SmartFake implements _i2.UserData { - _FakeUserData_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - /// A class which mocks [GoogleSignInApi]. /// /// See the documentation for Mockito's code generation for more information. class MockGoogleSignInApi extends _i1.Mock implements _i2.GoogleSignInApi { - MockGoogleSignInApi() { - _i1.throwOnMissingStub(this); - } - @override - _i3.Future init(_i2.InitParams? arg_params) => (super.noSuchMethod( - Invocation.method( - #init, - [arg_params], + String get pigeonVar_messageChannelSuffix => (super.noSuchMethod( + Invocation.getter(#pigeonVar_messageChannelSuffix), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#pigeonVar_messageChannelSuffix), ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); + returnValueForMissingStub: _i3.dummyValue( + this, + Invocation.getter(#pigeonVar_messageChannelSuffix), + ), + ) as String); @override - _i3.Future<_i2.UserData> signInSilently() => (super.noSuchMethod( + _i4.Future getGoogleServicesJsonServerClientId() => + (super.noSuchMethod( Invocation.method( - #signInSilently, + #getGoogleServicesJsonServerClientId, [], ), - returnValue: _i3.Future<_i2.UserData>.value(_FakeUserData_0( - this, - Invocation.method( - #signInSilently, - [], - ), - )), - ) as _i3.Future<_i2.UserData>); + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); @override - _i3.Future<_i2.UserData> signIn() => (super.noSuchMethod( + _i4.Future<_i2.GetCredentialResult> getCredential( + _i2.GetCredentialRequestParams? params) => + (super.noSuchMethod( Invocation.method( - #signIn, - [], + #getCredential, + [params], ), - returnValue: _i3.Future<_i2.UserData>.value(_FakeUserData_0( + returnValue: _i4.Future<_i2.GetCredentialResult>.value( + _i3.dummyValue<_i2.GetCredentialResult>( this, Invocation.method( - #signIn, - [], + #getCredential, + [params], ), )), - ) as _i3.Future<_i2.UserData>); - - @override - _i3.Future getAccessToken( - String? arg_email, - bool? arg_shouldRecoverAuth, - ) => - (super.noSuchMethod( - Invocation.method( - #getAccessToken, - [ - arg_email, - arg_shouldRecoverAuth, - ], - ), - returnValue: _i3.Future.value(_i4.dummyValue( + returnValueForMissingStub: _i4.Future<_i2.GetCredentialResult>.value( + _i3.dummyValue<_i2.GetCredentialResult>( this, Invocation.method( - #getAccessToken, - [ - arg_email, - arg_shouldRecoverAuth, - ], + #getCredential, + [params], ), )), - ) as _i3.Future); + ) as _i4.Future<_i2.GetCredentialResult>); @override - _i3.Future signOut() => (super.noSuchMethod( + _i4.Future clearCredentialState() => (super.noSuchMethod( Invocation.method( - #signOut, + #clearCredentialState, [], ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); @override - _i3.Future disconnect() => (super.noSuchMethod( - Invocation.method( - #disconnect, - [], - ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); - - @override - _i3.Future isSignedIn() => (super.noSuchMethod( - Invocation.method( - #isSignedIn, - [], - ), - returnValue: _i3.Future.value(false), - ) as _i3.Future); - - @override - _i3.Future clearAuthCache(String? arg_token) => (super.noSuchMethod( - Invocation.method( - #clearAuthCache, - [arg_token], - ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); - - @override - _i3.Future requestScopes(List? arg_scopes) => + _i4.Future<_i2.AuthorizeResult> authorize( + _i2.PlatformAuthorizationRequest? params, { + required bool? promptIfUnauthorized, + }) => (super.noSuchMethod( Invocation.method( - #requestScopes, - [arg_scopes], + #authorize, + [params], + {#promptIfUnauthorized: promptIfUnauthorized}, ), - returnValue: _i3.Future.value(false), - ) as _i3.Future); + returnValue: _i4.Future<_i2.AuthorizeResult>.value( + _i3.dummyValue<_i2.AuthorizeResult>( + this, + Invocation.method( + #authorize, + [params], + {#promptIfUnauthorized: promptIfUnauthorized}, + ), + )), + returnValueForMissingStub: _i4.Future<_i2.AuthorizeResult>.value( + _i3.dummyValue<_i2.AuthorizeResult>( + this, + Invocation.method( + #authorize, + [params], + {#promptIfUnauthorized: promptIfUnauthorized}, + ), + )), + ) as _i4.Future<_i2.AuthorizeResult>); } diff --git a/packages/google_sign_in/google_sign_in_ios/CHANGELOG.md b/packages/google_sign_in/google_sign_in_ios/CHANGELOG.md index ddcbd4044e04..0bd1341f5378 100644 --- a/packages/google_sign_in/google_sign_in_ios/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_ios/CHANGELOG.md @@ -1,3 +1,8 @@ +## 6.0.0 + +* **BREAKING CHANGE**: Switches to implementing version 3.0 of the platform + interface package, rather than 2.x, significantly changing the API surface. + ## 5.9.0 * Updates Google Sign-In SDK to 8.0+. diff --git a/packages/google_sign_in/google_sign_in_ios/README.md b/packages/google_sign_in/google_sign_in_ios/README.md index 3fe0f7a1307d..e3027fac31d5 100644 --- a/packages/google_sign_in/google_sign_in_ios/README.md +++ b/packages/google_sign_in/google_sign_in_ios/README.md @@ -11,24 +11,6 @@ so you do not need to add it to your `pubspec.yaml`. However, if you `import` this package to use any of its APIs directly, you should add it to your `pubspec.yaml` as usual. -### macOS setup - -The GoogleSignIn SDK requires keychain sharing to be enabled, by [adding the -following entitlements](https://flutter.dev/to/macos-entitlements): - -```xml - keychain-access-groups - - $(AppIdentifierPrefix)com.google.GIDSignIn - -``` - -Without this step, the plugin will throw a `keychain error` `PlatformException` -when trying to sign in. - -[1]: https://pub.dev/packages/google_sign_in -[2]: https://flutter.dev/to/endorsed-federated-plugin - ### iOS integration 1. [Create a Firebase project](https://firebase.google.com/docs/ios/setup#create-firebase-project) @@ -87,3 +69,32 @@ final GoogleSignIn googleSignIn = GoogleSignIn( ``` Note that step 6 is still required. + +#### App Store requirements + +Apple's App Review Guidelines impose +[extra login option requirements](https://developer.apple.com/app-store/review/guidelines/#login-services) +on apps that include Google Sign-In. Other packages, such as the Flutter Favorite +[`sign_in_with_apple`](https://pub.dev/packages/sign_in_with_apple), may +be useful in satisfying the review requirements. + +### macOS integration + +Follow the steps above for iOS integration, but using the `Info.plist` in the +`macos` directory. + +In addition, the GoogleSignIn SDK requires keychain sharing to be enabled, by +[adding the following entitlements](https://flutter.dev/to/macos-entitlements): + +```xml + keychain-access-groups + + $(AppIdentifierPrefix)com.google.GIDSignIn + +``` + +Without this step, the plugin will throw a `keychain error` `PlatformException` +when trying to sign in. + +[1]: https://pub.dev/packages/google_sign_in +[2]: https://flutter.dev/to/endorsed-federated-plugin diff --git a/packages/google_sign_in/google_sign_in_ios/darwin/Tests/GoogleSignInTests.m b/packages/google_sign_in/google_sign_in_ios/darwin/Tests/GoogleSignInTests.m index 6ec7f9088628..fa33c254ca68 100644 --- a/packages/google_sign_in/google_sign_in_ios/darwin/Tests/GoogleSignInTests.m +++ b/packages/google_sign_in/google_sign_in_ios/darwin/Tests/GoogleSignInTests.m @@ -71,22 +71,7 @@ - (void)testDisconnect { [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -- (void)testDisconnectIgnoresError { - NSError *sdkError = [NSError errorWithDomain:kGIDSignInErrorDomain - code:kGIDSignInErrorCodeHasNoAuthInKeychain - userInfo:nil]; - [(GIDSignIn *)[self.mockSignIn stub] - disconnectWithCompletion:[OCMArg invokeBlockWithArgs:sdkError, nil]]; - - XCTestExpectation *expectation = [self expectationWithDescription:@"expect result returns true"]; - [self.plugin disconnectWithCompletion:^(FlutterError *error) { - XCTAssertNil(error); - [expectation fulfill]; - }]; - [self waitForExpectationsWithTimeout:5.0 handler:nil]; -} - -#pragma mark - Init +#pragma mark - Configure - (void)testInitNoClientIdNoError { // Init plugin without GoogleService-Info.plist. @@ -95,13 +80,12 @@ - (void)testInitNoClientIdNoError { googleServiceProperties:nil]; // init call does not provide a clientId. - FSIInitParams *params = [FSIInitParams makeWithScopes:@[] - hostedDomain:nil - clientId:nil - serverClientId:nil]; + FSIPlatformConfigurationParams *params = [FSIPlatformConfigurationParams makeWithClientId:nil + serverClientId:nil + hostedDomain:nil]; FlutterError *error; - [self.plugin initializeSignInWithParameters:params error:&error]; + [self.plugin configureWithParameters:params error:&error]; XCTAssertNil(error); } @@ -109,29 +93,25 @@ - (void)testInitGoogleServiceInfoPlist { self.plugin = [[FLTGoogleSignInPlugin alloc] initWithSignIn:self.mockSignIn registrar:self.mockPluginRegistrar googleServiceProperties:self.googleServiceInfo]; - FSIInitParams *params = [FSIInitParams makeWithScopes:@[] - hostedDomain:@"example.com" - clientId:nil - serverClientId:nil]; + FSIPlatformConfigurationParams *params = + [FSIPlatformConfigurationParams makeWithClientId:nil + serverClientId:nil + hostedDomain:@"example.com"]; + + OCMExpect([self.mockSignIn + setConfiguration:[OCMArg checkWithBlock:^BOOL(GIDConfiguration *config) { + XCTAssertEqualObjects(config.hostedDomain, @"example.com"); + // Set in example app GoogleService-Info.plist. + XCTAssertEqualObjects( + config.clientID, + @"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"); + XCTAssertEqualObjects(config.serverClientID, @"YOUR_SERVER_CLIENT_ID"); + return YES; + }]]); - FlutterError *initializationError; - [self.plugin initializeSignInWithParameters:params error:&initializationError]; - XCTAssertNil(initializationError); - - // Initialization values used in the next sign in request. - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *error){ - }]; - OCMVerify([self configureMock:self.mockSignIn - forSignInWithHint:nil - additionalScopes:OCMOCK_ANY - completion:OCMOCK_ANY]); - - XCTAssertEqualObjects(self.plugin.configuration.hostedDomain, @"example.com"); - // Set in example app GoogleService-Info.plist. - XCTAssertEqualObjects( - self.plugin.configuration.clientID, - @"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"); - XCTAssertEqualObjects(self.plugin.configuration.serverClientID, @"YOUR_SERVER_CLIENT_ID"); + FlutterError *error; + [self.plugin configureWithParameters:params error:&error]; + XCTAssertNil(error); } - (void)testInitDynamicClientIdNullDomain { @@ -140,96 +120,75 @@ - (void)testInitDynamicClientIdNullDomain { registrar:self.mockPluginRegistrar googleServiceProperties:nil]; - FSIInitParams *params = [FSIInitParams makeWithScopes:@[] - hostedDomain:nil - clientId:@"mockClientId" - serverClientId:nil]; + OCMExpect( + [self.mockSignIn setConfiguration:[OCMArg checkWithBlock:^BOOL(GIDConfiguration *config) { + XCTAssertEqualObjects(config.hostedDomain, nil); + XCTAssertEqualObjects(config.clientID, @"mockClientId"); + XCTAssertEqualObjects(config.serverClientID, nil); + return YES; + }]]); + + FSIPlatformConfigurationParams *params = + [FSIPlatformConfigurationParams makeWithClientId:@"mockClientId" + serverClientId:nil + hostedDomain:nil]; FlutterError *initializationError; - [self.plugin initializeSignInWithParameters:params error:&initializationError]; + [self.plugin configureWithParameters:params error:&initializationError]; XCTAssertNil(initializationError); - // Initialization values used in the next sign in request. - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *error){ - }]; - OCMVerify([self configureMock:self.mockSignIn - forSignInWithHint:nil - additionalScopes:OCMOCK_ANY - completion:OCMOCK_ANY]); - - XCTAssertEqualObjects(self.plugin.configuration.hostedDomain, nil); - XCTAssertEqualObjects(self.plugin.configuration.clientID, @"mockClientId"); - XCTAssertEqualObjects(self.plugin.configuration.serverClientID, nil); + OCMVerifyAll(self.mockSignIn); } - (void)testInitDynamicServerClientIdNullDomain { self.plugin = [[FLTGoogleSignInPlugin alloc] initWithSignIn:self.mockSignIn registrar:self.mockPluginRegistrar googleServiceProperties:self.googleServiceInfo]; - FSIInitParams *params = [FSIInitParams makeWithScopes:@[] - hostedDomain:nil - clientId:nil - serverClientId:@"mockServerClientId"]; + FSIPlatformConfigurationParams *params = + [FSIPlatformConfigurationParams makeWithClientId:nil + serverClientId:@"mockServerClientId" + hostedDomain:nil]; + + OCMExpect([self.mockSignIn + setConfiguration:[OCMArg checkWithBlock:^BOOL(GIDConfiguration *config) { + XCTAssertEqualObjects(config.hostedDomain, nil); + // Set in example app GoogleService-Info.plist. + XCTAssertEqualObjects( + config.clientID, + @"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"); + XCTAssertEqualObjects(config.serverClientID, @"mockServerClientId"); + return YES; + }]]); + FlutterError *initializationError; - [self.plugin initializeSignInWithParameters:params error:&initializationError]; + [self.plugin configureWithParameters:params error:&initializationError]; XCTAssertNil(initializationError); - - // Initialization values used in the next sign in request. - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *error){ - }]; - OCMVerify([self configureMock:self.mockSignIn - forSignInWithHint:nil - additionalScopes:OCMOCK_ANY - completion:OCMOCK_ANY]); - - XCTAssertEqualObjects(self.plugin.configuration.hostedDomain, nil); - // Set in example app GoogleService-Info.plist. - XCTAssertEqualObjects( - self.plugin.configuration.clientID, - @"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"); - XCTAssertEqualObjects(self.plugin.configuration.serverClientID, @"mockServerClientId"); } - (void)testInitInfoPlist { - FSIInitParams *params = [FSIInitParams makeWithScopes:@[ @"scope1" ] - hostedDomain:@"example.com" - clientId:nil - serverClientId:nil]; + FSIPlatformConfigurationParams *params = + [FSIPlatformConfigurationParams makeWithClientId:nil + serverClientId:nil + hostedDomain:@"example.com"]; + + OCMExpect([self.mockSignIn + setConfiguration:[OCMArg checkWithBlock:^BOOL(GIDConfiguration *config) { + XCTAssertEqualObjects(config.hostedDomain, nil); + // Set in example app Info.plist. + XCTAssertEqualObjects( + config.clientID, + @"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"); + XCTAssertEqualObjects(config.serverClientID, @"YOUR_SERVER_CLIENT_ID"); + return YES; + }]]); FlutterError *error; self.plugin = [[FLTGoogleSignInPlugin alloc] initWithRegistrar:self.mockPluginRegistrar]; - [self.plugin initializeSignInWithParameters:params error:&error]; - XCTAssertNil(error); - XCTAssertNil(self.plugin.configuration); - XCTAssertNotNil(self.plugin.requestedScopes); - // Set in example app Info.plist. - XCTAssertEqualObjects( - self.plugin.signIn.configuration.clientID, - @"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"); - XCTAssertEqualObjects(self.plugin.signIn.configuration.serverClientID, @"YOUR_SERVER_CLIENT_ID"); -} - -#pragma mark - Is signed in - -- (void)testIsNotSignedIn { - OCMStub([self.mockSignIn hasPreviousSignIn]).andReturn(NO); - - FlutterError *error; - NSNumber *result = [self.plugin isSignedInWithError:&error]; - XCTAssertNil(error); - XCTAssertFalse(result.boolValue); -} - -- (void)testIsSignedIn { - OCMStub([self.mockSignIn hasPreviousSignIn]).andReturn(YES); - - FlutterError *error; - NSNumber *result = [self.plugin isSignedInWithError:&error]; + [self.plugin configureWithParameters:params error:&error]; XCTAssertNil(error); - XCTAssertTrue(result.boolValue); } -#pragma mark - Sign in silently +#pragma mark - restorePreviousSignIn - (void)testSignInSilently { id mockUser = OCMClassMock([GIDGoogleUser class]); @@ -240,20 +199,23 @@ - (void)testSignInSilently { invokeBlockWithArgs:mockUser, [NSNull null], nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin signInSilentlyWithCompletion:^(FSIUserData *user, FlutterError *error) { + [self.plugin restorePreviousSignInWithCompletion:^(FSISignInResult *result, FlutterError *error) { XCTAssertNil(error); - XCTAssertNotNil(user); + XCTAssertNil(result.error); + XCTAssertNotNil(result.success); + FSIUserData *user = result.success.user; XCTAssertNil(user.displayName); XCTAssertNil(user.email); XCTAssertEqualObjects(user.userId, @"mockID"); XCTAssertNil(user.photoUrl); - XCTAssertNil(user.serverAuthCode); + XCTAssertNil(result.success.accessToken); + XCTAssertNil(result.success.serverAuthCode); [expectation fulfill]; }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -- (void)testSignInSilentlyWithError { +- (void)testRestorePreviousSignInWithError { NSError *sdkError = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeHasNoAuthInKeychain userInfo:nil]; @@ -263,15 +225,16 @@ - (void)testSignInSilentlyWithError { invokeBlockWithArgs:[NSNull null], sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin signInSilentlyWithCompletion:^(FSIUserData *user, FlutterError *error) { - XCTAssertNil(user); - XCTAssertEqualObjects(error.code, @"sign_in_required"); + [self.plugin restorePreviousSignInWithCompletion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, FSIGoogleSignInErrorCodeNoAuthInKeychain); [expectation fulfill]; }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -#pragma mark - Sign in +#pragma mark - signIn - (void)testSignIn { self.plugin = [[FLTGoogleSignInPlugin alloc] initWithSignIn:self.mockSignIn @@ -285,12 +248,17 @@ - (void)testSignIn { OCMStub([mockUserProfile imageURLWithDimension:1337]) .andReturn([NSURL URLWithString:@"https://example.com/profile.png"]); + NSString *accessToken = @"mockAccessToken"; + NSString *serverAuthCode = @"mockAuthCode"; OCMStub([mockUser profile]).andReturn(mockUserProfile); OCMStub([mockUser userID]).andReturn(@"mockID"); + id mockAccessToken = OCMClassMock([GIDToken class]); + OCMStub([mockAccessToken tokenString]).andReturn(accessToken); + OCMStub([mockUser accessToken]).andReturn(mockAccessToken); id mockSignInResult = OCMClassMock([GIDSignInResult class]); OCMStub([mockSignInResult user]).andReturn(mockUser); - OCMStub([mockSignInResult serverAuthCode]).andReturn(@"mockAuthCode"); + OCMStub([mockSignInResult serverAuthCode]).andReturn(serverAuthCode); [self configureMock:[self.mockSignIn expect] forSignInWithHint:nil @@ -298,53 +266,53 @@ - (void)testSignIn { completion:[OCMArg invokeBlockWithArgs:mockSignInResult, [NSNull null], nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *error) { - XCTAssertNil(error); - XCTAssertEqualObjects(user.displayName, @"mockDisplay"); - XCTAssertEqualObjects(user.email, @"mock@example.com"); - XCTAssertEqualObjects(user.userId, @"mockID"); - XCTAssertEqualObjects(user.photoUrl, @"https://example.com/profile.png"); - XCTAssertEqualObjects(user.serverAuthCode, @"mockAuthCode"); - [expectation fulfill]; - }]; + [self.plugin signInWithScopeHint:@[] + nonce:nil + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + FSIUserData *user = result.success.user; + XCTAssertEqualObjects(user.displayName, @"mockDisplay"); + XCTAssertEqualObjects(user.email, @"mock@example.com"); + XCTAssertEqualObjects(user.userId, @"mockID"); + XCTAssertEqualObjects(user.photoUrl, @"https://example.com/profile.png"); + XCTAssertEqualObjects(result.success.accessToken, accessToken); + XCTAssertEqualObjects(result.success.serverAuthCode, serverAuthCode); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; - // Set in example app GoogleService-Info.plist. - XCTAssertEqualObjects( - self.plugin.configuration.clientID, - @"479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com"); - OCMVerifyAll(self.mockSignIn); } -- (void)testSignInWithInitializedScopes { +- (void)testSignInWithScopeHint { FlutterError *initializationError; - [self.plugin - initializeSignInWithParameters:[FSIInitParams makeWithScopes:@[ @"initial1", @"initial2" ] - hostedDomain:nil - clientId:nil - serverClientId:nil] - error:&initializationError]; + [self.plugin configureWithParameters:[FSIPlatformConfigurationParams makeWithClientId:nil + serverClientId:nil + hostedDomain:nil] + error:&initializationError]; id mockUser = OCMClassMock([GIDGoogleUser class]); OCMStub([mockUser userID]).andReturn(@"mockID"); id mockSignInResult = OCMClassMock([GIDSignInResult class]); OCMStub([mockSignInResult user]).andReturn(mockUser); + NSArray *requestedScopes = @[ @"scope1", @"scope2" ]; [self configureMock:[self.mockSignIn expect] forSignInWithHint:nil additionalScopes:[OCMArg checkWithBlock:^BOOL(NSArray *scopes) { - return [[NSSet setWithArray:scopes] - isEqualToSet:[NSSet setWithObjects:@"initial1", @"initial2", nil]]; + return [[NSSet setWithArray:scopes] isEqualToSet:[NSSet setWithArray:requestedScopes]]; }] completion:[OCMArg invokeBlockWithArgs:mockSignInResult, [NSNull null], nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *error) { - XCTAssertNil(error); - XCTAssertEqualObjects(user.userId, @"mockID"); - [expectation fulfill]; - }]; + [self.plugin signInWithScopeHint:requestedScopes + nonce:nil + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.error); + XCTAssertEqualObjects(result.success.user.userId, @"mockID"); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; OCMVerifyAll(self.mockSignIn); @@ -369,11 +337,14 @@ - (void)testSignInAlreadyGranted { completion:[OCMArg invokeBlockWithArgs:[NSNull null], sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *error) { - XCTAssertNil(error); - XCTAssertEqualObjects(user.userId, @"mockID"); - [expectation fulfill]; - }]; + [self.plugin signInWithScopeHint:@[] + nonce:nil + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.error); + XCTAssertEqualObjects(result.success.user.userId, @"mockID"); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } @@ -387,11 +358,16 @@ - (void)testSignInError { completion:[OCMArg invokeBlockWithArgs:[NSNull null], sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *error) { - XCTAssertNil(user); - XCTAssertEqualObjects(error.code, @"sign_in_canceled"); - [expectation fulfill]; - }]; + [self.plugin signInWithScopeHint:@[] + nonce:nil + completion:^(FSISignInResult *result, FlutterError *error) { + // Known errors from the SDK are returned as structured data, not + // FlutterError. + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, FSIGoogleSignInErrorCodeCanceled); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } @@ -403,22 +379,27 @@ - (void)testSignInException { .andThrow([NSException exceptionWithName:@"MockName" reason:@"MockReason" userInfo:nil]); __block FlutterError *error; - XCTAssertThrows( - [self.plugin signInWithCompletion:^(FSIUserData *user, FlutterError *signInError) { - XCTAssertNil(user); - error = signInError; - }]); + XCTAssertThrows([self.plugin + signInWithScopeHint:@[] + nonce:nil + completion:^(FSISignInResult *result, FlutterError *signInError) { + // Unexpected errors, such as runtime exceptions, are returned as FlutterError. + XCTAssertNil(result); + error = signInError; + }]); XCTAssertEqualObjects(error.code, @"google_sign_in"); XCTAssertEqualObjects(error.message, @"MockReason"); XCTAssertEqualObjects(error.details, @"MockName"); } -#pragma mark - Get tokens +#pragma mark - refreshedAuthorizationTokens -- (void)testGetTokens { - id mockUser = OCMClassMock([GIDGoogleUser class]); +- (void)testRefreshTokens { + id mockUser = [self signedInMockUser]; + NSString *userIdentifier = ((GIDGoogleUser *)mockUser).userID; id mockUserResponse = OCMClassMock([GIDGoogleUser class]); + OCMStub([mockUserResponse userID]).andReturn(userIdentifier); id mockIdToken = OCMClassMock([GIDToken class]); OCMStub([mockIdToken tokenString]).andReturn(@"mockIdToken"); @@ -431,21 +412,39 @@ - (void)testGetTokens { [[mockUser stub] refreshTokensIfNeededWithCompletion:[OCMArg invokeBlockWithArgs:mockUserResponse, [NSNull null], nil]]; - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin getAccessTokenWithCompletion:^(FSITokenData *token, FlutterError *error) { - XCTAssertNil(error); - XCTAssertEqualObjects(token.idToken, @"mockIdToken"); - XCTAssertEqualObjects(token.accessToken, @"mockAccessToken"); - [expectation fulfill]; - }]; + [self.plugin + refreshedAuthorizationTokensForUser:userIdentifier + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.error); + XCTAssertEqualObjects(result.success.user.idToken, @"mockIdToken"); + XCTAssertEqualObjects(result.success.accessToken, + @"mockAccessToken"); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -- (void)testGetTokensNoAuthKeychainError { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); +- (void)testRefreshTokensUnkownUser { + XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; + [self.plugin + refreshedAuthorizationTokensForUser:@"unknownUser" + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, + FSIGoogleSignInErrorCodeUserMismatch); + XCTAssertEqualObjects(result.error.message, + @"The user is no longer signed in."); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testRefreshTokensNoAuthKeychainError { + id mockUser = [self signedInMockUser]; NSError *sdkError = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeHasNoAuthInKeychain @@ -454,18 +453,19 @@ - (void)testGetTokensNoAuthKeychainError { sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin getAccessTokenWithCompletion:^(FSITokenData *token, FlutterError *error) { - XCTAssertNil(token); - XCTAssertEqualObjects(error.code, @"sign_in_required"); - XCTAssertEqualObjects(error.message, kGIDSignInErrorDomain); - [expectation fulfill]; - }]; + [self.plugin refreshedAuthorizationTokensForUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, + FSIGoogleSignInErrorCodeNoAuthInKeychain); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -- (void)testGetTokensCancelledError { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); +- (void)testRefreshTokensCancelledError { + id mockUser = [self signedInMockUser]; NSError *sdkError = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeCanceled @@ -474,18 +474,19 @@ - (void)testGetTokensCancelledError { sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin getAccessTokenWithCompletion:^(FSITokenData *token, FlutterError *error) { - XCTAssertNil(token); - XCTAssertEqualObjects(error.code, @"sign_in_canceled"); - XCTAssertEqualObjects(error.message, kGIDSignInErrorDomain); - [expectation fulfill]; - }]; + [self.plugin refreshedAuthorizationTokensForUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, + FSIGoogleSignInErrorCodeCanceled); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -- (void)testGetTokensURLError { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); +- (void)testRefreshTokensURLError { + id mockUser = [self signedInMockUser]; NSError *sdkError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut @@ -494,49 +495,53 @@ - (void)testGetTokensURLError { sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin getAccessTokenWithCompletion:^(FSITokenData *token, FlutterError *error) { - XCTAssertNil(token); - XCTAssertEqualObjects(error.code, @"network_error"); - XCTAssertEqualObjects(error.message, NSURLErrorDomain); - [expectation fulfill]; - }]; + [self.plugin refreshedAuthorizationTokensForUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(result.error); + XCTAssertNil(result.success); + NSString *expectedCode = [NSString + stringWithFormat:@"%@: %ld", NSURLErrorDomain, + NSURLErrorTimedOut]; + XCTAssertEqualObjects(error.code, expectedCode); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -- (void)testGetTokensUnknownError { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); +- (void)testRefreshTokensUnknownError { + id mockUser = [self signedInMockUser]; NSError *sdkError = [NSError errorWithDomain:@"BogusDomain" code:42 userInfo:nil]; [[mockUser stub] refreshTokensIfNeededWithCompletion:[OCMArg invokeBlockWithArgs:[NSNull null], sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin getAccessTokenWithCompletion:^(FSITokenData *token, FlutterError *error) { - XCTAssertNil(token); - XCTAssertEqualObjects(error.code, @"sign_in_failed"); - XCTAssertEqualObjects(error.message, @"BogusDomain"); - [expectation fulfill]; - }]; + [self.plugin refreshedAuthorizationTokensForUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(result.success); + XCTAssertEqualObjects(error.code, @"BogusDomain: 42"); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -#pragma mark - Request scopes +#pragma mark - addScopes - (void)testRequestScopesResultErrorIfNotSignedIn { XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin requestScopes:@[ @"mockScope1" ] - completion:^(NSNumber *success, FlutterError *error) { - XCTAssertNil(success); - XCTAssertEqualObjects(error.code, @"sign_in_required"); - [expectation fulfill]; - }]; + [self.plugin addScopes:@[ @"mockScope1" ] + forUser:@"unknownUser" + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, FSIGoogleSignInErrorCodeUserMismatch); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testRequestScopesIfNoMissingScope { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + id mockUser = [self signedInMockUser]; NSError *sdkError = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeScopesAlreadyGranted @@ -546,18 +551,19 @@ - (void)testRequestScopesIfNoMissingScope { completion:[OCMArg invokeBlockWithArgs:[NSNull null], sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin requestScopes:@[ @"mockScope1" ] - completion:^(NSNumber *success, FlutterError *error) { - XCTAssertNil(error); - XCTAssertTrue(success.boolValue); - [expectation fulfill]; - }]; + [self.plugin addScopes:@[ @"mockScope1" ] + forUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, FSIGoogleSignInErrorCodeScopesAlreadyGranted); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testRequestScopesResultErrorIfMismatchingUser { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + id mockUser = [self signedInMockUser]; NSError *sdkError = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeMismatchWithCurrentUser @@ -567,18 +573,19 @@ - (void)testRequestScopesResultErrorIfMismatchingUser { completion:[OCMArg invokeBlockWithArgs:[NSNull null], sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin requestScopes:@[ @"mockScope1" ] - completion:^(NSNumber *success, FlutterError *error) { - XCTAssertNil(success); - XCTAssertEqualObjects(error.code, @"mismatch_user"); - [expectation fulfill]; - }]; + [self.plugin addScopes:@[ @"mockScope1" ] + forUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(error); + XCTAssertNil(result.success); + XCTAssertEqual(result.error.type, FSIGoogleSignInErrorCodeUserMismatch); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testRequestScopesWithUnknownError { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + id mockUser = [self signedInMockUser]; NSError *sdkError = [NSError errorWithDomain:@"BogusDomain" code:42 userInfo:nil]; [self configureMock:[mockUser stub] @@ -586,110 +593,42 @@ - (void)testRequestScopesWithUnknownError { completion:[OCMArg invokeBlockWithArgs:[NSNull null], sdkError, nil]]; XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin requestScopes:@[ @"mockScope1" ] - completion:^(NSNumber *success, FlutterError *error) { - XCTAssertNil(error); - XCTAssertFalse(success.boolValue); - [expectation fulfill]; - }]; + [self.plugin addScopes:@[ @"mockScope1" ] + forUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(result); + XCTAssertEqualObjects(error.code, @"BogusDomain: 42"); + [expectation fulfill]; + }]; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testRequestScopesException { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); + id mockUser = [self signedInMockUser]; OCMExpect([self configureMock:mockUser forAddScopes:@[] completion:OCMOCK_ANY]) .andThrow([NSException exceptionWithName:@"MockName" reason:@"MockReason" userInfo:nil]); - [self.plugin requestScopes:@[] - completion:^(NSNumber *success, FlutterError *error) { - XCTAssertNil(success); - XCTAssertEqualObjects(error.code, @"request_scopes"); - XCTAssertEqualObjects(error.message, @"MockReason"); - XCTAssertEqualObjects(error.details, @"MockName"); - }]; -} - -- (void)testRequestScopesReturnsFalseIfOnlySubsetGranted { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); - NSArray *requestedScopes = @[ @"mockScope1", @"mockScope2" ]; - - // Only grant one of the two requested scopes. - id mockSignInResult = OCMClassMock([GIDSignInResult class]); - OCMStub([mockUser grantedScopes]).andReturn(@[ @"mockScope1" ]); - OCMStub([mockSignInResult user]).andReturn(mockUser); - - [self configureMock:[mockUser stub] - forAddScopes:requestedScopes - completion:[OCMArg invokeBlockWithArgs:mockSignInResult, [NSNull null], nil]]; - - XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin requestScopes:requestedScopes - completion:^(NSNumber *success, FlutterError *error) { - XCTAssertNil(error); - XCTAssertFalse(success.boolValue); - [expectation fulfill]; - }]; - [self waitForExpectationsWithTimeout:5.0 handler:nil]; + [self.plugin addScopes:@[] + forUser:((GIDGoogleUser *)mockUser).userID + completion:^(FSISignInResult *result, FlutterError *error) { + XCTAssertNil(result); + XCTAssertEqualObjects(error.code, @"request_scopes"); + XCTAssertEqualObjects(error.message, @"MockReason"); + XCTAssertEqualObjects(error.details, @"MockName"); + }]; } -- (void)testRequestsInitializedScopes { - id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); - - FSIInitParams *params = [FSIInitParams makeWithScopes:@[ @"initial1", @"initial2" ] - hostedDomain:nil - clientId:nil - serverClientId:nil]; - FlutterError *initializationError; - [self.plugin initializeSignInWithParameters:params error:&initializationError]; - XCTAssertNil(initializationError); - - // Include one of the initially requested scopes. - NSArray *addedScopes = @[ @"initial1", @"addScope1", @"addScope2" ]; - - [self.plugin requestScopes:addedScopes - completion:^(NSNumber *success, FlutterError *error){ - }]; - - // All four scopes are requested. - [self configureMock:[mockUser verify] - forAddScopes:[OCMArg checkWithBlock:^BOOL(NSArray *scopes) { - return [[NSSet setWithArray:scopes] - isEqualToSet:[NSSet setWithObjects:@"initial1", @"initial2", @"addScope1", - @"addScope2", nil]]; - }] - completion:OCMOCK_ANY]; -} +#pragma mark - Utils -- (void)testRequestScopesReturnsTrueIfGranted { +- (id)signedInMockUser { + NSString *identifier = @"mockID"; id mockUser = OCMClassMock([GIDGoogleUser class]); - OCMStub([self.mockSignIn currentUser]).andReturn(mockUser); - NSArray *requestedScopes = @[ @"mockScope1", @"mockScope2" ]; - - // Grant both of the requested scopes. - id mockSignInResult = OCMClassMock([GIDSignInResult class]); - OCMStub([mockUser grantedScopes]).andReturn(requestedScopes); - OCMStub([mockSignInResult user]).andReturn(mockUser); - - [self configureMock:[mockUser stub] - forAddScopes:requestedScopes - completion:[OCMArg invokeBlockWithArgs:mockSignInResult, [NSNull null], nil]]; - - XCTestExpectation *expectation = [self expectationWithDescription:@"completion called"]; - [self.plugin requestScopes:requestedScopes - completion:^(NSNumber *success, FlutterError *error) { - XCTAssertNil(error); - XCTAssertTrue(success.boolValue); - [expectation fulfill]; - }]; - [self waitForExpectationsWithTimeout:5.0 handler:nil]; + OCMStub([mockUser userID]).andReturn(identifier); + self.plugin.usersByIdentifier[identifier] = mockUser; + return mockUser; } -#pragma mark - Utils - - (void)configureMock:(id)mock forAddScopes:(NSArray *)scopes completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, diff --git a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/FLTGoogleSignInPlugin.m b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/FLTGoogleSignInPlugin.m index 79b807788800..4f058fa8b09b 100644 --- a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/FLTGoogleSignInPlugin.m +++ b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/FLTGoogleSignInPlugin.m @@ -14,7 +14,7 @@ static NSString *const kServerClientIdKey = @"SERVER_CLIENT_ID"; -static NSDictionary *loadGoogleServiceInfo(void) { +static NSDictionary *FSILoadGoogleServiceInfo(void) { NSString *plistPath = [[NSBundle mainBundle] pathForResource:@"GoogleService-Info" ofType:@"plist"]; if (plistPath) { @@ -23,35 +23,83 @@ return nil; } -// These error codes must match with ones declared on Android and Dart sides. -static NSString *const kErrorReasonSignInRequired = @"sign_in_required"; -static NSString *const kErrorReasonSignInCanceled = @"sign_in_canceled"; -static NSString *const kErrorReasonNetworkError = @"network_error"; -static NSString *const kErrorReasonSignInFailed = @"sign_in_failed"; - -static FlutterError *getFlutterError(NSError *error) { - NSString *errorCode; - if (error.code == kGIDSignInErrorCodeHasNoAuthInKeychain) { - errorCode = kErrorReasonSignInRequired; - } else if (error.code == kGIDSignInErrorCodeCanceled) { - errorCode = kErrorReasonSignInCanceled; - } else if ([error.domain isEqualToString:NSURLErrorDomain]) { - errorCode = kErrorReasonNetworkError; +/// Deep-converts values to something that can be safely encoded with the standard message codec, +/// for use in making NSError userInfo values safe to send as FlutterError details. +/// +/// Unexpected types are converted to a +static id FSISanitizedUserInfo(id value) { + if ([value isKindOfClass:[NSError class]]) { + NSError *error = value; + return @{ + @"domain" : error.domain, + @"code" : [NSString stringWithFormat:@"%ld", (long)error.code], + @"localizedDescription" : error.localizedDescription, + @"userInfo" : FSISanitizedUserInfo(error.userInfo), + }; + } else if ([value isKindOfClass:[NSString class]]) { + return value; + } else if ([value isKindOfClass:[NSURL class]]) { + return [value absoluteString]; + } else if ([value isKindOfClass:[NSNumber class]]) { + return value; + } else if ([value isKindOfClass:[NSArray class]]) { + NSArray *array = value; + NSMutableArray *safeValues = [[NSMutableArray alloc] initWithCapacity:array.count]; + for (id item in array) { + [safeValues addObject:FSISanitizedUserInfo(item)]; + } + return safeValues; + } else if ([value isKindOfClass:[NSDictionary class]]) { + NSDictionary *dict = value; + NSMutableDictionary *safeValues = [[NSMutableDictionary alloc] initWithCapacity:dict.count]; + for (id key in dict) { + safeValues[key] = FSISanitizedUserInfo(dict[key]); + } + return safeValues; } else { - errorCode = kErrorReasonSignInFailed; + return [NSString stringWithFormat:@"[Unsupported type: %@]", NSStringFromClass([value class])]; + } +} + +/// Maps an NSError to a corresponding FlutterError. +/// +/// This should only be used when an error can't be recognized and mapped to a +/// GoogleSignInErrorCode. +static FlutterError *FSIFlutterErrorForNSError(NSError *error) { + return [FlutterError + errorWithCode:[NSString stringWithFormat:@"%@: %ld", error.domain, (long)error.code] + message:error.localizedDescription + details:FSISanitizedUserInfo(error.userInfo)]; +} + +/// Maps a GIDSignInErrorCode to the corresponding Pigeon GoogleSignInErrorCode +static FSIGoogleSignInErrorCode FSIPigeonErrorCodeForGIDSignInErrorCode(NSInteger code) { + switch (code) { + case kGIDSignInErrorCodeKeychain: + return FSIGoogleSignInErrorCodeKeychainError; + case kGIDSignInErrorCodeCanceled: + return FSIGoogleSignInErrorCodeCanceled; + case kGIDSignInErrorCodeHasNoAuthInKeychain: + return FSIGoogleSignInErrorCodeNoAuthInKeychain; + case kGIDSignInErrorCodeEMM: + return FSIGoogleSignInErrorCodeEemError; + case kGIDSignInErrorCodeScopesAlreadyGranted: + return FSIGoogleSignInErrorCodeScopesAlreadyGranted; + case kGIDSignInErrorCodeMismatchWithCurrentUser: + return FSIGoogleSignInErrorCodeUserMismatch; + case kGIDSignInErrorCodeUnknown: + default: + return FSIGoogleSignInErrorCodeUnknown; } - return [FlutterError errorWithCode:errorCode - message:error.domain - details:error.localizedDescription]; } @interface FLTGoogleSignInPlugin () // The contents of GoogleService-Info.plist, if it exists. -@property(strong, nullable) NSDictionary *googleServiceProperties; +@property(nonatomic, nullable) NSDictionary *googleServiceProperties; // The plugin registrar, for querying views. -@property(strong, nonnull) id registrar; +@property(nonatomic) id registrar; @end @@ -60,7 +108,7 @@ @implementation FLTGoogleSignInPlugin + (void)registerWithRegistrar:(NSObject *)registrar { FLTGoogleSignInPlugin *instance = [[FLTGoogleSignInPlugin alloc] initWithRegistrar:registrar]; [registrar addApplicationDelegate:instance]; - FSIGoogleSignInApiSetup(registrar.messenger, instance); + SetUpFSIGoogleSignInApi(registrar.messenger, instance); } - (instancetype)initWithRegistrar:(NSObject *)registrar { @@ -71,7 +119,7 @@ - (instancetype)initWithSignIn:(GIDSignIn *)signIn registrar:(NSObject *)registrar { return [self initWithSignIn:signIn registrar:registrar - googleServiceProperties:loadGoogleServiceInfo()]; + googleServiceProperties:FSILoadGoogleServiceInfo()]; } - (instancetype)initWithSignIn:(GIDSignIn *)signIn @@ -82,11 +130,11 @@ - (instancetype)initWithSignIn:(GIDSignIn *)signIn _signIn = signIn; _registrar = registrar; _googleServiceProperties = googleServiceProperties; + _usersByIdentifier = [[NSMutableDictionary alloc] init]; // On the iOS simulator, we get "Broken pipe" errors after sign-in for some // unknown reason. We can avoid crashing the app by ignoring them. signal(SIGPIPE, SIG_IGN); - _requestedScopes = [[NSSet alloc] init]; } return self; } @@ -101,7 +149,7 @@ - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDiction - (BOOL)handleOpenURLs:(NSArray *)urls { BOOL handled = NO; for (NSURL *url in urls) { - handled = handled || [self.signIn handleURL:url]; + handled = [self.signIn handleURL:url] || handled; } return handled; } @@ -109,62 +157,42 @@ - (BOOL)handleOpenURLs:(NSArray *)urls { #pragma mark - FSIGoogleSignInApi -- (void)initializeSignInWithParameters:(nonnull FSIInitParams *)params - error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { +- (void)configureWithParameters:(FSIPlatformConfigurationParams *)params + error:(FlutterError *_Nullable *_Nonnull)error { + // If configuration information was passed from Dart, or present in GoogleService-Info.plist, + // use that. Otherwise, keep the default configuration, which GIDSignIn will automatically + // populate from Info.plist values (the recommended configuration method). GIDConfiguration *configuration = [self configurationWithClientIdentifier:params.clientId serverClientIdentifier:params.serverClientId hostedDomain:params.hostedDomain]; - self.requestedScopes = [NSSet setWithArray:params.scopes]; - if (configuration != nil) { - self.configuration = configuration; + if (configuration) { + self.signIn.configuration = configuration; } } -- (void)signInSilentlyWithCompletion:(nonnull void (^)(FSIUserData *_Nullable, - FlutterError *_Nullable))completion { +- (void)restorePreviousSignInWithCompletion:(nonnull void (^)(FSISignInResult *_Nullable, + FlutterError *_Nullable))completion { + __weak typeof(self) weakSelf = self; [self.signIn restorePreviousSignInWithCompletion:^(GIDGoogleUser *_Nullable user, NSError *_Nullable error) { - if (user != nil) { - [self didSignInForUser:user withServerAuthCode:nil completion:completion]; - } else { - // Forward all errors and let Dart side decide how to handle. - completion(nil, getFlutterError(error)); - } + [weakSelf handleAuthResultWithUser:user serverAuthCode:nil error:error completion:completion]; }]; } -- (nullable NSNumber *)isSignedInWithError: - (FlutterError *_Nullable __autoreleasing *_Nonnull)error { - return @([self.signIn hasPreviousSignIn]); -} - -- (void)signInWithCompletion:(nonnull void (^)(FSIUserData *_Nullable, - FlutterError *_Nullable))completion { +- (void)signInWithScopeHint:(NSArray *)scopeHint + nonce:(nullable NSString *)nonce + completion:(nonnull void (^)(FSISignInResult *_Nullable, + FlutterError *_Nullable))completion { @try { - // If the configuration settings are passed from the Dart API, use those. - // Otherwise, use settings from the GoogleService-Info.plist if available. - // If neither are available, do not set the configuration - GIDSignIn will automatically use - // settings from the Info.plist (which is the recommended method). - if (!self.configuration && self.googleServiceProperties) { - self.configuration = [self configurationWithClientIdentifier:nil - serverClientIdentifier:nil - hostedDomain:nil]; - } - if (self.configuration) { - self.signIn.configuration = self.configuration; - } - + __weak typeof(self) weakSelf = self; [self signInWithHint:nil - additionalScopes:self.requestedScopes.allObjects + additionalScopes:scopeHint + nonce:nonce completion:^(GIDSignInResult *_Nullable signInResult, NSError *_Nullable error) { - if (signInResult) { - [self didSignInForUser:signInResult.user - withServerAuthCode:signInResult.serverAuthCode - completion:completion]; - } else { - // Forward all errors and let Dart side decide how to handle. - completion(nil, getFlutterError(error)); - } + [weakSelf handleAuthResultWithUser:signInResult.user + serverAuthCode:signInResult.serverAuthCode + error:error + completion:completion]; }]; } @catch (NSException *e) { completion(nil, [FlutterError errorWithCode:@"google_sign_in" message:e.reason details:e.name]); @@ -172,79 +200,84 @@ - (void)signInWithCompletion:(nonnull void (^)(FSIUserData *_Nullable, } } -- (void)getAccessTokenWithCompletion:(nonnull void (^)(FSITokenData *_Nullable, - FlutterError *_Nullable))completion { - GIDGoogleUser *currentUser = self.signIn.currentUser; - [currentUser refreshTokensIfNeededWithCompletion:^(GIDGoogleUser *_Nullable user, - NSError *_Nullable error) { - if (error) { - completion(nil, getFlutterError(error)); - } else { - completion([FSITokenData makeWithIdToken:user.idToken.tokenString - accessToken:user.accessToken.tokenString], - nil); - } +- (void)refreshedAuthorizationTokensForUser:(NSString *)userId + completion:(nonnull void (^)(FSISignInResult *_Nullable, + FlutterError *_Nullable))completion { + GIDGoogleUser *user = self.usersByIdentifier[userId]; + if (user == nil) { + completion( + [FSISignInResult + makeWithSuccess:nil + error:[FSISignInFailure makeWithType:FSIGoogleSignInErrorCodeUserMismatch + message:@"The user is no longer signed in." + details:nil]], + nil); + return; + } + + __weak typeof(self) weakSelf = self; + [user refreshTokensIfNeededWithCompletion:^(GIDGoogleUser *_Nullable refreshedUser, + NSError *_Nullable error) { + [weakSelf handleAuthResultWithUser:refreshedUser + serverAuthCode:nil + error:error + completion:completion]; }]; } +- (void)addScopes:(nonnull NSArray *)scopes + forUser:(NSString *)userId + completion: + (nonnull void (^)(FSISignInResult *_Nullable, FlutterError *_Nullable))completion { + GIDGoogleUser *user = self.usersByIdentifier[userId]; + if (user == nil) { + completion( + [FSISignInResult + makeWithSuccess:nil + error:[FSISignInFailure makeWithType:FSIGoogleSignInErrorCodeUserMismatch + message:@"The user is no longer signed in." + details:nil]], + nil); + return; + } + + @try { + __weak typeof(self) weakSelf = self; + [self addScopes:scopes + forGoogleSignInUser:user + completion:^(GIDSignInResult *_Nullable signInResult, NSError *_Nullable error) { + [weakSelf handleAuthResultWithUser:signInResult.user + serverAuthCode:signInResult.serverAuthCode + error:error + completion:completion]; + }]; + } @catch (NSException *e) { + completion(nil, [FlutterError errorWithCode:@"request_scopes" message:e.reason details:e.name]); + } +} + - (void)signOutWithError:(FlutterError *_Nullable *_Nonnull)error { [self.signIn signOut]; + [self.usersByIdentifier removeAllObjects]; } - (void)disconnectWithCompletion:(nonnull void (^)(FlutterError *_Nullable))completion { [self.signIn disconnectWithCompletion:^(NSError *_Nullable error) { - // TODO(stuartmorgan): This preserves the pre-Pigeon-migration behavior, but it's unclear why - // 'error' is being ignored here. - completion(nil); + completion(error ? FSIFlutterErrorForNSError(error) : nil); }]; } -- (void)requestScopes:(nonnull NSArray *)scopes - completion:(nonnull void (^)(NSNumber *_Nullable, FlutterError *_Nullable))completion { - self.requestedScopes = [self.requestedScopes setByAddingObjectsFromArray:scopes]; - NSSet *requestedScopes = self.requestedScopes; - - @try { - GIDGoogleUser *currentUser = self.signIn.currentUser; - if (currentUser == nil) { - completion(nil, [FlutterError errorWithCode:@"sign_in_required" - message:@"No account to grant scopes." - details:nil]); - } - [self addScopes:requestedScopes.allObjects - completion:^(GIDSignInResult *_Nullable signInResult, NSError *_Nullable addedScopeError) { - BOOL granted = NO; - FlutterError *error = nil; - - if ([addedScopeError.domain isEqualToString:kGIDSignInErrorDomain] && - addedScopeError.code == kGIDSignInErrorCodeMismatchWithCurrentUser) { - error = [FlutterError errorWithCode:@"mismatch_user" - message:@"There is an operation on a previous " - @"user. Try signing in again." - details:nil]; - } else if ([addedScopeError.domain isEqualToString:kGIDSignInErrorDomain] && - addedScopeError.code == kGIDSignInErrorCodeScopesAlreadyGranted) { - // Scopes already granted, report success. - granted = YES; - } else if (signInResult.user) { - NSSet *grantedScopes = - [NSSet setWithArray:signInResult.user.grantedScopes]; - granted = [requestedScopes isSubsetOfSet:grantedScopes]; - } - completion(error == nil ? @(granted) : nil, error); - }]; - } @catch (NSException *e) { - completion(nil, [FlutterError errorWithCode:@"request_scopes" message:e.reason details:e.name]); - } -} - #pragma mark - private methods // Wraps the iOS and macOS sign in display methods. - (void)signInWithHint:(nullable NSString *)hint additionalScopes:(nullable NSArray *)additionalScopes - completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, - NSError *_Nullable error))completion { + nonce:(nullable NSString *)nonce + completion:(void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion { + // TODO(stuartmorgan): Add the nonce parameter to the calls below once it's available; it was + // added after 8.0, and based on https://github.com/google/GoogleSignIn-iOS/releases appears to + // be slated for an 8.1 release. See https://github.com/flutter/flutter/issues/85439. #if TARGET_OS_OSX [self.signIn signInWithPresentingWindow:self.registrar.view.window hint:hint @@ -260,15 +293,13 @@ - (void)signInWithHint:(nullable NSString *)hint // Wraps the iOS and macOS scope addition methods. - (void)addScopes:(NSArray *)scopes - completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, - NSError *_Nullable error))completion { - GIDGoogleUser *currentUser = self.signIn.currentUser; + forGoogleSignInUser:(GIDGoogleUser *)user + completion:(void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion { #if TARGET_OS_OSX - [currentUser addScopes:scopes presentingWindow:self.registrar.view.window completion:completion]; + [user addScopes:scopes presentingWindow:self.registrar.view.window completion:completion]; #else - [currentUser addScopes:scopes - presentingViewController:[self topViewController] - completion:completion]; + [user addScopes:scopes presentingViewController:[self topViewController] completion:completion]; #endif } @@ -291,23 +322,54 @@ - (GIDConfiguration *)configurationWithClientIdentifier:(NSString *)runtimeClien openIDRealm:nil]; } +- (void)handleAuthResultWithUser:(nullable GIDGoogleUser *)user + serverAuthCode:(nullable NSString *)serverAuthCode + error:(nullable NSError *)error + completion:(void (^)(FSISignInResult *_Nullable, + FlutterError *_Nullable))completion { + if (user) { + [self didSignInForUser:user withServerAuthCode:serverAuthCode completion:completion]; + } else { + // Convert expected errors into structured failure return, and everything else + // into a generic error. + if (error.domain == kGIDSignInErrorDomain) { + completion( + [FSISignInResult + makeWithSuccess:nil + error:[FSISignInFailure + makeWithType:FSIPigeonErrorCodeForGIDSignInErrorCode(error.code) + message:error.localizedDescription + details:FSISanitizedUserInfo(error.userInfo)]], + nil); + } else { + completion(nil, FSIFlutterErrorForNSError(error)); + } + } +} + - (void)didSignInForUser:(GIDGoogleUser *)user - withServerAuthCode:(NSString *_Nullable)serverAuthCode - completion: - (nonnull void (^)(FSIUserData *_Nullable, FlutterError *_Nullable))completion { - NSURL *photoUrl; + withServerAuthCode:(nullable NSString *)serverAuthCode + completion:(void (^)(FSISignInResult *_Nullable, FlutterError *_Nullable))completion { + self.usersByIdentifier[user.userID] = user; + + NSURL *photoURL; if (user.profile.hasImage) { // Placeholder that will be replaced by on the Dart side based on screen size. - photoUrl = [user.profile imageURLWithDimension:1337]; + photoURL = [user.profile imageURLWithDimension:1337]; } - completion([FSIUserData makeWithDisplayName:user.profile.name - email:user.profile.email - userId:user.userID - photoUrl:photoUrl.absoluteString - serverAuthCode:serverAuthCode - idToken:user.idToken.tokenString], - nil); + FSIUserData *userData = [FSIUserData makeWithDisplayName:user.profile.name + email:user.profile.email + userId:user.userID + photoUrl:photoURL.absoluteString + idToken:user.idToken.tokenString]; + FSISignInResult *result = + [FSISignInResult makeWithSuccess:[FSISignInSuccess makeWithUser:userData + accessToken:user.accessToken.tokenString + grantedScopes:user.grantedScopes + serverAuthCode:serverAuthCode] + error:nil]; + completion(result, nil); } #if TARGET_OS_IOS diff --git a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/FLTGoogleSignInPlugin_Test.h b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/FLTGoogleSignInPlugin_Test.h index b145d028ad97..430863556d59 100644 --- a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/FLTGoogleSignInPlugin_Test.h +++ b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/FLTGoogleSignInPlugin_Test.h @@ -15,20 +15,13 @@ NS_ASSUME_NONNULL_BEGIN /// Methods exposed for unit testing. @interface FLTGoogleSignInPlugin () -// Configuration wrapping Google Cloud Console, Google Apps, OpenID, -// and other initialization metadata. -@property(strong) GIDConfiguration *configuration; - -// Permissions requested during at sign in "init" method call -// unioned with scopes requested later with incremental authorization -// "requestScopes" method call. -// The "email" and "profile" base scopes are always implicitly requested. -@property(copy) NSSet *requestedScopes; - // Instance used to manage Google Sign In authentication including // sign in, sign out, and requesting additional scopes. @property(strong, readonly) GIDSignIn *signIn; +// A mapping of user IDs to GIDGoogleUser instances to use for follow-up calls. +@property(nonatomic) NSMutableDictionary *usersByIdentifier; + /// Inject @c FlutterPluginRegistrar for testing. - (instancetype)initWithRegistrar:(NSObject *)registrar; diff --git a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/messages.g.h b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/messages.g.h index 745c1ec91802..3f99f2785d05 100644 --- a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/messages.g.h +++ b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/messages.g.h @@ -1,7 +1,7 @@ // 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. -// Autogenerated from Pigeon (v11.0.1), do not edit directly. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. // See also: https://pub.dev/packages/pigeon #import @@ -13,29 +13,55 @@ NS_ASSUME_NONNULL_BEGIN -@class FSIInitParams; +/// Enum mapping of known codes from +/// https://developers.google.com/identity/sign-in/ios/reference/Enums/GIDSignInErrorCode +typedef NS_ENUM(NSUInteger, FSIGoogleSignInErrorCode) { + /// Either the underlying kGIDSignInErrorCodeUnknown, or a code that isn't + /// a known code mapped to a value below. + FSIGoogleSignInErrorCodeUnknown = 0, + /// kGIDSignInErrorCodeKeychain; an error reading or writing to keychain. + FSIGoogleSignInErrorCodeKeychainError = 1, + /// kGIDSignInErrorCodeHasNoAuthInKeychain; no auth present in the keychain. + /// + /// For restorePreviousSignIn, this indicates that there is no sign in to + /// restore. + FSIGoogleSignInErrorCodeNoAuthInKeychain = 2, + /// kGIDSignInErrorCodeCanceled; the request was canceled by the user. + FSIGoogleSignInErrorCodeCanceled = 3, + /// kGIDSignInErrorCodeEMM; an enterprise management error occurred. + FSIGoogleSignInErrorCodeEemError = 4, + /// kGIDSignInErrorCodeScopesAlreadyGranted; the requested scopes have already + /// been granted. + FSIGoogleSignInErrorCodeScopesAlreadyGranted = 5, + /// kGIDSignInErrorCodeMismatchWithCurrentUser; an operation was requested on + /// a non-current user. + FSIGoogleSignInErrorCodeUserMismatch = 6, +}; + +/// Wrapper for FSIGoogleSignInErrorCode to allow for nullability. +@interface FSIGoogleSignInErrorCodeBox : NSObject +@property(nonatomic, assign) FSIGoogleSignInErrorCode value; +- (instancetype)initWithValue:(FSIGoogleSignInErrorCode)value; +@end + +@class FSIPlatformConfigurationParams; @class FSIUserData; -@class FSITokenData; +@class FSISignInResult; +@class FSISignInFailure; +@class FSISignInSuccess; -/// Pigeon version of SignInInitParams. -/// -/// See SignInInitParams for details. -@interface FSIInitParams : NSObject -/// `init` unavailable to enforce nonnull fields, see the `make` class method. -- (instancetype)init NS_UNAVAILABLE; -+ (instancetype)makeWithScopes:(NSArray *)scopes - hostedDomain:(nullable NSString *)hostedDomain - clientId:(nullable NSString *)clientId - serverClientId:(nullable NSString *)serverClientId; -@property(nonatomic, strong) NSArray *scopes; -@property(nonatomic, copy, nullable) NSString *hostedDomain; +@interface FSIPlatformConfigurationParams : NSObject ++ (instancetype)makeWithClientId:(nullable NSString *)clientId + serverClientId:(nullable NSString *)serverClientId + hostedDomain:(nullable NSString *)hostedDomain; @property(nonatomic, copy, nullable) NSString *clientId; @property(nonatomic, copy, nullable) NSString *serverClientId; +@property(nonatomic, copy, nullable) NSString *hostedDomain; @end -/// Pigeon version of GoogleSignInUserData. +/// Pigeon version of GoogleSignInUserData + AuthenticationTokenData. /// -/// See GoogleSignInUserData for details. +/// See GoogleSignInUserData and AuthenticationTokenData for details. @interface FSIUserData : NSObject /// `init` unavailable to enforce nonnull fields, see the `make` class method. - (instancetype)init NS_UNAVAILABLE; @@ -43,55 +69,97 @@ NS_ASSUME_NONNULL_BEGIN email:(NSString *)email userId:(NSString *)userId photoUrl:(nullable NSString *)photoUrl - serverAuthCode:(nullable NSString *)serverAuthCode idToken:(nullable NSString *)idToken; @property(nonatomic, copy, nullable) NSString *displayName; @property(nonatomic, copy) NSString *email; @property(nonatomic, copy) NSString *userId; @property(nonatomic, copy, nullable) NSString *photoUrl; -@property(nonatomic, copy, nullable) NSString *serverAuthCode; @property(nonatomic, copy, nullable) NSString *idToken; @end -/// Pigeon version of GoogleSignInTokenData. +/// The response from an auth call. +@interface FSISignInResult : NSObject ++ (instancetype)makeWithSuccess:(nullable FSISignInSuccess *)success + error:(nullable FSISignInFailure *)error; +/// The success result, if any. /// -/// See GoogleSignInTokenData for details. -@interface FSITokenData : NSObject -+ (instancetype)makeWithIdToken:(nullable NSString *)idToken - accessToken:(nullable NSString *)accessToken; -@property(nonatomic, copy, nullable) NSString *idToken; -@property(nonatomic, copy, nullable) NSString *accessToken; +/// Exactly one of success and error will be non-nil. +@property(nonatomic, strong, nullable) FSISignInSuccess *success; +/// The error result, if any. +/// +/// Exactly one of success and error will be non-nil. +@property(nonatomic, strong, nullable) FSISignInFailure *error; @end -/// The codec used by FSIGoogleSignInApi. -NSObject *FSIGoogleSignInApiGetCodec(void); +/// An sign in failure. +@interface FSISignInFailure : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithType:(FSIGoogleSignInErrorCode)type + message:(nullable NSString *)message + details:(nullable id)details; +/// The type of failure. +@property(nonatomic, assign) FSIGoogleSignInErrorCode type; +/// The message associated with the failure, if any. +@property(nonatomic, copy, nullable) NSString *message; +/// Extra details about the failure, if any. +@property(nonatomic, strong, nullable) id details; +@end + +/// A successful auth result. +/// +/// Corresponds to the information in a native GIDSignInResult. Because of the +/// structure of the Google Sign In SDK, this has information corresponding to +/// both authn and authz steps, even though incremental authorization is +/// supported. +@interface FSISignInSuccess : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithUser:(FSIUserData *)user + accessToken:(NSString *)accessToken + grantedScopes:(NSArray *)grantedScopes + serverAuthCode:(nullable NSString *)serverAuthCode; +@property(nonatomic, strong) FSIUserData *user; +@property(nonatomic, copy) NSString *accessToken; +@property(nonatomic, copy) NSArray *grantedScopes; +@property(nonatomic, copy, nullable) NSString *serverAuthCode; +@end + +/// The codec used by all APIs. +NSObject *FSIGetMessagesCodec(void); @protocol FSIGoogleSignInApi -/// Initializes a sign in request with the given parameters. -- (void)initializeSignInWithParameters:(FSIInitParams *)params - error:(FlutterError *_Nullable *_Nonnull)error; -/// Starts a silent sign in. -- (void)signInSilentlyWithCompletion:(void (^)(FSIUserData *_Nullable, - FlutterError *_Nullable))completion; +/// Configures the sign in object with application-level parameters. +- (void)configureWithParameters:(FSIPlatformConfigurationParams *)params + error:(FlutterError *_Nullable *_Nonnull)error; +/// Attempts to restore an existing sign-in, if any, with minimal user +/// interaction. +- (void)restorePreviousSignInWithCompletion:(void (^)(FSISignInResult *_Nullable, + FlutterError *_Nullable))completion; /// Starts a sign in with user interaction. -- (void)signInWithCompletion:(void (^)(FSIUserData *_Nullable, FlutterError *_Nullable))completion; +- (void)signInWithScopeHint:(NSArray *)scopeHint + nonce:(nullable NSString *)nonce + completion: + (void (^)(FSISignInResult *_Nullable, FlutterError *_Nullable))completion; /// Requests the access token for the current sign in. -- (void)getAccessTokenWithCompletion:(void (^)(FSITokenData *_Nullable, - FlutterError *_Nullable))completion; +- (void)refreshedAuthorizationTokensForUser:(NSString *)userId + completion:(void (^)(FSISignInResult *_Nullable, + FlutterError *_Nullable))completion; +/// Requests authorization of the given additional scopes. +- (void)addScopes:(NSArray *)scopes + forUser:(NSString *)userId + completion:(void (^)(FSISignInResult *_Nullable, FlutterError *_Nullable))completion; /// Signs out the current user. - (void)signOutWithError:(FlutterError *_Nullable *_Nonnull)error; /// Revokes scope grants to the application. - (void)disconnectWithCompletion:(void (^)(FlutterError *_Nullable))completion; -/// Returns whether the user is currently signed in. -/// -/// @return `nil` only when `error != nil`. -- (nullable NSNumber *)isSignedInWithError:(FlutterError *_Nullable *_Nonnull)error; -/// Requests access to the given scopes. -- (void)requestScopes:(NSArray *)scopes - completion:(void (^)(NSNumber *_Nullable, FlutterError *_Nullable))completion; @end -extern void FSIGoogleSignInApiSetup(id binaryMessenger, +extern void SetUpFSIGoogleSignInApi(id binaryMessenger, NSObject *_Nullable api); +extern void SetUpFSIGoogleSignInApiWithSuffix(id binaryMessenger, + NSObject *_Nullable api, + NSString *messageChannelSuffix); + NS_ASSUME_NONNULL_END diff --git a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/messages.g.m b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/messages.g.m index 2ec6ea32d0e1..522e8cd26ee3 100644 --- a/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/messages.g.m +++ b/packages/google_sign_in/google_sign_in_ios/darwin/google_sign_in_ios/Sources/google_sign_in_ios/messages.g.m @@ -1,7 +1,7 @@ // 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. -// Autogenerated from Pigeon (v11.0.1), do not edit directly. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. // See also: https://pub.dev/packages/pigeon #import "./include/google_sign_in_ios/messages.g.h" @@ -16,7 +16,7 @@ #error File requires ARC to be enabled. #endif -static NSArray *wrapResult(id result, FlutterError *error) { +static NSArray *wrapResult(id result, FlutterError *error) { if (error) { return @[ error.code ?: [NSNull null], error.message ?: [NSNull null], error.details ?: [NSNull null] @@ -24,59 +24,79 @@ } return @[ result ?: [NSNull null] ]; } -static id GetNullableObjectAtIndex(NSArray *array, NSInteger key) { + +static id GetNullableObjectAtIndex(NSArray *array, NSInteger key) { id result = array[key]; return (result == [NSNull null]) ? nil : result; } -@interface FSIInitParams () -+ (FSIInitParams *)fromList:(NSArray *)list; -+ (nullable FSIInitParams *)nullableFromList:(NSArray *)list; -- (NSArray *)toList; +/// Enum mapping of known codes from +/// https://developers.google.com/identity/sign-in/ios/reference/Enums/GIDSignInErrorCode +@implementation FSIGoogleSignInErrorCodeBox +- (instancetype)initWithValue:(FSIGoogleSignInErrorCode)value { + self = [super init]; + if (self) { + _value = value; + } + return self; +} +@end + +@interface FSIPlatformConfigurationParams () ++ (FSIPlatformConfigurationParams *)fromList:(NSArray *)list; ++ (nullable FSIPlatformConfigurationParams *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; @end @interface FSIUserData () -+ (FSIUserData *)fromList:(NSArray *)list; -+ (nullable FSIUserData *)nullableFromList:(NSArray *)list; -- (NSArray *)toList; ++ (FSIUserData *)fromList:(NSArray *)list; ++ (nullable FSIUserData *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; @end -@interface FSITokenData () -+ (FSITokenData *)fromList:(NSArray *)list; -+ (nullable FSITokenData *)nullableFromList:(NSArray *)list; -- (NSArray *)toList; +@interface FSISignInResult () ++ (FSISignInResult *)fromList:(NSArray *)list; ++ (nullable FSISignInResult *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; @end -@implementation FSIInitParams -+ (instancetype)makeWithScopes:(NSArray *)scopes - hostedDomain:(nullable NSString *)hostedDomain - clientId:(nullable NSString *)clientId - serverClientId:(nullable NSString *)serverClientId { - FSIInitParams *pigeonResult = [[FSIInitParams alloc] init]; - pigeonResult.scopes = scopes; - pigeonResult.hostedDomain = hostedDomain; +@interface FSISignInFailure () ++ (FSISignInFailure *)fromList:(NSArray *)list; ++ (nullable FSISignInFailure *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface FSISignInSuccess () ++ (FSISignInSuccess *)fromList:(NSArray *)list; ++ (nullable FSISignInSuccess *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@implementation FSIPlatformConfigurationParams ++ (instancetype)makeWithClientId:(nullable NSString *)clientId + serverClientId:(nullable NSString *)serverClientId + hostedDomain:(nullable NSString *)hostedDomain { + FSIPlatformConfigurationParams *pigeonResult = [[FSIPlatformConfigurationParams alloc] init]; pigeonResult.clientId = clientId; pigeonResult.serverClientId = serverClientId; + pigeonResult.hostedDomain = hostedDomain; return pigeonResult; } -+ (FSIInitParams *)fromList:(NSArray *)list { - FSIInitParams *pigeonResult = [[FSIInitParams alloc] init]; - pigeonResult.scopes = GetNullableObjectAtIndex(list, 0); - NSAssert(pigeonResult.scopes != nil, @""); - pigeonResult.hostedDomain = GetNullableObjectAtIndex(list, 1); - pigeonResult.clientId = GetNullableObjectAtIndex(list, 2); - pigeonResult.serverClientId = GetNullableObjectAtIndex(list, 3); ++ (FSIPlatformConfigurationParams *)fromList:(NSArray *)list { + FSIPlatformConfigurationParams *pigeonResult = [[FSIPlatformConfigurationParams alloc] init]; + pigeonResult.clientId = GetNullableObjectAtIndex(list, 0); + pigeonResult.serverClientId = GetNullableObjectAtIndex(list, 1); + pigeonResult.hostedDomain = GetNullableObjectAtIndex(list, 2); return pigeonResult; } -+ (nullable FSIInitParams *)nullableFromList:(NSArray *)list { - return (list) ? [FSIInitParams fromList:list] : nil; ++ (nullable FSIPlatformConfigurationParams *)nullableFromList:(NSArray *)list { + return (list) ? [FSIPlatformConfigurationParams fromList:list] : nil; } -- (NSArray *)toList { +- (NSArray *)toList { return @[ - (self.scopes ?: [NSNull null]), - (self.hostedDomain ?: [NSNull null]), - (self.clientId ?: [NSNull null]), - (self.serverClientId ?: [NSNull null]), + self.clientId ?: [NSNull null], + self.serverClientId ?: [NSNull null], + self.hostedDomain ?: [NSNull null], ]; } @end @@ -86,98 +106,175 @@ + (instancetype)makeWithDisplayName:(nullable NSString *)displayName email:(NSString *)email userId:(NSString *)userId photoUrl:(nullable NSString *)photoUrl - serverAuthCode:(nullable NSString *)serverAuthCode idToken:(nullable NSString *)idToken { FSIUserData *pigeonResult = [[FSIUserData alloc] init]; pigeonResult.displayName = displayName; pigeonResult.email = email; pigeonResult.userId = userId; pigeonResult.photoUrl = photoUrl; - pigeonResult.serverAuthCode = serverAuthCode; pigeonResult.idToken = idToken; return pigeonResult; } -+ (FSIUserData *)fromList:(NSArray *)list { ++ (FSIUserData *)fromList:(NSArray *)list { FSIUserData *pigeonResult = [[FSIUserData alloc] init]; pigeonResult.displayName = GetNullableObjectAtIndex(list, 0); pigeonResult.email = GetNullableObjectAtIndex(list, 1); - NSAssert(pigeonResult.email != nil, @""); pigeonResult.userId = GetNullableObjectAtIndex(list, 2); - NSAssert(pigeonResult.userId != nil, @""); pigeonResult.photoUrl = GetNullableObjectAtIndex(list, 3); - pigeonResult.serverAuthCode = GetNullableObjectAtIndex(list, 4); - pigeonResult.idToken = GetNullableObjectAtIndex(list, 5); + pigeonResult.idToken = GetNullableObjectAtIndex(list, 4); return pigeonResult; } -+ (nullable FSIUserData *)nullableFromList:(NSArray *)list { ++ (nullable FSIUserData *)nullableFromList:(NSArray *)list { return (list) ? [FSIUserData fromList:list] : nil; } -- (NSArray *)toList { +- (NSArray *)toList { return @[ - (self.displayName ?: [NSNull null]), - (self.email ?: [NSNull null]), - (self.userId ?: [NSNull null]), - (self.photoUrl ?: [NSNull null]), - (self.serverAuthCode ?: [NSNull null]), - (self.idToken ?: [NSNull null]), + self.displayName ?: [NSNull null], + self.email ?: [NSNull null], + self.userId ?: [NSNull null], + self.photoUrl ?: [NSNull null], + self.idToken ?: [NSNull null], ]; } @end -@implementation FSITokenData -+ (instancetype)makeWithIdToken:(nullable NSString *)idToken - accessToken:(nullable NSString *)accessToken { - FSITokenData *pigeonResult = [[FSITokenData alloc] init]; - pigeonResult.idToken = idToken; +@implementation FSISignInResult ++ (instancetype)makeWithSuccess:(nullable FSISignInSuccess *)success + error:(nullable FSISignInFailure *)error { + FSISignInResult *pigeonResult = [[FSISignInResult alloc] init]; + pigeonResult.success = success; + pigeonResult.error = error; + return pigeonResult; +} ++ (FSISignInResult *)fromList:(NSArray *)list { + FSISignInResult *pigeonResult = [[FSISignInResult alloc] init]; + pigeonResult.success = GetNullableObjectAtIndex(list, 0); + pigeonResult.error = GetNullableObjectAtIndex(list, 1); + return pigeonResult; +} ++ (nullable FSISignInResult *)nullableFromList:(NSArray *)list { + return (list) ? [FSISignInResult fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.success ?: [NSNull null], + self.error ?: [NSNull null], + ]; +} +@end + +@implementation FSISignInFailure ++ (instancetype)makeWithType:(FSIGoogleSignInErrorCode)type + message:(nullable NSString *)message + details:(nullable id)details { + FSISignInFailure *pigeonResult = [[FSISignInFailure alloc] init]; + pigeonResult.type = type; + pigeonResult.message = message; + pigeonResult.details = details; + return pigeonResult; +} ++ (FSISignInFailure *)fromList:(NSArray *)list { + FSISignInFailure *pigeonResult = [[FSISignInFailure alloc] init]; + FSIGoogleSignInErrorCodeBox *boxedFSIGoogleSignInErrorCode = GetNullableObjectAtIndex(list, 0); + pigeonResult.type = boxedFSIGoogleSignInErrorCode.value; + pigeonResult.message = GetNullableObjectAtIndex(list, 1); + pigeonResult.details = GetNullableObjectAtIndex(list, 2); + return pigeonResult; +} ++ (nullable FSISignInFailure *)nullableFromList:(NSArray *)list { + return (list) ? [FSISignInFailure fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + [[FSIGoogleSignInErrorCodeBox alloc] initWithValue:self.type], + self.message ?: [NSNull null], + self.details ?: [NSNull null], + ]; +} +@end + +@implementation FSISignInSuccess ++ (instancetype)makeWithUser:(FSIUserData *)user + accessToken:(NSString *)accessToken + grantedScopes:(NSArray *)grantedScopes + serverAuthCode:(nullable NSString *)serverAuthCode { + FSISignInSuccess *pigeonResult = [[FSISignInSuccess alloc] init]; + pigeonResult.user = user; pigeonResult.accessToken = accessToken; + pigeonResult.grantedScopes = grantedScopes; + pigeonResult.serverAuthCode = serverAuthCode; return pigeonResult; } -+ (FSITokenData *)fromList:(NSArray *)list { - FSITokenData *pigeonResult = [[FSITokenData alloc] init]; - pigeonResult.idToken = GetNullableObjectAtIndex(list, 0); ++ (FSISignInSuccess *)fromList:(NSArray *)list { + FSISignInSuccess *pigeonResult = [[FSISignInSuccess alloc] init]; + pigeonResult.user = GetNullableObjectAtIndex(list, 0); pigeonResult.accessToken = GetNullableObjectAtIndex(list, 1); + pigeonResult.grantedScopes = GetNullableObjectAtIndex(list, 2); + pigeonResult.serverAuthCode = GetNullableObjectAtIndex(list, 3); return pigeonResult; } -+ (nullable FSITokenData *)nullableFromList:(NSArray *)list { - return (list) ? [FSITokenData fromList:list] : nil; ++ (nullable FSISignInSuccess *)nullableFromList:(NSArray *)list { + return (list) ? [FSISignInSuccess fromList:list] : nil; } -- (NSArray *)toList { +- (NSArray *)toList { return @[ - (self.idToken ?: [NSNull null]), - (self.accessToken ?: [NSNull null]), + self.user ?: [NSNull null], + self.accessToken ?: [NSNull null], + self.grantedScopes ?: [NSNull null], + self.serverAuthCode ?: [NSNull null], ]; } @end -@interface FSIGoogleSignInApiCodecReader : FlutterStandardReader +@interface FSIMessagesPigeonCodecReader : FlutterStandardReader @end -@implementation FSIGoogleSignInApiCodecReader +@implementation FSIMessagesPigeonCodecReader - (nullable id)readValueOfType:(UInt8)type { switch (type) { - case 128: - return [FSIInitParams fromList:[self readValue]]; - case 129: - return [FSITokenData fromList:[self readValue]]; + case 129: { + NSNumber *enumAsNumber = [self readValue]; + return enumAsNumber == nil + ? nil + : [[FSIGoogleSignInErrorCodeBox alloc] initWithValue:[enumAsNumber integerValue]]; + } case 130: + return [FSIPlatformConfigurationParams fromList:[self readValue]]; + case 131: return [FSIUserData fromList:[self readValue]]; + case 132: + return [FSISignInResult fromList:[self readValue]]; + case 133: + return [FSISignInFailure fromList:[self readValue]]; + case 134: + return [FSISignInSuccess fromList:[self readValue]]; default: return [super readValueOfType:type]; } } @end -@interface FSIGoogleSignInApiCodecWriter : FlutterStandardWriter +@interface FSIMessagesPigeonCodecWriter : FlutterStandardWriter @end -@implementation FSIGoogleSignInApiCodecWriter +@implementation FSIMessagesPigeonCodecWriter - (void)writeValue:(id)value { - if ([value isKindOfClass:[FSIInitParams class]]) { - [self writeByte:128]; - [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FSITokenData class]]) { + if ([value isKindOfClass:[FSIGoogleSignInErrorCodeBox class]]) { + FSIGoogleSignInErrorCodeBox *box = (FSIGoogleSignInErrorCodeBox *)value; [self writeByte:129]; + [self writeValue:(value == nil ? [NSNull null] : [NSNumber numberWithInteger:box.value])]; + } else if ([value isKindOfClass:[FSIPlatformConfigurationParams class]]) { + [self writeByte:130]; [self writeValue:[value toList]]; } else if ([value isKindOfClass:[FSIUserData class]]) { - [self writeByte:130]; + [self writeByte:131]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FSISignInResult class]]) { + [self writeByte:132]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FSISignInFailure class]]) { + [self writeByte:133]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FSISignInSuccess class]]) { + [self writeByte:134]; [self writeValue:[value toList]]; } else { [super writeValue:value]; @@ -185,66 +282,82 @@ - (void)writeValue:(id)value { } @end -@interface FSIGoogleSignInApiCodecReaderWriter : FlutterStandardReaderWriter +@interface FSIMessagesPigeonCodecReaderWriter : FlutterStandardReaderWriter @end -@implementation FSIGoogleSignInApiCodecReaderWriter +@implementation FSIMessagesPigeonCodecReaderWriter - (FlutterStandardWriter *)writerWithData:(NSMutableData *)data { - return [[FSIGoogleSignInApiCodecWriter alloc] initWithData:data]; + return [[FSIMessagesPigeonCodecWriter alloc] initWithData:data]; } - (FlutterStandardReader *)readerWithData:(NSData *)data { - return [[FSIGoogleSignInApiCodecReader alloc] initWithData:data]; + return [[FSIMessagesPigeonCodecReader alloc] initWithData:data]; } @end -NSObject *FSIGoogleSignInApiGetCodec(void) { +NSObject *FSIGetMessagesCodec(void) { static FlutterStandardMessageCodec *sSharedObject = nil; static dispatch_once_t sPred = 0; dispatch_once(&sPred, ^{ - FSIGoogleSignInApiCodecReaderWriter *readerWriter = - [[FSIGoogleSignInApiCodecReaderWriter alloc] init]; + FSIMessagesPigeonCodecReaderWriter *readerWriter = + [[FSIMessagesPigeonCodecReaderWriter alloc] init]; sSharedObject = [FlutterStandardMessageCodec codecWithReaderWriter:readerWriter]; }); return sSharedObject; } - -void FSIGoogleSignInApiSetup(id binaryMessenger, +void SetUpFSIGoogleSignInApi(id binaryMessenger, NSObject *api) { - /// Initializes a sign in request with the given parameters. + SetUpFSIGoogleSignInApiWithSuffix(binaryMessenger, api, @""); +} + +void SetUpFSIGoogleSignInApiWithSuffix(id binaryMessenger, + NSObject *api, + NSString *messageChannelSuffix) { + messageChannelSuffix = messageChannelSuffix.length > 0 + ? [NSString stringWithFormat:@".%@", messageChannelSuffix] + : @""; + /// Configures the sign in object with application-level parameters. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.init" + initWithName:[NSString + stringWithFormat: + @"%@%@", + @"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.configure", + messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; + codec:FSIGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(initializeSignInWithParameters:error:)], + NSCAssert([api respondsToSelector:@selector(configureWithParameters:error:)], @"FSIGoogleSignInApi api (%@) doesn't respond to " - @"@selector(initializeSignInWithParameters:error:)", + @"@selector(configureWithParameters:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - NSArray *args = message; - FSIInitParams *arg_params = GetNullableObjectAtIndex(args, 0); + NSArray *args = message; + FSIPlatformConfigurationParams *arg_params = GetNullableObjectAtIndex(args, 0); FlutterError *error; - [api initializeSignInWithParameters:arg_params error:&error]; + [api configureWithParameters:arg_params error:&error]; callback(wrapResult(nil, error)); }]; } else { [channel setMessageHandler:nil]; } } - /// Starts a silent sign in. + /// Attempts to restore an existing sign-in, if any, with minimal user + /// interaction. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signInSilently" + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_sign_in_ios." + @"GoogleSignInApi.restorePreviousSignIn", + messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; + codec:FSIGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(signInSilentlyWithCompletion:)], + NSCAssert([api respondsToSelector:@selector(restorePreviousSignInWithCompletion:)], @"FSIGoogleSignInApi api (%@) doesn't respond to " - @"@selector(signInSilentlyWithCompletion:)", + @"@selector(restorePreviousSignInWithCompletion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - [api signInSilentlyWithCompletion:^(FSIUserData *_Nullable output, - FlutterError *_Nullable error) { + [api restorePreviousSignInWithCompletion:^(FSISignInResult *_Nullable output, + FlutterError *_Nullable error) { callback(wrapResult(output, error)); }]; }]; @@ -255,17 +368,28 @@ void FSIGoogleSignInApiSetup(id binaryMessenger, /// Starts a sign in with user interaction. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signIn" + initWithName: + [NSString + stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signIn", + messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; + codec:FSIGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(signInWithCompletion:)], - @"FSIGoogleSignInApi api (%@) doesn't respond to @selector(signInWithCompletion:)", + NSCAssert([api respondsToSelector:@selector(signInWithScopeHint:nonce:completion:)], + @"FSIGoogleSignInApi api (%@) doesn't respond to " + @"@selector(signInWithScopeHint:nonce:completion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - [api signInWithCompletion:^(FSIUserData *_Nullable output, FlutterError *_Nullable error) { - callback(wrapResult(output, error)); - }]; + NSArray *args = message; + NSArray *arg_scopeHint = GetNullableObjectAtIndex(args, 0); + NSString *arg_nonce = GetNullableObjectAtIndex(args, 1); + [api signInWithScopeHint:arg_scopeHint + nonce:arg_nonce + completion:^(FSISignInResult *_Nullable output, + FlutterError *_Nullable error) { + callback(wrapResult(output, error)); + }]; }]; } else { [channel setMessageHandler:nil]; @@ -274,100 +398,102 @@ void FSIGoogleSignInApiSetup(id binaryMessenger, /// Requests the access token for the current sign in. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.getAccessToken" + initWithName:[NSString + stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_sign_in_ios." + @"GoogleSignInApi.getRefreshedAuthorizationTokens", + messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; + codec:FSIGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(getAccessTokenWithCompletion:)], + NSCAssert([api respondsToSelector:@selector(refreshedAuthorizationTokensForUser:completion:)], @"FSIGoogleSignInApi api (%@) doesn't respond to " - @"@selector(getAccessTokenWithCompletion:)", + @"@selector(refreshedAuthorizationTokensForUser:completion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - [api getAccessTokenWithCompletion:^(FSITokenData *_Nullable output, - FlutterError *_Nullable error) { - callback(wrapResult(output, error)); - }]; + NSArray *args = message; + NSString *arg_userId = GetNullableObjectAtIndex(args, 0); + [api refreshedAuthorizationTokensForUser:arg_userId + completion:^(FSISignInResult *_Nullable output, + FlutterError *_Nullable error) { + callback(wrapResult(output, error)); + }]; }]; } else { [channel setMessageHandler:nil]; } } - /// Signs out the current user. + /// Requests authorization of the given additional scopes. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signOut" + initWithName:[NSString + stringWithFormat: + @"%@%@", + @"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.addScopes", + messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; + codec:FSIGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(signOutWithError:)], - @"FSIGoogleSignInApi api (%@) doesn't respond to @selector(signOutWithError:)", + NSCAssert([api respondsToSelector:@selector(addScopes:forUser:completion:)], + @"FSIGoogleSignInApi api (%@) doesn't respond to " + @"@selector(addScopes:forUser:completion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - FlutterError *error; - [api signOutWithError:&error]; - callback(wrapResult(nil, error)); - }]; - } else { - [channel setMessageHandler:nil]; - } - } - /// Revokes scope grants to the application. - { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.disconnect" - binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; - if (api) { - NSCAssert( - [api respondsToSelector:@selector(disconnectWithCompletion:)], - @"FSIGoogleSignInApi api (%@) doesn't respond to @selector(disconnectWithCompletion:)", - api); - [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - [api disconnectWithCompletion:^(FlutterError *_Nullable error) { - callback(wrapResult(nil, error)); - }]; + NSArray *args = message; + NSArray *arg_scopes = GetNullableObjectAtIndex(args, 0); + NSString *arg_userId = GetNullableObjectAtIndex(args, 1); + [api addScopes:arg_scopes + forUser:arg_userId + completion:^(FSISignInResult *_Nullable output, FlutterError *_Nullable error) { + callback(wrapResult(output, error)); + }]; }]; } else { [channel setMessageHandler:nil]; } } - /// Returns whether the user is currently signed in. + /// Signs out the current user. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.isSignedIn" + initWithName:[NSString + stringWithFormat: + @"%@%@", + @"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signOut", + messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; + codec:FSIGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(isSignedInWithError:)], - @"FSIGoogleSignInApi api (%@) doesn't respond to @selector(isSignedInWithError:)", + NSCAssert([api respondsToSelector:@selector(signOutWithError:)], + @"FSIGoogleSignInApi api (%@) doesn't respond to @selector(signOutWithError:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { FlutterError *error; - NSNumber *output = [api isSignedInWithError:&error]; - callback(wrapResult(output, error)); + [api signOutWithError:&error]; + callback(wrapResult(nil, error)); }]; } else { [channel setMessageHandler:nil]; } } - /// Requests access to the given scopes. + /// Revokes scope grants to the application. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:@"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.requestScopes" + initWithName:[NSString + stringWithFormat: + @"%@%@", + @"dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.disconnect", + messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FSIGoogleSignInApiGetCodec()]; + codec:FSIGetMessagesCodec()]; if (api) { NSCAssert( - [api respondsToSelector:@selector(requestScopes:completion:)], - @"FSIGoogleSignInApi api (%@) doesn't respond to @selector(requestScopes:completion:)", + [api respondsToSelector:@selector(disconnectWithCompletion:)], + @"FSIGoogleSignInApi api (%@) doesn't respond to @selector(disconnectWithCompletion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { - NSArray *args = message; - NSArray *arg_scopes = GetNullableObjectAtIndex(args, 0); - [api requestScopes:arg_scopes - completion:^(NSNumber *_Nullable output, FlutterError *_Nullable error) { - callback(wrapResult(output, error)); - }]; + [api disconnectWithCompletion:^(FlutterError *_Nullable error) { + callback(wrapResult(nil, error)); + }]; }]; } else { [channel setMessageHandler:nil]; diff --git a/packages/google_sign_in/google_sign_in_ios/example/integration_test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in_ios/example/integration_test/google_sign_in_test.dart index f1388ce86d67..296cca2d0b02 100644 --- a/packages/google_sign_in/google_sign_in_ios/example/integration_test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in_ios/example/integration_test/google_sign_in_test.dart @@ -9,16 +9,16 @@ import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('Can initialize the plugin', (WidgetTester tester) async { + testWidgets('Can instantiate the plugin', (WidgetTester tester) async { final GoogleSignInPlatform signIn = GoogleSignInPlatform.instance; expect(signIn, isNotNull); }); - testWidgets('Method channel handler is present', (WidgetTester tester) async { - // isSignedIn can be called without initialization, so use it to validate - // that the native method handler is present (e.g., that the channel name - // is correct). + testWidgets('Can initialize the plugin', (WidgetTester tester) async { + // This is primarily to validate that the native method handler is present + // and correctly set up to receive messages (i.e., that this doesn't + // throw). final GoogleSignInPlatform signIn = GoogleSignInPlatform.instance; - await expectLater(signIn.isSignedIn(), completes); + await expectLater(signIn.init(const InitParameters()), completes); }); } diff --git a/packages/google_sign_in/google_sign_in_ios/example/lib/main.dart b/packages/google_sign_in/google_sign_in_ios/example/lib/main.dart index 81790d193c73..a821469792d3 100644 --- a/packages/google_sign_in/google_sign_in_ios/example/lib/main.dart +++ b/packages/google_sign_in/google_sign_in_ios/example/lib/main.dart @@ -8,10 +8,14 @@ import 'dart:async'; import 'dart:convert' show json; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:http/http.dart' as http; +const List _scopes = [ + 'email', + 'https://www.googleapis.com/auth/contacts.readonly', +]; + void main() { runApp( const MaterialApp( @@ -30,9 +34,10 @@ class SignInDemo extends StatefulWidget { class SignInDemoState extends State { GoogleSignInUserData? _currentUser; + bool _isAuthorized = false; String _contactText = ''; - // Future that completes when `initWithParams` has completed on the sign in - // instance. + String _errorMessage = ''; + // Future that completes when `init` has completed on the sign in instance. Future? _initialization; @override @@ -43,12 +48,7 @@ class SignInDemoState extends State { Future _ensureInitialized() { return _initialization ??= - GoogleSignInPlatform.instance.initWithParams(const SignInInitParameters( - scopes: [ - 'email', - 'https://www.googleapis.com/auth/contacts.readonly', - ], - )) + GoogleSignInPlatform.instance.init(const InitParameters()) ..catchError((dynamic _) { _initialization = null; }); @@ -57,33 +57,71 @@ class SignInDemoState extends State { void _setUser(GoogleSignInUserData? user) { setState(() { _currentUser = user; - if (user != null) { - _handleGetContact(user); - } }); + if (user != null) { + // Try getting contacts, in case authorization is already granted. + _handleGetContact(user); + } } Future _signIn() async { await _ensureInitialized(); - final GoogleSignInUserData? newUser = - await GoogleSignInPlatform.instance.signInSilently(); - _setUser(newUser); + try { + final AuthenticationResults? result = await GoogleSignInPlatform.instance + .attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + _setUser(result?.user); + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = e.code == GoogleSignInExceptionCode.canceled + ? '' + : 'GoogleSignInException ${e.code}: ${e.description}'; + }); + } } - Future> _getAuthHeaders() async { - final GoogleSignInUserData? user = _currentUser; - if (user == null) { - throw StateError('No user signed in'); + Future _handleAuthorizeScopes(GoogleSignInUserData user) async { + try { + final ClientAuthorizationTokenData? tokens = await GoogleSignInPlatform + .instance + .clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: _scopes, + userId: user.id, + email: user.email, + promptIfUnauthorized: true))); + + setState(() { + _isAuthorized = tokens != null; + _errorMessage = ''; + }); + if (_isAuthorized) { + unawaited(_handleGetContact(user)); + } + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = 'GoogleSignInException ${e.code}: ${e.description}'; + }); } + } - final GoogleSignInTokenData response = - await GoogleSignInPlatform.instance.getTokens( - email: user.email, - shouldRecoverAuth: true, - ); + Future?> _getAuthHeaders( + GoogleSignInUserData user) async { + final ClientAuthorizationTokenData? tokens = + await GoogleSignInPlatform.instance.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: _scopes, + userId: user.id, + email: user.email, + promptIfUnauthorized: false))); + if (tokens == null) { + return null; + } return { - 'Authorization': 'Bearer ${response.accessToken}', + 'Authorization': 'Bearer ${tokens.accessToken}', // TODO(kevmoo): Use the correct value once it's available. // See https://github.com/flutter/flutter/issues/80905 'X-Goog-AuthUser': '0', @@ -94,10 +132,17 @@ class SignInDemoState extends State { setState(() { _contactText = 'Loading contact info...'; }); + final Map? headers = await _getAuthHeaders(user); + setState(() { + _isAuthorized = headers != null; + }); + if (headers == null) { + return; + } final http.Response response = await http.get( Uri.parse('https://people.googleapis.com/v1/people/me/connections' '?requestMask.includeField=person.names'), - headers: await _getAuthHeaders(), + headers: headers, ); if (response.statusCode != 200) { setState(() { @@ -119,55 +164,63 @@ class SignInDemoState extends State { Future _handleSignIn() async { try { await _ensureInitialized(); - _setUser(await GoogleSignInPlatform.instance.signIn()); - } catch (error) { - final bool canceled = - error is PlatformException && error.code == 'sign_in_canceled'; - if (!canceled) { - print(error); - } + final AuthenticationResults result = await GoogleSignInPlatform.instance + .authenticate(const AuthenticateParameters()); + _setUser(result.user); + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = e.code == GoogleSignInExceptionCode.canceled + ? '' + : 'GoogleSignInException ${e.code}: ${e.description}'; + }); } } Future _handleSignOut() async { await _ensureInitialized(); - await GoogleSignInPlatform.instance.disconnect(); + await GoogleSignInPlatform.instance.disconnect(const DisconnectParams()); } Widget _buildBody() { final GoogleSignInUserData? user = _currentUser; - if (user != null) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ + return Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (user != null) ...[ ListTile( title: Text(user.displayName ?? ''), subtitle: Text(user.email), ), const Text('Signed in successfully.'), - Text(_contactText), ElevatedButton( onPressed: _handleSignOut, child: const Text('SIGN OUT'), ), - ElevatedButton( - child: const Text('REFRESH'), - onPressed: () => _handleGetContact(user), - ), - ], - ); - } else { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ + if (_isAuthorized) ...[ + // The user has Authorized all required scopes. + if (_contactText.isNotEmpty) Text(_contactText), + ElevatedButton( + child: const Text('REFRESH'), + onPressed: () => _handleGetContact(user), + ), + ] 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'), + ), + ], + ] else ...[ const Text('You are not currently signed in.'), ElevatedButton( onPressed: _handleSignIn, child: const Text('SIGN IN'), ), ], - ); - } + if (_errorMessage.isNotEmpty) Text(_errorMessage), + ], + ); } @override diff --git a/packages/google_sign_in/google_sign_in_ios/example/pubspec.yaml b/packages/google_sign_in/google_sign_in_ios/example/pubspec.yaml index d687148ca0eb..7a56b9a321c9 100644 --- a/packages/google_sign_in/google_sign_in_ios/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_ios/example/pubspec.yaml @@ -27,3 +27,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_platform_interface: {path: ../../../../packages/google_sign_in/google_sign_in_platform_interface} diff --git a/packages/google_sign_in/google_sign_in_ios/lib/google_sign_in_ios.dart b/packages/google_sign_in/google_sign_in_ios/lib/google_sign_in_ios.dart index 652ead516a00..fee68a7a3f2f 100644 --- a/packages/google_sign_in/google_sign_in_ios/lib/google_sign_in_ios.dart +++ b/packages/google_sign_in/google_sign_in_ios/lib/google_sign_in_ios.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:flutter/services.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'src/messages.g.dart'; @@ -19,101 +18,231 @@ class GoogleSignInIOS extends GoogleSignInPlatform { final GoogleSignInApi _api; + String? _nonce; + /// Registers this class as the default instance of [GoogleSignInPlatform]. static void registerWith() { GoogleSignInPlatform.instance = GoogleSignInIOS(); } @override - Future init({ - List scopes = const [], - SignInOption signInOption = SignInOption.standard, - String? hostedDomain, - String? clientId, - }) { - return initWithParams(SignInInitParameters( - signInOption: signInOption, - scopes: scopes, - hostedDomain: hostedDomain, - clientId: clientId, - )); + Future init(InitParameters params) async { + _nonce = params.nonce; + await _api.configure(PlatformConfigurationParams( + clientId: params.clientId, + serverClientId: params.serverClientId, + hostedDomain: params.hostedDomain)); } @override - Future initWithParams(SignInInitParameters params) { - if (params.signInOption == SignInOption.games) { - throw PlatformException( - code: 'unsupported-options', - message: 'Games sign in is not supported on iOS'); + Future attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params) async { + final SignInResult result = await _api.restorePreviousSignIn(); + + if (result.error?.type == GoogleSignInErrorCode.noAuthInKeychain) { + return null; } - if (params.forceAccountName != null) { - throw ArgumentError('Force account name is not supported on iOS'); + + final SignInFailure? failure = result.error; + if (failure != null) { + throw GoogleSignInException( + code: _exceptionCodeForErrorPlatformErrorCode(failure.type), + description: failure.message, + details: failure.details); } - return _api.init(InitParams( - scopes: params.scopes, - hostedDomain: params.hostedDomain, - clientId: params.clientId, - serverClientId: params.serverClientId, - )); + + // The native code must never return a null success and a null error. + // Switching the native implementation to Swift and using sealed classes + // in the Pigeon definition (see Android's messages.dart) will allow + // enforcing this via the type system instead of force unwrapping. + final SignInSuccess success = result.success!; + return _authenticationResultsFromSignInSuccess(success); } @override - Future signInSilently() { - return _api.signInSilently().then(_signInUserDataFromChannelData); + Future authenticate( + AuthenticateParameters params) async { + final SignInResult result = await _api.signIn(params.scopeHint, _nonce); + + // This should never happen; the corresponding native error code is + // documented as being specific to restorePreviousSignIn. + if (result.error?.type == GoogleSignInErrorCode.noAuthInKeychain) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: 'No auth reported during interactive sign in.'); + } + + final SignInFailure? failure = result.error; + if (failure != null) { + throw GoogleSignInException( + code: _exceptionCodeForErrorPlatformErrorCode(failure.type), + description: failure.message, + details: failure.details); + } + + // The native code must never return a null success and a null error. + // Switching the native implementation to Swift and using sealed classes + // in the Pigeon definition (see Android's messages.dart) will allow + // enforcing this via the type system instead of force unwrapping. + final SignInSuccess success = result.success!; + return _authenticationResultsFromSignInSuccess(success); } @override - Future signIn() { - return _api.signIn().then(_signInUserDataFromChannelData); + Future signOut(SignOutParams params) { + return _api.signOut(); } @override - Future getTokens( - {required String email, bool? shouldRecoverAuth = true}) { - return _api.getAccessToken().then(_signInTokenDataFromChannelData); + Future disconnect(DisconnectParams params) async { + await _api.disconnect(); + await signOut(const SignOutParams()); } @override - Future signOut() { - return _api.signOut(); + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params) async { + final (:String? accessToken, :String? serverAuthCode) = + await _getAuthorizationTokens(params.request); + return accessToken == null + ? null + : ClientAuthorizationTokenData(accessToken: accessToken); } @override - Future disconnect() { - return _api.disconnect(); + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params) async { + final (:String? accessToken, :String? serverAuthCode) = + await _getAuthorizationTokens(params.request); + return serverAuthCode == null + ? null + : ServerAuthorizationTokenData(serverAuthCode: serverAuthCode); } - @override - Future isSignedIn() { - return _api.isSignedIn(); + Future<({String? accessToken, String? serverAuthCode})> + _getAuthorizationTokens(AuthorizationRequestDetails request) async { + String? userId = request.userId; + + // The Google Sign In SDK requires authentication before authorization, so + // if the authentication isn't associated with an existing sign-in user + // run the authentication flow first. + if (userId == null) { + SignInResult result = await _api.restorePreviousSignIn(); + final SignInSuccess? success = result.success; + if (success == null) { + // There's no existing sign-in to use, so return the results of the + // combined authn+authz flow, if prompting is allowed. + if (request.promptIfUnauthorized) { + result = await _api.signIn(request.scopes, _nonce); + return _processAuthorizationResult(result); + } else { + // No existing authentication, and no prompting allowed, so return + // no tokens. + return (accessToken: null, serverAuthCode: null); + } + } else { + // Discard the authentication information, and extract the user ID to + // pass back to the authorization step so that it can re-associate + // with the currently signed in user on the native side. + userId = success.user.userId; + } + } + + final bool useExistingAuthorization = !request.promptIfUnauthorized; + SignInResult result = useExistingAuthorization + ? await _api.getRefreshedAuthorizationTokens(userId) + : await _api.addScopes(request.scopes, userId); + if (!useExistingAuthorization && + result.error?.type == GoogleSignInErrorCode.scopesAlreadyGranted) { + // The Google Sign In SDK returns an error when requesting scopes that are + // already authorized, so in that case request updated tokens instead to + // construct a valid token response. + result = await _api.getRefreshedAuthorizationTokens(userId); + } + if (result.error?.type == GoogleSignInErrorCode.noAuthInKeychain) { + return (accessToken: null, serverAuthCode: null); + } + + // If re-using an existing authorization, ensure that it has all of the + // requested scopes before returning it, as the list of requested scopes + // may have changed since the last authorization. + if (useExistingAuthorization) { + final SignInSuccess? success = result.success; + // Don't validate the OpenID Connect scopes (see + // https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect + // for details), as they should always be available, and the granted + // scopes may not report them with the same string as the request. + // For example, requesting 'email' can instead result in the grant + // 'https://www.googleapis.com/auth/userinfo.email'. + const Set openIdConnectScopes = { + 'email', + 'openid', + 'profile' + }; + if (success != null) { + if (request.scopes.any((String scope) => + !openIdConnectScopes.contains(scope) && + !success.grantedScopes.contains(scope))) { + return (accessToken: null, serverAuthCode: null); + } + } + } + + return _processAuthorizationResult(result); } - @override - Future clearAuthCache({required String token}) async { - // There's nothing to be done here on iOS since the expired/invalid - // tokens are refreshed automatically by getTokens. + Future<({String? accessToken, String? serverAuthCode})> + _processAuthorizationResult(SignInResult result) async { + final SignInFailure? failure = result.error; + if (failure != null) { + throw GoogleSignInException( + code: _exceptionCodeForErrorPlatformErrorCode(failure.type), + description: failure.message, + details: failure.details); + } + + return _authorizationTokenDataFromSignInSuccess(result.success); } - @override - Future requestScopes(List scopes) { - return _api.requestScopes(scopes); + AuthenticationResults _authenticationResultsFromSignInSuccess( + SignInSuccess result) { + final UserData userData = result.user; + final GoogleSignInUserData user = GoogleSignInUserData( + email: userData.email, + id: userData.userId, + displayName: userData.displayName, + photoUrl: userData.photoUrl); + return AuthenticationResults( + user: user, + authenticationTokens: + AuthenticationTokenData(idToken: userData.idToken)); } - GoogleSignInUserData _signInUserDataFromChannelData(UserData data) { - return GoogleSignInUserData( - email: data.email, - id: data.userId, - displayName: data.displayName, - photoUrl: data.photoUrl, - serverAuthCode: data.serverAuthCode, - idToken: data.idToken, + ({String? accessToken, String? serverAuthCode}) + _authorizationTokenDataFromSignInSuccess(SignInSuccess? result) { + return ( + accessToken: result?.accessToken, + serverAuthCode: result?.serverAuthCode ); } - GoogleSignInTokenData _signInTokenDataFromChannelData(TokenData data) { - return GoogleSignInTokenData( - idToken: data.idToken, - accessToken: data.accessToken, - ); + GoogleSignInExceptionCode _exceptionCodeForErrorPlatformErrorCode( + GoogleSignInErrorCode code) { + return switch (code) { + GoogleSignInErrorCode.unknown => GoogleSignInExceptionCode.unknownError, + GoogleSignInErrorCode.keychainError => + GoogleSignInExceptionCode.providerConfigurationError, + GoogleSignInErrorCode.canceled => GoogleSignInExceptionCode.canceled, + GoogleSignInErrorCode.eemError => + GoogleSignInExceptionCode.providerConfigurationError, + GoogleSignInErrorCode.userMismatch => + GoogleSignInExceptionCode.userMismatch, + // These should never be mapped to a GoogleSignInException; the caller + // should handle them. + GoogleSignInErrorCode.noAuthInKeychain => throw StateError( + '_exceptionCodeForErrorPlatformErrorCode called with no auth.'), + GoogleSignInErrorCode.scopesAlreadyGranted => throw StateError( + '_exceptionCodeForErrorPlatformErrorCode called with scopes already granted.'), + }; } } diff --git a/packages/google_sign_in/google_sign_in_ios/lib/src/messages.g.dart b/packages/google_sign_in/google_sign_in_ios/lib/src/messages.g.dart index 21dd2ebf8e2a..f4e23701c04c 100644 --- a/packages/google_sign_in/google_sign_in_ios/lib/src/messages.g.dart +++ b/packages/google_sign_in/google_sign_in_ios/lib/src/messages.g.dart @@ -1,9 +1,9 @@ // 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. -// Autogenerated from Pigeon (v11.0.1), do not edit directly. +// Autogenerated from Pigeon (v22.7.4), do not edit directly. // See also: https://pub.dev/packages/pigeon -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers import 'dart:async'; import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; @@ -11,55 +11,84 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; import 'package:flutter/services.dart'; -/// Pigeon version of SignInInitParams. -/// -/// See SignInInitParams for details. -class InitParams { - InitParams({ - required this.scopes, - this.hostedDomain, +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +/// Enum mapping of known codes from +/// https://developers.google.com/identity/sign-in/ios/reference/Enums/GIDSignInErrorCode +enum GoogleSignInErrorCode { + /// Either the underlying kGIDSignInErrorCodeUnknown, or a code that isn't + /// a known code mapped to a value below. + unknown, + + /// kGIDSignInErrorCodeKeychain; an error reading or writing to keychain. + keychainError, + + /// kGIDSignInErrorCodeHasNoAuthInKeychain; no auth present in the keychain. + /// + /// For restorePreviousSignIn, this indicates that there is no sign in to + /// restore. + noAuthInKeychain, + + /// kGIDSignInErrorCodeCanceled; the request was canceled by the user. + canceled, + + /// kGIDSignInErrorCodeEMM; an enterprise management error occurred. + eemError, + + /// kGIDSignInErrorCodeScopesAlreadyGranted; the requested scopes have already + /// been granted. + scopesAlreadyGranted, + + /// kGIDSignInErrorCodeMismatchWithCurrentUser; an operation was requested on + /// a non-current user. + userMismatch, +} + +class PlatformConfigurationParams { + PlatformConfigurationParams({ this.clientId, this.serverClientId, + this.hostedDomain, }); - List scopes; - - String? hostedDomain; - String? clientId; String? serverClientId; + String? hostedDomain; + Object encode() { return [ - scopes, - hostedDomain, clientId, serverClientId, + hostedDomain, ]; } - static InitParams decode(Object result) { + static PlatformConfigurationParams decode(Object result) { result as List; - return InitParams( - scopes: (result[0] as List?)!.cast(), - hostedDomain: result[1] as String?, - clientId: result[2] as String?, - serverClientId: result[3] as String?, + return PlatformConfigurationParams( + clientId: result[0] as String?, + serverClientId: result[1] as String?, + hostedDomain: result[2] as String?, ); } } -/// Pigeon version of GoogleSignInUserData. +/// Pigeon version of GoogleSignInUserData + AuthenticationTokenData. /// -/// See GoogleSignInUserData for details. +/// See GoogleSignInUserData and AuthenticationTokenData for details. class UserData { UserData({ this.displayName, required this.email, required this.userId, this.photoUrl, - this.serverAuthCode, this.idToken, }); @@ -71,8 +100,6 @@ class UserData { String? photoUrl; - String? serverAuthCode; - String? idToken; Object encode() { @@ -81,7 +108,6 @@ class UserData { email, userId, photoUrl, - serverAuthCode, idToken, ]; } @@ -93,53 +119,145 @@ class UserData { email: result[1]! as String, userId: result[2]! as String, photoUrl: result[3] as String?, - serverAuthCode: result[4] as String?, - idToken: result[5] as String?, + idToken: result[4] as String?, ); } } -/// Pigeon version of GoogleSignInTokenData. +/// The response from an auth call. +class SignInResult { + SignInResult({ + this.success, + this.error, + }); + + /// The success result, if any. + /// + /// Exactly one of success and error will be non-nil. + SignInSuccess? success; + + /// The error result, if any. + /// + /// Exactly one of success and error will be non-nil. + SignInFailure? error; + + Object encode() { + return [ + success, + error, + ]; + } + + static SignInResult decode(Object result) { + result as List; + return SignInResult( + success: result[0] as SignInSuccess?, + error: result[1] as SignInFailure?, + ); + } +} + +/// An sign in failure. +class SignInFailure { + SignInFailure({ + required this.type, + this.message, + this.details, + }); + + /// The type of failure. + GoogleSignInErrorCode type; + + /// The message associated with the failure, if any. + String? message; + + /// Extra details about the failure, if any. + Object? details; + + Object encode() { + return [ + type, + message, + details, + ]; + } + + static SignInFailure decode(Object result) { + result as List; + return SignInFailure( + type: result[0]! as GoogleSignInErrorCode, + message: result[1] as String?, + details: result[2], + ); + } +} + +/// A successful auth result. /// -/// See GoogleSignInTokenData for details. -class TokenData { - TokenData({ - this.idToken, - this.accessToken, +/// Corresponds to the information in a native GIDSignInResult. Because of the +/// structure of the Google Sign In SDK, this has information corresponding to +/// both authn and authz steps, even though incremental authorization is +/// supported. +class SignInSuccess { + SignInSuccess({ + required this.user, + required this.accessToken, + required this.grantedScopes, + this.serverAuthCode, }); - String? idToken; + UserData user; + + String accessToken; - String? accessToken; + List grantedScopes; + + String? serverAuthCode; Object encode() { return [ - idToken, + user, accessToken, + grantedScopes, + serverAuthCode, ]; } - static TokenData decode(Object result) { + static SignInSuccess decode(Object result) { result as List; - return TokenData( - idToken: result[0] as String?, - accessToken: result[1] as String?, + return SignInSuccess( + user: result[0]! as UserData, + accessToken: result[1]! as String, + grantedScopes: (result[2] as List?)!.cast(), + serverAuthCode: result[3] as String?, ); } } -class _GoogleSignInApiCodec extends StandardMessageCodec { - const _GoogleSignInApiCodec(); +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); @override void writeValue(WriteBuffer buffer, Object? value) { - if (value is InitParams) { - buffer.putUint8(128); - writeValue(buffer, value.encode()); - } else if (value is TokenData) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is GoogleSignInErrorCode) { buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is PlatformConfigurationParams) { + buffer.putUint8(130); writeValue(buffer, value.encode()); } else if (value is UserData) { - buffer.putUint8(130); + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is SignInResult) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is SignInFailure) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is SignInSuccess) { + buffer.putUint8(134); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -149,12 +267,19 @@ class _GoogleSignInApiCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 128: - return InitParams.decode(readValue(buffer)!); case 129: - return TokenData.decode(readValue(buffer)!); + final int? value = readValue(buffer) as int?; + return value == null ? null : GoogleSignInErrorCode.values[value]; case 130: + return PlatformConfigurationParams.decode(readValue(buffer)!); + case 131: return UserData.decode(readValue(buffer)!); + case 132: + return SignInResult.decode(readValue(buffer)!); + case 133: + return SignInFailure.decode(readValue(buffer)!); + case 134: + return SignInSuccess.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -165,217 +290,210 @@ class GoogleSignInApi { /// Constructor for [GoogleSignInApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. - GoogleSignInApi({BinaryMessenger? binaryMessenger}) - : _binaryMessenger = binaryMessenger; - final BinaryMessenger? _binaryMessenger; - - static const MessageCodec codec = _GoogleSignInApiCodec(); - - /// Initializes a sign in request with the given parameters. - Future init(InitParams arg_params) async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.init', codec, - binaryMessenger: _binaryMessenger); - final List? replyList = - await channel.send([arg_params]) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { + GoogleSignInApi( + {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Configures the sign in object with application-level parameters. + Future configure(PlatformConfigurationParams params) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.configure$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([params]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { return; } } - /// Starts a silent sign in. - Future signInSilently() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signInSilently', - codec, - binaryMessenger: _binaryMessenger); - final List? replyList = await channel.send(null) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { + /// Attempts to restore an existing sign-in, if any, with minimal user + /// interaction. + Future restorePreviousSignIn() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.restorePreviousSignIn$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (replyList[0] == null) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( code: 'null-error', message: 'Host platform returned null value for non-null return value.', ); } else { - return (replyList[0] as UserData?)!; + return (pigeonVar_replyList[0] as SignInResult?)!; } } /// Starts a sign in with user interaction. - Future signIn() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signIn', codec, - binaryMessenger: _binaryMessenger); - final List? replyList = await channel.send(null) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { + Future signIn(List scopeHint, String? nonce) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signIn$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = await pigeonVar_channel + .send([scopeHint, nonce]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (replyList[0] == null) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( code: 'null-error', message: 'Host platform returned null value for non-null return value.', ); } else { - return (replyList[0] as UserData?)!; + return (pigeonVar_replyList[0] as SignInResult?)!; } } /// Requests the access token for the current sign in. - Future getAccessToken() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.getAccessToken', - codec, - binaryMessenger: _binaryMessenger); - final List? replyList = await channel.send(null) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { + Future getRefreshedAuthorizationTokens(String userId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.getRefreshedAuthorizationTokens$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([userId]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (replyList[0] == null) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( code: 'null-error', message: 'Host platform returned null value for non-null return value.', ); } else { - return (replyList[0] as TokenData?)!; + return (pigeonVar_replyList[0] as SignInResult?)!; } } - /// Signs out the current user. - Future signOut() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signOut', codec, - binaryMessenger: _binaryMessenger); - final List? replyList = await channel.send(null) as List?; - if (replyList == null) { + /// Requests authorization of the given additional scopes. + Future addScopes(List scopes, String userId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.addScopes$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = await pigeonVar_channel + .send([scopes, userId]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); - } else if (replyList.length > 1) { + } else if (pigeonVar_replyList[0] == null) { throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', ); } else { - return; + return (pigeonVar_replyList[0] as SignInResult?)!; } } - /// Revokes scope grants to the application. - Future disconnect() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.disconnect', - codec, - binaryMessenger: _binaryMessenger); - final List? replyList = await channel.send(null) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { + /// Signs out the current user. + Future signOut() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.signOut$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { return; } } - /// Returns whether the user is currently signed in. - Future isSignedIn() async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.isSignedIn', - codec, - binaryMessenger: _binaryMessenger); - final List? replyList = await channel.send(null) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { - throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], - ); - } else if (replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (replyList[0] as bool?)!; - } - } - - /// Requests access to the given scopes. - Future requestScopes(List arg_scopes) async { - final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.requestScopes', - codec, - binaryMessenger: _binaryMessenger); - final List? replyList = - await channel.send([arg_scopes]) as List?; - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel.', - ); - } else if (replyList.length > 1) { - throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], - ); - } else if (replyList[0] == null) { + /// Revokes scope grants to the application. + Future disconnect() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.google_sign_in_ios.GoogleSignInApi.disconnect$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], ); } else { - return (replyList[0] as bool?)!; + return; } } } diff --git a/packages/google_sign_in/google_sign_in_ios/pigeons/messages.dart b/packages/google_sign_in/google_sign_in_ios/pigeons/messages.dart index e7de686ee72a..aae37cdf7280 100644 --- a/packages/google_sign_in/google_sign_in_ios/pigeons/messages.dart +++ b/packages/google_sign_in/google_sign_in_ios/pigeons/messages.dart @@ -7,44 +7,36 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon(PigeonOptions( dartOut: 'lib/src/messages.g.dart', objcHeaderOut: - 'darwin/google_sign_in_ios/Sources/google_sign_in/include/google_sign_in/messages.g.h', + 'darwin/google_sign_in_ios/Sources/google_sign_in_ios/include/google_sign_in_ios/messages.g.h', objcSourceOut: - 'darwin/google_sign_in_ios/Sources/google_sign_in/messages.g.m', + 'darwin/google_sign_in_ios/Sources/google_sign_in_ios/messages.g.m', objcOptions: ObjcOptions( prefix: 'FSI', - headerIncludePath: './include/google_sign_in/messages.g.h', + headerIncludePath: './include/google_sign_in_ios/messages.g.h', ), copyrightHeader: 'pigeons/copyright.txt', )) - -/// Pigeon version of SignInInitParams. -/// -/// See SignInInitParams for details. -class InitParams { - /// The parameters to use when initializing the sign in process. - const InitParams({ - this.scopes = const [], - this.hostedDomain, +class PlatformConfigurationParams { + PlatformConfigurationParams({ this.clientId, this.serverClientId, + this.hostedDomain, }); - final List scopes; - final String? hostedDomain; final String? clientId; final String? serverClientId; + final String? hostedDomain; } -/// Pigeon version of GoogleSignInUserData. +/// Pigeon version of GoogleSignInUserData + AuthenticationTokenData. /// -/// See GoogleSignInUserData for details. +/// See GoogleSignInUserData and AuthenticationTokenData for details. class UserData { UserData({ required this.email, required this.userId, this.displayName, this.photoUrl, - this.serverAuthCode, this.idToken, }); @@ -52,40 +44,110 @@ class UserData { final String email; final String userId; final String? photoUrl; - final String? serverAuthCode; final String? idToken; } -/// Pigeon version of GoogleSignInTokenData. +/// Enum mapping of known codes from +/// https://developers.google.com/identity/sign-in/ios/reference/Enums/GIDSignInErrorCode +enum GoogleSignInErrorCode { + /// Either the underlying kGIDSignInErrorCodeUnknown, or a code that isn't + /// a known code mapped to a value below. + unknown, + + /// kGIDSignInErrorCodeKeychain; an error reading or writing to keychain. + keychainError, + + /// kGIDSignInErrorCodeHasNoAuthInKeychain; no auth present in the keychain. + /// + /// For restorePreviousSignIn, this indicates that there is no sign in to + /// restore. + noAuthInKeychain, + + /// kGIDSignInErrorCodeCanceled; the request was canceled by the user. + canceled, + + /// kGIDSignInErrorCodeEMM; an enterprise management error occurred. + eemError, + + /// kGIDSignInErrorCodeScopesAlreadyGranted; the requested scopes have already + /// been granted. + scopesAlreadyGranted, + + /// kGIDSignInErrorCodeMismatchWithCurrentUser; an operation was requested on + /// a non-current user. + userMismatch, +} + +/// The response from an auth call. +// TODO(stuartmorgan): Switch to a sealed base class with two subclasses instead +// of using composition when the plugin is migrated to Swift. +class SignInResult { + /// The success result, if any. + /// + /// Exactly one of success and error will be non-nil. + SignInSuccess? success; + + /// The error result, if any. + /// + /// Exactly one of success and error will be non-nil. + SignInFailure? error; +} + +/// An sign in failure. +class SignInFailure { + /// The type of failure. + late GoogleSignInErrorCode type; + + /// The message associated with the failure, if any. + String? message; + + /// Extra details about the failure, if any. + Object? details; +} + +/// A successful auth result. /// -/// See GoogleSignInTokenData for details. -class TokenData { - TokenData({ - this.idToken, - this.accessToken, - }); +/// Corresponds to the information in a native GIDSignInResult. Because of the +/// structure of the Google Sign In SDK, this has information corresponding to +/// both authn and authz steps, even though incremental authorization is +/// supported. +class SignInSuccess { + late UserData user; - final String? idToken; - final String? accessToken; + late String accessToken; + + late List grantedScopes; + + // This is set only on a new sign in or scope grant, not a restored sign-in. + // See https://github.com/google/GoogleSignIn-iOS/issues/202 + String? serverAuthCode; } @HostApi() abstract class GoogleSignInApi { - /// Initializes a sign in request with the given parameters. - @ObjCSelector('initializeSignInWithParameters:') - void init(InitParams params); + /// Configures the sign in object with application-level parameters. + @ObjCSelector('configureWithParameters:') + void configure(PlatformConfigurationParams params); - /// Starts a silent sign in. + /// Attempts to restore an existing sign-in, if any, with minimal user + /// interaction. @async - UserData signInSilently(); + SignInResult restorePreviousSignIn(); /// Starts a sign in with user interaction. @async - UserData signIn(); + @ObjCSelector('signInWithScopeHint:nonce:') + SignInResult signIn(List scopeHint, String? nonce); /// Requests the access token for the current sign in. @async - TokenData getAccessToken(); + @ObjCSelector('refreshedAuthorizationTokensForUser:') + SignInResult getRefreshedAuthorizationTokens(String userId); + + /// Requests authorization of the given additional scopes. + @async + @ObjCSelector('addScopes:forUser:') + SignInResult addScopes(List scopes, String userId); /// Signs out the current user. void signOut(); @@ -93,12 +155,4 @@ abstract class GoogleSignInApi { /// Revokes scope grants to the application. @async void disconnect(); - - /// Returns whether the user is currently signed in. - bool isSignedIn(); - - /// Requests access to the given scopes. - @async - @ObjCSelector('requestScopes:') - bool requestScopes(List scopes); } diff --git a/packages/google_sign_in/google_sign_in_ios/pubspec.yaml b/packages/google_sign_in/google_sign_in_ios/pubspec.yaml index 87f3cfffab8a..337842d1bdb8 100644 --- a/packages/google_sign_in/google_sign_in_ios/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: google_sign_in_ios description: iOS implementation of the google_sign_in plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 5.9.0 +version: 6.0.0 environment: sdk: ^3.4.0 @@ -44,3 +44,7 @@ false_secrets: - /example/ios/Runner/Info.plist - /example/lib/main.dart - /example/macos/Runner/Info.plist +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_platform_interface: {path: ../../../packages/google_sign_in/google_sign_in_platform_interface} diff --git a/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.dart b/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.dart index bd9d473a320c..d32abd13bbe6 100644 --- a/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.dart +++ b/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_sign_in_ios/google_sign_in_ios.dart'; import 'package:google_sign_in_ios/src/messages.g.dart'; @@ -12,17 +11,11 @@ import 'package:mockito/mockito.dart'; import 'google_sign_in_ios_test.mocks.dart'; -final GoogleSignInUserData _user = GoogleSignInUserData( - email: 'john.doe@gmail.com', - id: '8162538176523816253123', - photoUrl: 'https://lh5.googleusercontent.com/photo.jpg', - displayName: 'John Doe', - serverAuthCode: '789', - idToken: '123'); - -final GoogleSignInTokenData _token = GoogleSignInTokenData( - idToken: '123', - accessToken: '456', +const GoogleSignInUserData _testUser = GoogleSignInUserData( + email: 'john.doe@gmail.com', + id: '8162538176523816253123', + photoUrl: 'https://lh5.googleusercontent.com/photo.jpg', + displayName: 'John Doe', ); @GenerateMocks([GoogleSignInApi]) @@ -30,11 +23,11 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late GoogleSignInIOS googleSignIn; - late MockGoogleSignInApi api; + late MockGoogleSignInApi mockApi; setUp(() { - api = MockGoogleSignInApi(); - googleSignIn = GoogleSignInIOS(api: api); + mockApi = MockGoogleSignInApi(); + googleSignIn = GoogleSignInIOS(api: mockApi); }); test('registered instance', () { @@ -42,164 +35,972 @@ void main() { expect(GoogleSignInPlatform.instance, isA()); }); - test('init throws for SignInOptions.games', () async { - expect( - () => googleSignIn.init( - hostedDomain: 'example.com', - signInOption: SignInOption.games, - clientId: 'fakeClientId'), - throwsA(isInstanceOf().having( - (PlatformException e) => e.code, 'code', 'unsupported-options'))); + group('init', () { + test('passes expected values', () async { + const String clientId = 'aClient'; + const String serverClientId = 'aServerClient'; + const String hostedDomain = 'example.com'; + + await googleSignIn.init(const InitParameters( + clientId: clientId, + serverClientId: serverClientId, + hostedDomain: hostedDomain, + )); + + final VerificationResult verification = + verify(mockApi.configure(captureAny)); + final PlatformConfigurationParams hostParams = + verification.captured[0] as PlatformConfigurationParams; + expect(hostParams.clientId, clientId); + expect(hostParams.serverClientId, serverClientId); + expect(hostParams.hostedDomain, hostedDomain); + }); }); - test('init throws for forceAccountName', () async { - expect( - () => googleSignIn.initWithParams( - const SignInInitParameters( - hostedDomain: 'example.com', - clientId: 'fakeClientId', - forceAccountName: 'fakeEmailAddress@example.com', - ), - ), - throwsA(isInstanceOf().having( - (ArgumentError e) => e.message, - 'message', - 'Force account name is not supported on iOS'))); - }); - - test('signInSilently transforms platform data to GoogleSignInUserData', - () async { - when(api.signInSilently()).thenAnswer((_) async => UserData( - email: _user.email, - userId: _user.id, - photoUrl: _user.photoUrl, - displayName: _user.displayName, - serverAuthCode: _user.serverAuthCode, - idToken: _user.idToken, - )); - - final dynamic response = await googleSignIn.signInSilently(); - - expect(response, _user); - }); - - test('signInSilently Exceptions -> throws', () async { - when(api.signInSilently()) - .thenAnswer((_) async => throw PlatformException(code: 'fail')); - - expect(googleSignIn.signInSilently(), - throwsA(isInstanceOf())); - }); - - test('signIn transforms platform data to GoogleSignInUserData', () async { - when(api.signIn()).thenAnswer((_) async => UserData( - email: _user.email, - userId: _user.id, - photoUrl: _user.photoUrl, - displayName: _user.displayName, - serverAuthCode: _user.serverAuthCode, - idToken: _user.idToken, - )); - - final dynamic response = await googleSignIn.signIn(); - - expect(response, _user); - }); - - test('signIn Exceptions -> throws', () async { - when(api.signIn()) - .thenAnswer((_) async => throw PlatformException(code: 'fail')); - - expect(googleSignIn.signIn(), throwsA(isInstanceOf())); - }); - - test('getTokens transforms platform data to GoogleSignInTokenData', () async { - const bool recoverAuth = false; - when(api.getAccessToken()).thenAnswer((_) async => - TokenData(idToken: _token.idToken, accessToken: _token.accessToken)); - - final GoogleSignInTokenData response = await googleSignIn.getTokens( - email: _user.email, shouldRecoverAuth: recoverAuth); - - expect(response, _token); + group('attemptLightweightAuthentication', () { + test('passes success data to caller', () async { + const String idToken = 'idToken'; + when(mockApi.restorePreviousSignIn()) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: idToken, + ), + accessToken: '', + grantedScopes: [], + ))); + + final AuthenticationResults? result = + await googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + + expect(result?.user, _testUser); + expect(result?.authenticationTokens.idToken, idToken); + }); + + test('returns null for missing auth', () async { + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: + SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + + final AuthenticationResults? result = + await googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); + + expect(result, null); + }); + + test('throws for other errors', () async { + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.keychainError))); + + expect( + googleSignIn.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.providerConfigurationError))); + }); }); - test('clearAuthCache silently no-ops', () async { - expect(googleSignIn.clearAuthCache(token: 'abc'), completes); + group('authenticate', () { + test('passes nonce if provided', () async { + const String nonce = 'nonce'; + when(mockApi.signIn(any, nonce)).thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: [], + ))); + + await googleSignIn.init(const InitParameters(nonce: nonce)); + await googleSignIn.authenticate(const AuthenticateParameters()); + + verify(mockApi.signIn(any, nonce)); + }); + + test('passes success data to caller', () async { + const String idToken = 'idToken'; + when(mockApi.signIn(any, null)).thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: idToken, + ), + accessToken: '', + grantedScopes: [], + ))); + + final AuthenticationResults result = + await googleSignIn.authenticate(const AuthenticateParameters()); + + expect(result.user, _testUser); + expect(result.authenticationTokens.idToken, idToken); + }); + + test('throws unknown for missing auth', () async { + when(mockApi.signIn(any, null)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf() + .having((GoogleSignInException e) => e.code, 'code', + GoogleSignInExceptionCode.unknownError) + .having((GoogleSignInException e) => e.description, 'description', + contains('No auth reported')))); + }); + + test('throws provider configuration error for keychain error', () async { + when(mockApi.signIn(any, null)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.keychainError))); + + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.providerConfigurationError))); + }); + + test('throws provider configuration error for EEM error', () async { + when(mockApi.signIn(any, null)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.eemError))); + + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.providerConfigurationError))); + }); + + test('throws canceled from SDK', () async { + when(mockApi.signIn(any, null)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.canceled))); + + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); + }); + + test('throws user mismatch from SDK', () async { + when(mockApi.signIn(any, null)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.userMismatch))); + + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.userMismatch))); + }); + + test('throws unknown from SDK', () async { + when(mockApi.signIn(any, null)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.unknown))); + + expect( + googleSignIn.authenticate(const AuthenticateParameters()), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); + }); }); - test('initWithParams passes arguments', () async { - const SignInInitParameters initParams = SignInInitParameters( - hostedDomain: 'example.com', - scopes: ['two', 'scopes'], - clientId: 'fakeClientId', + group('clientAuthorizationTokensForScopes', () { + // Request details used when the details of the request are not relevant to + // the test. + const AuthorizationRequestDetails defaultAuthRequest = + AuthorizationRequestDetails( + scopes: ['a'], + userId: null, + email: null, + promptIfUnauthorized: false, ); - await googleSignIn.init( - hostedDomain: initParams.hostedDomain, - scopes: initParams.scopes, - signInOption: initParams.signInOption, - clientId: initParams.clientId, - ); - - final VerificationResult result = verify(api.init(captureAny)); - final InitParams passedParams = result.captured[0] as InitParams; - expect(passedParams.hostedDomain, initParams.hostedDomain); - expect(passedParams.scopes, initParams.scopes); - expect(passedParams.clientId, initParams.clientId); - // This should use whatever the SignInInitParameters defaults are. - expect(passedParams.serverClientId, initParams.serverClientId); + test('passes expected values to addScopes if interaction is allowed', + () async { + const List scopes = ['a', 'b']; + when(mockApi.addScopes(any, _testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: scopes, + ))); + + await googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))); + + final VerificationResult verification = + verify(mockApi.addScopes(captureAny, _testUser.id)); + final List passedScopes = + verification.captured[0] as List; + expect(passedScopes, scopes); + }); + + test( + 'passes expected values to getRefreshedAuthorizationTokens if ' + 'interaction is not allowed', () async { + const List scopes = ['a', 'b']; + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: scopes, + ))); + + await googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: false, + ))); + + verify(mockApi.getRefreshedAuthorizationTokens(_testUser.id)); + }); + + test('attempts to restore previous sign in if no user is provided', + () async { + const List scopes = ['a', 'b']; + final SignInResult signInResult = SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: [], + )); + when(mockApi.restorePreviousSignIn()) + .thenAnswer((_) async => signInResult); + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => signInResult); + + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: false, + ))); + + // With no user ID provided to clientAuthorizationTokensForScopes, the + // implementation should attempt to restore an existing sign-in, and then + // when that succeeds, get the authorization tokens for that user. + verify(mockApi.restorePreviousSignIn()); + verify(mockApi.getRefreshedAuthorizationTokens(_testUser.id)); + }); + + test('returns null if unauthenticated and interaction is not allowed', + () async { + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: + SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + + final ClientAuthorizationTokenData? result = + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: ['a', 'b'], + userId: null, + email: null, + promptIfUnauthorized: false, + ))); + + // With no user ID provided to clientAuthorizationTokensForScopes, the + // implementation should attempt to restore an existing sign-in, and then + // when that fails, return null since without prompting, there is no way + // to authenticate. + verify(mockApi.restorePreviousSignIn()); + expect(result, null); + }); + + test( + 'attempts to authenticate if no user is provided or already signed in ' + 'and interaction is allowed', () async { + const List scopes = ['a', 'b']; + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: + SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + when(mockApi.signIn(scopes, null)).thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: [], + ))); + + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: true, + ))); + + // With no user ID provided to clientAuthorizationTokensForScopes, the + // implementation should attempt to restore an existing sign-in, and when + // that fails, prompt for a combined authn+authz. + verify(mockApi.restorePreviousSignIn()); + verify(mockApi.signIn(scopes, null)); + }); + + test('passes success data to caller when refreshing existing auth', + () async { + const List scopes = ['a', 'b']; + const String accessToken = 'token'; + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: accessToken, + grantedScopes: scopes, + ))); + + final ClientAuthorizationTokenData? result = + await googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: false, + ))); + + expect(result?.accessToken, accessToken); + }); + + test('passes success data to caller when calling addScopes', () async { + const List scopes = ['a', 'b']; + const String accessToken = 'token'; + when(mockApi.addScopes(scopes, _testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: accessToken, + grantedScopes: scopes, + ))); + + final ClientAuthorizationTokenData? result = + await googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))); + + expect(result?.accessToken, accessToken); + }); + + test( + 'successfully returns refreshed tokens if addScopes indicates the ' + 'requested scopes are already granted', () async { + const List scopes = ['a', 'b']; + const String accessToken = 'token'; + when(mockApi.addScopes(scopes, _testUser.id)).thenAnswer((_) async => + SignInResult( + error: SignInFailure( + type: GoogleSignInErrorCode.scopesAlreadyGranted))); + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: accessToken, + grantedScopes: scopes, + ))); + + final ClientAuthorizationTokenData? result = + await googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))); + + verify(mockApi.addScopes(scopes, _testUser.id)); + verify(mockApi.getRefreshedAuthorizationTokens(_testUser.id)); + + expect(result?.accessToken, accessToken); + }); + + test('returns null if re-using existing auth and scopes are missing', + () async { + const List requestedScopes = ['a', 'b']; + const List grantedScopes = ['a']; + const String accessToken = 'token'; + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: accessToken, + grantedScopes: grantedScopes, + ))); + + final ClientAuthorizationTokenData? result = + await googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: requestedScopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: false, + ))); + + expect(result, null); + }); + + test('returns null when unauthorized', () async { + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: + SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + + expect( + await googleSignIn.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + null); + }); + + test('thows canceled from SDK', () async { + when(mockApi.addScopes(any, any)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.canceled))); + + expect( + googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: const ['a'], + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); + }); + + test('throws unknown from SDK', () async { + when(mockApi.addScopes(any, any)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.unknown))); + + expect( + googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: const ['a'], + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); + }); + + test('throws user mismatch from SDK', () async { + when(mockApi.addScopes(any, any)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.userMismatch))); + + expect( + googleSignIn.clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: const ['a'], + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.userMismatch))); + }); }); - test('initWithParams passes arguments', () async { - const SignInInitParameters initParams = SignInInitParameters( - hostedDomain: 'example.com', - scopes: ['two', 'scopes'], - clientId: 'fakeClientId', - serverClientId: 'fakeServerClientId', - forceCodeForRefreshToken: true, + group('serverAuthorizationTokensForScopes', () { + // Request details used when the details of the request are not relevant to + // the test. + const AuthorizationRequestDetails defaultAuthRequest = + AuthorizationRequestDetails( + scopes: ['a'], + userId: null, + email: null, + promptIfUnauthorized: false, ); - await googleSignIn.initWithParams(initParams); - - final VerificationResult result = verify(api.init(captureAny)); - final InitParams passedParams = result.captured[0] as InitParams; - expect(passedParams.hostedDomain, initParams.hostedDomain); - expect(passedParams.scopes, initParams.scopes); - expect(passedParams.clientId, initParams.clientId); - expect(passedParams.serverClientId, initParams.serverClientId); - }); - - test('requestScopes passes arguments', () async { - const List scopes = ['newScope', 'anotherScope']; - when(api.requestScopes(scopes)).thenAnswer((_) async => true); - - final bool response = await googleSignIn.requestScopes(scopes); - - expect(response, true); + test('passes expected values to addScopes if interaction is allowed', + () async { + const List scopes = ['a', 'b']; + when(mockApi.addScopes(any, _testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: scopes, + ))); + + await googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))); + + final VerificationResult verification = + verify(mockApi.addScopes(captureAny, _testUser.id)); + final List passedScopes = + verification.captured[0] as List; + expect(passedScopes, scopes); + }); + + test( + 'passes expected values to getRefreshedAuthorizationTokens if ' + 'interaction is not allowed', () async { + const List scopes = ['a', 'b']; + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: scopes, + ))); + + await googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: false, + ))); + + verify(mockApi.getRefreshedAuthorizationTokens(_testUser.id)); + }); + + test('attempts to restore previous sign in if no user is provided', + () async { + const List scopes = ['a', 'b']; + final SignInResult signInResult = SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: [], + )); + when(mockApi.restorePreviousSignIn()) + .thenAnswer((_) async => signInResult); + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => signInResult); + + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: false, + ))); + + // With no user ID provided to serverAuthorizationTokensForScopes, the + // implementation should attempt to restore an existing sign-in, and then + // when that succeeds, get the authorization tokens for that user. + verify(mockApi.restorePreviousSignIn()); + verify(mockApi.getRefreshedAuthorizationTokens(_testUser.id)); + }); + + test('returns null if unauthenticated and interaction is not allowed', + () async { + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: + SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + + final ServerAuthorizationTokenData? result = + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: ['a', 'b'], + userId: null, + email: null, + promptIfUnauthorized: false, + ))); + + // With no user ID provided to serverAuthorizationTokensForScopes, the + // implementation should attempt to restore an existing sign-in, and then + // when that fails, return null since without prompting, there is no way + // to authenticate. + verify(mockApi.restorePreviousSignIn()); + expect(result, null); + }); + + test( + 'attempts to authenticate if no user is provided or already signed in ' + 'and interaction is allowed', () async { + const List scopes = ['a', 'b']; + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: + SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + when(mockApi.signIn(scopes, null)).thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: '', + ), + accessToken: '', + grantedScopes: [], + ))); + + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: true, + ))); + + // With no user ID provided to serverAuthorizationTokensForScopes, the + // implementation should attempt to restore an existing sign-in, and when + // that fails, prompt for a combined authn+authz. + verify(mockApi.restorePreviousSignIn()); + verify(mockApi.signIn(scopes, null)); + }); + + test('passes success data to caller when refreshing existing auth', + () async { + const List scopes = ['a', 'b']; + const String serverAuthCode = 'authCode'; + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: 'token', + serverAuthCode: serverAuthCode, + grantedScopes: scopes, + ))); + + final ServerAuthorizationTokenData? result = + await googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: false, + ))); + + expect(result?.serverAuthCode, serverAuthCode); + }); + + test('passes success data to caller when calling addScopes', () async { + const List scopes = ['a', 'b']; + const String serverAuthCode = 'authCode'; + when(mockApi.addScopes(scopes, _testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: 'token', + serverAuthCode: serverAuthCode, + grantedScopes: scopes, + ))); + + final ServerAuthorizationTokenData? result = + await googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))); + + expect(result?.serverAuthCode, serverAuthCode); + }); + + test( + 'successfully returns refreshed tokens if addScopes indicates the ' + 'requested scopes are already granted', () async { + const List scopes = ['a', 'b']; + const String serverAuthCode = 'authCode'; + when(mockApi.addScopes(scopes, _testUser.id)).thenAnswer((_) async => + SignInResult( + error: SignInFailure( + type: GoogleSignInErrorCode.scopesAlreadyGranted))); + when(mockApi.getRefreshedAuthorizationTokens(_testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: 'token', + serverAuthCode: serverAuthCode, + grantedScopes: scopes, + ))); + + final ServerAuthorizationTokenData? result = + await googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))); + + verify(mockApi.addScopes(scopes, _testUser.id)); + verify(mockApi.getRefreshedAuthorizationTokens(_testUser.id)); + + expect(result?.serverAuthCode, serverAuthCode); + }); + + test('returns null if re-using existing auth and scopes are missing', + () async { + const List requestedScopes = ['a', 'b']; + const List grantedScopes = ['a']; + const String accessToken = 'token'; + when(mockApi.addScopes(requestedScopes, _testUser.id)) + .thenAnswer((_) async => SignInResult( + success: SignInSuccess( + user: UserData( + displayName: _testUser.displayName, + email: _testUser.email, + userId: _testUser.id, + photoUrl: _testUser.photoUrl, + idToken: 'idToken', + ), + accessToken: accessToken, + grantedScopes: grantedScopes, + ))); + + final ServerAuthorizationTokenData? result = + await googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: requestedScopes, + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))); + + expect(result, null); + }); + + test('returns null when unauthorized', () async { + when(mockApi.restorePreviousSignIn()).thenAnswer((_) async => + SignInResult( + error: + SignInFailure(type: GoogleSignInErrorCode.noAuthInKeychain))); + + expect( + await googleSignIn.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: defaultAuthRequest)), + null); + }); + + test('thows canceled from SDK', () async { + when(mockApi.addScopes(any, any)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.canceled))); + + expect( + googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: const ['a'], + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.canceled))); + }); + + test('throws unknown from SDK', () async { + when(mockApi.addScopes(any, any)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.unknown))); + + expect( + googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: const ['a'], + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.unknownError))); + }); + + test('throws user mismatch from SDK', () async { + when(mockApi.addScopes(any, any)).thenAnswer((_) async => SignInResult( + error: SignInFailure(type: GoogleSignInErrorCode.userMismatch))); + + expect( + googleSignIn.serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: const ['a'], + userId: _testUser.id, + email: _testUser.email, + promptIfUnauthorized: true, + ))), + throwsA(isInstanceOf().having( + (GoogleSignInException e) => e.code, + 'code', + GoogleSignInExceptionCode.userMismatch))); + }); }); test('signOut calls through', () async { - await googleSignIn.signOut(); + await googleSignIn.signOut(const SignOutParams()); - verify(api.signOut()); + verify(mockApi.signOut()); }); - test('disconnect calls through', () async { - await googleSignIn.disconnect(); + test('disconnect calls through and also signs out', () async { + await googleSignIn.disconnect(const DisconnectParams()); - verify(api.disconnect()); + verifyInOrder(>[ + mockApi.disconnect(), + mockApi.signOut(), + ]); }); - test('isSignedIn passes true response', () async { - when(api.isSignedIn()).thenAnswer((_) async => true); - - expect(await googleSignIn.isSignedIn(), true); - }); - - test('isSignedIn passes false response', () async { - when(api.isSignedIn()).thenAnswer((_) async => false); - - expect(await googleSignIn.isSignedIn(), false); + // Returning null triggers the app-facing package to create stream events, + // per GoogleSignInPlatform docs, so it's important that this returns null + // unless the platform implementation is changed to create all necessary + // notifications. + test('authenticationEvents returns null', () async { + expect(googleSignIn.authenticationEvents, null); }); } diff --git a/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.mocks.dart b/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.mocks.dart index b1414eb47600..7c4871506d36 100644 --- a/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in_ios/test/google_sign_in_ios_test.mocks.dart @@ -1,12 +1,13 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in google_sign_in_ios/test/google_sign_in_ios_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i3; +import 'dart:async' as _i4; import 'package:google_sign_in_ios/src/messages.g.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i3; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -16,29 +17,15 @@ 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 _FakeUserData_0 extends _i1.SmartFake implements _i2.UserData { - _FakeUserData_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeTokenData_1 extends _i1.SmartFake implements _i2.TokenData { - _FakeTokenData_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); +class _FakeSignInResult_0 extends _i1.SmartFake implements _i2.SignInResult { + _FakeSignInResult_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [GoogleSignInApi]. @@ -50,96 +37,85 @@ class MockGoogleSignInApi extends _i1.Mock implements _i2.GoogleSignInApi { } @override - _i3.Future init(_i2.InitParams? arg_params) => (super.noSuchMethod( - Invocation.method( - #init, - [arg_params], + String get pigeonVar_messageChannelSuffix => (super.noSuchMethod( + Invocation.getter(#pigeonVar_messageChannelSuffix), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#pigeonVar_messageChannelSuffix), ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); + ) as String); @override - _i3.Future<_i2.UserData> signInSilently() => (super.noSuchMethod( - Invocation.method( - #signInSilently, - [], - ), - returnValue: _i3.Future<_i2.UserData>.value(_FakeUserData_0( - this, - Invocation.method( - #signInSilently, - [], - ), - )), - ) as _i3.Future<_i2.UserData>); + _i4.Future configure(_i2.PlatformConfigurationParams? params) => + (super.noSuchMethod( + Invocation.method(#configure, [params]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); @override - _i3.Future<_i2.UserData> signIn() => (super.noSuchMethod( - Invocation.method( - #signIn, - [], - ), - returnValue: _i3.Future<_i2.UserData>.value(_FakeUserData_0( - this, - Invocation.method( - #signIn, - [], + _i4.Future<_i2.SignInResult> restorePreviousSignIn() => (super.noSuchMethod( + Invocation.method(#restorePreviousSignIn, []), + returnValue: _i4.Future<_i2.SignInResult>.value( + _FakeSignInResult_0( + this, + Invocation.method(#restorePreviousSignIn, []), ), - )), - ) as _i3.Future<_i2.UserData>); + ), + ) as _i4.Future<_i2.SignInResult>); @override - _i3.Future<_i2.TokenData> getAccessToken() => (super.noSuchMethod( - Invocation.method( - #getAccessToken, - [], - ), - returnValue: _i3.Future<_i2.TokenData>.value(_FakeTokenData_1( - this, - Invocation.method( - #getAccessToken, - [], + _i4.Future<_i2.SignInResult> signIn(List? scopeHint, String? nonce) => + (super.noSuchMethod( + Invocation.method(#signIn, [scopeHint, nonce]), + returnValue: _i4.Future<_i2.SignInResult>.value( + _FakeSignInResult_0( + this, + Invocation.method(#signIn, [scopeHint, nonce]), ), - )), - ) as _i3.Future<_i2.TokenData>); + ), + ) as _i4.Future<_i2.SignInResult>); @override - _i3.Future signOut() => (super.noSuchMethod( - Invocation.method( - #signOut, - [], + _i4.Future<_i2.SignInResult> getRefreshedAuthorizationTokens( + String? userId, + ) => + (super.noSuchMethod( + Invocation.method(#getRefreshedAuthorizationTokens, [userId]), + returnValue: _i4.Future<_i2.SignInResult>.value( + _FakeSignInResult_0( + this, + Invocation.method(#getRefreshedAuthorizationTokens, [userId]), + ), ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); + ) as _i4.Future<_i2.SignInResult>); @override - _i3.Future disconnect() => (super.noSuchMethod( - Invocation.method( - #disconnect, - [], + _i4.Future<_i2.SignInResult> addScopes( + List? scopes, + String? userId, + ) => + (super.noSuchMethod( + Invocation.method(#addScopes, [scopes, userId]), + returnValue: _i4.Future<_i2.SignInResult>.value( + _FakeSignInResult_0( + this, + Invocation.method(#addScopes, [scopes, userId]), + ), ), - returnValue: _i3.Future.value(), - returnValueForMissingStub: _i3.Future.value(), - ) as _i3.Future); + ) as _i4.Future<_i2.SignInResult>); @override - _i3.Future isSignedIn() => (super.noSuchMethod( - Invocation.method( - #isSignedIn, - [], - ), - returnValue: _i3.Future.value(false), - ) as _i3.Future); + _i4.Future signOut() => (super.noSuchMethod( + Invocation.method(#signOut, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); @override - _i3.Future requestScopes(List? arg_scopes) => - (super.noSuchMethod( - Invocation.method( - #requestScopes, - [arg_scopes], - ), - returnValue: _i3.Future.value(false), - ) as _i3.Future); + _i4.Future disconnect() => (super.noSuchMethod( + Invocation.method(#disconnect, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); } diff --git a/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md b/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md index d12f577b7d3d..68c44da1f25f 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md @@ -1,3 +1,10 @@ +## 3.0.0 + +* **BREAKING CHANGE**: Overhauls the entire API surface to better abstract the + current set of underlying platform SDKs, and to use structured errors. See + API doc comments for details on the behaviors that platform implementations + must implement. + ## 2.5.0 * Adds a sign-in field to allow Android clients to explicitly specify an account name. diff --git a/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart b/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart index 110097f0dcb6..80a46f3e837c 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart @@ -4,13 +4,10 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'src/method_channel_google_sign_in.dart'; import 'src/types.dart'; -export 'src/method_channel_google_sign_in.dart'; export 'src/types.dart'; /// The interface that implementations of google_sign_in must implement. @@ -27,134 +24,129 @@ abstract class GoogleSignInPlatform extends PlatformInterface { static final Object _token = Object(); - /// Only mock implementations should set this to `true`. + /// The instance of [GoogleSignInPlatform] to use. /// - /// Mockito mocks implement this class with `implements` which is forbidden - /// (see class docs). This property provides a backdoor for mocks to skip the - /// verification that the class isn't implemented with `implements`. - @visibleForTesting - @Deprecated('Use MockPlatformInterfaceMixin instead') - bool get isMock => false; - - /// The default instance of [GoogleSignInPlatform] to use. - /// - /// Platform-specific plugins should override this with their own + /// Platform-implementations should override this with their own /// platform-specific class that extends [GoogleSignInPlatform] when they /// register themselves. /// /// Defaults to [MethodChannelGoogleSignIn]. static GoogleSignInPlatform get instance => _instance; - static GoogleSignInPlatform _instance = MethodChannelGoogleSignIn(); + static GoogleSignInPlatform _instance = _PlaceholderImplementation(); - // TODO(amirh): Extract common platform interface logic. - // https://github.com/flutter/flutter/issues/43368 static set instance(GoogleSignInPlatform instance) { - if (!instance.isMock) { - PlatformInterface.verify(instance, _token); - } + PlatformInterface.verify(instance, _token); _instance = instance; } - /// Initializes the plugin. Deprecated: call [initWithParams] instead. + /// Initializes the plugin with specified [params]. You must call this method + /// before calling other methods. /// - /// 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. + /// See: /// - /// 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: + /// * [InitParameters] + Future init(InitParameters params); + + /// Attempts to sign in without an explicit user intent. /// - /// The [signInOption] determines the user experience. [SigninOption.games] is - /// only supported on Android. + /// This is intended to support the use case where the user might be expected + /// to be signed in, but hasn't explicitly requested sign in, such as when + /// launching an application that is intended to be used while signed in. /// - /// See: - /// https://developers.google.com/identity/sign-in/web/reference#gapiauth2initparams - Future init({ - List scopes = const [], - SignInOption signInOption = SignInOption.standard, - String? hostedDomain, - String? clientId, - }) async { - throw UnimplementedError('init() has not been implemented.'); - } + /// This may be silent, or may show minimal UI, depending on the platform and + /// the context. + Future? attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params); - /// Initializes the plugin with specified [params]. You must call this method - /// before calling other methods. + /// Signs in with explicit user intent. /// - /// See: + /// This is intended to support the use case where the user has expressed + /// an explicit intent to sign in. + Future authenticate(AuthenticateParameters params); + + /// Returns true if the platform implementation supports the [authenticate] + /// method. /// - /// * [SignInInitParameters] - Future initWithParams(SignInInitParameters params) async { - await init( - scopes: params.scopes, - signInOption: params.signInOption, - hostedDomain: params.hostedDomain, - clientId: params.clientId, - ); - } + /// The default is true, but platforms that cannot support [authenticate] can + /// override this to return false, throw [UnsupportedError] from + /// [authenticate], and provide a different, platform-specific authentication + /// flow. + bool supportsAuthenticate() => true; - /// Attempts to reuse pre-existing credentials to sign in again, without user interaction. - Future signInSilently() async { - throw UnimplementedError('signInSilently() has not been implemented.'); - } + /// Returns the tokens used to authenticate other API calls from a client. + /// + /// This should only return null if prompting would be necessary but [params] + /// do not allow it, otherwise any failure should return an error. + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params); - /// Signs in the user with the options specified to [init]. - Future signIn() async { - throw UnimplementedError('signIn() has not been implemented.'); - } + /// Returns the tokens used to authenticate other API calls from a server. + /// + /// This should only return null if prompting would be necessary but [params] + /// do not allow it, otherwise any failure should return an error. + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params); - /// Returns the Tokens used to authenticate other API calls. - Future getTokens( - {required String email, bool? shouldRecoverAuth}) async { - throw UnimplementedError('getTokens() has not been implemented.'); - } + /// Signs out previously signed in accounts. + Future signOut(SignOutParams params); + + /// Revokes all of the scopes that all signed in users granted, and then them + /// out. + Future disconnect(DisconnectParams params); + + /// Returns a stream of authentication events. + /// + /// If this is not overridden, the app-facing package will assume that the + /// futures returned by [attemptLightweightAuthentication], [authenticate], + /// and [signOut] are the only sources of authentication-related events. + /// Implementations that have other sources should override this and provide + /// a stream with all authentication and sign-out events. + /// These will normally come from asynchronous flows, like the authenticate + /// and signOut methods, as well as potentially from platform-specific methods + /// (such as the Google Sign-In Button Widget from the Web implementation). + Stream? get authenticationEvents => null; +} - /// Signs out the current account from the application. - Future signOut() async { - throw UnimplementedError('signOut() has not been implemented.'); +/// An implementation of GoogleSignInPlatform that throws unimplemented errors, +/// to use as a default instance if no platform implementation has been +/// registered. +class _PlaceholderImplementation extends GoogleSignInPlatform { + @override + Future init(InitParameters params) { + throw UnimplementedError(); } - /// Revokes all of the scopes that the user granted. - Future disconnect() async { - throw UnimplementedError('disconnect() has not been implemented.'); + @override + Future attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params) { + throw UnimplementedError(); } - /// Returns whether the current user is currently signed in. - Future isSignedIn() async { - throw UnimplementedError('isSignedIn() has not been implemented.'); + @override + Future authenticate(AuthenticateParameters params) { + throw UnimplementedError(); } - /// Clears any cached information that the plugin may be holding on to. - Future clearAuthCache({required String token}) async { - throw UnimplementedError('clearAuthCache() has not been implemented.'); + @override + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params) { + throw UnimplementedError(); } - /// Requests the user grants additional Oauth [scopes]. - /// - /// Scopes should come from the full list - /// [here](https://developers.google.com/identity/protocols/googlescopes). - Future requestScopes(List scopes) async { - throw UnimplementedError('requestScopes() has not been implemented.'); + @override + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params) { + throw UnimplementedError(); } - /// Checks if the current user has granted access to all the specified [scopes]. - /// - /// Optionally, an [accessToken] can be passed for applications where a - /// long-lived token may be cached (like the web). - Future canAccessScopes( - List scopes, { - String? accessToken, - }) async { - throw UnimplementedError('canAccessScopes() has not been implemented.'); + @override + Future signOut(SignOutParams params) { + throw UnimplementedError(); } - /// Returns a stream of [GoogleSignInUserData] authentication events. - /// - /// These will normally come from asynchronous flows, like the Google Sign-In - /// Button Widget from the Web implementation, and will be funneled directly - /// to the `onCurrentUserChanged` Stream of the plugin. - Stream? get userDataEvents => null; + @override + Future disconnect(DisconnectParams params) { + throw UnimplementedError(); + } } diff --git a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/method_channel_google_sign_in.dart b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/method_channel_google_sign_in.dart deleted file mode 100644 index fde29aeb8e4d..000000000000 --- a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/method_channel_google_sign_in.dart +++ /dev/null @@ -1,101 +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/foundation.dart' show visibleForTesting; -import 'package:flutter/services.dart'; - -import '../google_sign_in_platform_interface.dart'; -import 'utils.dart'; - -/// An implementation of [GoogleSignInPlatform] that uses method channels. -class MethodChannelGoogleSignIn extends GoogleSignInPlatform { - /// This is only exposed for test purposes. It shouldn't be used by clients of - /// the plugin as it may break or change at any time. - @visibleForTesting - MethodChannel channel = - const MethodChannel('plugins.flutter.io/google_sign_in'); - - @override - Future init({ - List scopes = const [], - SignInOption signInOption = SignInOption.standard, - String? hostedDomain, - String? clientId, - }) { - return initWithParams(SignInInitParameters( - scopes: scopes, - signInOption: signInOption, - hostedDomain: hostedDomain, - clientId: clientId)); - } - - @override - Future initWithParams(SignInInitParameters params) { - return channel.invokeMethod('init', { - 'signInOption': params.signInOption.toString(), - 'scopes': params.scopes, - 'hostedDomain': params.hostedDomain, - 'clientId': params.clientId, - 'serverClientId': params.serverClientId, - 'forceCodeForRefreshToken': params.forceCodeForRefreshToken, - }); - } - - @override - Future signInSilently() { - return channel - .invokeMapMethod('signInSilently') - .then(getUserDataFromMap); - } - - @override - Future signIn() { - return channel - .invokeMapMethod('signIn') - .then(getUserDataFromMap); - } - - @override - Future getTokens( - {required String email, bool? shouldRecoverAuth = true}) { - return channel - .invokeMapMethod('getTokens', { - 'email': email, - 'shouldRecoverAuth': shouldRecoverAuth, - }).then((Map? result) => getTokenDataFromMap(result!)); - } - - @override - Future signOut() { - return channel.invokeMapMethod('signOut'); - } - - @override - Future disconnect() { - return channel.invokeMapMethod('disconnect'); - } - - @override - Future isSignedIn() async { - return (await channel.invokeMethod('isSignedIn'))!; - } - - @override - Future clearAuthCache({required String token}) { - return channel.invokeMethod( - 'clearAuthCache', - {'token': token}, - ); - } - - @override - Future requestScopes(List scopes) async { - return (await channel.invokeMethod( - 'requestScopes', - >{'scopes': scopes}, - ))!; - } -} diff --git a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart index 057927d5164b..047462fe22ea 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/types.dart @@ -2,24 +2,72 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart' show immutable; -/// Default configuration options to use when signing in. +/// An exception throws by the plugin when there is authenication or +/// authorization failure, or some other error. +@immutable +class GoogleSignInException implements Exception { + /// Crceates a new exception with the given information. + const GoogleSignInException( + {required this.code, this.description, this.details}); + + /// The type of failure. + final GoogleSignInExceptionCode code; + + /// A human-readable description of the failure. + final String? description; + + /// Any additional details about the failure. + final Object? details; + + @override + String toString() => + 'GoogleSignInException(code $code, $description, $details)'; +} + +/// Types of [GoogleSignInException]s, as indicated by +/// [GoogleSignInException.code]. /// -/// See also https://developers.google.com/android/reference/com/google/android/gms/auth/api/signin/GoogleSignInOptions -enum SignInOption { - /// Default configuration. Provides stable user ID and basic profile information. +/// Adding new values to this enum in the future will *not* be considered a +/// breaking change, so clients should not assume they can exhaustively match +/// exception codes. Clients should always include a default or other fallback. +enum GoogleSignInExceptionCode { + /// A catch-all for implemenatations that need to return a code that does not + /// have a corresponding known code. /// - /// See also https://developers.google.com/android/reference/com/google/android/gms/auth/api/signin/GoogleSignInOptions.html#DEFAULT_SIGN_IN. - standard, + /// Whenever possible, implementators should update the platform interface to + /// add new codes instead of using this type. When it is used, the + /// [GoogleSignInException.description] should have information allowing + /// developers to understand the issue. + unknownError, - /// Recommended configuration for Games sign in. + /// The operation was canceled by the user. + canceled, + + /// The operation was interrupted for a reason other than being intentionally + /// canceled by the user. + interrupted, + + /// The client is misconfigured. /// - /// This is currently only supported on Android and will throw an error if used - /// on other platforms. + /// The [GoogleSignInException.description] should include details about the + /// configuration problem. + clientConfigurationError, + + /// The underlying auth SDK is unavailable or misconfigured. + providerConfigurationError, + + /// UI needed to be displayed, but could not be. /// - /// See also https://developers.google.com/android/reference/com/google/android/gms/auth/api/signin/GoogleSignInOptions.html#public-static-final-googlesigninoptions-default_games_sign_in. - games + /// For example, this can be returned on Android if a call tries to show UI + /// when no Activity is available. + uiUnavailable, + + /// An operation was attempted on a user who is not the current user, on a + /// platform where the SDK only supports a single user being signed in at a + /// time. + userMismatch, } /// The parameters to use when initializing the sign in process. @@ -27,29 +75,15 @@ enum SignInOption { /// See: /// https://developers.google.com/identity/sign-in/web/reference#gapiauth2initparams @immutable -class SignInInitParameters { +class InitParameters { /// The parameters to use when initializing the sign in process. - const SignInInitParameters({ - this.scopes = const [], - this.signInOption = SignInOption.standard, - this.hostedDomain, + const InitParameters({ this.clientId, this.serverClientId, - this.forceCodeForRefreshToken = false, - this.forceAccountName, + this.nonce, + this.hostedDomain, }); - /// The list of OAuth scope codes to request when signing in. - final List scopes; - - /// The user experience to use when signing in. [SignInOption.games] is - /// only supported on Android. - final SignInOption signInOption; - - /// Restricts sign in to accounts of the user in the specified domain. - /// By default, the list of accounts will not be restricted. - final String? hostedDomain; - /// The OAuth client ID of the app. /// /// The default is null, which means that the client ID will be sourced from a @@ -74,128 +108,320 @@ class SignInInitParameters { /// where you can find the details about the configuration files. final String? serverClientId; - /// If true, ensures the authorization code can be exchanged for an access - /// token. + /// An optional nonce for added security in ID token requests. + final String? nonce; + + /// A hosted domain to restrict accounts to. + /// + /// The default is null, meaning no restriction. /// - /// This is only used on Android. - final bool forceCodeForRefreshToken; + /// How this restriction is interpreted if provided may vary by platform. + // This is in init paramater because different platforms apply it at different + // stages, and there is no expected use case for an instance varying in + // hosting restriction across calls, so this allows each implemented to handle + // it however best applies to its underlying SDK. + final String? hostedDomain; +} + +/// Parameters for the attemptLightweightAuthentication method. +@immutable +class AttemptLightweightAuthenticationParameters { + /// Creates new authentication parameters. + const AttemptLightweightAuthenticationParameters(); - /// Can be used to explicitly set an account name on the underlying platform sign-in API. + // This class exists despite currently being empty to allow future addition of + // parameters without breaking changes. +} + +/// Parameters for the authenticate method. +@immutable +class AuthenticateParameters { + /// Creates new authentication parameters. + const AuthenticateParameters({this.scopeHint = const []}); + + /// A list of scopes that the application will attempt to use/request + /// immediately. + /// + /// Implementations should ignore this paramater unless the underlying SDK + /// provides a combined authentication+authorization UI flow. Clients are + /// responsible for triggering an explicit authorization flow if authorization + /// isn't granted. + final List scopeHint; +} + +/// Common elements of authorization method parameters. +/// +/// Fields should be added here if they would apply to most or all authorization +/// requests, in particular if they apply to both +/// [ClientAuthorizationTokensForScopesParameters] and +/// [ServerAuthorizationTokensForScopesParameters]. +@immutable +class AuthorizationRequestDetails { + /// Creates a new authorization request specification. + const AuthorizationRequestDetails({ + required this.scopes, + required this.userId, + required this.email, + required this.promptIfUnauthorized, + }); + + /// The scopes to be authorized. + final List scopes; + + /// The account to authorize. + /// + /// If this is not specified, the platform implementation will determine the + /// account, and the method of doing so may vary by platform. For instance, + /// it may use the last account that was signed in, or it may prompt for + /// authentication as part of the authorization flow. + final String? userId; + + /// The email address of the account to authorize. + /// + /// Some platforms reference accounts by email at the SDK level, so this + /// should be provided if userId is provided. + final String? email; + + /// Whether to allow showing UI if the authorizations are not already + /// available without UI. /// - /// This should only be set on Android; other platforms may throw. - final String? forceAccountName; + /// Implementations should guarantee the 'false' behavior; if an underlying + /// SDK method may or may not show UI, and the wrapper cannot reliably + /// determine in advance, it should fail rather than call that method if + /// this parameter is false. + final bool promptIfUnauthorized; +} + +/// Parameters for the clientAuthorizationTokensForScopes method. +// +// This is distinct from [AuthorizationRequestDetails] to allow for divergence +// in method paramaters in the future without breaking changes. +@immutable +class ClientAuthorizationTokensForScopesParameters { + /// Creates a new parameter object with the given details. + const ClientAuthorizationTokensForScopesParameters({ + required this.request, + }); + + /// Details about the authorization request. + final AuthorizationRequestDetails request; +} + +/// Parameters for the serverAuthorizationTokensForScopes method. +// +// This is distinct from [AuthorizationRequestDetails] to allow for divergence +// in method paramaters in the future without breaking changes. +@immutable +class ServerAuthorizationTokensForScopesParameters { + /// Creates a new parameter object with the given details. + const ServerAuthorizationTokensForScopesParameters({ + required this.request, + }); + + /// Details about the authorization request. + final AuthorizationRequestDetails request; } -/// Holds information about the signed in user. +/// Holds information about the signed-in user. +@immutable class GoogleSignInUserData { /// Uses the given data to construct an instance. - GoogleSignInUserData({ + const GoogleSignInUserData({ required this.email, required this.id, this.displayName, this.photoUrl, - this.idToken, - this.serverAuthCode, }); - /// The display name of the signed in user. + /// The user's display name. /// /// Not guaranteed to be present for all users, even when configured. - String? displayName; + final String? displayName; - /// The email address of the signed in user. + /// The user's email address. /// - /// Applications should not key users by email address since a Google account's - /// email address can change. Use [id] as a key instead. + /// Applications should not key users by email address since a Google + /// account's email address can change. Use [id] as a key instead. /// - /// _Important_: Do not use this returned email address to communicate the - /// currently signed in user to your backend server. Instead, send an ID token - /// which can be securely validated on the server. See [idToken]. - String email; + /// This should not be used to communicate the currently signed in user to a + /// backend server. Instead, send an ID token which can be securely validated + /// on the server. See [AuthenticationTokenData.idToken]. + final String email; - /// The unique ID for the Google account. + /// The user's unique account ID. /// /// This is the preferred unique key to use for a user record. /// - /// _Important_: Do not use this returned Google ID to communicate the - /// currently signed in user to your backend server. Instead, send an ID token - /// which can be securely validated on the server. See [idToken]. - String id; + /// This should not be used to communicate the currently signed in user to a + /// backend server. Instead, send an ID token which can be securely validated + /// on the server. See [AuthenticationTokenData.idToken]. + final String id; - /// The photo url of the signed in user if the user has a profile picture. + /// The user's profile picture URL. /// /// Not guaranteed to be present for all users, even when configured. - String? photoUrl; + final String? photoUrl; + + @override + int get hashCode => Object.hash(displayName, email, id, photoUrl); + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is GoogleSignInUserData && + other.displayName == displayName && + other.email == email && + other.id == id && + other.photoUrl == photoUrl; + } +} + +/// Holds tokens that result from authentication. +@immutable +class AuthenticationTokenData { + /// Creates authentication data with the given tokens. + const AuthenticationTokenData({ + required this.idToken, + }); /// A token that can be sent to your own server to verify the authentication /// data. - String? idToken; - - /// Server auth code used to access Google Login - String? serverAuthCode; + final String? idToken; @override - // TODO(stuartmorgan): Make this class immutable in the next breaking change. - // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => - Object.hash(displayName, email, id, photoUrl, idToken, serverAuthCode); + int get hashCode => idToken.hashCode; @override - // TODO(stuartmorgan): Make this class immutable in the next breaking change. - // ignore: avoid_equals_and_hash_code_on_mutable_classes bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other is! GoogleSignInUserData) { + if (other.runtimeType != runtimeType) { return false; } - final GoogleSignInUserData otherUserData = other; - return otherUserData.displayName == displayName && - otherUserData.email == email && - otherUserData.id == id && - otherUserData.photoUrl == photoUrl && - otherUserData.idToken == idToken && - otherUserData.serverAuthCode == serverAuthCode; + return other is AuthenticationTokenData && other.idToken == idToken; } } -/// Holds authentication data after sign in. -class GoogleSignInTokenData { - /// Build `GoogleSignInTokenData`. - GoogleSignInTokenData({ - this.idToken, - this.accessToken, - this.serverAuthCode, +/// Holds tokens that result from authorization for a client endpoint. +@immutable +class ClientAuthorizationTokenData { + /// Creates authorization data with the given tokens. + const ClientAuthorizationTokenData({ + required this.accessToken, }); - /// An OpenID Connect ID token for the authenticated user. - String? idToken; - /// The OAuth2 access token used to access Google services. - String? accessToken; - - /// Server auth code used to access Google Login - String? serverAuthCode; + final String accessToken; @override - // TODO(stuartmorgan): Make this class immutable in the next breaking change. - // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hash(idToken, accessToken, serverAuthCode); + int get hashCode => accessToken.hashCode; @override - // TODO(stuartmorgan): Make this class immutable in the next breaking change. - // ignore: avoid_equals_and_hash_code_on_mutable_classes bool operator ==(Object other) { - if (identical(this, other)) { - return true; + if (other.runtimeType != runtimeType) { + return false; } - if (other is! GoogleSignInTokenData) { + return other is ClientAuthorizationTokenData && + other.accessToken == accessToken; + } +} + +/// Holds tokens that result from authorization for a server endpoint. +@immutable +class ServerAuthorizationTokenData { + /// Creates authorization data with the given tokens. + const ServerAuthorizationTokenData({ + required this.serverAuthCode, + }); + + /// Auth code to provide to a backend server to exchange for access or + /// refresh tokens. + final String serverAuthCode; + + @override + int get hashCode => serverAuthCode.hashCode; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { return false; } - final GoogleSignInTokenData otherTokenData = other; - return otherTokenData.idToken == idToken && - otherTokenData.accessToken == accessToken && - otherTokenData.serverAuthCode == serverAuthCode; + return other is ServerAuthorizationTokenData && + other.serverAuthCode == serverAuthCode; } } + +/// Return value for authentication request methods. +/// +/// Contains information about the authenticated user, as well as authentication +/// tokens. +@immutable +class AuthenticationResults { + /// Creates a new result object. + const AuthenticationResults( + {required this.user, required this.authenticationTokens}); + + /// The user that was authenticated. + final GoogleSignInUserData user; + + /// Authentication tokens for the signed-in user. + final AuthenticationTokenData authenticationTokens; +} + +/// Parameters for the signOut method. +@immutable +class SignOutParams { + /// Creates new sign-out parameters. + const SignOutParams(); + + // This class exists despite currently being empty to allow future addition of + // parameters without breaking changes. +} + +/// Parameters for the disconnect method. +@immutable +class DisconnectParams { + /// Creates new disconnect parameters. + const DisconnectParams(); + + // This class exists despite currently being empty to allow future addition of + // parameters without breaking changes. +} + +/// A base class for authentication event streams. +@immutable +sealed class AuthenticationEvent { + const AuthenticationEvent(); +} + +/// A sign-in event, corresponding to an authentication flow completing +/// successfully. +@immutable +class AuthenticationEventSignIn extends AuthenticationEvent { + /// Creates an event for a successful sign in. + const AuthenticationEventSignIn( + {required this.user, required this.authenticationTokens}); + + /// The user that was authenticated. + final GoogleSignInUserData user; + + /// Authentication tokens for the signed-in user. + final AuthenticationTokenData authenticationTokens; +} + +/// 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 AuthenticationEventSignOut extends AuthenticationEvent {} + +/// An authentication failure that resulted in an exception. +@immutable +class AuthenticationEventException extends AuthenticationEvent { + /// Creates an exception event. + const AuthenticationEventException(this.exception); + + /// The exception thrown during authentication. + final GoogleSignInException exception; +} diff --git a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_platform_interface/lib/src/utils.dart deleted file mode 100644 index 6f03a6c357fe..000000000000 --- a/packages/google_sign_in/google_sign_in_platform_interface/lib/src/utils.dart +++ /dev/null @@ -1,28 +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 '../google_sign_in_platform_interface.dart'; - -/// Converts user data coming from native code into the proper platform interface type. -GoogleSignInUserData? getUserDataFromMap(Map? data) { - if (data == null) { - return null; - } - return GoogleSignInUserData( - email: data['email']! as String, - id: data['id']! as String, - displayName: data['displayName'] as String?, - photoUrl: data['photoUrl'] as String?, - idToken: data['idToken'] as String?, - serverAuthCode: data['serverAuthCode'] as String?); -} - -/// Converts token data coming from native code into the proper platform interface type. -GoogleSignInTokenData getTokenDataFromMap(Map data) { - return GoogleSignInTokenData( - idToken: data['idToken'] as String?, - accessToken: data['accessToken'] as String?, - serverAuthCode: data['serverAuthCode'] as String?, - ); -} diff --git a/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml b/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml index d0156993eb38..b8fe4db68364 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/google_sign_i issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.5.0 +version: 3.0.0 environment: sdk: ^3.4.0 diff --git a/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart b/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart index 057f13cb26f5..0ef1d0495af0 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/test/google_sign_in_platform_interface_test.dart @@ -8,15 +8,8 @@ import 'package:mockito/mockito.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; void main() { - // Store the initial instance before any tests change it. - final GoogleSignInPlatform initialInstance = GoogleSignInPlatform.instance; - - group('$GoogleSignInPlatform', () { - test('$MethodChannelGoogleSignIn is the default instance', () { - expect(initialInstance, isA()); - }); - - test('Cannot be implemented with `implements`', () { + group('GoogleSignInPlatform', () { + test('cannot be implemented with `implements`', () { expect(() { GoogleSignInPlatform.instance = ImplementsGoogleSignInPlatform(); // In versions of `package:plugin_platform_interface` prior to fixing @@ -29,71 +22,119 @@ void main() { }, throwsA(anything)); }); - test('Can be extended', () { + test('can be extended', () { GoogleSignInPlatform.instance = ExtendsGoogleSignInPlatform(); }); - test('Can be mocked with `implements`', () { - GoogleSignInPlatform.instance = ModernMockImplementation(); - }); - - test('still supports legacy isMock', () { - GoogleSignInPlatform.instance = LegacyIsMockImplementation(); + test('can be mocked with `implements`', () { + GoogleSignInPlatform.instance = MockImplementation(); }); - }); - group('GoogleSignInTokenData', () { - test('can be compared by == operator', () { - final GoogleSignInTokenData firstInstance = GoogleSignInTokenData( - accessToken: 'accessToken', - idToken: 'idToken', - serverAuthCode: 'serverAuthCode', - ); - final GoogleSignInTokenData secondInstance = GoogleSignInTokenData( - accessToken: 'accessToken', - idToken: 'idToken', - serverAuthCode: 'serverAuthCode', - ); - expect(firstInstance == secondInstance, isTrue); + test('implements authenticationEvents to return null by default', () { + // This uses ExtendsGoogleSignInPlatform since that's within the control + // of the test file, and doesn't override authenticationEvents; using + // the default `.instance` would only validate that the placeholder has + // this behavior, which could be implemented in the subclass. + expect(ExtendsGoogleSignInPlatform().authenticationEvents, null); }); }); group('GoogleSignInUserData', () { test('can be compared by == operator', () { - final GoogleSignInUserData firstInstance = GoogleSignInUserData( + const GoogleSignInUserData firstInstance = GoogleSignInUserData( email: 'email', id: 'id', displayName: 'displayName', photoUrl: 'photoUrl', - idToken: 'idToken', - serverAuthCode: 'serverAuthCode', ); - final GoogleSignInUserData secondInstance = GoogleSignInUserData( + const GoogleSignInUserData secondInstance = GoogleSignInUserData( email: 'email', id: 'id', displayName: 'displayName', photoUrl: 'photoUrl', + ); + expect(firstInstance == secondInstance, isTrue); + }); + }); + + group('AuthenticationTokenData', () { + test('can be compared by == operator', () { + const AuthenticationTokenData firstInstance = AuthenticationTokenData( + idToken: 'idToken', + ); + const AuthenticationTokenData secondInstance = AuthenticationTokenData( idToken: 'idToken', - serverAuthCode: 'serverAuthCode', ); expect(firstInstance == secondInstance, isTrue); }); }); -} -class LegacyIsMockImplementation extends Mock implements GoogleSignInPlatform { - @override - bool get isMock => true; + group('ClientAuthorizationTokenData', () { + test('can be compared by == operator', () { + const ClientAuthorizationTokenData firstInstance = + ClientAuthorizationTokenData( + accessToken: 'accessToken', + ); + const ClientAuthorizationTokenData secondInstance = + ClientAuthorizationTokenData( + accessToken: 'accessToken', + ); + expect(firstInstance == secondInstance, isTrue); + }); + }); + + group('ServerAuthorizationTokenData', () { + test('can be compared by == operator', () { + const ServerAuthorizationTokenData firstInstance = + ServerAuthorizationTokenData( + serverAuthCode: 'serverAuthCode', + ); + const ServerAuthorizationTokenData secondInstance = + ServerAuthorizationTokenData( + serverAuthCode: 'serverAuthCode', + ); + expect(firstInstance == secondInstance, isTrue); + }); + }); } -class ModernMockImplementation extends Mock +class MockImplementation extends Mock with MockPlatformInterfaceMixin - implements GoogleSignInPlatform { - @override - bool get isMock => false; -} + implements GoogleSignInPlatform {} class ImplementsGoogleSignInPlatform extends Mock implements GoogleSignInPlatform {} -class ExtendsGoogleSignInPlatform extends GoogleSignInPlatform {} +class ExtendsGoogleSignInPlatform extends GoogleSignInPlatform { + @override + Future? attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params) async { + return null; + } + + @override + Future authenticate(AuthenticateParameters params) { + throw UnimplementedError(); + } + + @override + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params) async { + return null; + } + + @override + Future disconnect(DisconnectParams params) async {} + + @override + Future init(InitParameters params) async {} + + @override + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params) async { + return null; + } + + @override + Future signOut(SignOutParams params) async {} +} diff --git a/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart b/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart deleted file mode 100644 index 52e792a9c254..000000000000 --- a/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart +++ /dev/null @@ -1,174 +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/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:google_sign_in_platform_interface/src/utils.dart'; - -const Map kUserData = { - 'email': 'john.doe@gmail.com', - 'id': '8162538176523816253123', - 'photoUrl': 'https://lh5.googleusercontent.com/photo.jpg', - 'displayName': 'John Doe', - 'idToken': '123', - 'serverAuthCode': '789', -}; - -const Map kTokenData = { - 'idToken': '123', - 'accessToken': '456', - 'serverAuthCode': '789', -}; - -const Map kDefaultResponses = { - 'init': null, - 'signInSilently': kUserData, - 'signIn': kUserData, - 'signOut': null, - 'disconnect': null, - 'isSignedIn': true, - 'getTokens': kTokenData, - 'requestScopes': true, -}; - -final GoogleSignInUserData? kUser = getUserDataFromMap(kUserData); -final GoogleSignInTokenData kToken = - getTokenDataFromMap(kTokenData as Map); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('$MethodChannelGoogleSignIn', () { - final MethodChannelGoogleSignIn googleSignIn = MethodChannelGoogleSignIn(); - final MethodChannel channel = googleSignIn.channel; - - final List log = []; - late Map - responses; // Some tests mutate some kDefaultResponses - - setUp(() { - responses = Map.from(kDefaultResponses); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) { - log.add(methodCall); - final dynamic response = responses[methodCall.method]; - if (response != null && response is Exception) { - return Future.error('$response'); - } - return Future.value(response); - }); - log.clear(); - }); - - test('signInSilently transforms platform data to GoogleSignInUserData', - () async { - final dynamic response = await googleSignIn.signInSilently(); - expect(response, kUser); - }); - test('signInSilently Exceptions -> throws', () async { - responses['signInSilently'] = Exception('Not a user'); - expect(googleSignIn.signInSilently(), - throwsA(isInstanceOf())); - }); - - test('signIn transforms platform data to GoogleSignInUserData', () async { - final dynamic response = await googleSignIn.signIn(); - expect(response, kUser); - }); - test('signIn Exceptions -> throws', () async { - responses['signIn'] = Exception('Not a user'); - expect(googleSignIn.signIn(), throwsA(isInstanceOf())); - }); - - test('getTokens transforms platform data to GoogleSignInTokenData', - () async { - final dynamic response = await googleSignIn.getTokens( - email: 'example@example.com', shouldRecoverAuth: false); - expect(response, kToken); - expect( - log[0], - isMethodCall('getTokens', arguments: { - 'email': 'example@example.com', - 'shouldRecoverAuth': false, - })); - }); - - test('Other functions pass through arguments to the channel', () async { - final Map tests = { - () { - googleSignIn.init( - hostedDomain: 'example.com', - scopes: ['two', 'scopes'], - signInOption: SignInOption.games, - clientId: 'fakeClientId'); - }: isMethodCall('init', arguments: { - 'hostedDomain': 'example.com', - 'scopes': ['two', 'scopes'], - 'signInOption': 'SignInOption.games', - 'clientId': 'fakeClientId', - 'serverClientId': null, - 'forceCodeForRefreshToken': false, - }), - () { - googleSignIn.getTokens( - email: 'example@example.com', shouldRecoverAuth: false); - }: isMethodCall('getTokens', arguments: { - 'email': 'example@example.com', - 'shouldRecoverAuth': false, - }), - () { - googleSignIn.clearAuthCache(token: 'abc'); - }: isMethodCall('clearAuthCache', arguments: { - 'token': 'abc', - }), - () { - googleSignIn.requestScopes(['newScope', 'anotherScope']); - }: isMethodCall('requestScopes', arguments: { - 'scopes': ['newScope', 'anotherScope'], - }), - googleSignIn.signOut: isMethodCall('signOut', arguments: null), - googleSignIn.disconnect: isMethodCall('disconnect', arguments: null), - googleSignIn.isSignedIn: isMethodCall('isSignedIn', arguments: null), - }; - - for (final void Function() f in tests.keys) { - f(); - } - - expect(log, tests.values); - }); - - test('canAccessScopes is unimplemented', () async { - expect(() async { - await googleSignIn - .canAccessScopes(['someScope'], accessToken: 'token'); - }, throwsUnimplementedError); - }); - - test('userDataEvents returns null', () async { - expect(googleSignIn.userDataEvents, isNull); - }); - - test('initWithParams passes through arguments to the channel', () async { - await googleSignIn.initWithParams(const SignInInitParameters( - hostedDomain: 'example.com', - scopes: ['two', 'scopes'], - signInOption: SignInOption.games, - clientId: 'fakeClientId', - serverClientId: 'fakeServerClientId', - forceCodeForRefreshToken: true)); - expect(log, [ - isMethodCall('init', arguments: { - 'hostedDomain': 'example.com', - 'scopes': ['two', 'scopes'], - 'signInOption': 'SignInOption.games', - 'clientId': 'fakeClientId', - 'serverClientId': 'fakeServerClientId', - 'forceCodeForRefreshToken': true, - }), - ]); - }); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md index 0dd0d245abf8..337d93b8136b 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.0.0 + +* **BREAKING CHANGE**: Switches to implementing version 3.0 of the platform + interface package, rather than 2.x, significantly changing the API surface. + ## 0.12.4+4 * Asserts that new `forceAccountName` parameter is null (not used in web). diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md index f51d1bd63590..f9222b53b45f 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -2,149 +2,8 @@ The web implementation of [google_sign_in](https://pub.dev/packages/google_sign_in) -## Migrating to v0.11 and v0.12 (Google Identity Services) - -The `google_sign_in_web` plugin is backed by the new Google Identity Services -(GIS) JS SDK since version 0.11.0. - -The GIS SDK is used both for [Authentication](https://developers.google.com/identity/gsi/web/guides/overview) -and [Authorization](https://developers.google.com/identity/oauth2/web/guides/overview) flows. - -The GIS SDK, however, doesn't behave exactly like the one being deprecated. -Some concepts have experienced pretty drastic changes, and that's why this -plugin required a major version update. - -### Differences between Google Identity Services SDK and Google Sign-In for Web SDK. - -The **Google Sign-In JavaScript for Web JS SDK** is set to be deprecated after -March 31, 2023. **Google Identity Services (GIS) SDK** is the new solution to -quickly and easily sign users into your app using their Google accounts. - -* In the GIS SDK, Authentication and Authorization are now two separate concerns. - * Authentication (information about the current user) flows will not - authorize `scopes` anymore. - * Authorization (permissions for the app to access certain user information) - flows will not return authentication information. -* The GIS SDK no longer has direct access to previously-seen users upon initialization. - * `signInSilently` now displays the One Tap UX for web. -* **Since 0.12** The plugin provides an `idToken` (JWT-encoded info) when the - user successfully completes an authentication flow: - * In the plugin: `signInSilently` and through the web-only `renderButton` widget. -* The plugin `signIn` method uses the OAuth "Implicit Flow" to Authorize the requested `scopes`. - * This method only provides an `accessToken`, and not an `idToken`, so if your - app needs an `idToken`, this method **should be avoided on the web**. -* The GIS SDK no longer handles sign-in state and user sessions, it only provides - Authentication credentials for the moment the user did authenticate. -* The GIS SDK no longer is able to renew Authorization sessions on the web. - Once the token expires, API requests will begin to fail with unauthorized, - and user Authorization is required again. - -See more differences in the following migration guides: - -* Authentication > [Migrating from Google Sign-In](https://developers.google.com/identity/gsi/web/guides/migration) -* Authorization > [Migrate to Google Identity Services](https://developers.google.com/identity/oauth2/web/guides/migration-to-gis) - -### New use cases to take into account in your app - -#### Authentication != Authorization - -In the GIS SDK, the concepts of Authentication and Authorization have been separated. - -It is possible now to have an Authenticated user that hasn't Authorized any `scopes`. - -Flutter apps that need to run in the web must now handle the fact that an Authenticated -user may not have permissions to access the `scopes` it requires to function. - -The Google Sign In plugin has a new `canAccessScopes` method that can be used to -check if a user is Authorized or not. - -It is also possible that Authorizations expire while users are using an app -(after 3600 seconds), so apps should monitor response failures from the APIs, and -prompt users (interactively) to grant permissions again. - -Check the "Integration considerations > [UX separation for authentication and authorization](https://developers.google.com/identity/gsi/web/guides/integrate#ux_separation_for_authentication_and_authorization) -guide" in the official GIS SDK documentation for more information about this. - -_(See also the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example) -for a simple implementation of this (look at the `isAuthorized` variable).)_ - -#### Is this separation *always required*? - -Only if the scopes required by an app are different from the -[OpenID Connect scopes](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect). - -If an app only needs an `idToken`, or the OpenID Connect scopes, the Authentication -bits of the plugin should be enough for your app (`signInSilently` and `renderButton`). - -### What happened to the `signIn` method on the web? - -Because the GIS SDK for web no longer provides users with the ability to create -their own Sign-In buttons, or an API to start the sign in flow, the current -implementation of `signIn` (that does authorization and authentication) is no -longer feasible on the web. - -The web plugin attempts to simulate the old `signIn` behavior by using the -[OAuth Implicit pop-up flow](https://developers.google.com/identity/oauth2/web/guides/use-token-model), -which authenticates and authorizes users. - -The drawback of this approach is that the OAuth flow **only returns an `accessToken`**, -and a synthetic version of the User Data, that does **not include an `idToken`**. - -The solution to this is to **migrate your custom "Sign In" buttons in the web to -the Button Widget provided by this package: `Widget renderButton()`.** - -_(Check the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example) -for an example on how to mix the `renderButton` widget on the web, with a custom -button for the mobile.)_ - -#### Enable access to the People API for your GCP project - -If you want to use the `signIn` method on the web, the plugin will do an additional -request to the PeopleAPI to retrieve the logged-in user information (minus the `idToken`). - -For this to work, you must enable access to the People API on your Client ID in -the GCP console. - -This is **not recommended**. Ideally, your web application should use a mix of -`signInSilently` and the Google Sign In web `renderButton` to authenticate your -users, and then `canAccessScopes` and `requestScopes` to authorize the `scopes` -that are needed. - -#### Why is the `idToken` missing after `signIn`? - -The `idToken` is cryptographically signed by Google Identity Services, and -this plugin can't spoof that signature. - -#### User Sessions - -Since the GIS SDK does _not_ manage user sessions anymore, apps that relied on -this feature might break. - -If long-lived sessions are required, consider using some user authentication -system that supports Google Sign In as a federated Authentication provider, -like [Firebase Auth](https://firebase.google.com/docs/auth/flutter/federated-auth#google), -or similar. - -#### Expired / Invalid Authorization Tokens - -Since the GIS SDK does _not_ auto-renew authorization tokens anymore, it's now -the responsibility of your app to do so. - -Apps now need to monitor the status code of their REST API requests for response -codes different to `200`. For example: - -* `401`: Missing or invalid access token. -* `403`: Expired access token. - -In either case, your app needs to prompt the end user to `requestScopes`, to -**interactively** renew the token. - -The GIS SDK limits authorization token duration to one hour (3600 seconds). - ## Usage -### Import the package - This package is [endorsed](https://flutter.dev/to/endorsed-federated-plugin), which means you can simply use `google_sign_in` normally. This package will be automatically included in your app when you do, @@ -156,7 +15,7 @@ should add it to your `pubspec.yaml` as usual. For example, you need to import this package directly if you plan to use the web-only `Widget renderButton()` method. -### Web integration +## Integration First, go through the instructions [here](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) to create your Google Sign-In OAuth client ID. @@ -180,7 +39,7 @@ For local development, you must add two `localhost` entries: * `http://localhost` and * `http://localhost:7357` (or any port that is free in your machine) -#### Starting flutter in http://localhost:7357 +### Starting flutter in http://localhost:7357 Normally `flutter run` starts in a random port. In the case where you need to deal with authentication like the above, that's not the most appropriate behavior. @@ -190,35 +49,43 @@ You can tell `flutter run` to listen for requests in a specific host and port wi flutter run -d chrome --web-hostname localhost --web-port 7357 ``` -### Other APIs - -Read the rest of the instructions if you need to add extra APIs (like Google People API). - -### Using the plugin +## Authentication -See the [**Usage** instructions of `package:google_sign_in`](https://pub.dev/packages/google_sign_in#usage) +This implementation returns false for `supportsAuthentication`, and will throw +if `authenticate` is called. This is because the +[Google Identity Services (GIS) SDK](https://developers.google.com/identity/gsi/web/guides/overview) +only allows signing in using UI provided by the SDK. -Note that the **`serverClientId` parameter of the `GoogleSignIn` constructor is not supported on Web.** +On the web, instead of providing custom UI that calls `authenticate`, you should +display the Widget returned by `renderButton` (from `web_only.dart`), and listen +to `authenticationEvents` to know when the user has signed in. -## Example +### Migration from versions before 0.12 -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). +See [Migrating from Google Sign-In](https://developers.google.com/identity/gsi/web/guides/migration) +for information about the differences between authentication in the GIS SDK and +the SDK used in older versions of this plugin. -## API details - -See [google_sign_in.dart](https://github.com/flutter/packages/blob/main/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart) for more API details. - -## Contributions and Testing +Since the GIS SDK does _not_ manage user sessions anymore, apps that relied on +this feature might break. If long-lived sessions are required, consider using +some user authentication system that supports Google Sign In as a federated +Authentication provider, like +[Firebase Auth](https://firebase.google.com/docs/auth/flutter/federated-auth#google). -Tests are crucial for contributions to this package. All new contributions should be reasonably tested. +## Authorization -**Check the [`test/README.md` file](https://github.com/flutter/packages/blob/main/packages/google_sign_in/google_sign_in_web/test/README.md)** for more information on how to run tests on this package. +The GIS SDK does not renew authorization sessions. Once the token expires +(after 3600 seconds), API requests will begin to fail, and you must re-request +user authorization. For example: -Contributions to this package are welcome. Read the [Contributing to Flutter Plugins](https://github.com/flutter/packages/blob/main/CONTRIBUTING.md) guide to get started. +* `401`: Missing or invalid access token. +* `403`: Expired access token. -## Issues and feedback +See the "Integration considerations > [UX separation for authentication and authorization](https://developers.google.com/identity/gsi/web/guides/integrate#ux_separation_for_authentication_and_authorization) +guide" in the official GIS SDK documentation for more information about this. -Please file [issues](https://github.com/flutter/flutter/issues/new) -to send feedback or report a bug. +### Migration from versions before 0.12 -**Thank you!** +See [Migrate to Google Identity Services](https://developers.google.com/identity/oauth2/web/guides/migration-to-gis) +for information about the differences between authentication in the GIS SDK and +the SDK used in older versions of this plugin. diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart index 88b69f26fb5d..9ffff117dee5 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart @@ -4,7 +4,6 @@ import 'dart:async'; -import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_test/flutter_test.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:google_sign_in_web/google_sign_in_web.dart'; @@ -54,7 +53,7 @@ void main() { }); }); - group('initWithParams', () { + group('init', () { late GoogleSignInPlugin plugin; late MockGisSdkClient mockGis; @@ -67,10 +66,9 @@ void main() { }); testWidgets('initializes if all is OK', (_) async { - await plugin.initWithParams( - const SignInInitParameters( + await plugin.init( + const InitParameters( clientId: 'some-non-null-client-id', - scopes: ['ok1', 'ok2', 'ok3'], ), ); @@ -79,16 +77,16 @@ void main() { testWidgets('asserts clientId is not null', (_) async { expect(() async { - await plugin.initWithParams( - const SignInInitParameters(), + await plugin.init( + const InitParameters(), ); }, throwsAssertionError); }); testWidgets('asserts serverClientId must be null', (_) async { expect(() async { - await plugin.initWithParams( - const SignInInitParameters( + await plugin.init( + const InitParameters( clientId: 'some-non-null-client-id', serverClientId: 'unexpected-non-null-client-id', ), @@ -96,63 +94,28 @@ void main() { }, throwsAssertionError); }); - testWidgets('asserts no scopes have any spaces', (_) async { - expect(() async { - await plugin.initWithParams( - const SignInInitParameters( - clientId: 'some-non-null-client-id', - scopes: ['ok1', 'ok2', 'not ok', 'ok3'], - ), - ); - }, throwsAssertionError); - }); - - testWidgets('asserts forceAccountName must be null', (_) async { - expect(() async { - await plugin.initWithParams( - const SignInInitParameters( - clientId: 'some-non-null-client-id', - forceAccountName: 'fakeEmailAddress@example.com', - ), - ); - }, throwsAssertionError); - }); - testWidgets('must be called for most of the API to work', (_) async { expect(() async { - await plugin.signInSilently(); - }, throwsStateError); - - expect(() async { - await plugin.signIn(); - }, throwsStateError); - - expect(() async { - await plugin.getTokens(email: ''); - }, throwsStateError); - - expect(() async { - await plugin.signOut(); + await plugin.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); }, throwsStateError); expect(() async { - await plugin.disconnect(); + await plugin.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: [], + userId: null, + email: null, + promptIfUnauthorized: false))); }, throwsStateError); expect(() async { - await plugin.isSignedIn(); + await plugin.signOut(const SignOutParams()); }, throwsStateError); expect(() async { - await plugin.clearAuthCache(token: ''); - }, throwsStateError); - - expect(() async { - await plugin.requestScopes([]); - }, throwsStateError); - - expect(() async { - await plugin.canAccessScopes([]); + await plugin.disconnect(const DisconnectParams()); }, throwsStateError); }); }); @@ -160,9 +123,8 @@ void main() { group('(with mocked GIS)', () { late GoogleSignInPlugin plugin; late MockGisSdkClient mockGis; - const SignInInitParameters options = SignInInitParameters( + const InitParameters options = InitParameters( clientId: 'some-non-null-client-id', - scopes: ['ok1', 'ok2', 'ok3'], ); setUp(() { @@ -173,139 +135,182 @@ void main() { ); }); - group('signInSilently', () { + group('attemptLightweightAuthentication', () { setUp(() { - plugin.initWithParams(options); + plugin.init(options); }); - testWidgets('returns the GIS response', (_) async { - final GoogleSignInUserData someUser = extractUserData(person)!; - + testWidgets('Calls requestOneTap on GIS client', (_) async { mockito - .when(mockGis.signInSilently()) - .thenAnswer((_) => Future.value(someUser)); + .when(mockGis.requestOneTap()) + .thenAnswer((_) => Future.value()); - expect(await plugin.signInSilently(), someUser); + final Future? future = + plugin.attemptLightweightAuthentication( + const AttemptLightweightAuthenticationParameters()); - mockito - .when(mockGis.signInSilently()) - .thenAnswer((_) => Future.value()); + expect(future, null); - expect(await plugin.signInSilently(), isNull); - }); - }); + // Since the implementation intentionally doesn't return a future, just + // given the async call a chance to be made. + await pumpEventQueue(); - group('signIn', () { - setUp(() { - plugin.initWithParams(options); + mockito.verify(mockGis.requestOneTap()); }); + }); - testWidgets('returns the signed-in user', (_) async { - final GoogleSignInUserData someUser = extractUserData(person)!; - - mockito - .when(mockGis.signIn()) - .thenAnswer((_) => Future.value(someUser)); + group('clientAuthorizationTokensForScopes', () { + const String someAccessToken = '50m3_4cc35_70k3n'; + const List scopes = ['scope1', 'scope2']; - expect(await plugin.signIn(), someUser); + setUp(() { + plugin.init(options); }); - testWidgets('returns null if no user is signed in', (_) async { + testWidgets('calls requestScopes on GIS client', (_) async { mockito - .when(mockGis.signIn()) - .thenAnswer((_) => Future.value()); - - expect(await plugin.signIn(), isNull); - }); + .when( + mockGis.requestScopes(mockito.any, + promptIfUnauthorized: + mockito.anyNamed('promptIfUnauthorized'), + userHint: mockito.anyNamed('userHint')), + ) + .thenAnswer((_) => Future.value(someAccessToken)); - testWidgets('converts inner errors to PlatformException', (_) async { - mockito.when(mockGis.signIn()).thenThrow('popup_closed'); + final ClientAuthorizationTokenData? token = + await plugin.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: false))); - try { - await plugin.signIn(); - fail('signIn should have thrown an exception'); - } catch (exception) { - expect(exception, isA()); - expect((exception as PlatformException).code, 'popup_closed'); - } - }); - }); + final List arguments = mockito + .verify( + mockGis.requestScopes(mockito.captureAny, + promptIfUnauthorized: + mockito.captureAnyNamed('promptIfUnauthorized'), + userHint: mockito.captureAnyNamed('userHint')), + ) + .captured; - group('canAccessScopes', () { - const String someAccessToken = '50m3_4cc35_70k3n'; - const List scopes = ['scope1', 'scope2']; + expect(token?.accessToken, someAccessToken); - setUp(() { - plugin.initWithParams(options); + expect(arguments.elementAt(0), scopes); + expect(arguments.elementAt(1), false); + expect(arguments.elementAt(2), null); }); - testWidgets('passes-through call to gis client', (_) async { + testWidgets('passes expected values to requestScopes', (_) async { + const String someUserId = 'someUser'; mockito .when( - mockGis.canAccessScopes(mockito.captureAny, mockito.captureAny), + mockGis.requestScopes(mockito.any, + promptIfUnauthorized: + mockito.anyNamed('promptIfUnauthorized'), + userHint: mockito.anyNamed('userHint')), ) - .thenAnswer((_) => Future.value(true)); + .thenAnswer((_) => Future.value(someAccessToken)); - final bool canAccess = - await plugin.canAccessScopes(scopes, accessToken: someAccessToken); + final ClientAuthorizationTokenData? token = + await plugin.clientAuthorizationTokensForScopes( + const ClientAuthorizationTokensForScopesParameters( + request: AuthorizationRequestDetails( + scopes: scopes, + userId: someUserId, + email: 'someone@example.com', + promptIfUnauthorized: true))); final List arguments = mockito .verify( - mockGis.canAccessScopes(mockito.captureAny, mockito.captureAny), + mockGis.requestScopes(mockito.captureAny, + promptIfUnauthorized: + mockito.captureAnyNamed('promptIfUnauthorized'), + userHint: mockito.captureAnyNamed('userHint')), ) .captured; - expect(canAccess, isTrue); + expect(token?.accessToken, someAccessToken); - expect(arguments.first, scopes); - expect(arguments.elementAt(1), someAccessToken); + expect(arguments.elementAt(0), scopes); + expect(arguments.elementAt(1), true); + expect(arguments.elementAt(2), someUserId); }); }); - group('requestServerAuthCode', () { - const String someAuthCode = '50m3_4u7h_c0d3'; + group('serverAuthorizationTokensForScopes', () { + const String someAuthCode = 'abc123'; + const List scopes = ['scope1', 'scope2']; setUp(() { - plugin.initWithParams(options); + plugin.init(options); }); - testWidgets('passes-through call to gis client', (_) async { + testWidgets('calls requestServerAuthCode on GIS client', (_) async { mockito - .when(mockGis.requestServerAuthCode()) + .when( + mockGis.requestServerAuthCode(mockito.any), + ) .thenAnswer((_) => Future.value(someAuthCode)); - final String? serverAuthCode = await plugin.requestServerAuthCode(); + const AuthorizationRequestDetails request = AuthorizationRequestDetails( + scopes: scopes, + userId: null, + email: null, + promptIfUnauthorized: true); + final ServerAuthorizationTokenData? token = + await plugin.serverAuthorizationTokensForScopes( + const ServerAuthorizationTokensForScopesParameters( + request: request)); + + final List arguments = mockito + .verify(mockGis.requestServerAuthCode(mockito.captureAny)) + .captured; + + expect(token?.serverAuthCode, someAuthCode); - expect(serverAuthCode, someAuthCode); + final AuthorizationRequestDetails passedRequest = + arguments.first! as AuthorizationRequestDetails; + expect(passedRequest.scopes, request.scopes); + expect(passedRequest.userId, request.userId); + expect(passedRequest.email, request.email); + expect( + passedRequest.promptIfUnauthorized, request.promptIfUnauthorized); }); }); }); group('userDataEvents', () { - final StreamController controller = - StreamController.broadcast(); + final StreamController controller = + StreamController.broadcast(); late GoogleSignInPlugin plugin; setUp(() { plugin = GoogleSignInPlugin( debugOverrideLoader: true, - debugOverrideUserDataController: controller, + debugAuthenticationController: controller, ); }); testWidgets('accepts async user data events from GIS.', (_) async { - final Future data = plugin.userDataEvents!.first; + final Future event = + plugin.authenticationEvents.first; - final GoogleSignInUserData expected = extractUserData(person)!; + final AuthenticationEvent expected = AuthenticationEventSignIn( + user: extractUserData(person)!, + authenticationTokens: + const AuthenticationTokenData(idToken: 'someToken')); controller.add(expected); - expect(await data, expected, + expect(await event, expected, reason: 'Sign-in events should be propagated'); - final Future more = plugin.userDataEvents!.first; - controller.add(null); + final Future nextEvent = + plugin.authenticationEvents.first; + controller.add(AuthenticationEventSignOut()); - expect(await more, isNull, + expect(await nextEvent, isA(), reason: 'Sign-out events can also be propagated'); }); }); diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart index a6b5e9a71838..fd6866e78bb0 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart @@ -1,14 +1,14 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in google_sign_in_web_integration_tests/integration_test/google_sign_in_web_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; +import 'dart:async' as _i3; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' - as _i2; -import 'package:google_sign_in_web/src/button_configuration.dart' as _i5; -import 'package:google_sign_in_web/src/gis_client.dart' as _i3; + as _i5; +import 'package:google_sign_in_web/src/button_configuration.dart' as _i4; +import 'package:google_sign_in_web/src/gis_client.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -19,161 +19,73 @@ 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, - ); -} - /// A class which mocks [GisSdkClient]. /// /// See the documentation for Mockito's code generation for more information. -class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { +class MockGisSdkClient extends _i1.Mock implements _i2.GisSdkClient { @override - _i4.Future<_i2.GoogleSignInUserData?> signInSilently() => (super.noSuchMethod( - Invocation.method( - #signInSilently, - [], - ), - returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), - returnValueForMissingStub: - _i4.Future<_i2.GoogleSignInUserData?>.value(), - ) as _i4.Future<_i2.GoogleSignInUserData?>); + void requestOneTap() => super.noSuchMethod( + Invocation.method(#requestOneTap, []), + returnValueForMissingStub: null, + ); @override - _i4.Future renderButton( + _i3.Future renderButton( Object? parent, - _i5.GSIButtonConfiguration? options, + _i4.GSIButtonConfiguration? options, ) => (super.noSuchMethod( - Invocation.method( - #renderButton, - [ - parent, - options, - ], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future requestServerAuthCode() => (super.noSuchMethod( - Invocation.method( - #requestServerAuthCode, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( - Invocation.method( - #signIn, - [], - ), - returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), - returnValueForMissingStub: - _i4.Future<_i2.GoogleSignInUserData?>.value(), - ) as _i4.Future<_i2.GoogleSignInUserData?>); - - @override - _i2.GoogleSignInTokenData getTokens() => (super.noSuchMethod( - Invocation.method( - #getTokens, - [], - ), - returnValue: _FakeGoogleSignInTokenData_0( - this, - Invocation.method( - #getTokens, - [], - ), - ), - returnValueForMissingStub: _FakeGoogleSignInTokenData_0( - this, - Invocation.method( - #getTokens, - [], - ), - ), - ) as _i2.GoogleSignInTokenData); + Invocation.method(#renderButton, [parent, options]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future signOut() => (super.noSuchMethod( - Invocation.method( - #signOut, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i3.Future requestServerAuthCode( + _i5.AuthorizationRequestDetails? request, + ) => + (super.noSuchMethod( + Invocation.method(#requestServerAuthCode, [request]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future disconnect() => (super.noSuchMethod( - Invocation.method( - #disconnect, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i3.Future signOut() => (super.noSuchMethod( + Invocation.method(#signOut, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future isSignedIn() => (super.noSuchMethod( - Invocation.method( - #isSignedIn, - [], - ), - returnValue: _i4.Future.value(false), - returnValueForMissingStub: _i4.Future.value(false), - ) as _i4.Future); + _i3.Future disconnect() => (super.noSuchMethod( + Invocation.method(#disconnect, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future clearAuthCache() => (super.noSuchMethod( - Invocation.method( - #clearAuthCache, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future requestScopes(List? scopes) => (super.noSuchMethod( + _i3.Future requestScopes( + List? scopes, { + required bool? promptIfUnauthorized, + String? userHint, + }) => + (super.noSuchMethod( Invocation.method( #requestScopes, [scopes], + { + #promptIfUnauthorized: promptIfUnauthorized, + #userHint: userHint, + }, ), - returnValue: _i4.Future.value(false), - returnValueForMissingStub: _i4.Future.value(false), - ) as _i4.Future); - - @override - _i4.Future canAccessScopes( - List? scopes, - String? accessToken, - ) => - (super.noSuchMethod( - Invocation.method( - #canAccessScopes, - [ - scopes, - accessToken, - ], - ), - returnValue: _i4.Future.value(false), - returnValueForMissingStub: _i4.Future.value(false), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); } diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart index e81ccb6e95b5..5ed1244e8d8f 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart @@ -52,7 +52,6 @@ void main() { expect(user.id, expectedPersonId); expect(user.displayName, expectedPersonName); expect(user.photoUrl, expectedPersonPhoto); - expect(user.idToken, isNull); expect( accessTokenCompleter.future, completion('Bearer $expectedAccessToken'), @@ -87,7 +86,6 @@ void main() { expect(user.id, expectedPersonId); expect(user.displayName, expectedPersonName); expect(user.photoUrl, expectedPersonPhoto); - expect(user.idToken, isNull); }); testWidgets('no name/photo - keeps going', (_) async { @@ -104,7 +102,6 @@ void main() { expect(user.id, expectedPersonId); expect(user.displayName, isNull); expect(user.photoUrl, isNull); - expect(user.idToken, isNull); }); testWidgets('no userId - throws assertion error', (_) async { diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart index 9dec77b81bde..0914f762cd67 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart @@ -6,7 +6,6 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_identity_services_web/id.dart'; -import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:google_sign_in_web/src/utils.dart'; import 'package:integration_test/integration_test.dart'; @@ -17,63 +16,39 @@ import 'src/jwt_examples.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('gisResponsesToTokenData', () { - testWidgets('null objects -> no problem', (_) async { - final GoogleSignInTokenData tokens = gisResponsesToTokenData(null, null); - - expect(tokens.accessToken, isNull); - expect(tokens.idToken, isNull); - expect(tokens.serverAuthCode, isNull); - }); - - testWidgets('non-null objects are correctly used', (_) async { - const String expectedIdToken = 'some-value-for-testing'; - const String expectedAccessToken = 'another-value-for-testing'; - - final CredentialResponse credential = - jsifyAs({ - 'credential': expectedIdToken, - }); - final TokenResponse token = jsifyAs({ - 'access_token': expectedAccessToken, - }); - final GoogleSignInTokenData tokens = - gisResponsesToTokenData(credential, token); - - expect(tokens.accessToken, expectedAccessToken); - expect(tokens.idToken, expectedIdToken); - expect(tokens.serverAuthCode, isNull); - }); - }); - - group('gisResponsesToUserData', () { + group('gisResponsesToAuthenticationEvent', () { testWidgets('happy case', (_) async { - final GoogleSignInUserData data = gisResponsesToUserData(goodCredential)!; + final AuthenticationEventSignIn signIn = + gisResponsesToAuthenticationEvent(goodCredential)! + as AuthenticationEventSignIn; + final GoogleSignInUserData data = signIn.user; expect(data.displayName, 'Vincent Adultman'); expect(data.id, '123456'); expect(data.email, 'adultman@example.com'); expect(data.photoUrl, 'https://thispersondoesnotexist.com/image?x=.jpg'); - expect(data.idToken, goodJwtToken); + expect(signIn.authenticationTokens.idToken, goodJwtToken); }); testWidgets('happy case (minimal)', (_) async { - final GoogleSignInUserData data = - gisResponsesToUserData(minimalCredential)!; + final AuthenticationEventSignIn signIn = + gisResponsesToAuthenticationEvent(minimalCredential)! + as AuthenticationEventSignIn; + final GoogleSignInUserData data = signIn.user; expect(data.displayName, isNull); expect(data.id, '123456'); expect(data.email, 'adultman@example.com'); expect(data.photoUrl, isNull); - expect(data.idToken, minimalJwtToken); + expect(signIn.authenticationTokens.idToken, minimalJwtToken); }); testWidgets('null response -> null', (_) async { - expect(gisResponsesToUserData(null), isNull); + expect(gisResponsesToAuthenticationEvent(null), isNull); }); testWidgets('null response.credential -> null', (_) async { - expect(gisResponsesToUserData(nullCredential), isNull); + expect(gisResponsesToAuthenticationEvent(nullCredential), isNull); }); testWidgets('invalid payload -> null', (_) async { @@ -81,7 +56,7 @@ void main() { jsifyAs({ 'credential': 'some-bogus.thing-that-is-not.valid-jwt', }); - expect(gisResponsesToUserData(response), isNull); + expect(gisResponsesToAuthenticationEvent(response), isNull); }); }); diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.dart index 508e291bf2a9..79f8cd35cd88 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:google_sign_in_web/google_sign_in_web.dart' @@ -10,7 +11,7 @@ import 'package:google_sign_in_web/src/gis_client.dart'; import 'package:google_sign_in_web/web_only.dart' as web; import 'package:integration_test/integration_test.dart'; import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart' as mockito; +import 'package:mockito/mockito.dart'; import 'web_only_test.mocks.dart'; @@ -31,42 +32,73 @@ void main() { web.renderButton(); }, throwsAssertionError); }); - - testWidgets('requestServerAuthCode throws', (WidgetTester _) async { - expect(() async { - await web.requestServerAuthCode(); - }, throwsAssertionError); - }); }); group('web plugin instance', () { - const String someAuthCode = '50m3_4u7h_c0d3'; late MockGisSdkClient mockGis; - setUp(() { + setUp(() async { mockGis = MockGisSdkClient(); GoogleSignInPlatform.instance = GoogleSignInPlugin( debugOverrideLoader: true, debugOverrideGisSdkClient: mockGis, - )..initWithParams( - const SignInInitParameters( - clientId: 'does-not-matter', - ), - ); + ); + await GoogleSignInPlatform.instance.init( + const InitParameters( + clientId: 'does-not-matter', + ), + ); }); - testWidgets('call reaches GIS API', (WidgetTester _) async { - mockito - .when(mockGis.requestServerAuthCode()) - .thenAnswer((_) => Future.value(someAuthCode)); + testWidgets('renderButton returns successfully', (WidgetTester _) async { + when(mockGis.renderButton(any, any)) + .thenAnswer((_) => Future.value()); - final String? serverAuthCode = await web.requestServerAuthCode(); + final Widget button = web.renderButton(); - expect(serverAuthCode, someAuthCode); + expect(button, isNotNull); }); }); } /// Fake non-web implementation used to verify that the web_only methods /// throw when the wrong type of instance is configured. -class NonWebImplementation extends GoogleSignInPlatform {} +class NonWebImplementation extends GoogleSignInPlatform { + @override + Future? attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params) { + throw UnimplementedError(); + } + + @override + Future authenticate(AuthenticateParameters params) { + throw UnimplementedError(); + } + + @override + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params) { + throw UnimplementedError(); + } + + @override + Future disconnect(DisconnectParams params) { + throw UnimplementedError(); + } + + @override + Future init(InitParameters params) { + throw UnimplementedError(); + } + + @override + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params) { + throw UnimplementedError(); + } + + @override + Future signOut(SignOutParams params) { + throw UnimplementedError(); + } +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.mocks.dart index e7505d6c8ef0..b2b181c0cd8a 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/web_only_test.mocks.dart @@ -1,14 +1,14 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in google_sign_in_web_integration_tests/integration_test/web_only_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; +import 'dart:async' as _i3; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' - as _i2; -import 'package:google_sign_in_web/src/button_configuration.dart' as _i5; -import 'package:google_sign_in_web/src/gis_client.dart' as _i3; + as _i5; +import 'package:google_sign_in_web/src/button_configuration.dart' as _i4; +import 'package:google_sign_in_web/src/gis_client.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: type=lint @@ -19,161 +19,73 @@ 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, - ); -} - /// A class which mocks [GisSdkClient]. /// /// See the documentation for Mockito's code generation for more information. -class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { +class MockGisSdkClient extends _i1.Mock implements _i2.GisSdkClient { @override - _i4.Future<_i2.GoogleSignInUserData?> signInSilently() => (super.noSuchMethod( - Invocation.method( - #signInSilently, - [], - ), - returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), - returnValueForMissingStub: - _i4.Future<_i2.GoogleSignInUserData?>.value(), - ) as _i4.Future<_i2.GoogleSignInUserData?>); + void requestOneTap() => super.noSuchMethod( + Invocation.method(#requestOneTap, []), + returnValueForMissingStub: null, + ); @override - _i4.Future renderButton( + _i3.Future renderButton( Object? parent, - _i5.GSIButtonConfiguration? options, + _i4.GSIButtonConfiguration? options, ) => (super.noSuchMethod( - Invocation.method( - #renderButton, - [ - parent, - options, - ], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future requestServerAuthCode() => (super.noSuchMethod( - Invocation.method( - #requestServerAuthCode, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( - Invocation.method( - #signIn, - [], - ), - returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), - returnValueForMissingStub: - _i4.Future<_i2.GoogleSignInUserData?>.value(), - ) as _i4.Future<_i2.GoogleSignInUserData?>); - - @override - _i2.GoogleSignInTokenData getTokens() => (super.noSuchMethod( - Invocation.method( - #getTokens, - [], - ), - returnValue: _FakeGoogleSignInTokenData_0( - this, - Invocation.method( - #getTokens, - [], - ), - ), - returnValueForMissingStub: _FakeGoogleSignInTokenData_0( - this, - Invocation.method( - #getTokens, - [], - ), - ), - ) as _i2.GoogleSignInTokenData); + Invocation.method(#renderButton, [parent, options]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future signOut() => (super.noSuchMethod( - Invocation.method( - #signOut, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i3.Future requestServerAuthCode( + _i5.AuthorizationRequestDetails? request, + ) => + (super.noSuchMethod( + Invocation.method(#requestServerAuthCode, [request]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future disconnect() => (super.noSuchMethod( - Invocation.method( - #disconnect, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i3.Future signOut() => (super.noSuchMethod( + Invocation.method(#signOut, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future isSignedIn() => (super.noSuchMethod( - Invocation.method( - #isSignedIn, - [], - ), - returnValue: _i4.Future.value(false), - returnValueForMissingStub: _i4.Future.value(false), - ) as _i4.Future); + _i3.Future disconnect() => (super.noSuchMethod( + Invocation.method(#disconnect, []), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); @override - _i4.Future clearAuthCache() => (super.noSuchMethod( - Invocation.method( - #clearAuthCache, - [], - ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); - - @override - _i4.Future requestScopes(List? scopes) => (super.noSuchMethod( + _i3.Future requestScopes( + List? scopes, { + required bool? promptIfUnauthorized, + String? userHint, + }) => + (super.noSuchMethod( Invocation.method( #requestScopes, [scopes], + { + #promptIfUnauthorized: promptIfUnauthorized, + #userHint: userHint, + }, ), - returnValue: _i4.Future.value(false), - returnValueForMissingStub: _i4.Future.value(false), - ) as _i4.Future); - - @override - _i4.Future canAccessScopes( - List? scopes, - String? accessToken, - ) => - (super.noSuchMethod( - Invocation.method( - #canAccessScopes, - [ - scopes, - accessToken, - ], - ), - returnValue: _i4.Future.value(false), - returnValueForMissingStub: _i4.Future.value(false), - ) as _i4.Future); + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); } diff --git a/packages/google_sign_in/google_sign_in_web/example/lib/button_tester.dart b/packages/google_sign_in/google_sign_in_web/example/lib/button_tester.dart index 02b4346e9b87..862afe64e912 100644 --- a/packages/google_sign_in/google_sign_in_web/example/lib/button_tester.dart +++ b/packages/google_sign_in/google_sign_in_web/example/lib/button_tester.dart @@ -14,7 +14,7 @@ import 'src/button_configuration_column.dart'; final GoogleSignInPlatform _platform = GoogleSignInPlatform.instance; Future main() async { - await _platform.initWithParams(const SignInInitParameters( + await _platform.init(const InitParameters( clientId: 'your-client_id.apps.googleusercontent.com', )); runApp( @@ -41,19 +41,21 @@ class _ButtonConfiguratorState extends State { @override void initState() { super.initState(); - _platform.userDataEvents?.listen((GoogleSignInUserData? userData) { + _platform.authenticationEvents?.listen((AuthenticationEvent authEvent) { setState(() { - _userData = userData; + switch (authEvent) { + case AuthenticationEventSignIn(): + _userData = authEvent.user; + case AuthenticationEventSignOut(): + case AuthenticationEventException(): + _userData = null; + } }); }); } void _handleSignOut() { - _platform.signOut(); - setState(() { - // signOut does not broadcast through the userDataEvents, so we fake it. - _userData = null; - }); + _platform.signOut(const SignOutParams()); } void _handleNewWebButtonConfiguration(GSIButtonConfiguration newConfig) { diff --git a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml index fbc10d72532e..665db5488c09 100644 --- a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml @@ -26,3 +26,7 @@ dev_dependencies: flutter: uses-material-design: true +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_platform_interface: {path: ../../../../packages/google_sign_in/google_sign_in_platform_interface} diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index e6d3abc9e585..e897f13e9c8b 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -3,12 +3,10 @@ // found in the LICENSE file. import 'dart:async'; -import 'dart:js_interop'; import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart' show kDebugMode, visibleForTesting; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show PlatformException; import 'package:flutter/widgets.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:google_identity_services_web/loader.dart' as loader; @@ -50,10 +48,10 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { @visibleForTesting bool debugOverrideLoader = false, @visibleForTesting GisSdkClient? debugOverrideGisSdkClient, @visibleForTesting - StreamController? debugOverrideUserDataController, + StreamController? debugAuthenticationController, }) : _gisSdkClient = debugOverrideGisSdkClient, - _userDataController = debugOverrideUserDataController ?? - StreamController.broadcast() { + _authenticationController = debugAuthenticationController ?? + StreamController.broadcast() { autoDetectedClientId = web.document .querySelector(clientIdMetaSelector) ?.getAttribute(clientIdAttributeName); @@ -73,7 +71,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { Completer? _initCalled; // A StreamController to communicate status changes from the GisSdkClient. - final StreamController _userDataController; + final StreamController _authenticationController; // The instance of [GisSdkClient] backing the plugin. GisSdkClient? _gisSdkClient; @@ -104,8 +102,8 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { /// A future that resolves when the plugin is fully initialized. /// - /// This ensures that the SDK has been loaded, and that the `initWithParams` - /// method has finished running. + /// This ensures that the SDK has been loaded, and that the `init` method + /// has finished running. @visibleForTesting Future get initialized { _assertIsInitCalled(); @@ -123,22 +121,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } @override - Future init({ - List scopes = const [], - SignInOption signInOption = SignInOption.standard, - String? hostedDomain, - String? clientId, - }) { - return initWithParams(SignInInitParameters( - scopes: scopes, - signInOption: signInOption, - hostedDomain: hostedDomain, - clientId: clientId, - )); - } - - @override - Future initWithParams(SignInInitParameters params) async { + Future init(InitParameters params) async { final String? appClientId = params.clientId ?? autoDetectedClientId; assert( appClientId != null, @@ -149,162 +132,123 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { assert(params.serverClientId == null, 'serverClientId is not supported on Web.'); - assert( - !params.scopes.any((String scope) => scope.contains(' ')), - "OAuth 2.0 Scopes for Google APIs can't contain spaces. " - 'Check https://developers.google.com/identity/protocols/googlescopes ' - 'for a list of valid OAuth 2.0 scopes.'); - - assert(params.forceAccountName == null, - 'forceAccountName is not supported on Web.'); - _initCalled = Completer(); await _jsSdkLoadedFuture; _gisSdkClient ??= GisSdkClient( clientId: appClientId!, + nonce: params.nonce, hostedDomain: params.hostedDomain, - initialScopes: List.from(params.scopes), - userDataController: _userDataController, + authenticationController: _authenticationController, loggingEnabled: kDebugMode, ); _initCalled!.complete(); // Signal that `init` is fully done. } - // Register a factory for the Button HtmlElementView. - void _registerButtonFactory() { - ui_web.platformViewRegistry.registerViewFactory( - 'gsi_login_button', - (int viewId) { - final web.Element element = web.document.createElement('div'); - element.setAttribute('style', - 'width: 100%; height: 100%; overflow: hidden; display: flex; flex-wrap: wrap; align-content: center; justify-content: center;'); - element.id = 'sign_in_button_$viewId'; - return element; - }, - ); - } - - /// Render the GSI button web experience. - Widget renderButton({GSIButtonConfiguration? configuration}) { - final GSIButtonConfiguration config = - configuration ?? GSIButtonConfiguration(); - return FutureBuilder( - key: Key(config.hashCode.toString()), - future: initialized, - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (snapshot.hasData) { - return FlexHtmlElementView( - viewType: 'gsi_login_button', - onElementCreated: (Object element) { - _gisClient.renderButton(element, config); - }); - } - return const Text('Getting ready'); - }, - ); - } - @override - Future signInSilently() async { - await initialized; - - // The new user is being injected from the `userDataEvents` Stream. - return _gisClient.signInSilently(); + Future? attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params) { + initialized.then((void value) { + _gisClient.requestOneTap(); + }); + // One tap does not necessarily return immediately, and may never return, + // so clients should not await it. Return null to signal that. + return null; } @override - Future signIn() async { - if (kDebugMode) { - web.console.warn( - "The `signIn` method is discouraged on the web because it can't reliably provide an `idToken`.\n" - 'Use `signInSilently` and `renderButton` to authenticate your users instead.\n' - 'Read more: https://pub.dev/packages/google_sign_in_web' - .toJS); - } - await initialized; - - // This method mainly does oauth2 authorization, which happens to also do - // authentication if needed. However, the authentication information is not - // returned anymore. - // - // This method will synthesize authentication information from the People API - // if needed (or use the last identity seen from signInSilently). - try { - return _gisClient.signIn(); - } catch (reason) { - throw PlatformException( - code: reason.toString(), - message: 'Exception raised from signIn', - details: - 'https://developers.google.com/identity/oauth2/web/guides/error', - ); - } - } + bool supportsAuthenticate() => false; @override - Future getTokens({ - required String email, - bool? shouldRecoverAuth, - }) async { - await initialized; - - return _gisClient.getTokens(); + Future authenticate( + AuthenticateParameters params) async { + throw UnimplementedError('authenticate is not supported on the web. ' + 'Instead, use renderButton to create a sign-in widget.'); } @override - Future signOut() async { + Future signOut(SignOutParams params) async { await initialized; await _gisClient.signOut(); } @override - Future disconnect() async { + Future disconnect(DisconnectParams params) async { await initialized; await _gisClient.disconnect(); } @override - Future isSignedIn() async { + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params) async { await initialized; - return _gisClient.isSignedIn(); + final String? token = await _gisClient.requestScopes(params.request.scopes, + promptIfUnauthorized: params.request.promptIfUnauthorized, + userHint: params.request.userId); + return token == null + ? null + : ClientAuthorizationTokenData(accessToken: token); } @override - Future clearAuthCache({required String token}) async { + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params) async { await initialized; - await _gisClient.clearAuthCache(); - } - - @override - Future requestScopes(List scopes) async { - await initialized; + // There is no way to know whether the flow will prompt in advance, so + // always return null if prompting isn't allowed. + if (!params.request.promptIfUnauthorized) { + return null; + } - return _gisClient.requestScopes(scopes); + final String? code = await _gisClient.requestServerAuthCode(params.request); + return code == null + ? null + : ServerAuthorizationTokenData(serverAuthCode: code); } @override - Future canAccessScopes(List scopes, - {String? accessToken}) async { - await initialized; + Stream get authenticationEvents => + _authenticationController.stream; - return _gisClient.canAccessScopes(scopes, accessToken); - } + // -------- - @override - Stream? get userDataEvents => - _userDataController.stream; + // Register a factory for the Button HtmlElementView. + void _registerButtonFactory() { + ui_web.platformViewRegistry.registerViewFactory( + 'gsi_login_button', + (int viewId) { + final web.Element element = web.document.createElement('div'); + element.setAttribute('style', + 'width: 100%; height: 100%; overflow: hidden; display: flex; flex-wrap: wrap; align-content: center; justify-content: center;'); + element.id = 'sign_in_button_$viewId'; + return element; + }, + ); + } - /// Requests server auth code from GIS Client per: - /// https://developers.google.com/identity/oauth2/web/guides/use-code-model#initialize_a_code_client - Future requestServerAuthCode() async { - await initialized; - return _gisClient.requestServerAuthCode(); + /// Render the GSI button web experience. + Widget renderButton({GSIButtonConfiguration? configuration}) { + final GSIButtonConfiguration config = + configuration ?? GSIButtonConfiguration(); + return FutureBuilder( + key: Key(config.hashCode.toString()), + future: initialized, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return FlexHtmlElementView( + viewType: 'gsi_login_button', + onElementCreated: (Object element) { + _gisClient.renderButton(element, config); + }); + } + return const Text('Getting ready'); + }, + ); } } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart index 421ce1a187fd..90bfc7c141ec 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart @@ -12,7 +12,6 @@ import 'package:web/web.dart' as web; import 'button_configuration.dart' show GSIButtonConfiguration, convertButtonConfiguration; -import 'people.dart' as people; import 'utils.dart' as utils; /// A client to hide (most) of the interaction with the GIS SDK from the plugin. @@ -21,44 +20,29 @@ import 'utils.dart' as utils; class GisSdkClient { /// Create a GisSdkClient object. GisSdkClient({ - required List initialScopes, required String clientId, - required StreamController userDataController, + required StreamController authenticationController, bool loggingEnabled = false, + String? nonce, String? hostedDomain, - }) : _initialScopes = initialScopes, + }) : _clientId = clientId, + _hostedDomain = hostedDomain, _loggingEnabled = loggingEnabled, - _userDataEventsController = userDataController { + _authenticationController = authenticationController { if (_loggingEnabled) { id.setLogLevel('debug'); } - // Configure the Stream objects that are going to be used by the clients. - _configureStreams(); + _configureAuthenticationStream(); - // Initialize the SDK clients we need. + // Initialize the authentication SDK client. Authorization clients will be + // created as one-offs as needed. _initializeIdClient( clientId, onResponse: _onCredentialResponse, + nonce: nonce, hostedDomain: hostedDomain, useFedCM: true, ); - - _tokenClient = _initializeTokenClient( - clientId, - hostedDomain: hostedDomain, - onResponse: _onTokenResponse, - onError: _onTokenError, - ); - - if (initialScopes.isNotEmpty) { - _codeClient = _initializeCodeClient( - clientId, - hostedDomain: hostedDomain, - onResponse: _onCodeResponse, - onError: _onCodeError, - scopes: initialScopes, - ); - } } void _logIfEnabled(String message, [List? more]) { @@ -69,61 +53,38 @@ class GisSdkClient { } } - // Configure the credential (authentication) and token (authorization) response streams. - void _configureStreams() { - _tokenResponses = StreamController.broadcast(); + // Configure the credential (authentication) response stream. + void _configureAuthenticationStream() { _credentialResponses = StreamController.broadcast(); - _codeResponses = StreamController.broadcast(); - - _tokenResponses.stream.listen((TokenResponse response) { - _lastTokenResponse = response; - _lastTokenResponseExpiration = - DateTime.now().add(Duration(seconds: response.expires_in!)); - }, onError: (Object error) { - _logIfEnabled('Error on TokenResponse:', [error.toString()]); - _lastTokenResponse = null; - }); - - _codeResponses.stream.listen((CodeResponse response) { - _lastCodeResponse = response; - }, onError: (Object error) { - _logIfEnabled('Error on CodeResponse:', [error.toString()]); - _lastCodeResponse = null; - }); - - _credentialResponses.stream.listen((CredentialResponse response) { - _lastCredentialResponse = response; - }, onError: (Object error) { - _logIfEnabled('Error on CredentialResponse:', [error.toString()]); - _lastCredentialResponse = null; - }); // In the future, the userDataEvents could propagate null userDataEvents too. _credentialResponses.stream - .map(utils.gisResponsesToUserData) - .handleError(_cleanCredentialResponsesStreamErrors) - .forEach(_userDataEventsController.add); + .map(utils.gisResponsesToAuthenticationEvent) + .handleError(_convertCredentialResponsesStreamErrors) + .forEach(_authenticationController.add); } // This function handles the errors that on the _credentialResponses Stream. // - // Most of the time, these errors are part of the flow (like when One Tap UX - // cannot be rendered), and the stream of userDataEvents doesn't care about - // them. - // // (This has been separated to a function so the _configureStreams formatting // looks a little bit better) - void _cleanCredentialResponsesStreamErrors(Object error) { - _logIfEnabled( - 'Removing error from `userDataEvents`:', - [error.toString()], - ); + void _convertCredentialResponsesStreamErrors(Object error) { + _logIfEnabled('Error on CredentialResponse:', [error.toString()]); + if (error is GoogleSignInException) { + _authenticationController.add(AuthenticationEventException(error)); + } else { + _authenticationController.add(AuthenticationEventException( + GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: error.toString()))); + } } // Initializes the `id` SDK for the silent-sign in (authentication) client. void _initializeIdClient( String clientId, { required CallbackFn onResponse, + String? nonce, String? hostedDomain, bool? useFedCM, }) { @@ -133,6 +94,7 @@ class GisSdkClient { callback: onResponse, cancel_on_tap_outside: false, auto_select: true, // Attempt to sign-in silently. + nonce: nonce, hd: hostedDomain, use_fedcm_for_prompt: useFedCM, // Use the native browser prompt, when available. @@ -144,8 +106,9 @@ class GisSdkClient { // // (Normal doesn't mean successful, this might contain `error` information.) void _onCredentialResponse(CredentialResponse response) { - if (response.error != null) { - _credentialResponses.addError(response.error!); + final String? error = response.error; + if (error != null) { + _credentialResponses.addError(error); } else { _credentialResponses.add(response); } @@ -154,99 +117,53 @@ class GisSdkClient { // Creates a `oauth2.TokenClient` used for authorization (scope) requests. TokenClient _initializeTokenClient( String clientId, { + required List scopes, + String? userHint, String? hostedDomain, required TokenClientCallbackFn onResponse, required ErrorCallbackFn onError, }) { // Create a Token Client for authorization calls. final TokenClientConfig tokenConfig = TokenClientConfig( + prompt: userHint == null ? '' : 'select_account', client_id: clientId, + login_hint: userHint, hd: hostedDomain, - callback: _onTokenResponse, - error_callback: _onTokenError, - // This is here only to satisfy the initialization of the JS TokenClient. - // In reality, `scope` is always overridden when calling `requestScopes` - // (or the deprecated `signIn`) through an [OverridableTokenClientConfig] - // object. - scope: [' '], // Fake (but non-empty) list of scopes. + callback: onResponse, + error_callback: onError, + scope: scopes, ); return oauth2.initTokenClient(tokenConfig); } - // Handle a "normal" token (authorization) response. - // - // (Normal doesn't mean successful, this might contain `error` information.) - void _onTokenResponse(TokenResponse response) { - if (response.error != null) { - _tokenResponses.addError(response.error!); - } else { - _tokenResponses.add(response); - } - } - - // Handle a "not-directly-related-to-authorization" error. - // - // Token clients have an additional `error_callback` for miscellaneous - // errors, like "popup couldn't open" or "popup closed by user". - void _onTokenError(GoogleIdentityServicesError? error) { - if (error != null) { - _tokenResponses.addError(error.type); - } - } - // Creates a `oauth2.CodeClient` used for authorization (scope) requests. - CodeClient _initializeCodeClient( - String clientId, { - String? hostedDomain, + CodeClient _initializeCodeClient({ + String? userHint, required List scopes, required CodeClientCallbackFn onResponse, required ErrorCallbackFn onError, }) { // Create a Token Client for authorization calls. final CodeClientConfig codeConfig = CodeClientConfig( - client_id: clientId, - hd: hostedDomain, - callback: _onCodeResponse, - error_callback: _onCodeError, + client_id: _clientId, + login_hint: userHint, + hd: _hostedDomain, + callback: onResponse, + error_callback: onError, scope: scopes, - select_account: true, + select_account: userHint == null, + include_granted_scopes: true, ux_mode: UxMode.popup, ); return oauth2.initCodeClient(codeConfig); } - void _onCodeResponse(CodeResponse response) { - if (response.error != null) { - _codeResponses.addError(response.error!); - } else { - _codeResponses.add(response); - } - } - - void _onCodeError(GoogleIdentityServicesError? error) { - if (error != null) { - _codeResponses.addError(error.type); - } - } - /// Attempts to sign-in the user using the OneTap UX flow. - /// - /// If the user consents, to OneTap, the [GoogleSignInUserData] will be - /// generated from a proper [CredentialResponse], which contains `idToken`. - /// Else, it'll be synthesized by a request to the People API later, and the - /// `idToken` will be null. - Future signInSilently() async { - final Completer userDataCompleter = - Completer(); - + void requestOneTap() { // Ask the SDK to render the OneClick sign-in. // // And also handle its "moments". - id.prompt((PromptMomentNotification moment) { - _onPromptMoment(moment, userDataCompleter); - }); - - return userDataCompleter.future; + id.prompt(_onPromptMoment); } // Handles "prompt moments" of the OneClick card UI. @@ -254,36 +171,37 @@ class GisSdkClient { // See: https://developers.google.com/identity/gsi/web/guides/receive-notifications-prompt-ui-status Future _onPromptMoment( PromptMomentNotification moment, - Completer completer, ) async { - if (completer.isCompleted) { - return; // Skip once the moment has been handled. + if (moment.isDismissedMoment()) { + final MomentDismissedReason? reason = moment.getDismissedReason(); + switch (reason) { + case MomentDismissedReason.credential_returned: + // Nothing to do here, as the success handler will run. + break; + case MomentDismissedReason.cancel_called: + _credentialResponses.addError(const GoogleSignInException( + code: GoogleSignInExceptionCode.canceled)); + case MomentDismissedReason.flow_restarted: + // Ignore, as this is not a final state. + break; + case MomentDismissedReason.unknown_reason: + case null: + _credentialResponses.addError(GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: 'dismissed: $reason')); + } + return; } - if (moment.isDismissedMoment() && - moment.getDismissedReason() == - MomentDismissedReason.credential_returned) { - // Kick this part of the handler to the bottom of the JS event queue, so - // the _credentialResponses stream has time to propagate its last value, - // and we can use _lastCredentialResponse. - return Future.delayed(Duration.zero, () { - completer - .complete(utils.gisResponsesToUserData(_lastCredentialResponse)); - }); + if (moment.isSkippedMoment()) { + // getSkippedReason is not used in the exception details here, per + // https://developers.google.com/identity/gsi/web/guides/fedcm-migration + _credentialResponses.addError(const GoogleSignInException( + code: GoogleSignInExceptionCode.canceled)); } - // In any other 'failed' moments, return null and add an error to the stream. - if (moment.isNotDisplayed() || - moment.isSkippedMoment() || - moment.isDismissedMoment()) { - final String reason = moment.getNotDisplayedReason()?.toString() ?? - moment.getSkippedReason()?.toString() ?? - moment.getDismissedReason()?.toString() ?? - 'unknown_error'; - - _credentialResponses.addError(reason); - completer.complete(null); - } + // isNotDisplayed is intentionally ignored, per + // https://developers.google.com/identity/gsi/web/guides/fedcm-migration } /// Calls `id.renderButton` on [parent] with the given [options]. @@ -296,208 +214,162 @@ class GisSdkClient { /// Requests a server auth code per: /// https://developers.google.com/identity/oauth2/web/guides/use-code-model#initialize_a_code_client - Future requestServerAuthCode() async { - // TODO(dit): Enable granular authorization, https://github.com/flutter/flutter/issues/139406 - assert(_codeClient != null, - 'CodeClient not initialized correctly. Ensure the `scopes` list passed to `init()` or `initWithParams()` is not empty!'); - if (_codeClient == null) { - return null; - } - _codeClient!.requestCode(); - final CodeResponse response = await _codeResponses.stream.first; - return response.code; - } - - // TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727 - // - /// Starts an oauth2 "implicit" flow to authorize requests. - /// - /// The new GIS SDK does not return user authentication from this flow, so: - /// * If [_lastCredentialResponse] is **not** null (the user has successfully - /// `signInSilently`), we return that after this method completes. - /// * If [_lastCredentialResponse] is null, we add [people.scopes] to the - /// [_initialScopes], so we can retrieve User Profile information back - /// from the People API (without idToken). See [people.requestUserData]. - @Deprecated( - 'Use `renderButton` instead. See: https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services') - Future signIn() async { - // Warn users that this method will be removed. - web.console.warn( - 'The google_sign_in plugin `signIn` method is deprecated on the web, and will be removed in Q2 2024. Please use `renderButton` instead. See: ' - 'https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services' - .toJS); - // If we already know the user, use their `email` as a `hint`, so they don't - // have to pick their user again in the Authorization popup. - final GoogleSignInUserData? knownUser = - utils.gisResponsesToUserData(_lastCredentialResponse); - // This toggles a popup, so `signIn` *must* be called with - // user activation. - _tokenClient.requestAccessToken(OverridableTokenClientConfig( - prompt: knownUser == null ? 'select_account' : '', - login_hint: knownUser?.email, - scope: [ - ..._initialScopes, - // If the user hasn't gone through the auth process, - // the plugin will attempt to `requestUserData` after, - // so we need extra scopes to retrieve that info. - if (_lastCredentialResponse == null) ...people.scopes, - ], - )); - - await _tokenResponses.stream.first; - - return _computeUserDataForLastToken(); - } + Future requestServerAuthCode( + AuthorizationRequestDetails request) async { + final Completer<(String? code, Exception? e)> completer = + Completer<(String? code, Exception? e)>(); + final CodeClient codeClient = _initializeCodeClient( + userHint: request.userId, + onResponse: (CodeResponse response) { + final String? error = response.error; + if (error == null) { + completer.complete((response.code, null)); + } else { + completer.complete(( + null, + GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: response.error_description, + details: 'code: $error') + )); + } + }, + onError: (GoogleIdentityServicesError? error) { + completer.complete((null, _exceptionForGisError(error))); + }, + scopes: request.scopes, + ); - // This function returns the currently signed-in [GoogleSignInUserData]. - // - // It'll do a request to the People API (if needed). - // - // TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727 - Future _computeUserDataForLastToken() async { - // If the user hasn't authenticated, request their basic profile info - // from the People API. - // - // This synthetic response will *not* contain an `idToken` field. - if (_lastCredentialResponse == null && _requestedUserData == null) { - assert(_lastTokenResponse != null); - _requestedUserData = await people.requestUserData(_lastTokenResponse!); + codeClient.requestCode(); + final (String? code, Exception? e) = await completer.future; + if (e != null) { + throw e; } - // Complete user data either with the _lastCredentialResponse seen, - // or the synthetic _requestedUserData from above. - return utils.gisResponsesToUserData(_lastCredentialResponse) ?? - _requestedUserData; - } - - /// Returns a [GoogleSignInTokenData] from the latest seen responses. - GoogleSignInTokenData getTokens() { - return utils.gisResponsesToTokenData( - _lastCredentialResponse, - _lastTokenResponse, - _lastCodeResponse, - ); + return code; } /// Revokes the current authentication. Future signOut() async { - await clearAuthCache(); + _lastClientAuthorizationByUser.clear(); id.disableAutoSelect(); + _authenticationController.add(AuthenticationEventSignOut()); } - /// Revokes the current authorization and authentication. + /// Revokes all cached authorization tokens. Future disconnect() async { - if (_lastTokenResponse != null) { - oauth2.revoke(_lastTokenResponse!.access_token!); - } + _lastClientAuthorizationByUser.values + .map(((TokenResponse?, DateTime?) auth) => auth.$1?.access_token) + .nonNulls + .forEach(oauth2.revoke); + _lastClientAuthorizationByUser.clear(); await signOut(); } - /// Returns true if the client has recognized this user before, and the last-seen - /// credential is not expired. - Future isSignedIn() async { - bool isSignedIn = false; - if (_lastCredentialResponse != null) { - final DateTime? expiration = utils - .getCredentialResponseExpirationTimestamp(_lastCredentialResponse); - // All Google ID Tokens provide an "exp" date. If the method above cannot - // extract `expiration`, it's because `_lastCredentialResponse`'s contents - // are unexpected (or wrong) in any way. - // - // Users are considered to be signedIn when the last CredentialResponse - // exists and has an expiration date in the future. - // - // Users are not signed in in any other case. - // - // See: https://developers.google.com/identity/openid-connect/openid-connect#an-id-tokens-payload - isSignedIn = expiration?.isAfter(DateTime.now()) ?? false; - } - - return isSignedIn || _requestedUserData != null; - } - - /// Clears all the cached results from authentication and authorization. - Future clearAuthCache() async { - _lastCredentialResponse = null; - _lastTokenResponse = null; - _requestedUserData = null; - _lastCodeResponse = null; - } - - /// Requests the list of [scopes] passed in to the client. + /// Requests the given list of [scopes], and returns the resulting + /// authorization token if successful. /// /// Keeps the previously granted scopes. - Future requestScopes(List scopes) async { - // If we already know the user, use their `email` as a `hint`, so they don't - // have to pick their user again in the Authorization popup. - final GoogleSignInUserData? knownUser = - utils.gisResponsesToUserData(_lastCredentialResponse); - - _tokenClient.requestAccessToken(OverridableTokenClientConfig( - prompt: knownUser == null ? 'select_account' : '', - login_hint: knownUser?.email, - scope: scopes, - include_granted_scopes: true, - )); + Future requestScopes(List scopes, + {required bool promptIfUnauthorized, String? userHint}) async { + // If there's a usable cached token, return that. + final (TokenResponse? cachedResponse, DateTime? cacheExpiration) = + _lastClientAuthorizationByUser[userHint] ?? (null, null); + if (cachedResponse != null) { + final bool isTokenValid = + cacheExpiration?.isAfter(DateTime.now()) ?? false; + if (isTokenValid && oauth2.hasGrantedAllScopes(cachedResponse, scopes)) { + return cachedResponse.access_token; + } + } + + if (!promptIfUnauthorized) { + return null; + } - await _tokenResponses.stream.first; + final Completer<(String? token, Exception? e)> completer = + Completer<(String? token, Exception? e)>(); + final TokenClient tokenClient = _initializeTokenClient( + _clientId, + scopes: scopes, + userHint: userHint, + hostedDomain: _hostedDomain, + onResponse: (TokenResponse response) { + final String? error = response.error; + if (error == null) { + final String? token = response.access_token; + if (token == null) { + _lastClientAuthorizationByUser.remove(userHint); + } else { + final DateTime expiration = + DateTime.now().add(Duration(seconds: response.expires_in!)); + _lastClientAuthorizationByUser[userHint] = (response, expiration); + } + completer.complete((response.access_token, null)); + } else { + _lastClientAuthorizationByUser.remove(userHint); + completer.complete(( + null, + GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: response.error_description, + details: 'code: $error') + )); + } + }, + onError: (GoogleIdentityServicesError? error) { + _lastClientAuthorizationByUser.remove(userHint); + completer.complete((null, _exceptionForGisError(error))); + }, + ); + tokenClient.requestAccessToken(); - return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); + final (String? token, Exception? e) = await completer.future; + if (e != null) { + throw e; + } + return token; } - /// Checks if the passed-in `accessToken` can access all `scopes`. - /// - /// This validates that the `accessToken` is the same as the last seen - /// token response, that the token is not expired, then uses that response to - /// check if permissions are still granted. - Future canAccessScopes(List scopes, String? accessToken) async { - if (accessToken != null && _lastTokenResponse != null) { - if (accessToken == _lastTokenResponse!.access_token) { - final bool isTokenValid = - _lastTokenResponseExpiration?.isAfter(DateTime.now()) ?? false; - return isTokenValid && - oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); - } + GoogleSignInException _exceptionForGisError( + GoogleIdentityServicesError? error) { + final GoogleSignInExceptionCode code; + switch (error?.type) { + case GoogleIdentityServicesErrorType.missing_required_parameter: + code = GoogleSignInExceptionCode.clientConfigurationError; + case GoogleIdentityServicesErrorType.popup_closed: + code = GoogleSignInExceptionCode.canceled; + case GoogleIdentityServicesErrorType.popup_failed_to_open: + code = GoogleSignInExceptionCode.uiUnavailable; + case GoogleIdentityServicesErrorType.unknown: + case null: + code = GoogleSignInExceptionCode.unknownError; } - return false; + return GoogleSignInException( + code: code, + description: error?.message ?? 'SDK returned no error details'); } final bool _loggingEnabled; - // The scopes initially requested by the developer. - // - // We store this because we might need to add more at `signIn`. If the user - // doesn't `silentSignIn`, we expand this list to consult the People API to - // return some basic Authentication information. - final List _initialScopes; + // The identifier of this web client. + final String _clientId; - // The Google Identity Services client for oauth requests. - late TokenClient _tokenClient; - // CodeClient will not be created if `initialScopes` is empty. - CodeClient? _codeClient; + /// The domain to restrict logins to. + final String? _hostedDomain; - // Streams of credential and token responses. + // Stream of credential responses from sign-in events. late StreamController _credentialResponses; - late StreamController _tokenResponses; - late StreamController _codeResponses; - // The last-seen credential and token responses - CredentialResponse? _lastCredentialResponse; - TokenResponse? _lastTokenResponse; - // Expiration timestamp for the lastTokenResponse, which only has an `expires_in` field. - DateTime? _lastTokenResponseExpiration; - CodeResponse? _lastCodeResponse; + // The last client authorization token responses, keyed by the user ID the + // authorization was requested for. A nil key stores the last authorization + // request that was not associated with a known user (i.e., no user ID hint + // was provided with the request). + final Map + _lastClientAuthorizationByUser = + {}; /// The StreamController onto which the GIS Client propagates user authentication events. /// /// This is provided by the implementation of the plugin. - final StreamController _userDataEventsController; - - // If the user *authenticates* (signs in) through oauth2, the SDK doesn't return - // identity information anymore, so we synthesize it by calling the PeopleAPI - // (if needed) - // - // (This is a synthetic _lastCredentialResponse) - // - // TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727 - GoogleSignInUserData? _requestedUserData; + final StreamController _authenticationController; } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart index 05ed6a877d18..0f27f04d195b 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'package:google_identity_services_web/id.dart'; -import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; /// A codec that can encode/decode JWT payloads. @@ -65,7 +64,7 @@ Map? getResponsePayload(CredentialResponse? response) { /// /// May return `null`, if the `credentialResponse` is null, or its `credential` /// cannot be decoded. -GoogleSignInUserData? gisResponsesToUserData( +AuthenticationEvent? gisResponsesToAuthenticationEvent( CredentialResponse? credentialResponse) { final Map? payload = getResponsePayload(credentialResponse); if (payload == null) { @@ -75,12 +74,15 @@ GoogleSignInUserData? gisResponsesToUserData( assert(credentialResponse?.credential != null, 'The CredentialResponse cannot be null and have a payload.'); - return GoogleSignInUserData( - email: payload['email']! as String, - id: payload['sub']! as String, - displayName: payload['name'] as String?, - photoUrl: payload['picture'] as String?, - idToken: credentialResponse!.credential, + return AuthenticationEventSignIn( + user: GoogleSignInUserData( + email: payload['email']! as String, + id: payload['sub']! as String, + displayName: payload['name'] as String?, + photoUrl: payload['picture'] as String?, + ), + authenticationTokens: + AuthenticationTokenData(idToken: credentialResponse!.credential), ); } @@ -96,16 +98,3 @@ DateTime? getCredentialResponseExpirationTimestamp( // Return 'exp' (a timestamp in seconds since Epoch) as a DateTime. return (exp != null) ? DateTime.fromMillisecondsSinceEpoch(exp * 1000) : null; } - -/// Converts responses from the GIS library into TokenData for the plugin. -GoogleSignInTokenData gisResponsesToTokenData( - CredentialResponse? credentialResponse, - TokenResponse? tokenResponse, [ - CodeResponse? codeResponse, -]) { - return GoogleSignInTokenData( - idToken: credentialResponse?.credential, - accessToken: tokenResponse?.access_token, - serverAuthCode: codeResponse?.code, - ); -} diff --git a/packages/google_sign_in/google_sign_in_web/lib/web_only.dart b/packages/google_sign_in/google_sign_in_web/lib/web_only.dart index 34f153d0aef7..899a88cd45a2 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/web_only.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/web_only.dart @@ -39,10 +39,3 @@ GoogleSignInPlugin get _plugin { Widget renderButton({GSIButtonConfiguration? configuration}) { return _plugin.renderButton(configuration: configuration); } - -/// Requests server auth code from the GIS Client. -/// -/// See: https://developers.google.com/identity/oauth2/web/guides/use-code-model -Future requestServerAuthCode() async { - return _plugin.requestServerAuthCode(); -} diff --git a/packages/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml index 9ddbd479d248..830b947fb0d7 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android, iOS and Web. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.12.4+4 +version: 1.0.0 environment: sdk: ^3.4.0 @@ -34,3 +34,7 @@ dev_dependencies: topics: - authentication - google-sign-in +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + google_sign_in_platform_interface: {path: ../../../packages/google_sign_in/google_sign_in_platform_interface}