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.
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
linear_server.py
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
Two behaviours worth knowing, because both are deliberate and both 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.
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.