From bb67b77c2cb3cc83297a5156b88daf66e24f7c6e Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Fri, 6 Jan 2023 20:17:43 -0800 Subject: [PATCH 01/31] [google_sign_in_web] Migrate to GIS SDK. --- .../google_sign_in_web/CHANGELOG.md | 16 +- .../lib/google_sign_in_web.dart | 320 +++++++++++++----- .../google_sign_in_web/lib/src/load_gapi.dart | 3 +- .../google_sign_in_web/lib/src/people.dart | 120 +++++++ .../google_sign_in_web/lib/src/utils.dart | 71 ++-- .../google_sign_in_web/pubspec.yaml | 5 +- 6 files changed, 397 insertions(+), 138 deletions(-) create mode 100644 packages/google_sign_in/google_sign_in_web/lib/src/people.dart diff --git a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md index 85c46da8facc..6ae170badba0 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -1,5 +1,17 @@ -## NEXT - +## 0.11.0 + +* **Breaking Change:** Migrates JS-interop to `package:google_identity_services_web` + * Uses the new Google Identity Authentication and Authorization JS SDKs. [Docs](https://developers.google.com/identity). + * TODO: Move the below to a migration instructions doc in the README. + * Authentication and Authorization are now two separate concerns. + * `signInSilently` now displays the One Tap UX for web. The SDK no longer has + direct access to previously-seen users. + * The new SDK only provides an `idToken` when the user does `signInSilently`. + * The plugin attempts to mimic the old behavior (of retrieving Profile information + on `signIn`) but in that case, the `idToken` is not returned. + * The plugin no longer is able to renew Authorization sessions on the web. + Once the session expires, API requests will begin to fail with unauthorized, + and user Authorization is required again. * Updates minimum Flutter version to 3.0. ## 0.10.2+1 diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index 5d75c0da0c4f..04a0538aad8e 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -8,21 +8,19 @@ import 'dart:html' as html; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:google_identity_services_web/id.dart'; +import 'package:google_identity_services_web/loader.dart' as loader; +import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:js/js.dart'; +import 'package:js/js_util.dart'; -import 'src/js_interop/gapiauth2.dart' as auth2; -import 'src/load_gapi.dart' as gapi; -import 'src/utils.dart' show gapiUserToPluginUserData; +import 'src/people.dart' as people; +import 'src/utils.dart' as utils; const String _kClientIdMetaSelector = 'meta[name=google-signin-client_id]'; const String _kClientIdAttributeName = 'content'; -/// This is only exposed for testing. It shouldn't be accessed by users of the -/// plugin as it could break at any point. -@visibleForTesting -String gapiUrl = 'https://apis.google.com/js/platform.js'; - /// Implementation of the google_sign_in plugin for Web. class GoogleSignInPlugin extends GoogleSignInPlatform { /// Constructs the plugin immediately and begins initializing it in the @@ -34,13 +32,37 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { .querySelector(_kClientIdMetaSelector) ?.getAttribute(_kClientIdAttributeName); - _isGapiInitialized = gapi.inject(gapiUrl).then((_) => gapi.init()); + _isJsSdkLoaded = loader.loadWebSdk(); } - late Future _isGapiInitialized; - late Future _isAuthInitialized; + late Future _isJsSdkLoaded; bool _isInitCalled = false; + // The scopes initially requested by the developer. + // + // We store this because we might need to add more at `signIn`, if the user + // doesn't `silentSignIn`, we expand this list to consult the People API to + // return some basic Authentication information. + late List _initialScopes; + + // The Google Identity Services client for oauth requests. + late TokenClient _tokenClient; + + // Streams of credential and token responses. + late StreamController _credentialResponses; + late StreamController _tokenResponses; + + // The last-seen credential and token responses + CredentialResponse? _lastCredentialResponse; + TokenResponse? _lastTokenResponse; + + // If the user *authenticates* (signs in) through oauth2, the SDK doesn't return + // identity information anymore, so we synthesize it by calling the PeopleAPI + // (if needed) + // + // (This is a synthetic _lastCredentialResponse) + GoogleSignInUserData? _requestedUserData; + // This method throws if init or initWithParams hasn't been called at some // point in the past. It is used by the [initialized] getter to ensure that // users can't await on a Future that will never resolve. @@ -53,13 +75,15 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } } - /// A future that resolves when both GAPI and Auth2 have been correctly initialized. + /// A future that resolves when the SDK has been correctly loaded. @visibleForTesting Future get initialized { _assertIsInitCalled(); - return Future.wait(>[_isGapiInitialized, _isAuthInitialized]); + return _isJsSdkLoaded; + // TODO: make _isInitCalled a future that resolves when `init` is called } + // Stores the clientId found in the DOM (if any). String? _autoDetectedClientId; /// Factory method that initializes the plugin with [GoogleSignInPlatform]. @@ -100,141 +124,257 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { 'Check https://developers.google.com/identity/protocols/googlescopes ' 'for a list of valid OAuth 2.0 scopes.'); - await _isGapiInitialized; - - final auth2.GoogleAuth auth = auth2.init(auth2.ClientConfig( - hosted_domain: params.hostedDomain, - // The js lib wants a space-separated list of values - scope: params.scopes.join(' '), + await _isJsSdkLoaded; + + // Preserve the requested scopes to use them later in the `signIn` method. + _initialScopes = List.from(params.scopes); + + // Configure the Streams of credential (authentication) + // and token (authorization) responses + _tokenResponses = StreamController.broadcast(); + _credentialResponses = StreamController.broadcast(); + _tokenResponses.stream.listen((TokenResponse response) { + _lastTokenResponse = response; + }, onError: (Object error) { + _lastTokenResponse = null; + }); + _credentialResponses.stream.listen((CredentialResponse response) { + _lastCredentialResponse = response; + }, onError: (Object error) { + _lastCredentialResponse = null; + }); + + // TODO: Expose some form of 'debug' mode from the plugin? + // TODO: Remove this before releasing. + id.setLogLevel('debug'); + + // Initialize `id` for the silent-sign in code. + final IdConfiguration idConfig = IdConfiguration( client_id: appClientId!, - plugin_name: 'dart-google_sign_in_web', - )); + callback: allowInterop(_onCredentialResponse), + cancel_on_tap_outside: false, + auto_select: true, // Attempt to sign-in silently. + ); + id.initialize(idConfig); + + // Create a Token Client for authorization calls. + final TokenClientConfig tokenConfig = TokenClientConfig( + client_id: appClientId, + hosted_domain: params.hostedDomain, + callback: allowInterop(_onTokenResponse), + error_callback: allowInterop(_onTokenError), + // `scope` will be modified in the `signIn` method + scope: ' ', + ); + _tokenClient = oauth2.initTokenClient(tokenConfig); - final Completer isAuthInitialized = Completer(); - _isAuthInitialized = isAuthInitialized.future; _isInitCalled = true; + return; + } - auth.then(allowInterop((auth2.GoogleAuth initializedAuth) { - // onSuccess - - // TODO(ditman): https://github.com/flutter/flutter/issues/48528 - // This plugin doesn't notify the app of external changes to the - // state of the authentication, i.e: if you logout elsewhere... + // Handle a "normal" credential (authentication) response. + // + // (Normal doesn't mean successful, this might contain `error` information.) + void _onCredentialResponse(CredentialResponse response) { + if (response.error != null) { + _credentialResponses.addError(response.error!); + } else { + _credentialResponses.add(response); + } + } - isAuthInitialized.complete(); - }), allowInterop((auth2.GoogleAuthInitFailureError reason) { - // onError - isAuthInitialized.completeError(PlatformException( - code: reason.error, - message: reason.details, - details: - 'https://developers.google.com/identity/sign-in/web/reference#error_codes', - )); - })); + // Handle a "normal" token (authorization) response. + // + // (Normal doesn't mean successful, this might contain `error` information.) + void _onTokenResponse(TokenResponse response) { + if (response.error != null) { + _tokenResponses.addError(response.error!); + } else { + _tokenResponses.add(response); + } + } - return _isAuthInitialized; + // Handle a "not-directly-related-to-authorization" error. + // + // Token clients have an additional `error_callback` for miscellaneous + // errors, like "popup couldn't open" or "popup closed by user". + void _onTokenError(Object? error) { + // This is handled in a funky (js_interop) way because of: + // https://github.com/dart-lang/sdk/issues/50899 + _tokenResponses.addError(getProperty(error!, 'type')); } @override Future signInSilently() async { await initialized; - return gapiUserToPluginUserData( - auth2.getAuthInstance()?.currentUser?.get()); + final Completer userDataCompleter = + Completer(); + + // Ask the SDK to render the OneClick sign-in. + id.prompt(allowInterop((PromptMomentNotification moment) { + // Kick our handler to the bottom of the JS event queue, so the + // _credentialResponses stream has time to propagate its last + // value, so we can use _lastCredentialResponse in _onPromptMoment. + Future.delayed(Duration.zero, () { + _onPromptMoment(moment, userDataCompleter); + }); + })); + + return userDataCompleter.future; + } + + // Handles "prompt moments" of the OneClick card UI. + // + // See: https://developers.google.com/identity/gsi/web/guides/receive-notifications-prompt-ui-status + Future _onPromptMoment( + PromptMomentNotification moment, + Completer completer, + ) async { + if (completer.isCompleted) { + return; // Skip once the moment has been handled. + } + + if (moment.isDismissedMoment() && + moment.getDismissedReason() == + MomentDismissedReason.credential_returned) { + // This could resolve with the returned credential, like so: + // + // completer.complete( + // utils.gisResponsesToUserData( + // _lastCredentialResponse, + // )); + // + // But since the credential is not performing any authorization, and current + // apps expect that, we simulate a "failure" here. + // + // A successful `silentSignIn` however, will prevent an extra request later + // when requesting oauth2 tokens at `signIn`. + completer.complete(null); + return; + } + + // In any other 'failed' moments, return null and add an error to the stream. + if (moment.isNotDisplayed() || + moment.isSkippedMoment() || + moment.isDismissedMoment()) { + final String reason = moment.getNotDisplayedReason()?.toString() ?? + moment.getSkippedReason()?.toString() ?? + moment.getDismissedReason()?.toString() ?? + 'silentSignIn failed.'; + + _credentialResponses.addError(reason); + completer.complete(null); + } } @override Future signIn() async { await initialized; + final Completer userDataCompleter = + Completer(); + try { - return gapiUserToPluginUserData(await auth2.getAuthInstance()?.signIn()); - } on auth2.GoogleAuthSignInError catch (reason) { + // This toggles a popup, so `signIn` *must* be called with + // user activation. + _tokenClient.requestAccessToken(OverridableTokenClientConfig( + scope: [ + ..._initialScopes, + // If the user hasn't gone through the auth process, + // the plugin will attempt to `requestUserData` after, + // so we need extra scopes to retrieve that info. + if (_lastCredentialResponse == null) ...people.scopes, + ].join(' '), + )); + + // This stream is modified from _onTokenResponse and _onTokenError. + await _tokenResponses.stream.first; + + // If the user hasn't authenticated, request their basic profile info + // from the People API. + // + // This synthetic response will *not* contain an `idToken` field. + if (_lastCredentialResponse == null && _requestedUserData == null) { + assert(_lastTokenResponse != null); + _requestedUserData = await people.requestUserData( + _lastTokenResponse!, + _lastCredentialResponse?.credential, + ); + } + // Complete user data either with the _lastCredentialResponse seen, + // or the synthetic _requestedUserData from above. + userDataCompleter.complete( + utils.gisResponsesToUserData(_lastCredentialResponse) ?? + _requestedUserData); + } catch (reason) { throw PlatformException( - code: reason.error, - message: 'Exception raised from GoogleAuth.signIn()', + code: reason.toString(), + message: 'Exception raised from signIn', details: - 'https://developers.google.com/identity/sign-in/web/reference#error_codes_2', + 'https://developers.google.com/identity/oauth2/web/guides/error', ); } + + return userDataCompleter.future; } @override - Future getTokens( - {required String email, bool? shouldRecoverAuth}) async { + Future getTokens({ + required String email, + bool? shouldRecoverAuth, + }) async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - final auth2.AuthResponse? response = currentUser?.getAuthResponse(); - - return GoogleSignInTokenData( - idToken: response?.id_token, accessToken: response?.access_token); + return utils.gisResponsesToTokenData( + _lastCredentialResponse, + _lastTokenResponse, + ); } @override Future signOut() async { await initialized; - return auth2.getAuthInstance()?.signOut(); + clearAuthCache(); + id.disableAutoSelect(); } @override Future disconnect() async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - - if (currentUser == null) { - return; + if (_lastTokenResponse != null) { + oauth2.revoke(_lastTokenResponse!.access_token); } - - return currentUser.disconnect(); + signOut(); } @override Future isSignedIn() async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - - if (currentUser == null) { - return false; - } - - return currentUser.isSignedIn(); + return _lastCredentialResponse != null || _requestedUserData != null; } @override - Future clearAuthCache({required String token}) async { + Future clearAuthCache({String token = 'unused_in_web'}) async { await initialized; - return auth2.getAuthInstance()?.disconnect(); + _lastCredentialResponse = null; + _lastTokenResponse = null; + _requestedUserData = null; } @override Future requestScopes(List scopes) async { await initialized; - final auth2.GoogleUser? currentUser = - auth2.getAuthInstance()?.currentUser?.get(); - - if (currentUser == null) { - return false; - } - - final String grantedScopes = currentUser.getGrantedScopes() ?? ''; - final Iterable missingScopes = - scopes.where((String scope) => !grantedScopes.contains(scope)); - - if (missingScopes.isEmpty) { - return true; - } + _tokenClient.requestAccessToken(OverridableTokenClientConfig( + scope: scopes.join(' '), + )); - final Object? response = await currentUser - .grant(auth2.SigninOptions(scope: missingScopes.join(' '))); + await _tokenResponses.stream.first; - return response != null; + return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); } } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart b/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart index 57b91838b8f1..23a6a59b1e1e 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart @@ -1,7 +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. - +/* @JS() library gapi_onload; @@ -57,3 +57,4 @@ Future init() { // After this resolves, we can use gapi.auth2! return gapiLoadCompleter.future; } +*/ diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart new file mode 100644 index 000000000000..82064e5ad006 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart @@ -0,0 +1,120 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:google_identity_services_web/oauth2.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:http/http.dart' as http; + +/// Basic scopes for self-id +const List scopes = [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email', +]; + +/// People API to return my profile info... +const String MY_PROFILE = 'https://content-people.googleapis.com/v1/people/me' + '?sources=READ_SOURCE_TYPE_PROFILE' + '&personFields=photos%2Cnames%2CemailAddresses'; + +/// Requests user data from the People API. +/// +/// The `idToken` is an optional string that the plugin may have, if the user +/// has consented to the `silentSignIn` flow. +Future requestUserData( + TokenResponse tokenResponse, String? idToken) async { + print('[google_sign_in_web]: Attempting to fetch people/me PROFILE info.'); + + // Request my profile from the People API... + final Map profile = await _get(tokenResponse, MY_PROFILE); + + print('[google_sign_in_web]: Handling response $profile'); + + // Now transform the JSON response into a GoogleSignInUserData... + final String? userId = _extractId(profile); + final String? email = _extractField( + profile['emailAddresses'] as List?, + 'value', + ); + + assert(userId != null); + assert(email != null); + + return GoogleSignInUserData( + id: userId!, + email: email!, + displayName: _extractField( + profile['names'] as List?, + 'displayName', + ), + photoUrl: _extractField( + profile['photos'] as List?, + 'url', + ), + idToken: idToken, + ); +} + +/// Extracts the UserID +String? _extractId(Map profile) { + final String? resourceName = profile['resourceName'] as String?; + return resourceName?.substring(7); +} + +String? _extractField(List? values, String fieldName) { + if (values != null) { + for (final Object? value in values) { + if (value != null && value is Map) { + final bool isPrimary = _deepGet(value, + path: ['metadata', 'primary'], defaultValue: false); + if (isPrimary) { + return value[fieldName] as String?; + } + } + } + } + + return null; +} + +/// Attempts to get a property of type `T` from a deeply nested object. +/// +/// Returns `default` if the property is not found. +T _deepGet( + Map source, { + required List path, + required T defaultValue, +}) { + final String value = path.removeLast(); + Object? data = source; + for (final String index in path) { + if (data != null && data is Map) { + data = data[index]; + } else { + break; + } + } + if (data != null && data is Map) { + return (data[value] ?? defaultValue) as T; + } else { + return defaultValue; + } +} + +/// Gets from [url] with an authorization header defined by [token]. +/// +/// Attempts to [jsonDecode] the result. +Future> _get(TokenResponse token, String url) async { + final Uri uri = Uri.parse(url); + final http.Response response = await http.get(uri, headers: { + 'Authorization': '${token.token_type} ${token.access_token}', + }); + + if (response.statusCode != 200) { + throw http.ClientException(response.body, uri); + } + + return jsonDecode(response.body) as Map; +} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart index 45acb1ffd7ed..0b4e3416f7b5 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart @@ -2,59 +2,42 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; -import 'dart:html' as html; - +import 'package:google_identity_services_web/id.dart'; +import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:jwt_decoder/jwt_decoder.dart' as jwt; -import 'js_interop/gapiauth2.dart' as auth2; - -/// Injects a list of JS [libraries] as `script` tags into a [target] [html.HtmlElement]. -/// -/// If [target] is not provided, it defaults to the web app's `head` tag (see `web/index.html`). -/// [libraries] is a list of URLs that are used as the `src` attribute of `script` tags -/// to which an `onLoad` listener is attached (one per URL). +/// Converts a [CredentialResponse] into a [GoogleSignInUserData]. /// -/// Returns a [Future] that resolves when all of the `script` tags `onLoad` events trigger. -Future injectJSLibraries( - List libraries, { - html.HtmlElement? target, -}) { - final List> loading = >[]; - final List tags = []; - - final html.Element targetElement = target ?? html.querySelector('head')!; - - for (final String library in libraries) { - final html.ScriptElement script = html.ScriptElement() - ..async = true - ..defer = true - // ignore: unsafe_html - ..src = library; - // TODO(ditman): add a timeout race to fail this future - loading.add(script.onLoad.first); - tags.add(script); +/// 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; } - targetElement.children.addAll(tags); - return Future.wait(loading); -} + final Map? payload = + jwt.JwtDecoder.tryDecode(credentialResponse.credential!); -/// Utility method that converts `currentUser` to the equivalent [GoogleSignInUserData]. -/// -/// This method returns `null` when the [currentUser] is not signed in. -GoogleSignInUserData? gapiUserToPluginUserData(auth2.GoogleUser? currentUser) { - final bool isSignedIn = currentUser?.isSignedIn() ?? false; - final auth2.BasicProfile? profile = currentUser?.getBasicProfile(); - if (!isSignedIn || profile?.getId() == null) { + if (payload == null) { return null; } return GoogleSignInUserData( - displayName: profile?.getName(), - email: profile?.getEmail() ?? '', - id: profile?.getId() ?? '', - photoUrl: profile?.getImageUrl(), - idToken: currentUser?.getAuthResponse().id_token, + email: payload['email']! as String, + id: payload['sub']! as String, + displayName: payload['name']! as String, + photoUrl: payload['picture']! as String, + idToken: credentialResponse.credential, + ); +} + +/// Converts responses from the GIS library into TokenData for the plugin. +GoogleSignInTokenData gisResponsesToTokenData( + CredentialResponse? credentialResponse, TokenResponse? tokenResponse) { + return GoogleSignInTokenData( + idToken: credentialResponse?.credential, + accessToken: tokenResponse?.access_token, ); } diff --git a/packages/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml index a9d39471c3ed..d9d03465c721 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/plugins/tree/main/packages/google_sign_in/google_sign_in_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22 -version: 0.10.2+1 +version: 0.11.0 environment: sdk: ">=2.12.0 <3.0.0" @@ -22,8 +22,11 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter + google_identity_services_web: ^0.2.0 google_sign_in_platform_interface: ^2.2.0 + http: ^0.13.5 js: ^0.6.3 + jwt_decoder: ^2.0.1 dev_dependencies: flutter_test: From 095feaf433326cce52e85481075bd0507a995f03 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Fri, 6 Jan 2023 20:27:28 -0800 Subject: [PATCH 02/31] include_granted_scopes in requestScopes call. --- .../google_sign_in_web/lib/google_sign_in_web.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index 04a0538aad8e..41bd5117d225 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -371,6 +371,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { _tokenClient.requestAccessToken(OverridableTokenClientConfig( scope: scopes.join(' '), + include_granted_scopes: true, )); await _tokenResponses.stream.first; From 60ffbb3aa0c72189ae89b1f2152708890d8221f0 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 10 Jan 2023 17:07:22 -0800 Subject: [PATCH 03/31] Remove the old JS-interop layer. --- .../lib/src/js_interop/gapi.dart | 56 -- .../lib/src/js_interop/gapiauth2.dart | 497 ------------------ .../google_sign_in_web/lib/src/load_gapi.dart | 60 --- 3 files changed, 613 deletions(-) delete mode 100644 packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapi.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapiauth2.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapi.dart b/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapi.dart deleted file mode 100644 index 3be4b2d77b66..000000000000 --- a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapi.dart +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -/// Type definitions for Google API Client -/// Project: https://github.com/google/google-api-javascript-client -/// Definitions by: Frank M , grant -/// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped -/// TypeScript Version: 2.3 - -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/gapi - -// ignore_for_file: public_member_api_docs, -// * public_member_api_docs originally undocumented because the file was -// autogenerated. - -@JS() -library gapi; - -import 'package:js/js.dart'; - -// Module gapi -typedef LoadCallback = void Function( - [dynamic args1, - dynamic args2, - dynamic args3, - dynamic args4, - dynamic args5]); - -@anonymous -@JS() -abstract class LoadConfig { - external factory LoadConfig( - {LoadCallback callback, - Function? onerror, - num? timeout, - Function? ontimeout}); - external LoadCallback get callback; - external set callback(LoadCallback v); - external Function? get onerror; - external set onerror(Function? v); - external num? get timeout; - external set timeout(num? v); - external Function? get ontimeout; - external set ontimeout(Function? v); -} - -/*type CallbackOrConfig = LoadConfig | LoadCallback;*/ -/// Pragmatically initialize gapi class member. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiloadlibraries-callbackorconfig -@JS('gapi.load') -external void load( - String apiName, dynamic /*LoadConfig|LoadCallback*/ callback); -// End module gapi - -// Manually removed gapi.auth and gapi.client, unused by this plugin. diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapiauth2.dart b/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapiauth2.dart deleted file mode 100644 index 35a2d08e74b6..000000000000 --- a/packages/google_sign_in/google_sign_in_web/lib/src/js_interop/gapiauth2.dart +++ /dev/null @@ -1,497 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -/// Type definitions for non-npm package Google Sign-In API 0.0 -/// Project: https://developers.google.com/identity/sign-in/web/ -/// Definitions by: Derek Lawless -/// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped -/// TypeScript Version: 2.3 - -/// - -// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/gapi.auth2 - -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, -// * public_member_api_docs originally undocumented because the file was -// autogenerated. -// * non_constant_identifier_names required to be able to use the same parameter -// names as the underlying library. - -@JS() -library gapiauth2; - -import 'package:js/js.dart'; -import 'package:js/js_util.dart' show promiseToFuture; - -@anonymous -@JS() -class GoogleAuthInitFailureError { - external String get error; - external set error(String? value); - - external String get details; - external set details(String? value); -} - -@anonymous -@JS() -class GoogleAuthSignInError { - external String get error; - external set error(String value); -} - -@anonymous -@JS() -class OfflineAccessResponse { - external String? get code; - external set code(String? value); -} - -// Module gapi.auth2 -/// GoogleAuth is a singleton class that provides methods to allow the user to sign in with a Google account, -/// get the user's current sign-in status, get specific data from the user's Google profile, -/// request additional scopes, and sign out from the current account. -@JS('gapi.auth2.GoogleAuth') -class GoogleAuth { - external IsSignedIn get isSignedIn; - external set isSignedIn(IsSignedIn v); - external CurrentUser? get currentUser; - external set currentUser(CurrentUser? v); - - /// Calls the onInit function when the GoogleAuth object is fully initialized, or calls the onFailure function if - /// initialization fails. - external dynamic then(dynamic Function(GoogleAuth googleAuth) onInit, - [dynamic Function(GoogleAuthInitFailureError reason) onFailure]); - - /// Signs out all accounts from the application. - external dynamic signOut(); - - /// Revokes all of the scopes that the user granted. - external dynamic disconnect(); - - /// Attaches the sign-in flow to the specified container's click handler. - external dynamic attachClickHandler( - dynamic container, - SigninOptions options, - dynamic Function(GoogleUser googleUser) onsuccess, - dynamic Function(String reason) onfailure); -} - -@anonymous -@JS() -abstract class _GoogleAuth { - external Promise signIn( - [dynamic /*SigninOptions|SigninOptionsBuilder*/ options]); - external Promise grantOfflineAccess( - [OfflineAccessOptions? options]); -} - -extension GoogleAuthExtensions on GoogleAuth { - Future signIn( - [dynamic /*SigninOptions|SigninOptionsBuilder*/ options]) { - final _GoogleAuth tt = this as _GoogleAuth; - return promiseToFuture(tt.signIn(options)); - } - - Future grantOfflineAccess( - [OfflineAccessOptions? options]) { - final _GoogleAuth tt = this as _GoogleAuth; - return promiseToFuture(tt.grantOfflineAccess(options)); - } -} - -@anonymous -@JS() -abstract class IsSignedIn { - /// Returns whether the current user is currently signed in. - external bool get(); - - /// Listen for changes in the current user's sign-in state. - external void listen(dynamic Function(bool signedIn) listener); -} - -@anonymous -@JS() -abstract class CurrentUser { - /// Returns a GoogleUser object that represents the current user. Note that in a newly-initialized - /// GoogleAuth instance, the current user has not been set. Use the currentUser.listen() method or the - /// GoogleAuth.then() to get an initialized GoogleAuth instance. - external GoogleUser get(); - - /// Listen for changes in currentUser. - external void listen(dynamic Function(GoogleUser user) listener); -} - -@anonymous -@JS() -abstract class SigninOptions { - external factory SigninOptions( - {String app_package_name, - bool fetch_basic_profile, - String prompt, - String scope, - String /*'popup'|'redirect'*/ ux_mode, - String redirect_uri, - String login_hint}); - - /// The package name of the Android app to install over the air. - /// See Android app installs from your web site: - /// https://developers.google.com/identity/sign-in/web/android-app-installs - external String? get app_package_name; - external set app_package_name(String? v); - - /// Fetch users' basic profile information when they sign in. - /// Adds 'profile', 'email' and 'openid' to the requested scopes. - /// True if unspecified. - external bool? get fetch_basic_profile; - external set fetch_basic_profile(bool? v); - - /// Specifies whether to prompt the user for re-authentication. - /// See OpenID Connect Request Parameters: - /// https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters - external String? get prompt; - external set prompt(String? v); - - /// The scopes to request, as a space-delimited string. - /// Optional if fetch_basic_profile is not set to false. - external String? get scope; - external set scope(String? v); - - /// The UX mode to use for the sign-in flow. - /// By default, it will open the consent flow in a popup. - external String? /*'popup'|'redirect'*/ get ux_mode; - external set ux_mode(String? /*'popup'|'redirect'*/ v); - - /// If using ux_mode='redirect', this parameter allows you to override the default redirect_uri that will be used at the end of the consent flow. - /// The default redirect_uri is the current URL stripped of query parameters and hash fragment. - external String? get redirect_uri; - external set redirect_uri(String? v); - - // When your app knows which user it is trying to authenticate, it can provide this parameter as a hint to the authentication server. - // Passing this hint suppresses the account chooser and either pre-fill the email box on the sign-in form, or select the proper session (if the user is using multiple sign-in), - // which can help you avoid problems that occur if your app logs in the wrong user account. The value can be either an email address or the sub string, - // which is equivalent to the user's Google ID. - // https://developers.google.com/identity/protocols/OpenIDConnect?hl=en#authenticationuriparameters - external String? get login_hint; - external set login_hint(String? v); -} - -/// Definitions by: John -/// Interface that represents the different configuration parameters for the GoogleAuth.grantOfflineAccess(options) method. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2offlineaccessoptions -@anonymous -@JS() -abstract class OfflineAccessOptions { - external factory OfflineAccessOptions( - {String scope, - String /*'select_account'|'consent'*/ prompt, - String app_package_name}); - external String? get scope; - external set scope(String? v); - external String? /*'select_account'|'consent'*/ get prompt; - external set prompt(String? /*'select_account'|'consent'*/ v); - external String? get app_package_name; - external set app_package_name(String? v); -} - -/// Interface that represents the different configuration parameters for the gapi.auth2.init method. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2clientconfig -@anonymous -@JS() -abstract class ClientConfig { - external factory ClientConfig({ - String client_id, - String cookie_policy, - String scope, - bool fetch_basic_profile, - String? hosted_domain, - String openid_realm, - String /*'popup'|'redirect'*/ ux_mode, - String redirect_uri, - String plugin_name, - }); - - /// The app's client ID, found and created in the Google Developers Console. - external String? get client_id; - external set client_id(String? v); - - /// The domains for which to create sign-in cookies. Either a URI, single_host_origin, or none. - /// Defaults to single_host_origin if unspecified. - external String? get cookie_policy; - external set cookie_policy(String? v); - - /// The scopes to request, as a space-delimited string. Optional if fetch_basic_profile is not set to false. - external String? get scope; - external set scope(String? v); - - /// Fetch users' basic profile information when they sign in. Adds 'profile' and 'email' to the requested scopes. True if unspecified. - external bool? get fetch_basic_profile; - external set fetch_basic_profile(bool? v); - - /// The Google Apps domain to which users must belong to sign in. This is susceptible to modification by clients, - /// so be sure to verify the hosted domain property of the returned user. Use GoogleUser.getHostedDomain() on the client, - /// and the hd claim in the ID Token on the server to verify the domain is what you expected. - external String? get hosted_domain; - external set hosted_domain(String? v); - - /// Used only for OpenID 2.0 client migration. Set to the value of the realm that you are currently using for OpenID 2.0, - /// as described in OpenID 2.0 (Migration). - external String? get openid_realm; - external set openid_realm(String? v); - - /// The UX mode to use for the sign-in flow. - /// By default, it will open the consent flow in a popup. - external String? /*'popup'|'redirect'*/ get ux_mode; - external set ux_mode(String? /*'popup'|'redirect'*/ v); - - /// If using ux_mode='redirect', this parameter allows you to override the default redirect_uri that will be used at the end of the consent flow. - /// The default redirect_uri is the current URL stripped of query parameters and hash fragment. - external String? get redirect_uri; - external set redirect_uri(String? v); - - /// Allows newly created Client IDs to use the Google Platform Library from now until the March 30th, 2023 deprecation date. - /// See: https://github.com/flutter/flutter/issues/88084 - external String? get plugin_name; - external set plugin_name(String? v); -} - -@JS('gapi.auth2.SigninOptionsBuilder') -class SigninOptionsBuilder { - external dynamic setAppPackageName(String name); - external dynamic setFetchBasicProfile(bool fetch); - external dynamic setPrompt(String prompt); - external dynamic setScope(String scope); - external dynamic setLoginHint(String hint); -} - -@anonymous -@JS() -abstract class BasicProfile { - external String? getId(); - external String? getName(); - external String? getGivenName(); - external String? getFamilyName(); - external String? getImageUrl(); - external String? getEmail(); -} - -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authresponse -@anonymous -@JS() -abstract class AuthResponse { - external String? get access_token; - external set access_token(String? v); - external String? get id_token; - external set id_token(String? v); - external String? get login_hint; - external set login_hint(String? v); - external String? get scope; - external set scope(String? v); - external num? get expires_in; - external set expires_in(num? v); - external num? get first_issued_at; - external set first_issued_at(num? v); - external num? get expires_at; - external set expires_at(num? v); -} - -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authorizeconfig -@anonymous -@JS() -abstract class AuthorizeConfig { - external factory AuthorizeConfig( - {String client_id, - String scope, - String response_type, - String prompt, - String cookie_policy, - String hosted_domain, - String login_hint, - String app_package_name, - String openid_realm, - bool include_granted_scopes}); - external String get client_id; - external set client_id(String v); - external String get scope; - external set scope(String v); - external String? get response_type; - external set response_type(String? v); - external String? get prompt; - external set prompt(String? v); - external String? get cookie_policy; - external set cookie_policy(String? v); - external String? get hosted_domain; - external set hosted_domain(String? v); - external String? get login_hint; - external set login_hint(String? v); - external String? get app_package_name; - external set app_package_name(String? v); - external String? get openid_realm; - external set openid_realm(String? v); - external bool? get include_granted_scopes; - external set include_granted_scopes(bool? v); -} - -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authorizeresponse -@anonymous -@JS() -abstract class AuthorizeResponse { - external factory AuthorizeResponse( - {String access_token, - String id_token, - String code, - String scope, - num expires_in, - num first_issued_at, - num expires_at, - String error, - String error_subtype}); - external String get access_token; - external set access_token(String v); - external String get id_token; - external set id_token(String v); - external String get code; - external set code(String v); - external String get scope; - external set scope(String v); - external num get expires_in; - external set expires_in(num v); - external num get first_issued_at; - external set first_issued_at(num v); - external num get expires_at; - external set expires_at(num v); - external String get error; - external set error(String v); - external String get error_subtype; - external set error_subtype(String v); -} - -/// A GoogleUser object represents one user account. -@anonymous -@JS() -abstract class GoogleUser { - /// Get the user's unique ID string. - external String? getId(); - - /// Returns true if the user is signed in. - external bool isSignedIn(); - - /// Get the user's Google Apps domain if the user signed in with a Google Apps account. - external String? getHostedDomain(); - - /// Get the scopes that the user granted as a space-delimited string. - external String? getGrantedScopes(); - - /// Get the user's basic profile information. - external BasicProfile? getBasicProfile(); - - /// Get the response object from the user's auth session. - // This returns an empty JS object when the user hasn't attempted to sign in. - external AuthResponse getAuthResponse([bool includeAuthorizationData]); - - /// Returns true if the user granted the specified scopes. - external bool hasGrantedScopes(String scopes); - - // Has the API for grant and grantOfflineAccess changed? - /// Request additional scopes to the user. - /// - /// See GoogleAuth.signIn() for the list of parameters and the error code. - external dynamic grant( - [dynamic /*SigninOptions|SigninOptionsBuilder*/ options]); - - /// Get permission from the user to access the specified scopes offline. - /// When you use GoogleUser.grantOfflineAccess(), the sign-in flow skips the account chooser step. - /// See GoogleUser.grantOfflineAccess(). - external void grantOfflineAccess(String scopes); - - /// Revokes all of the scopes that the user granted. - external void disconnect(); -} - -@anonymous -@JS() -abstract class _GoogleUser { - /// Forces a refresh of the access token, and then returns a Promise for the new AuthResponse. - external Promise reloadAuthResponse(); -} - -extension GoogleUserExtensions on GoogleUser { - Future reloadAuthResponse() { - final _GoogleUser tt = this as _GoogleUser; - return promiseToFuture(tt.reloadAuthResponse()); - } -} - -/// Initializes the GoogleAuth object. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2initparams -@JS('gapi.auth2.init') -external GoogleAuth init(ClientConfig params); - -/// Returns the GoogleAuth object. You must initialize the GoogleAuth object with gapi.auth2.init() before calling this method. -@JS('gapi.auth2.getAuthInstance') -external GoogleAuth? getAuthInstance(); - -/// Performs a one time OAuth 2.0 authorization. -/// Reference: https://developers.google.com/api-client-library/javascript/reference/referencedocs#gapiauth2authorizeparams-callback -@JS('gapi.auth2.authorize') -external void authorize( - AuthorizeConfig params, void Function(AuthorizeResponse response) callback); -// End module gapi.auth2 - -// Module gapi.signin2 -@JS('gapi.signin2.render') -external void render( - dynamic id, - dynamic - /*{ - /** - * The auth scope or scopes to authorize. Auth scopes for individual APIs can be found in their documentation. - */ - scope?: string; - - /** - * The width of the button in pixels (default: 120). - */ - width?: number; - - /** - * The height of the button in pixels (default: 36). - */ - height?: number; - - /** - * Display long labels such as "Sign in with Google" rather than "Sign in" (default: false). - */ - longtitle?: boolean; - - /** - * The color theme of the button: either light or dark (default: light). - */ - theme?: string; - - /** - * The callback function to call when a user successfully signs in (default: none). - */ - onsuccess?(user: auth2.GoogleUser): void; - - /** - * The callback function to call when sign-in fails (default: none). - */ - onfailure?(reason: { error: string }): void; - - /** - * The package name of the Android app to install over the air. See - * Android app installs from your web site. - * Optional. (default: none) - */ - app_package_name?: string; - }*/ - options); - -// End module gapi.signin2 -@JS() -abstract class Promise { - external factory Promise( - void Function(void Function(T result) resolve, Function reject) executor); -} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart b/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart deleted file mode 100644 index 23a6a59b1e1e..000000000000 --- a/packages/google_sign_in/google_sign_in_web/lib/src/load_gapi.dart +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. -/* -@JS() -library gapi_onload; - -import 'dart:async'; - -import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:js/js.dart'; - -import 'js_interop/gapi.dart' as gapi; -import 'utils.dart' show injectJSLibraries; - -@JS() -external set gapiOnloadCallback(Function callback); - -// This name must match the external setter above -/// This is only exposed for testing. It shouldn't be accessed by users of the -/// plugin as it could break at any point. -@visibleForTesting -const String kGapiOnloadCallbackFunctionName = 'gapiOnloadCallback'; -String _addOnloadToScript(String url) => url.startsWith('data:') - ? url - : '$url?onload=$kGapiOnloadCallbackFunctionName'; - -/// Injects the GAPI library by its [url], and other additional [libraries]. -/// -/// GAPI has an onload API where it'll call a callback when it's ready, JSONP style. -Future inject(String url, {List libraries = const []}) { - // Inject the GAPI library, and configure the onload global - final Completer gapiOnLoad = Completer(); - gapiOnloadCallback = allowInterop(() { - // Funnel the GAPI onload to a Dart future - gapiOnLoad.complete(); - }); - - // Attach the onload callback to the main url - final List allLibraries = [ - _addOnloadToScript(url), - ...libraries - ]; - - return Future.wait( - >[injectJSLibraries(allLibraries), gapiOnLoad.future]); -} - -/// Initialize the global gapi object so 'auth2' can be used. -/// Returns a promise that resolves when 'auth2' is ready. -Future init() { - final Completer gapiLoadCompleter = Completer(); - gapi.load('auth2', allowInterop(() { - gapiLoadCompleter.complete(); - })); - - // After this resolves, we can use gapi.auth2! - return gapiLoadCompleter.future; -} -*/ From 66cebf30e2e9395ac6945915ea0c0ce6b58ef3e1 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 11 Jan 2023 19:21:10 -0800 Subject: [PATCH 04/31] Introduce a mockable GisSdkClient for tests. --- .../lib/google_sign_in_web.dart | 253 ++------------- .../lib/src/gis_client.dart | 303 ++++++++++++++++++ .../google_sign_in_web/lib/src/people.dart | 3 - 3 files changed, 338 insertions(+), 221 deletions(-) create mode 100644 packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index 41bd5117d225..94cbd84873a6 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -8,15 +8,10 @@ import 'dart:html' as html; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; -import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/loader.dart' as loader; -import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:js/js.dart'; -import 'package:js/js_util.dart'; -import 'src/people.dart' as people; -import 'src/utils.dart' as utils; +import 'src/gis_client.dart'; const String _kClientIdMetaSelector = 'meta[name=google-signin-client_id]'; const String _kClientIdAttributeName = 'content'; @@ -38,30 +33,8 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { late Future _isJsSdkLoaded; bool _isInitCalled = false; - // The scopes initially requested by the developer. - // - // We store this because we might need to add more at `signIn`, if the user - // doesn't `silentSignIn`, we expand this list to consult the People API to - // return some basic Authentication information. - late List _initialScopes; - - // The Google Identity Services client for oauth requests. - late TokenClient _tokenClient; - - // Streams of credential and token responses. - late StreamController _credentialResponses; - late StreamController _tokenResponses; - - // The last-seen credential and token responses - CredentialResponse? _lastCredentialResponse; - TokenResponse? _lastTokenResponse; - - // If the user *authenticates* (signs in) through oauth2, the SDK doesn't return - // identity information anymore, so we synthesize it by calling the PeopleAPI - // (if needed) - // - // (This is a synthetic _lastCredentialResponse) - GoogleSignInUserData? _requestedUserData; + // The instance of [GisSdkClient] backing the plugin. + late GisSdkClient _gisClient; // This method throws if init or initWithParams hasn't been called at some // point in the past. It is used by the [initialized] getter to ensure that @@ -80,7 +53,6 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { Future get initialized { _assertIsInitCalled(); return _isJsSdkLoaded; - // TODO: make _isInitCalled a future that resolves when `init` is called } // Stores the clientId found in the DOM (if any). @@ -126,186 +98,49 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { await _isJsSdkLoaded; - // Preserve the requested scopes to use them later in the `signIn` method. - _initialScopes = List.from(params.scopes); - - // Configure the Streams of credential (authentication) - // and token (authorization) responses - _tokenResponses = StreamController.broadcast(); - _credentialResponses = StreamController.broadcast(); - _tokenResponses.stream.listen((TokenResponse response) { - _lastTokenResponse = response; - }, onError: (Object error) { - _lastTokenResponse = null; - }); - _credentialResponses.stream.listen((CredentialResponse response) { - _lastCredentialResponse = response; - }, onError: (Object error) { - _lastCredentialResponse = null; - }); - - // TODO: Expose some form of 'debug' mode from the plugin? - // TODO: Remove this before releasing. - id.setLogLevel('debug'); - - // Initialize `id` for the silent-sign in code. - final IdConfiguration idConfig = IdConfiguration( - client_id: appClientId!, - callback: allowInterop(_onCredentialResponse), - cancel_on_tap_outside: false, - auto_select: true, // Attempt to sign-in silently. + final GisSdkClient gisClient = GisSdkClient( + clientId: appClientId!, + hostedDomain: params.hostedDomain, + initialScopes: List.from(params.scopes), + // *TODO(dit): Remove this before releasing. + loggingEnabled: true, ); - id.initialize(idConfig); - - // Create a Token Client for authorization calls. - final TokenClientConfig tokenConfig = TokenClientConfig( - client_id: appClientId, - hosted_domain: params.hostedDomain, - callback: allowInterop(_onTokenResponse), - error_callback: allowInterop(_onTokenError), - // `scope` will be modified in the `signIn` method - scope: ' ', - ); - _tokenClient = oauth2.initTokenClient(tokenConfig); - - _isInitCalled = true; - return; - } - // Handle a "normal" credential (authentication) response. - // - // (Normal doesn't mean successful, this might contain `error` information.) - void _onCredentialResponse(CredentialResponse response) { - if (response.error != null) { - _credentialResponses.addError(response.error!); - } else { - _credentialResponses.add(response); - } + return initWithClient(gisClient); } - // Handle a "normal" token (authorization) response. - // - // (Normal doesn't mean successful, this might contain `error` information.) - void _onTokenResponse(TokenResponse response) { - if (response.error != null) { - _tokenResponses.addError(response.error!); - } else { - _tokenResponses.add(response); - } - } + /// Initializes the plugin with a pre-made [GisSdkClient], that can be overridden from tests. + @visibleForTesting + Future initWithClient(GisSdkClient gisClient) async { + _gisClient = gisClient; - // Handle a "not-directly-related-to-authorization" error. - // - // Token clients have an additional `error_callback` for miscellaneous - // errors, like "popup couldn't open" or "popup closed by user". - void _onTokenError(Object? error) { - // This is handled in a funky (js_interop) way because of: - // https://github.com/dart-lang/sdk/issues/50899 - _tokenResponses.addError(getProperty(error!, 'type')); + _isInitCalled = true; } @override Future signInSilently() async { await initialized; - final Completer userDataCompleter = - Completer(); - - // Ask the SDK to render the OneClick sign-in. - id.prompt(allowInterop((PromptMomentNotification moment) { - // Kick our handler to the bottom of the JS event queue, so the - // _credentialResponses stream has time to propagate its last - // value, so we can use _lastCredentialResponse in _onPromptMoment. - Future.delayed(Duration.zero, () { - _onPromptMoment(moment, userDataCompleter); - }); - })); - - return userDataCompleter.future; - } - - // Handles "prompt moments" of the OneClick card UI. - // - // See: https://developers.google.com/identity/gsi/web/guides/receive-notifications-prompt-ui-status - Future _onPromptMoment( - PromptMomentNotification moment, - Completer completer, - ) async { - if (completer.isCompleted) { - return; // Skip once the moment has been handled. - } - - if (moment.isDismissedMoment() && - moment.getDismissedReason() == - MomentDismissedReason.credential_returned) { - // This could resolve with the returned credential, like so: - // - // completer.complete( - // utils.gisResponsesToUserData( - // _lastCredentialResponse, - // )); - // - // But since the credential is not performing any authorization, and current - // apps expect that, we simulate a "failure" here. - // - // A successful `silentSignIn` however, will prevent an extra request later - // when requesting oauth2 tokens at `signIn`. - completer.complete(null); - return; - } - - // In any other 'failed' moments, return null and add an error to the stream. - if (moment.isNotDisplayed() || - moment.isSkippedMoment() || - moment.isDismissedMoment()) { - final String reason = moment.getNotDisplayedReason()?.toString() ?? - moment.getSkippedReason()?.toString() ?? - moment.getDismissedReason()?.toString() ?? - 'silentSignIn failed.'; - - _credentialResponses.addError(reason); - completer.complete(null); - } + // Since the new GIS SDK does *not* perform authorization at the same time as + // authentication (and every one of our users expects that), we need to tell + // the plugin that this failed regardless of the actual result. + // + // However, if this succeeds, we'll save a People API request later. + return _gisClient.signInSilently().then((_) => null); } @override Future signIn() async { await initialized; - final Completer userDataCompleter = - Completer(); + // This method mainly does oauth2 authorization, which happens to also do + // authentication if needed. However, the authentication information is not + // returned anymore. + // + // This method will synthesize authentication information from the People API + // if needed (or use the last identity seen from signInSilently). try { - // This toggles a popup, so `signIn` *must* be called with - // user activation. - _tokenClient.requestAccessToken(OverridableTokenClientConfig( - scope: [ - ..._initialScopes, - // If the user hasn't gone through the auth process, - // the plugin will attempt to `requestUserData` after, - // so we need extra scopes to retrieve that info. - if (_lastCredentialResponse == null) ...people.scopes, - ].join(' '), - )); - - // This stream is modified from _onTokenResponse and _onTokenError. - await _tokenResponses.stream.first; - - // If the user hasn't authenticated, request their basic profile info - // from the People API. - // - // This synthetic response will *not* contain an `idToken` field. - if (_lastCredentialResponse == null && _requestedUserData == null) { - assert(_lastTokenResponse != null); - _requestedUserData = await people.requestUserData( - _lastTokenResponse!, - _lastCredentialResponse?.credential, - ); - } - // Complete user data either with the _lastCredentialResponse seen, - // or the synthetic _requestedUserData from above. - userDataCompleter.complete( - utils.gisResponsesToUserData(_lastCredentialResponse) ?? - _requestedUserData); + return _gisClient.signIn(); } catch (reason) { throw PlatformException( code: reason.toString(), @@ -314,8 +149,6 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { 'https://developers.google.com/identity/oauth2/web/guides/error', ); } - - return userDataCompleter.future; } @override @@ -325,57 +158,41 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { }) async { await initialized; - return utils.gisResponsesToTokenData( - _lastCredentialResponse, - _lastTokenResponse, - ); + return _gisClient.getTokens(); } @override Future signOut() async { await initialized; - clearAuthCache(); - id.disableAutoSelect(); + _gisClient.signOut(); } @override Future disconnect() async { await initialized; - if (_lastTokenResponse != null) { - oauth2.revoke(_lastTokenResponse!.access_token); - } - signOut(); + _gisClient.disconnect(); } @override Future isSignedIn() async { await initialized; - return _lastCredentialResponse != null || _requestedUserData != null; + return _gisClient.isSignedIn(); } @override - Future clearAuthCache({String token = 'unused_in_web'}) async { + Future clearAuthCache({required String token}) async { await initialized; - _lastCredentialResponse = null; - _lastTokenResponse = null; - _requestedUserData = null; + _gisClient.clearAuthCache(); } @override Future requestScopes(List scopes) async { await initialized; - _tokenClient.requestAccessToken(OverridableTokenClientConfig( - scope: scopes.join(' '), - include_granted_scopes: true, - )); - - await _tokenResponses.stream.first; - - return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); + return _gisClient.requestScopes(scopes); } } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart new file mode 100644 index 000000000000..80d9bd14099f --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart @@ -0,0 +1,303 @@ +// 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:google_identity_services_web/id.dart'; +import 'package:google_identity_services_web/oauth2.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:js/js.dart'; +import 'package:js/js_util.dart'; + +import 'people.dart' as people; +import 'utils.dart' as utils; + +/// A client to hide (most) of the interaction with the GIS SDK from the plugin. +/// +/// (Overridable for testing) +class GisSdkClient { + /// Create a GisSdkClient object. + GisSdkClient({ + required List initialScopes, + required String clientId, + bool loggingEnabled = false, + String? hostedDomain, + }) : _initialScopes = initialScopes { + if (loggingEnabled) { + id.setLogLevel('debug'); + } + // Configure the Stream objects that are going to be used by the clients. + _configureStreams(); + + // Initialize the SDK clients we need. + _initializeIdClient( + clientId, + onResponse: _onCredentialResponse, + ); + _tokenClient = _initializeTokenClient( + clientId, + hostedDomain: hostedDomain, + onResponse: _onTokenResponse, + onError: _onTokenError, + ); + } + + // Configure the credential (authentication) and token (authorization) response streams. + void _configureStreams() { + _tokenResponses = StreamController.broadcast(); + _credentialResponses = StreamController.broadcast(); + _tokenResponses.stream.listen((TokenResponse response) { + _lastTokenResponse = response; + }, onError: (Object error) { + _lastTokenResponse = null; + }); + _credentialResponses.stream.listen((CredentialResponse response) { + _lastCredentialResponse = response; + }, onError: (Object error) { + _lastCredentialResponse = null; + }); + } + + // Initializes the `id` SDK for the silent-sign in (authentication) client. + void _initializeIdClient(String clientId, { + required CallbackFn onResponse, + }) { + // Initialize `id` for the silent-sign in code. + final IdConfiguration idConfig = IdConfiguration( + client_id: clientId, + callback: allowInterop(onResponse), + cancel_on_tap_outside: false, + auto_select: true, // Attempt to sign-in silently. + ); + id.initialize(idConfig); + } + + // Handle a "normal" credential (authentication) response. + // + // (Normal doesn't mean successful, this might contain `error` information.) + void _onCredentialResponse(CredentialResponse response) { + if (response.error != null) { + _credentialResponses.addError(response.error!); + } else { + _credentialResponses.add(response); + } + } + + // Creates a `oauth2.TokenClient` used for authorization (scope) requests. + TokenClient _initializeTokenClient(String clientId, { + String? hostedDomain, + required TokenClientCallbackFn onResponse, + required ErrorCallbackFn onError, + }) { + // Create a Token Client for authorization calls. + final TokenClientConfig tokenConfig = TokenClientConfig( + client_id: clientId, + hosted_domain: hostedDomain, + callback: allowInterop(_onTokenResponse), + error_callback: allowInterop(_onTokenError), + // `scope` will be modified by the `signIn` method, in case we need to + // backfill user Profile info. + scope: ' ', + ); + return oauth2.initTokenClient(tokenConfig); + } + + // Handle a "normal" token (authorization) response. + // + // (Normal doesn't mean successful, this might contain `error` information.) + void _onTokenResponse(TokenResponse response) { + if (response.error != null) { + _tokenResponses.addError(response.error!); + } else { + _tokenResponses.add(response); + } + } + + // Handle a "not-directly-related-to-authorization" error. + // + // Token clients have an additional `error_callback` for miscellaneous + // errors, like "popup couldn't open" or "popup closed by user". + void _onTokenError(Object? error) { + // This is handled in a funky (js_interop) way because of: + // https://github.com/dart-lang/sdk/issues/50899 + _tokenResponses.addError(getProperty(error!, 'type')); + } + + /// Attempts to sign-in the user using the OneTap UX flow. + /// + /// If the user consents, to OneTap, the [GoogleSignInUserData] will be + /// generated from a proper [CredentialResponse], which contains `idToken`. + /// Else, it'll be synthesized by a request to the People API later, and the + /// `idToken` will be null. + Future signInSilently() async { + final Completer userDataCompleter = + Completer(); + + // Ask the SDK to render the OneClick sign-in. + // + // And also handle its "moments". + id.prompt(allowInterop((PromptMomentNotification moment) { + _onPromptMoment(moment, userDataCompleter); + })); + + return userDataCompleter.future; + } + + // Handles "prompt moments" of the OneClick card UI. + // + // See: https://developers.google.com/identity/gsi/web/guides/receive-notifications-prompt-ui-status + Future _onPromptMoment( + PromptMomentNotification moment, + Completer completer, + ) async { + if (completer.isCompleted) { + return; // Skip once the moment has been handled. + } + + if (moment.isDismissedMoment() && + moment.getDismissedReason() == + MomentDismissedReason.credential_returned) { + // Kick this part of the handler to the bottom of the JS event queue, so + // the _credentialResponses stream has time to propagate its last value, + // and we can use _lastCredentialResponse. + return Future.delayed(Duration.zero, () { + completer.complete( + utils.gisResponsesToUserData(_lastCredentialResponse) + ); + }); + } + + // In any other 'failed' moments, return null and add an error to the stream. + if (moment.isNotDisplayed() || + moment.isSkippedMoment() || + moment.isDismissedMoment()) { + final String reason = moment.getNotDisplayedReason()?.toString() ?? + moment.getSkippedReason()?.toString() ?? + moment.getDismissedReason()?.toString() ?? + 'unknown_error'; + + _credentialResponses.addError(reason); + completer.complete(null); + } + } + + /// Starts an oauth2 "implicit" flow to authorize requests. + /// + /// The new GIS SDK does not return user authentication from this flow, so: + /// * If [_lastCredentialResponse] is **not** null (the user has successfully + /// `signInSilently`), we return that after this method completes. + /// * If [_lastCredentialResponse] is null, we add [people.scopes] to the + /// [_initialScopes], so we can retrieve User Profile information back + /// from the People API (without idToken). See [people.requestUserData]. + Future signIn() async { + // This toggles a popup, so `signIn` *must* be called with + // user activation. + _tokenClient.requestAccessToken(OverridableTokenClientConfig( + scope: [ + ..._initialScopes, + // If the user hasn't gone through the auth process, + // the plugin will attempt to `requestUserData` after, + // so we need extra scopes to retrieve that info. + if (_lastCredentialResponse == null) ...people.scopes, + ].join(' '), + )); + + await _tokenResponses.stream.first; + + return _computeUserDataForLastToken(); + } + + // This function returns the currently signed-in [GoogleSignInUserData]. + // + // It'll do a request to the People API (if needed). + Future _computeUserDataForLastToken() async { + // If the user hasn't authenticated, request their basic profile info + // from the People API. + // + // This synthetic response will *not* contain an `idToken` field. + if (_lastCredentialResponse == null && _requestedUserData == null) { + assert(_lastTokenResponse != null); + _requestedUserData = await people.requestUserData( + _lastTokenResponse!, + _lastCredentialResponse?.credential, + ); + } + // Complete user data either with the _lastCredentialResponse seen, + // or the synthetic _requestedUserData from above. + return utils.gisResponsesToUserData(_lastCredentialResponse) ?? + _requestedUserData; + } + + /// Returns a [GoogleSignInTokenData] from the latest seen responses. + GoogleSignInTokenData getTokens() { + return utils.gisResponsesToTokenData( + _lastCredentialResponse, + _lastTokenResponse, + ); + } + + /// Revokes the current authentication. + Future signOut() async { + clearAuthCache(); + id.disableAutoSelect(); + } + + /// Revokes the current authorization and authentication. + Future disconnect() async { + if (_lastTokenResponse != null) { + oauth2.revoke(_lastTokenResponse!.access_token); + } + signOut(); + } + + /// Returns true if the client has recognized this user before. + Future isSignedIn() async { + return _lastCredentialResponse != null || _requestedUserData != null; + } + + /// Clears all the cached results from authentication and authorization. + Future clearAuthCache() async { + _lastCredentialResponse = null; + _lastTokenResponse = null; + _requestedUserData = null; + } + + /// Requests the list of [scopes] passed in to the client. + /// + /// Keeps the previously granted scopes. + Future requestScopes(List scopes) async { + _tokenClient.requestAccessToken(OverridableTokenClientConfig( + scope: scopes.join(' '), + include_granted_scopes: true, + )); + + await _tokenResponses.stream.first; + + return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes); + } + + // The scopes initially requested by the developer. + // + // We store this because we might need to add more at `signIn`, if the user + // doesn't `silentSignIn`, we expand this list to consult the People API to + // return some basic Authentication information. + final List _initialScopes; + + // The Google Identity Services client for oauth requests. + late TokenClient _tokenClient; + + // Streams of credential and token responses. + late StreamController _credentialResponses; + late StreamController _tokenResponses; + + // The last-seen credential and token responses + CredentialResponse? _lastCredentialResponse; + TokenResponse? _lastTokenResponse; + + // If the user *authenticates* (signs in) through oauth2, the SDK doesn't return + // identity information anymore, so we synthesize it by calling the PeopleAPI + // (if needed) + // + // (This is a synthetic _lastCredentialResponse) + GoogleSignInUserData? _requestedUserData; +} diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart index 82064e5ad006..2d5cef72e017 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart @@ -25,13 +25,10 @@ const String MY_PROFILE = 'https://content-people.googleapis.com/v1/people/me' /// has consented to the `silentSignIn` flow. Future requestUserData( TokenResponse tokenResponse, String? idToken) async { - print('[google_sign_in_web]: Attempting to fetch people/me PROFILE info.'); // Request my profile from the People API... final Map profile = await _get(tokenResponse, MY_PROFILE); - print('[google_sign_in_web]: Handling response $profile'); - // Now transform the JSON response into a GoogleSignInUserData... final String? userId = _extractId(profile); final String? email = _extractField( From 1e88f00c748bdc111eacd06e5612929f747e3e0f Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 11 Jan 2023 20:46:29 -0800 Subject: [PATCH 05/31] Split the people utils. --- .../lib/src/gis_client.dart | 5 +- .../google_sign_in_web/lib/src/people.dart | 65 ++++++++++++------- 2 files changed, 42 insertions(+), 28 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 80d9bd14099f..93f753c03e48 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 @@ -217,10 +217,7 @@ class GisSdkClient { // This synthetic response will *not* contain an `idToken` field. if (_lastCredentialResponse == null && _requestedUserData == null) { assert(_lastTokenResponse != null); - _requestedUserData = await people.requestUserData( - _lastTokenResponse!, - _lastCredentialResponse?.credential, - ); + _requestedUserData = await people.requestUserData(_lastTokenResponse!); } // Complete user data either with the _lastCredentialResponse seen, // or the synthetic _requestedUserData from above. diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart index 2d5cef72e017..29d3581a8947 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart @@ -19,20 +19,23 @@ const String MY_PROFILE = 'https://content-people.googleapis.com/v1/people/me' '?sources=READ_SOURCE_TYPE_PROFILE' '&personFields=photos%2Cnames%2CemailAddresses'; -/// Requests user data from the People API. +/// Requests user data from the People API using the given [tokenResponse]. +Future requestUserData(TokenResponse tokenResponse) async { + + // Request my profile from the People API. + final Map person = await _get(tokenResponse, MY_PROFILE); + + // Now transform the Person response into a GoogleSignInUserData. + return extractUserData(person); +} + +/// Extracts user data from a Person resource. /// -/// The `idToken` is an optional string that the plugin may have, if the user -/// has consented to the `silentSignIn` flow. -Future requestUserData( - TokenResponse tokenResponse, String? idToken) async { - - // Request my profile from the People API... - final Map profile = await _get(tokenResponse, MY_PROFILE); - - // Now transform the JSON response into a GoogleSignInUserData... - final String? userId = _extractId(profile); - final String? email = _extractField( - profile['emailAddresses'] as List?, +/// See: https://developers.google.com/people/api/rest/v1/people#Person +GoogleSignInUserData? extractUserData(Map json) { + final String? userId = extractUserId(json); + final String? email = extractPrimaryField( + json['emailAddresses'] as List?, 'value', ); @@ -42,25 +45,39 @@ Future requestUserData( return GoogleSignInUserData( id: userId!, email: email!, - displayName: _extractField( - profile['names'] as List?, + displayName: extractPrimaryField( + json['names'] as List?, 'displayName', ), - photoUrl: _extractField( - profile['photos'] as List?, + photoUrl: extractPrimaryField( + json['photos'] as List?, 'url', ), - idToken: idToken, + // idToken: null, // Synthetic user data doesn't contain an idToken! ); } -/// Extracts the UserID -String? _extractId(Map profile) { +/// Extracts the ID from a Person resource. +/// +/// The User ID looks like this: +/// { +/// 'resourceName': 'people/PERSON_ID', +/// ... +/// } +String? extractUserId(Map profile) { final String? resourceName = profile['resourceName'] as String?; - return resourceName?.substring(7); + return resourceName?.split('/').last; } -String? _extractField(List? values, String fieldName) { +/// Extracts the [fieldName] marked as 'primary' from a list of [values]. +/// +/// Values can be one of: +/// * `emailAddresses` +/// * `names` +/// * `photos` +/// +/// From a Person object. +String? extractPrimaryField(List? values, String fieldName) { if (values != null) { for (final Object? value in values) { if (value != null && value is Map) { @@ -76,9 +93,9 @@ String? _extractField(List? values, String fieldName) { return null; } -/// Attempts to get a property of type `T` from a deeply nested object. +/// Attempts to get the property in [path] of type `T` from a deeply nested [source]. /// -/// Returns `default` if the property is not found. +/// Returns [default] if the property is not found. T _deepGet( Map source, { required List path, From 7f3b2812dbe8a94afe4fa4beabfdd3b4ea6b1702 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 11 Jan 2023 23:13:50 -0800 Subject: [PATCH 06/31] Delete tests for the old code. --- .../auth2_legacy_init_test.dart | 223 ----------------- .../example/integration_test/auth2_test.dart | 230 ------------------ .../gapi_load_legacy_init_test.dart | 51 ---- .../integration_test/gapi_load_test.dart | 50 ---- .../gapi_mocks/gapi_mocks.dart | 13 - .../gapi_mocks/src/auth2_init.dart | 109 --------- .../integration_test/gapi_mocks/src/gapi.dart | 12 - .../gapi_mocks/src/google_user.dart | 30 --- .../gapi_mocks/src/test_iife.dart | 15 -- .../integration_test/gapi_utils_test.dart | 70 ------ .../integration_test/src/test_utils.dart | 10 - 11 files changed, 813 deletions(-) delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_legacy_init_test.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_test.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_legacy_init_test.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_test.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/gapi_mocks.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/auth2_init.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/gapi.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/google_user.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/test_iife.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_utils_test.dart delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/src/test_utils.dart diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_legacy_init_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_legacy_init_test.dart deleted file mode 100644 index 5dada90397fa..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_legacy_init_test.dart +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// This file is a copy of `auth2_test.dart`, before it was migrated to the -// new `initWithParams` method, and is kept to ensure test coverage of the -// deprecated `init` method, until it is removed. - -import 'dart:html' as html; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:google_sign_in_web/google_sign_in_web.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:js/js_util.dart' as js_util; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - final GoogleSignInTokenData expectedTokenData = - GoogleSignInTokenData(idToken: '70k3n', accessToken: 'access_70k3n'); - - final GoogleSignInUserData expectedUserData = GoogleSignInUserData( - displayName: 'Foo Bar', - email: 'foo@example.com', - id: '123', - photoUrl: 'http://example.com/img.jpg', - idToken: expectedTokenData.idToken, - ); - - late GoogleSignInPlugin plugin; - - group('plugin.initialize() throws a catchable exception', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('initialize throws PlatformException', - (WidgetTester tester) async { - await expectLater( - plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ), - throwsA(isA())); - }); - - testWidgets('initialize forwards error code from JS', - (WidgetTester tester) async { - try { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - fail('plugin.initialize should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'idpiframe_initialization_failed'); - } - }); - }); - - group('other methods also throw catchable exceptions on initialize fail', () { - // This function ensures that initialize gets called, but for some reason, - // we ignored that it has thrown stuff... - Future discardInit() async { - try { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - } catch (e) { - // Noop so we can call other stuff - } - } - - setUp(() { - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('signInSilently throws', (WidgetTester tester) async { - await discardInit(); - await expectLater( - plugin.signInSilently(), throwsA(isA())); - }); - - testWidgets('signIn throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('getTokens throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.getTokens(email: 'test@example.com'), - throwsA(isA())); - }); - testWidgets('requestScopes', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.requestScopes(['newScope']), - throwsA(isA())); - }); - }); - - group('auth2 Init Successful', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess(expectedUserData)); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('Init requires clientId', (WidgetTester tester) async { - expect(plugin.init(hostedDomain: ''), throwsAssertionError); - }); - - testWidgets("Init doesn't accept spaces in scopes", - (WidgetTester tester) async { - expect( - plugin.init( - hostedDomain: '', - clientId: '', - scopes: ['scope with spaces'], - ), - throwsAssertionError); - }); - - // See: https://github.com/flutter/flutter/issues/88084 - testWidgets('Init passes plugin_name parameter with the expected value', - (WidgetTester tester) async { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - - final Object? initParameters = - js_util.getProperty(html.window, 'gapi2.init.parameters'); - expect(initParameters, isNotNull); - - final Object? pluginNameParameter = - js_util.getProperty(initParameters!, 'plugin_name'); - expect(pluginNameParameter, isA()); - expect(pluginNameParameter, 'dart-google_sign_in_web'); - }); - - group('Successful .initialize, then', () { - setUp(() async { - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - await plugin.initialized; - }); - - testWidgets('signInSilently', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = - (await plugin.signInSilently())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('signIn', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = (await plugin.signIn())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('getTokens', (WidgetTester tester) async { - final GoogleSignInTokenData actualToken = - await plugin.getTokens(email: expectedUserData.email); - - expect(actualToken, expectedTokenData); - }); - - testWidgets('requestScopes', (WidgetTester tester) async { - final bool scopeGranted = - await plugin.requestScopes(['newScope']); - - expect(scopeGranted, isTrue); - }); - }); - }); - - group('auth2 Init successful, but exception on signIn() method', () { - setUp(() async { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2SignInError()); - plugin = GoogleSignInPlugin(); - await plugin.init( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - ); - await plugin.initialized; - }); - - testWidgets('User aborts sign in flow, throws PlatformException', - (WidgetTester tester) async { - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('User aborts sign in flow, error code is forwarded from JS', - (WidgetTester tester) async { - try { - await plugin.signIn(); - fail('plugin.signIn() should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'popup_closed_by_user'); - } - }); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_test.dart deleted file mode 100644 index 3e803b83fa0c..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/auth2_test.dart +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:html' as html; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:google_sign_in_web/google_sign_in_web.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:js/js_util.dart' as js_util; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - final GoogleSignInTokenData expectedTokenData = - GoogleSignInTokenData(idToken: '70k3n', accessToken: 'access_70k3n'); - - final GoogleSignInUserData expectedUserData = GoogleSignInUserData( - displayName: 'Foo Bar', - email: 'foo@example.com', - id: '123', - photoUrl: 'http://example.com/img.jpg', - idToken: expectedTokenData.idToken, - ); - - late GoogleSignInPlugin plugin; - - group('plugin.initWithParams() throws a catchable exception', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('throws PlatformException', (WidgetTester tester) async { - await expectLater( - plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )), - throwsA(isA())); - }); - - testWidgets('forwards error code from JS', (WidgetTester tester) async { - try { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - fail('plugin.initWithParams should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'idpiframe_initialization_failed'); - } - }); - }); - - group('other methods also throw catchable exceptions on initWithParams fail', - () { - // This function ensures that initWithParams gets called, but for some - // reason, we ignored that it has thrown stuff... - Future discardInit() async { - try { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - } catch (e) { - // Noop so we can call other stuff - } - } - - setUp(() { - gapiUrl = toBase64Url(gapi_mocks.auth2InitError()); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('signInSilently throws', (WidgetTester tester) async { - await discardInit(); - await expectLater( - plugin.signInSilently(), throwsA(isA())); - }); - - testWidgets('signIn throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('getTokens throws', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.getTokens(email: 'test@example.com'), - throwsA(isA())); - }); - testWidgets('requestScopes', (WidgetTester tester) async { - await discardInit(); - await expectLater(plugin.requestScopes(['newScope']), - throwsA(isA())); - }); - }); - - group('auth2 Init Successful', () { - setUp(() { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess(expectedUserData)); - plugin = GoogleSignInPlugin(); - }); - - testWidgets('Init requires clientId', (WidgetTester tester) async { - expect( - plugin.initWithParams(const SignInInitParameters(hostedDomain: '')), - throwsAssertionError); - }); - - testWidgets("Init doesn't accept serverClientId", - (WidgetTester tester) async { - expect( - plugin.initWithParams(const SignInInitParameters( - clientId: '', - serverClientId: '', - )), - throwsAssertionError); - }); - - testWidgets("Init doesn't accept spaces in scopes", - (WidgetTester tester) async { - expect( - plugin.initWithParams(const SignInInitParameters( - hostedDomain: '', - clientId: '', - scopes: ['scope with spaces'], - )), - throwsAssertionError); - }); - - // See: https://github.com/flutter/flutter/issues/88084 - testWidgets('Init passes plugin_name parameter with the expected value', - (WidgetTester tester) async { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - - final Object? initParameters = - js_util.getProperty(html.window, 'gapi2.init.parameters'); - expect(initParameters, isNotNull); - - final Object? pluginNameParameter = - js_util.getProperty(initParameters!, 'plugin_name'); - expect(pluginNameParameter, isA()); - expect(pluginNameParameter, 'dart-google_sign_in_web'); - }); - - group('Successful .initWithParams, then', () { - setUp(() async { - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - await plugin.initialized; - }); - - testWidgets('signInSilently', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = - (await plugin.signInSilently())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('signIn', (WidgetTester tester) async { - final GoogleSignInUserData actualUser = (await plugin.signIn())!; - - expect(actualUser, expectedUserData); - }); - - testWidgets('getTokens', (WidgetTester tester) async { - final GoogleSignInTokenData actualToken = - await plugin.getTokens(email: expectedUserData.email); - - expect(actualToken, expectedTokenData); - }); - - testWidgets('requestScopes', (WidgetTester tester) async { - final bool scopeGranted = - await plugin.requestScopes(['newScope']); - - expect(scopeGranted, isTrue); - }); - }); - }); - - group('auth2 Init successful, but exception on signIn() method', () { - setUp(() async { - // The pre-configured use case for the instances of the plugin in this test - gapiUrl = toBase64Url(gapi_mocks.auth2SignInError()); - plugin = GoogleSignInPlugin(); - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: 'foo', - scopes: ['some', 'scope'], - clientId: '1234', - )); - await plugin.initialized; - }); - - testWidgets('User aborts sign in flow, throws PlatformException', - (WidgetTester tester) async { - await expectLater(plugin.signIn(), throwsA(isA())); - }); - - testWidgets('User aborts sign in flow, error code is forwarded from JS', - (WidgetTester tester) async { - try { - await plugin.signIn(); - fail('plugin.signIn() should have thrown an exception!'); - } catch (e) { - final String code = js_util.getProperty(e, 'code'); - expect(code, 'popup_closed_by_user'); - } - }); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_legacy_init_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_legacy_init_test.dart deleted file mode 100644 index 7bfef53f7a23..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_legacy_init_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// This file is a copy of `gapi_load_test.dart`, before it was migrated to the -// new `initWithParams` method, and is kept to ensure test coverage of the -// deprecated `init` method, until it is removed. - -import 'dart:html' as html; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:google_sign_in_web/google_sign_in_web.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess( - GoogleSignInUserData(email: 'test@test.com', id: '1234'))); - - testWidgets('Plugin is initialized after GAPI fully loads and init is called', - (WidgetTester tester) async { - expect( - html.querySelector('script[src^="data:"]'), - isNull, - reason: 'Mock script not present before instantiating the plugin', - ); - final GoogleSignInPlugin plugin = GoogleSignInPlugin(); - expect( - html.querySelector('script[src^="data:"]'), - isNotNull, - reason: 'Mock script should be injected', - ); - expect(() { - plugin.initialized; - }, throwsStateError, - reason: - 'The plugin should throw if checking for `initialized` before calling .init'); - await plugin.init(hostedDomain: '', clientId: ''); - await plugin.initialized; - expect( - plugin.initialized, - completes, - reason: 'The plugin should complete the future once initialized.', - ); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_test.dart deleted file mode 100644 index fc753e20d92c..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_load_test.dart +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:html' as html; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:google_sign_in_web/google_sign_in_web.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'gapi_mocks/gapi_mocks.dart' as gapi_mocks; -import 'src/test_utils.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - gapiUrl = toBase64Url(gapi_mocks.auth2InitSuccess( - GoogleSignInUserData(email: 'test@test.com', id: '1234'))); - - testWidgets('Plugin is initialized after GAPI fully loads and init is called', - (WidgetTester tester) async { - expect( - html.querySelector('script[src^="data:"]'), - isNull, - reason: 'Mock script not present before instantiating the plugin', - ); - final GoogleSignInPlugin plugin = GoogleSignInPlugin(); - expect( - html.querySelector('script[src^="data:"]'), - isNotNull, - reason: 'Mock script should be injected', - ); - expect(() { - plugin.initialized; - }, throwsStateError, - reason: 'The plugin should throw if checking for `initialized` before ' - 'calling .initWithParams'); - await plugin.initWithParams(const SignInInitParameters( - hostedDomain: '', - clientId: '', - )); - await plugin.initialized; - expect( - plugin.initialized, - completes, - reason: 'The plugin should complete the future once initialized.', - ); - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/gapi_mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/gapi_mocks.dart deleted file mode 100644 index 43eb9a55d06b..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/gapi_mocks.dart +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -library gapi_mocks; - -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; - -import 'src/gapi.dart'; -import 'src/google_user.dart'; -import 'src/test_iife.dart'; - -part 'src/auth2_init.dart'; diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/auth2_init.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/auth2_init.dart deleted file mode 100644 index 84f4e6ee8ba8..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/auth2_init.dart +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -part of gapi_mocks; - -// JS mock of a gapi.auth2, with a successfully identified user -String auth2InitSuccess(GoogleSignInUserData userData) => testIife(''' -${gapi()} - -var mockUser = ${googleUser(userData)}; - -function GapiAuth2() {} -GapiAuth2.prototype.init = function (initOptions) { - /*Leak the initOptions so we can look at them later.*/ - window['gapi2.init.parameters'] = initOptions; - return { - then: (onSuccess, onError) => { - window.setTimeout(() => { - onSuccess(window.gapi.auth2); - }, 30); - }, - currentUser: { - listen: (cb) => { - window.setTimeout(() => { - cb(mockUser); - }, 30); - } - } - } -}; - -GapiAuth2.prototype.getAuthInstance = function () { - return { - signIn: () => { - return new Promise((resolve, reject) => { - window.setTimeout(() => { - resolve(mockUser); - }, 30); - }); - }, - currentUser: { - get: () => mockUser, - }, - } -}; - -window.gapi.auth2 = new GapiAuth2(); -'''); - -String auth2InitError() => testIife(''' -${gapi()} - -function GapiAuth2() {} -GapiAuth2.prototype.init = function (initOptions) { - return { - then: (onSuccess, onError) => { - window.setTimeout(() => { - onError({ - error: 'idpiframe_initialization_failed', - details: 'This error was raised from a test.', - }); - }, 30); - } - } -}; - -window.gapi.auth2 = new GapiAuth2(); -'''); - -String auth2SignInError([String error = 'popup_closed_by_user']) => testIife(''' -${gapi()} - -var mockUser = null; - -function GapiAuth2() {} -GapiAuth2.prototype.init = function (initOptions) { - return { - then: (onSuccess, onError) => { - window.setTimeout(() => { - onSuccess(window.gapi.auth2); - }, 30); - }, - currentUser: { - listen: (cb) => { - window.setTimeout(() => { - cb(mockUser); - }, 30); - } - } - } -}; - -GapiAuth2.prototype.getAuthInstance = function () { - return { - signIn: () => { - return new Promise((resolve, reject) => { - window.setTimeout(() => { - reject({ - error: '$error' - }); - }, 30); - }); - }, - } -}; - -window.gapi.auth2 = new GapiAuth2(); -'''); diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/gapi.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/gapi.dart deleted file mode 100644 index 0e652c647a38..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/gapi.dart +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// The JS mock of the global gapi object -String gapi() => ''' -function Gapi() {}; -Gapi.prototype.load = function (script, cb) { - window.setTimeout(cb, 30); -}; -window.gapi = new Gapi(); -'''; diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/google_user.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/google_user.dart deleted file mode 100644 index e5e6eb262502..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/google_user.dart +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; - -// Creates the JS representation of some user data -String googleUser(GoogleSignInUserData data) => ''' -{ - getBasicProfile: () => { - return { - getName: () => '${data.displayName}', - getEmail: () => '${data.email}', - getId: () => '${data.id}', - getImageUrl: () => '${data.photoUrl}', - }; - }, - getAuthResponse: () => { - return { - id_token: '${data.idToken}', - access_token: 'access_${data.idToken}', - } - }, - getGrantedScopes: () => 'some scope', - grant: () => true, - isSignedIn: () => { - return ${data != null ? 'true' : 'false'}; - }, -} -'''; diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/test_iife.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/test_iife.dart deleted file mode 100644 index c5aac367c1de..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_mocks/src/test_iife.dart +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:google_sign_in_web/src/load_gapi.dart' - show kGapiOnloadCallbackFunctionName; - -// Wraps some JS mock code in an IIFE that ends by calling the onLoad dart callback. -String testIife(String mock) => ''' -(function() { - $mock; - window['$kGapiOnloadCallbackFunctionName'](); -})(); -''' - .replaceAll(RegExp(r'\s{2,}'), ''); diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_utils_test.dart deleted file mode 100644 index b9daac44dba8..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/gapi_utils_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_sign_in_web/src/js_interop/gapiauth2.dart' as gapi; -import 'package:google_sign_in_web/src/utils.dart'; -import 'package:integration_test/integration_test.dart'; - -void main() { - // The non-null use cases are covered by the auth2_test.dart file. - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('gapiUserToPluginUserData', () { - late FakeGoogleUser fakeUser; - - setUp(() { - fakeUser = FakeGoogleUser(); - }); - - testWidgets('null user -> null response', (WidgetTester tester) async { - expect(gapiUserToPluginUserData(null), isNull); - }); - - testWidgets('not signed-in user -> null response', - (WidgetTester tester) async { - expect(gapiUserToPluginUserData(fakeUser), isNull); - }); - - testWidgets('signed-in, but null profile user -> null response', - (WidgetTester tester) async { - fakeUser.setIsSignedIn(true); - expect(gapiUserToPluginUserData(fakeUser), isNull); - }); - - testWidgets('signed-in, null userId in profile user -> null response', - (WidgetTester tester) async { - fakeUser.setIsSignedIn(true); - fakeUser.setBasicProfile(FakeBasicProfile()); - expect(gapiUserToPluginUserData(fakeUser), isNull); - }); - }); -} - -class FakeGoogleUser extends Fake implements gapi.GoogleUser { - bool _isSignedIn = false; - gapi.BasicProfile? _basicProfile; - - @override - bool isSignedIn() => _isSignedIn; - @override - gapi.BasicProfile? getBasicProfile() => _basicProfile; - - // ignore: use_setters_to_change_properties - void setIsSignedIn(bool isSignedIn) { - _isSignedIn = isSignedIn; - } - - // ignore: use_setters_to_change_properties - void setBasicProfile(gapi.BasicProfile basicProfile) { - _basicProfile = basicProfile; - } -} - -class FakeBasicProfile extends Fake implements gapi.BasicProfile { - String? _id; - - @override - String? getId() => _id; -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/test_utils.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/test_utils.dart deleted file mode 100644 index 56aa61df136e..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/test_utils.dart +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:convert'; - -String toBase64Url(String contents) { - // Open the file - return 'data:text/javascript;base64,${base64.encode(utf8.encode(contents))}'; -} From c77e6d32473b67f85a3f59690964986d2a1e7b13 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 11 Jan 2023 23:15:04 -0800 Subject: [PATCH 07/31] Add some tests for the new code. --- .../example/integration_test/utils_test.dart | 80 +++++++++++++++++++ .../google_sign_in_web/example/pubspec.yaml | 2 + 2 files changed, 82 insertions(+) create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart new file mode 100644 index 000000000000..74512595f04a --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart @@ -0,0 +1,80 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_identity_services_web/id.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:google_sign_in_web/src/utils.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jose/jose.dart'; +import 'package:js/js_util.dart' as js_util; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('gisResponsesToTokenData', () { + + }); + + group('gisResponsesToUserData', () { + testWidgets('happy case', (WidgetTester tester) async { + final CredentialResponse response = createJwt({ + 'email': 'test@example.com', + 'sub': '123456', + 'name': 'Test McTestface', + 'picture': 'https://thispersondoesnotexist.com/image', + }); + + final GoogleSignInUserData data = gisResponsesToUserData(response)!; + + expect(data.displayName, 'Test McTestface'); + expect(data.id, '123456'); + expect(data.email, 'test@example.com'); + expect(data.photoUrl, 'https://thispersondoesnotexist.com/image'); + expect(data.idToken, response.credential); + }); + + testWidgets('null response -> null', (WidgetTester tester) async { + expect(gisResponsesToUserData(null), isNull); + }); + + testWidgets('null response.credential -> null', (WidgetTester tester) async { + final CredentialResponse response = createJwt(null); + expect(gisResponsesToUserData(response), isNull); + }); + + testWidgets('invalid payload -> null', (WidgetTester tester) async { + final CredentialResponse response = jsifyAs({ + 'credential': 'some-bogus.thing-that-is-not.valid-jwt', + }); + expect(gisResponsesToUserData(response), isNull); + }); + }); +} + +CredentialResponse createJwt(Map? claims) { + String? credential; + if (claims != null) { + final JsonWebTokenClaims token = JsonWebTokenClaims.fromJson(claims); + final JsonWebSignatureBuilder builder = JsonWebSignatureBuilder(); + builder.jsonContent = token.toJson(); + builder.addRecipient(JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), + }), algorithm: 'HS256'); // bogus crypto, don't use this for prod! + builder.setProtectedHeader('typ', 'JWT'); + credential = builder.build().toCompactSerialization(); + + print('Generated JWT: $credential'); + } + return jsifyAs({ + 'credential': credential, + }); +} + +T jsifyAs(Map data) { + return js_util.jsify(data) as T; +} diff --git a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml index 848517534ed2..815bca66cf4a 100644 --- a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml @@ -16,8 +16,10 @@ dev_dependencies: sdk: flutter flutter_test: sdk: flutter + google_identity_services_web: ^0.2.0 google_sign_in_platform_interface: ^2.2.0 http: ^0.13.0 integration_test: sdk: flutter + jose: ^0.3.3 js: ^0.6.3 From bbe23490390caae2d023e2d6c7974890eddac5c8 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Thu, 12 Jan 2023 16:56:57 -0800 Subject: [PATCH 08/31] More utils_test.dart --- .../example/integration_test/utils_test.dart | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 74512595f04a..95153b279812 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 @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_identity_services_web/id.dart'; +import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:google_sign_in_web/src/utils.dart'; import 'package:integration_test/integration_test.dart'; @@ -16,6 +17,28 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('gisResponsesToTokenData', () { + testWidgets('null objects -> no problem', (WidgetTester tester) async { + final GoogleSignInTokenData tokens = gisResponsesToTokenData(null, null); + expect(tokens.accessToken, isNull); + expect(tokens.idToken, isNull); + expect(tokens.serverAuthCode, isNull); + }); + + testWidgets('non-null objects are correctly used', (WidgetTester tester) async { + const String expectedIdToken = 'some-value-for-testing'; + const String expectedAccessToken = 'another-value-for-testing'; + + final CredentialResponse credential = jsifyAs({ + 'credential': expectedIdToken, + }); + final TokenResponse token = jsifyAs( { + 'access_token': expectedAccessToken, + }); + final GoogleSignInTokenData tokens = gisResponsesToTokenData(credential, token); + expect(tokens.accessToken, expectedAccessToken); + expect(tokens.idToken, expectedIdToken); + expect(tokens.serverAuthCode, isNull); + }); }); From 77fb227644f5080799c45a80bf2b0b965c3e4b43 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Thu, 12 Jan 2023 17:01:24 -0800 Subject: [PATCH 09/31] Make jsifyAs reusable. --- .../example/integration_test/src/jsify_as.dart | 10 ++++++++++ .../example/integration_test/utils_test.dart | 10 +++------- 2 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart new file mode 100644 index 000000000000..f0e487f85603 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart @@ -0,0 +1,10 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:js/js_util.dart' as js_util; + +/// Convert a [data] object into a JS Object of type `T`. +T jsifyAs(Map data) { + return js_util.jsify(data) as T; +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart index 95153b279812..5cf9fb31d385 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 @@ -11,7 +11,8 @@ import 'package:google_sign_in_platform_interface/google_sign_in_platform_interf import 'package:google_sign_in_web/src/utils.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jose/jose.dart'; -import 'package:js/js_util.dart' as js_util; + +import 'src/jsify_as.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -39,7 +40,6 @@ void main() { expect(tokens.idToken, expectedIdToken); expect(tokens.serverAuthCode, isNull); }); - }); group('gisResponsesToUserData', () { @@ -90,14 +90,10 @@ CredentialResponse createJwt(Map? claims) { }), algorithm: 'HS256'); // bogus crypto, don't use this for prod! builder.setProtectedHeader('typ', 'JWT'); credential = builder.build().toCompactSerialization(); - + // ignore:avoid_print print('Generated JWT: $credential'); } return jsifyAs({ 'credential': credential, }); } - -T jsifyAs(Map data) { - return js_util.jsify(data) as T; -} From 552d7b34fac533ba1814d9a04d0a7771749e19bb Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 11:14:19 -0800 Subject: [PATCH 10/31] Ignore the tester in utils_test.dart --- .../example/integration_test/utils_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 5cf9fb31d385..350c4b04989c 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 @@ -18,14 +18,14 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('gisResponsesToTokenData', () { - testWidgets('null objects -> no problem', (WidgetTester tester) async { + testWidgets('null objects -> no problem', (_) async { final GoogleSignInTokenData tokens = gisResponsesToTokenData(null, null); expect(tokens.accessToken, isNull); expect(tokens.idToken, isNull); expect(tokens.serverAuthCode, isNull); }); - testWidgets('non-null objects are correctly used', (WidgetTester tester) async { + testWidgets('non-null objects are correctly used', (_) async { const String expectedIdToken = 'some-value-for-testing'; const String expectedAccessToken = 'another-value-for-testing'; @@ -43,7 +43,7 @@ void main() { }); group('gisResponsesToUserData', () { - testWidgets('happy case', (WidgetTester tester) async { + testWidgets('happy case', (_) async { final CredentialResponse response = createJwt({ 'email': 'test@example.com', 'sub': '123456', @@ -60,16 +60,16 @@ void main() { expect(data.idToken, response.credential); }); - testWidgets('null response -> null', (WidgetTester tester) async { + testWidgets('null response -> null', (_) async { expect(gisResponsesToUserData(null), isNull); }); - testWidgets('null response.credential -> null', (WidgetTester tester) async { + testWidgets('null response.credential -> null', (_) async { final CredentialResponse response = createJwt(null); expect(gisResponsesToUserData(response), isNull); }); - testWidgets('invalid payload -> null', (WidgetTester tester) async { + testWidgets('invalid payload -> null', (_) async { final CredentialResponse response = jsifyAs({ 'credential': 'some-bogus.thing-that-is-not.valid-jwt', }); From 060a11dd2fea2996bcc8ebaa850a2ed1566f171f Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 11:16:15 -0800 Subject: [PATCH 11/31] Make Clients overridable, and some renaming. --- .../lib/google_sign_in_web.dart | 14 ++--- .../google_sign_in_web/lib/src/people.dart | 62 +++++++++++-------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index 94cbd84873a6..04c199187171 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -79,7 +79,9 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } @override - Future initWithParams(SignInInitParameters params) async { + Future initWithParams(SignInInitParameters params, { + @visibleForTesting GisSdkClient? overrideClient, + }) async { final String? appClientId = params.clientId ?? _autoDetectedClientId; assert( appClientId != null, @@ -98,7 +100,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { await _isJsSdkLoaded; - final GisSdkClient gisClient = GisSdkClient( + _gisClient = overrideClient ?? GisSdkClient( clientId: appClientId!, hostedDomain: params.hostedDomain, initialScopes: List.from(params.scopes), @@ -106,14 +108,6 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { loggingEnabled: true, ); - return initWithClient(gisClient); - } - - /// Initializes the plugin with a pre-made [GisSdkClient], that can be overridden from tests. - @visibleForTesting - Future initWithClient(GisSdkClient gisClient) async { - _gisClient = gisClient; - _isInitCalled = true; } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart index 29d3581a8947..30f4418722e6 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart @@ -4,6 +4,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:http/http.dart' as http; @@ -20,10 +21,13 @@ const String MY_PROFILE = 'https://content-people.googleapis.com/v1/people/me' '&personFields=photos%2Cnames%2CemailAddresses'; /// Requests user data from the People API using the given [tokenResponse]. -Future requestUserData(TokenResponse tokenResponse) async { - +Future requestUserData(TokenResponse tokenResponse, { + @visibleForTesting http.Client? overrideClient, +}) async { // Request my profile from the People API. - final Map person = await _get(tokenResponse, MY_PROFILE); + final Map person = await _doRequest(MY_PROFILE, tokenResponse, + overrideClient: overrideClient, + ); // Now transform the Person response into a GoogleSignInUserData. return extractUserData(person); @@ -33,8 +37,8 @@ Future requestUserData(TokenResponse tokenResponse) async /// /// See: https://developers.google.com/people/api/rest/v1/people#Person GoogleSignInUserData? extractUserData(Map json) { - final String? userId = extractUserId(json); - final String? email = extractPrimaryField( + final String? userId = _extractUserId(json); + final String? email = _extractPrimaryField( json['emailAddresses'] as List?, 'value', ); @@ -45,11 +49,11 @@ GoogleSignInUserData? extractUserData(Map json) { return GoogleSignInUserData( id: userId!, email: email!, - displayName: extractPrimaryField( + displayName: _extractPrimaryField( json['names'] as List?, 'displayName', ), - photoUrl: extractPrimaryField( + photoUrl: _extractPrimaryField( json['photos'] as List?, 'url', ), @@ -64,7 +68,7 @@ GoogleSignInUserData? extractUserData(Map json) { /// 'resourceName': 'people/PERSON_ID', /// ... /// } -String? extractUserId(Map profile) { +String? _extractUserId(Map profile) { final String? resourceName = profile['resourceName'] as String?; return resourceName?.split('/').last; } @@ -77,14 +81,15 @@ String? extractUserId(Map profile) { /// * `photos` /// /// From a Person object. -String? extractPrimaryField(List? values, String fieldName) { +T? _extractPrimaryField(List? values, String fieldName) { if (values != null) { for (final Object? value in values) { if (value != null && value is Map) { - final bool isPrimary = _deepGet(value, - path: ['metadata', 'primary'], defaultValue: false); + final bool isPrimary = _extractPath(value, + path: ['metadata', 'primary'], + defaultValue: false,); if (isPrimary) { - return value[fieldName] as String?; + return value[fieldName] as T?; } } } @@ -96,22 +101,22 @@ String? extractPrimaryField(List? values, String fieldName) { /// Attempts to get the property in [path] of type `T` from a deeply nested [source]. /// /// Returns [default] if the property is not found. -T _deepGet( +T _extractPath( Map source, { required List path, required T defaultValue, }) { - final String value = path.removeLast(); + final String valueKey = path.removeLast(); Object? data = source; - for (final String index in path) { + for (final String key in path) { if (data != null && data is Map) { - data = data[index]; + data = data[key]; } else { break; } } if (data != null && data is Map) { - return (data[value] ?? defaultValue) as T; + return (data[valueKey] ?? defaultValue) as T; } else { return defaultValue; } @@ -120,15 +125,20 @@ T _deepGet( /// Gets from [url] with an authorization header defined by [token]. /// /// Attempts to [jsonDecode] the result. -Future> _get(TokenResponse token, String url) async { +Future> _doRequest(String url, TokenResponse token, { + http.Client? overrideClient, +}) async { final Uri uri = Uri.parse(url); - final http.Response response = await http.get(uri, headers: { - 'Authorization': '${token.token_type} ${token.access_token}', - }); - - if (response.statusCode != 200) { - throw http.ClientException(response.body, uri); + final http.Client client = overrideClient ?? http.Client(); + try { + final http.Response response = await client.get(uri, headers: { + 'Authorization': '${token.token_type} ${token.access_token}', + }); + if (response.statusCode != 200) { + throw http.ClientException(response.body, uri); + } + return jsonDecode(response.body) as Map; + } finally { + client.close(); } - - return jsonDecode(response.body) as Map; } From 17e73fad12849110ddcb885c32494cd98c71e3c2 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 11:16:47 -0800 Subject: [PATCH 12/31] Test people.dart --- .../example/integration_test/people_test.dart | 125 ++++++++++++++++++ .../example/integration_test/src/person.dart | 59 +++++++++ 2 files changed, 184 insertions(+) create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart new file mode 100644 index 000000000000..f453071f2190 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart @@ -0,0 +1,125 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_identity_services_web/oauth2.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:google_sign_in_web/src/people.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_test; +import 'package:integration_test/integration_test.dart'; + +import 'src/jsify_as.dart'; +import 'src/person.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('requestUserData', () { + const String expectedAccessToken = '3xp3c73d_4cc355_70k3n'; + + final TokenResponse fakeToken = jsifyAs( { + 'token_type': 'Bearer', + 'access_token': expectedAccessToken, + }); + + testWidgets('happy case', (_) async { + final Completer accessTokenCompleter = Completer(); + + final http.Client mockClient = http_test.MockClient( + (http.Request request) async { + + accessTokenCompleter.complete(request.headers['Authorization']); + + return http.Response( + jsonEncode(person), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final GoogleSignInUserData? user = await requestUserData( + fakeToken, + overrideClient: mockClient, + ); + + expect(user, isNotNull); + expect(user!.email, expectedPersonEmail); + expect(user.id, expectedPersonId); + expect(user.displayName, expectedPersonName); + expect(user.photoUrl, expectedPersonPhoto); + expect(user.idToken, isNull); + expect(accessTokenCompleter.future, completion('Bearer $expectedAccessToken')); + }); + + testWidgets('Unauthorized request - throws exception', (_) async { + final http.Client mockClient = http_test.MockClient( + (http.Request request) async { + return http.Response( + 'Unauthorized', + 403, + ); + }); + + expect(() async { + await requestUserData( + fakeToken, + overrideClient: mockClient, + ); + }, throwsA(isA())); + }); + }); + + group('extractUserData', () { + testWidgets('happy case', (_) async { + final GoogleSignInUserData? user = extractUserData(person); + + expect(user, isNotNull); + expect(user!.email, expectedPersonEmail); + expect(user.id, expectedPersonId); + expect(user.displayName, expectedPersonName); + expect(user.photoUrl, expectedPersonPhoto); + expect(user.idToken, isNull); + }); + + testWidgets('no name/photo - keeps going', (_) async { + final Map personWithoutSomeData = mapWithoutKeys(person, { + 'names', + 'photos', + }); + + final GoogleSignInUserData? user = extractUserData(personWithoutSomeData); + + expect(user, isNotNull); + expect(user!.email, expectedPersonEmail); + expect(user.id, expectedPersonId); + expect(user.displayName, isNull); + expect(user.photoUrl, isNull); + expect(user.idToken, isNull); + }); + + testWidgets('no userId - throws assertion error', (_) async { + final Map personWithoutId = mapWithoutKeys(person, { + 'resourceName', + }); + + expect(() { + extractUserData(personWithoutId); + }, throwsAssertionError); + }); + + testWidgets('no email - throws assertion error', (_) async { + final Map personWithoutEmail = mapWithoutKeys(person, { + 'emailAddresses', + }); + + expect(() { + extractUserData(personWithoutEmail); + }, throwsAssertionError); + }); + }); +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart new file mode 100644 index 000000000000..bd79bd83dae3 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart @@ -0,0 +1,59 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +const String expectedPersonId = '1234567890'; +const String expectedPersonName = 'Test McTestFace True'; +const String expectedPersonEmail = 'mctestface@example.com'; +const String expectedPersonPhoto = 'https://thispersondoesnotexist.com/image?x=.jpg'; + +/// A subset of https://developers.google.com/people/api/rest/v1/people#Person +final Map person = { + 'resourceName': 'people/$expectedPersonId', + 'emailAddresses': [ + { + 'metadata': { + 'primary': false, + }, + 'value': 'bad@example.com', + }, + { + 'metadata': {}, + 'value': 'nope@example.com', + }, + { + 'metadata': { + 'primary': true, + }, + 'value': expectedPersonEmail, + }, + ], + 'names': [ + { + 'metadata': { + 'primary': true, + }, + 'displayName': expectedPersonName, + }, + { + 'metadata': { + 'primary': false, + }, + 'displayName': 'Fakey McFakeface', + }, + ], + 'photos': [ + { + 'metadata': { + 'primary': true, + }, + 'url': expectedPersonPhoto, + }, + ], +}; + +T mapWithoutKeys>(T map, Set keysToRemove) { + return Map.fromEntries( + map.entries.where((MapEntry entry) => !keysToRemove.contains(entry.key)) + ) as T; +} From 55ff6ac5038a4daba665b7a184d24334156abb19 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 15:21:52 -0800 Subject: [PATCH 13/31] Make autoDetectedClientId more testable. --- .../lib/google_sign_in_web.dart | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index 04c199187171..ec0b86cbf0ef 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -6,15 +6,19 @@ import 'dart:async'; import 'dart:html' as html; import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:flutter/services.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:google_identity_services_web/loader.dart' as loader; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'src/gis_client.dart'; -const String _kClientIdMetaSelector = 'meta[name=google-signin-client_id]'; -const String _kClientIdAttributeName = 'content'; +/// The `name` of the meta-tag to define a ClientID in HTML. +const String clientIdMetaName = 'google-signin-client_id'; +/// The selector used to find the meta-tag that defines a ClientID in HTML. +const String clientIdMetaSelector = 'meta[name=$clientIdMetaName]'; +/// The attribute name that stores the Client ID in the meta-tag that defines a Client ID in HTML. +const String clientIdAttributeName = 'content'; /// Implementation of the google_sign_in plugin for Web. class GoogleSignInPlugin extends GoogleSignInPlatform { @@ -22,12 +26,18 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { /// background. /// /// The plugin is completely initialized when [initialized] completed. - GoogleSignInPlugin() { - _autoDetectedClientId = html - .querySelector(_kClientIdMetaSelector) - ?.getAttribute(_kClientIdAttributeName); - - _isJsSdkLoaded = loader.loadWebSdk(); + GoogleSignInPlugin({ + @visibleForTesting bool debugOverrideLoader = false + }) { + autoDetectedClientId = html + .querySelector(clientIdMetaSelector) + ?.getAttribute(clientIdAttributeName); + + if (debugOverrideLoader) { + _isJsSdkLoaded = Future.value(true); + } else { + _isJsSdkLoaded = loader.loadWebSdk(); + } } late Future _isJsSdkLoaded; @@ -55,8 +65,9 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { return _isJsSdkLoaded; } - // Stores the clientId found in the DOM (if any). - String? _autoDetectedClientId; + /// Stores the client ID if it was set in a meta-tag of the page. + @visibleForTesting + late String? autoDetectedClientId; /// Factory method that initializes the plugin with [GoogleSignInPlatform]. static void registerWith(Registrar registrar) { @@ -82,7 +93,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { Future initWithParams(SignInInitParameters params, { @visibleForTesting GisSdkClient? overrideClient, }) async { - final String? appClientId = params.clientId ?? _autoDetectedClientId; + final String? appClientId = params.clientId ?? autoDetectedClientId; assert( appClientId != null, 'ClientID not set. Either set it on a ' From 203ae47c40b4a8f6ffe6685e964c954feeb23636 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 15:22:38 -0800 Subject: [PATCH 14/31] Add mockito. --- .../google_sign_in_web/example/build.yaml | 6 ++++++ .../google_sign_in_web/example/pubspec.yaml | 2 ++ .../google_sign_in_web/example/regen_mocks.sh | 10 ++++++++++ .../google_sign_in_web/example/run_test.sh | 7 ++++--- 4 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 packages/google_sign_in/google_sign_in_web/example/build.yaml create mode 100755 packages/google_sign_in/google_sign_in_web/example/regen_mocks.sh diff --git a/packages/google_sign_in/google_sign_in_web/example/build.yaml b/packages/google_sign_in/google_sign_in_web/example/build.yaml new file mode 100644 index 000000000000..db3104bb04c6 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + sources: + - integration_test/*.dart + - lib/$lib$ + - $package$ diff --git a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml index 815bca66cf4a..d6699b61910e 100644 --- a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: path: ../ dev_dependencies: + build_runner: ^2.1.1 flutter_driver: sdk: flutter flutter_test: @@ -23,3 +24,4 @@ dev_dependencies: sdk: flutter jose: ^0.3.3 js: ^0.6.3 + mockito: ^5.3.2 diff --git a/packages/google_sign_in/google_sign_in_web/example/regen_mocks.sh b/packages/google_sign_in/google_sign_in_web/example/regen_mocks.sh new file mode 100755 index 000000000000..78bcdc0f9e28 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/regen_mocks.sh @@ -0,0 +1,10 @@ +#!/usr/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +flutter pub get + +echo "(Re)generating mocks." + +flutter pub run build_runner build --delete-conflicting-outputs diff --git a/packages/google_sign_in/google_sign_in_web/example/run_test.sh b/packages/google_sign_in/google_sign_in_web/example/run_test.sh index 28877dce8d6e..fcac5f600acb 100755 --- a/packages/google_sign_in/google_sign_in_web/example/run_test.sh +++ b/packages/google_sign_in/google_sign_in_web/example/run_test.sh @@ -6,9 +6,11 @@ if pgrep -lf chromedriver > /dev/null; then echo "chromedriver is running." + ./regen_mocks.sh + if [ $# -eq 0 ]; then echo "No target specified, running all tests..." - find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' + find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' else echo "Running test target: $1..." set -x @@ -17,7 +19,6 @@ if pgrep -lf chromedriver > /dev/null; then else echo "chromedriver is not running." + echo "Please, check the README.md for instructions on how to use run_test.sh" fi - - From e41c012881bba1a30da9e1d967a5ecf811cac205 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 15:28:27 -0800 Subject: [PATCH 15/31] Comment about where to better split the code so GisSdkClient is testable too. --- .../google_sign_in/google_sign_in_web/lib/src/gis_client.dart | 4 ++++ 1 file changed, 4 insertions(+) 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 93f753c03e48..e584db6f9ab5 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 @@ -3,6 +3,10 @@ // found in the LICENSE file. import 'dart:async'; +// TODO(dit): A better layer to split this would be to make a mockable client that +// exposes only the APIs we need from gis_web/id.dart and gis_web/oauth2.dart. +// That way, the GisSdkClient class would be testable (and the mock surface would +// only deal with the few methods we actually use from the SDK). Next version. import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; From 138f5f3fe147e06ca14654a387cbb2905b65cdd5 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 17:46:07 -0800 Subject: [PATCH 16/31] Add google_sign_in_web_test.dart (and its mocks) --- .../google_sign_in_web_test.dart | 195 ++++++++++++++++++ .../google_sign_in_web_test.mocks.dart | 125 +++++++++++ .../example/integration_test/src/dom.dart | 57 +++++ 3 files changed, 377 insertions(+) create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart new file mode 100644 index 000000000000..8a577f10b6a8 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart @@ -0,0 +1,195 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart' show PlatformException; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +import 'package:google_sign_in_web/google_sign_in_web.dart'; +import 'package:google_sign_in_web/src/gis_client.dart'; +import 'package:google_sign_in_web/src/people.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart' as mockito; + +import 'google_sign_in_web_test.mocks.dart'; +import 'src/dom.dart'; +import 'src/person.dart'; + +// Mock GisSdkClient so we can simulate any response from the JS side. +@GenerateMocks([], customMocks: >[ + MockSpec(onMissingStub: OnMissingStub.returnDefault), +]) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Constructor', () { + const String expectedClientId = '3xp3c73d_c113n7_1d'; + + testWidgets('Loads clientId when set in a meta', (_) async { + final GoogleSignInPlugin plugin = GoogleSignInPlugin( + debugOverrideLoader: true + ); + + expect(plugin.autoDetectedClientId, isNull); + + // Add it to the test page now, and try again + final DomHtmlMetaElement meta = document.createElement('meta') as DomHtmlMetaElement + ..name = clientIdMetaName + ..content = expectedClientId; + + document.head.appendChild(meta); + + final GoogleSignInPlugin another = GoogleSignInPlugin( + debugOverrideLoader: true + ); + + expect(another.autoDetectedClientId, expectedClientId); + + // cleanup + meta.remove(); + }); + }); + + group('initWithParams', () { + late GoogleSignInPlugin plugin; + late MockGisSdkClient mockGis; + + setUp(() { + plugin = GoogleSignInPlugin( + debugOverrideLoader: true + ); + mockGis = MockGisSdkClient(); + }); + + testWidgets('must be called for most of the API to work', (_) async { + expect(() async { await plugin.signInSilently(); }, throwsA(isA())); + expect(() async { await plugin.signIn(); }, throwsA(isA())); + expect(() async { await plugin.getTokens(email: ''); }, throwsA(isA())); + expect(() async { await plugin.signOut(); }, throwsA(isA())); + expect(() async { await plugin.disconnect(); }, throwsA(isA())); + expect(() async { await plugin.isSignedIn(); }, throwsA(isA())); + expect(() async { await plugin.clearAuthCache(token: ''); }, throwsA(isA())); + expect(() async { await plugin.requestScopes([]); }, throwsA(isA())); + }); + + testWidgets('initializes if all is OK', (_) async { + await plugin.initWithParams( + const SignInInitParameters( + clientId: 'some-non-null-client-id', + scopes: ['ok1', 'ok2', 'ok3'], + ), + overrideClient: mockGis, + ); + + expect(plugin.initialized, completes); + }); + + testWidgets('asserts clientId is not null', (_) async { + expect(() async { + await plugin.initWithParams( + const SignInInitParameters(), + overrideClient: mockGis, + ); + }, throwsAssertionError); + }); + + testWidgets('asserts serverClientId must be null', (_) async { + expect(() async { + await plugin.initWithParams( + const SignInInitParameters( + clientId: 'some-non-null-client-id', + serverClientId: 'unexpected-non-null-client-id', + ), + overrideClient: mockGis, + ); + }, throwsAssertionError); + }); + + testWidgets('asserts no scopes have any spaces', (_) async { + expect(() async { + await plugin.initWithParams( + const SignInInitParameters( + clientId: 'some-non-null-client-id', + scopes: ['ok1', 'ok2', 'not ok', 'ok3'], + ), + overrideClient: mockGis, + ); + }, throwsAssertionError); + }); + }); + + group('(with mocked GIS)', () { + late GoogleSignInPlugin plugin; + late MockGisSdkClient mockGis; + const SignInInitParameters options = SignInInitParameters( + clientId: 'some-non-null-client-id', + scopes: ['ok1', 'ok2', 'ok3'], + ); + + setUp(() { + plugin = GoogleSignInPlugin( + debugOverrideLoader: true + ); + mockGis = MockGisSdkClient(); + }); + + group('signInSilently', () { + setUp(() { + plugin.initWithParams(options, overrideClient: mockGis); + }); + + testWidgets('always returns null, regardless of GIS response', (_) async { + final GoogleSignInUserData someUser = extractUserData(person)!; + + mockito.when(mockGis.signInSilently()).thenAnswer( + (_) => Future.value(someUser), + ); + + expect(plugin.signInSilently(), completion(isNull)); + + mockito.when(mockGis.signInSilently()).thenAnswer( + (_) => Future.value(), + ); + + expect(plugin.signInSilently(), completion(isNull)); + }); + }); + + group('signIn', () { + setUp(() { + plugin.initWithParams(options, overrideClient: mockGis); + }); + + testWidgets('returns the signed-in user', (_) async { + final GoogleSignInUserData someUser = extractUserData(person)!; + + mockito.when(mockGis.signIn()).thenAnswer( + (_) => Future.value(someUser), + ); + + expect(await plugin.signIn(), someUser); + }); + + testWidgets('returns null if no user is signed in', (_) async { + mockito.when(mockGis.signIn()).thenAnswer( + (_) => Future.value(), + ); + + expect(await plugin.signIn(), isNull); + }); + + testWidgets('converts inner errors.toString to PlatformException', (_) async { + mockito.when(mockGis.signIn()).thenThrow('popup_closed'); + + try { + await plugin.signIn(); + fail('signIn should have thrown an exception'); + } catch(exception) { + expect(exception, isA()); + expect((exception as PlatformException).code, 'popup_closed'); + } + }); + }); + }); +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart new file mode 100644 index 000000000000..b60dac9d4b95 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.mocks.dart @@ -0,0 +1,125 @@ +// Mocks generated by Mockito 5.3.2 from annotations +// in google_sign_in_web_integration_tests/integration_test/google_sign_in_web_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart' + as _i2; +import 'package:google_sign_in_web/src/gis_client.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeGoogleSignInTokenData_0 extends _i1.SmartFake + implements _i2.GoogleSignInTokenData { + _FakeGoogleSignInTokenData_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [GisSdkClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGisSdkClient extends _i1.Mock implements _i3.GisSdkClient { + @override + _i4.Future<_i2.GoogleSignInUserData?> signInSilently() => (super.noSuchMethod( + Invocation.method( + #signInSilently, + [], + ), + returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), + returnValueForMissingStub: + _i4.Future<_i2.GoogleSignInUserData?>.value(), + ) as _i4.Future<_i2.GoogleSignInUserData?>); + @override + _i4.Future<_i2.GoogleSignInUserData?> signIn() => (super.noSuchMethod( + Invocation.method( + #signIn, + [], + ), + returnValue: _i4.Future<_i2.GoogleSignInUserData?>.value(), + returnValueForMissingStub: + _i4.Future<_i2.GoogleSignInUserData?>.value(), + ) as _i4.Future<_i2.GoogleSignInUserData?>); + @override + _i2.GoogleSignInTokenData getTokens() => (super.noSuchMethod( + Invocation.method( + #getTokens, + [], + ), + returnValue: _FakeGoogleSignInTokenData_0( + this, + Invocation.method( + #getTokens, + [], + ), + ), + returnValueForMissingStub: _FakeGoogleSignInTokenData_0( + this, + Invocation.method( + #getTokens, + [], + ), + ), + ) as _i2.GoogleSignInTokenData); + @override + _i4.Future signOut() => (super.noSuchMethod( + Invocation.method( + #signOut, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + _i4.Future disconnect() => (super.noSuchMethod( + Invocation.method( + #disconnect, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + _i4.Future isSignedIn() => (super.noSuchMethod( + Invocation.method( + #isSignedIn, + [], + ), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.Future); + @override + _i4.Future clearAuthCache() => (super.noSuchMethod( + Invocation.method( + #clearAuthCache, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + _i4.Future requestScopes(List? scopes) => (super.noSuchMethod( + Invocation.method( + #requestScopes, + [scopes], + ), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) as _i4.Future); +} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart new file mode 100644 index 000000000000..9a9d8ce69f81 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart @@ -0,0 +1,57 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/* +// DOM shim. This file contains everything we need from the DOM API written as +// @staticInterop, so we don't need dart:html +// https://developer.mozilla.org/en-US/docs/Web/API/ +// +// (To be replaced by `package:web`) +*/ + +import 'package:js/js.dart'; + +/// Document interface +@JS() +@staticInterop +abstract class DomHtmlDocument {} + +/// Some methods of document +extension DomHtmlDocumentExtension on DomHtmlDocument { + /// document.head + external DomHtmlElement get head; + /// document.createElement + external DomHtmlElement createElement(String tagName); +} + +/// An instance of an HTMLElement +@JS() +@staticInterop +abstract class DomHtmlElement {} + +/// (Some) methods of HtmlElement +extension DomHtmlElementExtension on DomHtmlElement { + /// Node.appendChild + external DomHtmlElement appendChild(DomHtmlElement child); + /// Element.remove + external void remove(); +} + +/// An instance of an HTMLMetaElement +@JS() +@staticInterop +abstract class DomHtmlMetaElement extends DomHtmlElement {} + +/// Some methods exclusive of Script elements +extension DomHtmlMetaElementExtension on DomHtmlMetaElement { + external set name(String name); + external set content(String content); +} + +// Getters + +/// window.document +@JS() +@staticInterop +external DomHtmlDocument get document; From 16d0843cd7865e12be00c27d4f1c9f3de914612b Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 18:40:45 -0800 Subject: [PATCH 17/31] dart format --- .../google_sign_in_web_test.dart | 88 ++++++++++++------- .../example/integration_test/people_test.dart | 49 ++++++----- .../example/integration_test/src/dom.dart | 2 + .../example/integration_test/src/person.dart | 47 +++++----- .../example/integration_test/utils_test.dart | 21 +++-- .../lib/google_sign_in_web.dart | 24 ++--- .../lib/src/gis_client.dart | 14 +-- .../google_sign_in_web/lib/src/people.dart | 22 +++-- 8 files changed, 162 insertions(+), 105 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart index 8a577f10b6a8..3dcc192e8aaa 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/google_sign_in_web_test.dart @@ -28,20 +28,21 @@ void main() { testWidgets('Loads clientId when set in a meta', (_) async { final GoogleSignInPlugin plugin = GoogleSignInPlugin( - debugOverrideLoader: true + debugOverrideLoader: true, ); expect(plugin.autoDetectedClientId, isNull); // Add it to the test page now, and try again - final DomHtmlMetaElement meta = document.createElement('meta') as DomHtmlMetaElement - ..name = clientIdMetaName - ..content = expectedClientId; + final DomHtmlMetaElement meta = + document.createElement('meta') as DomHtmlMetaElement + ..name = clientIdMetaName + ..content = expectedClientId; document.head.appendChild(meta); final GoogleSignInPlugin another = GoogleSignInPlugin( - debugOverrideLoader: true + debugOverrideLoader: true, ); expect(another.autoDetectedClientId, expectedClientId); @@ -57,22 +58,11 @@ void main() { setUp(() { plugin = GoogleSignInPlugin( - debugOverrideLoader: true + debugOverrideLoader: true, ); mockGis = MockGisSdkClient(); }); - testWidgets('must be called for most of the API to work', (_) async { - expect(() async { await plugin.signInSilently(); }, throwsA(isA())); - expect(() async { await plugin.signIn(); }, throwsA(isA())); - expect(() async { await plugin.getTokens(email: ''); }, throwsA(isA())); - expect(() async { await plugin.signOut(); }, throwsA(isA())); - expect(() async { await plugin.disconnect(); }, throwsA(isA())); - expect(() async { await plugin.isSignedIn(); }, throwsA(isA())); - expect(() async { await plugin.clearAuthCache(token: ''); }, throwsA(isA())); - expect(() async { await plugin.requestScopes([]); }, throwsA(isA())); - }); - testWidgets('initializes if all is OK', (_) async { await plugin.initWithParams( const SignInInitParameters( @@ -117,6 +107,40 @@ void main() { ); }, throwsAssertionError); }); + + testWidgets('must be called for most of the API to work', (_) async { + expect(() async { + await plugin.signInSilently(); + }, throwsStateError); + + expect(() async { + await plugin.signIn(); + }, throwsStateError); + + expect(() async { + await plugin.getTokens(email: ''); + }, throwsStateError); + + expect(() async { + await plugin.signOut(); + }, throwsStateError); + + expect(() async { + await plugin.disconnect(); + }, throwsStateError); + + expect(() async { + await plugin.isSignedIn(); + }, throwsStateError); + + expect(() async { + await plugin.clearAuthCache(token: ''); + }, throwsStateError); + + expect(() async { + await plugin.requestScopes([]); + }, throwsStateError); + }); }); group('(with mocked GIS)', () { @@ -129,7 +153,7 @@ void main() { setUp(() { plugin = GoogleSignInPlugin( - debugOverrideLoader: true + debugOverrideLoader: true, ); mockGis = MockGisSdkClient(); }); @@ -142,15 +166,15 @@ void main() { testWidgets('always returns null, regardless of GIS response', (_) async { final GoogleSignInUserData someUser = extractUserData(person)!; - mockito.when(mockGis.signInSilently()).thenAnswer( - (_) => Future.value(someUser), - ); + mockito + .when(mockGis.signInSilently()) + .thenAnswer((_) => Future.value(someUser)); expect(plugin.signInSilently(), completion(isNull)); - mockito.when(mockGis.signInSilently()).thenAnswer( - (_) => Future.value(), - ); + mockito + .when(mockGis.signInSilently()) + .thenAnswer((_) => Future.value()); expect(plugin.signInSilently(), completion(isNull)); }); @@ -164,28 +188,28 @@ void main() { testWidgets('returns the signed-in user', (_) async { final GoogleSignInUserData someUser = extractUserData(person)!; - mockito.when(mockGis.signIn()).thenAnswer( - (_) => Future.value(someUser), - ); + mockito + .when(mockGis.signIn()) + .thenAnswer((_) => Future.value(someUser)); expect(await plugin.signIn(), someUser); }); testWidgets('returns null if no user is signed in', (_) async { - mockito.when(mockGis.signIn()).thenAnswer( - (_) => Future.value(), - ); + mockito + .when(mockGis.signIn()) + .thenAnswer((_) => Future.value()); expect(await plugin.signIn(), isNull); }); - testWidgets('converts inner errors.toString to PlatformException', (_) async { + testWidgets('converts inner errors to PlatformException', (_) async { mockito.when(mockGis.signIn()).thenThrow('popup_closed'); try { await plugin.signIn(); fail('signIn should have thrown an exception'); - } catch(exception) { + } catch (exception) { expect(exception, isA()); expect((exception as PlatformException).code, 'popup_closed'); } diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart index f453071f2190..e81ccb6e95b5 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/people_test.dart @@ -22,7 +22,7 @@ void main() { group('requestUserData', () { const String expectedAccessToken = '3xp3c73d_4cc355_70k3n'; - final TokenResponse fakeToken = jsifyAs( { + final TokenResponse fakeToken = jsifyAs({ 'token_type': 'Bearer', 'access_token': expectedAccessToken, }); @@ -31,16 +31,16 @@ void main() { final Completer accessTokenCompleter = Completer(); final http.Client mockClient = http_test.MockClient( - (http.Request request) async { - - accessTokenCompleter.complete(request.headers['Authorization']); - - return http.Response( - jsonEncode(person), - 200, - headers: {'content-type': 'application/json'}, - ); - }); + (http.Request request) async { + accessTokenCompleter.complete(request.headers['Authorization']); + + return http.Response( + jsonEncode(person), + 200, + headers: {'content-type': 'application/json'}, + ); + }, + ); final GoogleSignInUserData? user = await requestUserData( fakeToken, @@ -53,17 +53,21 @@ void main() { expect(user.displayName, expectedPersonName); expect(user.photoUrl, expectedPersonPhoto); expect(user.idToken, isNull); - expect(accessTokenCompleter.future, completion('Bearer $expectedAccessToken')); + expect( + accessTokenCompleter.future, + completion('Bearer $expectedAccessToken'), + ); }); testWidgets('Unauthorized request - throws exception', (_) async { final http.Client mockClient = http_test.MockClient( - (http.Request request) async { - return http.Response( - 'Unauthorized', - 403, - ); - }); + (http.Request request) async { + return http.Response( + 'Unauthorized', + 403, + ); + }, + ); expect(() async { await requestUserData( @@ -87,7 +91,8 @@ void main() { }); testWidgets('no name/photo - keeps going', (_) async { - final Map personWithoutSomeData = mapWithoutKeys(person, { + final Map personWithoutSomeData = + mapWithoutKeys(person, { 'names', 'photos', }); @@ -103,7 +108,8 @@ void main() { }); testWidgets('no userId - throws assertion error', (_) async { - final Map personWithoutId = mapWithoutKeys(person, { + final Map personWithoutId = + mapWithoutKeys(person, { 'resourceName', }); @@ -113,7 +119,8 @@ void main() { }); testWidgets('no email - throws assertion error', (_) async { - final Map personWithoutEmail = mapWithoutKeys(person, { + final Map personWithoutEmail = + mapWithoutKeys(person, { 'emailAddresses', }); diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart index 9a9d8ce69f81..f7d3152a7e64 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/dom.dart @@ -21,6 +21,7 @@ abstract class DomHtmlDocument {} extension DomHtmlDocumentExtension on DomHtmlDocument { /// document.head external DomHtmlElement get head; + /// document.createElement external DomHtmlElement createElement(String tagName); } @@ -34,6 +35,7 @@ abstract class DomHtmlElement {} extension DomHtmlElementExtension on DomHtmlElement { /// Node.appendChild external DomHtmlElement appendChild(DomHtmlElement child); + /// Element.remove external void remove(); } diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart index bd79bd83dae3..824886c9b1b9 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart @@ -5,46 +5,47 @@ const String expectedPersonId = '1234567890'; const String expectedPersonName = 'Test McTestFace True'; const String expectedPersonEmail = 'mctestface@example.com'; -const String expectedPersonPhoto = 'https://thispersondoesnotexist.com/image?x=.jpg'; +const String expectedPersonPhoto = + 'https://thispersondoesnotexist.com/image?x=.jpg'; -/// A subset of https://developers.google.com/people/api/rest/v1/people#Person -final Map person = { +/// A subset of https://developers.google.com/people/api/rest/v1/people#Person. +final Map person = { 'resourceName': 'people/$expectedPersonId', - 'emailAddresses': [ - { - 'metadata': { + 'emailAddresses': [ + { + 'metadata': { 'primary': false, }, 'value': 'bad@example.com', }, - { - 'metadata': {}, + { + 'metadata': {}, 'value': 'nope@example.com', }, - { - 'metadata': { + { + 'metadata': { 'primary': true, }, 'value': expectedPersonEmail, }, ], - 'names': [ - { - 'metadata': { + 'names': [ + { + 'metadata': { 'primary': true, }, 'displayName': expectedPersonName, }, - { - 'metadata': { + { + 'metadata': { 'primary': false, }, 'displayName': 'Fakey McFakeface', }, ], - 'photos': [ - { - 'metadata': { + 'photos': [ + { + 'metadata': { 'primary': true, }, 'url': expectedPersonPhoto, @@ -52,8 +53,14 @@ final Map person = { ], }; -T mapWithoutKeys>(T map, Set keysToRemove) { +/// Returns a copy of [map] without the [keysToRemove]. +T mapWithoutKeys>( + T map, + Set keysToRemove, +) { return Map.fromEntries( - map.entries.where((MapEntry entry) => !keysToRemove.contains(entry.key)) + map.entries.where((MapEntry entry) { + return !keysToRemove.contains(entry.key); + }), ) as T; } diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart index 350c4b04989c..e998cd30fa59 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 @@ -29,13 +29,15 @@ void main() { const String expectedIdToken = 'some-value-for-testing'; const String expectedAccessToken = 'another-value-for-testing'; - final CredentialResponse credential = jsifyAs({ + final CredentialResponse credential = + jsifyAs({ 'credential': expectedIdToken, }); - final TokenResponse token = jsifyAs( { + final TokenResponse token = jsifyAs({ 'access_token': expectedAccessToken, }); - final GoogleSignInTokenData tokens = gisResponsesToTokenData(credential, token); + final GoogleSignInTokenData tokens = + gisResponsesToTokenData(credential, token); expect(tokens.accessToken, expectedAccessToken); expect(tokens.idToken, expectedIdToken); expect(tokens.serverAuthCode, isNull); @@ -70,7 +72,8 @@ void main() { }); testWidgets('invalid payload -> null', (_) async { - final CredentialResponse response = jsifyAs({ + final CredentialResponse response = + jsifyAs({ 'credential': 'some-bogus.thing-that-is-not.valid-jwt', }); expect(gisResponsesToUserData(response), isNull); @@ -84,10 +87,12 @@ CredentialResponse createJwt(Map? claims) { final JsonWebTokenClaims token = JsonWebTokenClaims.fromJson(claims); final JsonWebSignatureBuilder builder = JsonWebSignatureBuilder(); builder.jsonContent = token.toJson(); - builder.addRecipient(JsonWebKey.fromJson({ - 'kty': 'oct', - 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), - }), algorithm: 'HS256'); // bogus crypto, don't use this for prod! + builder.addRecipient( + JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), + }), + algorithm: 'HS256'); // bogus crypto, don't use this for prod! builder.setProtectedHeader('typ', 'JWT'); credential = builder.build().toCompactSerialization(); // ignore:avoid_print diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index ec0b86cbf0ef..d536829e3c70 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -15,8 +15,10 @@ import 'src/gis_client.dart'; /// The `name` of the meta-tag to define a ClientID in HTML. const String clientIdMetaName = 'google-signin-client_id'; + /// The selector used to find the meta-tag that defines a ClientID in HTML. const String clientIdMetaSelector = 'meta[name=$clientIdMetaName]'; + /// The attribute name that stores the Client ID in the meta-tag that defines a Client ID in HTML. const String clientIdAttributeName = 'content'; @@ -26,9 +28,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { /// background. /// /// The plugin is completely initialized when [initialized] completed. - GoogleSignInPlugin({ - @visibleForTesting bool debugOverrideLoader = false - }) { + GoogleSignInPlugin({@visibleForTesting bool debugOverrideLoader = false}) { autoDetectedClientId = html .querySelector(clientIdMetaSelector) ?.getAttribute(clientIdAttributeName); @@ -90,7 +90,8 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { } @override - Future initWithParams(SignInInitParameters params, { + Future initWithParams( + SignInInitParameters params, { @visibleForTesting GisSdkClient? overrideClient, }) async { final String? appClientId = params.clientId ?? autoDetectedClientId; @@ -111,13 +112,14 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { await _isJsSdkLoaded; - _gisClient = overrideClient ?? GisSdkClient( - clientId: appClientId!, - hostedDomain: params.hostedDomain, - initialScopes: List.from(params.scopes), - // *TODO(dit): Remove this before releasing. - loggingEnabled: true, - ); + _gisClient = overrideClient ?? + GisSdkClient( + clientId: appClientId!, + hostedDomain: params.hostedDomain, + initialScopes: List.from(params.scopes), + // *TODO(dit): Remove this before releasing. + loggingEnabled: true, + ); _isInitCalled = true; } 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 e584db6f9ab5..927cbdaf69f5 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 @@ -38,6 +38,7 @@ class GisSdkClient { clientId, onResponse: _onCredentialResponse, ); + _tokenClient = _initializeTokenClient( clientId, hostedDomain: hostedDomain, @@ -63,7 +64,8 @@ class GisSdkClient { } // Initializes the `id` SDK for the silent-sign in (authentication) client. - void _initializeIdClient(String clientId, { + void _initializeIdClient( + String clientId, { required CallbackFn onResponse, }) { // Initialize `id` for the silent-sign in code. @@ -88,7 +90,8 @@ class GisSdkClient { } // Creates a `oauth2.TokenClient` used for authorization (scope) requests. - TokenClient _initializeTokenClient(String clientId, { + TokenClient _initializeTokenClient( + String clientId, { String? hostedDomain, required TokenClientCallbackFn onResponse, required ErrorCallbackFn onError, @@ -165,9 +168,8 @@ class GisSdkClient { // the _credentialResponses stream has time to propagate its last value, // and we can use _lastCredentialResponse. return Future.delayed(Duration.zero, () { - completer.complete( - utils.gisResponsesToUserData(_lastCredentialResponse) - ); + completer + .complete(utils.gisResponsesToUserData(_lastCredentialResponse)); }); } @@ -226,7 +228,7 @@ class GisSdkClient { // Complete user data either with the _lastCredentialResponse seen, // or the synthetic _requestedUserData from above. return utils.gisResponsesToUserData(_lastCredentialResponse) ?? - _requestedUserData; + _requestedUserData; } /// Returns a [GoogleSignInTokenData] from the latest seen responses. diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart index 30f4418722e6..eecbb1ffc17d 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart @@ -21,11 +21,14 @@ const String MY_PROFILE = 'https://content-people.googleapis.com/v1/people/me' '&personFields=photos%2Cnames%2CemailAddresses'; /// Requests user data from the People API using the given [tokenResponse]. -Future requestUserData(TokenResponse tokenResponse, { +Future requestUserData( + TokenResponse tokenResponse, { @visibleForTesting http.Client? overrideClient, }) async { // Request my profile from the People API. - final Map person = await _doRequest(MY_PROFILE, tokenResponse, + final Map person = await _doRequest( + MY_PROFILE, + tokenResponse, overrideClient: overrideClient, ); @@ -85,9 +88,11 @@ T? _extractPrimaryField(List? values, String fieldName) { if (values != null) { for (final Object? value in values) { if (value != null && value is Map) { - final bool isPrimary = _extractPath(value, - path: ['metadata', 'primary'], - defaultValue: false,); + final bool isPrimary = _extractPath( + value, + path: ['metadata', 'primary'], + defaultValue: false, + ); if (isPrimary) { return value[fieldName] as T?; } @@ -125,13 +130,16 @@ T _extractPath( /// Gets from [url] with an authorization header defined by [token]. /// /// Attempts to [jsonDecode] the result. -Future> _doRequest(String url, TokenResponse token, { +Future> _doRequest( + String url, + TokenResponse token, { http.Client? overrideClient, }) async { final Uri uri = Uri.parse(url); final http.Client client = overrideClient ?? http.Client(); try { - final http.Response response = await client.get(uri, headers: { + final http.Response response = + await client.get(uri, headers: { 'Authorization': '${token.token_type} ${token.access_token}', }); if (response.statusCode != 200) { From 0fc3c98d38f16ab2377dfa446b7125be6880c8e6 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 18:51:26 -0800 Subject: [PATCH 18/31] Log only in debug. --- .../google_sign_in_web/lib/google_sign_in_web.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index d536829e3c70..92450d7192bd 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'dart:html' as html; -import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/foundation.dart' show visibleForTesting, kDebugMode; import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:google_identity_services_web/loader.dart' as loader; @@ -117,8 +117,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { clientId: appClientId!, hostedDomain: params.hostedDomain, initialScopes: List.from(params.scopes), - // *TODO(dit): Remove this before releasing. - loggingEnabled: true, + loggingEnabled: kDebugMode, ); _isInitCalled = true; From 5bfcec51474f1657b055c685c8d436718fc78cd0 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 17 Jan 2023 19:25:42 -0800 Subject: [PATCH 19/31] Sync min sdk with package gis_web --- packages/google_sign_in/google_sign_in_web/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d9d03465c721..af38042bfb31 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -6,7 +6,7 @@ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+ version: 0.11.0 environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.17.0 <3.0.0" flutter: ">=3.0.0" flutter: From 8bee18be3f43f7e24b4878c1a6fa097f0e9c97b2 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Fri, 27 Jan 2023 18:02:20 -0800 Subject: [PATCH 20/31] Add migration notes to the README. --- .../google_sign_in_web/CHANGELOG.md | 11 +- .../google_sign_in_web/README.md | 123 +++++++++++++----- 2 files changed, 90 insertions(+), 44 deletions(-) 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 6ae170badba0..015334d77a59 100644 --- a/packages/google_sign_in/google_sign_in_web/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in_web/CHANGELOG.md @@ -2,16 +2,7 @@ * **Breaking Change:** Migrates JS-interop to `package:google_identity_services_web` * Uses the new Google Identity Authentication and Authorization JS SDKs. [Docs](https://developers.google.com/identity). - * TODO: Move the below to a migration instructions doc in the README. - * Authentication and Authorization are now two separate concerns. - * `signInSilently` now displays the One Tap UX for web. The SDK no longer has - direct access to previously-seen users. - * The new SDK only provides an `idToken` when the user does `signInSilently`. - * The plugin attempts to mimic the old behavior (of retrieving Profile information - on `signIn`) but in that case, the `idToken` is not returned. - * The plugin no longer is able to renew Authorization sessions on the web. - Once the session expires, API requests will begin to fail with unauthorized, - and user Authorization is required again. + * Added "Migrating to v0.11" section to the `README.md`. * Updates minimum Flutter version to 3.0. ## 0.10.2+1 diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md index 7c02379808da..681ada865073 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -2,6 +2,87 @@ The web implementation of [google_sign_in](https://pub.dev/packages/google_sign_in) +## Migrating to v0.11 (Google Identity Services) + +The `google_sign_in_web` plugin is backed by the new Google Identity Services +JS SDK since version 0.11.0. + +The new SDK is used both for [Authentication](https://developers.google.com/identity/gsi/web/guides/overview) +and [Authorization](https://developers.google.com/identity/oauth2/web/guides/overview) flows. + +The new SDK, however, doesn't behave exactly like the one being deprecated. +Some concepts have experienced pretty drastic changes, and that's why this +required a major version update. + +### Key differences between the SDK + +* For the SDK, Authentication and Authorization are now two separate concerns. + * Authentication (information about the current user) flows will not + authorize `scopes` anymore. + * Authorization (permissions for the app to access certain user information) + flows will not return authentication information. +* The SDK no longer has direct access to previously-seen users upon initialization. + * `signInSilently` now displays the One Tap UX for web. +* The new SDK only provides an `idToken` (JWT-encoded info) when the user + successfully completes `signInSilently`. +* `signIn` uses the Oauth "Implicit Flow" to Authorize the requested `scopes`. + * If the user hasn't `signInSilently`, they'll have to sign in as a first step + of the Authorization popup flow. + * If `signInSilently` was unsuccessful, the plugin will add extra `scopes` to + `signIn` and retrieve basic Profile information from the People API via a + REST call immediately after a successful authorization. In this case, the + `idToken` field of the `GoogleSignInUserData` will always be null. +* The SDK no longer handles sign-in state and user sessions, it only provides + Authentication credentials for the moment the user did authenticate. +* The SDK no longer is able to renew Authorization sessions on the web. + Once the token expires, API requests will begin to fail with unauthorized, + and user Authorization is required again. + +See more differences in the following migration guides: + +* Authentication > [Migrating from Google Sign-In](https://developers.google.com/identity/gsi/web/guides/migration) +* Authorization > [Migrate to Google Identity Services](https://developers.google.com/identity/oauth2/web/guides/migration-to-gis) + +### New use cases to take into account in your app + +#### User Sessions + +Since the new SDK does *not* manage user sessions anymore, apps that relied on +this feature might break. + +If long-lived sessions are required, consider using some User authentication +system that supports Google Sign In as a federated Authentication provider, +like [Firebase Auth](https://firebase.google.com/docs/auth/flutter/federated-auth#google), +or similar (expand this list as other providers become generally available for +Flutter web). + +#### Expired / Invalid Authorization Tokens + +Since the new SDK does *not* auto-renew authorization tokens anymore, it's now +the responsibility of your app to do so. + +Apps now need to monitor the status code of their REST API requests for response +codes different to `200`. For example: + +* `401`: Missing or invalid access token. +* `403`: Expired access token. + +In either case, your app needs to prompt the end user to `signIn` again, to +interactively renew the token. The GIS SDK limits authorization token duration +to one hour (3600 seconds). + +#### Null `idToken` + +The `GoogleSignInUserData` returned after `signIn` may contain a `null` `idToken` +field. This is not an indication of the session being invalid, it's just that +the user canceled (or wasn't presented with) the OneTap UX from `signInSilently`. + +In cases where the OneTap UX does not authenticate the user, the `signIn` method +will attempt to "fill in the gap" by requesting basic profile information of the +currently signed-in user. + +In that case, the `GoogleSignInUserData` will contain a `null` `idToken`. + ## Usage ### Import the package @@ -12,7 +93,7 @@ normally. This package will be automatically included in your app when you do. ### Web integration -First, go through the instructions [here](https://developers.google.com/identity/sign-in/web/sign-in#before_you_begin) to create your Google Sign-In OAuth client ID. +First, go through the instructions [here](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) to create your Google Sign-In OAuth client ID. On your `web/index.html` file, add the following `meta` tag, somewhere in the `head` of the document: @@ -29,7 +110,10 @@ You can do this by: 2. Clicking "Edit" in the OAuth 2.0 Web application client that you created above. 3. Adding the URIs you want to the **Authorized JavaScript origins**. -For local development, may add a `localhost` entry, for example: `http://localhost:7357` +For local development, you must add two `localhost` entries: + +* `http://localhost` and +* `http://localhost:7357` (or any port that is free in your machine) #### Starting flutter in http://localhost:7357 @@ -45,40 +129,11 @@ flutter run -d chrome --web-hostname localhost --web-port 7357 Read the rest of the instructions if you need to add extra APIs (like Google People API). - ### Using the plugin -Add the following import to your Dart code: -```dart -import 'package:google_sign_in/google_sign_in.dart'; -``` +See the [**Usage** instructions of `package:google_sign_in`](https://pub.dev/packages/google_sign_in#usage) -Initialize GoogleSignIn with the scopes you want: - -```dart -GoogleSignIn _googleSignIn = GoogleSignIn( - scopes: [ - 'email', - 'https://www.googleapis.com/auth/contacts.readonly', - ], -); -``` - -[Full list of available scopes](https://developers.google.com/identity/protocols/googlescopes). - -Note that the `serverClientId` parameter of the `GoogleSignIn` constructor is not supported on Web. - -You can now use the `GoogleSignIn` class to authenticate in your Dart code, e.g. - -```dart -Future _handleSignIn() async { - try { - await _googleSignIn.signIn(); - } catch (error) { - print(error); - } -} -``` +Note that the **`serverClientId` parameter of the `GoogleSignIn` constructor is not supported on Web.** ## Example @@ -86,7 +141,7 @@ Find the example wiring in the [Google sign-in example application](https://gith ## API details -See the [google_sign_in.dart](https://github.com/flutter/plugins/blob/main/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart) for more API details. +See [google_sign_in.dart](https://github.com/flutter/plugins/blob/main/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart) for more API details. ## Contributions and Testing From c7a9a32d9c97db706d5023b3b75d9b8e9dfaffde Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Fri, 27 Jan 2023 19:15:12 -0800 Subject: [PATCH 21/31] When the user is known upon signIn, remove friction. * Do not ask for user selection again in the authorization popup * Pass the email of the known user as a hint to the signIn method --- .../google_sign_in_web/lib/src/gis_client.dart | 6 ++++++ 1 file changed, 6 insertions(+) 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 927cbdaf69f5..d057d865f2df 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 @@ -196,9 +196,15 @@ class GisSdkClient { /// [_initialScopes], so we can retrieve User Profile information back /// from the People API (without idToken). See [people.requestUserData]. Future signIn() async { + // If we already know the user, use their `email` as a `hint`, so they don't + // have to pick their user again in the Authorization popup. + final GoogleSignInUserData? knownUser = + utils.gisResponsesToUserData(_lastCredentialResponse); // This toggles a popup, so `signIn` *must* be called with // user activation. _tokenClient.requestAccessToken(OverridableTokenClientConfig( + prompt: knownUser == null ? 'select_account' : '', + hint: knownUser?.email, scope: [ ..._initialScopes, // If the user hasn't gone through the auth process, From 6e6048448e5d9f91ea0109b1b567999cbf10f86f Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Fri, 27 Jan 2023 19:33:13 -0800 Subject: [PATCH 22/31] Address PR comments / checks. --- .../integration_test/src/create_jwt.dart | 34 +++++++++++++++++++ .../example/integration_test/utils_test.dart | 26 +------------- .../lib/src/gis_client.dart | 4 +-- 3 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart new file mode 100644 index 000000000000..ac830e02e9f8 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:google_identity_services_web/id.dart'; +import 'package:jose/jose.dart'; + +import 'jsify_as.dart'; + +/// Wraps a key-value map of [claims] in a JWT token similar GIS'. +/// +/// Note that the encryption of this token is weak, and this method should +/// only be used for tests! +CredentialResponse createJwt(Map? claims) { + String? credential; + if (claims != null) { + final JsonWebTokenClaims token = JsonWebTokenClaims.fromJson(claims); + final JsonWebSignatureBuilder builder = JsonWebSignatureBuilder(); + builder.jsonContent = token.toJson(); + builder.addRecipient( + JsonWebKey.fromJson({ + 'kty': 'oct', + 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), + }), + algorithm: 'HS256'); // bogus crypto, don't use this for prod! + builder.setProtectedHeader('typ', 'JWT'); + credential = builder.build().toCompactSerialization(); + } + return jsifyAs({ + 'credential': credential, + }); +} 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 e998cd30fa59..90f85addc5bc 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 @@ -2,16 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; import 'package:google_sign_in_web/src/utils.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:jose/jose.dart'; +import 'src/create_jwt.dart'; import 'src/jsify_as.dart'; void main() { @@ -80,25 +78,3 @@ void main() { }); }); } - -CredentialResponse createJwt(Map? claims) { - String? credential; - if (claims != null) { - final JsonWebTokenClaims token = JsonWebTokenClaims.fromJson(claims); - final JsonWebSignatureBuilder builder = JsonWebSignatureBuilder(); - builder.jsonContent = token.toJson(); - builder.addRecipient( - JsonWebKey.fromJson({ - 'kty': 'oct', - 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), - }), - algorithm: 'HS256'); // bogus crypto, don't use this for prod! - builder.setProtectedHeader('typ', 'JWT'); - credential = builder.build().toCompactSerialization(); - // ignore:avoid_print - print('Generated JWT: $credential'); - } - return jsifyAs({ - 'credential': credential, - }); -} 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 d057d865f2df..b0cbbace0fcc 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 @@ -199,7 +199,7 @@ class GisSdkClient { // If we already know the user, use their `email` as a `hint`, so they don't // have to pick their user again in the Authorization popup. final GoogleSignInUserData? knownUser = - utils.gisResponsesToUserData(_lastCredentialResponse); + utils.gisResponsesToUserData(_lastCredentialResponse); // This toggles a popup, so `signIn` *must* be called with // user activation. _tokenClient.requestAccessToken(OverridableTokenClientConfig( @@ -287,7 +287,7 @@ class GisSdkClient { // The scopes initially requested by the developer. // - // We store this because we might need to add more at `signIn`, if the user + // We store this because we might need to add more at `signIn`. If the user // doesn't `silentSignIn`, we expand this list to consult the People API to // return some basic Authentication information. final List _initialScopes; From f1e55aa5f5aacae41ded6b8e3477ce02f534f76f Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Fri, 27 Jan 2023 21:38:53 -0800 Subject: [PATCH 23/31] Update migration guide after comments from testers. --- .../google_sign_in_web/README.md | 64 ++++++++++++++----- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md index 681ada865073..cf006e2004b0 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -45,9 +45,52 @@ See more differences in the following migration guides: ### New use cases to take into account in your app +#### Enable access to the People API for your GCP project + +Since the GIS SDK is separating Authentication from Authorization, the +[Oauth Implicit pop-up flow](https://developers.google.com/identity/oauth2/web/guides/use-token-model) +used to Authorize scopes does **not** return any Authentication information +anymore (user credential / `idToken`). + +If the plugin is not able to Authenticate an user from `signInSilently` (the +OneTap UX flow), it'll add extra `scopes` to those requested by the programmer +so it can perform a [People API request](https://developers.google.com/people/api/rest/v1/people/get) +to retrieve basic profile information about the user that is signed-in. + +The information retrieved from the People API is used to complete data for the +[`GoogleSignInAccount`](https://pub.dev/documentation/google_sign_in/latest/google_sign_in/GoogleSignInAccount-class.html) +object that is returned after `signIn` completes successfully. + +#### `signInSilently` always returns `null` + +Previous versions of this plugin were able to return a `GoogleSignInAccount` +object that was fully populated (signed-in and authorized) from `signInSilently` +because the former SDK equated "is authenticated" and "is authorized". + +With the GIS SDK, `signInSilently` only deals with user Authentication, so users +retrieved "silently" will only contain an `idToken`, but not an `accessToken`. + +Only after `signIn` or `requestScopes`, a user will be fully formed. + +The GIS-backed plugin always returns `null` from `signInSilently`, to force apps +that expect the former logic to perform a full `signIn`, which will result in a +fully Authenticated and Authorized user, and making this migration easier. + +#### `idToken` is `null` in the `GoogleSignInAccount` object after `signIn` + +Since the GIS SDK is separating Authentication and Authorization, when a user +fails to Authenticate through `signInSilently` and the plugin performs the +fallback request to the People API described above, +the returned `GoogleSignInUserData` object will contain basic profile information +(name, email, photo, ID), but its `idToken` will be `null`. + +This is because JWT are cryptographically signed by Google Identity Services, and +this plugin won't spoof that signature when it retrieves the information from a +simple REST request. + #### User Sessions -Since the new SDK does *not* manage user sessions anymore, apps that relied on +Since the new SDK does _not_ manage user sessions anymore, apps that relied on this feature might break. If long-lived sessions are required, consider using some User authentication @@ -58,7 +101,7 @@ Flutter web). #### Expired / Invalid Authorization Tokens -Since the new SDK does *not* auto-renew authorization tokens anymore, it's now +Since the new SDK does _not_ auto-renew authorization tokens anymore, it's now the responsibility of your app to do so. Apps now need to monitor the status code of their REST API requests for response @@ -67,21 +110,10 @@ codes different to `200`. For example: * `401`: Missing or invalid access token. * `403`: Expired access token. -In either case, your app needs to prompt the end user to `signIn` again, to -interactively renew the token. The GIS SDK limits authorization token duration -to one hour (3600 seconds). - -#### Null `idToken` - -The `GoogleSignInUserData` returned after `signIn` may contain a `null` `idToken` -field. This is not an indication of the session being invalid, it's just that -the user canceled (or wasn't presented with) the OneTap UX from `signInSilently`. - -In cases where the OneTap UX does not authenticate the user, the `signIn` method -will attempt to "fill in the gap" by requesting basic profile information of the -currently signed-in user. +In either case, your app needs to prompt the end user to `signIn` or +`requestScopes`, to interactively renew the token. -In that case, the `GoogleSignInUserData` will contain a `null` `idToken`. +The GIS SDK limits authorization token duration to one hour (3600 seconds). ## Usage From e9efdd60917b272b9fe4a4608af8f06bcb0626e9 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 14 Feb 2023 02:13:50 +0000 Subject: [PATCH 24/31] Update README.md --- .../google_sign_in_web/README.md | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md index cf006e2004b0..64bfd7a20161 100644 --- a/packages/google_sign_in/google_sign_in_web/README.md +++ b/packages/google_sign_in/google_sign_in_web/README.md @@ -5,36 +5,40 @@ The web implementation of [google_sign_in](https://pub.dev/packages/google_sign_ ## Migrating to v0.11 (Google Identity Services) The `google_sign_in_web` plugin is backed by the new Google Identity Services -JS SDK since version 0.11.0. +(GIS) JS SDK since version 0.11.0. -The new SDK is used both for [Authentication](https://developers.google.com/identity/gsi/web/guides/overview) +The GIS SDK is used both for [Authentication](https://developers.google.com/identity/gsi/web/guides/overview) and [Authorization](https://developers.google.com/identity/oauth2/web/guides/overview) flows. -The new SDK, however, doesn't behave exactly like the one being deprecated. +The GIS SDK, however, doesn't behave exactly like the one being deprecated. Some concepts have experienced pretty drastic changes, and that's why this -required a major version update. +plugin required a major version update. -### Key differences between the SDK +### Differences between Google Identity Services SDK and Google Sign-In for Web SDK. -* For the SDK, Authentication and Authorization are now two separate concerns. +The **Google Sign-In JavaScript for Web JS SDK** is set to be deprecated after +March 31, 2023. **Google Identity Services (GIS) SDK** is the new solution to +quickly and easily sign users into your app suing their Google accounts. + +* In the GIS SDK, Authentication and Authorization are now two separate concerns. * Authentication (information about the current user) flows will not authorize `scopes` anymore. * Authorization (permissions for the app to access certain user information) flows will not return authentication information. -* The SDK no longer has direct access to previously-seen users upon initialization. +* The GIS SDK no longer has direct access to previously-seen users upon initialization. * `signInSilently` now displays the One Tap UX for web. -* The new SDK only provides an `idToken` (JWT-encoded info) when the user - successfully completes `signInSilently`. -* `signIn` uses the Oauth "Implicit Flow" to Authorize the requested `scopes`. +* The GIS SDK only provides an `idToken` (JWT-encoded info) when the user + successfully completes an authentication flow. In the plugin: `signInSilently`. +* The plugin `signIn` method uses the Oauth "Implicit Flow" to Authorize the requested `scopes`. * If the user hasn't `signInSilently`, they'll have to sign in as a first step of the Authorization popup flow. * If `signInSilently` was unsuccessful, the plugin will add extra `scopes` to `signIn` and retrieve basic Profile information from the People API via a REST call immediately after a successful authorization. In this case, the `idToken` field of the `GoogleSignInUserData` will always be null. -* The SDK no longer handles sign-in state and user sessions, it only provides +* The GIS SDK no longer handles sign-in state and user sessions, it only provides Authentication credentials for the moment the user did authenticate. -* The SDK no longer is able to renew Authorization sessions on the web. +* The GIS SDK no longer is able to renew Authorization sessions on the web. Once the token expires, API requests will begin to fail with unauthorized, and user Authorization is required again. @@ -90,18 +94,17 @@ simple REST request. #### User Sessions -Since the new SDK does _not_ manage user sessions anymore, apps that relied on +Since the GIS SDK does _not_ manage user sessions anymore, apps that relied on this feature might break. -If long-lived sessions are required, consider using some User authentication +If long-lived sessions are required, consider using some user authentication system that supports Google Sign In as a federated Authentication provider, like [Firebase Auth](https://firebase.google.com/docs/auth/flutter/federated-auth#google), -or similar (expand this list as other providers become generally available for -Flutter web). +or similar. #### Expired / Invalid Authorization Tokens -Since the new SDK does _not_ auto-renew authorization tokens anymore, it's now +Since the GIS SDK does _not_ auto-renew authorization tokens anymore, it's now the responsibility of your app to do so. Apps now need to monitor the status code of their REST API requests for response From 85218f4c646e1c40dc60a43adbae435a8da1b6bc Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 14 Feb 2023 02:14:15 +0000 Subject: [PATCH 25/31] Remove package:jose from tests. --- .../integration_test/src/create_jwt.dart | 34 ------------ .../integration_test/src/jwt_examples.dart | 54 +++++++++++++++++++ .../example/integration_test/utils_test.dart | 15 ++---- .../google_sign_in_web/example/pubspec.yaml | 1 - 4 files changed, 58 insertions(+), 46 deletions(-) delete mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart create mode 100644 packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart deleted file mode 100644 index ac830e02e9f8..000000000000 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/create_jwt.dart +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:convert'; - -import 'package:google_identity_services_web/id.dart'; -import 'package:jose/jose.dart'; - -import 'jsify_as.dart'; - -/// Wraps a key-value map of [claims] in a JWT token similar GIS'. -/// -/// Note that the encryption of this token is weak, and this method should -/// only be used for tests! -CredentialResponse createJwt(Map? claims) { - String? credential; - if (claims != null) { - final JsonWebTokenClaims token = JsonWebTokenClaims.fromJson(claims); - final JsonWebSignatureBuilder builder = JsonWebSignatureBuilder(); - builder.jsonContent = token.toJson(); - builder.addRecipient( - JsonWebKey.fromJson({ - 'kty': 'oct', - 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), - }), - algorithm: 'HS256'); // bogus crypto, don't use this for prod! - builder.setProtectedHeader('typ', 'JWT'); - credential = builder.build().toCompactSerialization(); - } - return jsifyAs({ - 'credential': credential, - }); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart new file mode 100644 index 000000000000..4704258c8254 --- /dev/null +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart @@ -0,0 +1,54 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:google_identity_services_web/id.dart'; + +import 'jsify_as.dart'; + +/// A JWT token with null `credential`. +final CredentialResponse nullCredential = + jsifyAs({ + 'credential': null, +}); + +/// A JWT token for predefined values. +/// +/// 'email': 'test@example.com', +/// 'sub': '123456', +/// 'name': 'Test McTestface', +/// 'picture': 'https://thispersondoesnotexist.com/image', +/// +/// Signed with HS256 and the private key: 'symmetric-encryption-is-weak' +final CredentialResponse okCredential = + jsifyAs({ + 'credential': + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiVGVzdCBNY1Rlc3RmYWNlIiwicGljdHVyZSI6Imh0dHBzOi8vdGhpc3BlcnNvbmRvZXNub3RleGlzdC5jb20vaW1hZ2UifQ.pDNaEns4DYZZu6-GeWdgwo1QNcKCCHXEVs26vPD_Rnk', +}); + +// The following code can be useful to generate new `CredentialResponse`s as +// examples. It is implemented using package:jose and dart:convert. +// +// Wraps a key-value map of [claims] in a JWT token similar GIS's. +// +// Note that the encryption of this token is weak, and this method should +// only be used for tests! +// CredentialResponse createJwt(Map? claims) { +// String? credential; +// if (claims != null) { +// final JsonWebTokenClaims token = JsonWebTokenClaims.fromJson(claims); +// final JsonWebSignatureBuilder builder = JsonWebSignatureBuilder(); +// builder.jsonContent = token.toJson(); +// builder.addRecipient( +// JsonWebKey.fromJson({ +// 'kty': 'oct', +// 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), +// }), +// algorithm: 'HS256'); // bogus crypto, don't use this for prod! +// builder.setProtectedHeader('typ', 'JWT'); +// credential = builder.build().toCompactSerialization(); +// } +// return jsifyAs({ +// 'credential': credential, +// }); +// } 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 90f85addc5bc..05fc25476b9f 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 @@ -9,8 +9,8 @@ import 'package:google_sign_in_platform_interface/google_sign_in_platform_interf import 'package:google_sign_in_web/src/utils.dart'; import 'package:integration_test/integration_test.dart'; -import 'src/create_jwt.dart'; import 'src/jsify_as.dart'; +import 'src/jwt_examples.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -44,20 +44,13 @@ void main() { group('gisResponsesToUserData', () { testWidgets('happy case', (_) async { - final CredentialResponse response = createJwt({ - 'email': 'test@example.com', - 'sub': '123456', - 'name': 'Test McTestface', - 'picture': 'https://thispersondoesnotexist.com/image', - }); - - final GoogleSignInUserData data = gisResponsesToUserData(response)!; + final GoogleSignInUserData data = gisResponsesToUserData(okCredential)!; expect(data.displayName, 'Test McTestface'); expect(data.id, '123456'); expect(data.email, 'test@example.com'); expect(data.photoUrl, 'https://thispersondoesnotexist.com/image'); - expect(data.idToken, response.credential); + expect(data.idToken, okCredential.credential); }); testWidgets('null response -> null', (_) async { @@ -65,7 +58,7 @@ void main() { }); testWidgets('null response.credential -> null', (_) async { - final CredentialResponse response = createJwt(null); + final CredentialResponse response = nullCredential; expect(gisResponsesToUserData(response), isNull); }); diff --git a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml index d6699b61910e..c73953374696 100644 --- a/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/example/pubspec.yaml @@ -22,6 +22,5 @@ dev_dependencies: http: ^0.13.0 integration_test: sdk: flutter - jose: ^0.3.3 js: ^0.6.3 mockito: ^5.3.2 From 0a70e2187bd205ff57a6e4c1783e37619ca0a2e6 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 14 Feb 2023 02:41:06 +0000 Subject: [PATCH 26/31] Rename to Vincent Adultman --- .../integration_test/src/jsify_as.dart | 2 +- .../integration_test/src/jwt_examples.dart | 36 +++++-------------- .../example/integration_test/src/person.dart | 4 +-- .../example/integration_test/utils_test.dart | 6 ++-- 4 files changed, 14 insertions(+), 34 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart index f0e487f85603..82547b284fe0 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jsify_as.dart @@ -4,7 +4,7 @@ import 'package:js/js_util.dart' as js_util; -/// Convert a [data] object into a JS Object of type `T`. +/// Converts a [data] object into a JS Object of type `T`. T jsifyAs(Map data) { return js_util.jsify(data) as T; } diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart index 4704258c8254..98f2e3c3ac1c 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 @@ -14,41 +14,21 @@ final CredentialResponse nullCredential = /// A JWT token for predefined values. /// -/// 'email': 'test@example.com', +/// 'email': 'adultman@example.com', /// 'sub': '123456', -/// 'name': 'Test McTestface', -/// 'picture': 'https://thispersondoesnotexist.com/image', +/// 'name': 'Vincent Adultman', +/// 'picture': 'https://thispersondoesnotexist.com/image?x=.jpg', /// /// Signed with HS256 and the private key: 'symmetric-encryption-is-weak' final CredentialResponse okCredential = jsifyAs({ 'credential': - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiVGVzdCBNY1Rlc3RmYWNlIiwicGljdHVyZSI6Imh0dHBzOi8vdGhpc3BlcnNvbmRvZXNub3RleGlzdC5jb20vaW1hZ2UifQ.pDNaEns4DYZZu6-GeWdgwo1QNcKCCHXEVs26vPD_Rnk', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwibmFtZSI6IlZpbmNlbnQgQWR1bHRtYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly90aGlzcGVyc29uZG9lc25vdGV4aXN0LmNvbS9pbWFnZT94PS5qcGcifQ.lqzULA_U3YzEl_-fL7YLU-kFXmdD2ttJLTv-UslaNQ4', }); -// The following code can be useful to generate new `CredentialResponse`s as -// examples. It is implemented using package:jose and dart:convert. +// More encrypted credential responses may be created on https://jwt.io. // -// Wraps a key-value map of [claims] in a JWT token similar GIS's. +// First, decode the credential that's listed above, modify to your heart's +// content, and add a new credential here. // -// Note that the encryption of this token is weak, and this method should -// only be used for tests! -// CredentialResponse createJwt(Map? claims) { -// String? credential; -// if (claims != null) { -// final JsonWebTokenClaims token = JsonWebTokenClaims.fromJson(claims); -// final JsonWebSignatureBuilder builder = JsonWebSignatureBuilder(); -// builder.jsonContent = token.toJson(); -// builder.addRecipient( -// JsonWebKey.fromJson({ -// 'kty': 'oct', -// 'k': base64.encode('symmetric-encryption-is-weak'.codeUnits), -// }), -// algorithm: 'HS256'); // bogus crypto, don't use this for prod! -// builder.setProtectedHeader('typ', 'JWT'); -// credential = builder.build().toCompactSerialization(); -// } -// return jsifyAs({ -// 'credential': credential, -// }); -// } +// (It can also be done with `package:jose` and `dart:convert`.) diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart index 824886c9b1b9..2525596eabe9 100644 --- a/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart +++ b/packages/google_sign_in/google_sign_in_web/example/integration_test/src/person.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. const String expectedPersonId = '1234567890'; -const String expectedPersonName = 'Test McTestFace True'; -const String expectedPersonEmail = 'mctestface@example.com'; +const String expectedPersonName = 'Vincent Adultman'; +const String expectedPersonEmail = 'adultman@example.com'; const String expectedPersonPhoto = 'https://thispersondoesnotexist.com/image?x=.jpg'; 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 05fc25476b9f..f809e1661d35 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 @@ -46,10 +46,10 @@ void main() { testWidgets('happy case', (_) async { final GoogleSignInUserData data = gisResponsesToUserData(okCredential)!; - expect(data.displayName, 'Test McTestface'); + expect(data.displayName, 'Vincent Adultman'); expect(data.id, '123456'); - expect(data.email, 'test@example.com'); - expect(data.photoUrl, 'https://thispersondoesnotexist.com/image'); + expect(data.email, 'adultman@example.com'); + expect(data.photoUrl, 'https://thispersondoesnotexist.com/image?x=.jpg'); expect(data.idToken, okCredential.credential); }); From 34704e6450f5b0d5e33e80bc9e0fc99449b746db Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 14 Feb 2023 02:43:49 +0000 Subject: [PATCH 27/31] _isJsSdkLoaded -> _jsSdkLoadedFuture --- .../google_sign_in_web/lib/google_sign_in_web.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart index 92450d7192bd..827b17ca5b44 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/google_sign_in_web.dart @@ -34,13 +34,13 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { ?.getAttribute(clientIdAttributeName); if (debugOverrideLoader) { - _isJsSdkLoaded = Future.value(true); + _jsSdkLoadedFuture = Future.value(true); } else { - _isJsSdkLoaded = loader.loadWebSdk(); + _jsSdkLoadedFuture = loader.loadWebSdk(); } } - late Future _isJsSdkLoaded; + late Future _jsSdkLoadedFuture; bool _isInitCalled = false; // The instance of [GisSdkClient] backing the plugin. @@ -62,7 +62,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { @visibleForTesting Future get initialized { _assertIsInitCalled(); - return _isJsSdkLoaded; + return _jsSdkLoadedFuture; } /// Stores the client ID if it was set in a meta-tag of the page. @@ -110,7 +110,7 @@ class GoogleSignInPlugin extends GoogleSignInPlatform { 'Check https://developers.google.com/identity/protocols/googlescopes ' 'for a list of valid OAuth 2.0 scopes.'); - await _isJsSdkLoaded; + await _jsSdkLoadedFuture; _gisClient = overrideClient ?? GisSdkClient( From 908beacd9110f028b303c551ca93404621ea25af Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 14 Feb 2023 02:47:47 +0000 Subject: [PATCH 28/31] Remove idToken comment. --- packages/google_sign_in/google_sign_in_web/lib/src/people.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart index eecbb1ffc17d..528dc89b1a75 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/people.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/people.dart @@ -60,7 +60,7 @@ GoogleSignInUserData? extractUserData(Map json) { json['photos'] as List?, 'url', ), - // idToken: null, // Synthetic user data doesn't contain an idToken! + // Synthetic user data doesn't contain an idToken! ); } From 7087d4950caeb527e62f667d98ccabb04ff4a4f1 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 14 Feb 2023 02:56:57 +0000 Subject: [PATCH 29/31] Link issue to split mocking better. --- .../google_sign_in_web/lib/src/gis_client.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 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 b0cbbace0fcc..3815322e6900 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 @@ -3,13 +3,11 @@ // found in the LICENSE file. import 'dart:async'; -// TODO(dit): A better layer to split this would be to make a mockable client that -// exposes only the APIs we need from gis_web/id.dart and gis_web/oauth2.dart. -// That way, the GisSdkClient class would be testable (and the mock surface would -// only deal with the few methods we actually use from the SDK). Next version. +// TODO(dit): Split `id` and `oauth2` "services" for mocking. https://github.com/flutter/flutter/issues/120657 import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; +// ignore: unnecessary_import import 'package:js/js.dart'; import 'package:js/js_util.dart'; From 382ed3d703a9a6d83a969220f2426b1e95801dbc Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Tue, 14 Feb 2023 22:49:08 +0000 Subject: [PATCH 30/31] Remove dependency in package:jwt_decoder --- .../integration_test/src/jwt_examples.dart | 32 ++++-- .../example/integration_test/utils_test.dart | 108 +++++++++++++++++- .../google_sign_in_web/lib/src/utils.dart | 51 ++++++++- .../google_sign_in_web/pubspec.yaml | 1 - 4 files changed, 175 insertions(+), 17 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 98f2e3c3ac1c..72841c5165ee 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 @@ -6,13 +6,19 @@ import 'package:google_identity_services_web/id.dart'; import 'jsify_as.dart'; -/// A JWT token with null `credential`. +/// A CredentialResponse with null `credential`. final CredentialResponse nullCredential = jsifyAs({ 'credential': null, }); -/// A JWT token for predefined values. +/// A CredentialResponse wrapping a known good JWT Token as its `credential`. +final CredentialResponse goodCredential = + jsifyAs({ + 'credential': goodJwtToken, +}); + +/// A JWT token with predefined values. /// /// 'email': 'adultman@example.com', /// 'sub': '123456', @@ -20,15 +26,21 @@ final CredentialResponse nullCredential = /// 'picture': 'https://thispersondoesnotexist.com/image?x=.jpg', /// /// Signed with HS256 and the private key: 'symmetric-encryption-is-weak' -final CredentialResponse okCredential = - jsifyAs({ - 'credential': - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwibmFtZSI6IlZpbmNlbnQgQWR1bHRtYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly90aGlzcGVyc29uZG9lc25vdGV4aXN0LmNvbS9pbWFnZT94PS5qcGcifQ.lqzULA_U3YzEl_-fL7YLU-kFXmdD2ttJLTv-UslaNQ4', -}); +const String goodJwtToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$goodPayload.lqzULA_U3YzEl_-fL7YLU-kFXmdD2ttJLTv-UslaNQ4'; + +/// The payload of a JWT token that contains predefined values. +/// +/// 'email': 'adultman@example.com', +/// 'sub': '123456', +/// 'name': 'Vincent Adultman', +/// 'picture': 'https://thispersondoesnotexist.com/image?x=.jpg', +const String goodPayload = + 'eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwibmFtZSI6IlZpbmNlbnQgQWR1bHRtYW4iLCJwaWN0dXJlIjoiaHR0cHM6Ly90aGlzcGVyc29uZG9lc25vdGV4aXN0LmNvbS9pbWFnZT94PS5qcGcifQ'; -// More encrypted credential responses may be created on https://jwt.io. +// More encrypted JWT Tokens may be created on https://jwt.io. // -// First, decode the credential that's listed above, modify to your heart's +// First, decode the `goodJwtToken` above, modify to your heart's // content, and add a new credential here. // -// (It can also be done with `package:jose` and `dart:convert`.) +// (New tokens can also be created with `package:jose` and `dart:convert`.) diff --git a/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart b/packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart index f809e1661d35..82701e587be1 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 @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/oauth2.dart'; @@ -18,6 +20,7 @@ void main() { group('gisResponsesToTokenData', () { testWidgets('null objects -> no problem', (_) async { final GoogleSignInTokenData tokens = gisResponsesToTokenData(null, null); + expect(tokens.accessToken, isNull); expect(tokens.idToken, isNull); expect(tokens.serverAuthCode, isNull); @@ -36,6 +39,7 @@ void main() { }); final GoogleSignInTokenData tokens = gisResponsesToTokenData(credential, token); + expect(tokens.accessToken, expectedAccessToken); expect(tokens.idToken, expectedIdToken); expect(tokens.serverAuthCode, isNull); @@ -44,13 +48,13 @@ void main() { group('gisResponsesToUserData', () { testWidgets('happy case', (_) async { - final GoogleSignInUserData data = gisResponsesToUserData(okCredential)!; + final GoogleSignInUserData data = gisResponsesToUserData(goodCredential)!; expect(data.displayName, 'Vincent Adultman'); expect(data.id, '123456'); expect(data.email, 'adultman@example.com'); expect(data.photoUrl, 'https://thispersondoesnotexist.com/image?x=.jpg'); - expect(data.idToken, okCredential.credential); + expect(data.idToken, goodJwtToken); }); testWidgets('null response -> null', (_) async { @@ -58,8 +62,7 @@ void main() { }); testWidgets('null response.credential -> null', (_) async { - final CredentialResponse response = nullCredential; - expect(gisResponsesToUserData(response), isNull); + expect(gisResponsesToUserData(nullCredential), isNull); }); testWidgets('invalid payload -> null', (_) async { @@ -70,4 +73,101 @@ void main() { expect(gisResponsesToUserData(response), isNull); }); }); + + group('getJwtTokenPayload', () { + testWidgets('happy case -> data', (_) async { + final Map? data = getJwtTokenPayload(goodJwtToken); + + expect(data, isNotNull); + expect(data, containsPair('name', 'Vincent Adultman')); + expect(data, containsPair('email', 'adultman@example.com')); + expect(data, containsPair('sub', '123456')); + expect( + data, + containsPair( + 'picture', + 'https://thispersondoesnotexist.com/image?x=.jpg', + )); + }); + + testWidgets('null Token -> null', (_) async { + final Map? data = getJwtTokenPayload(null); + + expect(data, isNull); + }); + + testWidgets('Token not matching the format -> null', (_) async { + final Map? data = getJwtTokenPayload('1234.4321'); + + expect(data, isNull); + }); + + testWidgets('Bad token that matches the format -> null', (_) async { + final Map? data = getJwtTokenPayload('1234.abcd.4321'); + + expect(data, isNull); + }); + }); + + group('decodeJwtPayload', () { + testWidgets('Good payload -> data', (_) async { + final Map? data = decodeJwtPayload(goodPayload); + + expect(data, isNotNull); + expect(data, containsPair('name', 'Vincent Adultman')); + expect(data, containsPair('email', 'adultman@example.com')); + expect(data, containsPair('sub', '123456')); + expect( + data, + containsPair( + 'picture', + 'https://thispersondoesnotexist.com/image?x=.jpg', + )); + }); + + testWidgets('Proper JSON payload -> data', (_) async { + final String payload = base64.encode(utf8.encode('{"properJson": true}')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNotNull); + expect(data, containsPair('properJson', true)); + }); + + testWidgets('Not-normalized base-64 payload -> data', (_) async { + // This is the payload generated by the "Proper JSON payload" test, but + // we remove the leading "=" symbols so it's length is not a multiple of 4 + // anymore! + final String payload = 'eyJwcm9wZXJKc29uIjogdHJ1ZX0='.replaceAll('=', ''); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNotNull); + expect(data, containsPair('properJson', true)); + }); + + testWidgets('Invalid JSON payload -> null', (_) async { + final String payload = base64.encode(utf8.encode('{properJson: false}')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + + testWidgets('Non JSON payload -> null', (_) async { + final String payload = base64.encode(utf8.encode('not-json')); + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + + testWidgets('Non base-64 payload -> null', (_) async { + const String payload = 'not-base-64-at-all'; + + final Map? data = decodeJwtPayload(payload); + + expect(data, isNull); + }); + }); } diff --git a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart index 0b4e3416f7b5..345244a110e6 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart @@ -2,10 +2,57 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:google_identity_services_web/id.dart'; import 'package:google_identity_services_web/oauth2.dart'; import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart'; -import 'package:jwt_decoder/jwt_decoder.dart' as jwt; + +/// A codec that can encode/decode JWT payloads. +/// +/// See https://www.rfc-editor.org/rfc/rfc7519#section-3 +final Codec jwtCodec = json.fuse(utf8).fuse(base64); + +/// A RegExp that can match, and extract parts from a JWT Token. +/// +/// A JWT token consists of 3 base-64 encoded parts of data separated by periods: +/// +/// header.payload.signature +/// +/// More info: https://regexr.com/789qc +final RegExp jwtTokenRegexp = RegExp( + r'^(?
[^\.\s]+)\.(?[^\.\s]+)\.(?[^\.\s]+)$'); + +/// Decodes the `claims` of a JWT token and returns them as a Map. +/// +/// JWT `claims` are stored as a JSON object in the `payload` part of the token. +/// +/// (This method does not validate the signature of the token.) +/// +/// See https://www.rfc-editor.org/rfc/rfc7519#section-3 +Map? getJwtTokenPayload(String? token) { + if (token != null) { + final RegExpMatch? match = jwtTokenRegexp.firstMatch(token); + if (match != null) { + return decodeJwtPayload(match.namedGroup('payload')); + } + } + + return null; +} + +/// Decodes a JWT payload using the [jwtCodec]. +Map? decodeJwtPayload(String? payload) { + try { + // Payload must be normalized before passing it to the codec + return (jwtCodec.decode(base64.normalize(payload!)) + as Map?) + ?.cast(); + } catch (_) { + // Do nothing, we always return null for any failure. + } + return null; +} /// Converts a [CredentialResponse] into a [GoogleSignInUserData]. /// @@ -18,7 +65,7 @@ GoogleSignInUserData? gisResponsesToUserData( } final Map? payload = - jwt.JwtDecoder.tryDecode(credentialResponse.credential!); + getJwtTokenPayload(credentialResponse.credential); if (payload == null) { return null; 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 af38042bfb31..40e8b0381e67 100644 --- a/packages/google_sign_in/google_sign_in_web/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in_web/pubspec.yaml @@ -26,7 +26,6 @@ dependencies: google_sign_in_platform_interface: ^2.2.0 http: ^0.13.5 js: ^0.6.3 - jwt_decoder: ^2.0.1 dev_dependencies: flutter_test: From 7d91c221eb52bb98715a1a76372cc4771f8dcce8 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Thu, 16 Feb 2023 21:39:27 +0000 Subject: [PATCH 31/31] Remove unneeded cast call. --- packages/google_sign_in/google_sign_in_web/lib/src/utils.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 345244a110e6..c4bb9d403d2d 100644 --- a/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart +++ b/packages/google_sign_in/google_sign_in_web/lib/src/utils.dart @@ -45,9 +45,7 @@ Map? getJwtTokenPayload(String? token) { Map? decodeJwtPayload(String? payload) { try { // Payload must be normalized before passing it to the codec - return (jwtCodec.decode(base64.normalize(payload!)) - as Map?) - ?.cast(); + return jwtCodec.decode(base64.normalize(payload!)) as Map?; } catch (_) { // Do nothing, we always return null for any failure. }