charter.auth, not charter.
OAuth 2.0 declared rather than wrapped: a server is a frozen declaration in RFC
8414’s vocabulary, a client refreshes a grant you already hold, a flow covers
the two steps of obtaining one that are protocol rather than product, and a
revocation is the one step of ending it.
The host keeps the callback route, the session, and storage. Charter signs
nothing, so JWT-bearer and service-account grants are out of scope.
OAuth2Server
str
required
Where the form POST goes. Empty raises
DeclarationError."client_secret_post" | "client_secret_basic" | "secret_key_basic"
default:"\"client_secret_post\""
How the client authenticates (RFC 6749 §2.3.1).
client_secret_post puts the
credentials in the form body, which is what Google, GitHub and most APIs
expect; client_secret_basic puts them in an HTTP Basic header.
secret_key_basic is outside the RFC: the client secret alone is the Basic
username, with no password and no client ID, which is how Stripe’s token
endpoint takes your secret key. Any other value raises
DeclarationError.Optional[str]
default:"None"
The issuer identifier, when the server publishes one.
Optional[str]
default:"None"
Where a user is sent for consent. Optional: a declaration without one is still
a working token-refresh-only server, and
OAuth2Flow.authorize raises with instructions if
you start a consent flow against one.Mapping[str, str]
default:"{}"
Extra authorization-URL parameters this registration needs. The one field
discovery can never fill in — Google without
{"access_type": "offline", "prompt": "consent"} returns no refresh token and
reports no error. Normalised to a read-only, key-sorted mapping, so the
declaration stays frozen all the way down.bool
default:"True"
False for a server whose permissions are set where the app is registered —
Notion’s capabilities, a Stripe App’s manifest. OAuth2Flow.authorize then
takes no scopes and sends no scope, and raises if given some, since they
would be ignored.str
default:"\" \""
How the consent link joins scopes. RFC 6749 §3.3 says a space; Linear and
Shopify document a comma.
"form" | "json"
default:"\"form\""
How the token endpoint wants its body, for the exchange and every refresh.
The RFC’s form unless the server documents otherwise; Notion’s takes only
JSON.
Optional[int]
default:"None"
The access token’s lifetime in seconds, for a server that documents one but
leaves
expires_in out of its token response. Stripe’s tokens last an hour
and its response never says so. Without this, a token with no expires_in
is kept until the API refuses it, and that call fails. With it, the token is
renewed shortly before it lapses. An expires_in in the response always
wins. Anything but a positive whole number raises DeclarationError.Mapping[str, str]
default:"{}"
Vendor fields for the code exchange, as
authorization_params are for the
consent link. Shopify asks for its expiring offline token, the one public
apps must use for the GraphQL Admin API, with {"expiring": "1"}. Sent with
the exchange only, never with a refresh. A field the exchange owns
(grant_type, code, redirect_uri, code_verifier, client_id,
client_secret, refresh_token) raises DeclarationError.tuple[str, ...]
default:"()"
Error codes, besides RFC 6749’s
invalid_grant, that the server uses to say
a grant is gone. Slack answers a spent refresh token with
invalid_refresh_token and an uninstalled app with token_revoked, both in
an HTTP 200; GitHub uses bad_refresh_token, and Linear invalid_request. A declared code is handled
like invalid_grant: a 60-second cool-down, one more read of a
GrantLoader, and a CredentialError whose reauthorize is
true. invalid_grant always counts. A bare string, or an empty or
non-string code, raises DeclarationError.Optional[Union[Revocation, Revoker]]
default:"None"
How the server ends a grant, for a “disconnect” button: a
Revocation, which is RFC 7009 unless it says otherwise, or
a Revoker, for a server whose disconnect is more than one
request carrying a token (Stripe Apps).
OAuth2Client.revoke and
revoke_token send it. None means the server documents
no way to end a grant. Anything else, a bare URL
included, raises DeclarationError, and so does a revocation sending its
token in the Authorization header to a server whose client authentication
uses that header too.OAuth2Server.discover
.well-known/openid-configuration, then .well-known/oauth-authorization-server.
This is the path for anything an enterprise runs — Okta, Entra ID, Auth0,
Keycloak and Ping all publish one.
token_endpoint_auth_method is chosen from
token_endpoint_auth_methods_supported, preferring client_secret_post. A
document with no token_endpoint, or advertising neither usable method, raises
CredentialError; so does exhausting both well-known paths, with every attempt
named in the message.
A revocation_endpoint in the document becomes a Revocation.
Its auth method is the token endpoint’s when
revocation_endpoint_auth_methods_supported allows it or is absent, and
otherwise the first of client_secret_post, client_secret_basic and none
the list names. A list naming none of them leaves revocation unset rather
than failing discovery.
The one network call in this library that is not a tool call.
Revocation
OAuth2Server as
revocation. The defaults are RFC 7009:
one POST carrying the refresh token as token, authenticated and encoded the
way the token endpoint is. Revoking the refresh token ends the grant and every
access token issued from it, and the app leaves the user’s list of connected
apps. Every other field is a vendor’s departure, written down as the token
endpoint’s are. Each provider page carries its declaration.
str
required
Where the request goes.
{client_id} is replaced with the client ID, since
GitHub’s endpoint is per registration. Empty raises DeclarationError."refresh_token" | "access_token"
default:"\"refresh_token\""
Which token the endpoint takes.
"access_token" is for an endpoint that ends
the whole grant given any live access token: GitHub’s
DELETE /applications/{client_id}/grant, Slack’s apps.uninstall,
Shopify’s api_permissions/current.json. A client sends the freshest token
it can get, renewing first if it has lapsed.Optional[RevocationAuthMethod]
default:"None"
How the client authenticates, when it differs from the token endpoint.
None inherits token_endpoint_auth_method; "none" sends no client
credentials, for an endpoint documented to take the token alone (Google,
Linear).Optional[str]
default:"None"
The body field carrying the token,
token when left as None. GitHub
names it access_token.Optional[str]
default:"None"
Send the token as the request’s own credential instead.
"Authorization"
sends Bearer <token>; any other header gets the bare token, as Shopify’s
X-Shopify-Access-Token does. Requires token_type="access_token", and
cannot be combined with token_field; either mistake raises
DeclarationError."POST" | "DELETE"
default:"\"POST\""
DELETE for GitHub and Shopify.Optional["form" | "json"]
default:"None"
The body’s format, inheriting
token_request_format when None. GitHub’s
API takes JSON where its token endpoint takes a form. A request with nothing
to put in a body sends none.tuple[str, ...]
default:"()"
Error codes with which the server says the token was already dead.
invalid_token (RFC 6750’s code, and Google’s answer), invalid_grant and
the server’s dead_grant_errors always count. Slack declares invalid_auth,
account_inactive and token_expired. A bare string raises
DeclarationError.tuple[int, ...]
default:"()"
Statuses that say the same, for a server that answers without an OAuth error
code: GitHub’s
404 and 422, Linear’s 400 and 401. A 401 always
counts when the token is the request’s own credential. Anything but 4xx
codes raises DeclarationError.bool
default:"False"
Send RFC 7009’s optional
token_type_hint, refresh_token or
access_token for whichever token is sent. Linear’s docs suggest it “to
help the server identify the token”; measured, Linear only finds a refresh
token when told, and answers a live one 401 Token not found otherwise, so
its declaration sets this. Combined with token_header, raises DeclarationError.False rather than raising. That needs a server that
says so, which RFC 7009 does not ask of one: its §2.2 has the server answer
200 for a token it does not know. Google, Linear, GitHub and Slack answer
with an error instead; Notion follows the RFC, so there revoke() returns
True either way. Any other refusal raises
CredentialError, with the error code and description and the token and
client secret redacted from both.
revoke_token
OAuth2Client.revoke
calls this.
Pass what you have. A server that revokes by refresh token is sent
refresh_token when given, since that ends the whole grant, and
access_token otherwise. One declared with token_type="access_token" needs
a live access_token, and raises CredentialError without one.
client_id and client_secret are needed wherever the client authenticates,
or {client_id} is in the endpoint. A server whose revocation is a
Revoker is handed fields, the token-response fields it names
(Stripe’s stripe_user_id), and raises CredentialError without them.
Returns True when the server confirmed the revocation, and False when it
said the token was already dead. Raises DeclarationError when the server
declares no revocation, and CredentialError for any other refusal.
An access token sent to a server that revokes by refresh token is the
exception. If the server calls it dead, that says nothing about the grant: an
access token lapses while its refresh token stays live, and Google answers a
lapsed one exactly as it answers a revoked grant. That raises
CredentialError asking for the refresh_token, rather than returning a
False that would read as disconnected.
A server that takes an access token can only answer for the one it was sent.
For a token that expires (a GitHub App’s, or Slack’s with rotation on), a
False may mean only that this token lapsed or was retired by a refresh, and
the grant is still live; revoke those through
OAuth2Client.revoke, which renews the token first.
Notion answers 200 to any token at all, so there True says nothing either.
Revoker
revocation where one
request carrying a token is not it. Stripe Apps is the case: a grant is an
install of the app, and ending it is two requests, finding the account’s
install and uninstalling it, neither carrying a token. A Revoker is such a
procedure, kept in the provider’s pack, and
OAuth2Client.revoke,
SubjectProvider.revoke and
revoke_token run it where they would send a
Revocation, so disconnecting is the same call for every
server. Charter ships one,
charter.packs.stripe.StripeAppUninstall.
grant_fields names the token-response fields it needs. A client keeps them
from the exchange (TokenGrant.raw), from a TokenGrant its loader returns,
or from any refresh, and refreshes once before revoking if it has seen none.
A grant the token endpoint calls dead is False without the Revoker being
asked. revoke returns True when the server confirmed the grant ended and
False when it was already gone, and raises
CredentialError for any other refusal.
Its requests go through GrantToRevoke.send, which
authenticates them and reads a refusal as the rest of Charter does. Make it a
frozen dataclass, so two declarations of one server compare equal.
GrantToRevoke
Revoker is handed: the grant, the client it was issued
to, and the token-response fields its grant_fields names. http is the
client Charter owns and closes; send is how a Revoker uses it.
GrantToRevoke.send
Revoker writes its steps and nothing else. The
client authenticates as auth_method says, inheriting the server’s
token_endpoint_auth_method when None, as a Revocation
does; "none" sends no client credentials. client_secret_post puts them in
the body, data or json, and raises DeclarationError on a GET, which
has none.
A response at 300 or above raises
CredentialError, unless its status is
in allow: doing says what failed, followed by the server’s code and
explanation, read from whichever error shape it uses (RFC 6749’s, Stripe’s,
GitHub’s, Notion’s), with every credential the grant holds redacted.
provider names it on the error.
OAuth2Client
CredentialProvider backed by a
token endpoint. Holds one grant — one end user, or one machine identity. Serve
many users by wrapping it in
SubjectProvider.
OAuth2Server
required
The declaration to refresh against. Anything else raises
TypeError.str
required
Your registration’s client id. Empty raises
CredentialError.str
required
Your registration’s client secret. Empty raises
CredentialError.Optional[str | GrantLoader]
default:"None"
The grant to renew. Required for the
refresh_token grant — omitting it
raises CredentialError saying that obtaining a grant is your application’s
flow.A GrantLoader instead of a string reads your store whenever
the client’s token is due or refused: right before a refresh, and once more
if the server answers invalid_grant or a code in dead_grant_errors. Use
one when more than one process serves the same user and the server rotates
refresh tokens. A string is a
copy, and it goes stale as soon as another process refreshes:
More than one process. A loader
that returns None says no grant is stored (the user disconnected), and
raises a CredentialError whose reauthorize is true. One that raises, or
returns something with no refresh token in it, raises CredentialError."refresh_token" | "client_credentials"
default:"\"refresh_token\""
refresh_token is the agent acting for an end user; client_credentials is
the agent acting as itself. These are the two grants that are a plain form
POST; any other value raises ValueError.Optional[str]
default:"None"
Space-separated scopes to send with the token request, when the server wants
them narrowed.
Optional[Callable[[Credentials, Optional[str]], None | Awaitable[None]]]
default:"None"
Called after every successful refresh with the new credentials and the refresh
token to store next time — which may be a new one, since some servers rotate
on every use. This is where persistence lives. A callback that raises is
logged at WARNING and does not fail the call the token was fetched for. With
a
GrantLoader, the client keeps the refresh token it
holds rather than read back the spent one the store still has, and runs the
callback again in the background, at once and then every five seconds of
calls, while the store still holds the spent token.Optional[RefreshLock]
default:"None"
A lock across processes for this grant, as a function returning an async
context manager. The client holds it while it reads the store, refreshes if
it still has to, and runs
on_refresh, so the next holder reads that result
instead of refreshing again. Mandatory against a server with reuse
detection, where two processes presenting one refresh token revoke the
grant, and against one whose refresh retires the previous access token,
where they revoke each other’s tokens and calls fail:
A lock around the refresh.
Requires refresh_token to be a GrantLoader, or raises
ValueError. A lock that cannot be taken raises CredentialError.int
default:"90"
How early to renew. Capped at half the lifetime the server granted, so a
60-second token does not refresh on every call.
int
default:"20"
Timeout for the token request.
Optional[httpx.AsyncClient]
default:"None"
Reuse an HTTP client for token requests. When omitted, one is created and
closed per request.
Tool.invoke() from several threads at once. A refresh runs to the end
even if every call waiting on it is cancelled, since the server may already
have rotated the refresh token, and the new one is read, adopted and handed to
on_refresh.
An invalid_grant answer, or a code the server declares in
dead_grant_errors, marks the grant dead for 60 seconds and re-raises the same
error without asking the server again — one revoked user must not become a
stream of requests against an endpoint that rate-limits per client. The error’s
reauthorize is true, which is the signal to show the user a reconnect button.
Attributes and methods
OAuth2Server
The declaration passed in.
"refresh_token" | "client_credentials"
Which grant this client uses.
Optional[str]
The scope string sent with token requests.
int
The configured leeway, before the lifetime cap is applied.
Optional[str]
The current refresh token, which is not necessarily the one passed in: a
server that rotates hands back a new one on every refresh and Charter adopts
it.
Credentials
The provider protocol. Returns the cached token, or refreshes.
None
Forget the cached token and any dead-grant cool-down. Call it after
re-authorizing, if you are reusing the client rather than building a new one.
None
Forget the cached token if it is
credentials.token — called by the runtime
when the API refuses it, so the next call fetches a new one rather than
sending a revoked token until its expiry. A token that has already been
replaced is left alone, and the grant and its cool-down are untouched.bool
End the grant at the server, then stop using it: the disconnect button.
Sends the server’s
Revocation with the current refresh
token, or with a live access token where the server takes one: any still
valid for the request, refreshing first only if none is. A grant in its
dead-grant cool-down is still sent, since only the server’s answer to this
request decides that the grant is gone. With a GrantLoader the
store is read first, under refresh_lock, so the grant revoked is the one
stored and not one another process has rotated away from. A refresh in
flight in this process finishes first, for the same reason. A
Revoker is handed the grant with the token-response fields it
names, refreshing first if none has carried them. An access
token the server calls dead is renewed and sent once more, since GitHub
and Notion retire a worker’s token when another worker refreshes, and
that says nothing about the grant.True when the server confirmed it, False when the grant was already
gone. Either way the client then refuses to refresh: get_credentials
raises a CredentialError whose reauthorize is true, without asking the
token endpoint, until reset() or until the loader returns a different
refresh token, and revoking it again asks nothing and returns False. A
client_credentials client revokes its cached token and only drops it. Any
other refusal raises CredentialError and leaves the client working. It runs
to the end even if the caller is cancelled. No revocation on the server
raises DeclarationError before any request. Deleting the stored grant is
yours, afterwards.OAuth2Client.from_grant
exchange: the grant’s
access token pre-fills the cache, so the first tool call after connecting spends
no refresh. A grant carrying no refresh token is refused with CredentialError
— for a one-shot access token, use
StaticTokenProvider.
OAuth2Flow
client_id, client_secret, redirect_uri — beside the server declaration,
which holds what the server is.
str
required
The exact callback URL registered with the authorization server, never one
built from user input. Empty raises
CredentialError.authorize() and exchange(): the state and the
PKCE verifier are handed to you and taken back, because only your framework
knows which browser is which.
OAuth2Flow.authorize
Iterable[str]
default:"()"
What to ask for. Empty raises
CredentialError: an authorization request for
nothing is a bug upstream. scopes_for derives this from a
tool set. Against a server declared with uses_scopes=False it is the other
way round: leave it empty, and no scope is sent.Optional[str]
default:"None"
Your own CSRF token. Generated with
secrets.token_urlsafe(32) when omitted.Optional[str]
default:"None"
Which account to pre-select on the consent screen.
bool
default:"True"
S256 PKCE (RFC 7636).
False exists for the rare server that rejects unknown
parameters, and is never the recommended path.Optional[Mapping[str, str]]
default:"None"
Per-request additions, merged last.
authorization_params, then extra_params. Neither map may set client_id,
redirect_uri, state or anything starting with code_challenge — identity
and the CSRF/PKCE material belong to the flow, and an attempt raises
ValueError naming the source. A server with no authorization_endpoint raises
CredentialError.
scope is encoded with %20 rather than +, which some servers reject.
OAuth2Flow.exchange
str
required
The authorization code from the callback. Empty raises
CredentialError.Optional[str]
default:"None"
The verifier
authorize() returned, when PKCE was used.bool
default:"True"
Raise
CredentialError when the server returns no refresh token. This is the
silent failure of the whole flow — the exchange succeeds, the access token
works, and the integration dies within the hour. Pass False only for a
server that genuinely never issues one.AuthorizationRequest
authorize() hands back.
str
Redirect the user here.
str
Put it in the session, server-side and keyed to this browser, and compare it
at the callback with
states_match.Optional[str]
Keep it beside the state and pass it to
exchange(). None when
pkce=False.TokenGrant
str
Valid now, typically for an hour.
Optional[str]
What you persist.
None when the server issued none and
expect_refresh_token=False.Optional[datetime]
Derived from
expires_in, or None when the server sent none.list[str]
What the server says was actually granted, which is not always what was asked
for: some consent screens let a user deselect. One scope per item, split on
spaces as RFC 6749 says and on commas as GitHub, Slack and Shopify send them.
Dict[str, Any]
The untouched response body, for vendor extras.
repr and str mask the tokens, so printing a grant does not put a secret
in a log line.
This is deliberately not
Credentials: that is the injection
currency, handed out on every tool call. A grant is what you persist once.
states_match
state is the one authorize() issued. Compares in
constant time, so the CSRF check is one obvious call rather than a plain ==
that leaks how many leading characters matched.
Verifying state at the callback is the host’s job: it is what keeps an
attacker from splicing their code into your user’s session.
scopes_for
gmail.readonly or anything broader, and the relabeller
needs gmail.modify, so the second agent asks for gmail.modify alone. Nothing
declares that gmail.modify covers gmail.readonly: it is read off the tools’
own AnyOf requirements, which list what the provider accepts.
A tool declared with plain scopes needs each of them, so tools declared that way
get their union in first-seen order, as they always have. Two tools that list the
same two scopes in opposite orders raise a DeclarationError, since neither can
then be called the narrower.
The consent screen is then exactly what the agent can do, kept in lockstep with
the tool set instead of maintained beside it.
kind asks about another kind of credential than the pack’s default:
scopes_for(tools, kind=github.APP) is the permissions to set on a GitHub App,
kind=stripe.RESTRICTED_KEY the narrowest restricted key. A string names a kind
too, and one no tool declares is refused with the kinds that exist. A tool that
cannot run under the kind raises a DeclarationError naming it, its
Unavailable reason and the kinds that would run it.
AnyOf
scopes (or a factory’s scopes / scopes_override) wherever the
provider accepts more than one scope for the call:
scopes_override=[SEND] on the tool.
List only scopes that let the tool do everything its arguments can ask for.
Google’s files.get also accepts drive.file, but that scope reaches only files
the app created, so gdrive.files_get does not offer it: a grant holding it would
pass the consent screen and then fail on the user’s own files.
options holds the scopes; met_by(granted) says whether a grant holding
granted meets the requirement. Equal when the options are equal, in order.
Unavailable
scopes_for and missing_scopes refuse a
tool set that includes it for that kind, rather than build a grant that cannot run
it. reason holds the words; equal when the reasons are equal.
Kind
github.APP, slack.USER, stripe.RESTRICTED_KEY)
and a tool’s scopes maps each kind to its requirements:
granted says where the names go: "consent" in the authorization request,
"configured" where the app or key is made. default marks the kind a plain
list of scopes means, which is what tool.scopes holds; the others are in
tool.kind_scopes. A mapping names one provider’s kinds and at most one default.
On a factory, a tool’s list scopes_override replaces the default kind’s and
keeps the rest, and a mapping replaces the kinds it names; derived(scopes=...)
does the same. Equal by name, provider, granted and default; str(kind) is
"github.app".
missing_scopes
gmail.modify runs a tool whose narrowest option is gmail.readonly. The answer
is scopes_for over the requirements the grant leaves unmet.
ScopeRefusal
scope_refusals=[...], the way an
Envelope is. Every tool the
factory builds then raises InsufficientScope
for it, before the status or the envelope would call it something vaguer.
status is the statuses the refusal comes back with, 200 included for an API
that reports failure inside a success. signal is a regular expression the body
must match; without one, held_header must be set, or every response with that
status would read as a missing scope. The scopes the provider names come from the
first group of needed, the JSON path needed_field, or the header
needed_header, whichever finds some: backticked, quoted, or a plain list.
held_header names the header listing what the token holds, and a response counts
only when the token holds none of the named scopes, which is how GitHub’s 404 for
a repository it hides is told from its 404 for one that does not exist.
alternatives says whether several named scopes are each enough (the usual case)
or all needed. kind is the kind of credential the names belong to.
The standard WWW-Authenticate: Bearer error="insufficient_scope", scope="…"
challenge needs no declaration: it is read on every response, and its scopes as
alternatives. A pattern that is not a regular expression, or a needed without a
group, is refused when the declaration is made.
Types in these signatures
The aliases the signatures above are declared in. They are exported fromcharter.auth so a host writing its own wrapper can name them without importing
from a submodule.
ScopeRequirement
scopes: a scope the tool needs, an AnyOf
of scopes any one of which will do, or Unavailable for a kind
of credential that cannot run the tool at all.
Grant
OAuth2Client uses. "refresh_token" acts for a user
who consented; "client_credentials" acts as the application itself, with no
user and so no refresh token to store.
RevocationAuthMethod
none for an endpoint that takes the token alone. Google’s is
documented with nothing but token, so its declaration sends the client secret
nowhere it is not asked for.
TokenEndpointAuthMethod
secret_key_basic, Stripe) the secret alone
as the Basic username. RFC 8414
names this field, and a server’s own metadata usually announces which it wants;
OAuth2Server.discover reads it for you.
Getting it wrong is a silent-shaped failure: the server answers
invalid_client, which reads like a wrong secret rather than a wrong envelope.
OnRefresh
on_refresh that raises is logged as a warning and does not fail the call:
the token is still good for this process. So a callback with the wrong
signature fails quietly, and the rotated token is never stored. A client with a
GrantLoader calls it again for that refresh in the
background, at once and then every five seconds of calls, while the store
still holds the refresh token the server spent.
GrantLoader
refresh_token, it reads a grant from your store, where on_refresh
wrote it. Return the refresh token alone, and no process refreshes with one
another has already spent. Return a TokenGrant with the access
token and its expiry too, and a process whose own token is due takes the stored
one instead of refreshing, so one refresh serves every process. That is
required against Stripe, whose refresh revokes the previous access token within
seconds. A stored token is skipped when it is inside the renewal window, or is
the one the API just refused. So is a stored refresh token this client has
already rotated away from: the store is behind it, because on_refresh failed.
Return None when no grant is stored, because the user disconnected or never
connected. The client raises a CredentialError whose reauthorize is true,
and asks the token endpoint nothing.
RefreshLock
refresh_lock, it takes a lock scoped to one grant, across every
process that serves it, over your own store: a database’s advisory lock, a
Redis lock. Called once per refresh, and held from the read of the store to the
end of on_refresh.
Whatever it holds while held, the loader and on_refresh must not need. A lock
that takes a connection from the pool they use deadlocks once as many users
refresh at once as the pool has connections: each holds one for its lock and
waits for another. A lock around the refresh.
Related
- Authorization servers — declarations for the servers you are likeliest to meet
- The OAuth flow — the route, the session and the storage around these two calls
- Credentials