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 85c46da8facc..015334d77a59 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,5 +1,8 @@ -## NEXT +## 0.11.0 +* **Breaking Change:** Migrates JS-interop to `package:google_identity_services_web` + * Uses the new Google Identity Authentication and Authorization JS SDKs. [Docs](https://developers.google.com/identity). + * Added "Migrating to v0.11" section to the `README.md`. * Updates minimum Flutter version to 3.0. ## 0.10.2+1 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 7c02379808da..64bfd7a20161 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -2,6 +2,122 @@ The web implementation of [google_sign_in](https://pub.dev/packages/google_sign_in) +## Migrating to v0.11 (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 suing 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. +* The GIS SDK only provides an `idToken` (JWT-encoded info) when the user + successfully completes an authentication flow. In the plugin: `signInSilently`. +* The plugin `signIn` method uses the Oauth "Implicit Flow" to Authorize the requested `scopes`. + * If the user hasn't `signInSilently`, they'll have to sign in as a first step + of the Authorization popup flow. + * If `signInSilently` was unsuccessful, the plugin will add extra `scopes` to + `signIn` and retrieve basic Profile information from the People API via a + REST call immediately after a successful authorization. In this case, the + `idToken` field of the `GoogleSignInUserData` will always be null. +* 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 + +#### Enable access to the People API for your GCP project + +Since the GIS SDK is separating Authentication from Authorization, the +[Oauth Implicit pop-up flow](https://developers.google.com/identity/oauth2/web/guides/use-token-model) +used to Authorize scopes does **not** return any Authentication information +anymore (user credential / `idToken`). + +If the plugin is not able to Authenticate an user from `signInSilently` (the +OneTap UX flow), it'll add extra `scopes` to those requested by the programmer +so it can perform a [People API request](https://developers.google.com/people/api/rest/v1/people/get) +to retrieve basic profile information about the user that is signed-in. + +The information retrieved from the People API is used to complete data for the +[`GoogleSignInAccount`](https://pub.dev/documentation/google_sign_in/latest/google_sign_in/GoogleSignInAccount-class.html) +object that is returned after `signIn` completes successfully. + +#### `signInSilently` always returns `null` + +Previous versions of this plugin were able to return a `GoogleSignInAccount` +object that was fully populated (signed-in and authorized) from `signInSilently` +because the former SDK equated "is authenticated" and "is authorized". + +With the GIS SDK, `signInSilently` only deals with user Authentication, so users +retrieved "silently" will only contain an `idToken`, but not an `accessToken`. + +Only after `signIn` or `requestScopes`, a user will be fully formed. + +The GIS-backed plugin always returns `null` from `signInSilently`, to force apps +that expect the former logic to perform a full `signIn`, which will result in a +fully Authenticated and Authorized user, and making this migration easier. + +#### `idToken` is `null` in the `GoogleSignInAccount` object after `signIn` + +Since the GIS SDK is separating Authentication and Authorization, when a user +fails to Authenticate through `signInSilently` and the plugin performs the +fallback request to the People API described above, +the returned `GoogleSignInUserData` object will contain basic profile information +(name, email, photo, ID), but its `idToken` will be `null`. + +This is because JWT are cryptographically signed by Google Identity Services, and +this plugin won't spoof that signature when it retrieves the information from a +simple REST request. + +#### 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 `signIn` or +`requestScopes`, to interactively renew the token. + +The GIS SDK limits authorization token duration to one hour (3600 seconds). + ## Usage ### Import the package @@ -12,7 +128,7 @@ normally. This package will be automatically included in your app when you do. ### Web integration -First, go through the instructions [here](https://developers.google.com/identity/sign-in/web/sign-in#before_you_begin) to create your Google Sign-In OAuth client ID. +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. On your `web/index.html` file, add the following `meta` tag, somewhere in the `head` of the document: @@ -29,7 +145,10 @@ You can do this by: 2. Clicking "Edit" in the OAuth 2.0 Web application client that you created above. 3. Adding the URIs you want to the **Authorized JavaScript origins**. -For local development, may add a `localhost` entry, for example: `http://localhost:7357` +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 @@ -45,40 +164,11 @@ flutter run -d chrome --web-hostname localhost --web-port 7357 Read the rest of the instructions if you need to add extra APIs (like Google People API). - ### Using the plugin -Add the following import to your Dart code: - -```dart -import 'package:google_sign_in/google_sign_in.dart'; -``` - -Initialize GoogleSignIn with the scopes you want: -```dart -GoogleSignIn _googleSignIn = GoogleSignIn( - scopes: [ - 'email', - 'https://www.googleapis.com/auth/contacts.readonly', - ], -); -``` - -[Full list of available scopes](https://developers.google.com/identity/protocols/googlescopes). - -Note that the `serverClientId` parameter of the `GoogleSignIn` constructor is not supported on Web. +See the [**Usage** instructions of `package:google_sign_in`](https://pub.dev/packages/google_sign_in#usage) -You can now use the `GoogleSignIn` class to authenticate in your Dart code, e.g. - -```dart -Future _handleSignIn() async { - try { - await _googleSignIn.signIn(); - } catch (error) { - print(error); - } -} -``` +Note that the **`serverClientId` parameter of the `GoogleSignIn` constructor is not supported on Web.** ## Example @@ -86,7 +176,7 @@ Find the example wiring in the [Google sign-in example application](https://gith ## API details -See the [google_sign_in.dart](https://github.com/flutter/plugins/blob/main/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart) for more API details. +See [google_sign_in.dart](https://github.com/flutter/plugins/blob/main/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart) for more API details. ## Contributions and Testing diff --git a/packages/google_sign_in/google_sign_in_web/example/build.yaml b/packages/google_sign_in/google_sign_in_web/example/build.yaml new file mode 100644 index 000000000000..db3104bb04c6 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + sources: + - integration_test/*.dart + - lib/$lib$ + - $package$ diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_legacy_init_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_legacy_init_test.dart deleted file mode 100644 index 5dada90397fa..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_legacy_init_test.dart +++ /dev/null @@ -1,223 +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. - -// This file is a copy of `auth2_test.dart`, before it was migrated to the -// new `initWithParams` method, and is kept to ensure test coverage of the -// deprecated `init` method, until it is removed. - -import 'dart:html' as html; - -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_web/google_sign_in_web.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:js/js_util.dart' as js_util; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - final GoogleSignInTokenData expectedTokenData = - GoogleSignInTokenData(idToken: '70k3n', accessToken: 'access_70k3n'); - - final GoogleSignInUserData expectedUserData = GoogleSignInUserData( - displayName: 'Foo Bar', - email: 'foo@example.com', - id: '123', - photoUrl: 'http://example.com/img.jpg', - idToken: expectedTokenData.idToken, - ); - - late GoogleSignInPlugin plugin; - - group('plugin.initialize() throws a catchable exception', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('initialize throws PlatformException', - (WidgetTester tester) async { - await expectLater( - plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ), - throwsA(isA())); - }); - - testWidgets('initialize forwards error code from JS', - (WidgetTester tester) async { - try { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - fail('plugin.initialize should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'idpiframe_initialization_failed'); - } - }); - }); - - group('other methods also throw catchable exceptions on initialize fail', () { - // This function ensures that initialize gets called, but for some reason, - // we ignored that it has thrown stuff... - Future discardInit() async { - try { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - } catch (e) { - // Noop so we can call other stuff - } - } - - setUp(() { - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('signInSilently throws', (WidgetTester tester) async { - await discardInit(); - await expectLater( - plugin.signInSilently(), throwsA(isA())); - }); - - testWidgets('signIn throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('getTokens throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.getTokens(email: 'test@example.com'), - throwsA(isA())); - }); - testWidgets('requestScopes', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.requestScopes(['newScope']), - throwsA(isA())); - }); - }); - - group('auth2 Init Successful', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess(expectedUserData)); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('Init requires clientId', (WidgetTester tester) async { - expect(plugin.init(hostedDomain: ''), throwsAssertionError); - }); - - testWidgets("Init doesn't accept spaces in scopes", - (WidgetTester tester) async { - expect( - plugin.init( - hostedDomain: '', - clientId: '', - scopes: ['scope with spaces'], - ), - throwsAssertionError); - }); - - // See: https://github.com/flutter/flutter/issues/88084 - testWidgets('Init passes plugin_name parameter with the expected value', - (WidgetTester tester) async { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - - final Object? initParameters = - js_util.getProperty(html.window, 'gapi2.init.parameters'); - expect(initParameters, isNotNull); - - final Object? pluginNameParameter = - js_util.getProperty(initParameters!, 'plugin_name'); - expect(pluginNameParameter, isA()); - expect(pluginNameParameter, 'dart-google_sign_in_web'); - }); - - group('Successful .initialize, then', () { - setUp(() async { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - await plugin.initialized; - }); - - testWidgets('signInSilently', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = - (await plugin.signInSilently())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('signIn', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = (await plugin.signIn())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('getTokens', (WidgetTester tester) async { - final GoogleSignInTokenData actualToken = - await plugin.getTokens(email: expectedUserData.email); - - expect(actualToken, expectedTokenData); - }); - - testWidgets('requestScopes', (WidgetTester tester) async { - final bool scopeGranted = - await plugin.requestScopes(['newScope']); - - expect(scopeGranted, isTrue); - }); - }); - }); - - group('auth2 Init successful, but exception on signIn() method', () { - setUp(() async { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2SignInError()); - plugin = GoogleSignInPlugin(); - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - await plugin.initialized; - }); - - testWidgets('User aborts sign in flow, throws PlatformException', - (WidgetTester tester) async { - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('User aborts sign in flow, error code is forwarded from JS', - (WidgetTester tester) async { - try { - await plugin.signIn(); - fail('plugin.signIn() should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'popup_closed_by_user'); - } - }); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_test.dart deleted file mode 100644 index 3e803b83fa0c..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_test.dart +++ /dev/null @@ -1,230 +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:html' as html; - -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_web/google_sign_in_web.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:js/js_util.dart' as js_util; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - final GoogleSignInTokenData expectedTokenData = - GoogleSignInTokenData(idToken: '70k3n', accessToken: 'access_70k3n'); - - final GoogleSignInUserData expectedUserData = GoogleSignInUserData( - displayName: 'Foo Bar', - email: 'foo@example.com', - id: '123', - photoUrl: 'http://example.com/img.jpg', - idToken: expectedTokenData.idToken, - ); - - late GoogleSignInPlugin plugin; - - group('plugin.initWithParams() throws a catchable exception', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('throws PlatformException', (WidgetTester tester) async { - await expectLater( - plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )), - throwsA(isA())); - }); - - testWidgets('forwards error code from JS', (WidgetTester tester) async { - try { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - fail('plugin.initWithParams should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'idpiframe_initialization_failed'); - } - }); - }); - - group('other methods also throw catchable exceptions on initWithParams fail', - () { - // This function ensures that initWithParams gets called, but for some - // reason, we ignored that it has thrown stuff... - Future discardInit() async { - try { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - } catch (e) { - // Noop so we can call other stuff - } - } - - setUp(() { - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('signInSilently throws', (WidgetTester tester) async { - await discardInit(); - await expectLater( - plugin.signInSilently(), throwsA(isA())); - }); - - testWidgets('signIn throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('getTokens throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.getTokens(email: 'test@example.com'), - throwsA(isA())); - }); - testWidgets('requestScopes', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.requestScopes(['newScope']), - throwsA(isA())); - }); - }); - - group('auth2 Init Successful', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess(expectedUserData)); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('Init requires clientId', (WidgetTester tester) async { - expect( - plugin.initWithParams(const SignInInitParameters(hostedDomain: '')), - throwsAssertionError); - }); - - testWidgets("Init doesn't accept serverClientId", - (WidgetTester tester) async { - expect( - plugin.initWithParams(const SignInInitParameters( - clientId: '', - serverClientId: '', - )), - throwsAssertionError); - }); - - testWidgets("Init doesn't accept spaces in scopes", - (WidgetTester tester) async { - expect( - plugin.initWithParams(const SignInInitParameters( - hostedDomain: '', - clientId: '', - scopes: ['scope with spaces'], - )), - throwsAssertionError); - }); - - // See: https://github.com/flutter/flutter/issues/88084 - testWidgets('Init passes plugin_name parameter with the expected value', - (WidgetTester tester) async { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - - final Object? initParameters = - js_util.getProperty(html.window, 'gapi2.init.parameters'); - expect(initParameters, isNotNull); - - final Object? pluginNameParameter = - js_util.getProperty(initParameters!, 'plugin_name'); - expect(pluginNameParameter, isA()); - expect(pluginNameParameter, 'dart-google_sign_in_web'); - }); - - group('Successful .initWithParams, then', () { - setUp(() async { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - await plugin.initialized; - }); - - testWidgets('signInSilently', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = - (await plugin.signInSilently())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('signIn', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = (await plugin.signIn())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('getTokens', (WidgetTester tester) async { - final GoogleSignInTokenData actualToken = - await plugin.getTokens(email: expectedUserData.email); - - expect(actualToken, expectedTokenData); - }); - - testWidgets('requestScopes', (WidgetTester tester) async { - final bool scopeGranted = - await plugin.requestScopes(['newScope']); - - expect(scopeGranted, isTrue); - }); - }); - }); - - group('auth2 Init successful, but exception on signIn() method', () { - setUp(() async { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2SignInError()); - plugin = GoogleSignInPlugin(); - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - await plugin.initialized; - }); - - testWidgets('User aborts sign in flow, throws PlatformException', - (WidgetTester tester) async { - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('User aborts sign in flow, error code is forwarded from JS', - (WidgetTester tester) async { - try { - await plugin.signIn(); - fail('plugin.signIn() should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'popup_closed_by_user'); - } - }); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_legacy_init_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_legacy_init_test.dart deleted file mode 100644 index 7bfef53f7a23..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_legacy_init_test.dart +++ /dev/null @@ -1,51 +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. - -// This file is a copy of `gapi_load_test.dart`, before it was migrated to the -// new `initWithParams` method, and is kept to ensure test coverage of the -// deprecated `init` method, until it is removed. - -import 'dart:html' as html; - -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'; -import 'package:integration_test/integration_test.dart'; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess( - GoogleSignInUserData(email: 'test@test.com', id: '1234'))); - - testWidgets('Plugin is initialized after GAPI fully loads and init is called', - (WidgetTester tester) async { - expect( - html.querySelector('script[src^="data:"]'), - isNull, - reason: 'Mock script not present before instantiating the plugin', - ); - final GoogleSignInPlugin plugin = GoogleSignInPlugin(); - expect( - html.querySelector('script[src^="data:"]'), - isNotNull, - reason: 'Mock script should be injected', - ); - expect(() { - plugin.initialized; - }, throwsStateError, - reason: - 'The plugin should throw if checking for `initialized` before calling .init'); - await plugin.init(hostedDomain: '', clientId: ''); - await plugin.initialized; - expect( - plugin.initialized, - completes, - reason: 'The plugin should complete the future once initialized.', - ); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_test.dart deleted file mode 100644 index fc753e20d92c..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_test.dart +++ /dev/null @@ -1,50 +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:html' as html; - -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'; -import 'package:integration_test/integration_test.dart'; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess( - GoogleSignInUserData(email: 'test@test.com', id: '1234'))); - - testWidgets('Plugin is initialized after GAPI fully loads and init is called', - (WidgetTester tester) async { - expect( - html.querySelector('script[src^="data:"]'), - isNull, - reason: 'Mock script not present before instantiating the plugin', - ); - final GoogleSignInPlugin plugin = GoogleSignInPlugin(); - expect( - html.querySelector('script[src^="data:"]'), - isNotNull, - reason: 'Mock script should be injected', - ); - expect(() { - plugin.initialized; - }, throwsStateError, - reason: 'The plugin should throw if checking for `initialized` before ' - 'calling .initWithParams'); - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: '', - clientId: '', - )); - await plugin.initialized; - expect( - plugin.initialized, - completes, - reason: 'The plugin should complete the future once initialized.', - ); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/gapi_mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/gapi_mocks.dart deleted file mode 100644 index 43eb9a55d06b..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/gapi_mocks.dart +++ /dev/null @@ -1,13 +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. - -library gapi_mocks; - -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; - -import 'src/gapi.dart'; -import 'src/google_user.dart'; -import 'src/test_iife.dart'; - -part 'src/auth2_init.dart'; diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/auth2_init.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/auth2_init.dart deleted file mode 100644 index 84f4e6ee8ba8..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/auth2_init.dart +++ /dev/null @@ -1,109 +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. - -part of gapi_mocks; - -// JS mock of a gapi.auth2, with a successfully identified user -String auth2InitSuccess(GoogleSignInUserData userData) => testIife(''' -${gapi()} - -var mockUser = ${googleUser(userData)}; - -function GapiAuth2() {} -GapiAuth2.prototype.init = function (initOptions) { - /*Leak the initOptions so we can look at them later.*/ - window['gapi2.init.parameters'] = initOptions; - return { - then: (onSuccess, onError) => { - window.setTimeout(() => { - onSuccess(window.gapi.auth2); - }, 30); - }, - currentUser: { - listen: (cb) => { - window.setTimeout(() => { - cb(mockUser); - }, 30); - } - } - } -}; - -GapiAuth2.prototype.getAuthInstance = function () { - return { - signIn: () => { - return new Promise((resolve, reject) => { - window.setTimeout(() => { - resolve(mockUser); - }, 30); - }); - }, - currentUser: { - get: () => mockUser, - }, - } -}; - -window.gapi.auth2 = new GapiAuth2(); -'''); - -String auth2InitError() => testIife(''' -${gapi()} - -function GapiAuth2() {} -GapiAuth2.prototype.init = function (initOptions) { - return { - then: (onSuccess, onError) => { - window.setTimeout(() => { - onError({ - error: 'idpiframe_initialization_failed', - details: 'This error was raised from a test.', - }); - }, 30); - } - } -}; - -window.gapi.auth2 = new GapiAuth2(); -'''); - -String auth2SignInError([String error = 'popup_closed_by_user']) => testIife(''' -${gapi()} - -var mockUser = null; - -function GapiAuth2() {} -GapiAuth2.prototype.init = function (initOptions) { - return { - then: (onSuccess, onError) => { - window.setTimeout(() => { - onSuccess(window.gapi.auth2); - }, 30); - }, - currentUser: { - listen: (cb) => { - window.setTimeout(() => { - cb(mockUser); - }, 30); - } - } - } -}; - -GapiAuth2.prototype.getAuthInstance = function () { - return { - signIn: () => { - return new Promise((resolve, reject) => { - window.setTimeout(() => { - reject({ - error: '$error' - }); - }, 30); - }); - }, - } -}; - -window.gapi.auth2 = new GapiAuth2(); -'''); diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/gapi.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/gapi.dart deleted file mode 100644 index 0e652c647a38..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/gapi.dart +++ /dev/null @@ -1,12 +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. - -// The JS mock of the global gapi object -String gapi() => ''' -function Gapi() {}; -Gapi.prototype.load = function (script, cb) { - window.setTimeout(cb, 30); -}; -window.gapi = new Gapi(); -'''; diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/google_user.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/google_user.dart deleted file mode 100644 index e5e6eb262502..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/google_user.dart +++ /dev/null @@ -1,30 +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:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; - -// Creates the JS representation of some user data -String googleUser(GoogleSignInUserData data) => ''' -{ - getBasicProfile: () => { - return { - getName: () => '${data.displayName}', - getEmail: () => '${data.email}', - getId: () => '${data.id}', - getImageUrl: () => '${data.photoUrl}', - }; - }, - getAuthResponse: () => { - return { - id_token: '${data.idToken}', - access_token: 'access_${data.idToken}', - } - }, - getGrantedScopes: () => 'some scope', - grant: () => true, - isSignedIn: () => { - return ${data != null ? 'true' : 'false'}; - }, -} -'''; diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/test_iife.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/test_iife.dart deleted file mode 100644 index c5aac367c1de..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/test_iife.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:google_sign_in_web/src/load_gapi.dart' - show kGapiOnloadCallbackFunctionName; - -// Wraps some JS mock code in an IIFE that ends by calling the onLoad dart callback. -String testIife(String mock) => ''' -(function() { - $mock; - window['$kGapiOnloadCallbackFunctionName'](); -})(); -''' - .replaceAll(RegExp(r'\s{2,}'), ''); diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_utils_test.dart deleted file mode 100644 index b9daac44dba8..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_utils_test.dart +++ /dev/null @@ -1,70 +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_test/flutter_test.dart'; -import 'package:google_sign_in_web/src/js_interop/gapiauth2.dart' as gapi; -import 'package:google_sign_in_web/src/utils.dart'; -import 'package:integration_test/integration_test.dart'; - -void main() { - // The non-null use cases are covered by the auth2_test.dart file. - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('gapiUserToPluginUserData', () { - late FakeGoogleUser fakeUser; - - setUp(() { - fakeUser = FakeGoogleUser(); - }); - - testWidgets('null user -> null response', (WidgetTester tester) async { - expect(gapiUserToPluginUserData(null), isNull); - }); - - testWidgets('not signed-in user -> null response', - (WidgetTester tester) async { - expect(gapiUserToPluginUserData(fakeUser), isNull); - }); - - testWidgets('signed-in, but null profile user -> null response', - (WidgetTester tester) async { - fakeUser.setIsSignedIn(true); - expect(gapiUserToPluginUserData(fakeUser), isNull); - }); - - testWidgets('signed-in, null userId in profile user -> null response', - (WidgetTester tester) async { - fakeUser.setIsSignedIn(true); - fakeUser.setBasicProfile(FakeBasicProfile()); - expect(gapiUserToPluginUserData(fakeUser), isNull); - }); - }); -} - -class FakeGoogleUser extends Fake implements gapi.GoogleUser { - bool _isSignedIn = false; - gapi.BasicProfile? _basicProfile; - - @override - bool isSignedIn() => _isSignedIn; - @override - gapi.BasicProfile? getBasicProfile() => _basicProfile; - - // ignore: use_setters_to_change_properties - void setIsSignedIn(bool isSignedIn) { - _isSignedIn = isSignedIn; - } - - // ignore: use_setters_to_change_properties - void setBasicProfile(gapi.BasicProfile basicProfile) { - _basicProfile = basicProfile; - } -} - -class FakeBasicProfile extends Fake implements gapi.BasicProfile { - String? _id; - - @override - String? getId() => _id; -} 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 new file mode 100644 index 000000000000..3dcc192e8aaa --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart @@ -0,0 +1,219 @@ +// 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' 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'; +import 'package:google_sign_in_web/src/gis_client.dart'; +import 'package:google_sign_in_web/src/people.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart' as mockito; + +import 'google_sign_in_web_test.mocks.dart'; +import 'src/dom.dart'; +import 'src/person.dart'; + +// Mock GisSdkClient so we can simulate any response from the JS side. +@GenerateMocks([], customMocks: >[ + MockSpec(onMissingStub: OnMissingStub.returnDefault), +]) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Constructor', () { + const String expectedClientId = '3xp3c73d_c113n7_1d'; + + testWidgets('Loads clientId when set in a meta', (_) async { + final GoogleSignInPlugin plugin = GoogleSignInPlugin( + debugOverrideLoader: true, + ); + + expect(plugin.autoDetectedClientId, isNull); + + // Add it to the test page now, and try again + final DomHtmlMetaElement meta = + document.createElement('meta') as DomHtmlMetaElement + ..name = clientIdMetaName + ..content = expectedClientId; + + document.head.appendChild(meta); + + final GoogleSignInPlugin another = GoogleSignInPlugin( + debugOverrideLoader: true, + ); + + expect(another.autoDetectedClientId, expectedClientId); + + // cleanup + meta.remove(); + }); + }); + + group('initWithParams', () { + late GoogleSignInPlugin plugin; + late MockGisSdkClient mockGis; + + setUp(() { + plugin = GoogleSignInPlugin( + debugOverrideLoader: true, + ); + mockGis = MockGisSdkClient(); + }); + + testWidgets('initializes if all is OK', (_) async { + await plugin.initWithParams( + const SignInInitParameters( + clientId: 'some-non-null-client-id', + scopes: ['ok1', 'ok2', 'ok3'], + ), + overrideClient: mockGis, + ); + + expect(plugin.initialized, completes); + }); + + testWidgets('asserts clientId is not null', (_) async { + expect(() async { + await plugin.initWithParams( + const SignInInitParameters(), + overrideClient: mockGis, + ); + }, throwsAssertionError); + }); + + testWidgets('asserts serverClientId must be null', (_) async { + expect(() async { + await plugin.initWithParams( + const SignInInitParameters( + clientId: 'some-non-null-client-id', + serverClientId: 'unexpected-non-null-client-id', + ), + overrideClient: mockGis, + ); + }, 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'], + ), + overrideClient: mockGis, + ); + }, 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(); + }, throwsStateError); + + expect(() async { + await plugin.disconnect(); + }, throwsStateError); + + expect(() async { + await plugin.isSignedIn(); + }, throwsStateError); + + expect(() async { + await plugin.clearAuthCache(token: ''); + }, throwsStateError); + + expect(() async { + await plugin.requestScopes([]); + }, throwsStateError); + }); + }); + + group('(with mocked GIS)', () { + late GoogleSignInPlugin plugin; + late MockGisSdkClient mockGis; + const SignInInitParameters options = SignInInitParameters( + clientId: 'some-non-null-client-id', + scopes: ['ok1', 'ok2', 'ok3'], + ); + + setUp(() { + plugin = GoogleSignInPlugin( + debugOverrideLoader: true, + ); + mockGis = MockGisSdkClient(); + }); + + group('signInSilently', () { + setUp(() { + plugin.initWithParams(options, overrideClient: mockGis); + }); + + testWidgets('always returns null, regardless of GIS response', (_) async { + final GoogleSignInUserData someUser = extractUserData(person)!; + + mockito + .when(mockGis.signInSilently()) + .thenAnswer((_) => Future.value(someUser)); + + expect(plugin.signInSilently(), completion(isNull)); + + mockito + .when(mockGis.signInSilently()) + .thenAnswer((_) => Future.value()); + + expect(plugin.signInSilently(), completion(isNull)); + }); + }); + + group('signIn', () { + setUp(() { + plugin.initWithParams(options, overrideClient: mockGis); + }); + + testWidgets('returns the signed-in user', (_) async { + final GoogleSignInUserData someUser = extractUserData(person)!; + + mockito + .when(mockGis.signIn()) + .thenAnswer((_) => Future.value(someUser)); + + expect(await plugin.signIn(), someUser); + }); + + testWidgets('returns null if no user is signed in', (_) async { + mockito + .when(mockGis.signIn()) + .thenAnswer((_) => Future.value()); + + expect(await plugin.signIn(), isNull); + }); + + testWidgets('converts inner errors to PlatformException', (_) async { + mockito.when(mockGis.signIn()).thenThrow('popup_closed'); + + try { + await plugin.signIn(); + fail('signIn should have thrown an exception'); + } catch (exception) { + expect(exception, isA()); + expect((exception as PlatformException).code, 'popup_closed'); + } + }); + }); + }); +} 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 new file mode 100644 index 000000000000..b60dac9d4b95 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart @@ -0,0 +1,125 @@ +// Mocks generated by Mockito 5.3.2 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 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' + as _i2; +import 'package:google_sign_in_web/src/gis_client.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// 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 { + @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?>); + @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); + @override + _i4.Future signOut() => (super.noSuchMethod( + Invocation.method( + #signOut, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + _i4.Future disconnect() => (super.noSuchMethod( + Invocation.method( + #disconnect, + [], + ), + 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), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.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( + Invocation.method( + #requestScopes, + [scopes], + ), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.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 new file mode 100644 index 000000000000..e81ccb6e95b5 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart @@ -0,0 +1,132 @@ +// 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 'dart:convert'; + +import 'package:flutter_test/flutter_test.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/people.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_test; +import 'package:integration_test/integration_test.dart'; + +import 'src/jsify_as.dart'; +import 'src/person.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('requestUserData', () { + const String expectedAccessToken = '3xp3c73d_4cc355_70k3n'; + + final TokenResponse fakeToken = jsifyAs({ + 'token_type': 'Bearer', + 'access_token': expectedAccessToken, + }); + + testWidgets('happy case', (_) async { + final Completer accessTokenCompleter = Completer(); + + final http.Client mockClient = http_test.MockClient( + (http.Request request) async { + accessTokenCompleter.complete(request.headers['Authorization']); + + return http.Response( + jsonEncode(person), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); + + final GoogleSignInUserData? user = await requestUserData( + fakeToken, + overrideClient: mockClient, + ); + + expect(user, isNotNull); + expect(user!.email, expectedPersonEmail); + expect(user.id, expectedPersonId); + expect(user.displayName, expectedPersonName); + expect(user.photoUrl, expectedPersonPhoto); + expect(user.idToken, isNull); + expect( + accessTokenCompleter.future, + completion('Bearer $expectedAccessToken'), + ); + }); + + testWidgets('Unauthorized request - throws exception', (_) async { + final http.Client mockClient = http_test.MockClient( + (http.Request request) async { + return http.Response( + 'Unauthorized', + 403, + ); + }, + ); + + expect(() async { + await requestUserData( + fakeToken, + overrideClient: mockClient, + ); + }, throwsA(isA())); + }); + }); + + group('extractUserData', () { + testWidgets('happy case', (_) async { + final GoogleSignInUserData? user = extractUserData(person); + + expect(user, isNotNull); + expect(user!.email, expectedPersonEmail); + expect(user.id, expectedPersonId); + expect(user.displayName, expectedPersonName); + expect(user.photoUrl, expectedPersonPhoto); + expect(user.idToken, isNull); + }); + + testWidgets('no name/photo - keeps going', (_) async { + final Map personWithoutSomeData = + mapWithoutKeys(person, { + 'names', + 'photos', + }); + + final GoogleSignInUserData? user = extractUserData(personWithoutSomeData); + + expect(user, isNotNull); + expect(user!.email, expectedPersonEmail); + expect(user.id, expectedPersonId); + expect(user.displayName, isNull); + expect(user.photoUrl, isNull); + expect(user.idToken, isNull); + }); + + testWidgets('no userId - throws assertion error', (_) async { + final Map personWithoutId = + mapWithoutKeys(person, { + 'resourceName', + }); + + expect(() { + extractUserData(personWithoutId); + }, throwsAssertionError); + }); + + testWidgets('no email - throws assertion error', (_) async { + final Map personWithoutEmail = + mapWithoutKeys(person, { + 'emailAddresses', + }); + + expect(() { + extractUserData(personWithoutEmail); + }, throwsAssertionError); + }); + }); +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart new file mode 100644 index 000000000000..f7d3152a7e64 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart @@ -0,0 +1,59 @@ +// 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. + +/* +// DOM shim. This file contains everything we need from the DOM API written as +// @staticInterop, so we don't need dart:html +// https://developer.mozilla.org/en-US/docs/Web/API/ +// +// (To be replaced by `package:web`) +*/ + +import 'package:js/js.dart'; + +/// Document interface +@JS() +@staticInterop +abstract class DomHtmlDocument {} + +/// Some methods of document +extension DomHtmlDocumentExtension on DomHtmlDocument { + /// document.head + external DomHtmlElement get head; + + /// document.createElement + external DomHtmlElement createElement(String tagName); +} + +/// An instance of an HTMLElement +@JS() +@staticInterop +abstract class DomHtmlElement {} + +/// (Some) methods of HtmlElement +extension DomHtmlElementExtension on DomHtmlElement { + /// Node.appendChild + external DomHtmlElement appendChild(DomHtmlElement child); + + /// Element.remove + external void remove(); +} + +/// An instance of an HTMLMetaElement +@JS() +@staticInterop +abstract class DomHtmlMetaElement extends DomHtmlElement {} + +/// Some methods exclusive of Script elements +extension DomHtmlMetaElementExtension on DomHtmlMetaElement { + external set name(String name); + external set content(String content); +} + +// Getters + +/// window.document +@JS() +@staticInterop +external DomHtmlDocument get document; diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart new file mode 100644 index 000000000000..82547b284fe0 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart @@ -0,0 +1,10 @@ +// 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:js/js_util.dart' as js_util; + +/// Converts a [data] object into a JS Object of type `T`. +T jsifyAs(Map data) { + return js_util.jsify(data) as T; +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart new file mode 100644 index 000000000000..72841c5165ee --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart @@ -0,0 +1,46 @@ +// 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:google_identity_services_web/id.dart'; + +import 'jsify_as.dart'; + +/// A CredentialResponse with null `credential`. +final CredentialResponse nullCredential = + jsifyAs({ + 'credential': null, +}); + +/// A CredentialResponse wrapping a known good JWT Token as its `credential`. +final CredentialResponse goodCredential = + jsifyAs({ + 'credential': goodJwtToken, +}); + +/// A JWT token with predefined values. +/// +/// 'email': 'adultman@example.com', +/// 'sub': '123456', +/// 'name': 'Vincent Adultman', +/// 'picture': 'https://thispersondoesnotexist.com/image?x=.jpg', +/// +/// Signed with HS256 and the private key: 'symmetric-encryption-is-weak' +const String goodJwtToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$goodPayload.lqzULA_U3YzEl_-fL7YLU-kFXmdD2ttJLTv-UslaNQ4'; + +/// The payload of a JWT token that contains predefined values. +/// +/// 'email': 'adultman@example.com', +/// 'sub': '123456', +/// 'name': 'Vincent Adultman', +/// 'picture': 'https://thispersondoesnotexist.com/image?x=.jpg', +const String goodPayload = + 'eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwibmFtZSI6IlZpbmNlbnQgQWR1bHRtYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly90aGlzcGVyc29uZG9lc25vdGV4aXN0LmNvbS9pbWFnZT94PS5qcGcifQ'; + +// More encrypted JWT Tokens may be created on https://jwt.io. +// +// First, decode the `goodJwtToken` above, modify to your heart's +// content, and add a new credential here. +// +// (New tokens can also be created with `package:jose` and `dart:convert`.) diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart new file mode 100644 index 000000000000..2525596eabe9 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart @@ -0,0 +1,66 @@ +// 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. + +const String expectedPersonId = '1234567890'; +const String expectedPersonName = 'Vincent Adultman'; +const String expectedPersonEmail = 'adultman@example.com'; +const String expectedPersonPhoto = + 'https://thispersondoesnotexist.com/image?x=.jpg'; + +/// A subset of https://developers.google.com/people/api/rest/v1/people#Person. +final Map person = { + 'resourceName': 'people/$expectedPersonId', + 'emailAddresses': [ + { + 'metadata': { + 'primary': false, + }, + 'value': 'bad@example.com', + }, + { + 'metadata': {}, + 'value': 'nope@example.com', + }, + { + 'metadata': { + 'primary': true, + }, + 'value': expectedPersonEmail, + }, + ], + 'names': [ + { + 'metadata': { + 'primary': true, + }, + 'displayName': expectedPersonName, + }, + { + 'metadata': { + 'primary': false, + }, + 'displayName': 'Fakey McFakeface', + }, + ], + 'photos': [ + { + 'metadata': { + 'primary': true, + }, + 'url': expectedPersonPhoto, + }, + ], +}; + +/// Returns a copy of [map] without the [keysToRemove]. +T mapWithoutKeys>( + T map, + Set keysToRemove, +) { + return Map.fromEntries( + map.entries.where((MapEntry entry) { + return !keysToRemove.contains(entry.key); + }), + ) as T; +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/test_utils.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/test_utils.dart deleted file mode 100644 index 56aa61df136e..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/test_utils.dart +++ /dev/null @@ -1,10 +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:convert'; - -String toBase64Url(String contents) { - // Open the file - return 'data:text/javascript;base64,${base64.encode(utf8.encode(contents))}'; -} 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 new file mode 100644 index 000000000000..82701e587be1 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart @@ -0,0 +1,173 @@ +// 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: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'; + +import 'src/jsify_as.dart'; +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', () { + testWidgets('happy case', (_) async { + final GoogleSignInUserData data = gisResponsesToUserData(goodCredential)!; + + 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); + }); + + testWidgets('null response -> null', (_) async { + expect(gisResponsesToUserData(null), isNull); + }); + + testWidgets('null response.credential -> null', (_) async { + expect(gisResponsesToUserData(nullCredential), isNull); + }); + + testWidgets('invalid payload -> null', (_) async { + final CredentialResponse response = + jsifyAs({ + 'credential': 'some-bogus.thing-that-is-not.valid-jwt', + }); + expect(gisResponsesToUserData(response), isNull); + }); + }); + + group('getJwtTokenPayload', () { + testWidgets('happy case -> data', (_) async { + final Map? data = getJwtTokenPayload(goodJwtToken); + + expect(data, isNotNull); + expect(data, containsPair('name', 'Vincent Adultman')); + expect(data, containsPair('email', 'adultman@example.com')); + expect(data, containsPair('sub', '123456')); + expect( + data, + containsPair( + 'picture', + 'https://thispersondoesnotexist.com/image?x=.jpg', + )); + }); + + testWidgets('null Token -> null', (_) async { + final Map? data = getJwtTokenPayload(null); + + expect(data, isNull); + }); + + testWidgets('Token not matching the format -> null', (_) async { + final Map? data = getJwtTokenPayload('1234.4321'); + + expect(data, isNull); + }); + + testWidgets('Bad token that matches the format -> null', (_) async { + final Map? data = getJwtTokenPayload('1234.abcd.4321'); + + expect(data, isNull); + }); + }); + + group('decodeJwtPayload', () { + testWidgets('Good payload -> data', (_) async { + final Map? data = decodeJwtPayload(goodPayload); + + expect(data, isNotNull); + expect(data, containsPair('name', 'Vincent Adultman')); + expect(data, containsPair('email', 'adultman@example.com')); + expect(data, containsPair('sub', '123456')); + expect( + data, + containsPair( + 'picture', + 'https://thispersondoesnotexist.com/image?x=.jpg', + )); + }); + + testWidgets('Proper JSON payload -> data', (_) async { + final String payload = base64.encode(utf8.encode('{"properJson": true}')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNotNull); + expect(data, containsPair('properJson', true)); + }); + + testWidgets('Not-normalized base-64 payload -> data', (_) async { + // This is the payload generated by the "Proper JSON payload" test, but + // we remove the leading "=" symbols so it's length is not a multiple of 4 + // anymore! + final String payload = 'eyJwcm9wZXJKc29uIjogdHJ1ZX0='.replaceAll('=', ''); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNotNull); + expect(data, containsPair('properJson', true)); + }); + + testWidgets('Invalid JSON payload -> null', (_) async { + final String payload = base64.encode(utf8.encode('{properJson: false}')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + + testWidgets('Non JSON payload -> null', (_) async { + final String payload = base64.encode(utf8.encode('not-json')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + + testWidgets('Non base-64 payload -> null', (_) async { + const String payload = 'not-base-64-at-all'; + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + }); +} 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 848517534ed2..c73953374696 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 @@ -12,12 +12,15 @@ dependencies: path: ../ dev_dependencies: + build_runner: ^2.1.1 flutter_driver: sdk: flutter flutter_test: sdk: flutter + google_identity_services_web: ^0.2.0 google_sign_in_platform_interface: ^2.2.0 http: ^0.13.0 integration_test: sdk: flutter js: ^0.6.3 + mockito: ^5.3.2 diff --git a/packages/google_sign_in/google_sign_in_web/example/regen_mocks.sh b/packages/google_sign_in/google_sign_in_web/example/regen_mocks.sh new file mode 100755 index 000000000000..78bcdc0f9e28 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/regen_mocks.sh @@ -0,0 +1,10 @@ +#!/usr/bin/bash +# 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. + +flutter pub get + +echo "(Re)generating mocks." + +flutter pub run build_runner build --delete-conflicting-outputs diff --git a/packages/google_sign_in/google_sign_in_web/example/run_test.sh b/packages/google_sign_in/google_sign_in_web/example/run_test.sh index 28877dce8d6e..fcac5f600acb 100755 --- a/packages/google_sign_in/google_sign_in_web/example/run_test.sh +++ b/packages/google_sign_in/google_sign_in_web/example/run_test.sh @@ -6,9 +6,11 @@ if pgrep -lf chromedriver > /dev/null; then echo "chromedriver is running." + ./regen_mocks.sh + if [ $# -eq 0 ]; then echo "No target specified, running all tests..." - find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' + find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' else echo "Running test target: $1..." set -x @@ -17,7 +19,6 @@ if pgrep -lf chromedriver > /dev/null; then else echo "chromedriver is not running." + echo "Please, check the README.md for instructions on how to use run_test.sh" fi - - 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 5d75c0da0c4f..827b17ca5b44 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 @@ -5,23 +5,22 @@ import 'dart:async'; import 'dart:html' as html; -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting, kDebugMode; +import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:google_identity_services_web/loader.dart' as loader; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:js/js.dart'; -import 'src/js_interop/gapiauth2.dart' as auth2; -import 'src/load_gapi.dart' as gapi; -import 'src/utils.dart' show gapiUserToPluginUserData; +import 'src/gis_client.dart'; -const String _kClientIdMetaSelector = 'meta[name=google-signin-client_id]'; -const String _kClientIdAttributeName = 'content'; +/// The `name` of the meta-tag to define a ClientID in HTML. +const String clientIdMetaName = 'google-signin-client_id'; -/// This is only exposed for testing. It shouldn't be accessed by users of the -/// plugin as it could break at any point. -@visibleForTesting -String gapiUrl = 'https://apis.google.com/js/platform.js'; +/// The selector used to find the meta-tag that defines a ClientID in HTML. +const String clientIdMetaSelector = 'meta[name=$clientIdMetaName]'; + +/// The attribute name that stores the Client ID in the meta-tag that defines a Client ID in HTML. +const String clientIdAttributeName = 'content'; /// Implementation of the google_sign_in plugin for Web. class GoogleSignInPlugin extends GoogleSignInPlatform { @@ -29,18 +28,24 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { /// background. /// /// The plugin is completely initialized when [initialized] completed. - GoogleSignInPlugin() { - _autoDetectedClientId = html - .querySelector(_kClientIdMetaSelector) - ?.getAttribute(_kClientIdAttributeName); - - _isGapiInitialized = gapi.inject(gapiUrl).then((_) => gapi.init()); + GoogleSignInPlugin({@visibleForTesting bool debugOverrideLoader = false}) { + autoDetectedClientId = html + .querySelector(clientIdMetaSelector) + ?.getAttribute(clientIdAttributeName); + + if (debugOverrideLoader) { + _jsSdkLoadedFuture = Future.value(true); + } else { + _jsSdkLoadedFuture = loader.loadWebSdk(); + } } - late Future _isGapiInitialized; - late Future _isAuthInitialized; + late Future _jsSdkLoadedFuture; bool _isInitCalled = false; + // The instance of [GisSdkClient] backing the plugin. + late GisSdkClient _gisClient; + // This method throws if init or initWithParams hasn't been called at some // point in the past. It is used by the [initialized] getter to ensure that // users can't await on a Future that will never resolve. @@ -53,14 +58,16 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } } - /// A future that resolves when both GAPI and Auth2 have been correctly initialized. + /// A future that resolves when the SDK has been correctly loaded. @visibleForTesting Future get initialized { _assertIsInitCalled(); - return Future.wait(>[_isGapiInitialized, _isAuthInitialized]); + return _jsSdkLoadedFuture; } - String? _autoDetectedClientId; + /// Stores the client ID if it was set in a meta-tag of the page. + @visibleForTesting + late String? autoDetectedClientId; /// Factory method that initializes the plugin with [GoogleSignInPlatform]. static void registerWith(Registrar registrar) { @@ -83,8 +90,11 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } @override - Future initWithParams(SignInInitParameters params) async { - final String? appClientId = params.clientId ?? _autoDetectedClientId; + Future initWithParams( + SignInInitParameters params, { + @visibleForTesting GisSdkClient? overrideClient, + }) async { + final String? appClientId = params.clientId ?? autoDetectedClientId; assert( appClientId != null, 'ClientID not set. Either set it on a ' @@ -100,141 +110,95 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { 'Check https://developers.google.com/identity/protocols/googlescopes ' 'for a list of valid OAuth 2.0 scopes.'); - await _isGapiInitialized; + await _jsSdkLoadedFuture; - final auth2.GoogleAuth auth = auth2.init(auth2.ClientConfig( - hosted_domain: params.hostedDomain, - // The js lib wants a space-separated list of values - scope: params.scopes.join(' '), - client_id: appClientId!, - plugin_name: 'dart-google_sign_in_web', - )); + _gisClient = overrideClient ?? + GisSdkClient( + clientId: appClientId!, + hostedDomain: params.hostedDomain, + initialScopes: List.from(params.scopes), + loggingEnabled: kDebugMode, + ); - final Completer isAuthInitialized = Completer(); - _isAuthInitialized = isAuthInitialized.future; _isInitCalled = true; - - auth.then(allowInterop((auth2.GoogleAuth initializedAuth) { - // onSuccess - - // TODO(ditman): https://github.com/flutter/flutter/issues/48528 - // This plugin doesn't notify the app of external changes to the - // state of the authentication, i.e: if you logout elsewhere... - - isAuthInitialized.complete(); - }), allowInterop((auth2.GoogleAuthInitFailureError reason) { - // onError - isAuthInitialized.completeError(PlatformException( - code: reason.error, - message: reason.details, - details: - 'https://developers.google.com/identity/sign-in/web/reference#error_codes', - )); - })); - - return _isAuthInitialized; } @override Future signInSilently() async { await initialized; - return gapiUserToPluginUserData( - auth2.getAuthInstance()?.currentUser?.get()); + // Since the new GIS SDK does *not* perform authorization at the same time as + // authentication (and every one of our users expects that), we need to tell + // the plugin that this failed regardless of the actual result. + // + // However, if this succeeds, we'll save a People API request later. + return _gisClient.signInSilently().then((_) => null); } @override Future signIn() async { 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 gapiUserToPluginUserData(await auth2.getAuthInstance()?.signIn()); - } on auth2.GoogleAuthSignInError catch (reason) { + return _gisClient.signIn(); + } catch (reason) { throw PlatformException( - code: reason.error, - message: 'Exception raised from GoogleAuth.signIn()', + code: reason.toString(), + message: 'Exception raised from signIn', details: - 'https://developers.google.com/identity/sign-in/web/reference#error_codes_2', + 'https://developers.google.com/identity/oauth2/web/guides/error', ); } } @override - Future getTokens( - {required String email, bool? shouldRecoverAuth}) async { + Future getTokens({ + required String email, + bool? shouldRecoverAuth, + }) async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - final auth2.AuthResponse? response = currentUser?.getAuthResponse(); - - return GoogleSignInTokenData( - idToken: response?.id_token, accessToken: response?.access_token); + return _gisClient.getTokens(); } @override Future signOut() async { await initialized; - return auth2.getAuthInstance()?.signOut(); + _gisClient.signOut(); } @override Future disconnect() async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - - if (currentUser == null) { - return; - } - - return currentUser.disconnect(); + _gisClient.disconnect(); } @override Future isSignedIn() async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - - if (currentUser == null) { - return false; - } - - return currentUser.isSignedIn(); + return _gisClient.isSignedIn(); } @override Future clearAuthCache({required String token}) async { await initialized; - return auth2.getAuthInstance()?.disconnect(); + _gisClient.clearAuthCache(); } @override Future requestScopes(List scopes) async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - - if (currentUser == null) { - return false; - } - - final String grantedScopes = currentUser.getGrantedScopes() ?? ''; - final Iterable missingScopes = - scopes.where((String scope) => !grantedScopes.contains(scope)); - - if (missingScopes.isEmpty) { - return true; - } - - final Object? response = await currentUser - .grant(auth2.SigninOptions(scope: missingScopes.join(' '))); - - return response != null; + return _gisClient.requestScopes(scopes); } } 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 new file mode 100644 index 000000000000..3815322e6900 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart @@ -0,0 +1,310 @@ +// 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'; + +// TODO(dit): Split `id` and `oauth2` "services" for mocking. https://github.com/flutter/flutter/issues/120657 +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'; +// ignore: unnecessary_import +import 'package:js/js.dart'; +import 'package:js/js_util.dart'; + +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. +/// +/// (Overridable for testing) +class GisSdkClient { + /// Create a GisSdkClient object. + GisSdkClient({ + required List initialScopes, + required String clientId, + bool loggingEnabled = false, + String? hostedDomain, + }) : _initialScopes = initialScopes { + if (loggingEnabled) { + id.setLogLevel('debug'); + } + // Configure the Stream objects that are going to be used by the clients. + _configureStreams(); + + // Initialize the SDK clients we need. + _initializeIdClient( + clientId, + onResponse: _onCredentialResponse, + ); + + _tokenClient = _initializeTokenClient( + clientId, + hostedDomain: hostedDomain, + onResponse: _onTokenResponse, + onError: _onTokenError, + ); + } + + // Configure the credential (authentication) and token (authorization) response streams. + void _configureStreams() { + _tokenResponses = StreamController.broadcast(); + _credentialResponses = StreamController.broadcast(); + _tokenResponses.stream.listen((TokenResponse response) { + _lastTokenResponse = response; + }, onError: (Object error) { + _lastTokenResponse = null; + }); + _credentialResponses.stream.listen((CredentialResponse response) { + _lastCredentialResponse = response; + }, onError: (Object error) { + _lastCredentialResponse = null; + }); + } + + // Initializes the `id` SDK for the silent-sign in (authentication) client. + void _initializeIdClient( + String clientId, { + required CallbackFn onResponse, + }) { + // Initialize `id` for the silent-sign in code. + final IdConfiguration idConfig = IdConfiguration( + client_id: clientId, + callback: allowInterop(onResponse), + cancel_on_tap_outside: false, + auto_select: true, // Attempt to sign-in silently. + ); + id.initialize(idConfig); + } + + // Handle a "normal" credential (authentication) response. + // + // (Normal doesn't mean successful, this might contain `error` information.) + void _onCredentialResponse(CredentialResponse response) { + if (response.error != null) { + _credentialResponses.addError(response.error!); + } else { + _credentialResponses.add(response); + } + } + + // Creates a `oauth2.TokenClient` used for authorization (scope) requests. + TokenClient _initializeTokenClient( + String clientId, { + String? hostedDomain, + required TokenClientCallbackFn onResponse, + required ErrorCallbackFn onError, + }) { + // Create a Token Client for authorization calls. + final TokenClientConfig tokenConfig = TokenClientConfig( + client_id: clientId, + hosted_domain: hostedDomain, + callback: allowInterop(_onTokenResponse), + error_callback: allowInterop(_onTokenError), + // `scope` will be modified by the `signIn` method, in case we need to + // backfill user Profile info. + scope: ' ', + ); + 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(Object? error) { + // This is handled in a funky (js_interop) way because of: + // https://github.com/dart-lang/sdk/issues/50899 + _tokenResponses.addError(getProperty(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(); + + // Ask the SDK to render the OneClick sign-in. + // + // And also handle its "moments". + id.prompt(allowInterop((PromptMomentNotification moment) { + _onPromptMoment(moment, userDataCompleter); + })); + + return userDataCompleter.future; + } + + // Handles "prompt moments" of the OneClick card UI. + // + // 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() && + 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)); + }); + } + + // 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); + } + } + + /// 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]. + Future signIn() 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); + // This toggles a popup, so `signIn` *must* be called with + // user activation. + _tokenClient.requestAccessToken(OverridableTokenClientConfig( + prompt: knownUser == null ? 'select_account' : '', + 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, + ].join(' '), + )); + + await _tokenResponses.stream.first; + + return _computeUserDataForLastToken(); + } + + // This function returns the currently signed-in [GoogleSignInUserData]. + // + // It'll do a request to the People API (if needed). + 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!); + } + // 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, + ); + } + + /// Revokes the current authentication. + Future signOut() async { + clearAuthCache(); + id.disableAutoSelect(); + } + + /// Revokes the current authorization and authentication. + Future disconnect() async { + if (_lastTokenResponse != null) { + oauth2.revoke(_lastTokenResponse!.access_token); + } + signOut(); + } + + /// Returns true if the client has recognized this user before. + Future isSignedIn() async { + return _lastCredentialResponse != null || _requestedUserData != null; + } + + /// Clears all the cached results from authentication and authorization. + Future clearAuthCache() async { + _lastCredentialResponse = null; + _lastTokenResponse = null; + _requestedUserData = null; + } + + /// Requests the list of [scopes] passed in to the client. + /// + /// Keeps the previously granted scopes. + Future requestScopes(List scopes) async { + _tokenClient.requestAccessToken(OverridableTokenClientConfig( + scope: scopes.join(' '), + include_granted_scopes: true, + )); + + await _tokenResponses.stream.first; + + return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); + } + + // 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 Google Identity Services client for oauth requests. + late TokenClient _tokenClient; + + // Streams of credential and token responses. + late StreamController _credentialResponses; + late StreamController _tokenResponses; + + // The last-seen credential and token responses + CredentialResponse? _lastCredentialResponse; + TokenResponse? _lastTokenResponse; + + // 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) + GoogleSignInUserData? _requestedUserData; +} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapi.dart b/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapi.dart deleted file mode 100644 index 3be4b2d77b66..000000000000 --- a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapi.dart +++ /dev/null @@ -1,56 +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. - -/// Type definitions for Google API Client -/// Project: https://github.com/google/google-api-javascript-client -/// Definitions by: Frank M , grant -/// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped -/// TypeScript Version: 2.3 - -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/gapi - -// ignore_for_file: public_member_api_docs, -// * public_member_api_docs originally undocumented because the file was -// autogenerated. - -@JS() -library gapi; - -import 'package:js/js.dart'; - -// Module gapi -typedef LoadCallback = void Function( - [dynamic args1, - dynamic args2, - dynamic args3, - dynamic args4, - dynamic args5]); - -@anonymous -@JS() -abstract class LoadConfig { - external factory LoadConfig( - {LoadCallback callback, - Function? onerror, - num? timeout, - Function? ontimeout}); - external LoadCallback get callback; - external set callback(LoadCallback v); - external Function? get onerror; - external set onerror(Function? v); - external num? get timeout; - external set timeout(num? v); - external Function? get ontimeout; - external set ontimeout(Function? v); -} - -/*type CallbackOrConfig = LoadConfig | LoadCallback;*/ -/// Pragmatically initialize gapi class member. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiloadlibraries-callbackorconfig -@JS('gapi.load') -external void load( - String apiName, dynamic /*LoadConfig|LoadCallback*/ callback); -// End module gapi - -// Manually removed gapi.auth and gapi.client, unused by this plugin. diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapiauth2.dart b/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapiauth2.dart deleted file mode 100644 index 35a2d08e74b6..000000000000 --- a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapiauth2.dart +++ /dev/null @@ -1,497 +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. - -/// Type definitions for non-npm package Google Sign-In API 0.0 -/// Project: https://developers.google.com/identity/sign-in/web/ -/// Definitions by: Derek Lawless -/// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped -/// TypeScript Version: 2.3 - -/// - -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/gapi.auth2 - -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, -// * public_member_api_docs originally undocumented because the file was -// autogenerated. -// * non_constant_identifier_names required to be able to use the same parameter -// names as the underlying library. - -@JS() -library gapiauth2; - -import 'package:js/js.dart'; -import 'package:js/js_util.dart' show promiseToFuture; - -@anonymous -@JS() -class GoogleAuthInitFailureError { - external String get error; - external set error(String? value); - - external String get details; - external set details(String? value); -} - -@anonymous -@JS() -class GoogleAuthSignInError { - external String get error; - external set error(String value); -} - -@anonymous -@JS() -class OfflineAccessResponse { - external String? get code; - external set code(String? value); -} - -// Module gapi.auth2 -/// GoogleAuth is a singleton class that provides methods to allow the user to sign in with a Google account, -/// get the user's current sign-in status, get specific data from the user's Google profile, -/// request additional scopes, and sign out from the current account. -@JS('gapi.auth2.GoogleAuth') -class GoogleAuth { - external IsSignedIn get isSignedIn; - external set isSignedIn(IsSignedIn v); - external CurrentUser? get currentUser; - external set currentUser(CurrentUser? v); - - /// Calls the onInit function when the GoogleAuth object is fully initialized, or calls the onFailure function if - /// initialization fails. - external dynamic then(dynamic Function(GoogleAuth googleAuth) onInit, - [dynamic Function(GoogleAuthInitFailureError reason) onFailure]); - - /// Signs out all accounts from the application. - external dynamic signOut(); - - /// Revokes all of the scopes that the user granted. - external dynamic disconnect(); - - /// Attaches the sign-in flow to the specified container's click handler. - external dynamic attachClickHandler( - dynamic container, - SigninOptions options, - dynamic Function(GoogleUser googleUser) onsuccess, - dynamic Function(String reason) onfailure); -} - -@anonymous -@JS() -abstract class _GoogleAuth { - external Promise signIn( - [dynamic /*SigninOptions|SigninOptionsBuilder*/ options]); - external Promise grantOfflineAccess( - [OfflineAccessOptions? options]); -} - -extension GoogleAuthExtensions on GoogleAuth { - Future signIn( - [dynamic /*SigninOptions|SigninOptionsBuilder*/ options]) { - final _GoogleAuth tt = this as _GoogleAuth; - return promiseToFuture(tt.signIn(options)); - } - - Future grantOfflineAccess( - [OfflineAccessOptions? options]) { - final _GoogleAuth tt = this as _GoogleAuth; - return promiseToFuture(tt.grantOfflineAccess(options)); - } -} - -@anonymous -@JS() -abstract class IsSignedIn { - /// Returns whether the current user is currently signed in. - external bool get(); - - /// Listen for changes in the current user's sign-in state. - external void listen(dynamic Function(bool signedIn) listener); -} - -@anonymous -@JS() -abstract class CurrentUser { - /// Returns a GoogleUser object that represents the current user. Note that in a newly-initialized - /// GoogleAuth instance, the current user has not been set. Use the currentUser.listen() method or the - /// GoogleAuth.then() to get an initialized GoogleAuth instance. - external GoogleUser get(); - - /// Listen for changes in currentUser. - external void listen(dynamic Function(GoogleUser user) listener); -} - -@anonymous -@JS() -abstract class SigninOptions { - external factory SigninOptions( - {String app_package_name, - bool fetch_basic_profile, - String prompt, - String scope, - String /*'popup'|'redirect'*/ ux_mode, - String redirect_uri, - String login_hint}); - - /// The package name of the Android app to install over the air. - /// See Android app installs from your web site: - /// https://developers.google.com/identity/sign-in/web/android-app-installs - external String? get app_package_name; - external set app_package_name(String? v); - - /// Fetch users' basic profile information when they sign in. - /// Adds 'profile', 'email' and 'openid' to the requested scopes. - /// True if unspecified. - external bool? get fetch_basic_profile; - external set fetch_basic_profile(bool? v); - - /// Specifies whether to prompt the user for re-authentication. - /// See OpenID Connect Request Parameters: - /// https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters - external String? get prompt; - external set prompt(String? v); - - /// The scopes to request, as a space-delimited string. - /// Optional if fetch_basic_profile is not set to false. - external String? get scope; - external set scope(String? v); - - /// The UX mode to use for the sign-in flow. - /// By default, it will open the consent flow in a popup. - external String? /*'popup'|'redirect'*/ get ux_mode; - external set ux_mode(String? /*'popup'|'redirect'*/ v); - - /// If using ux_mode='redirect', this parameter allows you to override the default redirect_uri that will be used at the end of the consent flow. - /// The default redirect_uri is the current URL stripped of query parameters and hash fragment. - external String? get redirect_uri; - external set redirect_uri(String? v); - - // When your app knows which user it is trying to authenticate, it can provide this parameter as a hint to the authentication server. - // Passing this hint suppresses the account chooser and either pre-fill the email box on the sign-in form, or select the proper session (if the user is using multiple sign-in), - // which can help you avoid problems that occur if your app logs in the wrong user account. The value can be either an email address or the sub string, - // which is equivalent to the user's Google ID. - // https://developers.google.com/identity/protocols/OpenIDConnect?hl=en#authenticationuriparameters - external String? get login_hint; - external set login_hint(String? v); -} - -/// Definitions by: John -/// Interface that represents the different configuration parameters for the GoogleAuth.grantOfflineAccess(options) method. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2offlineaccessoptions -@anonymous -@JS() -abstract class OfflineAccessOptions { - external factory OfflineAccessOptions( - {String scope, - String /*'select_account'|'consent'*/ prompt, - String app_package_name}); - external String? get scope; - external set scope(String? v); - external String? /*'select_account'|'consent'*/ get prompt; - external set prompt(String? /*'select_account'|'consent'*/ v); - external String? get app_package_name; - external set app_package_name(String? v); -} - -/// Interface that represents the different configuration parameters for the gapi.auth2.init method. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2clientconfig -@anonymous -@JS() -abstract class ClientConfig { - external factory ClientConfig({ - String client_id, - String cookie_policy, - String scope, - bool fetch_basic_profile, - String? hosted_domain, - String openid_realm, - String /*'popup'|'redirect'*/ ux_mode, - String redirect_uri, - String plugin_name, - }); - - /// The app's client ID, found and created in the Google Developers Console. - external String? get client_id; - external set client_id(String? v); - - /// The domains for which to create sign-in cookies. Either a URI, single_host_origin, or none. - /// Defaults to single_host_origin if unspecified. - external String? get cookie_policy; - external set cookie_policy(String? v); - - /// The scopes to request, as a space-delimited string. Optional if fetch_basic_profile is not set to false. - external String? get scope; - external set scope(String? v); - - /// Fetch users' basic profile information when they sign in. Adds 'profile' and 'email' to the requested scopes. True if unspecified. - external bool? get fetch_basic_profile; - external set fetch_basic_profile(bool? v); - - /// The Google Apps domain to which users must belong to sign in. This is susceptible to modification by clients, - /// so be sure to verify the hosted domain property of the returned user. Use GoogleUser.getHostedDomain() on the client, - /// and the hd claim in the ID Token on the server to verify the domain is what you expected. - external String? get hosted_domain; - external set hosted_domain(String? v); - - /// Used only for OpenID 2.0 client migration. Set to the value of the realm that you are currently using for OpenID 2.0, - /// as described in OpenID 2.0 (Migration). - external String? get openid_realm; - external set openid_realm(String? v); - - /// The UX mode to use for the sign-in flow. - /// By default, it will open the consent flow in a popup. - external String? /*'popup'|'redirect'*/ get ux_mode; - external set ux_mode(String? /*'popup'|'redirect'*/ v); - - /// If using ux_mode='redirect', this parameter allows you to override the default redirect_uri that will be used at the end of the consent flow. - /// The default redirect_uri is the current URL stripped of query parameters and hash fragment. - external String? get redirect_uri; - external set redirect_uri(String? v); - - /// Allows newly created Client IDs to use the Google Platform Library from now until the March 30th, 2023 deprecation date. - /// See: https://github.com/flutter/flutter/issues/88084 - external String? get plugin_name; - external set plugin_name(String? v); -} - -@JS('gapi.auth2.SigninOptionsBuilder') -class SigninOptionsBuilder { - external dynamic setAppPackageName(String name); - external dynamic setFetchBasicProfile(bool fetch); - external dynamic setPrompt(String prompt); - external dynamic setScope(String scope); - external dynamic setLoginHint(String hint); -} - -@anonymous -@JS() -abstract class BasicProfile { - external String? getId(); - external String? getName(); - external String? getGivenName(); - external String? getFamilyName(); - external String? getImageUrl(); - external String? getEmail(); -} - -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authresponse -@anonymous -@JS() -abstract class AuthResponse { - external String? get access_token; - external set access_token(String? v); - external String? get id_token; - external set id_token(String? v); - external String? get login_hint; - external set login_hint(String? v); - external String? get scope; - external set scope(String? v); - external num? get expires_in; - external set expires_in(num? v); - external num? get first_issued_at; - external set first_issued_at(num? v); - external num? get expires_at; - external set expires_at(num? v); -} - -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authorizeconfig -@anonymous -@JS() -abstract class AuthorizeConfig { - external factory AuthorizeConfig( - {String client_id, - String scope, - String response_type, - String prompt, - String cookie_policy, - String hosted_domain, - String login_hint, - String app_package_name, - String openid_realm, - bool include_granted_scopes}); - external String get client_id; - external set client_id(String v); - external String get scope; - external set scope(String v); - external String? get response_type; - external set response_type(String? v); - external String? get prompt; - external set prompt(String? v); - external String? get cookie_policy; - external set cookie_policy(String? v); - external String? get hosted_domain; - external set hosted_domain(String? v); - external String? get login_hint; - external set login_hint(String? v); - external String? get app_package_name; - external set app_package_name(String? v); - external String? get openid_realm; - external set openid_realm(String? v); - external bool? get include_granted_scopes; - external set include_granted_scopes(bool? v); -} - -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authorizeresponse -@anonymous -@JS() -abstract class AuthorizeResponse { - external factory AuthorizeResponse( - {String access_token, - String id_token, - String code, - String scope, - num expires_in, - num first_issued_at, - num expires_at, - String error, - String error_subtype}); - external String get access_token; - external set access_token(String v); - external String get id_token; - external set id_token(String v); - external String get code; - external set code(String v); - external String get scope; - external set scope(String v); - external num get expires_in; - external set expires_in(num v); - external num get first_issued_at; - external set first_issued_at(num v); - external num get expires_at; - external set expires_at(num v); - external String get error; - external set error(String v); - external String get error_subtype; - external set error_subtype(String v); -} - -/// A GoogleUser object represents one user account. -@anonymous -@JS() -abstract class GoogleUser { - /// Get the user's unique ID string. - external String? getId(); - - /// Returns true if the user is signed in. - external bool isSignedIn(); - - /// Get the user's Google Apps domain if the user signed in with a Google Apps account. - external String? getHostedDomain(); - - /// Get the scopes that the user granted as a space-delimited string. - external String? getGrantedScopes(); - - /// Get the user's basic profile information. - external BasicProfile? getBasicProfile(); - - /// Get the response object from the user's auth session. - // This returns an empty JS object when the user hasn't attempted to sign in. - external AuthResponse getAuthResponse([bool includeAuthorizationData]); - - /// Returns true if the user granted the specified scopes. - external bool hasGrantedScopes(String scopes); - - // Has the API for grant and grantOfflineAccess changed? - /// Request additional scopes to the user. - /// - /// See GoogleAuth.signIn() for the list of parameters and the error code. - external dynamic grant( - [dynamic /*SigninOptions|SigninOptionsBuilder*/ options]); - - /// Get permission from the user to access the specified scopes offline. - /// When you use GoogleUser.grantOfflineAccess(), the sign-in flow skips the account chooser step. - /// See GoogleUser.grantOfflineAccess(). - external void grantOfflineAccess(String scopes); - - /// Revokes all of the scopes that the user granted. - external void disconnect(); -} - -@anonymous -@JS() -abstract class _GoogleUser { - /// Forces a refresh of the access token, and then returns a Promise for the new AuthResponse. - external Promise reloadAuthResponse(); -} - -extension GoogleUserExtensions on GoogleUser { - Future reloadAuthResponse() { - final _GoogleUser tt = this as _GoogleUser; - return promiseToFuture(tt.reloadAuthResponse()); - } -} - -/// Initializes the GoogleAuth object. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2initparams -@JS('gapi.auth2.init') -external GoogleAuth init(ClientConfig params); - -/// Returns the GoogleAuth object. You must initialize the GoogleAuth object with gapi.auth2.init() before calling this method. -@JS('gapi.auth2.getAuthInstance') -external GoogleAuth? getAuthInstance(); - -/// Performs a one time OAuth 2.0 authorization. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authorizeparams-callback -@JS('gapi.auth2.authorize') -external void authorize( - AuthorizeConfig params, void Function(AuthorizeResponse response) callback); -// End module gapi.auth2 - -// Module gapi.signin2 -@JS('gapi.signin2.render') -external void render( - dynamic id, - dynamic - /*{ - /** - * The auth scope or scopes to authorize. Auth scopes for individual APIs can be found in their documentation. - */ - scope?: string; - - /** - * The width of the button in pixels (default: 120). - */ - width?: number; - - /** - * The height of the button in pixels (default: 36). - */ - height?: number; - - /** - * Display long labels such as "Sign in with Google" rather than "Sign in" (default: false). - */ - longtitle?: boolean; - - /** - * The color theme of the button: either light or dark (default: light). - */ - theme?: string; - - /** - * The callback function to call when a user successfully signs in (default: none). - */ - onsuccess?(user: auth2.GoogleUser): void; - - /** - * The callback function to call when sign-in fails (default: none). - */ - onfailure?(reason: { error: string }): void; - - /** - * The package name of the Android app to install over the air. See - * Android app installs from your web site. - * Optional. (default: none) - */ - app_package_name?: string; - }*/ - options); - -// End module gapi.signin2 -@JS() -abstract class Promise { - external factory Promise( - void Function(void Function(T result) resolve, Function reject) executor); -} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart b/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart deleted file mode 100644 index 57b91838b8f1..000000000000 --- a/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart +++ /dev/null @@ -1,59 +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. - -@JS() -library gapi_onload; - -import 'dart:async'; - -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:js/js.dart'; - -import 'js_interop/gapi.dart' as gapi; -import 'utils.dart' show injectJSLibraries; - -@JS() -external set gapiOnloadCallback(Function callback); - -// This name must match the external setter above -/// This is only exposed for testing. It shouldn't be accessed by users of the -/// plugin as it could break at any point. -@visibleForTesting -const String kGapiOnloadCallbackFunctionName = 'gapiOnloadCallback'; -String _addOnloadToScript(String url) => url.startsWith('data:') - ? url - : '$url?onload=$kGapiOnloadCallbackFunctionName'; - -/// Injects the GAPI library by its [url], and other additional [libraries]. -/// -/// GAPI has an onload API where it'll call a callback when it's ready, JSONP style. -Future inject(String url, {List libraries = const []}) { - // Inject the GAPI library, and configure the onload global - final Completer gapiOnLoad = Completer(); - gapiOnloadCallback = allowInterop(() { - // Funnel the GAPI onload to a Dart future - gapiOnLoad.complete(); - }); - - // Attach the onload callback to the main url - final List allLibraries = [ - _addOnloadToScript(url), - ...libraries - ]; - - return Future.wait( - >[injectJSLibraries(allLibraries), gapiOnLoad.future]); -} - -/// Initialize the global gapi object so 'auth2' can be used. -/// Returns a promise that resolves when 'auth2' is ready. -Future init() { - final Completer gapiLoadCompleter = Completer(); - gapi.load('auth2', allowInterop(() { - gapiLoadCompleter.complete(); - })); - - // After this resolves, we can use gapi.auth2! - return gapiLoadCompleter.future; -} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart new file mode 100644 index 000000000000..528dc89b1a75 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart @@ -0,0 +1,152 @@ +// 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:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:google_identity_services_web/oauth2.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:http/http.dart' as http; + +/// Basic scopes for self-id +const List scopes = [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email', +]; + +/// People API to return my profile info... +const String MY_PROFILE = 'https://content-people.googleapis.com/v1/people/me' + '?sources=READ_SOURCE_TYPE_PROFILE' + '&personFields=photos%2Cnames%2CemailAddresses'; + +/// Requests user data from the People API using the given [tokenResponse]. +Future requestUserData( + TokenResponse tokenResponse, { + @visibleForTesting http.Client? overrideClient, +}) async { + // Request my profile from the People API. + final Map person = await _doRequest( + MY_PROFILE, + tokenResponse, + overrideClient: overrideClient, + ); + + // Now transform the Person response into a GoogleSignInUserData. + return extractUserData(person); +} + +/// Extracts user data from a Person resource. +/// +/// See: https://developers.google.com/people/api/rest/v1/people#Person +GoogleSignInUserData? extractUserData(Map json) { + final String? userId = _extractUserId(json); + final String? email = _extractPrimaryField( + json['emailAddresses'] as List?, + 'value', + ); + + assert(userId != null); + assert(email != null); + + return GoogleSignInUserData( + id: userId!, + email: email!, + displayName: _extractPrimaryField( + json['names'] as List?, + 'displayName', + ), + photoUrl: _extractPrimaryField( + json['photos'] as List?, + 'url', + ), + // Synthetic user data doesn't contain an idToken! + ); +} + +/// Extracts the ID from a Person resource. +/// +/// The User ID looks like this: +/// { +/// 'resourceName': 'people/PERSON_ID', +/// ... +/// } +String? _extractUserId(Map profile) { + final String? resourceName = profile['resourceName'] as String?; + return resourceName?.split('/').last; +} + +/// Extracts the [fieldName] marked as 'primary' from a list of [values]. +/// +/// Values can be one of: +/// * `emailAddresses` +/// * `names` +/// * `photos` +/// +/// From a Person object. +T? _extractPrimaryField(List? values, String fieldName) { + if (values != null) { + for (final Object? value in values) { + if (value != null && value is Map) { + final bool isPrimary = _extractPath( + value, + path: ['metadata', 'primary'], + defaultValue: false, + ); + if (isPrimary) { + return value[fieldName] as T?; + } + } + } + } + + return null; +} + +/// Attempts to get the property in [path] of type `T` from a deeply nested [source]. +/// +/// Returns [default] if the property is not found. +T _extractPath( + Map source, { + required List path, + required T defaultValue, +}) { + final String valueKey = path.removeLast(); + Object? data = source; + for (final String key in path) { + if (data != null && data is Map) { + data = data[key]; + } else { + break; + } + } + if (data != null && data is Map) { + return (data[valueKey] ?? defaultValue) as T; + } else { + return defaultValue; + } +} + +/// Gets from [url] with an authorization header defined by [token]. +/// +/// Attempts to [jsonDecode] the result. +Future> _doRequest( + String url, + TokenResponse token, { + http.Client? overrideClient, +}) async { + final Uri uri = Uri.parse(url); + final http.Client client = overrideClient ?? http.Client(); + try { + final http.Response response = + await client.get(uri, headers: { + 'Authorization': '${token.token_type} ${token.access_token}', + }); + if (response.statusCode != 200) { + throw http.ClientException(response.body, uri); + } + return jsonDecode(response.body) as Map; + } finally { + client.close(); + } +} 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 45acb1ffd7ed..c4bb9d403d2d 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 @@ -2,59 +2,87 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; -import 'dart:html' as html; +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'; -import 'js_interop/gapiauth2.dart' as auth2; - -/// Injects a list of JS [libraries] as `script` tags into a [target] [html.HtmlElement]. -/// -/// If [target] is not provided, it defaults to the web app's `head` tag (see `web/index.html`). -/// [libraries] is a list of URLs that are used as the `src` attribute of `script` tags -/// to which an `onLoad` listener is attached (one per URL). -/// -/// Returns a [Future] that resolves when all of the `script` tags `onLoad` events trigger. -Future injectJSLibraries( - List libraries, { - html.HtmlElement? target, -}) { - final List> loading = >[]; - final List tags = []; - - final html.Element targetElement = target ?? html.querySelector('head')!; - - for (final String library in libraries) { - final html.ScriptElement script = html.ScriptElement() - ..async = true - ..defer = true - // ignore: unsafe_html - ..src = library; - // TODO(ditman): add a timeout race to fail this future - loading.add(script.onLoad.first); - tags.add(script); +/// 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); + +/// A RegExp that can match, and extract parts from a JWT Token. +/// +/// A JWT token consists of 3 base-64 encoded parts of data separated by periods: +/// +/// header.payload.signature +/// +/// More info: https://regexr.com/789qc +final RegExp jwtTokenRegexp = RegExp( + r'^(?
[^\.\s]+)\.(?[^\.\s]+)\.(?[^\.\s]+)$'); + +/// Decodes the `claims` of a JWT token and returns them as a Map. +/// +/// JWT `claims` are stored as a JSON object in the `payload` part of the token. +/// +/// (This method does not validate the signature of the token.) +/// +/// See https://www.rfc-editor.org/rfc/rfc7519#section-3 +Map? getJwtTokenPayload(String? token) { + if (token != null) { + final RegExpMatch? match = jwtTokenRegexp.firstMatch(token); + if (match != null) { + return decodeJwtPayload(match.namedGroup('payload')); + } } - targetElement.children.addAll(tags); - return Future.wait(loading); + return null; } -/// Utility method that converts `currentUser` to the equivalent [GoogleSignInUserData]. +/// Decodes a JWT payload using the [jwtCodec]. +Map? decodeJwtPayload(String? payload) { + try { + // Payload must be normalized before passing it to the codec + return jwtCodec.decode(base64.normalize(payload!)) as Map?; + } catch (_) { + // Do nothing, we always return null for any failure. + } + return null; +} + +/// Converts a [CredentialResponse] into a [GoogleSignInUserData]. /// -/// This method returns `null` when the [currentUser] is not signed in. -GoogleSignInUserData? gapiUserToPluginUserData(auth2.GoogleUser? currentUser) { - final bool isSignedIn = currentUser?.isSignedIn() ?? false; - final auth2.BasicProfile? profile = currentUser?.getBasicProfile(); - if (!isSignedIn || profile?.getId() == null) { +/// May return `null`, if the `credentialResponse` is null, or its `credential` +/// cannot be decoded. +GoogleSignInUserData? gisResponsesToUserData( + CredentialResponse? credentialResponse) { + if (credentialResponse == null || credentialResponse.credential == null) { + return null; + } + + final Map? payload = + getJwtTokenPayload(credentialResponse.credential); + + if (payload == null) { return null; } return GoogleSignInUserData( - displayName: profile?.getName(), - email: profile?.getEmail() ?? '', - id: profile?.getId() ?? '', - photoUrl: profile?.getImageUrl(), - idToken: currentUser?.getAuthResponse().id_token, + email: payload['email']! as String, + id: payload['sub']! as String, + displayName: payload['name']! as String, + photoUrl: payload['picture']! as String, + idToken: credentialResponse.credential, + ); +} + +/// Converts responses from the GIS library into TokenData for the plugin. +GoogleSignInTokenData gisResponsesToTokenData( + CredentialResponse? credentialResponse, TokenResponse? tokenResponse) { + return GoogleSignInTokenData( + idToken: credentialResponse?.credential, + accessToken: tokenResponse?.access_token, ); } 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 a9d39471c3ed..40e8b0381e67 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,10 +3,10 @@ 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/plugins/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.10.2+1 +version: 0.11.0 environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" flutter: ">=3.0.0" flutter: @@ -22,7 +22,9 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter + google_identity_services_web: ^0.2.0 google_sign_in_platform_interface: ^2.2.0 + http: ^0.13.5 js: ^0.6.3 dev_dependencies: