gmail,
gcalendar, gsheets, gdocs, gdrive and gforms. One client registration, one consent
screen, one refresh token, one OAuth2Client handed to all six.
The server
google_server.py
scripts/live_google_check.py holds the same
constant in Python and fails if Google’s own discovery document stops agreeing
with it.
revocation is how a user is disconnected: Disconnecting.
Google documents its revocation endpoint with the token alone, so
auth_method="none" keeps the client secret out of that request.
Google does publish metadata, so
await OAuth2Server.discover("https://accounts.google.com") builds the same
three endpoint fields at the cost of a network round trip at import time. What
it can never fill in is authorization_params — see below, because that is the
field the whole integration turns on.
Scopes
Each tool lists the scopes Google’s reference accepts for its method, narrowest first, leaving out any that reach only part of what the tool can do.scopes_for then picks the narrowest set that
covers the tools you hand out. Every scope is under
https://www.googleapis.com/auth/ except the full-mailbox one:
The category is what launching costs: any restricted scope means a security
assessment before other people can connect, repeated every year, and a sensitive
one means Google’s verification without it. Hand out tools, not packs, and the
consent screen follows:
gmail_narrow_scopes.py
gmail.modify alone: it covers reading as well, and a grant
of both would be no wider. Only threads_delete needs the full mailbox, and it
covers every other Gmail tool, so the whole pack asks for that one scope.
drive.file reaches only files your app created or the user opened with it,
which is why only files_create, about_get and the three create tools in Docs,
Sheets and Forms offer it. files_create under it can create at the root or in a
folder your app made; a parents folder the user made needs drive.
google_scopes.py
403 from the API, not from the token endpoint.
Each pack also reads the environment, so a script or the
MCP server needs no wiring at all. All six renew a grant from
$GOOGLE_TOKEN_FILE (an authorized-user JSON file) or from
$GOOGLE_REFRESH_TOKEN with $GOOGLE_CLIENT_ID and $GOOGLE_CLIENT_SECRET,
through one shared client built on the server above. Failing those they read
$GOOGLE_ACCESS_TOKEN, the static-token path, which never refreshes. The order
is on environment fallbacks.
A call the grant does not cover answers 403 with the standard
WWW-Authenticate: Bearer error="insufficient_scope", scope="…" challenge, which
lists every scope Google would accept, legacy aliases included. Charter reads it on
any response and raises InsufficientScope
with the narrowest of them the tool lists.
Wiring the packs
google_client.py
SubjectProvider,
which keeps one of these per subject.
Getting the first grant
The snippets here are a web app’s: a route, a session, a row per user. For one account of your own, Set up Google does the same two protocol steps from a terminal, with no route and no database. Charter refreshes a grant you already hold and never obtains one — the consent redirect,state, PKCE and the callback route are your framework’s.
Two steps of it are protocol, and they are the ones below:
google_connect.py
google_callback.py
grant.scopes is what was really issued; comparing
it with missing_scopes is the only place that gap is visible before a tool call
returns 403.
OAuth2Client.from_grant(GOOGLE, grant, client_id=..., client_secret=...) turns
that grant into the provider above without spending a refresh.
Refresh behaviour
Google does not rotate refresh tokens. A refresh response carries anaccess_token, an expires_in of about an hour, and no refresh_token — so
on_refresh hands you back the same string you passed in. The row worth writing
is the one exchange() returned; on_refresh still earns its place as the hook
that tells your store the grant is alive.
Concurrent refreshes are harmless here, because nothing is invalidated by a
second one. OAuth2Client serialises them anyway — twelve parallel tool calls
wait on one refresh — which is what makes the same code safe against the
rotating servers on the other two provider pages.
A revoked grant answers invalid_grant. Revocation happens at
myaccount.google.com/permissions, or when a Workspace admin removes the app.
Charter raises CredentialError and then
stops asking for 60 seconds, because the token endpoint rate-limits per client
and one dead user must not degrade the rest. Call reset() after re-authorizing.
What fails silently
access_type=offline and prompt=consent. Without the first, Google returns
no refresh token at all. Without the second, a returning user who already
approved your app gets a token response with no refresh token in it — Google
only re-issues one on a consenting authorization. Nothing errors either way:
the access token works, and the integration dies within the hour. That is why
the lore lives on the OAuth2Server declaration, and why exchange() defaults
to expect_refresh_token=True and names both parameters when none comes back.
A hundred refresh tokens per account, per client. Google caps them and
silently invalidates the oldest when you cross the line. An app that runs the
consent flow on every login instead of reusing the stored grant will work for
its first hundred users and then start logging out the earliest ones.
Refresh tokens expire in seven days while the app is in testing. A Cloud
Console project whose publishing status is Testing issues refresh tokens that
die after a week. The symptom — works all week, invalid_grant on Monday — is
worth recognising before you go looking for a bug in your storage.
login_hint is a hint. It pre-fills the account chooser; the user can pick a
different account. If your product ties the grant to an email address, read the
address back from the API rather than trusting the one you asked for.
Disconnecting
A “Disconnect Google” button has to revoke the grant, not only delete your row. Without the revocation the refresh token keeps working, your app stays under the user’s third-party connections with every scope it was given, and Google’s refresh tokens do not expire on their own.google_disconnect.py
revoke() posts the refresh token
to https://oauth2.googleapis.com/revoke. Measured against Google, it answers
200 {}, every access token issued from the grant is refused in the same
second, and the refresh token comes back invalid_grant.
It ends every grant your app holds for that account, not only this one.
Revoking one Calendar grant also ended a second refresh token issued to the
same client for the same account, and a third issued for Docs. Google keeps
one authorization per account per client, and revoking removes all of it. If
you store a row per pack or per scope, disconnecting one disconnects them all,
so delete them all.
A grant already gone answers 400 invalid_token (“Token is not revocable.”),
and revoke() returns False rather than raising, since the outcome is the
one asked for. Google gives that same answer to an access token that has
lapsed while the grant behind it is still live, so revoke with the refresh
token. Handed only an access token,
revoke_token raises rather than return
False when Google calls it dead. A live access token ends the whole grant.
Serving many users through SubjectProvider, await provider.revoke(user.id)
does the lookup and the revocation, then forgets the user’s client:
Disconnecting a user.
Where the rest is
- Getting the grant — the route, the session,
state, PKCE - Authorization servers — discovery, and what varies between servers
- Packs — what the six Google packs cover