diff --git a/packages/google_sign_in/CHANGELOG.md b/packages/google_sign_in/CHANGELOG.md index c6db6c413..f6df99f45 100644 --- a/packages/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.2.0 + +* Update `google_sign_in_platform_interface` to 3.1.0. +* Update the Tizen Device Flow implementation for the `google_sign_in` 7.2.0 API. +* Add support for client authorization requests such as `authorizationForScopes`, `authorizeScopes`, and `authorizationHeaders` for Device Flow allowed scopes. +* Update minimum Flutter and Dart versions to 3.29 and 3.7. + ## 0.1.5 * Update code format. diff --git a/packages/google_sign_in/README.md b/packages/google_sign_in/README.md index 61df280c3..0cf5fc2b5 100644 --- a/packages/google_sign_in/README.md +++ b/packages/google_sign_in/README.md @@ -10,8 +10,8 @@ This package is not an _endorsed_ implementation of `google_sign_in`. Therefore, ```yaml dependencies: - google_sign_in: ^5.4.1 - google_sign_in_tizen: ^0.1.5 + google_sign_in: ^7.2.0 + google_sign_in_tizen: ^0.2.0 ``` For detailed usage on how to use `google_sign_in`, see https://pub.dev/packages/google_sign_in#usage. @@ -28,20 +28,55 @@ You also need to add some additional code to your app to fully integrate `google ### Adding OAuth credentials -Unlike "Authorization Code Grant with PKCE", "Device Flow" requires a [client secret](https://developers.google.com/identity/protocols/oauth2/limited-input-device#step-4:-poll-googles-authorization-server) parameter during Google Sign-In. You must call `GoogleSignInTizen.setCredentials` function with your client's OAuth credentials before calling `google_sign_in`'s API. +Unlike "Authorization Code Grant with PKCE", "Device Flow" requires a [client secret](https://developers.google.com/identity/protocols/oauth2/limited-input-device#step-4:-poll-googles-authorization-server) parameter during Google Sign-In. You must call `GoogleSignInTizen.setCredentials` with your client's OAuth credentials before calling `GoogleSignIn.instance.initialize`. ```dart +import 'package:google_sign_in/google_sign_in.dart'; import 'package:google_sign_in_tizen/google_sign_in_tizen.dart'; -GoogleSignInTizen.setCredentials( - clientId: 'YOUR_CLIENT_ID', - clientSecret: 'YOUR_CLIENT_SECRET', -); +Future initializeGoogleSignIn() async { + GoogleSignInTizen.setCredentials( + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + ); + await GoogleSignIn.instance.initialize(clientId: 'YOUR_CLIENT_ID'); +} +``` + +If you pass `clientId` to `GoogleSignIn.instance.initialize`, it must match the `clientId` passed to `GoogleSignInTizen.setCredentials`. + +### Authorizing additional scopes + +`GoogleSignInAccount.authorizationClient` can be used to request client authorization tokens on Tizen for scopes allowed by Google OAuth Device Flow. See [Allowed scopes](https://developers.google.com/identity/protocols/oauth2/limited-input-device#allowedscopes) before requesting additional scopes. + +Device Flow cannot preselect or force the Google account on the secondary device. If a request is tied to an existing `GoogleSignInAccount` and the user completes the flow with a different account, the request fails with `GoogleSignInExceptionCode.userMismatch`. + +```dart +final GoogleSignInClientAuthorization authorization = await user + .authorizationClient + .authorizeScopes([ + 'https://www.googleapis.com/auth/youtube.readonly', +]); +``` + +### Server authorization tokens are not supported + +`authorizationClient.authorizeServer(...)` is not supported on Tizen because OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628#section-3.5)) does not include a server auth code in its token response — the protocol simply has no such field. Calling it throws `GoogleSignInException` with `GoogleSignInExceptionCode.providerConfigurationError`: + +```dart +try { + final GoogleSignInServerAuthorization? serverAuth = await user + .authorizationClient + .authorizeServer(scopes); +} on GoogleSignInException catch (e) { + // e.code == GoogleSignInExceptionCode.providerConfigurationError + // e.description explains why server auth code is unavailable on Tizen. +} ``` :warning: Security concerns -Storing a client secret in code is considered a bad practice as it exposes [security vulnerabilites](https://datatracker.ietf.org/doc/html/rfc8628#section-5.6), you should perform extra steps to protect your client credentials. +Storing a client secret in code is considered a bad practice as it exposes [security vulnerabilities](https://datatracker.ietf.org/doc/html/rfc8628#section-5.6), you should perform extra steps to protect your client credentials. 1. Do not upload credentials to public repositories. @@ -96,4 +131,4 @@ The `http://tizen.org/privilege/internet` privilege is required to perform netwo ## Supported devices -All devices running Tizen 5.5 or later. \ No newline at end of file +All devices running Tizen 5.5 or later. diff --git a/packages/google_sign_in/example/integration_test/google_sign_in_test.dart b/packages/google_sign_in/example/integration_test/google_sign_in_test.dart new file mode 100644 index 000000000..8a7cd7ae4 --- /dev/null +++ b/packages/google_sign_in/example/integration_test/google_sign_in_test.dart @@ -0,0 +1,25 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:google_sign_in_tizen/google_sign_in_tizen.dart'; +import 'package:google_sign_in_tizen_example/credentials.dart' as credentials; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Can initialize the plugin', (WidgetTester tester) async { + GoogleSignInTizen.setCredentials( + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + ); + + final GoogleSignIn signIn = GoogleSignIn.instance; + expect(signIn, isNotNull); + + await signIn.initialize(clientId: credentials.clientId); + }); +} diff --git a/packages/google_sign_in/example/lib/credentials.dart b/packages/google_sign_in/example/lib/credentials.dart index 23604a45b..2f8b51101 100644 --- a/packages/google_sign_in/example/lib/credentials.dart +++ b/packages/google_sign_in/example/lib/credentials.dart @@ -6,6 +6,6 @@ // on source respositories. // // Developers should fill their own credentials to run the app. -const String cliendId = +const String clientId = '87599666314-cobrkfg77lk98a6c8l3o2ntn8d9g6s6l.apps.googleusercontent.com'; const String clientSecret = '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'; diff --git a/packages/google_sign_in/example/lib/main.dart b/packages/google_sign_in/example/lib/main.dart index 1875d9750..8488c6ec0 100644 --- a/packages/google_sign_in/example/lib/main.dart +++ b/packages/google_sign_in/example/lib/main.dart @@ -2,23 +2,38 @@ // 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:google_sign_in_tizen/google_sign_in_tizen.dart'; import 'package:http/http.dart' as http; import 'credentials.dart' as credentials; +import 'src/web_wrapper.dart' as web; -GoogleSignIn _googleSignIn = GoogleSignIn(scopes: ['email', 'profile']); +/// To run this example, replace this value with your client ID, and/or +/// update the relevant configuration files, as described in the README. +String? clientId = credentials.clientId; + +/// To run this example, replace this value with your server client ID, and/or +/// update the relevant configuration files, as described in the README. +String? serverClientId; + +/// The scopes required by this application. +// #docregion CheckAuthorization +const List scopes = [ + 'https://www.googleapis.com/auth/contacts.readonly', +]; +// #enddocregion CheckAuthorization void main() { GoogleSignInTizen.setCredentials( - clientId: credentials.cliendId, + clientId: credentials.clientId, clientSecret: credentials.clientSecret, ); runApp( @@ -30,48 +45,124 @@ 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 = ''; + String _errorMessage = ''; + String _serverAuthCode = ''; @override void initState() { super.initState(); - _googleSignIn.onCurrentUserChanged.listen((GoogleSignInAccount? account) { - setState(() { - _currentUser = account; - }); - if (_currentUser != null) { - _handleGetContact(_currentUser!); - } + + // #docregion Setup + final GoogleSignIn signIn = GoogleSignIn.instance; + unawaited( + signIn.initialize(clientId: clientId, serverClientId: serverClientId).then(( + _, + ) { + signIn.authenticationEvents + .listen(_handleAuthenticationEvent) + .onError(_handleAuthenticationError); + + /// This example always uses the stream-based approach to determining + /// which UI state to show, rather than using the future returned here, + /// if any, to conditionally skip directly to the signed-in state. + signIn.attemptLightweightAuthentication(); + }), + ); + // #enddocregion Setup + } + + Future _handleAuthenticationEvent( + GoogleSignInAuthenticationEvent event, + ) async { + // #docregion CheckAuthorization + final GoogleSignInAccount? user = // ... + // #enddocregion CheckAuthorization + switch (event) { + GoogleSignInAuthenticationEventSignIn() => event.user, + GoogleSignInAuthenticationEventSignOut() => null, + }; + + // Check for existing authorization. + // #docregion CheckAuthorization + final GoogleSignInClientAuthorization? authorization = await user + ?.authorizationClient + .authorizationForScopes(scopes); + // #enddocregion CheckAuthorization + + setState(() { + _currentUser = user; + _isAuthorized = authorization != null; + _errorMessage = ''; }); - _googleSignIn.signInSilently(); + + // If the user has already granted access to the required scopes, call the + // REST API. + if (user != null && authorization != null) { + unawaited(_handleGetContact(user)); + } } + Future _handleAuthenticationError(Object e) async { + setState(() { + _currentUser = null; + _isAuthorized = false; + _errorMessage = + e is GoogleSignInException + ? _errorMessageFromSignInException(e) + : 'Unknown error: $e'; + }); + } + + // Calls the People API REST endpoint for the signed-in user to retrieve information. Future _handleGetContact(GoogleSignInAccount user) async { setState(() { _contactText = 'Loading contact info...'; }); + final Map? headers = await user.authorizationClient + .authorizationHeaders(scopes); + if (headers == null) { + setState(() { + _contactText = ''; + _errorMessage = 'Failed to construct authorization headers.'; + }); + return; + } final http.Response response = await http.get( Uri.parse( 'https://people.googleapis.com/v1/people/me/connections' '?requestMask.includeField=person.names', ), - headers: await user.authHeaders, + headers: headers, ); if (response.statusCode != 200) { - setState(() { - _contactText = 'People API gave a ${response.statusCode} ' - 'response. Check logs for details.'; - }); - print('People API ${response.statusCode} response: ${response.body}'); + if (response.statusCode == 401 || response.statusCode == 403) { + setState(() { + _isAuthorized = false; + _errorMessage = + 'People API gave a ${response.statusCode} response. ' + 'Please re-authorize access.'; + }); + } else { + print('People API ${response.statusCode} response: ${response.body}'); + setState(() { + _contactText = + 'People API gave a ${response.statusCode} ' + 'response. Check logs for details.'; + }); + } return; } final Map data = @@ -88,17 +179,22 @@ class SignInDemoState extends State { String? _pickFirstNamedContact(Map data) { final List? connections = data['connections'] as List?; - final Map? contact = connections?.firstWhere( - (dynamic contact) => (contact as Map)['names'] != null, - orElse: () => null, - ) as Map?; + final Map? contact = + connections?.firstWhere( + (dynamic contact) => + (contact as Map)['names'] != null, + orElse: () => null, + ) + as Map?; if (contact != null) { final List names = contact['names'] as List; - final Map? name = names.firstWhere( - (dynamic name) => - (name as Map)['displayName'] != null, - orElse: () => null, - ) as Map?; + final Map? name = + names.firstWhere( + (dynamic name) => + (name as Map)['displayName'] != null, + orElse: () => null, + ) + as Map?; if (name != null) { return name['displayName'] as String?; } @@ -106,51 +202,149 @@ class SignInDemoState extends State { return null; } - Future _handleSignIn() async { + // Prompts the user to authorize `scopes`. + // + // If authorizationRequiresUserInteraction() is true, this must be called from + // a user interaction (button click). In this example app, a button is used + // regardless, so authorizationRequiresUserInteraction() is not checked. + Future _handleAuthorizeScopes(GoogleSignInAccount user) async { try { - await _googleSignIn.signIn(); - } catch (error) { - print(error); + // #docregion RequestScopes + final GoogleSignInClientAuthorization authorization = await user + .authorizationClient + .authorizeScopes(scopes); + // #enddocregion RequestScopes + + // The returned tokens are ignored since _handleGetContact uses the + // authorizationHeaders method to re-read the token cached by + // authorizeScopes. The code above is used as a README excerpt, so shows + // the simpler pattern of getting the authorization for immediate use. + // That results in an unused variable, which this statement suppresses + // (without adding an ignore: directive to the README excerpt). + // ignore: unnecessary_statements + authorization; + + setState(() { + _isAuthorized = true; + _errorMessage = ''; + }); + unawaited(_handleGetContact(_currentUser!)); + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = _errorMessageFromSignInException(e); + }); + } + } + + // Requests a server auth code for the authorized scopes. + // + // If authorizationRequiresUserInteraction() is true, this must be called from + // a user interaction (button click). In this example app, a button is used + // regardless, so authorizationRequiresUserInteraction() is not checked. + Future _handleGetAuthCode(GoogleSignInAccount user) async { + try { + // #docregion RequestServerAuth + final GoogleSignInServerAuthorization? serverAuth = await user + .authorizationClient + .authorizeServer(scopes); + // #enddocregion RequestServerAuth + + setState(() { + _serverAuthCode = serverAuth == null ? '' : serverAuth.serverAuthCode; + }); + } on GoogleSignInException catch (e) { + setState(() { + _errorMessage = _errorMessageFromSignInException(e); + }); } } - Future _handleSignOut() => _googleSignIn.disconnect(); + Future _handleSignOut() async { + // Disconnect instead of just signing out, to reset the example state as + // much as possible. + await GoogleSignIn.instance.disconnect(); + } Widget _buildBody() { final GoogleSignInAccount? user = _currentUser; - if (user != null) { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - ListTile( - leading: GoogleUserCircleAvatar(identity: user), - title: Text(user.displayName ?? ''), - subtitle: Text(user.email), - ), - const Text('Signed in successfully.'), - Text(_contactText), - ElevatedButton( - onPressed: _handleSignOut, - child: const Text('SIGN OUT'), - ), - ElevatedButton( - child: const Text('REFRESH'), - onPressed: () => _handleGetContact(user), - ), - ], - ); - } else { - return Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const Text('You are not currently signed in.'), + return Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (user != null) + ..._buildAuthenticatedWidgets(user) + else + ..._buildUnauthenticatedWidgets(), + if (_errorMessage.isNotEmpty) Text(_errorMessage), + ], + ); + } + + /// Returns the list of widgets to include if the user is authenticated. + List _buildAuthenticatedWidgets(GoogleSignInAccount user) { + return [ + // The user is Authenticated. + ListTile( + leading: GoogleUserCircleAvatar(identity: user), + title: Text(user.displayName ?? ''), + subtitle: Text(user.email), + ), + const Text('Signed in successfully.'), + if (_isAuthorized) ...[ + // The user has Authorized all required scopes. + if (_contactText.isNotEmpty) Text(_contactText), + ElevatedButton( + child: const Text('REFRESH'), + onPressed: () => _handleGetContact(user), + ), + if (_serverAuthCode.isEmpty) ElevatedButton( - onPressed: _handleSignIn, - child: const Text('SIGN IN'), + child: const Text('REQUEST SERVER CODE'), + onPressed: () => _handleGetAuthCode(user), + ) + else + Text('Server auth code:\n$_serverAuthCode'), + ] else ...[ + // The user has NOT Authorized all required scopes. + const Text('Authorization needed to read your contacts.'), + ElevatedButton( + onPressed: () => _handleAuthorizeScopes(user), + child: const Text('REQUEST PERMISSIONS'), + ), + ], + ElevatedButton(onPressed: _handleSignOut, child: const Text('SIGN OUT')), + ]; + } + + /// Returns the list of widgets to include if the user is not authenticated. + List _buildUnauthenticatedWidgets() { + return [ + const Text('You are not currently signed in.'), + // #docregion ExplicitSignIn + if (GoogleSignIn.instance.supportsAuthenticate()) + ElevatedButton( + onPressed: () async { + try { + await GoogleSignIn.instance.authenticate(); + } catch (e) { + // #enddocregion ExplicitSignIn + _errorMessage = e.toString(); + // #docregion ExplicitSignIn + } + }, + child: const Text('SIGN IN'), + ) + else ...[ + if (kIsWeb) + web.renderButton() + // #enddocregion ExplicitSignIn + else + const Text( + 'This platform does not have a known authentication method', ), - ], - ); - } + // #docregion ExplicitSignIn + ], + // #enddocregion ExplicitSignIn + ]; } @override @@ -163,4 +357,14 @@ class SignInDemoState extends State { ), ); } + + String _errorMessageFromSignInException(GoogleSignInException e) { + // In practice, an application should likely have specific handling for most + // or all of the, but for simplicity this just handles cancel, and reports + // the rest as generic errors. + return switch (e.code) { + GoogleSignInExceptionCode.canceled => 'Sign in canceled', + _ => 'GoogleSignInException ${e.code}: ${e.description}', + }; + } } diff --git a/packages/google_sign_in/example/lib/src/web_wrapper.dart b/packages/google_sign_in/example/lib/src/web_wrapper.dart new file mode 100644 index 000000000..e96b03438 --- /dev/null +++ b/packages/google_sign_in/example/lib/src/web_wrapper.dart @@ -0,0 +1,5 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'web_wrapper_stub.dart' if (dart.library.js_util) 'web_wrapper_web.dart'; diff --git a/packages/google_sign_in/example/lib/src/web_wrapper_stub.dart b/packages/google_sign_in/example/lib/src/web_wrapper_stub.dart new file mode 100644 index 000000000..1e55df4a1 --- /dev/null +++ b/packages/google_sign_in/example/lib/src/web_wrapper_stub.dart @@ -0,0 +1,11 @@ +// 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'; + +/// Stub for the web-only renderButton method, since google_sign_in_web has to +/// be behind a conditional import. +Widget renderButton() { + throw StateError('This should only be called on web'); +} diff --git a/packages/google_sign_in/example/lib/src/web_wrapper_web.dart b/packages/google_sign_in/example/lib/src/web_wrapper_web.dart new file mode 100644 index 000000000..e60009b86 --- /dev/null +++ b/packages/google_sign_in/example/lib/src/web_wrapper_web.dart @@ -0,0 +1,5 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'package:google_sign_in_web/web_only.dart'; diff --git a/packages/google_sign_in/example/pubspec.yaml b/packages/google_sign_in/example/pubspec.yaml index 7747eab11..ac512a3b0 100644 --- a/packages/google_sign_in/example/pubspec.yaml +++ b/packages/google_sign_in/example/pubspec.yaml @@ -3,15 +3,16 @@ description: Demonstrates how to use the google_sign_in_tizen plugin. publish_to: "none" environment: - sdk: ">=3.1.0 <4.0.0" - flutter: ">=3.13.0" + sdk: ">=3.7.0 <4.0.0" + flutter: ">=3.29.0" dependencies: flutter: sdk: flutter - google_sign_in: ^5.4.1 + google_sign_in: ^7.2.0 google_sign_in_tizen: path: ../ + google_sign_in_web: ^1.1.0 http: ">=0.13.0 <2.0.0" dev_dependencies: diff --git a/packages/google_sign_in/example/test_driver/integration_test.dart b/packages/google_sign_in/example/test_driver/integration_test.dart new file mode 100644 index 000000000..4f10f2a52 --- /dev/null +++ b/packages/google_sign_in/example/test_driver/integration_test.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. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/google_sign_in/lib/google_sign_in_tizen.dart b/packages/google_sign_in/lib/google_sign_in_tizen.dart index 1c6eec929..41137d83c 100644 --- a/packages/google_sign_in/lib/google_sign_in_tizen.dart +++ b/packages/google_sign_in/lib/google_sign_in_tizen.dart @@ -4,49 +4,77 @@ import 'dart:convert'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'src/authorization_exception.dart'; import 'src/device_flow_widget.dart' as device_flow_widget; import 'src/oauth2.dart'; export 'src/authorization_exception.dart'; +const List _authenticationScopes = [ + 'openid', + 'email', + 'profile', +]; + /// Holds authentication data after Google sign in for Tizen. -class _GoogleSignInTokenDataTizen extends GoogleSignInTokenData { +class _GoogleSignInTokenDataTizen { /// Creates an instance of [_GoogleSignInTokenDataTizen]. _GoogleSignInTokenDataTizen({ - required super.accessToken, + required this.accessToken, required this.accessTokenExpirationDate, - required super.idToken, + required this.idToken, + required Iterable grantedScopes, this.refreshToken, - }); + }) : grantedScopes = grantedScopes.map(_normalizeScope).toSet(); - @override - String get accessToken => super.accessToken!; + /// The OAuth2 access token used to access Google services. + final String accessToken; /// The estimated expiration date of [accessToken]. final DateTime accessTokenExpirationDate; - @override - String get idToken => super.idToken!; + /// The OpenID Connect ID token that identifies the user. + final String idToken; /// The OAuth2 refresh token to exchange for new access tokens. final String? refreshToken; - /// Returns `true` if [accessToken] is expired and needs to be refreshed, - /// otherwise `false`. + /// The scopes granted to [accessToken]. + final Set grantedScopes; + + /// Returns `true` if [accessToken] is expired and needs to be refreshed. bool get isExpired { const Duration minimalTimeToExpire = Duration(minutes: 1); - return accessTokenExpirationDate + return DateTime.now() .add(minimalTimeToExpire) - .isBefore(DateTime.now()); + .isAfter(accessTokenExpirationDate); + } + + /// Returns `true` if all [scopes] are granted by [accessToken]. + bool grantsScopes(List scopes) { + return scopes + .map(_normalizeScope) + .every((String scope) => grantedScopes.contains(scope)); + } + + /// Returns a copy whose access token will be refreshed on next use. + _GoogleSignInTokenDataTizen withInvalidatedAccessToken() { + return _GoogleSignInTokenDataTizen( + accessToken: accessToken, + accessTokenExpirationDate: DateTime.fromMillisecondsSinceEpoch(0), + idToken: idToken, + refreshToken: refreshToken, + grantedScopes: grantedScopes, + ); } /// Creates a [_GoogleSignInTokenDataTizen] from a json object. static _GoogleSignInTokenDataTizen fromJson(Map json) { + final Object? grantedScopesJson = json['granted_scopes']; return _GoogleSignInTokenDataTizen( accessToken: json['access_token']! as String, accessTokenExpirationDate: DateTime.parse( @@ -54,6 +82,10 @@ class _GoogleSignInTokenDataTizen extends GoogleSignInTokenData { ), idToken: json['id_token']! as String, refreshToken: json['refresh_token'] as String?, + grantedScopes: + grantedScopesJson is List + ? grantedScopesJson.cast() + : _authenticationScopes, ); } @@ -61,8 +93,10 @@ class _GoogleSignInTokenDataTizen extends GoogleSignInTokenData { Map toJson() { return { 'access_token': accessToken, - 'access_token_expiration_date': accessTokenExpirationDate.toString(), + 'access_token_expiration_date': + accessTokenExpirationDate.toIso8601String(), 'id_token': idToken, + 'granted_scopes': grantedScopes.toList(), if (refreshToken != null) 'refresh_token': refreshToken!, }; } @@ -85,7 +119,7 @@ class _CachedTokenStorage { // ignore: invalid_use_of_visible_for_testing_member final FlutterSecureStorage _storage = const FlutterSecureStorage(); - final String _kToken = 'token'; + static const String _kToken = 'token'; /// Cached token. _GoogleSignInTokenDataTizen? _token; @@ -100,11 +134,18 @@ class _CachedTokenStorage { return _token!; } final String? jsonString = await _storage.read(key: _kToken); - return jsonString != null - ? _GoogleSignInTokenDataTizen.fromJson( - jsonDecode(jsonString) as Map, - ) - : null; + if (jsonString == null) { + return null; + } + try { + _token = _GoogleSignInTokenDataTizen.fromJson( + jsonDecode(jsonString) as Map, + ); + return _token; + } catch (_) { + await removeToken(); + return null; + } } Future removeToken() async { @@ -124,8 +165,6 @@ class GoogleSignInTizen extends GoogleSignInPlatform { final _CachedTokenStorage _storage = _CachedTokenStorage(); - List _scopes = []; - final DeviceAuthClient _authClient = DeviceAuthClient( authorizationEndPoint: Uri.parse( 'https://oauth2.googleapis.com/device/code', @@ -136,7 +175,7 @@ class GoogleSignInTizen extends GoogleSignInPlatform { /// Sets [clientId] and [clientSecret] to be used for GoogleSignIn authentication. /// - /// This must be called before calling the GoogleSignIn's signIn API. + /// This must be called before calling [GoogleSignIn.initialize]. static void setCredentials({ required String clientId, required String clientSecret, @@ -164,187 +203,325 @@ class GoogleSignInTizen extends GoogleSignInPlatform { void _ensureSetCredentials() { if (_credentials == null) { - throw PlatformException( - code: 'credentials-missing', - message: 'Cannot initialize GoogleSignInTizen: ClientID and ' - 'ClientSecret has not been set, first call `setCredentials` ' - "in google_sign_in_tizen.dart before calling GoogleSignIn's signIn API.", + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.clientConfigurationError, + description: + 'Cannot initialize GoogleSignInTizen: clientId and ' + 'clientSecret have not been set. Call ' + 'GoogleSignInTizen.setCredentials before using GoogleSignIn.', ); } } void _ensureNavigatorKeyAssigned() { if (device_flow_widget.navigatorKey.currentContext == null) { - throw PlatformException( - code: 'navigatorkey-unassigned', - message: 'Cannot initialize GoogleSignInTizen: a default or custom ' - 'navigator key must be assigned to `navigatorKey` parameter in ' - '`MaterialApp` or `CupertinoApp`.', + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.uiUnavailable, + description: + 'Cannot show GoogleSignInTizen authorization UI: a ' + 'navigator key must be assigned to MaterialApp or CupertinoApp.', ); } } @override - Future init({ - List scopes = const [], - SignInOption signInOption = SignInOption.standard, - String? hostedDomain, - String? clientId, - }) async { - if (signInOption == SignInOption.games) { - throw PlatformException( - code: 'unsupported-options', - message: 'Games sign in is not supported on Tizen.', - ); - } - + Future init(InitParameters params) async { _ensureSetCredentials(); - if (clientId != null) { - _credentials = _Credentials(clientId, _credentials!.clientSecret); + if (params.clientId != null && params.clientId != _credentials!.clientId) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.clientConfigurationError, + description: + 'The clientId passed to GoogleSignIn.initialize must match ' + 'the clientId passed to GoogleSignInTizen.setCredentials.', + ); } - _scopes = scopes; - } - - @override - Future signInSilently() async { - final _GoogleSignInTokenDataTizen? existingToken = - await _storage.getToken(); - if (existingToken == null) { - throw PlatformException( - code: 'not-signed-in', - message: 'Cannot get tokens as there is no signed in user.', + if (params.hostedDomain != null) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.clientConfigurationError, + description: 'hostedDomain is not supported by google_sign_in_tizen.', ); } - // Check if access token expired. - if (!existingToken.isExpired) { - return _createUserData(existingToken.idToken); + if (params.nonce != null) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.clientConfigurationError, + description: 'nonce is not supported by google_sign_in_tizen.', + ); } - final _GoogleSignInTokenDataTizen token = await _refreshToken( - existingToken, - ); - await _storage.saveToken(token); - - return _createUserData(token.idToken); } @override - Future signIn() async { - _ensureSetCredentials(); - _ensureNavigatorKeyAssigned(); + Future attemptLightweightAuthentication( + AttemptLightweightAuthenticationParameters params, + ) async { + final _GoogleSignInTokenDataTizen? token = await _getValidToken(); + return token == null + ? null + : AuthenticationResults( + user: _userDataFromIdToken(token.idToken), + authenticationTokens: AuthenticationTokenData(idToken: token.idToken), + ); + } - final AuthorizationResponse authorizationResponse = - await _authClient.requestAuthorization(_credentials!.clientId, _scopes); + @override + bool supportsAuthenticate() => true; - final Future tokenResponseFuture = _authClient.pollToken( - clientId: _credentials!.clientId, - clientSecret: _credentials!.clientSecret, - deviceCode: authorizationResponse.deviceCode, - interval: authorizationResponse.interval, + @override + Future authenticate( + AuthenticateParameters params, + ) async { + final _GoogleSignInTokenDataTizen token = await _signInWithDeviceFlow( + _authenticationScopes, ); - - device_flow_widget.showDeviceFlowWidget( - code: authorizationResponse.userCode, - verificationUrl: authorizationResponse.verificationUrl, - expiresIn: authorizationResponse.expiresIn, - onExpired: () => _authClient.cancelPollToken(), - onCanceled: () => _authClient.cancelPollToken(), + await _storage.saveToken(token); + return AuthenticationResults( + user: _userDataFromIdToken(token.idToken), + authenticationTokens: AuthenticationTokenData(idToken: token.idToken), ); + } - // Waits until user interaction on secondary device is finished, code is expired, - // polling is cancelled, or networking error occurred. - final TokenResponse? tokenResponse = await tokenResponseFuture.onError(( - _, - __, - ) { - device_flow_widget.closeDeviceFlowWidget(); - return null; - }); - if (tokenResponse == null) { + @override + bool authorizationRequiresUserInteraction() => false; + + @override + Future clientAuthorizationTokensForScopes( + ClientAuthorizationTokensForScopesParameters params, + ) async { + final AuthorizationRequestDetails request = params.request; + final _GoogleSignInTokenDataTizen? existingToken = await _getValidToken(); + if (existingToken != null && + _tokenMatchesRequestedUser(existingToken, request) && + existingToken.grantsScopes(request.scopes)) { + return ClientAuthorizationTokenData( + accessToken: existingToken.accessToken, + ); + } + + if (!request.promptIfUnauthorized) { return null; } - device_flow_widget.closeDeviceFlowWidget(); - final _GoogleSignInTokenDataTizen token = _GoogleSignInTokenDataTizen( - accessToken: tokenResponse.accessToken, - accessTokenExpirationDate: DateTime.now().add(tokenResponse.expiresIn), - refreshToken: tokenResponse.refreshToken, - idToken: tokenResponse.idToken, + final _GoogleSignInTokenDataTizen token = await _signInWithDeviceFlow( + _scopesForAuthentication(request.scopes), ); + if (!_tokenMatchesRequestedUser(token, request)) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.userMismatch, + description: + 'The authorized Google account does not match the ' + 'requested account.', + ); + } await _storage.saveToken(token); - return _createUserData(token.idToken); + return ClientAuthorizationTokenData(accessToken: token.accessToken); } + /// Device Flow does not return a server auth code, so this is unsupported on + /// Tizen. Throws [GoogleSignInException] so callers can distinguish "not + /// supported on this platform" from "user has not authorized yet". @override - Future getTokens({ - required String email, - bool? shouldRecoverAuth = true, - }) async { + Future serverAuthorizationTokensForScopes( + ServerAuthorizationTokensForScopesParameters params, + ) async { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.providerConfigurationError, + description: + 'Server authorization tokens are not supported by ' + 'google_sign_in_tizen because the plugin uses OAuth 2.0 Device ' + 'Authorization Grant, which does not return a server auth code.', + ); + } + + @override + Future clearAuthorizationToken( + ClearAuthorizationTokenParams params, + ) async { final _GoogleSignInTokenDataTizen? existingToken = await _storage.getToken(); - if (existingToken == null) { - throw PlatformException( - code: 'not-signed-in', - message: 'Cannot get tokens as there is no signed in user.', - ); - } - - // Check if access token expired. - if (!existingToken.isExpired) { - return existingToken; + if (existingToken == null || + existingToken.accessToken != params.accessToken) { + return; } - final _GoogleSignInTokenDataTizen token = await _refreshToken( - existingToken, - ); - - await _storage.saveToken(token); - return token; + await _storage.saveToken(existingToken.withInvalidatedAccessToken()); } @override - Future signOut() => _storage.removeToken(); + Future signOut(SignOutParams params) => _storage.removeToken(); @override - Future disconnect() async { + Future disconnect(DisconnectParams params) async { final _GoogleSignInTokenDataTizen? existingToken = await _storage.getToken(); if (existingToken == null) { return; } - await _authClient.revokeToken(existingToken.accessToken); - await signOut(); + try { + await _authClient.revokeToken(existingToken.accessToken); + } on AuthorizationException catch (error) { + throw _exceptionFromAuthorizationException(error); + } catch (error) { + throw _unknownException(error); + } finally { + await signOut(const SignOutParams()); + } } - @override - Future isSignedIn() async => await _storage.getToken() != null; + Future<_GoogleSignInTokenDataTizen?> _getValidToken() async { + final _GoogleSignInTokenDataTizen? existingToken = + await _storage.getToken(); + if (existingToken == null) { + return null; + } + if (!existingToken.isExpired) { + return existingToken; + } + if (existingToken.refreshToken == null) { + await _storage.removeToken(); + return null; + } - @override - Future clearAuthCache({String? token}) { - throw UnimplementedError('clearAuthCache() has not been implemented.'); + try { + final _GoogleSignInTokenDataTizen token = await _refreshToken( + existingToken, + ); + await _storage.saveToken(token); + return token; + } on AuthorizationException catch (error) { + if (error.error == 'invalid_grant') { + await _storage.removeToken(); + return null; + } + throw _exceptionFromAuthorizationException(error); + } catch (error) { + throw _unknownException(error); + } } - @override - Future requestScopes(List scopes) { - throw UnimplementedError('requestScopes() has not been implemented.'); + Future<_GoogleSignInTokenDataTizen> _signInWithDeviceFlow( + List scopes, + ) async { + _ensureSetCredentials(); + _ensureNavigatorKeyAssigned(); + + bool expired = false; + bool canceled = false; + bool widgetShown = false; + + try { + final AuthorizationResponse authorizationResponse = await _authClient + .requestAuthorization(_credentials!.clientId, scopes); + + final Future tokenResponseFuture = _authClient.pollToken( + clientId: _credentials!.clientId, + clientSecret: _credentials!.clientSecret, + deviceCode: authorizationResponse.deviceCode, + interval: authorizationResponse.interval, + ); + + widgetShown = true; + device_flow_widget.showDeviceFlowWidget( + code: authorizationResponse.userCode, + verificationUrl: authorizationResponse.verificationUrl, + expiresIn: authorizationResponse.expiresIn, + onExpired: () { + expired = true; + _authClient.cancelPollToken(); + }, + onCanceled: () { + canceled = true; + _authClient.cancelPollToken(); + }, + ); + + final TokenResponse? tokenResponse = await tokenResponseFuture; + if (tokenResponse == null) { + if (canceled) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.canceled, + description: 'Sign in was canceled.', + ); + } + throw GoogleSignInException( + code: GoogleSignInExceptionCode.interrupted, + description: + expired + ? 'The device authorization code expired.' + : 'Device authorization was interrupted.', + ); + } + return _tokenDataFromResponse(tokenResponse, requestedScopes: scopes); + } on AuthorizationException catch (error) { + throw _exceptionFromAuthorizationException(error); + } on GoogleSignInException { + rethrow; + } catch (error) { + throw _unknownException(error); + } finally { + if (widgetShown) { + device_flow_widget.closeDeviceFlowWidget(); + } + } } - GoogleSignInUserData _createUserData(String idToken) { - // Decodes JWT payload as a json object. - final List splitTokens = idToken.split('.'); - if (splitTokens.length != 3) { - throw const FormatException('Invalid idToken.'); + _GoogleSignInTokenDataTizen _tokenDataFromResponse( + TokenResponse tokenResponse, { + required Iterable requestedScopes, + _GoogleSignInTokenDataTizen? previousToken, + }) { + final String? idToken = tokenResponse.idToken ?? previousToken?.idToken; + if (idToken == null) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.providerConfigurationError, + description: 'Google token response did not include an idToken.', + ); + } + + return _GoogleSignInTokenDataTizen( + accessToken: tokenResponse.accessToken, + accessTokenExpirationDate: DateTime.now().add(tokenResponse.expiresIn), + refreshToken: tokenResponse.refreshToken ?? previousToken?.refreshToken, + idToken: idToken, + grantedScopes: + tokenResponse.scope.isNotEmpty + ? tokenResponse.scope + : previousToken?.grantedScopes ?? requestedScopes, + ); + } + + GoogleSignInUserData _userDataFromIdToken(String idToken) { + final Map json; + try { + final List splitTokens = idToken.split('.'); + if (splitTokens.length != 3) { + throw const FormatException('Invalid idToken.'); + } + final String normalizedPayload = base64Url.normalize(splitTokens[1]); + final String payloadString = utf8.decode( + base64Url.decode(normalizedPayload), + ); + json = jsonDecode(payloadString) as Map; + } catch (error) { + throw GoogleSignInException( + code: GoogleSignInExceptionCode.providerConfigurationError, + description: 'Google token response included an invalid idToken.', + details: error.toString(), + ); + } + + final String? email = json['email'] as String?; + final String? id = json['sub'] as String?; + if (email == null || id == null) { + throw const GoogleSignInException( + code: GoogleSignInExceptionCode.providerConfigurationError, + description: 'Google idToken did not include the required user data.', + ); } - final String normalizedPayload = base64.normalize(splitTokens[1]); - final String payloadString = utf8.decode(base64.decode(normalizedPayload)); - final Map json = - jsonDecode(payloadString) as Map; return GoogleSignInUserData( - email: json['email']! as String, - id: json['sub']! as String, + email: email, + id: id, displayName: json['name'] as String?, - idToken: idToken, photoUrl: json['picture'] as String?, ); } @@ -352,13 +529,6 @@ class GoogleSignInTizen extends GoogleSignInPlatform { Future<_GoogleSignInTokenDataTizen> _refreshToken( _GoogleSignInTokenDataTizen token, ) async { - if (token.refreshToken == null) { - throw PlatformException( - code: 'refresh-token-missing', - message: 'Cannot refresh tokens as refresh tokens are missing. ' - 'Request new tokens by signing-in again.', - ); - } _ensureSetCredentials(); final TokenResponse tokenResponse = await _authClient.refreshToken( @@ -367,11 +537,64 @@ class GoogleSignInTizen extends GoogleSignInPlatform { refreshToken: token.refreshToken!, ); - return _GoogleSignInTokenDataTizen( - accessToken: tokenResponse.accessToken, - accessTokenExpirationDate: DateTime.now().add(tokenResponse.expiresIn), - refreshToken: tokenResponse.refreshToken ?? token.refreshToken, - idToken: tokenResponse.idToken, + return _tokenDataFromResponse( + tokenResponse, + requestedScopes: token.grantedScopes, + previousToken: token, ); } + + bool _tokenMatchesRequestedUser( + _GoogleSignInTokenDataTizen token, + AuthorizationRequestDetails request, + ) { + if (request.userId == null && request.email == null) { + return true; + } + final GoogleSignInUserData user = _userDataFromIdToken(token.idToken); + return (request.userId == null || request.userId == user.id) && + (request.email == null || request.email == user.email); + } + + List _scopesForAuthentication(List scopes) { + return { + ..._authenticationScopes, + ...scopes.map(_normalizeScope), + }.toList(); + } +} + +GoogleSignInException _exceptionFromAuthorizationException( + AuthorizationException error, +) { + final GoogleSignInExceptionCode code = switch (error.error) { + 'access_denied' => GoogleSignInExceptionCode.canceled, + 'invalid_client' => GoogleSignInExceptionCode.clientConfigurationError, + 'invalid_scope' => GoogleSignInExceptionCode.clientConfigurationError, + _ => GoogleSignInExceptionCode.unknownError, + }; + return GoogleSignInException( + code: code, + description: error.description ?? error.error, + details: { + 'error': error.error, + 'uri': error.uri?.toString(), + }, + ); +} + +GoogleSignInException _unknownException(Object error) { + return GoogleSignInException( + code: GoogleSignInExceptionCode.unknownError, + description: 'Google Sign-In failed.', + details: error.toString(), + ); +} + +String _normalizeScope(String scope) { + return switch (scope) { + 'https://www.googleapis.com/auth/userinfo.email' => 'email', + 'https://www.googleapis.com/auth/userinfo.profile' => 'profile', + _ => scope, + }; } diff --git a/packages/google_sign_in/lib/src/device_flow_widget.dart b/packages/google_sign_in/lib/src/device_flow_widget.dart index b9bbe9b55..c85a452ea 100644 --- a/packages/google_sign_in/lib/src/device_flow_widget.dart +++ b/packages/google_sign_in/lib/src/device_flow_widget.dart @@ -19,7 +19,10 @@ GlobalKey navigatorKey = GlobalKey(); /// Closes the widget that was shown from [showDeviceFlowWidget]. void closeDeviceFlowWidget() { - navigatorKey.currentState!.pop(); + final NavigatorState? navigator = navigatorKey.currentState; + if (navigator != null && navigator.canPop()) { + navigator.pop(); + } } /// Displays a widget that shows [code] and [verificationUrl]. @@ -37,9 +40,7 @@ void showDeviceFlowWidget({ final TextStyle bodyStyle = Theme.of( context, ).textTheme.bodyLarge!.copyWith(color: Colors.black); - final TextStyle titleStyle = Theme.of(context) - .textTheme - .titleLarge! + final TextStyle titleStyle = Theme.of(context).textTheme.titleLarge! .copyWith(color: Colors.black, fontWeight: FontWeight.bold); return AlertDialog( @@ -77,7 +78,7 @@ void showDeviceFlowWidget({ FittedBox(child: Text('Code will expire in:', style: bodyStyle)), const SizedBox(height: 5), _CountdownTimer( - const Duration(minutes: 30), + expiresIn, style: bodyStyle, onFinished: () { onExpired?.call(); @@ -138,8 +139,10 @@ class _CountdownTimerState extends State<_CountdownTimer> { @override Widget build(BuildContext context) { final String minutes = _remaining.inMinutes.toString().padLeft(2, '0'); - final String seconds = - _remaining.inSeconds.remainder(60).toString().padLeft(2, '0'); + final String seconds = _remaining.inSeconds + .remainder(60) + .toString() + .padLeft(2, '0'); return FittedBox(child: Text('$minutes:$seconds', style: widget.style)); } diff --git a/packages/google_sign_in/lib/src/oauth2.dart b/packages/google_sign_in/lib/src/oauth2.dart index 532503066..4db7c1ada 100644 --- a/packages/google_sign_in/lib/src/oauth2.dart +++ b/packages/google_sign_in/lib/src/oauth2.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; import 'dart:convert' as convert; import 'dart:io'; @@ -22,14 +23,18 @@ class AuthorizationResponse { /// Creates a [AuthorizationResponse] from a json object. static AuthorizationResponse fromJson(Map json) { + final String? verificationUrl = + json['verification_url'] as String? ?? + json['verification_uri'] as String?; return AuthorizationResponse._( deviceCode: json['device_code']! as String, userCode: json['user_code']! as String, - verificationUrl: Uri.parse(json['verification_url']! as String), + verificationUrl: Uri.parse(verificationUrl!), expiresIn: Duration(seconds: json['expires_in']! as int), - interval: json['interval'] is int - ? Duration(seconds: json['interval']! as int) - : null, + interval: + json['interval'] is int + ? Duration(seconds: json['interval']! as int) + : null, ); } @@ -61,7 +66,7 @@ class TokenResponse { required this.expiresIn, this.refreshToken, List? scope, - required this.idToken, + this.idToken, }) : scope = scope ?? []; /// Creates a [TokenResponse] from a json object. @@ -71,10 +76,11 @@ class TokenResponse { tokenType: json['token_type']! as String, expiresIn: Duration(seconds: json['expires_in']! as int), refreshToken: json['refresh_token'] as String?, - scope: json['scope'] is String - ? (json['scope']! as String).split(' ').toList() - : null, - idToken: json['id_token']! as String, + scope: + json['scope'] is String + ? (json['scope']! as String).split(' ').toList() + : null, + idToken: json['id_token'] as String?, ); } @@ -97,7 +103,7 @@ class TokenResponse { /// The token issued by the Google authorization server that holds Google /// account information. - final String idToken; + final String? idToken; } /// OAuth 2.0 client that handles Device Authorization Grant. @@ -127,12 +133,19 @@ class DeviceAuthClient { final http.Client _httpClient; bool _isPolling = false; + Completer? _pollCancellationCompleter; /// Checks whether polling started by by [pollToken] is in progress. bool get isPolling => _isPolling; /// Stops poll request started by [pollToken]. - void cancelPollToken() => _isPolling = false; + void cancelPollToken() { + _isPolling = false; + final Completer? cancellationCompleter = _pollCancellationCompleter; + if (cancellationCompleter != null && !cancellationCompleter.isCompleted) { + cancellationCompleter.complete(); + } + } /// Requests authroization grant from [authorizationEndPoint]. Future requestAuthorization( @@ -199,29 +212,59 @@ class DeviceAuthClient { ); } _isPolling = true; - while (isPolling) { - try { - final TokenResponse tokenResponse = await Future.delayed( - interval, - () => requestToken(clientId, clientSecret, deviceCode), - ); - _isPolling = false; - return tokenResponse; - } on AuthorizationException catch (e) { - // Subsequent requests MUST be increased by 5 seconds. - // See: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5. - if (e.error == 'slow_down') { - interval = interval + const Duration(seconds: 5); - } - // The authorization request is still pending as the end user hasn't - // yet completed the user-interaction steps. - else if (e.error != 'authorization_pending') { + _pollCancellationCompleter = Completer(); + try { + while (isPolling) { + try { + await _waitForNextPoll(interval); + if (!isPolling) { + return null; + } + + final TokenResponse tokenResponse = await _raceCancellation( + requestToken(clientId, clientSecret, deviceCode), + ); + if (!isPolling) { + return null; + } _isPolling = false; - rethrow; + return tokenResponse; + } on _PollingCanceled { + return null; + } on AuthorizationException catch (e) { + // Subsequent requests MUST be increased by 5 seconds. + // See: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5. + if (e.error == 'slow_down') { + interval = interval + const Duration(seconds: 5); + } + // The authorization request is still pending as the end user hasn't + // yet completed the user-interaction steps. + else if (e.error != 'authorization_pending') { + _isPolling = false; + rethrow; + } } } + return null; + } finally { + _isPolling = false; + _pollCancellationCompleter = null; + } + } + + Future _waitForNextPoll(Duration interval) { + return _raceCancellation(Future.delayed(interval)); + } + + Future _raceCancellation(Future future) { + final Future? cancellationFuture = _pollCancellationCompleter?.future; + if (cancellationFuture == null) { + return future; } - return null; + return Future.any(>[ + future, + cancellationFuture.then((_) => throw const _PollingCanceled()), + ]); } /// Requests a revoke token request to [revokeEndPoint]. @@ -285,3 +328,7 @@ class DeviceAuthClient { } } } + +class _PollingCanceled implements Exception { + const _PollingCanceled(); +} diff --git a/packages/google_sign_in/pubspec.yaml b/packages/google_sign_in/pubspec.yaml index 66670ba73..c7a6f5f34 100644 --- a/packages/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/pubspec.yaml @@ -2,11 +2,11 @@ name: google_sign_in_tizen description: Tizen implementation of the google_sign_in plugin. homepage: https://github.com/flutter-tizen/plugins repository: https://github.com/flutter-tizen/plugins/tree/master/packages/google_sign_in -version: 0.1.5 +version: 0.2.0 environment: - sdk: ">=3.1.0 <4.0.0" - flutter: ">=3.13.0" + sdk: ">=3.7.0 <4.0.0" + flutter: ">=3.29.0" flutter: plugin: @@ -19,11 +19,12 @@ flutter: dependencies: flutter: sdk: flutter - flutter_secure_storage: ^6.0.0 - flutter_secure_storage_tizen: ^0.1.0 + flutter_secure_storage: ^10.0.0 + flutter_secure_storage_tizen: ^0.1.3 flutter_svg: ^1.1.5 - google_sign_in_platform_interface: ^2.3.0 + google_sign_in_platform_interface: ^3.1.0 http: ">=0.13.0 <2.0.0" + path_provider_tizen: ^2.2.1 dev_dependencies: flutter_test: