Skip to main content
Everything on this page is imported from 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

Read the server’s own metadata and build a declaration from it. Tries .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

How a server ends a grant, declared on 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.
A token already dead is not a failure: for a disconnect it is the outcome asked for, so it returns 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

Revoke a grant held as a string, without building a client: a Slack bot token from an app without rotation, a classic GitHub OAuth token, or a disconnect handler holding a stored row. 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

A server’s own way of ending a grant, declared as 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

What a 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

One request as the app, so a 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

A 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.
The access token is cached in memory until the leeway window and written nowhere. Concurrent callers wait on a single refresh: some servers invalidate the previous refresh token on use, so twelve parallel tool calls each firing their own refresh would poison eleven of them. They share its outcome, so a refresh that fails is asked once for all of them rather than once each, in turn, and they share it across threads too: one client can serve tools called through 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

A client seeded from a fresh 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

The stateless half of obtaining a grant. Registration facts live here — 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.
Nothing is remembered between 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

Build the authorization URL (RFC 6749 §4.1.1). Pure — no I/O, no state.
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.
Parameters merge later-wins: the standard set, then the server’s 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

Trade the callback’s code for tokens (RFC 6749 §4.1.3). One form POST to the same token endpoint the refresh uses.
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.
Codes are single-use, so a failed exchange is never retried and no cooldown applies; restarting the flow is the host’s move.

AuthorizationRequest

What 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

What a code exchange returned — the one moment a refresh token is visible.
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.
Both 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

Whether the callback’s 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

The fewest, narrowest scopes an authorization request needs to run these tools. Every tool’s requirements are met by the result, no scope in it can be dropped without leaving one unmet, and among such sets it is the one that avoids the broadest scopes first. The result depends on which tools are handed in, never on the order they arrive in.
Gmail’s readers accept 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

One requirement several scopes can meet, listed narrowest first. Put it in a tool’s scopes (or a factory’s scopes / scopes_override) wherever the provider accepts more than one scope for the call:
Then 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

A tool that cannot run under one kind of credential, and why. It goes where a requirement would:
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

One kind of credential a provider issues, with its own permission names. A pack declares each as a constant (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

What to add to a grant so it runs every one of these tools; empty when it already does. It compares by requirement rather than by string, so a grant holding gmail.modify runs a tool whose narrowest option is gmail.readonly. The answer is scopes_for over the requirements the grant leaves unmet.

ScopeRefusal

How an API says a token’s grant does not cover a call, declared once on the factory with 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 from charter.auth so a host writing its own wrapper can name them without importing from a submodule.

ScopeRequirement

One item of a tool’s 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

Which OAuth 2.0 grant an 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

How the client authenticates to a revocation endpoint: the token endpoint’s methods, plus 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

How the client authenticates to the token endpoint — credentials in the form body, in an HTTP Basic header, or (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

Called after every successful refresh, with the new credentials and the refresh token to store next time, which may be a new one. Sync or async; both are awaited correctly. This is where a rotated refresh token gets persisted. A server that rotates hands you a new one in the refresh response and invalidates the old immediately, so a host that does not write it down is one restart away from a dead grant.
An 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

Passed as 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

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