| // 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'; |
| |
| // 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'; |
| |
| 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<String> 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<TokenResponse>.broadcast(); |
| _credentialResponses = StreamController<CredentialResponse>.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<GoogleSignInUserData?> signInSilently() async { |
| final Completer<GoogleSignInUserData?> userDataCompleter = |
| Completer<GoogleSignInUserData?>(); |
| |
| // 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<void> _onPromptMoment( |
| PromptMomentNotification moment, |
| Completer<GoogleSignInUserData?> 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<void>.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<GoogleSignInUserData?> 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: <String>[ |
| ..._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<GoogleSignInUserData?> _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!); |
| } |
| // 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<void> signOut() async { |
| clearAuthCache(); |
| id.disableAutoSelect(); |
| } |
| |
| /// Revokes the current authorization and authentication. |
| Future<void> disconnect() async { |
| if (_lastTokenResponse != null) { |
| oauth2.revoke(_lastTokenResponse!.access_token); |
| } |
| signOut(); |
| } |
| |
| /// Returns true if the client has recognized this user before. |
| Future<bool> isSignedIn() async { |
| return _lastCredentialResponse != null || _requestedUserData != null; |
| } |
| |
| /// Clears all the cached results from authentication and authorization. |
| Future<void> clearAuthCache() async { |
| _lastCredentialResponse = null; |
| _lastTokenResponse = null; |
| _requestedUserData = null; |
| } |
| |
| /// Requests the list of [scopes] passed in to the client. |
| /// |
| /// Keeps the previously granted scopes. |
| Future<bool> requestScopes(List<String> 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<String> _initialScopes; |
| |
| // The Google Identity Services client for oauth requests. |
| late TokenClient _tokenClient; |
| |
| // Streams of credential and token responses. |
| late StreamController<CredentialResponse> _credentialResponses; |
| late StreamController<TokenResponse> _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; |
| } |