Skip to main content
One authorization server serves all six Google packs: 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
This is the canonical copy. 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
The third asks for 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
Ask for these in one authorization request and one refresh token covers all six packs. Adding a pack later means sending the user back through consent: a grant covers the scopes it was issued for and nothing else, and the failure when it does not is a 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
One client across six packs on purpose: the access token is fetched once and cached once, so a run that sends a mail, files a calendar event and appends a row spends one refresh rather than six. Serving many end users instead of one account is 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
At the callback, check what Google actually granted before you store it:
google_callback.py
Google’s consent screen lets a user approve some scopes and refuse others, and the exchange still succeeds. 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 an access_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