google-auth,
not a Slack client, nothing. A token endpoint is a form POST, and what differs
between servers is a handful of fields, named the way RFC 8414 names
them.
There is deliberately no built-in catalogue of vendors. A constant shipped in
a release is pinned to that release, so when it changes everyone on an older
version is broken until you upgrade — whereas a constant in your own code you fix
in ten seconds. And a blessed-vendor list would imply that unlisted servers are
second class, which is the opposite of what is true here.
So: discover it, or copy four lines from a page here.
Most servers describe themselves
Okta, Microsoft Entra ID, Auth0, Keycloak, Ping and Google all publish a metadata document. One call toOAuth2Server.discover
builds the declaration, and it cannot go stale:
discover_server.py
/.well-known/openid-configuration), then
RFC 8414 (/.well-known/oauth-authorization-server). If a server publishes
neither, it tells you both URLs it tried.
This is the path for the enterprise IdP nobody has heard of — which is almost
always one of the products above, and therefore more discoverable than the
consumer APIs the packs talk to, not less.
Discovery fills in the authorization_endpoint too, when the document has one,
and the revocation_endpoint that a disconnect button needs.
What it can never fill in is authorization_params — see
Google, where those two parameters decide whether the
integration lives past its first hour.
Entra ID: the tenant lives in the path, so discover per tenant —
await OAuth2Server.discover(f"https://login.microsoftonline.com/{tenant}/v2.0").
The common and organizations pseudo-tenants work the same way.
Servers you write down
Copy the block. Paste it into your code. It documents itself there forever, and you own the value. Google is here despite publishing discovery, because a network round trip at import time is worse than a constant.The three the packs use
Each has a page carrying its constant, the scopes its packs declare, the wiring, and the refresh traps that cost people an afternoon. The constants live there and nowhere else, so there is one copy to be wrong:
Google’s constant is additionally checked against the live server by
scripts/live_google_check.py, which fails if Google’s own discovery document
stops agreeing with what the page says. Run it with Application Default
Credentials:
Linear, Stripe, Notion and Shopify
Each of these departs from the RFC somewhere: Linear and Shopify join scopes with commas, Stripe authenticates its token endpoint with your secret key, Notion wants JSON and takes no scopes. Their constants are on Connect your users’ accounts, beside the flow that uses them, and each departure is a field on the declaration rather than code.Anything else
One field is mandatory and the rest have sensible defaults — every one of them is specified onOAuth2Server:
If a wrong choice fails loudly it is safe to write down — a server expecting
Basic answers
invalid_client on the first call. That is why these are declared
and rotates_refresh_token is not: rotation guessed wrongly fails silently,
days later. authorization_params is the exception that proves the rule — it
does fail silently when wrong, which is exactly why it belongs written on the
declaration where the constants live, and why
exchange() checks the result (see
the flow guide).
Getting the grant
Everything above assumed a refresh token you already hold. When your product’s users connect their own accounts, the grant has to be obtained first —OAuth2Flow does the two protocol steps, your
framework does everything between them. The flow guide has the shape end to end, and each
provider page has it wired for that server, including the vendor parameters that
decide whether a refresh token comes back at all.
Using it
A declaration plus your registration is anOAuth2Client, and that credential provider is
what a factory like oauth_tool_factory
or a pack takes:
acme_client.py
configure() —
gmail.configure(credential_provider=...), wired in full on
the Google page. For many end users, wrap it in
SubjectProvider.
Serving many users
get_credentials(provider) names
the API, not the person — the runtime has no business knowing there is a person.
So identity travels out of band, read by your provider through
SubjectProvider and
use_subject, never by the runtime:
acme_many_users.py
gmail.configure(credential_provider=SubjectProvider(for_user)).
Tools are built once at import and work unchanged through LangChain, ADK, the
OpenAI tools interface and the Claude Agent SDK, because the lookup happens below
every adapter. A ContextVar is per-task in asyncio, so concurrent requests
sharing one set of tools cannot see each other’s subject — and an unset subject
raises rather than falling back to a default, because acting as the wrong
principal is the one failure a boundary layer cannot ship.
Providers are capped (max_subjects, default 1000) and evicted
least-recently-used. Each carries its own cached token and its own refresh
lock, so eviction removes both together.
What the cache does, precisely
Three behaviours worth knowing, because all three are deliberate and all three are visible: Renewal is capped at half the granted lifetime.leeway_seconds (default 90)
renews a token shortly before it expires. But a server issuing 60-second tokens
would make every token born already inside its own renewal window, so every call
would refresh — turning one request into two, and churning a new refresh token per
call against a rotating server. The leeway is therefore capped at half whatever
lifetime the server granted, so early renewal still happens for an hour-long token
and quietly stops mattering for a very short one.
A revoked grant is asked about once a minute, not once a call. invalid_grant
means the refresh token is dead until a human re-authorizes; retrying cannot help.
Token endpoints rate-limit per client, so an agent that keeps calling tools for a
revoked user would degrade every other user of your app. The client remembers the
refusal for 60 seconds and raises the same CredentialError without asking again.
Call reset() — or build a new client — after re-authorizing. The error’s
reauthorize is true, so your app can tell this from a failure worth retrying.
A server that names a dead grant its own way declares the codes in
dead_grant_errors: Slack answers invalid_refresh_token or token_revoked,
GitHub bad_refresh_token, Linear invalid_request.
A token the API refuses is dropped, not kept until it expires. A token can
die early: an app uninstalled and reinstalled, a secret rotated, a grant revoked
and given again. When a call comes back 401, or with an envelope’s credential
error, the runtime tells the provider that issued the token, and the client
forgets it, so the next call fetches a new one. Before this a running server
sent the dead token until the expiry it was issued with, which for Shopify is a
day. The call that was refused still raises: retrying is yours to decide. Only
that token is dropped — if a concurrent call has already replaced it, the
replacement stays — and the grant is kept, so a dead grant still lands on the
cool-down above.
A provider of your own can take part by defining invalidate(credentials).
SubjectProvider passes it on to the current subject’s provider, and the packs’
own providers pass it on to the client they hold.
Disconnecting a user
Deleting a stored refresh token does not disconnect anybody. The grant stays live at the server with every scope it was given, the app stays on the user’s list of connected apps, and for a server whose refresh tokens never expire, Google’s among them, that is permanent. Disconnecting is a request to the server, and it is one more piece of protocol: RFC 7009 token revocation. The server says how it takes that request in its declaration, asrevocation, and the client sends it:
acme_disconnect.py
discover() fills revocation in
from the server’s revocation_endpoint, so most enterprise IdPs need nothing
written. The vendors depart from the RFC in small ways, each a field on
Revocation: GitHub, Slack and Shopify end a grant given a live access token
rather than the refresh token, GitHub and Shopify with a DELETE. The
declarations on the provider pages already carry them. One server departs
further: Stripe Apps ends a grant by uninstalling the app, two requests and
no token, so its declaration is a Revoker, a
procedure kept in the Stripe pack, and the call is still revoke().
Serving many users, SubjectProvider.revoke(user_id)
builds that user’s client from your store and revokes the grant, in one
call:
acme_disconnect_route.py
revoke_token(server, refresh_token=..., access_token=...).
Already gone is not an error. A user who removed the app from their
account settings first, or a grant that expired, comes back as the server’s
“invalid token”. revoke() returns False rather than raising, since the
user is disconnected either way. Any other refusal, such as a wrong client
secret, raises CredentialError and
leaves the client as it was, so the call can be retried. RFC 7009 itself has
a server answer 200 for a token it does not know, and a server that follows
it, Notion among them, never says “already gone”: there revoke() returns
True either way. Only the server’s answer to the revocation decides it: a
grant in the 60-second cool-down is still sent, with any access token the
client holds that is still valid, since the invalid_grant that began the
cool-down may have been about one spent refresh token rather than the grant.
A revoked client does not refresh. Afterwards get_credentials raises a
CredentialError whose reauthorize is true, without asking the token
endpoint. A client built with a GrantLoader
recovers on its own once the store holds a new grant: the user connected
again. That is why SubjectProvider.revoke keeps such a client in place: a
call made in the moment before you delete the row is refused, rather than sent
with the access token the row still holds, which Stripe goes on accepting for
seconds after an uninstall. Any other provider is forgotten and built from
your store again on the next lookup.
Delete your row after the revocation, not before. The client needs the
refresh token to send. A loader-backed client reads the store under its
refresh_lock first, so in a multi-process deployment the token revoked is
the one the store holds, not one another process has already rotated away
from.
What Charter does not do
It does not hold anything betweenauthorize() and exchange(). The two
protocol steps of obtaining a grant are OAuth2Flow’s (the flow
guide); the callback route, the session holding state and the
PKCE verifier, storage and the consent UI belong to your web framework, forever.
It does not sign anything. JWT-bearer, Google service accounts and anything
else needing a signed assertion are out of scope — the same rule that keeps AWS
SigV4 out. Two grants are supported, both a plain form POST:
refresh_token and client_credentials.
It does not store a token. The access token lives in memory for the seconds
it is valid. Persistence is yours, through on_refresh — which is also how a
multi-process deployment shares one refresh: your store is the shared cache.
Deleting the row when a user disconnects is yours for the same reason;
revoking the grant at the server is Charter’s.