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

Every pack declares what its tools need, and scopes_for reads it back: Read the second row as the reason scopes_for takes tools rather than packs. Google covers every Gmail endpoint here with gmail.modify except threads_delete, which it puts behind full mailbox access because the delete cannot be undone. That scope is declared on the tool, so it enters a consent request only when you hand the tool out:
gmail_narrow_scopes.py
google_scopes.py
Ask for the union 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 names an environment variable. All six read $GOOGLE_ACCESS_TOKEN, which is the static-token path for a script. It never refreshes.

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, your own account 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 against scopes_for 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.

Where the rest is