diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 0723487fd90..f490fef4799 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,5 +1,9 @@ -## NEXT +## 6.1.0 +* Exposes the new method `canAccessScopes`. +* Updates example app to separate Authentication from Authorization for those + platforms where scopes are not automatically granted upon signIn (like the web). + * Updates README with information about these changes. * Aligns Dart and Flutter SDK constraints. ## 6.0.2 diff --git a/packages/google_sign_in/google_sign_in/README.md b/packages/google_sign_in/google_sign_in/README.md index ac5baeae96c..316da3fb68b 100644 --- a/packages/google_sign_in/google_sign_in/README.md +++ b/packages/google_sign_in/google_sign_in/README.md @@ -96,7 +96,15 @@ be an option. ### Web integration -For web integration details, see the +The new SDK used by the web has fully separated Authentication from Authorization, +so `signIn` and `signInSilently` no longer authorize Oauth `scopes`. + +Flutter Apps must be able to detect what scopes have been granted by their users, +and if the grants are still valid. + +Read below about **Working with scopes, and incremental authorization** for +general information about changes that may be needed on an app, and for more +specific web integration details, see the [`google_sign_in_web` package](https://pub.dev/packages/google_sign_in_web). ## Usage @@ -139,6 +147,74 @@ Future _handleSignIn() async { } ``` +In the web, you should use the **Google Sign In button** (and not the `signIn` method) +to guarantee that your user authentication contains a valid `idToken`. + +For more details, take a look at the +[`google_sign_in_web` package](https://pub.dev/packages/google_sign_in_web). + +## Working with scopes, and incremental authorization. + +### Checking if scopes have been granted + +Users may (or may *not*) grant all the scopes that your application requests at +Sign In. In fact, in the web, no scopes are granted by signIn or silentSignIn anymore. + +Your app must be able to: + +* Detect if the authenticated user has authorized the scopes your app needs. +* Detect if the scopes that were granted a few minutes ago are still valid. + +There's a new method that allows your app to check this: + +```dart +final bool isAuthorized = await _googleSignIn.canAccessScopes(scopes); +``` + +### Requesting more scopes when needed + +If your app determines that the user hasn't granted the scopes it requires, it +should initiate an Authorization request **from an user interaction** (like a +button press). + +```dart +Future _handleAuthorizeScopes() async { + final bool isAuthorized = await _googleSignIn.requestScopes(scopes); + if (isAuthorized) { + // Do things that only authorized users can do! + _handleGetContact(_currentUser!); + } +} +``` + +The `requestScopes` returns a `boolean` value that is `true` if the user has +granted all the requested scopes or `false` otherwise. + +Once your app determines that the current user `isAuthorized` to access the +services for which you need `scopes`, it can proceed normally. + +### Authorization expiration + +In the web, **the `accessToken` is no longer refreshed**. It expires after 3600 +seconds (one hour), so your app needs to be able to handle failed REST requests, +and update its UI to prompt the user for a new Authorization round. + +This can be done by combining the error responses from your REST requests with +the `canAccessScopes` and `requestScopes` methods described above. + +For more details, take a look at the +[`google_sign_in_web` package](https://pub.dev/packages/google_sign_in_web). + +### My app didn't need any of this, what gives!? + +The new web SDK implicitly grant access to `email`, `profile` and `openid` when +users complete the sign-in process (either via the One Tap UX or the Google Sign +In button). + +If your app only needs an `idToken`, or only requests permissions to some of the +[OpenID Connect scopes](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect), +you might not need to implement any of the scope handling above. + ## Example Find the example wiring in the diff --git a/packages/google_sign_in/google_sign_in/example/lib/main.dart b/packages/google_sign_in/google_sign_in/example/lib/main.dart index 523ead71262..b1400de07f2 100644 --- a/packages/google_sign_in/google_sign_in/example/lib/main.dart +++ b/packages/google_sign_in/google_sign_in/example/lib/main.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: public_member_api_docs, avoid_print +// ignore_for_file: avoid_print import 'dart:async'; import 'dart:convert' show json; @@ -11,13 +11,18 @@ import 'package:flutter/material.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:http/http.dart' as http; +import 'src/sign_in_button.dart'; + +/// The scopes required by this application. +const List scopes = [ + 'email', + 'https://www.googleapis.com/auth/contacts.readonly', +]; + GoogleSignIn _googleSignIn = GoogleSignIn( // Optional clientId - // clientId: '479882132969-9i9aqik3jfjd7qhci1nqf0bm2g71rm1u.apps.googleusercontent.com', - scopes: [ - 'email', - 'https://www.googleapis.com/auth/contacts.readonly', - ], + // clientId: 'your-client_id.apps.googleusercontent.com', + scopes: scopes, ); void main() { @@ -29,31 +34,53 @@ void main() { ); } +/// The SignInDemo app. class SignInDemo extends StatefulWidget { + /// const SignInDemo({super.key}); @override - State createState() => SignInDemoState(); + State createState() => _SignInDemoState(); } -class SignInDemoState extends State { +class _SignInDemoState extends State { GoogleSignInAccount? _currentUser; + bool _isAuthorized = false; // has granted permissions? String _contactText = ''; @override void initState() { super.initState(); - _googleSignIn.onCurrentUserChanged.listen((GoogleSignInAccount? account) { + + _googleSignIn.onCurrentUserChanged + .listen((GoogleSignInAccount? account) async { + // Check if the account can access scopes... + bool isAuthorized = false; + if (account != null) { + isAuthorized = await _googleSignIn.canAccessScopes(scopes); + } + setState(() { _currentUser = account; + _isAuthorized = isAuthorized; }); - if (_currentUser != null) { - _handleGetContact(_currentUser!); + + // Now that we know that the user can access the required scopes, the app + // can call the REST API. + if (isAuthorized) { + _handleGetContact(account!); } }); + + // In the web, _googleSignIn.signInSilently() triggers the One Tap UX. + // + // It is recommended by Google Identity Services to render both the One Tap UX + // and the Google Sign In button together to "reduce friction and improve + // sign-in rates" ([docs](https://developers.google.com/identity/gsi/web/guides/display-button#html)). _googleSignIn.signInSilently(); } + // Calls the People API REST endpoint for the signed-in user to retrieve information. Future _handleGetContact(GoogleSignInAccount user) async { setState(() { _contactText = 'Loading contact info...'; @@ -103,6 +130,10 @@ class SignInDemoState extends State { return null; } + // This is the on-click handler for the Sign In button that is rendered by Flutter. + // + // On the web, the on-click handler of the Sign In button is owned by the JS + // SDK, so this method can be considered mobile only. Future _handleSignIn() async { try { await _googleSignIn.signIn(); @@ -111,11 +142,28 @@ class SignInDemoState extends State { } } + // Prompts the user to authorize `scopes`. + // + // This action is **required** in platforms that don't perform Authentication + // and Authorization at the same time (like the web). + // + // On the web, this must be called from an user interaction (button click). + Future _handleAuthorizeScopes() async { + final bool isAuthorized = await _googleSignIn.requestScopes(scopes); + setState(() { + _isAuthorized = isAuthorized; + }); + if (isAuthorized) { + _handleGetContact(_currentUser!); + } + } + Future _handleSignOut() => _googleSignIn.disconnect(); Widget _buildBody() { final GoogleSignInAccount? user = _currentUser; if (user != null) { + // The user is Authenticated return Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ @@ -127,25 +175,39 @@ class SignInDemoState extends State { subtitle: Text(user.email), ), const Text('Signed in successfully.'), - Text(_contactText), + if (_isAuthorized) ...[ + // The user has Authorized all required scopes + Text(_contactText), + ElevatedButton( + child: const Text('REFRESH'), + onPressed: () => _handleGetContact(user), + ), + ], + if (!_isAuthorized) ...[ + // The user has NOT Authorized all required scopes. + // (Mobile users may never see this button!) + const Text('Additional permissions needed to read your contacts.'), + ElevatedButton( + onPressed: _handleAuthorizeScopes, + child: const Text('REQUEST PERMISSIONS'), + ), + ], ElevatedButton( onPressed: _handleSignOut, child: const Text('SIGN OUT'), ), - ElevatedButton( - child: const Text('REFRESH'), - onPressed: () => _handleGetContact(user), - ), ], ); } else { + // The user is NOT Authenticated return Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ const Text('You are not currently signed in.'), - ElevatedButton( + // This method is used to separate mobile from web code with conditional exports. + // See: src/sign_in_button.dart + buildSignInButton( onPressed: _handleSignIn, - child: const Text('SIGN IN'), ), ], ); diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button.dart b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button.dart new file mode 100644 index 00000000000..c0a33966312 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'sign_in_button/stub.dart' + if (dart.library.js_util) 'sign_in_button/web.dart' + if (dart.library.io) 'sign_in_button/mobile.dart'; diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/mobile.dart b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/mobile.dart new file mode 100644 index 00000000000..8d929d7ef83 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/mobile.dart @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'stub.dart'; + +/// Renders a SIGN IN button that calls `handleSignIn` onclick. +Widget buildSignInButton({HandleSignInFn? onPressed}) { + return ElevatedButton( + onPressed: onPressed, + child: const Text('SIGN IN'), + ); +} diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/stub.dart b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/stub.dart new file mode 100644 index 00000000000..85a54f0ac27 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/stub.dart @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// The type of the onClick callback for the (mobile) Sign In Button. +typedef HandleSignInFn = Future Function(); + +/// Renders a SIGN IN button that (maybe) calls the `handleSignIn` onclick. +Widget buildSignInButton({HandleSignInFn? onPressed}) { + return Container(); +} diff --git a/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/web.dart b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/web.dart new file mode 100644 index 00000000000..4189fc6cd72 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/lib/src/sign_in_button/web.dart @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:google_sign_in_web/google_sign_in_web.dart' as web; + +import 'stub.dart'; + +/// Renders a web-only SIGN IN button. +Widget buildSignInButton({HandleSignInFn? onPressed}) { + return (GoogleSignInPlatform.instance as web.GoogleSignInPlugin) + .renderButton(); +} diff --git a/packages/google_sign_in/google_sign_in/example/pubspec.yaml b/packages/google_sign_in/google_sign_in/example/pubspec.yaml index ff09d94279c..93ac6c9117b 100644 --- a/packages/google_sign_in/google_sign_in/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/example/pubspec.yaml @@ -16,8 +16,21 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ + google_sign_in_platform_interface: ^2.2.0 + google_sign_in_web: ^0.11.0 http: ^0.13.0 +dependency_overrides: + google_identity_services_web: + git: + url: https://github.com/ditman/flutter-packages.git + ref: gis-web-fix-render-button-api + path: packages/google_identity_services_web + google_sign_in_platform_interface: + path: ../../google_sign_in_platform_interface + google_sign_in_web: + path: ../../google_sign_in_web + dev_dependencies: espresso: ^0.2.0 flutter_driver: diff --git a/packages/google_sign_in/google_sign_in/example/web/index.html b/packages/google_sign_in/google_sign_in/example/web/index.html index 5710c936c2e..6bd23335f2c 100644 --- a/packages/google_sign_in/google_sign_in/example/web/index.html +++ b/packages/google_sign_in/google_sign_in/example/web/index.html @@ -5,7 +5,7 @@ - + Google Sign-in Example diff --git a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart index 8e908dc479e..ff94949b6cf 100644 --- a/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart +++ b/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart @@ -189,7 +189,13 @@ class GoogleSignIn { this.clientId, this.serverClientId, this.forceCodeForRefreshToken = false, - }); + }) { + // Start initializing. + // Async methods in the plugin will await for this to be done. + if (kIsWeb) { + unawaited(_ensureInitialized()); + } + } /// Factory for creating default sign in user experience. factory GoogleSignIn.standard({ @@ -261,11 +267,9 @@ class GoogleSignIn { StreamController.broadcast(); /// Subscribe to this stream to be notified when the current user changes. - Stream get onCurrentUserChanged => - _currentUserController.stream; - - // Future that completes when we've finished calling `init` on the native side - Future? _initialization; + Stream get onCurrentUserChanged { + return _currentUserController.stream; + } Future _callMethod( Future Function() method) async { @@ -278,6 +282,7 @@ class GoogleSignIn { : null); } + // Sets the current user, and propagates it through the _currentUserController. GoogleSignInAccount? _setCurrentUser(GoogleSignInAccount? currentUser) { if (currentUser != _currentUser) { _currentUser = currentUser; @@ -286,20 +291,36 @@ class GoogleSignIn { return _currentUser; } - Future _ensureInitialized() { - return _initialization ??= - GoogleSignInPlatform.instance.initWithParams(SignInInitParameters( + // Future that completes when we've finished calling `init` on the native side. + Future? _initialization; + + // Performs initialization, guarding it with the _initialization future. + Future _ensureInitialized() async { + return _initialization ??= _doInitialization() + ..catchError((dynamic _) { + // Invalidate initialization if it errors out. + _initialization = null; + }); + } + + // Actually performs the initialization. + // + // This method calls initWithParams, and then, if the plugin instance has a + // userDataEvents Stream, connects it to the [_setCurrentUser] method. + Future _doInitialization() async { + await GoogleSignInPlatform.instance.initWithParams(SignInInitParameters( signInOption: signInOption, scopes: scopes, hostedDomain: hostedDomain, clientId: clientId, serverClientId: serverClientId, forceCodeForRefreshToken: forceCodeForRefreshToken, - )) - ..catchError((dynamic _) { - // Invalidate initialization if it errors out. - _initialization = null; - }); + )); + + GoogleSignInPlatform.instance.userDataEvents + ?.map((GoogleSignInUserData? userData) { + return userData != null ? GoogleSignInAccount._(this, userData) : null; + }).forEach(_setCurrentUser); } /// The most recently scheduled method call. @@ -424,4 +445,24 @@ class GoogleSignIn { await _ensureInitialized(); return GoogleSignInPlatform.instance.requestScopes(scopes); } + + /// Checks if the [_currentUser] can access all the given [scopes]. + /// + /// Optionally, an [accessToken] can be passed to perform this check. This + /// may be useful when an application holds on to a cached, potentially + /// long-lived [accessToken]. + Future canAccessScopes( + List scopes, { + String? accessToken, + }) async { + await _ensureInitialized(); + + final String? token = + accessToken ?? (await _currentUser?.authentication)?.accessToken; + + return GoogleSignInPlatform.instance.canAccessScopes( + scopes, + accessToken: token, + ); + } } diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index d0b6b713101..816f8f0298d 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 6.0.2 +version: 6.1.0 environment: sdk: ">=2.17.0 <4.0.0" @@ -27,6 +27,12 @@ dependencies: google_sign_in_platform_interface: ^2.2.0 google_sign_in_web: ^0.11.0 +dependency_overrides: + google_sign_in_platform_interface: + path: ../google_sign_in_platform_interface + google_sign_in_web: + path: ../google_sign_in_web + dev_dependencies: build_runner: ^2.1.10 flutter_driver: @@ -43,5 +49,3 @@ false_secrets: - /example/android/app/google-services.json - /example/ios/Runner/GoogleService-Info.plist - /example/ios/RunnerTests/GoogleSignInTests.m - - /example/lib/main.dart - - /example/web/index.html diff --git a/packages/google_sign_in/google_sign_in/regen_mocks.sh b/packages/google_sign_in/google_sign_in/regen_mocks.sh new file mode 100755 index 00000000000..78bcdc0f9e2 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/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/test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart index 2296f2d7988..86b83cf4a09 100644 --- a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.dart @@ -31,6 +31,7 @@ void main() { setUp(() { mockPlatform = MockGoogleSignInPlatform(); when(mockPlatform.isMock).thenReturn(true); + when(mockPlatform.userDataEvents).thenReturn(null); when(mockPlatform.signInSilently()) .thenAnswer((Invocation _) async => kDefaultUser); when(mockPlatform.signIn()) @@ -260,10 +261,13 @@ void main() { }); test('can sign in after init failed before', () async { - final GoogleSignIn googleSignIn = GoogleSignIn(); - + // Web eagerly `initWithParams` when GoogleSignIn is created, so make sure + // the initWithParams is throwy ASAP. when(mockPlatform.initWithParams(any)) .thenThrow(Exception('First init fails')); + + final GoogleSignIn googleSignIn = GoogleSignIn(); + expect(googleSignIn.signIn(), throwsA(isInstanceOf())); when(mockPlatform.initWithParams(any)) @@ -327,6 +331,63 @@ void main() { verify(mockPlatform.requestScopes(['testScope'])); }); + test('canAccessScopes forwards calls to platform', () async { + final GoogleSignIn googleSignIn = GoogleSignIn(); + when(mockPlatform.canAccessScopes( + any, + accessToken: anyNamed('accessToken'), + )).thenAnswer((Invocation _) async => true); + + await googleSignIn.signIn(); + final bool result = await googleSignIn.canAccessScopes( + ['testScope'], + accessToken: 'xyz', + ); + + expect(result, isTrue); + _verifyInit(mockPlatform); + verify(mockPlatform.canAccessScopes( + ['testScope'], + accessToken: 'xyz', + )); + }); + + test('userDataEvents are forwarded through the onUserChanged stream', + () async { + final StreamController userDataController = + StreamController(); + + when(mockPlatform.userDataEvents) + .thenAnswer((Invocation _) => userDataController.stream); + when(mockPlatform.isSignedIn()).thenAnswer((Invocation _) async => false); + + final GoogleSignIn googleSignIn = GoogleSignIn(); + await googleSignIn.isSignedIn(); + + // This is needed to ensure `_ensureInitialized` is called! + final Future> nextTwoEvents = + googleSignIn.onCurrentUserChanged.take(2).toList(); + + // Dispatch two events + userDataController.add(kDefaultUser); + userDataController.add(null); + + final List events = await nextTwoEvents; + + expect(events.first, isNotNull); + + final GoogleSignInAccount user = events.first!; + + expect(user.displayName, equals(kDefaultUser.displayName)); + expect(user.email, equals(kDefaultUser.email)); + expect(user.id, equals(kDefaultUser.id)); + expect(user.photoUrl, equals(kDefaultUser.photoUrl)); + expect(user.serverAuthCode, equals(kDefaultUser.serverAuthCode)); + + // The second event was a null... + expect(events.last, isNull); + }); + test('user starts as null', () async { final GoogleSignIn googleSignIn = GoogleSignIn(); expect(googleSignIn.currentUser, isNull); diff --git a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart index 4e669628391..5b4c12255f4 100644 --- a/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in/test/google_sign_in_test.mocks.dart @@ -1,7 +1,8 @@ -// Mocks generated by Mockito 5.1.0 from annotations +// Mocks generated by Mockito 5.3.2 from annotations // in google_sign_in/test/google_sign_in_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' @@ -18,9 +19,18 @@ import 'package:mockito/mockito.dart' as _i1; // 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.Fake - implements _i2.GoogleSignInTokenData {} +class _FakeGoogleSignInTokenData_0 extends _i1.SmartFake + implements _i2.GoogleSignInTokenData { + _FakeGoogleSignInTokenData_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} /// A class which mocks [GoogleSignInPlatform]. /// @@ -32,69 +42,140 @@ class MockGoogleSignInPlatform extends _i1.Mock } @override - bool get isMock => - (super.noSuchMethod(Invocation.getter(#isMock), returnValue: false) - as bool); + bool get isMock => (super.noSuchMethod( + Invocation.getter(#isMock), + returnValue: false, + ) as bool); @override - _i4.Future init( - {List? scopes = const [], - _i2.SignInOption? signInOption = _i2.SignInOption.standard, - String? hostedDomain, - String? clientId}) => + _i4.Future init({ + List? scopes = const [], + _i2.SignInOption? signInOption = _i2.SignInOption.standard, + String? hostedDomain, + String? clientId, + }) => (super.noSuchMethod( - Invocation.method(#init, [], { + Invocation.method( + #init, + [], + { #scopes: scopes, #signInOption: signInOption, #hostedDomain: hostedDomain, - #clientId: clientId - }), - returnValue: Future.value(), - returnValueForMissingStub: Future.value()) as _i4.Future); + #clientId: clientId, + }, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); @override _i4.Future initWithParams(_i2.SignInInitParameters? params) => - (super.noSuchMethod(Invocation.method(#initWithParams, [params]), - returnValue: Future.value(), - returnValueForMissingStub: Future.value()) as _i4.Future); + (super.noSuchMethod( + Invocation.method( + #initWithParams, + [params], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); @override - _i4.Future<_i2.GoogleSignInUserData?> signInSilently() => - (super.noSuchMethod(Invocation.method(#signInSilently, []), - returnValue: Future<_i2.GoogleSignInUserData?>.value()) - as _i4.Future<_i2.GoogleSignInUserData?>); + _i4.Future<_i2.GoogleSignInUserData?> signInSilently() => (super.noSuchMethod( + Invocation.method( + #signInSilently, + [], + ), + returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), + ) as _i4.Future<_i2.GoogleSignInUserData?>); @override - _i4.Future<_i2.GoogleSignInUserData?> signIn() => - (super.noSuchMethod(Invocation.method(#signIn, []), - returnValue: Future<_i2.GoogleSignInUserData?>.value()) - as _i4.Future<_i2.GoogleSignInUserData?>); + _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( + Invocation.method( + #signIn, + [], + ), + returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), + ) as _i4.Future<_i2.GoogleSignInUserData?>); @override - _i4.Future<_i2.GoogleSignInTokenData> getTokens( - {String? email, bool? shouldRecoverAuth}) => + _i4.Future<_i2.GoogleSignInTokenData> getTokens({ + required String? email, + bool? shouldRecoverAuth, + }) => (super.noSuchMethod( - Invocation.method(#getTokens, [], - {#email: email, #shouldRecoverAuth: shouldRecoverAuth}), - returnValue: Future<_i2.GoogleSignInTokenData>.value( - _FakeGoogleSignInTokenData_0())) - as _i4.Future<_i2.GoogleSignInTokenData>); + Invocation.method( + #getTokens, + [], + { + #email: email, + #shouldRecoverAuth: shouldRecoverAuth, + }, + ), + returnValue: _i4.Future<_i2.GoogleSignInTokenData>.value( + _FakeGoogleSignInTokenData_0( + this, + Invocation.method( + #getTokens, + [], + { + #email: email, + #shouldRecoverAuth: shouldRecoverAuth, + }, + ), + )), + ) as _i4.Future<_i2.GoogleSignInTokenData>); @override - _i4.Future signOut() => - (super.noSuchMethod(Invocation.method(#signOut, []), - returnValue: Future.value(), - returnValueForMissingStub: Future.value()) as _i4.Future); + _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: Future.value(), - returnValueForMissingStub: Future.value()) as _i4.Future); + _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: Future.value(false)) as _i4.Future); + _i4.Future isSignedIn() => (super.noSuchMethod( + Invocation.method( + #isSignedIn, + [], + ), + returnValue: _i4.Future.value(false), + ) as _i4.Future); @override - _i4.Future clearAuthCache({String? token}) => (super.noSuchMethod( - Invocation.method(#clearAuthCache, [], {#token: token}), - returnValue: Future.value(), - returnValueForMissingStub: Future.value()) as _i4.Future); + _i4.Future clearAuthCache({required String? token}) => + (super.noSuchMethod( + Invocation.method( + #clearAuthCache, + [], + {#token: token}, + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); @override - _i4.Future requestScopes(List? scopes) => - (super.noSuchMethod(Invocation.method(#requestScopes, [scopes]), - returnValue: Future.value(false)) as _i4.Future); + _i4.Future requestScopes(List? scopes) => (super.noSuchMethod( + Invocation.method( + #requestScopes, + [scopes], + ), + returnValue: _i4.Future.value(false), + ) as _i4.Future); + @override + _i4.Future canAccessScopes( + List? scopes, { + String? accessToken, + }) => + (super.noSuchMethod( + Invocation.method( + #canAccessScopes, + [scopes], + {#accessToken: accessToken}, + ), + returnValue: _i4.Future.value(false), + ) as _i4.Future); } diff --git a/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md b/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md index 751bf5b65dc..fed8fb77a2d 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_platform_interface/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 2.4.0 +* Introduces: `canAccessScopes` method and `userDataEvents` stream. * Aligns Dart and Flutter SDK constraints. ## 2.3.1 diff --git a/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart b/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart index 64fc88d4866..9b9f932ab32 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/lib/google_sign_in_platform_interface.dart @@ -139,4 +139,22 @@ abstract class GoogleSignInPlatform extends PlatformInterface { Future requestScopes(List scopes) async { throw UnimplementedError('requestScopes() has not been implmented.'); } + + /// Determines if the current user can access all [scopes]. + /// + /// Optionally, an [accessToken] can be passed for applications where a + /// long-lived token may be cached (like the web). + Future canAccessScopes( + List scopes, { + String? accessToken, + }) async { + return isSignedIn(); + } + + /// Returns a stream of [GoogleSignInUserData] authentication events. + /// + /// These will normally come from asynchronous flows, like the Google Sign-In + /// Button Widget from the Web implementation, and will be funneled directly + /// to the `onCurrentUserChanged` Stream of the plugin. + Stream? get userDataEvents => null; } diff --git a/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml b/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml index 0a3f49f4be8..3b28759e12b 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/google_sign_i issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.3.1 +version: 2.3.2 environment: sdk: ">=2.17.0 <4.0.0" diff --git a/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart b/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart index 0837f6d5d02..e501b9217cb 100644 --- a/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart +++ b/packages/google_sign_in/google_sign_in_platform_interface/test/method_channel_google_sign_in_test.dart @@ -141,6 +141,25 @@ void main() { expect(log, tests.values); }); + test('canAccessScopes is the same as isSignedIn', () async { + await googleSignIn.canAccessScopes(['someScope']); + expect(log, [ + isMethodCall('isSignedIn', arguments: null), + ]); + }); + + test('canAccessScopes can accept an optional accessToken', () async { + await googleSignIn + .canAccessScopes(['someScope'], accessToken: 'token'); + expect(log, [ + isMethodCall('isSignedIn', arguments: null), + ]); + }); + + test('userDataEvents returns null', () async { + expect(googleSignIn.userDataEvents, isNull); + }); + test('initWithParams passes through arguments to the channel', () async { await googleSignIn.initWithParams(const SignInInitParameters( hostedDomain: 'example.com', 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 2d02e63eee3..66fb2ed634a 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.11.1 + +* Authentication: + * Adds web-only `renderButton` method and its configuration object, as a new + authentication mechanism. + * Prepares a `userDataEvents` Stream, so the Google Sign In Button can propagate + authentication changes to the core plugin. + * `signInSilently` now returns an authenticated (but not authorized) user. +* Authorization: + * Implements the new `canAccessScopes` method. + * Ensures that the `requestScopes` call doesn't trigger user selection when the + current user is known (similar to what `signIn` does). + ## 0.11.0+2 * Clarifies explanation of endorsement in README. 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 865a5346eed..9b3c4449944 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -28,14 +28,11 @@ quickly and easily sign users into your app suing their Google accounts. * 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`. + successfully completes an authentication flow. + * In the plugin: `signInSilently` and through the web-only `renderButton` widget. * The plugin `signIn` method uses the Oauth "Implicit Flow" to Authorize the requested `scopes`. - * 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. + * This method only provides an `accessToken`, and not an `idToken`, so if your + app needs an `idToken`, this method **should be avoided**. * 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. @@ -49,48 +46,73 @@ See more differences in the following migration guides: ### New use cases to take into account in your app -#### Enable access to the People API for your GCP project +#### Authentication != Authorization + +In the GIS SDK, the concepts of Authentication and Authorization have been separated. + +It is possible now to have an Authenticated user that hasn't Authorized any `scopes`. + +Flutter apps that need to run in the web must now handle the fact that an Authenticated +user may not have permissions to access the `scopes` it requires to function. + +The Google Sign In plugin has a new `canAccessScopes` method that can be used to +check if a user is Authorized or not. + +It is also possible that Authorizations expire while users are using an App +(after 3600 seconds), so Apps should monitor response failures from the APIs, and +prompt users (interactively) to grant permissions again. + +Check the "Integration considerations > [UX separation for authentication and authorization](https://developers.google.com/identity/gsi/web/guides/integrate#ux_separation_for_authentication_and_authorization) +guide" in the official GIS SDK documentation for more information about this. -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`). +_(See also the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example) +for a simple implementation of this (look at the `isAuthorized` variable).)_ -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. +#### Is this separation *always required*? -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. +Only if the scopes required by an App are different from the +[OpenID Connect scopes](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect). -#### `signInSilently` always returns `null` +If an App only needs an `idToken`, or the OpenID Connect scopes, the "Sign In" +bits of the plugin should be enough for your app (`signInSilently` and `renderButton`). -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". +#### What happened to the `signIn` method on the web? -With the GIS SDK, `signInSilently` only deals with user Authentication, so users -retrieved "silently" will only contain an `idToken`, but not an `accessToken`. +Because the GIS SDK for web no longer provides users with the ability to create +their own Sign-In buttons, or an API to start the sign in flow, the current +implementation of `signIn` (that does authorization and authentication) is impossible +to implement on the web. + +The web plugin attempts to simulate the old `signIn` behavior by using the +[Oauth Implicit pop-up flow](https://developers.google.com/identity/oauth2/web/guides/use-token-model), which authenticates and authorizes users. + +The drawback of this approach is that the Oauth flow **only returns an `accessToken`**, +and a synthetic version of the User Data, that does **not include an `idToken`**. + +The solution to this is to migrate your custom "Sign In" buttons in the web to +the Button Widget provided by this package: `Widget renderButton`. + +_(Check the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example) +for an example on how to mix the `renderButton` widget on the web, with a custom +button for the mobile.)_ + +#### Enable access to the People API for your GCP project -Only after `signIn` or `requestScopes`, a user will be fully formed. +If you want to use the `signIn` method on the web, the plugin will do an additional +request to the PeopleAPI to retrieve the logged-in user information (minus the `idToken`). -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. +For this to work, you must enable access to the People API on your Client ID in +the GCP console. -#### `idToken` is `null` in the `GoogleSignInAccount` object after `signIn` +This is **not recommended**. Ideally, your web application should use a mix of +`signInSilently` and the Google Sign In web `renderButton` to authenticate your +users, and then `canAccessScopes` and `requestScopes` to authorize the `scopes` +that are needed. -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`. +#### Why is the `idToken` `null` after `signIn` -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. +The `idToken` is cryptographically signed by Google Identity Services, and +this plugin can't spoof that signature. #### User Sessions @@ -113,8 +135,8 @@ 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. +In either case, your app needs to prompt the end user to `requestScopes`, to +**interactively** renew the token. The GIS SDK limits authorization token duration to one hour (3600 seconds). diff --git a/packages/google_sign_in/google_sign_in_web/example/README.md b/packages/google_sign_in/google_sign_in_web/example/README.md index 0e51ae5ecbd..fd5d2325b88 100644 --- a/packages/google_sign_in/google_sign_in_web/example/README.md +++ b/packages/google_sign_in/google_sign_in_web/example/README.md @@ -17,3 +17,14 @@ in the Flutter wiki for instructions to setup and run the tests in this package. Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. + +# button_tester.dart + +The button_tester.dart file contains an example app to test the different configuration +values of the Google Sign In Button Widget. + +To run that example: + +``` +flutter run -d chrome --target=lib/button_tester.dart +``` diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/flexible_size_html_element_view_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/flexible_size_html_element_view_test.dart new file mode 100644 index 00000000000..b3bf1d591a3 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/flexible_size_html_element_view_test.dart @@ -0,0 +1,207 @@ +// 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:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in_web/src/flexible_size_html_element_view.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'src/dom.dart'; + +/// Used to keep track of the number of HtmlElementView factories the test has registered. +int widgetFactoryNumber = 0; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('FlexHtmlElementView', () { + tearDown(() { + widgetFactoryNumber++; + }); + + testWidgets('empty case, calls onPlatformViewCreated', + (WidgetTester tester) async { + final Completer viewCreatedCompleter = Completer(); + + await pumpResizableWidget(tester, onPlatformViewCreated: (int id) { + viewCreatedCompleter.complete(id); + }); + await tester.pumpAndSettle(); + + await expectLater(viewCreatedCompleter.future, completes); + }); + + testWidgets('empty case, renders with initial size', + (WidgetTester tester) async { + const Size initialSize = Size(160, 100); + + final Element element = await pumpResizableWidget( + tester, + initialSize: initialSize, + ); + await tester.pumpAndSettle(); + + // Expect that the element matches the initialSize. + expect(element.size!.width, initialSize.width); + expect(element.size!.height, initialSize.height); + }); + + testWidgets('initialSize null, adopts size of injected element', + (WidgetTester tester) async { + const Size childSize = Size(300, 40); + + final DomHtmlElement resizable = document.createElement('div'); + resize(resizable, childSize); + + final Element element = await pumpResizableWidget( + tester, + onPlatformViewCreated: injectElement(resizable), + ); + await tester.pumpAndSettle(); + + // Expect that the element matches the initialSize. + expect(element.size!.width, childSize.width); + expect(element.size!.height, childSize.height); + }); + + testWidgets('with initialSize, adopts size of injected element', + (WidgetTester tester) async { + const Size initialSize = Size(160, 100); + const Size newSize = Size(300, 40); + + final DomHtmlElement resizable = document.createElement('div'); + resize(resizable, newSize); + + final Element element = await pumpResizableWidget( + tester, + initialSize: initialSize, + onPlatformViewCreated: injectElement(resizable), + ); + await tester.pumpAndSettle(); + + // Expect that the element matches the initialSize. + expect(element.size!.width, newSize.width); + expect(element.size!.height, newSize.height); + }); + + testWidgets('with injected element that resizes, follows resizes', + (WidgetTester tester) async { + const Size initialSize = Size(160, 100); + final Size expandedSize = initialSize * 2; + final Size contractedSize = initialSize / 2; + + final DomHtmlElement resizable = document.createElement('div') + ..setAttribute( + 'style', 'width: 100%; height: 100%; background: #fabada;'); + + final Element element = await pumpResizableWidget( + tester, + initialSize: initialSize, + onPlatformViewCreated: injectElement(resizable), + ); + await tester.pumpAndSettle(); + + // Expect that the element matches the initialSize, because the + // resizable is defined as width:100%, height:100%. + expect(element.size!.width, initialSize.width); + expect(element.size!.height, initialSize.height); + + // Expands + resize(resizable, expandedSize); + + await tester.pumpAndSettle(); + + expect(element.size!.width, expandedSize.width); + expect(element.size!.height, expandedSize.height); + + // Contracts + resize(resizable, contractedSize); + + await tester.pumpAndSettle(); + + expect(element.size!.width, contractedSize.width); + expect(element.size!.height, contractedSize.height); + }); + }); +} + +/// Injects a ResizableFromJs widget into the `tester`. +Future pumpResizableWidget( + WidgetTester tester, { + void Function(int)? onPlatformViewCreated, + Size? initialSize, +}) async { + await tester.pumpWidget(ResizableFromJs( + instanceId: widgetFactoryNumber, + onPlatformViewCreated: onPlatformViewCreated, + initialSize: initialSize, + )); + // Needed for JS to have time to kick-off. + await tester.pump(); + + // Return the element we just pumped + final Iterable elements = + find.byKey(Key('resizable_from_js_$widgetFactoryNumber')).evaluate(); + expect(elements, hasLength(1)); + return elements.first; +} + +class ResizableFromJs extends StatelessWidget { + ResizableFromJs({ + required this.instanceId, + this.onPlatformViewCreated, + this.initialSize, + super.key, + }) { + // ignore: avoid_dynamic_calls, undefined_prefixed_name + ui.platformViewRegistry.registerViewFactory( + 'resizable_from_js_$instanceId', + (int viewId) { + final DomHtmlElement element = document.createElement('div'); + element.setAttribute('style', + 'width: 100%; height: 100%; overflow: hidden; background: red;'); + element.id = 'test_element_$viewId'; + return element; + }, + ); + } + + final int instanceId; + final void Function(int)? onPlatformViewCreated; + final Size? initialSize; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: FlexHtmlElementView( + viewType: 'resizable_from_js_$instanceId', + key: Key('resizable_from_js_$instanceId'), + onPlatformViewCreated: onPlatformViewCreated, + initialSize: initialSize ?? const Size(640, 480), + ), + ), + ), + ); + } +} + +/// Resizes `resizable` to `size`. +void resize(DomHtmlElement resizable, Size size) { + resizable.setAttribute('style', + 'width: ${size.width}px; height: ${size.height}px; background: #fabada'); +} + +/// Returns a function that can be used to inject `element` in `onPlatformViewCreated` callbacks. +void Function(int) injectElement(DomHtmlElement element) { + return (int viewId) { + final DomHtmlElement root = + document.querySelector('#test_element_$viewId')!; + root.appendChild(element); + }; +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart index 3dcc192e8aa..fdea1b7f7ca 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_test/flutter_test.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; @@ -57,10 +59,11 @@ void main() { late MockGisSdkClient mockGis; setUp(() { + mockGis = MockGisSdkClient(); plugin = GoogleSignInPlugin( debugOverrideLoader: true, + debugOverrideGisSdkClient: mockGis, ); - mockGis = MockGisSdkClient(); }); testWidgets('initializes if all is OK', (_) async { @@ -69,7 +72,6 @@ void main() { clientId: 'some-non-null-client-id', scopes: ['ok1', 'ok2', 'ok3'], ), - overrideClient: mockGis, ); expect(plugin.initialized, completes); @@ -79,7 +81,6 @@ void main() { expect(() async { await plugin.initWithParams( const SignInInitParameters(), - overrideClient: mockGis, ); }, throwsAssertionError); }); @@ -91,7 +92,6 @@ void main() { clientId: 'some-non-null-client-id', serverClientId: 'unexpected-non-null-client-id', ), - overrideClient: mockGis, ); }, throwsAssertionError); }); @@ -103,7 +103,6 @@ void main() { clientId: 'some-non-null-client-id', scopes: ['ok1', 'ok2', 'not ok', 'ok3'], ), - overrideClient: mockGis, ); }, throwsAssertionError); }); @@ -140,6 +139,10 @@ void main() { expect(() async { await plugin.requestScopes([]); }, throwsStateError); + + expect(() async { + await plugin.canAccessScopes([]); + }, throwsStateError); }); }); @@ -152,37 +155,38 @@ void main() { ); setUp(() { + mockGis = MockGisSdkClient(); plugin = GoogleSignInPlugin( debugOverrideLoader: true, + debugOverrideGisSdkClient: mockGis, ); - mockGis = MockGisSdkClient(); }); group('signInSilently', () { setUp(() { - plugin.initWithParams(options, overrideClient: mockGis); + plugin.initWithParams(options); }); - testWidgets('always returns null, regardless of GIS response', (_) async { + testWidgets('returns the GIS response', (_) async { final GoogleSignInUserData someUser = extractUserData(person)!; mockito .when(mockGis.signInSilently()) - .thenAnswer((_) => Future.value(someUser)); + .thenAnswer((_) => Future.value(someUser)); - expect(plugin.signInSilently(), completion(isNull)); + expect(await plugin.signInSilently(), someUser); mockito .when(mockGis.signInSilently()) .thenAnswer((_) => Future.value()); - expect(plugin.signInSilently(), completion(isNull)); + expect(await plugin.signInSilently(), isNull); }); }); group('signIn', () { setUp(() { - plugin.initWithParams(options, overrideClient: mockGis); + plugin.initWithParams(options); }); testWidgets('returns the signed-in user', (_) async { @@ -215,5 +219,65 @@ void main() { } }); }); + + group('canAccessScopes', () { + const String someAccessToken = '50m3_4cc35_70k3n'; + const List scopes = ['scope1', 'scope2']; + + setUp(() { + plugin.initWithParams(options); + }); + + testWidgets('passes-through call to gis client', (_) async { + mockito + .when( + mockGis.canAccessScopes(mockito.captureAny, mockito.captureAny), + ) + .thenAnswer((_) => Future.value(true)); + + final bool canAccess = + await plugin.canAccessScopes(scopes, accessToken: someAccessToken); + + final List arguments = mockito + .verify( + mockGis.canAccessScopes(mockito.captureAny, mockito.captureAny), + ) + .captured; + + expect(canAccess, isTrue); + + expect(arguments.first, scopes); + expect(arguments.elementAt(1), someAccessToken); + }); + }); + }); + + group('userDataEvents', () { + final StreamController controller = + StreamController.broadcast(); + late GoogleSignInPlugin plugin; + + setUp(() { + plugin = GoogleSignInPlugin( + debugOverrideLoader: true, + debugOverrideUserDataController: controller, + ); + }); + + testWidgets('accepts async user data events from GIS.', (_) async { + final Future data = plugin.userDataEvents!.first; + + final GoogleSignInUserData expected = extractUserData(person)!; + controller.add(expected); + + expect(await data, expected, + reason: 'Sign-in events should be propagated'); + + final Future more = plugin.userDataEvents!.first; + controller.add(null); + + expect(await more, isNull, + reason: 'Sign-out events can also be propagated'); + }); }); } diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart index b60dac9d4b9..f6abd3fcc08 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart @@ -7,6 +7,7 @@ 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/button_configuration.dart' as _i5; import 'package:google_sign_in_web/src/gis_client.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; @@ -47,6 +48,22 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { _i4.Future<_i2.GoogleSignInUserData?>.value(), ) as _i4.Future<_i2.GoogleSignInUserData?>); @override + _i4.Future renderButton( + Object? parent, + _i5.GSIButtonConfiguration? options, + ) => + (super.noSuchMethod( + Invocation.method( + #renderButton, + [ + parent, + options, + ], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( Invocation.method( #signIn, @@ -122,4 +139,20 @@ class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { returnValue: _i4.Future.value(false), returnValueForMissingStub: _i4.Future.value(false), ) as _i4.Future); + @override + _i4.Future canAccessScopes( + List? scopes, + String? accessToken, + ) => + (super.noSuchMethod( + Invocation.method( + #canAccessScopes, + [ + scopes, + accessToken, + ], + ), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.Future); } 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 index f7d3152a7e6..58bbe2138d9 100644 --- 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 @@ -24,6 +24,9 @@ extension DomHtmlDocumentExtension on DomHtmlDocument { /// document.createElement external DomHtmlElement createElement(String tagName); + + /// document.querySelector + external DomHtmlElement? querySelector(String selector); } /// An instance of an HTMLElement @@ -33,9 +36,17 @@ abstract class DomHtmlElement {} /// (Some) methods of HtmlElement extension DomHtmlElementExtension on DomHtmlElement { + external String get id; + external set id(String id); + external set innerText(String innerText); + external String? getAttribute(String attributeName); + /// Node.appendChild external DomHtmlElement appendChild(DomHtmlElement child); + /// Element.setAttribute + external void setAttribute(String name, Object value); + /// Element.remove external void remove(); } diff --git a/packages/google_sign_in/google_sign_in_web/example/lib/button_tester.dart b/packages/google_sign_in/google_sign_in_web/example/lib/button_tester.dart new file mode 100644 index 00000000000..4e23b0580c3 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/lib/button_tester.dart @@ -0,0 +1,103 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:google_sign_in_web/google_sign_in_web.dart'; + +import 'src/button_configuration_column.dart'; + +// The instance of the plugin is automatically created by Flutter before calling +// our main code, let's grab it directly from the Platform interface of the plugin. +GoogleSignInPlugin _plugin = + GoogleSignInPlatform.instance as GoogleSignInPlugin; + +Future main() async { + await _plugin.initWithParams(const SignInInitParameters( + clientId: 'your-client_id.apps.googleusercontent.com', + )); + runApp( + const MaterialApp( + title: 'Sign in with Google button Tester', + home: ButtonConfiguratorDemo(), + ), + ); +} + +/// The home widget of this app. +class ButtonConfiguratorDemo extends StatefulWidget { + /// A const constructor for the Widget. + const ButtonConfiguratorDemo({super.key}); + + @override + State createState() => _ButtonConfiguratorState(); +} + +class _ButtonConfiguratorState extends State { + GoogleSignInUserData? _userData; // sign-in information? + GSIButtonConfiguration? _buttonConfiguration; // button configuration + + @override + void initState() { + super.initState(); + _plugin.userDataEvents?.listen((GoogleSignInUserData? userData) { + setState(() { + _userData = userData; + }); + }); + } + + void _handleSignOut() { + _plugin.signOut(); + setState(() { + // signOut does not broadcast through the userDataEvents, so we fake it. + _userData = null; + }); + } + + void _handleNewWebButtonConfiguration(GSIButtonConfiguration newConfig) { + setState(() { + _buttonConfiguration = newConfig; + }); + } + + Widget _buildBody() { + return Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_userData == null) + _plugin.renderButton(configuration: _buttonConfiguration), + if (_userData != null) ...[ + Text('Hello, ${_userData!.displayName}!'), + ElevatedButton( + onPressed: _handleSignOut, + child: const Text('SIGN OUT'), + ), + ] + ], + ), + ), + renderWebButtonConfiguration( + _buttonConfiguration, + onChange: _userData == null ? _handleNewWebButtonConfiguration : null, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Sign in with Google button Tester'), + ), + body: ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: _buildBody(), + )); + } +} diff --git a/packages/google_sign_in/google_sign_in_web/example/lib/src/button_configuration_column.dart b/packages/google_sign_in/google_sign_in_web/example/lib/src/button_configuration_column.dart new file mode 100644 index 00000000000..455781f0767 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/lib/src/button_configuration_column.dart @@ -0,0 +1,198 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:google_sign_in_web/google_sign_in_web.dart'; + +/// Type of the onChange function for `ButtonConfiguration`. +typedef OnWebConfigChangeFn = void Function(GSIButtonConfiguration newConfig); + +/// (Incomplete) List of the locales that can be used to configure the button. +const List availableLocales = [ + 'en_US', + 'es_ES', + 'pt_BR', + 'fr_FR', + 'it_IT', + 'de_DE', +]; + +/// Renders a Scrollable Column widget that allows the user to see (and change) a ButtonConfiguration. +Widget renderWebButtonConfiguration( + GSIButtonConfiguration? currentConfig, { + OnWebConfigChangeFn? onChange, +}) { + final ScrollController scrollController = ScrollController(); + return Scrollbar( + controller: scrollController, + thumbVisibility: true, + interactive: true, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _renderLocaleCard( + value: currentConfig?.locale, + locales: availableLocales, + onChanged: _onChanged(currentConfig, onChange), + ), + _renderMinimumWidthCard( + value: currentConfig?.minimumWidth, + max: 500, + actualMax: 400, + onChanged: _onChanged(currentConfig, onChange), + ), + _renderRadioListTileCard( + title: 'ButtonType', + values: GSIButtonType.values, + selected: currentConfig?.type, + onChanged: _onChanged(currentConfig, onChange), + ), + _renderRadioListTileCard( + title: 'ButtonShape', + values: GSIButtonShape.values, + selected: currentConfig?.shape, + onChanged: _onChanged(currentConfig, onChange), + ), + _renderRadioListTileCard( + title: 'ButtonSize', + values: GSIButtonSize.values, + selected: currentConfig?.size, + onChanged: _onChanged(currentConfig, onChange), + ), + _renderRadioListTileCard( + title: 'ButtonTheme', + values: GSIButtonTheme.values, + selected: currentConfig?.theme, + onChanged: _onChanged(currentConfig, onChange), + ), + _renderRadioListTileCard( + title: 'ButtonText', + values: GSIButtonText.values, + selected: currentConfig?.text, + onChanged: _onChanged(currentConfig, onChange), + ), + _renderRadioListTileCard( + title: 'ButtonLogoAlignment', + values: GSIButtonLogoAlignment.values, + selected: currentConfig?.logoAlignment, + onChanged: + _onChanged(currentConfig, onChange), + ), + ], + ))); +} + +/// Renders a Config card with a dropdown of locales. +Widget _renderLocaleCard( + {String? value, + required List locales, + void Function(String?)? onChanged}) { + return _renderConfigCard(title: 'locale', children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: DropdownButton( + items: locales + .map((String locale) => DropdownMenuItem( + value: locale, + child: Text(locale), + )) + .toList(), + value: value, + onChanged: onChanged, + isExpanded: true, + // padding: const EdgeInsets.symmetric(horizontal: 16), // Prefer padding here! + ), + ), + ]); +} + +/// Renders a card with a slider +Widget _renderMinimumWidthCard( + {double? value, + double min = 0, + double actualMax = 10, + double max = 11, + void Function(double)? onChanged}) { + return _renderConfigCard(title: 'minimumWidth', children: [ + Slider( + label: value?.toString() ?? 'null', + value: value ?? 0, + min: min, + max: max, + secondaryTrackValue: actualMax, + onChanged: onChanged, + divisions: 10, + ) + ]); +} + +/// Renders a Config Card with the values of an Enum as radio buttons. +Widget _renderRadioListTileCard( + {required String title, + required List values, + T? selected, + void Function(T?)? onChanged}) { + return _renderConfigCard( + title: title, + children: values + .map((T value) => RadioListTile( + value: value, + groupValue: selected, + onChanged: onChanged, + selected: value == selected, + title: Text(value.name), + dense: true, + )) + .toList()); +} + +/// Renders a Card where we render some `children` that change config. +Widget _renderConfigCard( + {required String title, required List children}) { + return Container( + constraints: const BoxConstraints(maxWidth: 200), + child: Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + dense: true, + ), + ...children, + ], + ))); +} + +/// Sets a `value` into an `old` configuration object. +GSIButtonConfiguration _copyConfigWith( + GSIButtonConfiguration? old, Object? value) { + return GSIButtonConfiguration( + locale: value is String ? value : old?.locale, + minimumWidth: + value is double ? (value == 0 ? null : value) : old?.minimumWidth, + type: value is GSIButtonType ? value : old?.type, + theme: value is GSIButtonTheme ? value : old?.theme, + size: value is GSIButtonSize ? value : old?.size, + text: value is GSIButtonText ? value : old?.text, + shape: value is GSIButtonShape ? value : old?.shape, + logoAlignment: value is GSIButtonLogoAlignment ? value : old?.logoAlignment, + ); +} + +/// Returns a function that modifies the `current` configuration with a `value`, then calls `fn` with it. +Function(T?)? _onChanged( + GSIButtonConfiguration? current, OnWebConfigChangeFn? fn) { + if (fn == null) { + return null; + } + return (T? value) { + fn(_copyConfigWith(current, value)); + }; +} 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 bc6115509cc..098a481dc17 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 @@ -6,19 +6,32 @@ environment: flutter: ">=3.0.0" dependencies: + cupertino_icons: ^1.0.2 flutter: sdk: flutter + google_identity_services_web: ^0.2.0 + google_sign_in_platform_interface: ^2.2.0 google_sign_in_web: path: ../ +flutter: + uses-material-design: true + +dependency_overrides: + google_identity_services_web: + git: + url: https://github.com/ditman/flutter-packages.git + ref: gis-web-fix-render-button-api + path: packages/google_identity_services_web + google_sign_in_platform_interface: + path: ../../google_sign_in_platform_interface + 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 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 827b17ca5b4..5b4976e761e 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 @@ -4,15 +4,32 @@ import 'dart:async'; import 'dart:html' as html; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart' show visibleForTesting, kDebugMode; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter/widgets.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:google_identity_services_web/loader.dart' as loader; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'src/button_configuration.dart' show GSIButtonConfiguration; +import 'src/dom.dart'; +import 'src/flexible_size_html_element_view.dart'; import 'src/gis_client.dart'; +// Export the configuration types for the renderButton method. +export 'src/button_configuration.dart' + show + GSIButtonConfiguration, + GSIButtonLogoAlignment, + GSIButtonShape, + GSIButtonSize, + GSIButtonText, + GSIButtonTheme, + GSIButtonType; + /// The `name` of the meta-tag to define a ClientID in HTML. const String clientIdMetaName = 'google-signin-client_id'; @@ -28,11 +45,26 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { /// background. /// /// The plugin is completely initialized when [initialized] completed. - GoogleSignInPlugin({@visibleForTesting bool debugOverrideLoader = false}) { + /// + /// For tests, the plugin can skip its loading process with [debugOverrideLoader], + /// and the implementation of the underlying GIS SDK client through [debugOverrideGisSdkClient]. + GoogleSignInPlugin({ + @visibleForTesting + bool debugOverrideLoader = false, + @visibleForTesting + GisSdkClient? debugOverrideGisSdkClient, + @visibleForTesting + StreamController? + debugOverrideUserDataController, + }) : _gisClient = debugOverrideGisSdkClient, + _userDataController = debugOverrideUserDataController ?? + StreamController.broadcast() { autoDetectedClientId = html .querySelector(clientIdMetaSelector) ?.getAttribute(clientIdAttributeName); + _registerButtonFactory(); + if (debugOverrideLoader) { _jsSdkLoadedFuture = Future.value(true); } else { @@ -40,11 +72,18 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } } + // A future that completes when the JS loader is done. late Future _jsSdkLoadedFuture; + // A future that completes when the `init` call is done. + final Completer _initDone = Completer(); + // A (synchronous) marker to assert that `init` has been called. bool _isInitCalled = false; + // A StreamController to communicate status changes from the GisSdkClient. + final StreamController _userDataController; + // The instance of [GisSdkClient] backing the plugin. - late GisSdkClient _gisClient; + 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 @@ -62,7 +101,8 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { @visibleForTesting Future get initialized { _assertIsInitCalled(); - return _jsSdkLoadedFuture; + return Future.wait( + >[_jsSdkLoadedFuture, _initDone.future]); } /// Stores the client ID if it was set in a meta-tag of the page. @@ -90,10 +130,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } @override - Future initWithParams( - SignInInitParameters params, { - @visibleForTesting GisSdkClient? overrideClient, - }) async { + Future initWithParams(SignInInitParameters params) async { final String? appClientId = params.clientId ?? autoDetectedClientId; assert( appClientId != null, @@ -110,29 +147,66 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { 'Check https://developers.google.com/identity/protocols/googlescopes ' 'for a list of valid OAuth 2.0 scopes.'); + _isInitCalled = true; // Mark `init` ASAP before going async. + await _jsSdkLoadedFuture; - _gisClient = overrideClient ?? - GisSdkClient( - clientId: appClientId!, - hostedDomain: params.hostedDomain, - initialScopes: List.from(params.scopes), - loggingEnabled: kDebugMode, - ); + _gisClient ??= GisSdkClient( + clientId: appClientId!, + hostedDomain: params.hostedDomain, + initialScopes: List.from(params.scopes), + userDataController: _userDataController, + loggingEnabled: kDebugMode, + ); + + _initDone.complete(); // Signal that `init` is fully done. + } + + // Register a factory for the Button HtmlElementView. + void _registerButtonFactory() { + // ignore: avoid_dynamic_calls, undefined_prefixed_name + ui.platformViewRegistry.registerViewFactory( + 'gsi_login_button', + (int viewId) { + final DomElement element = createDomElement('div'); + element.setAttribute('style', + 'width: 100%; height: 100%; overflow: hidden; display: flex; flex-wrap: wrap; align-content: center; justify-content: center;'); + element.id = 'sign_in_button_$viewId'; + return element; + }, + ); + } - _isInitCalled = true; + /// Render the GSI button web experience. + Widget renderButton({GSIButtonConfiguration? configuration}) { + final GSIButtonConfiguration config = + configuration ?? GSIButtonConfiguration(); + return FutureBuilder( + key: Key(config.hashCode.toString()), + future: initialized, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return FlexHtmlElementView( + viewType: 'gsi_login_button', + onPlatformViewCreated: (int viewId) { + final DomElement? element = + domDocument.querySelector('#sign_in_button_$viewId'); + assert(element != null, + 'Cannot render GSI button. DOM is not ready!'); + _gisClient!.renderButton(element!, config); + }); + } + return const Text('Getting ready'); + }, + ); } @override Future signInSilently() async { await initialized; - // 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); + // The new user is being injected from the `userDataEvents` Stream. + return _gisClient!.signInSilently(); //.then((_) => null); } @override @@ -146,7 +220,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { // This method will synthesize authentication information from the People API // if needed (or use the last identity seen from signInSilently). try { - return _gisClient.signIn(); + return _gisClient!.signIn(); } catch (reason) { throw PlatformException( code: reason.toString(), @@ -164,41 +238,53 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { }) async { await initialized; - return _gisClient.getTokens(); + return _gisClient!.getTokens(); } @override Future signOut() async { await initialized; - _gisClient.signOut(); + _gisClient!.signOut(); } @override Future disconnect() async { await initialized; - _gisClient.disconnect(); + _gisClient!.disconnect(); } @override Future isSignedIn() async { await initialized; - return _gisClient.isSignedIn(); + return _gisClient!.isSignedIn(); } @override Future clearAuthCache({required String token}) async { await initialized; - _gisClient.clearAuthCache(); + _gisClient!.clearAuthCache(); } @override Future requestScopes(List scopes) async { await initialized; - return _gisClient.requestScopes(scopes); + return _gisClient!.requestScopes(scopes); } + + @override + Future canAccessScopes(List scopes, + {String? accessToken}) async { + await initialized; + + return _gisClient!.canAccessScopes(scopes, accessToken); + } + + @override + Stream? get userDataEvents => + _userDataController.stream; } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/button_configuration.dart b/packages/google_sign_in/google_sign_in_web/lib/src/button_configuration.dart new file mode 100644 index 00000000000..28db05ebe00 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/src/button_configuration.dart @@ -0,0 +1,202 @@ +// 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' as id; +import 'package:js/js_util.dart' as js_util; + +/// Converts user-facing `GisButtonConfiguration` into the JS-Interop `id.GsiButtonConfiguration`. +id.GsiButtonConfiguration? convertButtonConfiguration( + GSIButtonConfiguration? config, +) { + if (config == null) { + return null; + } + return js_util.jsify({ + if (config.type != null) 'type': _idType[config.type], + if (config.theme != null) 'theme': _idTheme[config.theme], + if (config.size != null) 'size': _idSize[config.size], + if (config.text != null) 'text': _idText[config.text], + if (config.shape != null) 'shape': _idShape[config.shape], + if (config.logoAlignment != null) + 'logo_alignment': _idLogoAlignment[config.logoAlignment], + if (config.minimumWidth != null) 'width': config.minimumWidth, + if (config.locale != null) 'locale': config.locale, + }) as id.GsiButtonConfiguration; +} + +/// A class to configure the Google Sign-In Button for web. +/// +/// https://developers.google.com/identity/gsi/web/reference/js-reference#GsiButtonConfiguration +class GSIButtonConfiguration { + /// Constructs a button configuration object. + GSIButtonConfiguration({ + this.type, + this.theme, + this.size, + this.text, + this.shape, + this.logoAlignment, + this.minimumWidth, + this.locale, + }) : assert(minimumWidth == null || minimumWidth > 0); + + /// The button type: icon, or standard button. + final GSIButtonType? type; + + /// The button theme. + /// + /// For example, filledBlue or filledBlack. + final GSIButtonTheme? theme; + + /// The button size. + /// + /// For example, small or large. + final GSIButtonSize? size; + + /// The button text. + /// + /// For example "Sign in with Google" or "Sign up with Google". + final GSIButtonText? text; + + /// The button shape. + /// + /// For example, rectangular or circular. + final GSIButtonShape? shape; + + /// The Google logo alignment: left or center. + final GSIButtonLogoAlignment? logoAlignment; + + /// The minimum button width, in pixels. + /// + /// The maximum width is 400 pixels. + final double? minimumWidth; + + /// The pre-set locale of the button text. + /// + /// If not set, the browser's default locale or the Google session user's + /// preference is used. + /// + /// Different users might see different versions of localized buttons, possibly + /// with different sizes. + final String? locale; +} + +/// The type of button to be rendered. +/// +/// https://developers.google.com/identity/gsi/web/reference/js-reference#type +enum GSIButtonType { + /// A button with text or personalized information. + standard, + + /// An icon button without text. + icon; +} + +const Map _idType = + { + GSIButtonType.icon: id.ButtonType.icon, + GSIButtonType.standard: id.ButtonType.standard, +}; + +/// The theme of the button to be rendered. +/// +/// https://developers.google.com/identity/gsi/web/reference/js-reference#theme +enum GSIButtonTheme { + /// A standard button theme. + outline, + + /// A blue-filled button theme. + filledBlue, + + /// A black-filled button theme. + filledBlack; +} + +const Map _idTheme = + { + GSIButtonTheme.outline: id.ButtonTheme.outline, + GSIButtonTheme.filledBlue: id.ButtonTheme.filled_blue, + GSIButtonTheme.filledBlack: id.ButtonTheme.filled_black, +}; + +/// The size of the button to be rendered. +/// +/// https://developers.google.com/identity/gsi/web/reference/js-reference#size +enum GSIButtonSize { + /// A large button (about 40px tall). + large, + + /// A medium-sized button (about 32px tall). + medium, + + /// A small button (about 20px tall). + small; +} + +const Map _idSize = + { + GSIButtonSize.large: id.ButtonSize.large, + GSIButtonSize.medium: id.ButtonSize.medium, + GSIButtonSize.small: id.ButtonSize.small, +}; + +/// The button text. +/// +/// https://developers.google.com/identity/gsi/web/reference/js-reference#text +enum GSIButtonText { + /// The button text is "Sign in with Google". + signinWith, + + /// The button text is "Sign up with Google". + signupWith, + + /// The button text is "Continue with Google". + continueWith, + + /// The button text is "Sign in". + signin; +} + +const Map _idText = + { + GSIButtonText.signinWith: id.ButtonText.signin_with, + GSIButtonText.signupWith: id.ButtonText.signup_with, + GSIButtonText.continueWith: id.ButtonText.continue_with, + GSIButtonText.signin: id.ButtonText.signin, +}; + +/// The button shape. +/// +/// https://developers.google.com/identity/gsi/web/reference/js-reference#shape +enum GSIButtonShape { + /// The rectangular-shaped button. + rectangular, + + /// The circle-shaped button. + pill; + // Does this need circle and square? +} + +const Map _idShape = + { + GSIButtonShape.rectangular: id.ButtonShape.rectangular, + GSIButtonShape.pill: id.ButtonShape.pill, +}; + +/// The alignment of the Google logo. The default value is left. This attribute only applies to the standard button type. +/// +/// https://developers.google.com/identity/gsi/web/reference/js-reference#logo_alignment +enum GSIButtonLogoAlignment { + /// Left-aligns the Google logo. + left, + + /// Center-aligns the Google logo. + center; +} + +const Map _idLogoAlignment = + { + GSIButtonLogoAlignment.left: id.ButtonLogoAlignment.left, + GSIButtonLogoAlignment.center: id.ButtonLogoAlignment.center, +}; diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/dom.dart b/packages/google_sign_in/google_sign_in_web/lib/src/dom.dart new file mode 100644 index 00000000000..08a15c08351 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/src/dom.dart @@ -0,0 +1,209 @@ +// 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. + +// ignore_for_file: public_member_api_docs + +import 'package:js/js.dart'; +import 'package:js/js_util.dart' as js_util; + +@JS() +@staticInterop +class DomConsole {} + +extension DomConsoleExtension on DomConsole { + void debug(String message, [List? more]) => + js_util.callMethod(this, 'debug', [message, ...?more]); + void info(String message, [List? more]) => + js_util.callMethod(this, 'info', [message, ...?more]); +} + +@JS() +@staticInterop +class DomWindow {} + +@JS() +@staticInterop +class DomDocument {} + +extension DomDocumentExtension on DomDocument { + external DomElement? querySelector(String selectors); + DomElement createElement(String name, [Object? options]) => + js_util.callMethod(this, 'createElement', + [name, if (options != null) options]) as DomElement; +} + +@JS() +@staticInterop +class DomElement {} + +extension DomElementExtension on DomElement { + external String get id; + external set id(String id); + external String? getAttribute(String attributeName); + external void remove(); + external void setAttribute(String name, Object value); + external void removeAttribute(String name); + external set tabIndex(double? value); + external double? get tabIndex; + external set className(String value); + external String get className; + external bool hasAttribute(String name); + external DomElement? get firstChild; + external DomElement? querySelector(String selectors); + external String get tagName; +} + +@JS('window') +external DomWindow get domWindow; + +@JS('document') +external DomDocument get domDocument; + +@JS('console') +external DomConsole get domConsole; + +DomElement createDomElement(String tag) => domDocument.createElement(tag); + +/// DOM Observers: Mutation and Size +typedef DomMutationCallbackFn = void Function( + List mutation, DomMutationObserver observer); + +@JS() +@staticInterop +class DomMutationObserver {} + +DomMutationObserver createDomMutationObserver(DomMutationCallbackFn fn) => + domCallConstructorString('MutationObserver', [ + allowInterop( + (List mutations, DomMutationObserver observer) { + fn(mutations.cast(), observer); + }, + ) + ])! as DomMutationObserver; + +extension DomMutationObserverExtension on DomMutationObserver { + external void disconnect(); + void observe(DomElement target, + {bool? childList, + bool? attributes, + bool? subtree, + List? attributeFilter}) { + final Map options = { + if (childList != null) 'childList': childList, + if (attributes != null) 'attributes': attributes, + if (subtree != null) 'subtree': subtree, + if (attributeFilter != null) 'attributeFilter': attributeFilter + }; + return js_util + .callMethod(this, 'observe', [target, js_util.jsify(options)]); + } +} + +@JS() +@staticInterop +class DomMutationRecord {} + +extension DomMutationRecordExtension on DomMutationRecord { + external List? get addedNodes; + external List? get removedNodes; + external String? get attributeName; + external String? get type; +} + +/// ResizeObserver JS binding. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver +@JS() +@staticInterop +abstract class DomResizeObserver {} + +/// Creates a DomResizeObserver with a callback. +/// +/// Internally converts the `List` of entries into the expected +/// `List` +DomResizeObserver? createDomResizeObserver(DomResizeObserverCallbackFn fn) { + return domCallConstructorString('ResizeObserver', [ + allowInterop((List entries, DomResizeObserver observer) { + fn(entries.cast(), observer); + }), + ]) as DomResizeObserver?; +} + +/// ResizeObserver instance methods. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#instance_methods +extension DomResizeObserverExtension on DomResizeObserver { + external void disconnect(); + external void observe(DomElement target, + [DomResizeObserverObserveOptions options]); + external void unobserve(DomElement target); +} + +/// Options object passed to the `observe` method of a [DomResizeObserver]. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#parameters +@JS() +@staticInterop +@anonymous +abstract class DomResizeObserverObserveOptions { + external factory DomResizeObserverObserveOptions({ + String box, + }); +} + +/// Type of the function used to create a Resize Observer. +typedef DomResizeObserverCallbackFn = void Function( + List entries, DomResizeObserver observer); + +/// The object passed to the [DomResizeObserverCallbackFn], which allows access to the new dimensions of the observed element. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry +@JS() +@staticInterop +abstract class DomResizeObserverEntry {} + +/// ResizeObserverEntry instance properties. +/// +/// See: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry#instance_properties +extension DomResizeObserverEntryExtension on DomResizeObserverEntry { + /// A DOMRectReadOnly object containing the new size of the observed element when the callback is run. + /// + /// Note that this is better supported than the above two properties, but it + /// is left over from an earlier implementation of the Resize Observer API, is + /// still included in the spec for web compat reasons, and may be deprecated + /// in future versions. + external DomRectReadOnly get contentRect; + external DomElement get target; + // Some more future getters: + // + // borderBoxSize + // contentBoxSize + // devicePixelContentBoxSize +} + +@JS() +@staticInterop +class DomRectReadOnly {} + +extension DomRectReadOnlyExtension on DomRectReadOnly { + external double get x; + external double get y; + external double get width; + external double get height; + external double get top; + external double get right; + external double get bottom; + external double get left; +} + +Object? domGetConstructor(String constructorName) => + js_util.getProperty(domWindow, constructorName); + +T? domCallConstructorString(String constructorName, List args) { + final Object? constructor = domGetConstructor(constructorName); + if (constructor == null) { + return null; + } + return js_util.callConstructor(constructor, args); +} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/flexible_size_html_element_view.dart b/packages/google_sign_in/google_sign_in_web/lib/src/flexible_size_html_element_view.dart new file mode 100644 index 00000000000..af91e0030a9 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/src/flexible_size_html_element_view.dart @@ -0,0 +1,140 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'dom.dart'; + +/// An HTMLElementView widget that resizes with its contents. +class FlexHtmlElementView extends StatefulWidget { + /// Constructor + const FlexHtmlElementView({ + super.key, + required this.viewType, + this.onPlatformViewCreated, + this.initialSize, + }); + + /// See [HtmlElementView.viewType]. + final String viewType; + + /// See [HtmlElementView.onPlatformViewCreated]. + final PlatformViewCreatedCallback? onPlatformViewCreated; + + /// The initial Size for the widget, before it starts tracking its contents. + final Size? initialSize; + + @override + State createState() => _FlexHtmlElementView(); +} + +class _FlexHtmlElementView extends State { + /// The last measured size of the watched element. + Size? _lastReportedSize; + + /// Watches for changes being made to the DOM tree. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver + DomMutationObserver? _mutationObserver; + + /// Reports changes to the dimensions of an Element's content box. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API + DomResizeObserver? _resizeObserver; + + @override + void dispose() { + // Disconnect the observers + _mutationObserver?.disconnect(); + _resizeObserver?.disconnect(); + super.dispose(); + } + + /// Update the state with the new `size`, if needed. + void _doResize(Size size) { + if (size != _lastReportedSize) { + domConsole.debug( + 'Resizing', [widget.viewType, size.width, size.height]); + setState(() { + _lastReportedSize = size; + }); + } + } + + /// The function called whenever an observed resize occurs. + void _onResizeEntries( + List resizes, + DomResizeObserver observer, + ) { + final DomRectReadOnly rect = resizes.last.contentRect; + if (rect.width > 0 && rect.height > 0) { + _doResize(Size(rect.width, rect.height)); + } + } + + /// A function which will be called on each DOM change that qualifies given the observed node and options. + /// + /// When mutations are received, this function attaches a Resize Observer to + /// the first child of the mutation, which will drive + void _onMutationRecords( + List mutations, + DomMutationObserver observer, + ) { + for (final DomMutationRecord mutation in mutations) { + if (mutation.addedNodes != null) { + final DomElement? element = _locateSizeProvider(mutation.addedNodes!); + if (element != null) { + _resizeObserver = createDomResizeObserver(_onResizeEntries); + _resizeObserver?.observe(element); + // Stop looking at other mutations + observer.disconnect(); + return; + } + } + } + } + + /// Registers a MutationObserver on the root element of the HtmlElementView. + void _registerListeners(DomElement? root) { + assert(root != null, 'DOM is not ready for the FlexHtmlElementView'); + _mutationObserver = createDomMutationObserver(_onMutationRecords); + // Monitor the size of the child element, whenever it's created... + _mutationObserver!.observe( + root!, + childList: true, + ); //subtree: true); + } + + @override + Widget build(BuildContext context) { + return SizedBox.fromSize( + size: _lastReportedSize ?? widget.initialSize ?? const Size(1, 1), + child: HtmlElementView( + viewType: widget.viewType, + onPlatformViewCreated: (int viewId) async { + _registerListeners(_locatePlatformViewRoot(viewId)); + if (widget.onPlatformViewCreated != null) { + widget.onPlatformViewCreated!(viewId); + } + }), + ); + } +} + +/// Locates which of the elements will act as the size provider. +/// +/// The `elements` list should contain a single element: the only child of the +/// element returned by `_locatePlatformViewRoot`. +DomElement? _locateSizeProvider(List elements) { + return elements.first; +} + +/// Finds the root element of a platform view by its `viewId`. +/// +/// This element matches the one returned by the registered platform view factory. +DomElement? _locatePlatformViewRoot(int viewId) { + return domDocument + .querySelector('flt-platform-view[slot\$="-$viewId"] :first-child'); +} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart index 3815322e690..612ac5a2ce9 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart @@ -11,6 +11,9 @@ import 'package:google_sign_in_platform_interface/google_sign_in_platform_interf import 'package:js/js.dart'; import 'package:js/js_util.dart'; +import 'button_configuration.dart' + show GSIButtonConfiguration, convertButtonConfiguration; +import 'dom.dart'; import 'people.dart' as people; import 'utils.dart' as utils; @@ -22,10 +25,13 @@ class GisSdkClient { GisSdkClient({ required List initialScopes, required String clientId, + required StreamController userDataController, bool loggingEnabled = false, String? hostedDomain, - }) : _initialScopes = initialScopes { - if (loggingEnabled) { + }) : _initialScopes = initialScopes, + _loggingEnabled = loggingEnabled, + _userDataEventsController = userDataController { + if (_loggingEnabled) { id.setLogLevel('debug'); } // Configure the Stream objects that are going to be used by the clients. @@ -45,20 +51,51 @@ class GisSdkClient { ); } + void _logIfEnabled(String message, [List? more]) { + if (_loggingEnabled) { + domConsole.info('[google_sign_in_web] $message', more); + } + } + // 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) { + _logIfEnabled('Error on TokenResponse:', [error.toString()]); _lastTokenResponse = null; }); + _credentialResponses.stream.listen((CredentialResponse response) { _lastCredentialResponse = response; }, onError: (Object error) { + _logIfEnabled('Error on CredentialResponse:', [error.toString()]); _lastCredentialResponse = null; }); + + // In the future, the userDataEvents could propagate null userDataEvents too. + _credentialResponses.stream + .map(utils.gisResponsesToUserData) + .handleError(_cleanCredentialResponsesStreamErrors) + .forEach(_userDataEventsController.add); + } + + // This function handles the errors that on the _credentialResponses Stream. + // + // Most of the time, these errors are part of the flow (like when One Tap UX + // cannot be rendered), and the stream of userDataEvents doesn't care about + // them. + // + // (This has been separated to a function so the _configureStreams formatting + // looks a little bit better) + void _cleanCredentialResponsesStreamErrors(Object error) { + _logIfEnabled( + 'Removing error from `userDataEvents`:', + [error.toString()], + ); } // Initializes the `id` SDK for the silent-sign in (authentication) client. @@ -185,6 +222,14 @@ class GisSdkClient { } } + /// Calls `id.renderButton` on [parent] with the given [options]. + Future renderButton( + Object parent, + GSIButtonConfiguration options, + ) async { + return id.renderButton(parent, convertButtonConfiguration(options)!); + } + /// Starts an oauth2 "implicit" flow to authorize requests. /// /// The new GIS SDK does not return user authentication from this flow, so: @@ -273,7 +318,14 @@ class GisSdkClient { /// /// Keeps the previously granted scopes. Future requestScopes(List scopes) async { + // If we already know the user, use their `email` as a `hint`, so they don't + // have to pick their user again in the Authorization popup. + final GoogleSignInUserData? knownUser = + utils.gisResponsesToUserData(_lastCredentialResponse); + _tokenClient.requestAccessToken(OverridableTokenClientConfig( + prompt: knownUser == null ? 'select_account' : '', + hint: knownUser?.email, scope: scopes.join(' '), include_granted_scopes: true, )); @@ -283,6 +335,22 @@ class GisSdkClient { return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); } + /// Checks if the passed-in `accessToken` can access all `scopes`. + /// + /// This validates that the `accessToken` is the same as the last seen + /// token response, and uses that response to check if permissions are + /// still granted. + Future canAccessScopes(List scopes, String? accessToken) async { + if (accessToken != null && _lastTokenResponse != null) { + if (accessToken == _lastTokenResponse!.access_token) { + return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); + } + } + return false; + } + + final bool _loggingEnabled; + // The scopes initially requested by the developer. // // We store this because we might need to add more at `signIn`. If the user @@ -301,6 +369,11 @@ class GisSdkClient { CredentialResponse? _lastCredentialResponse; TokenResponse? _lastTokenResponse; + /// The StreamController onto which the GIS Client propagates user authentication events. + /// + /// This is provided by the implementation of the plugin. + final StreamController _userDataEventsController; + // If the user *authenticates* (signs in) through oauth2, the SDK doesn't return // identity information anymore, so we synthesize it by calling the PeopleAPI // (if needed) 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 92066ffacce..4ad77314a3f 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android, iOS and Web. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.11.0+2 +version: 0.11.1 environment: sdk: ">=2.17.0 <4.0.0" @@ -30,3 +30,12 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + +dependency_overrides: + google_identity_services_web: + git: + url: https://github.com/ditman/flutter-packages.git + ref: gis-web-fix-render-button-api + path: packages/google_identity_services_web + google_sign_in_platform_interface: + path: ../google_sign_in_platform_interface \ No newline at end of file