Skip to main content
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.
Charter’s OAuth surface has two halves. 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:
  1. Build the authorization URL — RFC 6749 §4.1.1, PKCE per RFC 7636. A pure function: no I/O, no state, no framework.
  2. Exchange the code for tokens — RFC 6749 §4.1.3. One form POST to the same token_endpoint the refresh uses.
Everything between those two steps belongs to your web framework, and the library holds nothing across the gap — 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

Two lanes, Charter and you, with seven numbered steps alternating between them. Charter: flow.authorize() builds the authorization URL, PKCE verifier and challenge, and state. A dashed band across both lanes: the redirect and the consent screen, on the authorization server, where neither of you is running. You: the callback route, then states_match() verifying state. Charter: flow.exchange() turns the code into tokens. You: storing the refresh token. Charter: OAuth2Client.from_grant() refreshing, caching and rotation from then on. A bracket down your lane, from the authorization URL to the state check, marks what your session holds across the redirect: state and the PKCE verifier. Charter’s two touches are brief and yours is continuous, which is the same thing the bracket says: the verifier exists on your side for the whole time the browser is somewhere else. Row by row, with each half’s link:

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 without access_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_params on the OAuth2Server — copy it from the Google page with the rest of that server’s constants.
  • The exchange checks the result: exchange() defaults to expect_refresh_token=True and raises a CredentialError naming access_type=offline / prompt=consent when no refresh token came back. Pass expect_refresh_token=False only 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 answers invalid_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
Whenever a worker’s own token is due, or the API has refused it, the client reads the store first. A stored access token that is still good, and isn’t the one just refused, is used as it is: one refresh serves every worker. Otherwise the client refreshes with the stored refresh token, and if the server answers 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_grant cannot help, because the grant is already gone. Here the lock is mandatory.
Charter holds no connection to your store, so you supply the lock: a function returning an async context manager that one process at a time can hold for a grant, such as your database’s advisory lock or a Redis lock. The client holds it while it reads the store, refreshes if it still has to, and runs on_refresh. Whoever takes it next reads the stored result instead of refreshing again:
stripe_locked_workers.py
One rule keeps it safe with many users: whatever the lock holds while it is held, the loader and 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 no plain 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.