Skip to content

Commit 3f48061

Browse files
authored
[gsi_web] Adds Sign In button. (flutter#3636)
[gsi_web] Adds Sign In button.
1 parent 6102af6 commit 3f48061

16 files changed

+1492
-89
lines changed

packages/google_sign_in/google_sign_in_web/CHANGELOG.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
## NEXT
2-
1+
## 0.12.0
2+
3+
* Authentication:
4+
* Adds web-only `renderButton` method and its configuration object, as a new
5+
authentication mechanism.
6+
* Prepares a `userDataEvents` Stream, so the Google Sign In Button can propagate
7+
authentication changes to the core plugin.
8+
* **Breaking Change:** `signInSilently` now returns an authenticated (but not authorized) user.
9+
* Authorization:
10+
* Implements the new `canAccessScopes` method.
11+
* Ensures that the `requestScopes` call doesn't trigger user selection when the
12+
current user is known (similar to what `signIn` does).
313
* Updates minimum Flutter version to 3.3.
414

515
## 0.11.0+2

packages/google_sign_in/google_sign_in_web/README.md

Lines changed: 69 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The web implementation of [google_sign_in](https://pub.dev/packages/google_sign_in)
44

5-
## Migrating to v0.11 (Google Identity Services)
5+
## Migrating to v0.11 and v0.12 (Google Identity Services)
66

77
The `google_sign_in_web` plugin is backed by the new Google Identity Services
88
(GIS) JS SDK since version 0.11.0.
@@ -27,15 +27,12 @@ quickly and easily sign users into your app suing their Google accounts.
2727
flows will not return authentication information.
2828
* The GIS SDK no longer has direct access to previously-seen users upon initialization.
2929
* `signInSilently` now displays the One Tap UX for web.
30-
* The GIS SDK only provides an `idToken` (JWT-encoded info) when the user
31-
successfully completes an authentication flow. In the plugin: `signInSilently`.
32-
* The plugin `signIn` method uses the Oauth "Implicit Flow" to Authorize the requested `scopes`.
33-
* If the user hasn't `signInSilently`, they'll have to sign in as a first step
34-
of the Authorization popup flow.
35-
* If `signInSilently` was unsuccessful, the plugin will add extra `scopes` to
36-
`signIn` and retrieve basic Profile information from the People API via a
37-
REST call immediately after a successful authorization. In this case, the
38-
`idToken` field of the `GoogleSignInUserData` will always be null.
30+
* **Since 0.12** The plugin provides an `idToken` (JWT-encoded info) when the
31+
user successfully completes an authentication flow:
32+
* In the plugin: `signInSilently` and through the web-only `renderButton` widget.
33+
* The plugin `signIn` method uses the OAuth "Implicit Flow" to Authorize the requested `scopes`.
34+
* This method only provides an `accessToken`, and not an `idToken`, so if your
35+
app needs an `idToken`, this method **should be avoided on the web**.
3936
* The GIS SDK no longer handles sign-in state and user sessions, it only provides
4037
Authentication credentials for the moment the user did authenticate.
4138
* The GIS SDK no longer is able to renew Authorization sessions on the web.
@@ -49,48 +46,74 @@ See more differences in the following migration guides:
4946

5047
### New use cases to take into account in your app
5148

52-
#### Enable access to the People API for your GCP project
49+
#### Authentication != Authorization
50+
51+
In the GIS SDK, the concepts of Authentication and Authorization have been separated.
52+
53+
It is possible now to have an Authenticated user that hasn't Authorized any `scopes`.
54+
55+
Flutter apps that need to run in the web must now handle the fact that an Authenticated
56+
user may not have permissions to access the `scopes` it requires to function.
57+
58+
The Google Sign In plugin has a new `canAccessScopes` method that can be used to
59+
check if a user is Authorized or not.
60+
61+
It is also possible that Authorizations expire while users are using an app
62+
(after 3600 seconds), so apps should monitor response failures from the APIs, and
63+
prompt users (interactively) to grant permissions again.
64+
65+
Check the "Integration considerations > [UX separation for authentication and authorization](https://developers.google.com/identity/gsi/web/guides/integrate#ux_separation_for_authentication_and_authorization)
66+
guide" in the official GIS SDK documentation for more information about this.
5367

54-
Since the GIS SDK is separating Authentication from Authorization, the
55-
[Oauth Implicit pop-up flow](https://developers.google.com/identity/oauth2/web/guides/use-token-model)
56-
used to Authorize scopes does **not** return any Authentication information
57-
anymore (user credential / `idToken`).
68+
_(See also the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example)
69+
for a simple implementation of this (look at the `isAuthorized` variable).)_
5870

59-
If the plugin is not able to Authenticate an user from `signInSilently` (the
60-
OneTap UX flow), it'll add extra `scopes` to those requested by the programmer
61-
so it can perform a [People API request](https://developers.google.com/people/api/rest/v1/people/get)
62-
to retrieve basic profile information about the user that is signed-in.
71+
#### Is this separation *always required*?
6372

64-
The information retrieved from the People API is used to complete data for the
65-
[`GoogleSignInAccount`](https://pub.dev/documentation/google_sign_in/latest/google_sign_in/GoogleSignInAccount-class.html)
66-
object that is returned after `signIn` completes successfully.
73+
Only if the scopes required by an app are different from the
74+
[OpenID Connect scopes](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect).
6775

68-
#### `signInSilently` always returns `null`
76+
If an app only needs an `idToken`, or the OpenID Connect scopes, the Authentication
77+
bits of the plugin should be enough for your app (`signInSilently` and `renderButton`).
6978

70-
Previous versions of this plugin were able to return a `GoogleSignInAccount`
71-
object that was fully populated (signed-in and authorized) from `signInSilently`
72-
because the former SDK equated "is authenticated" and "is authorized".
79+
### What happened to the `signIn` method on the web?
7380

74-
With the GIS SDK, `signInSilently` only deals with user Authentication, so users
75-
retrieved "silently" will only contain an `idToken`, but not an `accessToken`.
81+
Because the GIS SDK for web no longer provides users with the ability to create
82+
their own Sign-In buttons, or an API to start the sign in flow, the current
83+
implementation of `signIn` (that does authorization and authentication) is no
84+
longer feasible on the web.
7685

77-
Only after `signIn` or `requestScopes`, a user will be fully formed.
86+
The web plugin attempts to simulate the old `signIn` behavior by using the
87+
[OAuth Implicit pop-up flow](https://developers.google.com/identity/oauth2/web/guides/use-token-model),
88+
which authenticates and authorizes users.
7889

79-
The GIS-backed plugin always returns `null` from `signInSilently`, to force apps
80-
that expect the former logic to perform a full `signIn`, which will result in a
81-
fully Authenticated and Authorized user, and making this migration easier.
90+
The drawback of this approach is that the OAuth flow **only returns an `accessToken`**,
91+
and a synthetic version of the User Data, that does **not include an `idToken`**.
8292

83-
#### `idToken` is `null` in the `GoogleSignInAccount` object after `signIn`
93+
The solution to this is to **migrate your custom "Sign In" buttons in the web to
94+
the Button Widget provided by this package: `Widget renderButton()`.**
8495

85-
Since the GIS SDK is separating Authentication and Authorization, when a user
86-
fails to Authenticate through `signInSilently` and the plugin performs the
87-
fallback request to the People API described above,
88-
the returned `GoogleSignInUserData` object will contain basic profile information
89-
(name, email, photo, ID), but its `idToken` will be `null`.
96+
_(Check the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example)
97+
for an example on how to mix the `renderButton` widget on the web, with a custom
98+
button for the mobile.)_
9099

91-
This is because JWT are cryptographically signed by Google Identity Services, and
92-
this plugin won't spoof that signature when it retrieves the information from a
93-
simple REST request.
100+
#### Enable access to the People API for your GCP project
101+
102+
If you want to use the `signIn` method on the web, the plugin will do an additional
103+
request to the PeopleAPI to retrieve the logged-in user information (minus the `idToken`).
104+
105+
For this to work, you must enable access to the People API on your Client ID in
106+
the GCP console.
107+
108+
This is **not recommended**. Ideally, your web application should use a mix of
109+
`signInSilently` and the Google Sign In web `renderButton` to authenticate your
110+
users, and then `canAccessScopes` and `requestScopes` to authorize the `scopes`
111+
that are needed.
112+
113+
#### Why is the `idToken` missing after `signIn`?
114+
115+
The `idToken` is cryptographically signed by Google Identity Services, and
116+
this plugin can't spoof that signature.
94117

95118
#### User Sessions
96119

@@ -113,8 +136,8 @@ codes different to `200`. For example:
113136
* `401`: Missing or invalid access token.
114137
* `403`: Expired access token.
115138

116-
In either case, your app needs to prompt the end user to `signIn` or
117-
`requestScopes`, to interactively renew the token.
139+
In either case, your app needs to prompt the end user to `requestScopes`, to
140+
**interactively** renew the token.
118141

119142
The GIS SDK limits authorization token duration to one hour (3600 seconds).
120143

@@ -130,6 +153,9 @@ so you do not need to add it to your `pubspec.yaml`.
130153
However, if you `import` this package to use any of its APIs directly, you
131154
should add it to your `pubspec.yaml` as usual.
132155

156+
For example, you need to import this package directly if you plan to use the
157+
web-only `Widget renderButton()` method.
158+
133159
### Web integration
134160

135161
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.

packages/google_sign_in/google_sign_in_web/example/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,14 @@ in the Flutter wiki for instructions to setup and run the tests in this package.
1717

1818
Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests)
1919
for more info.
20+
21+
# button_tester.dart
22+
23+
The button_tester.dart file contains an example app to test the different configuration
24+
values of the Google Sign In Button Widget.
25+
26+
To run that example:
27+
28+
```console
29+
$ flutter run -d chrome --target=lib/button_tester.dart
30+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:ui' as ui;
7+
8+
import 'package:flutter/material.dart';
9+
import 'package:flutter_test/flutter_test.dart';
10+
import 'package:google_sign_in_web/src/flexible_size_html_element_view.dart';
11+
import 'package:integration_test/integration_test.dart';
12+
13+
import 'src/dom.dart';
14+
15+
/// Used to keep track of the number of HtmlElementView factories the test has registered.
16+
int widgetFactoryNumber = 0;
17+
18+
void main() {
19+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
20+
21+
group('FlexHtmlElementView', () {
22+
tearDown(() {
23+
widgetFactoryNumber++;
24+
});
25+
26+
testWidgets('empty case, calls onPlatformViewCreated',
27+
(WidgetTester tester) async {
28+
final Completer<int> viewCreatedCompleter = Completer<int>();
29+
30+
await pumpResizableWidget(tester, onPlatformViewCreated: (int id) {
31+
viewCreatedCompleter.complete(id);
32+
});
33+
await tester.pumpAndSettle();
34+
35+
await expectLater(viewCreatedCompleter.future, completes);
36+
});
37+
38+
testWidgets('empty case, renders with initial size',
39+
(WidgetTester tester) async {
40+
const Size initialSize = Size(160, 100);
41+
42+
final Element element = await pumpResizableWidget(
43+
tester,
44+
initialSize: initialSize,
45+
);
46+
await tester.pumpAndSettle();
47+
48+
// Expect that the element matches the initialSize.
49+
expect(element.size!.width, initialSize.width);
50+
expect(element.size!.height, initialSize.height);
51+
});
52+
53+
testWidgets('initialSize null, adopts size of injected element',
54+
(WidgetTester tester) async {
55+
const Size childSize = Size(300, 40);
56+
57+
final DomHtmlElement resizable = document.createElement('div');
58+
resize(resizable, childSize);
59+
60+
final Element element = await pumpResizableWidget(
61+
tester,
62+
onPlatformViewCreated: injectElement(resizable),
63+
);
64+
await tester.pumpAndSettle();
65+
66+
// Expect that the element matches the initialSize.
67+
expect(element.size!.width, childSize.width);
68+
expect(element.size!.height, childSize.height);
69+
});
70+
71+
testWidgets('with initialSize, adopts size of injected element',
72+
(WidgetTester tester) async {
73+
const Size initialSize = Size(160, 100);
74+
const Size newSize = Size(300, 40);
75+
76+
final DomHtmlElement resizable = document.createElement('div');
77+
resize(resizable, newSize);
78+
79+
final Element element = await pumpResizableWidget(
80+
tester,
81+
initialSize: initialSize,
82+
onPlatformViewCreated: injectElement(resizable),
83+
);
84+
await tester.pumpAndSettle();
85+
86+
// Expect that the element matches the initialSize.
87+
expect(element.size!.width, newSize.width);
88+
expect(element.size!.height, newSize.height);
89+
});
90+
91+
testWidgets('with injected element that resizes, follows resizes',
92+
(WidgetTester tester) async {
93+
const Size initialSize = Size(160, 100);
94+
final Size expandedSize = initialSize * 2;
95+
final Size contractedSize = initialSize / 2;
96+
97+
final DomHtmlElement resizable = document.createElement('div')
98+
..setAttribute(
99+
'style', 'width: 100%; height: 100%; background: #fabada;');
100+
101+
final Element element = await pumpResizableWidget(
102+
tester,
103+
initialSize: initialSize,
104+
onPlatformViewCreated: injectElement(resizable),
105+
);
106+
await tester.pumpAndSettle();
107+
108+
// Expect that the element matches the initialSize, because the
109+
// resizable is defined as width:100%, height:100%.
110+
expect(element.size!.width, initialSize.width);
111+
expect(element.size!.height, initialSize.height);
112+
113+
// Expands
114+
resize(resizable, expandedSize);
115+
116+
await tester.pumpAndSettle();
117+
118+
expect(element.size!.width, expandedSize.width);
119+
expect(element.size!.height, expandedSize.height);
120+
121+
// Contracts
122+
resize(resizable, contractedSize);
123+
124+
await tester.pumpAndSettle();
125+
126+
expect(element.size!.width, contractedSize.width);
127+
expect(element.size!.height, contractedSize.height);
128+
});
129+
});
130+
}
131+
132+
/// Injects a ResizableFromJs widget into the `tester`.
133+
Future<Element> pumpResizableWidget(
134+
WidgetTester tester, {
135+
void Function(int)? onPlatformViewCreated,
136+
Size? initialSize,
137+
}) async {
138+
await tester.pumpWidget(ResizableFromJs(
139+
instanceId: widgetFactoryNumber,
140+
onPlatformViewCreated: onPlatformViewCreated,
141+
initialSize: initialSize,
142+
));
143+
// Needed for JS to have time to kick-off.
144+
await tester.pump();
145+
146+
// Return the element we just pumped
147+
final Iterable<Element> elements =
148+
find.byKey(Key('resizable_from_js_$widgetFactoryNumber')).evaluate();
149+
expect(elements, hasLength(1));
150+
return elements.first;
151+
}
152+
153+
class ResizableFromJs extends StatelessWidget {
154+
ResizableFromJs({
155+
required this.instanceId,
156+
this.onPlatformViewCreated,
157+
this.initialSize,
158+
super.key,
159+
}) {
160+
// ignore: avoid_dynamic_calls, undefined_prefixed_name
161+
ui.platformViewRegistry.registerViewFactory(
162+
'resizable_from_js_$instanceId',
163+
(int viewId) {
164+
final DomHtmlElement element = document.createElement('div');
165+
element.setAttribute('style',
166+
'width: 100%; height: 100%; overflow: hidden; background: red;');
167+
element.id = 'test_element_$viewId';
168+
return element;
169+
},
170+
);
171+
}
172+
173+
final int instanceId;
174+
final void Function(int)? onPlatformViewCreated;
175+
final Size? initialSize;
176+
177+
@override
178+
Widget build(BuildContext context) {
179+
return MaterialApp(
180+
home: Scaffold(
181+
body: Center(
182+
child: FlexHtmlElementView(
183+
viewType: 'resizable_from_js_$instanceId',
184+
key: Key('resizable_from_js_$instanceId'),
185+
onPlatformViewCreated: onPlatformViewCreated,
186+
initialSize: initialSize ?? const Size(640, 480),
187+
),
188+
),
189+
),
190+
);
191+
}
192+
}
193+
194+
/// Resizes `resizable` to `size`.
195+
void resize(DomHtmlElement resizable, Size size) {
196+
resizable.setAttribute('style',
197+
'width: ${size.width}px; height: ${size.height}px; background: #fabada');
198+
}
199+
200+
/// Returns a function that can be used to inject `element` in `onPlatformViewCreated` callbacks.
201+
void Function(int) injectElement(DomHtmlElement element) {
202+
return (int viewId) {
203+
final DomHtmlElement root =
204+
document.querySelector('#test_element_$viewId')!;
205+
root.appendChild(element);
206+
};
207+
}

0 commit comments

Comments
 (0)