Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/google_sign_in/google_sign_in_web/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
## NEXT

## 0.12.0

* Authentication:
* Adds web-only `renderButton` method and its configuration object, as a new
authentication mechanism.
* Prepares a `userDataEvents` Stream, so the Google Sign In Button can propagate
authentication changes to the core plugin.
* **Breaking Change:** `signInSilently` now returns an authenticated (but not authorized) user.
* Authorization:
* Implements the new `canAccessScopes` method.
* Ensures that the `requestScopes` call doesn't trigger user selection when the
current user is known (similar to what `signIn` does).
* Updates minimum Flutter version to 3.3.

## 0.11.0+2
Expand Down
112 changes: 69 additions & 43 deletions packages/google_sign_in/google_sign_in_web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

## Migrating to v0.11 (Google Identity Services)
## Migrating to v0.11 and v0.12 (Google Identity Services)

The `google_sign_in_web` plugin is backed by the new Google Identity Services
(GIS) JS SDK since version 0.11.0.
Expand All @@ -27,15 +27,12 @@ quickly and easily sign users into your app suing their Google accounts.
flows will not return authentication information.
* The GIS SDK no longer has direct access to previously-seen users upon initialization.
* `signInSilently` now displays the One Tap UX for web.
* 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.
* **Since 0.12** The plugin provides an `idToken` (JWT-encoded info) when the
user successfully completes an authentication flow:
* In the plugin: `signInSilently` and through the web-only `renderButton` widget.
* The plugin `signIn` method uses the OAuth "Implicit Flow" to Authorize the requested `scopes`.
* This method only provides an `accessToken`, and not an `idToken`, so if your
app needs an `idToken`, this method **should be avoided on the web**.
* 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 GIS SDK no longer is able to renew Authorization sessions on the web.
Expand All @@ -49,48 +46,74 @@ 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
#### Authentication != Authorization

In the GIS SDK, the concepts of Authentication and Authorization have been separated.

It is possible now to have an Authenticated user that hasn't Authorized any `scopes`.

Flutter apps that need to run in the web must now handle the fact that an Authenticated
user may not have permissions to access the `scopes` it requires to function.

The Google Sign In plugin has a new `canAccessScopes` method that can be used to
check if a user is Authorized or not.

It is also possible that Authorizations expire while users are using an app
(after 3600 seconds), so apps should monitor response failures from the APIs, and
prompt users (interactively) to grant permissions again.

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)
guide" in the official GIS SDK documentation for more information about this.

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`).
_(See also the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example)
for a simple implementation of this (look at the `isAuthorized` variable).)_

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.
#### Is this separation *always required*?

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.
Only if the scopes required by an app are different from the
[OpenID Connect scopes](https://developers.google.com/identity/protocols/oauth2/scopes#openid-connect).

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

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".
### What happened to the `signIn` method on the web?

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

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

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.
The drawback of this approach is that the OAuth flow **only returns an `accessToken`**,
and a synthetic version of the User Data, that does **not include an `idToken`**.

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

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`.
_(Check the [package:google_sign_in example app](https://pub.dev/packages/google_sign_in/example)
for an example on how to mix the `renderButton` widget on the web, with a custom
button for the mobile.)_

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.
#### Enable access to the People API for your GCP project

If you want to use the `signIn` method on the web, the plugin will do an additional
request to the PeopleAPI to retrieve the logged-in user information (minus the `idToken`).

For this to work, you must enable access to the People API on your Client ID in
the GCP console.

This is **not recommended**. Ideally, your web application should use a mix of
`signInSilently` and the Google Sign In web `renderButton` to authenticate your
users, and then `canAccessScopes` and `requestScopes` to authorize the `scopes`
that are needed.

#### Why is the `idToken` missing after `signIn`?

The `idToken` is cryptographically signed by Google Identity Services, and
this plugin can't spoof that signature.

#### User Sessions

Expand All @@ -113,8 +136,8 @@ 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` or
`requestScopes`, to interactively renew the token.
In either case, your app needs to prompt the end user to `requestScopes`, to
**interactively** renew the token.

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

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

For example, you need to import this package directly if you plan to use the
web-only `Widget renderButton()` method.

### Web integration

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.
Expand Down
11 changes: 11 additions & 0 deletions packages/google_sign_in/google_sign_in_web/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,14 @@ in the Flutter wiki for instructions to setup and run the tests in this package.

Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests)
for more info.

# button_tester.dart

The button_tester.dart file contains an example app to test the different configuration
values of the Google Sign In Button Widget.

To run that example:

```console
$ flutter run -d chrome --target=lib/button_tester.dart
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// 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:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_sign_in_web/src/flexible_size_html_element_view.dart';
import 'package:integration_test/integration_test.dart';

import 'src/dom.dart';

/// Used to keep track of the number of HtmlElementView factories the test has registered.
int widgetFactoryNumber = 0;

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('FlexHtmlElementView', () {
tearDown(() {
widgetFactoryNumber++;
});

testWidgets('empty case, calls onPlatformViewCreated',
(WidgetTester tester) async {
final Completer<int> viewCreatedCompleter = Completer<int>();

await pumpResizableWidget(tester, onPlatformViewCreated: (int id) {
viewCreatedCompleter.complete(id);
});
await tester.pumpAndSettle();

await expectLater(viewCreatedCompleter.future, completes);
});

testWidgets('empty case, renders with initial size',
(WidgetTester tester) async {
const Size initialSize = Size(160, 100);

final Element element = await pumpResizableWidget(
tester,
initialSize: initialSize,
);
await tester.pumpAndSettle();

// Expect that the element matches the initialSize.
expect(element.size!.width, initialSize.width);
expect(element.size!.height, initialSize.height);
});

testWidgets('initialSize null, adopts size of injected element',
(WidgetTester tester) async {
const Size childSize = Size(300, 40);

final DomHtmlElement resizable = document.createElement('div');
resize(resizable, childSize);

final Element element = await pumpResizableWidget(
tester,
onPlatformViewCreated: injectElement(resizable),
);
await tester.pumpAndSettle();

// Expect that the element matches the initialSize.
expect(element.size!.width, childSize.width);
expect(element.size!.height, childSize.height);
});

testWidgets('with initialSize, adopts size of injected element',
(WidgetTester tester) async {
const Size initialSize = Size(160, 100);
const Size newSize = Size(300, 40);

final DomHtmlElement resizable = document.createElement('div');
resize(resizable, newSize);

final Element element = await pumpResizableWidget(
tester,
initialSize: initialSize,
onPlatformViewCreated: injectElement(resizable),
);
await tester.pumpAndSettle();

// Expect that the element matches the initialSize.
expect(element.size!.width, newSize.width);
expect(element.size!.height, newSize.height);
});

testWidgets('with injected element that resizes, follows resizes',
(WidgetTester tester) async {
const Size initialSize = Size(160, 100);
final Size expandedSize = initialSize * 2;
final Size contractedSize = initialSize / 2;

final DomHtmlElement resizable = document.createElement('div')
..setAttribute(
'style', 'width: 100%; height: 100%; background: #fabada;');

final Element element = await pumpResizableWidget(
tester,
initialSize: initialSize,
onPlatformViewCreated: injectElement(resizable),
);
await tester.pumpAndSettle();

// Expect that the element matches the initialSize, because the
// resizable is defined as width:100%, height:100%.
expect(element.size!.width, initialSize.width);
expect(element.size!.height, initialSize.height);

// Expands
resize(resizable, expandedSize);

await tester.pumpAndSettle();

expect(element.size!.width, expandedSize.width);
expect(element.size!.height, expandedSize.height);

// Contracts
resize(resizable, contractedSize);

await tester.pumpAndSettle();

expect(element.size!.width, contractedSize.width);
expect(element.size!.height, contractedSize.height);
});
});
}

/// Injects a ResizableFromJs widget into the `tester`.
Future<Element> pumpResizableWidget(
WidgetTester tester, {
void Function(int)? onPlatformViewCreated,
Size? initialSize,
}) async {
await tester.pumpWidget(ResizableFromJs(
instanceId: widgetFactoryNumber,
onPlatformViewCreated: onPlatformViewCreated,
initialSize: initialSize,
));
// Needed for JS to have time to kick-off.
await tester.pump();

// Return the element we just pumped
final Iterable<Element> elements =
find.byKey(Key('resizable_from_js_$widgetFactoryNumber')).evaluate();
expect(elements, hasLength(1));
return elements.first;
}

class ResizableFromJs extends StatelessWidget {
ResizableFromJs({
required this.instanceId,
this.onPlatformViewCreated,
this.initialSize,
super.key,
}) {
// ignore: avoid_dynamic_calls, undefined_prefixed_name
ui.platformViewRegistry.registerViewFactory(
'resizable_from_js_$instanceId',
(int viewId) {
final DomHtmlElement element = document.createElement('div');
element.setAttribute('style',
'width: 100%; height: 100%; overflow: hidden; background: red;');
element.id = 'test_element_$viewId';
return element;
},
);
}

final int instanceId;
final void Function(int)? onPlatformViewCreated;
final Size? initialSize;

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: FlexHtmlElementView(
viewType: 'resizable_from_js_$instanceId',
key: Key('resizable_from_js_$instanceId'),
onPlatformViewCreated: onPlatformViewCreated,
initialSize: initialSize ?? const Size(640, 480),
),
),
),
);
}
}

/// Resizes `resizable` to `size`.
void resize(DomHtmlElement resizable, Size size) {
resizable.setAttribute('style',
'width: ${size.width}px; height: ${size.height}px; background: #fabada');
}

/// Returns a function that can be used to inject `element` in `onPlatformViewCreated` callbacks.
void Function(int) injectElement(DomHtmlElement element) {
return (int viewId) {
final DomHtmlElement root =
document.querySelector('#test_element_$viewId')!;
root.appendChild(element);
};
}
Loading