From 2914d71a08b2c32c4be4b01f2f4e3e017bddea45 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 24 Oct 2023 13:29:00 -0700 Subject: [PATCH 1/4] [gsi_web] Enable FedCM where available. --- .../google_sign_in_web/lib/src/gis_client.dart | 15 +++++++++++++++ .../google_sign_in_web/pubspec.yaml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) 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 c8d6ebfe1dca..1fd01f84055f 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 @@ -41,6 +41,8 @@ class GisSdkClient { _initializeIdClient( clientId, onResponse: _onCredentialResponse, + hostedDomain: hostedDomain, + useFedCM: true, ); _tokenClient = _initializeTokenClient( @@ -102,6 +104,8 @@ class GisSdkClient { void _initializeIdClient( String clientId, { required CallbackFn onResponse, + String? hostedDomain, + bool? useFedCM, }) { // Initialize `id` for the silent-sign in code. final IdConfiguration idConfig = IdConfiguration( @@ -109,6 +113,9 @@ class GisSdkClient { callback: allowInterop(onResponse), cancel_on_tap_outside: false, auto_select: true, // Attempt to sign-in silently. + hd: hostedDomain, + use_fedcm_for_prompt: + useFedCM, // Use the native browser prompt, when available. ); id.initialize(idConfig); } @@ -238,7 +245,15 @@ class GisSdkClient { /// * If [_lastCredentialResponse] is null, we add [people.scopes] to the /// [_initialScopes], so we can retrieve User Profile information back /// from the People API (without idToken). See [people.requestUserData]. + @Deprecated( + 'Use `renderButton` instead. See: https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services') Future signIn() async { + // Warn users that this method will be removed. + domConsole.warn( + 'The google_sign_in plugin `signIn` method is deprecated on the web, and will be removed in Q2 2024. Please use `renderButton` instead. See: ', + [ + 'https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services' + ]); // 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 = 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 c4a32959e0bf..932a7ffb5750 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - google_identity_services_web: ^0.2.1 + google_identity_services_web: ^0.2.2 google_sign_in_platform_interface: ^2.4.0 http: ">=0.13.0 <2.0.0" js: ^0.6.3 From 6e358e3edc4fa4d40cfb259c8c5a31c860ec328c Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 24 Oct 2023 17:26:58 -0700 Subject: [PATCH 2/4] [gis_web] Use expiration dates to improve accuracy when checking isSignedIn/canAccessScopes. --- .../integration_test/src/jwt_examples.dart | 28 ++++++++++++++-- .../example/integration_test/utils_test.dart | 24 ++++++++++++++ .../lib/src/gis_client.dart | 29 +++++++++++++--- .../google_sign_in_web/lib/src/utils.dart | 33 ++++++++++++++----- 4 files changed, 99 insertions(+), 15 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart index 336b626c11de..07515ec1a920 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart @@ -24,6 +24,11 @@ final CredentialResponse minimalCredential = 'credential': minimalJwtToken, }); +final CredentialResponse expiredCredential = + jsifyAs({ + 'credential': expiredJwtToken, +}); + /// A JWT token with predefined values. /// /// 'email': 'adultman@example.com', @@ -55,11 +60,30 @@ const String minimalJwtToken = /// The payload of a JWT token that contains only non-nullable values. /// -/// "email": "adultman@example.com", -/// "sub": "123456" +/// 'email': 'adultman@example.com', +/// 'sub': '123456' const String minimalPayload = 'eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2In0'; +/// A JWT token with minimal set of predefined values and an expiration timestamp. +/// +/// 'email': 'adultman@example.com', +/// 'sub': '123456', +/// 'exp': 1430330400 +/// +/// Signed with HS256 and the private key: 'symmetric-encryption-is-weak' +const String expiredJwtToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$expiredPayload.--gb5tnVSSsLg4zjjVH0FUUvT4rbehIcnBhB-8Iekm4'; + +/// The payload of a JWT token that contains only non-nullable values, and an +/// expiration timestamp of 1430330400 (Wednesday, April 29, 2015 6:00:00 PM UTC) +/// +/// 'email': 'adultman@example.com', +/// 'sub': '123456', +/// 'exp': 1430330400 +const String expiredPayload = + 'eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwiZXhwIjoxNDMwMzMwNDAwfQ'; + // More encrypted JWT Tokens may be created on https://jwt.io. // // First, decode the `goodJwtToken` above, modify to your heart's diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart index 6f5fcfd97efa..9dec77b81bde 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart @@ -85,6 +85,30 @@ void main() { }); }); + group('getCredentialResponseExpirationTimestamp', () { + testWidgets('Good payload -> data', (_) async { + final DateTime? expiration = + getCredentialResponseExpirationTimestamp(expiredCredential); + + expect(expiration, isNotNull); + expect(expiration!.millisecondsSinceEpoch, 1430330400 * 1000); + }); + + testWidgets('No expiration -> null', (_) async { + expect( + getCredentialResponseExpirationTimestamp(minimalCredential), isNull); + }); + + testWidgets('Bad data -> null', (_) async { + final CredentialResponse bogus = + jsifyAs({ + 'credential': 'some-bogus.thing-that-is-not.valid-jwt', + }); + + expect(getCredentialResponseExpirationTimestamp(bogus), isNull); + }); + }); + group('getJwtTokenPayload', () { testWidgets('happy case -> data', (_) async { final Map? data = getJwtTokenPayload(goodJwtToken); 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 1fd01f84055f..8ead0d2a99d3 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 @@ -66,6 +66,8 @@ class GisSdkClient { _tokenResponses.stream.listen((TokenResponse response) { _lastTokenResponse = response; + _lastTokenResponseExpiration = + DateTime.now().add(Duration(seconds: response.expires_in)); }, onError: (Object error) { _logIfEnabled('Error on TokenResponse:', [error.toString()]); _lastTokenResponse = null; @@ -280,6 +282,8 @@ class GisSdkClient { // This function returns the currently signed-in [GoogleSignInUserData]. // // It'll do a request to the People API (if needed). + // + // @Deprecated Future _computeUserDataForLastToken() async { // If the user hasn't authenticated, request their basic profile info // from the People API. @@ -317,9 +321,17 @@ class GisSdkClient { await signOut(); } - /// Returns true if the client has recognized this user before. + /// Returns true if the client has recognized this user before, and the last-seen + /// credential is not expired. Future isSignedIn() async { - return _lastCredentialResponse != null || _requestedUserData != null; + bool isSignedIn = false; + if (_lastCredentialResponse != null) { + final DateTime? expiration = utils + .getCredentialResponseExpirationTimestamp(_lastCredentialResponse); + isSignedIn = expiration?.isAfter(DateTime.now()) ?? false; + } + + return isSignedIn || _requestedUserData != null; } /// Clears all the cached results from authentication and authorization. @@ -353,12 +365,15 @@ class GisSdkClient { /// 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. + /// token response, that the token is not expired, then uses that response to + /// check if permissions are still granted. Future canAccessScopes(List scopes, String? accessToken) async { if (accessToken != null && _lastTokenResponse != null) { if (accessToken == _lastTokenResponse!.access_token) { - return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); + final bool isTokenValid = + _lastTokenResponseExpiration?.isAfter(DateTime.now()) ?? false; + return isTokenValid && + oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); } } return false; @@ -383,6 +398,8 @@ class GisSdkClient { // The last-seen credential and token responses CredentialResponse? _lastCredentialResponse; TokenResponse? _lastTokenResponse; + // Expiration timestamp for the lastTokenResponse, which only has an `expires_in` field. + DateTime? _lastTokenResponseExpiration; /// The StreamController onto which the GIS Client propagates user authentication events. /// @@ -394,5 +411,7 @@ class GisSdkClient { // (if needed) // // (This is a synthetic _lastCredentialResponse) + // + // @Deprecated GoogleSignInUserData? _requestedUserData; } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart index 8ec9cb572017..06f49d6733d8 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart @@ -52,19 +52,22 @@ Map? decodeJwtPayload(String? payload) { return null; } +/// Returns the payload of a [CredentialResponse]. +Map? getResponsePayload(CredentialResponse? response) { + if (response == null || response.credential == null) { + return null; + } + + return getJwtTokenPayload(response.credential); +} + /// Converts a [CredentialResponse] into a [GoogleSignInUserData]. /// /// May return `null`, if the `credentialResponse` is null, or its `credential` /// cannot be decoded. GoogleSignInUserData? gisResponsesToUserData( CredentialResponse? credentialResponse) { - if (credentialResponse == null || credentialResponse.credential == null) { - return null; - } - - final Map? payload = - getJwtTokenPayload(credentialResponse.credential); - + final Map? payload = getResponsePayload(credentialResponse); if (payload == null) { return null; } @@ -74,10 +77,24 @@ GoogleSignInUserData? gisResponsesToUserData( id: payload['sub']! as String, displayName: payload['name'] as String?, photoUrl: payload['picture'] as String?, - idToken: credentialResponse.credential, + idToken: credentialResponse!.credential, ); } +/// Returns the expiration timestamp ('exp') of a [CredentialResponse]. +/// +/// May return `null` if the `credentialResponse` is null, its `credential` +/// cannot be decoded, or the `exp` field is not set on the JWT payload. +DateTime? getCredentialResponseExpirationTimestamp( + CredentialResponse? credentialResponse) { + final Map? payload = getResponsePayload(credentialResponse); + if (payload == null || payload['exp'] == null) { + return null; + } + + return DateTime.fromMillisecondsSinceEpoch((payload['exp']! as int) * 1000); +} + /// Converts responses from the GIS library into TokenData for the plugin. GoogleSignInTokenData gisResponsesToTokenData( CredentialResponse? credentialResponse, TokenResponse? tokenResponse) { From f1beade95862a3a5a2a455b9999ac2dd22406582 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 24 Oct 2023 18:29:07 -0700 Subject: [PATCH 3/4] [gsi_web] Update CHANGELOG and version. --- packages/google_sign_in/google_sign_in_web/CHANGELOG.md | 9 +++++++++ packages/google_sign_in/google_sign_in_web/pubspec.yaml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) 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 0a6f7259b187..90aeabceebea 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,12 @@ +## 0.12.1 + +* Enables FedCM on browsers that support this authentication mechanism. +* Uses the expiration timestamps of Credential and Token responses to improve + the accuracy of `isSignedIn` and `canAccessScopes` methods. +* Deprecates `signIn()` method. + * Users should migrate to `renderButton` and `silentSignIn`, as described in + the README. + ## 0.12.0+5 * Migrates to `dart:ui_web` APIs. 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 932a7ffb5750..e9836a35cef5 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android, iOS and Web. repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.12.0+5 +version: 0.12.1 environment: sdk: ">=3.1.0 <4.0.0" From 95609214e3430f0d9f96c724866b079575bdb9cb Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 1 Nov 2023 17:03:15 -0700 Subject: [PATCH 4/4] Address PR comments. --- .../google_sign_in_web/lib/src/gis_client.dart | 16 ++++++++++++++-- .../google_sign_in_web/lib/src/utils.dart | 16 +++++++++------- 2 files changed, 23 insertions(+), 9 deletions(-) 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 8ead0d2a99d3..cf79de8a3a20 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 @@ -239,6 +239,8 @@ class GisSdkClient { return id.renderButton(parent, convertButtonConfiguration(options)!); } + // TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727 + // /// Starts an oauth2 "implicit" flow to authorize requests. /// /// The new GIS SDK does not return user authentication from this flow, so: @@ -283,7 +285,7 @@ class GisSdkClient { // // It'll do a request to the People API (if needed). // - // @Deprecated + // TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727 Future _computeUserDataForLastToken() async { // If the user hasn't authenticated, request their basic profile info // from the People API. @@ -328,6 +330,16 @@ class GisSdkClient { if (_lastCredentialResponse != null) { final DateTime? expiration = utils .getCredentialResponseExpirationTimestamp(_lastCredentialResponse); + // All Google ID Tokens provide an "exp" date. If the method above cannot + // extract `expiration`, it's because `_lastCredentialResponse`'s contents + // are unexpected (or wrong) in any way. + // + // Users are considered to be signedIn when the last CredentialResponse + // exists and has an expiration date in the future. + // + // Users are not signed in in any other case. + // + // See: https://developers.google.com/identity/openid-connect/openid-connect#an-id-tokens-payload isSignedIn = expiration?.isAfter(DateTime.now()) ?? false; } @@ -412,6 +424,6 @@ class GisSdkClient { // // (This is a synthetic _lastCredentialResponse) // - // @Deprecated + // TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727 GoogleSignInUserData? _requestedUserData; } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart index 06f49d6733d8..609a387d1178 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart @@ -54,11 +54,11 @@ Map? decodeJwtPayload(String? payload) { /// Returns the payload of a [CredentialResponse]. Map? getResponsePayload(CredentialResponse? response) { - if (response == null || response.credential == null) { + if (response?.credential == null) { return null; } - return getJwtTokenPayload(response.credential); + return getJwtTokenPayload(response!.credential); } /// Converts a [CredentialResponse] into a [GoogleSignInUserData]. @@ -72,6 +72,9 @@ GoogleSignInUserData? gisResponsesToUserData( return null; } + assert(credentialResponse?.credential != null, + 'The CredentialResponse cannot be null and have a payload.'); + return GoogleSignInUserData( email: payload['email']! as String, id: payload['sub']! as String, @@ -88,11 +91,10 @@ GoogleSignInUserData? gisResponsesToUserData( DateTime? getCredentialResponseExpirationTimestamp( CredentialResponse? credentialResponse) { final Map? payload = getResponsePayload(credentialResponse); - if (payload == null || payload['exp'] == null) { - return null; - } - - return DateTime.fromMillisecondsSinceEpoch((payload['exp']! as int) * 1000); + // Get the 'exp' field from the payload, if present. + final int? exp = (payload != null) ? payload['exp'] as int? : null; + // Return 'exp' (a timestamp in seconds since Epoch) as a DateTime. + return (exp != null) ? DateTime.fromMillisecondsSinceEpoch(exp * 1000) : null; } /// Converts responses from the GIS library into TokenData for the plugin.