blob: b28796b8d68f969f9dd5989f9b03c1935decd08e [file] [log] [blame]
// 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:js_interop';
// 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';
import 'package:web/web.dart' as web;
import 'button_configuration.dart'
show GSIButtonConfiguration, convertButtonConfiguration;
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,
required StreamController<GoogleSignInUserData?> userDataController,
bool loggingEnabled = false,
String? hostedDomain,
}) : _initialScopes = initialScopes,
_loggingEnabled = loggingEnabled,
_userDataEventsController = userDataController {
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,
hostedDomain: hostedDomain,
useFedCM: true,
);
_tokenClient = _initializeTokenClient(
clientId,
hostedDomain: hostedDomain,
onResponse: _onTokenResponse,
onError: _onTokenError,
);
if (initialScopes.isNotEmpty) {
_codeClient = _initializeCodeClient(
clientId,
hostedDomain: hostedDomain,
onResponse: _onCodeResponse,
onError: _onCodeError,
scopes: initialScopes,
);
}
}
void _logIfEnabled(String message, [List<Object?>? more]) {
if (_loggingEnabled) {
final String log =
<Object?>['[google_sign_in_web]', message, ...?more].join(' ');
web.console.info(log.toJS);
}
}
// Configure the credential (authentication) and token (authorization) response streams.
void _configureStreams() {
_tokenResponses = StreamController<TokenResponse>.broadcast();
_credentialResponses = StreamController<CredentialResponse>.broadcast();
_codeResponses = StreamController<CodeResponse>.broadcast();
_tokenResponses.stream.listen((TokenResponse response) {
_lastTokenResponse = response;
_lastTokenResponseExpiration =
DateTime.now().add(Duration(seconds: response.expires_in!));
}, onError: (Object error) {
_logIfEnabled('Error on TokenResponse:', <Object>[error.toString()]);
_lastTokenResponse = null;
});
_codeResponses.stream.listen((CodeResponse response) {
_lastCodeResponse = response;
}, onError: (Object error) {
_logIfEnabled('Error on CodeResponse:', <Object>[error.toString()]);
_lastCodeResponse = null;
});
_credentialResponses.stream.listen((CredentialResponse response) {
_lastCredentialResponse = response;
}, onError: (Object error) {
_logIfEnabled('Error on CredentialResponse:', <Object>[error.toString()]);
_lastCredentialResponse = null;
});
// In the future, the userDataEvents could propagate null userDataEvents too.
_credentialResponses.stream
.map(utils.gisResponsesToUserData)
.handleError(_cleanCredentialResponsesStreamErrors)
.forEach(_userDataEventsController.add);
}
// This function handles the errors that on the _credentialResponses Stream.
//
// Most of the time, these errors are part of the flow (like when One Tap UX
// cannot be rendered), and the stream of userDataEvents doesn't care about
// them.
//
// (This has been separated to a function so the _configureStreams formatting
// looks a little bit better)
void _cleanCredentialResponsesStreamErrors(Object error) {
_logIfEnabled(
'Removing error from `userDataEvents`:',
<Object>[error.toString()],
);
}
// Initializes the `id` SDK for the silent-sign in (authentication) client.
void _initializeIdClient(
String clientId, {
required CallbackFn onResponse,
String? hostedDomain,
bool? useFedCM,
}) {
// Initialize `id` for the silent-sign in code.
final IdConfiguration idConfig = IdConfiguration(
client_id: clientId,
callback: onResponse,
cancel_on_tap_outside: false,
auto_select: true, // Attempt to sign-in silently.
hd: hostedDomain,
use_fedcm_for_prompt:
useFedCM, // Use the native browser prompt, when available.
);
id.initialize(idConfig);
}
// 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,
hd: hostedDomain,
callback: _onTokenResponse,
error_callback: _onTokenError,
// This is here only to satisfy the initialization of the JS TokenClient.
// In reality, `scope` is always overridden when calling `requestScopes`
// (or the deprecated `signIn`) through an [OverridableTokenClientConfig]
// object.
scope: <String>[' '], // Fake (but non-empty) list of scopes.
);
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) {
if (error != null) {
_tokenResponses.addError((error as GoogleIdentityServicesError).type);
}
}
// Creates a `oauth2.CodeClient` used for authorization (scope) requests.
CodeClient _initializeCodeClient(
String clientId, {
String? hostedDomain,
required List<String> scopes,
required CodeClientCallbackFn onResponse,
required ErrorCallbackFn onError,
}) {
// Create a Token Client for authorization calls.
final CodeClientConfig codeConfig = CodeClientConfig(
client_id: clientId,
hd: hostedDomain,
callback: _onCodeResponse,
error_callback: _onCodeError,
scope: scopes,
select_account: true,
ux_mode: UxMode.popup,
);
return oauth2.initCodeClient(codeConfig);
}
void _onCodeResponse(CodeResponse response) {
if (response.error != null) {
_codeResponses.addError(response.error!);
} else {
_codeResponses.add(response);
}
}
void _onCodeError(Object? error) {
if (error != null) {
_codeResponses.addError((error as GoogleIdentityServicesError).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((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);
}
}
/// Calls `id.renderButton` on [parent] with the given [options].
Future<void> renderButton(
Object parent,
GSIButtonConfiguration options,
) async {
return id.renderButton(parent, convertButtonConfiguration(options));
}
/// Requests a server auth code per:
/// https://developers.google.com/identity/oauth2/web/guides/use-code-model#initialize_a_code_client
Future<String?> requestServerAuthCode() async {
// TODO(dit): Enable granular authorization, https://github.com/flutter/flutter/issues/139406
assert(_codeClient != null,
'CodeClient not initialized correctly. Ensure the `scopes` list passed to `init()` or `initWithParams()` is not empty!');
if (_codeClient == null) {
return null;
}
_codeClient!.requestCode();
final CodeResponse response = await _codeResponses.stream.first;
return response.code;
}
// TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727
//
/// Starts an oauth2 "implicit" flow to authorize requests.
///
/// The new GIS SDK does not return user authentication from this flow, so:
/// * 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].
@Deprecated(
'Use `renderButton` instead. See: https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services')
Future<GoogleSignInUserData?> signIn() async {
// Warn users that this method will be removed.
web.console.warn(
'The google_sign_in plugin `signIn` method is deprecated on the web, and will be removed in Q2 2024. Please use `renderButton` instead. See: '
'https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services'
.toJS);
// 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' : '',
login_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,
],
));
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).
//
// TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727
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,
_lastCodeResponse,
);
}
/// Revokes the current authentication.
Future<void> signOut() async {
await clearAuthCache();
id.disableAutoSelect();
}
/// Revokes the current authorization and authentication.
Future<void> disconnect() async {
if (_lastTokenResponse != null) {
oauth2.revoke(_lastTokenResponse!.access_token!);
}
await signOut();
}
/// Returns true if the client has recognized this user before, and the last-seen
/// credential is not expired.
Future<bool> isSignedIn() async {
bool isSignedIn = false;
if (_lastCredentialResponse != null) {
final DateTime? expiration = utils
.getCredentialResponseExpirationTimestamp(_lastCredentialResponse);
// All Google ID Tokens provide an "exp" date. If the method above cannot
// extract `expiration`, it's because `_lastCredentialResponse`'s contents
// are unexpected (or wrong) in any way.
//
// Users are considered to be signedIn when the last CredentialResponse
// exists and has an expiration date in the future.
//
// Users are not signed in in any other case.
//
// See: https://developers.google.com/identity/openid-connect/openid-connect#an-id-tokens-payload
isSignedIn = expiration?.isAfter(DateTime.now()) ?? false;
}
return isSignedIn || _requestedUserData != null;
}
/// Clears all the cached results from authentication and authorization.
Future<void> clearAuthCache() async {
_lastCredentialResponse = null;
_lastTokenResponse = null;
_requestedUserData = null;
_lastCodeResponse = null;
}
/// Requests the list of [scopes] passed in to the client.
///
/// Keeps the previously granted scopes.
Future<bool> requestScopes(List<String> scopes) 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);
_tokenClient.requestAccessToken(OverridableTokenClientConfig(
prompt: knownUser == null ? 'select_account' : '',
login_hint: knownUser?.email,
scope: scopes,
include_granted_scopes: true,
));
await _tokenResponses.stream.first;
return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes);
}
/// Checks if the passed-in `accessToken` can access all `scopes`.
///
/// This validates that the `accessToken` is the same as the last seen
/// token response, that the token is not expired, then uses that response to
/// check if permissions are still granted.
Future<bool> canAccessScopes(List<String> scopes, String? accessToken) async {
if (accessToken != null && _lastTokenResponse != null) {
if (accessToken == _lastTokenResponse!.access_token) {
final bool isTokenValid =
_lastTokenResponseExpiration?.isAfter(DateTime.now()) ?? false;
return isTokenValid &&
oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes);
}
}
return false;
}
final bool _loggingEnabled;
// 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;
// CodeClient will not be created if `initialScopes` is empty.
CodeClient? _codeClient;
// Streams of credential and token responses.
late StreamController<CredentialResponse> _credentialResponses;
late StreamController<TokenResponse> _tokenResponses;
late StreamController<CodeResponse> _codeResponses;
// The last-seen credential and token responses
CredentialResponse? _lastCredentialResponse;
TokenResponse? _lastTokenResponse;
// Expiration timestamp for the lastTokenResponse, which only has an `expires_in` field.
DateTime? _lastTokenResponseExpiration;
CodeResponse? _lastCodeResponse;
/// The StreamController onto which the GIS Client propagates user authentication events.
///
/// This is provided by the implementation of the plugin.
final StreamController<GoogleSignInUserData?> _userDataEventsController;
// 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)
//
// TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727
GoogleSignInUserData? _requestedUserData;
}