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 is five steps and ends in a working call.
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.

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