Skip to content

Commit 33c2b4e

Browse files
authored
[google_sign_in] Enable FedCM for web. Use token expiration. (flutter#5225)
* Enables [**FedCM API**](https://developer.mozilla.org/en-US/docs/Web/API/FedCM_API) on compatible browsers. * The GIS JS SDK falls-back to the JS implementation on browsers that don't support the new standard. See [migration instructions](https://developers.google.com/identity/gsi/web/guides/fedcm-migration)). * Uses the supplied token expiration information to **more accurately compute `isSignedIn()` and `canAccessScopes(scopes)`**. * This does not handle the case where users sign in/out in another tab or from outside the web app, that's still something that needs to be checked server-side. * **Deprecates the `signIn()` method on the web.** * Users should migrate to a combination of `renderButton()` and `silentSignIn()`, as described [here](https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services). ### Issues * FedCM: * Fixes flutter#133703 (once rebuilt/redeployed) * Fixes `b/301259123` * Token expiration: * Unblocks `b/245740319` ### Testing * Added a few unit tests * Manually verified token expiration: https://dit-gis-test.web.app
1 parent 9f0e92f commit 33c2b4e

File tree

6 files changed

+139
-17
lines changed

6 files changed

+139
-17
lines changed

packages/google_sign_in/google_sign_in_web/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 0.12.1
2+
3+
* Enables FedCM on browsers that support this authentication mechanism.
4+
* Uses the expiration timestamps of Credential and Token responses to improve
5+
the accuracy of `isSignedIn` and `canAccessScopes` methods.
6+
* Deprecates `signIn()` method.
7+
* Users should migrate to `renderButton` and `silentSignIn`, as described in
8+
the README.
9+
110
## 0.12.0+5
211

312
* Migrates to `dart:ui_web` APIs.

packages/google_sign_in/google_sign_in_web/example/integration_test/src/jwt_examples.dart

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ final CredentialResponse minimalCredential =
2424
'credential': minimalJwtToken,
2525
});
2626

27+
final CredentialResponse expiredCredential =
28+
jsifyAs<CredentialResponse>(<String, Object?>{
29+
'credential': expiredJwtToken,
30+
});
31+
2732
/// A JWT token with predefined values.
2833
///
2934
/// 'email': '[email protected]',
@@ -55,11 +60,30 @@ const String minimalJwtToken =
5560

5661
/// The payload of a JWT token that contains only non-nullable values.
5762
///
58-
/// "email": "[email protected]",
59-
/// "sub": "123456"
63+
/// 'email': '[email protected]',
64+
/// 'sub': '123456'
6065
const String minimalPayload =
6166
'eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2In0';
6267

68+
/// A JWT token with minimal set of predefined values and an expiration timestamp.
69+
///
70+
/// 'email': '[email protected]',
71+
/// 'sub': '123456',
72+
/// 'exp': 1430330400
73+
///
74+
/// Signed with HS256 and the private key: 'symmetric-encryption-is-weak'
75+
const String expiredJwtToken =
76+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$expiredPayload.--gb5tnVSSsLg4zjjVH0FUUvT4rbehIcnBhB-8Iekm4';
77+
78+
/// The payload of a JWT token that contains only non-nullable values, and an
79+
/// expiration timestamp of 1430330400 (Wednesday, April 29, 2015 6:00:00 PM UTC)
80+
///
81+
/// 'email': '[email protected]',
82+
/// 'sub': '123456',
83+
/// 'exp': 1430330400
84+
const String expiredPayload =
85+
'eyJlbWFpbCI6ImFkdWx0bWFuQGV4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwiZXhwIjoxNDMwMzMwNDAwfQ';
86+
6387
// More encrypted JWT Tokens may be created on https://jwt.io.
6488
//
6589
// First, decode the `goodJwtToken` above, modify to your heart's

packages/google_sign_in/google_sign_in_web/example/integration_test/utils_test.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,30 @@ void main() {
8585
});
8686
});
8787

88+
group('getCredentialResponseExpirationTimestamp', () {
89+
testWidgets('Good payload -> data', (_) async {
90+
final DateTime? expiration =
91+
getCredentialResponseExpirationTimestamp(expiredCredential);
92+
93+
expect(expiration, isNotNull);
94+
expect(expiration!.millisecondsSinceEpoch, 1430330400 * 1000);
95+
});
96+
97+
testWidgets('No expiration -> null', (_) async {
98+
expect(
99+
getCredentialResponseExpirationTimestamp(minimalCredential), isNull);
100+
});
101+
102+
testWidgets('Bad data -> null', (_) async {
103+
final CredentialResponse bogus =
104+
jsifyAs<CredentialResponse>(<String, Object?>{
105+
'credential': 'some-bogus.thing-that-is-not.valid-jwt',
106+
});
107+
108+
expect(getCredentialResponseExpirationTimestamp(bogus), isNull);
109+
});
110+
});
111+
88112
group('getJwtTokenPayload', () {
89113
testWidgets('happy case -> data', (_) async {
90114
final Map<String, Object?>? data = getJwtTokenPayload(goodJwtToken);

packages/google_sign_in/google_sign_in_web/lib/src/gis_client.dart

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class GisSdkClient {
4141
_initializeIdClient(
4242
clientId,
4343
onResponse: _onCredentialResponse,
44+
hostedDomain: hostedDomain,
45+
useFedCM: true,
4446
);
4547

4648
_tokenClient = _initializeTokenClient(
@@ -64,6 +66,8 @@ class GisSdkClient {
6466

6567
_tokenResponses.stream.listen((TokenResponse response) {
6668
_lastTokenResponse = response;
69+
_lastTokenResponseExpiration =
70+
DateTime.now().add(Duration(seconds: response.expires_in));
6771
}, onError: (Object error) {
6872
_logIfEnabled('Error on TokenResponse:', <Object>[error.toString()]);
6973
_lastTokenResponse = null;
@@ -102,13 +106,18 @@ class GisSdkClient {
102106
void _initializeIdClient(
103107
String clientId, {
104108
required CallbackFn onResponse,
109+
String? hostedDomain,
110+
bool? useFedCM,
105111
}) {
106112
// Initialize `id` for the silent-sign in code.
107113
final IdConfiguration idConfig = IdConfiguration(
108114
client_id: clientId,
109115
callback: allowInterop(onResponse),
110116
cancel_on_tap_outside: false,
111117
auto_select: true, // Attempt to sign-in silently.
118+
hd: hostedDomain,
119+
use_fedcm_for_prompt:
120+
useFedCM, // Use the native browser prompt, when available.
112121
);
113122
id.initialize(idConfig);
114123
}
@@ -230,6 +239,8 @@ class GisSdkClient {
230239
return id.renderButton(parent, convertButtonConfiguration(options)!);
231240
}
232241

242+
// TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727
243+
//
233244
/// Starts an oauth2 "implicit" flow to authorize requests.
234245
///
235246
/// The new GIS SDK does not return user authentication from this flow, so:
@@ -238,7 +249,15 @@ class GisSdkClient {
238249
/// * If [_lastCredentialResponse] is null, we add [people.scopes] to the
239250
/// [_initialScopes], so we can retrieve User Profile information back
240251
/// from the People API (without idToken). See [people.requestUserData].
252+
@Deprecated(
253+
'Use `renderButton` instead. See: https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services')
241254
Future<GoogleSignInUserData?> signIn() async {
255+
// Warn users that this method will be removed.
256+
domConsole.warn(
257+
'The google_sign_in plugin `signIn` method is deprecated on the web, and will be removed in Q2 2024. Please use `renderButton` instead. See: ',
258+
<String>[
259+
'https://pub.dev/packages/google_sign_in_web#migrating-to-v011-and-v012-google-identity-services'
260+
]);
242261
// If we already know the user, use their `email` as a `hint`, so they don't
243262
// have to pick their user again in the Authorization popup.
244263
final GoogleSignInUserData? knownUser =
@@ -265,6 +284,8 @@ class GisSdkClient {
265284
// This function returns the currently signed-in [GoogleSignInUserData].
266285
//
267286
// It'll do a request to the People API (if needed).
287+
//
288+
// TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727
268289
Future<GoogleSignInUserData?> _computeUserDataForLastToken() async {
269290
// If the user hasn't authenticated, request their basic profile info
270291
// from the People API.
@@ -302,9 +323,27 @@ class GisSdkClient {
302323
await signOut();
303324
}
304325

305-
/// Returns true if the client has recognized this user before.
326+
/// Returns true if the client has recognized this user before, and the last-seen
327+
/// credential is not expired.
306328
Future<bool> isSignedIn() async {
307-
return _lastCredentialResponse != null || _requestedUserData != null;
329+
bool isSignedIn = false;
330+
if (_lastCredentialResponse != null) {
331+
final DateTime? expiration = utils
332+
.getCredentialResponseExpirationTimestamp(_lastCredentialResponse);
333+
// All Google ID Tokens provide an "exp" date. If the method above cannot
334+
// extract `expiration`, it's because `_lastCredentialResponse`'s contents
335+
// are unexpected (or wrong) in any way.
336+
//
337+
// Users are considered to be signedIn when the last CredentialResponse
338+
// exists and has an expiration date in the future.
339+
//
340+
// Users are not signed in in any other case.
341+
//
342+
// See: https://developers.google.com/identity/openid-connect/openid-connect#an-id-tokens-payload
343+
isSignedIn = expiration?.isAfter(DateTime.now()) ?? false;
344+
}
345+
346+
return isSignedIn || _requestedUserData != null;
308347
}
309348

310349
/// Clears all the cached results from authentication and authorization.
@@ -338,12 +377,15 @@ class GisSdkClient {
338377
/// Checks if the passed-in `accessToken` can access all `scopes`.
339378
///
340379
/// This validates that the `accessToken` is the same as the last seen
341-
/// token response, and uses that response to check if permissions are
342-
/// still granted.
380+
/// token response, that the token is not expired, then uses that response to
381+
/// check if permissions are still granted.
343382
Future<bool> canAccessScopes(List<String> scopes, String? accessToken) async {
344383
if (accessToken != null && _lastTokenResponse != null) {
345384
if (accessToken == _lastTokenResponse!.access_token) {
346-
return oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes);
385+
final bool isTokenValid =
386+
_lastTokenResponseExpiration?.isAfter(DateTime.now()) ?? false;
387+
return isTokenValid &&
388+
oauth2.hasGrantedAllScopes(_lastTokenResponse!, scopes);
347389
}
348390
}
349391
return false;
@@ -368,6 +410,8 @@ class GisSdkClient {
368410
// The last-seen credential and token responses
369411
CredentialResponse? _lastCredentialResponse;
370412
TokenResponse? _lastTokenResponse;
413+
// Expiration timestamp for the lastTokenResponse, which only has an `expires_in` field.
414+
DateTime? _lastTokenResponseExpiration;
371415

372416
/// The StreamController onto which the GIS Client propagates user authentication events.
373417
///
@@ -379,5 +423,7 @@ class GisSdkClient {
379423
// (if needed)
380424
//
381425
// (This is a synthetic _lastCredentialResponse)
426+
//
427+
// TODO(dit): Clean this up. https://github.com/flutter/flutter/issues/137727
382428
GoogleSignInUserData? _requestedUserData;
383429
}

packages/google_sign_in/google_sign_in_web/lib/src/utils.dart

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,32 +52,51 @@ Map<String, Object?>? decodeJwtPayload(String? payload) {
5252
return null;
5353
}
5454

55+
/// Returns the payload of a [CredentialResponse].
56+
Map<String, Object?>? getResponsePayload(CredentialResponse? response) {
57+
if (response?.credential == null) {
58+
return null;
59+
}
60+
61+
return getJwtTokenPayload(response!.credential);
62+
}
63+
5564
/// Converts a [CredentialResponse] into a [GoogleSignInUserData].
5665
///
5766
/// May return `null`, if the `credentialResponse` is null, or its `credential`
5867
/// cannot be decoded.
5968
GoogleSignInUserData? gisResponsesToUserData(
6069
CredentialResponse? credentialResponse) {
61-
if (credentialResponse == null || credentialResponse.credential == null) {
62-
return null;
63-
}
64-
65-
final Map<String, Object?>? payload =
66-
getJwtTokenPayload(credentialResponse.credential);
67-
70+
final Map<String, Object?>? payload = getResponsePayload(credentialResponse);
6871
if (payload == null) {
6972
return null;
7073
}
7174

75+
assert(credentialResponse?.credential != null,
76+
'The CredentialResponse cannot be null and have a payload.');
77+
7278
return GoogleSignInUserData(
7379
email: payload['email']! as String,
7480
id: payload['sub']! as String,
7581
displayName: payload['name'] as String?,
7682
photoUrl: payload['picture'] as String?,
77-
idToken: credentialResponse.credential,
83+
idToken: credentialResponse!.credential,
7884
);
7985
}
8086

87+
/// Returns the expiration timestamp ('exp') of a [CredentialResponse].
88+
///
89+
/// May return `null` if the `credentialResponse` is null, its `credential`
90+
/// cannot be decoded, or the `exp` field is not set on the JWT payload.
91+
DateTime? getCredentialResponseExpirationTimestamp(
92+
CredentialResponse? credentialResponse) {
93+
final Map<String, Object?>? payload = getResponsePayload(credentialResponse);
94+
// Get the 'exp' field from the payload, if present.
95+
final int? exp = (payload != null) ? payload['exp'] as int? : null;
96+
// Return 'exp' (a timestamp in seconds since Epoch) as a DateTime.
97+
return (exp != null) ? DateTime.fromMillisecondsSinceEpoch(exp * 1000) : null;
98+
}
99+
81100
/// Converts responses from the GIS library into TokenData for the plugin.
82101
GoogleSignInTokenData gisResponsesToTokenData(
83102
CredentialResponse? credentialResponse, TokenResponse? tokenResponse) {

packages/google_sign_in/google_sign_in_web/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ description: Flutter plugin for Google Sign-In, a secure authentication system
33
for signing in with a Google account on Android, iOS and Web.
44
repository: https://github.com/flutter/packages/tree/main/packages/google_sign_in/google_sign_in_web
55
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_sign_in%22
6-
version: 0.12.0+5
6+
version: 0.12.1
77

88
environment:
99
sdk: ">=3.1.0 <4.0.0"
@@ -22,7 +22,7 @@ dependencies:
2222
sdk: flutter
2323
flutter_web_plugins:
2424
sdk: flutter
25-
google_identity_services_web: ^0.2.1
25+
google_identity_services_web: ^0.2.2
2626
google_sign_in_platform_interface: ^2.4.0
2727
http: ">=0.13.0 <2.0.0"
2828
js: ^0.6.3

0 commit comments

Comments
 (0)