Users love SSO options. They reduce typing, remove onboarding friction, and add a dash of credibility to your software applications. There have been a thousand articles written about adding Google Sign In to Android and iOS Flutter apps so this post will focus instead on the web app implementation.
Authentication establishes who someone is and this process is typically handled by a “Sign In” or “Sign Up” button.
Authorization on the other hand, is the process of granting or rejecting access to a user’s data. A user can be authenticated (signed in) to an app but refuse to allow that app to access their personal information.
In the past, Google Sign In on the web used the Google Sign-In platform library to authenticate and authorize users in a single go. On March 31st, 2023, that library was deprecated and the web implementation migrated to the new Google Identity Services SDK which treats authentication and authorization separately. This newer way of doing things unfortunately means the Flutter web implementation is more complex than those on the mobile platforms.
ID Tokens are artifacts introduced by the OpenID Connect protocol that prove a user has been authenticated.
Access Tokens allow a client application to access resources on behalf of a user and are therefore associated with authorization rather than authentication.
On the web, the google_sign_in plugin will only provide an ID token if you use the signInSilently method or the renderButton widget. The signIn method used on mobile platforms only provides an access token and so should be avoided if your app needs an ID token. As explained in the readme for the google_sign_in_web plugin, the signIn method uses the “OAuth implicit flow” to authorize scopes which omits the ID token in its response.
This blog post by Auth0 further explains the difference between the two types of tokens.
The following highlights the primary factors that make Google Sign In on the web slightly more complicated:
With these things in mind, lets get started.
First, add the google_sign_in plugin to your pubspec.yaml
file. This plugin handles all of the heavy lifting related to Google SSO and as mentioned, supports Android, iOS, and web.
Next, locate or create a Google client ID on the Credentials page in your Google Cloud console (steps here). The client should be of the type “Web application” and you should add both http://localhost and http://localhost:port as authorized JavaScript origins so you can test locally.
Tip: Add —web-port 5000 to your run configuration to fix the localhost port of your web app.
Once you have a client ID, you can pass it to the GoogleSignIn class one of two ways.
GoogleSignIn googleSignIn = GoogleSignIn(
clientId: const String.fromEnvironment('GOOGLE_CLIENT_ID'),
);
<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">
There are a few different ways to “sign users in” with the google_sign_in package on web. The method you use will determine if you receive an ID token or access token.
Although the signIn method is not recommended on Flutter web (since it no longer returns an ID token to verify the user’s authentication status), you can still use it to initiate the OAuth flow.
GoogleSignIn googleSignIn = GoogleSignIn(
clientId: const String.fromEnvironment('GOOGLE_CLIENT_ID'),
);
await googleSignIn.signIn();
This code will open the Google Sign In popup and allow the user to select their account. Once again, this process does not return an ID token so it is NOT RECOMMENDED. Further, you may see the following error logged in the console:
The OAuth token was not passed to gapi.client, since the gapi.client library is not loaded in your page.
This message can be safely ignored although it should serve as a reminder that you will not have access to the user’s ID token.
When you use the signInSilently method, the user will be shown the One Tap UX for Google Sign In. If they are not signed in, the box will ask them to select an account. If they are already signed in, the popup will indicate which account they are signed into and then disappear.
If the One Tap UX is closed manually by the user using the close icon, you will not see the popup again according to the exponential cool-down time table.
GoogleSignIn googleSignIn = GoogleSignIn(
clientId: const String.fromEnvironment('GOOGLE_CLIENT_ID'),
);
await googleSignIn.signInSilently();
The renderButton widget is a web-only widget included in the google_sign_in_web plugin. Since it can only be used on web, you’ll need to stub its implementation on mobile platforms as shown in the official example. If you are developing an app specifically for web however, the following few lines of code are all you need.
import 'package:google_sign_in_platform_interface/google_sign_in_platform_interface.dart';
import 'package:google_sign_in_web/google_sign_in_web.dart' as web;
// ...
(GoogleSignInPlatform.instance as web.GoogleSignInPlugin).renderButton(),
The button doesn’t provide a direct way to monitor the signed in user but the GoogleSignIn class does. You can use the onCurrentUserChanged stream property to react to users signing in and out.
GoogleSignInAccount? user;
GoogleSignIn googleSignIn = GoogleSignIn(
clientId: const String.fromEnvironment('GOOGLE_CLIENT_ID'),
);
@override
void initState() {
googleSignIn.onCurrentUserChanged.listen((GoogleSignInAccount? account) {
setState(() {
user = account;
});
});
super.initState();
}
Once the user has signed in using one of the methods above, you can retrieve their ID and access tokens if they are available. To do this, use the authentication property on the GoogleSignInAccount instance for the user.
FutureBuilder<GoogleSignInAuthentication>(
future: user!.authentication,
builder: (context, auth) {
return Column(
children: [
const ListTile(
leading: Icon(Icons.person),
title: Text('ID Token'),
),
SelectableText(auth.data?.idToken ?? ''),
const ListTile(
leading: Icon(Icons.lock),
title: Text('Access Token'),
),
SelectableText(auth.data?.accessToken ?? '')
],
);
},
),
The ID token is a JWT-encoded data structure containing information about the token’s issuer, audience, and expiration date. You can plug the value into the decoder at jtw.io to examine its contents. You should notice that the “aud” property of the JWT payload matches the client ID of your web application (ex. 967…apps.googleusercontent.com), meaning that this token was generated for your app specifically. Generally though, your client app should never do this.
The access token in this case is not JWT-encoded but instead adheres to the format agreed on by the authorization server that issued it and the resource server where it will be used to access data. Again, your client app should leave this token alone.
If you’d like to request information about your user from Google (such as from the People API), you’ll first need to check if the user has authorized the necessary scopes using the web-only canAccessScopes method.
Future<bool> _canAccessBirthday() async {
return googleSignIn.canAccessScopes(
['https://www.googleapis.com/auth/user.birthday.read'],
);
}
If this method returns true
, your web application can fetch the requested data. If the method returns false
, you’ll need to request access to the related scopes using requestScopes.
await googleSignIn.requestScopes([
'https://www.googleapis.com/auth/user.birthday.read',
]);
Doing this will cause a popup to appear. Here the user can grant your web app access to the requested data.
The google_sign_in implementation on Flutter web does not remember a user’s authorized scopes by default. This means that if a user refreshes the page, you’ll need to request the required scopes all over again. Not great. You can alleviate some of this user pain by caching the Google user’s access token and passing it to the the canAccessScopes method like this:
Future<bool> _canAccessBirthday() async {
String accessToken = sharedPreferences.getString('googleAccessToken') ?? '';
debugPrint('accessToken: $accessToken');
return googleSignIn.canAccessScopes(
['https://www.googleapis.com/auth/user.birthday.read'],
accessToken: accessToken,
);
}
You can check for access and request it if its missing back-to-back as well:
Future<bool> _canAccessBirthday() async {
String accessToken = sharedPreferences.getString('googleAccessToken') ?? '';
bool authorized = await googleSignIn.canAccessScopes(
['https://www.googleapis.com/auth/user.birthday.read'],
accessToken: accessToken,
);
if (authorized) return true;
try {
bool authorized = await googleSignIn.requestScopes(['https://www.googleapis.com/auth/user.birthday.read']);
return authorized;
} catch (e) {
debugPrint('Error: $e');
return false;
}
}
There are two ways to sign users out. The first and obvious way is to use the signOut method which as the docs state “marks the user as being in a signed out state”. The important thing about this state is that it still “remembers” the authorized scopes. If a user signs in again on the same device, they will not need to approve scopes that have already been approved.
The second way to log a user out is to use the disconnect method. Unlike the signOut method, this one completely disconnects the user from the application and revokes previous authorizations. If the user signs in again at a later point, they will be asked to approve all scopes again (ex. birthday, gender, addresses, contacts, etc).
The method you choose will depend on your application and use case. For many web apps where users are expected to return at a later date, signing them out is preferrable to disconnecting them.
You can download a simple application that demonstrates how the google_sign_in plugin works on web from the COTR GitHub account. Happy coding!
Also published here.