This page is the flow inside your app, for a product whose users each
connect their own account. Connecting one account — yours — to a script or an
agent you run needs none of it: your own account is
five steps and ends in a working call.
OAuth2Client
uses a grant: inject, cache, refresh, rotate, die loudly.
OAuth2Flow obtains one — and does only the two
steps of that which are protocol rather than product:
- Build the authorization URL — RFC 6749 §4.1.1, PKCE per RFC 7636. A pure function: no I/O, no state, no framework.
- Exchange the code for tokens — RFC
6749 §4.1.3. One form POST to the same
token_endpointthe refresh uses.
authorize() hands state and the PKCE
verifier back to you, and exchange() takes the verifier back. That is not a
missing feature; it is the line. Only your framework knows which browser is
which, so the session is yours, and a library that quietly grew one would have
grown a wrong one.
The split, precisely


The flow
oauth_routes.py
scopes_for(gmail.TOOLS) reads the scopes metadata every tool is declared
with — deduped, first-seen order — so the consent screen asks for exactly what
the agent can do, and stays in lockstep with the tool set instead of being
hand-maintained beside it.
from_grant seeds the client’s cache with the grant’s access token, so the
first tool call after connecting does not spend a refresh. A grant with no
refresh token is refused there: a client that can never refresh is a footgun,
not a convenience.
The failure this flow exists to catch
The dangerous parameters in OAuth are the ones that fail silently when omitted. Google withoutaccess_type=offline returns no refresh token: nothing
errors, the access token works, the integration dies an hour later. Two
defenses, layered:
- The lore is declared, once, as
authorization_paramson theOAuth2Server— copy it from the Google page with the rest of that server’s constants. - The exchange checks the result:
exchange()defaults toexpect_refresh_token=Trueand raises aCredentialErrornamingaccess_type=offline/prompt=consentwhen no refresh token came back. Passexpect_refresh_token=Falseonly for a server that genuinely never issues one.
Security invariants
PKCE always. On by default, S256 only — there is noplain method and
never will be. pkce=False exists solely for a server that rejects unknown
parameters; it is not a documented path.
Verify state, in constant time. The state check at your callback is the
CSRF defense that keeps an attacker from splicing their authorization code into
your user’s session. states_match(expected, received)
is == minus the timing leak — use it instead of ==, and reject the callback
when it fails.
redirect_uri never comes from user input. It is a registration fact,
fixed at OAuth2Flow construction and sent identically in both steps. Building
it per-request from a Host header or a next= parameter is how open
redirectors and token leaks happen.
Identity cannot be smuggled through params. authorization_params and
extra_params may add vendor parameters, but overriding client_id,
redirect_uri, state or code_challenge* through either raises ValueError.
Secrets stay out of reprs. OAuth2Flow,
TokenGrant and
OAuth2Client all print with their secrets
masked; a grant in a log line leaks nothing.
Trying it from a terminal
examples/connect_google.py runs the whole
flow for a personal account with a stdlib http.server loopback callback — an
example you own and can read in one screen, not a library API. The two calls
above are the only Charter in it; everything else is the redirect and the wait,
which is the point.
Your own account wraps that script in the rest of the
job — what to register with Google first, what to store afterwards, and the one
call that proves the scopes were right.
Non-goals
- Device authorization grant (RFC 8628) — out of this iteration; a legitimate future candidate, since it is form-POST-only and would fix the CLI on-ramp.
- JWT-bearer / service accounts — never: they need a signature, and nothing in Charter signs anything, by the same rule that keeps AWS SigV4 out.