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
names what each pack needs, and each guide ends in a working call. For what
each pack’s users connect with, and the server constants, see
every pack.
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.
More than one process
The client caches a user’s access token and refreshes it under a lock, so twelve concurrent tool calls in one process share one refresh, and its outcome: when it fails, all twelve get its error, rather than each asking the token endpoint in turn. That holds for calls on several threads too. A refresh whose caller is cancelled midway, by a timeout or a client hanging up, still finishes, because by then the server may have rotated the refresh token and the answer is the only place the new one exists. That lock is per process. Run several workers (gunicorn, a few containers, serverless instances) and each builds its own client for the same user, each holding its own copy of the grant. Against a server that keeps one refresh token for the life of the grant, as Google does, that costs nothing. Against one that rotates (Stripe, Linear, Slack with rotation on), a copy goes stale the moment another worker refreshes: the server has spent that refresh token and handed its successor to someone else. The stale worker’s next refresh answersinvalid_grant, and
Charter reports that the user has to authorize again, for a grant that is fine.
Stripe goes further: a refresh also revokes the previous access token, within
a few seconds. Two workers that each refresh fail each other’s next call, and
then refresh again.
So the workers share the grant through your store. Hand the client a function
that reads it, where you would have passed the refresh token, and have
on_refresh write all three values back:
stripe_workers.py
invalid_grant it reads the store once more, in case another worker stored a
successor in the moment between. on_refresh has finished before the call that
triggered the refresh goes out.
When the store holds no grant for the user, because they disconnected in
another worker, have the loader return None. The client then raises a
CredentialError whose reauthorize is
true, the signal to show a reconnect button, and asks the token endpoint
nothing.
A loader may return only the refresh token, as a string. That is enough
against a server that rotates refresh tokens but leaves earlier access tokens
alone. Returning the whole grant is right everywhere, and it saves the extra
refreshes.
What each provider does
OAuth leaves all of this to the server. RFC 6749 §6 lets it issue a new refresh token or not, and lets it revoke the old one; the fate of earlier access tokens isn’t specified at all. So the providers differ, and the table is what each one’s own documentation says. Where a cell was measured rather than read, it says so.
Two things follow for several workers. Wherever a refresh retires the previous
access token (GitHub, Notion, Stripe, and Slack beyond two), the workers have to
share the access token as well as the refresh token, so the loader returns the
whole grant. Returning it is right for the others too: it saves the refreshes.
None of the seven documents reuse detection, and none showed it: a spent
refresh token presented again was accepted or refused, and in every run the
grant kept working. That does not make the lock below optional. Where a
refresh retires the previous access token, workers that refresh together
revoke each other’s tokens, and calls fail.
A lock around the refresh
Two workers that find the stored token due at the same moment both read the store before either has written to it, so both refresh with the same token. What that costs depends on the server:- Stripe, and servers like it, accept some of the refreshes and revoke
the access token of each one that finished before the last, within seconds.
Measured with four processes on one Stripe grant, every one due at the same
moment every 15 seconds: without the lock, 104 of 1,976 calls failed in
three minutes, and a worker whose refresh token another had just spent got
invalid_grant, which reads as “the user has to authorize again” for a grant that was fine. With the lock, 6,969 calls in ten minutes, none failed, and one refresh per renewal. With a lock held through a database, across nine processes on three machines in Paris, Virginia and Singapore, 8,803 calls, none failed, and again one refresh per renewal. Take the lock. - A server with reuse detection treats the second presentation as theft.
RFC 9700 §4.14 says
it “will revoke the active refresh token”, and it recommends this design.
Nothing fails at first: the loser takes the winner’s stored access token.
An hour later no refresh token is left, and the user has to connect again.
Reading the store after
invalid_grantcannot help, because the grant is already gone. Here the lock is mandatory.
on_refresh. Whoever takes it next reads the stored result instead of
refreshing again:
stripe_locked_workers.py
on_refresh must not need. A lock that takes a connection
from the pool they read and write through deadlocks as soon as as many users
refresh at once as the pool has connections: each holds one for its lock and
waits for another, and every refresh in the process stops, along with
everything else sharing that pool. Give the lock connections of its own. The
lock needs a loader: a
refresh token passed as a string is a copy, and holding a lock cannot make a
copy current, so refresh_lock without one raises ValueError. A lock that
cannot be taken raises CredentialError and makes no refresh.
on_refresh carries more weight here than in one process. An on_refresh that
raises is logged and the call goes ahead, since the token is still good for
this process. The store then holds a refresh token the server has already
spent. The client that refreshed does not read it back over the one it holds:
it goes on refreshing with its own. And it runs on_refresh again, in the
background and under the lock, as soon as that refresh is done, then, while the
store keeps failing, on a call at least five seconds after each try. It only
writes while the store still holds the spent token, leaving alone a store that
has moved on to another grant, or to none, and no call waits on it. Until that
second write lands, other workers read the spent token. Against a server that
refuses one (Stripe, GitHub, Notion) their refreshes fail as a dead grant, and
against reuse detection the first of them disconnects the user. So make sure a
failing on_refresh alerts you.
Ending the grant
The flow has a third protocol step, at the other end of the connection: when the user disconnects, the grant is revoked at the server (RFC 7009), and only then is your row deleted.await client.revoke() sends it, as the server’s
declaration says; with many users, await provider.revoke(user_id) on the
SubjectProvider finds the client first. Deleting the row alone leaves the
grant alive. Disconnecting a user
has the rest.
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 and errors. OAuth2Flow,
TokenGrant and
OAuth2Client all print with their secrets
masked; a grant in a log line leaks nothing. Servers quote what they refuse
(“Refresh token does not exist: rt_…”), so a token endpoint’s error has every
credential the request sent replaced by *** before it becomes a
CredentialError. A revocation endpoint’s error is redacted the same way.
Trying it from a terminal
examples/connect/my_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.
Set up Google 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.