diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index d37374ac358..2b728ba9cb4 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,5 +1,12 @@ -## NEXT - +## 6.1.0 + +* Exposes the new method `canAccessScopes`. + * This method is only needed, and implemented, on the web platform. + * Other platforms will throw an `UnimplementedError`. +* Updates example app to separate Authentication from Authorization for those + platforms where scopes are not automatically granted upon `signIn` (like the web). + * When `signInSilently` is successful, it returns a User object again on the web. + * Updates README with information about these changes. * Updates minimum Flutter version to 3.3. * Aligns Dart and Flutter SDK constraints. diff --git a/packages/google_sign_in/google_sign_in/README.md b/packages/google_sign_in/google_sign_in/README.md index ac5baeae96c..154e683c376 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 @@ -114,7 +122,7 @@ Add the following import to your Dart code: import 'package:google_sign_in/google_sign_in.dart'; ``` -Initialize GoogleSignIn with the scopes you want: +Initialize `GoogleSignIn` with the scopes you want: ```dart GoogleSignIn _googleSignIn = GoogleSignIn( @@ -139,6 +147,83 @@ 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. + +If your app supports both mobile and web, read this section! + +### Checking if scopes have been granted + +Users may (or may *not*) grant all the scopes that an application requests at +Sign In. In fact, in the web, no scopes are granted by `signIn`, `silentSignIn` +or the `renderButton` widget anymore. + +Applications must be able to: + +* Detect if the authenticated user has authorized the scopes they need. +* Determine if the scopes that were granted a few minutes ago are still valid. + +There's a new method that enables the checks above, `canAccessScopes`: + +```dart +final bool isAuthorized = await _googleSignIn.canAccessScopes(scopes); +``` + +_(Only implemented in the web platform, from version 6.1.0 of this package)_ + +### Requesting more scopes when needed + +If an app determines that the user hasn't granted the scopes it requires, it +should initiate an Authorization request. (Remember that in the web platform, +this request **must be initiated from an user interaction**, like a button press). + +```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). + +### Does an app always need to check `canAccessScopes`? + +The new web SDK implicitly grant access to the `email`, `profile` and `openid` +scopes when users complete the sign-in process (either via the One Tap UX or the +Google Sign In button). + +If an app only needs an `idToken`, or only requests permissions to any/all of +the three scopes mentioned above +([OpenID Connect scopes](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect)), +it won't need to implement any additional scope handling. + +If an app needs any scope other than `email`, `profile` and `openid`, it **must** +implement a more complete scope handling, as described 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..abb587ccdf8 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,22 +2,28 @@ // 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; +import 'package:flutter/foundation.dart'; 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 +35,54 @@ 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 { + // In mobile, being authenticated means being authorized... + bool isAuthorized = account != null; + // However, in the web... + if (kIsWeb && 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 +132,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 +144,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 +177,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 f46a4df7eb8..46791b746ca 100644 --- a/packages/google_sign_in/google_sign_in/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/example/pubspec.yaml @@ -16,6 +16,8 @@ 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.4.0 + google_sign_in_web: ^0.12.0 http: ^0.13.0 dev_dependencies: 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..f3afb11c8e9 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,15 @@ class GoogleSignIn { this.clientId, this.serverClientId, this.forceCodeForRefreshToken = false, - }); + }) { + // Start initializing. + if (kIsWeb) { + // Start initializing the plugin ASAP, so the `userDataEvents` Stream for + // the web can be used without calling any other methods of the plugin + // (like `silentSignIn` or `isSignedIn`). + unawaited(_ensureInitialized()); + } + } /// Factory for creating default sign in user experience. factory GoogleSignIn.standard({ @@ -261,11 +269,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 +284,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 +293,36 @@ class GoogleSignIn { return _currentUser; } - Future _ensureInitialized() { - return _initialization ??= - GoogleSignInPlatform.instance.initWithParams(SignInInitParameters( + // Future that completes when `init` has completed 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 +447,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 3fcabdf0ffd..f2b7bdd1aa8 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.18.0 <4.0.0" @@ -24,8 +24,8 @@ dependencies: sdk: flutter google_sign_in_android: ^6.1.0 google_sign_in_ios: ^5.5.0 - google_sign_in_platform_interface: ^2.2.0 - google_sign_in_web: ^0.11.0 + google_sign_in_platform_interface: ^2.4.0 + google_sign_in_web: ^0.12.0 dev_dependencies: build_runner: ^2.1.10 @@ -43,5 +43,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 b27e3aef406..bce011e7cd9 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 @@ -165,4 +165,17 @@ class MockGoogleSignInPlatform extends _i1.Mock ), 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); }