OAuth2Client is for.
The server
github_server.py
discover() has nothing to read and the constant is the declaration. Both URLs
are on github.com, not api.github.com — the API host the pack calls is a
different host from the one that issues the token.
No authorization_params: GitHub takes its refresh behaviour from the app’s own
settings rather than from the authorization request.
revocation deletes the user’s authorization of your app, which is
GitHub’s REST API
rather than an RFC 7009 endpoint: Disconnecting.
dead_grant_errors is GitHub’s name for a dead grant. A spent, expired or
revoked refresh token comes back as bad_refresh_token in an HTTP 200, rather
than RFC 6749’s invalid_grant in a 400; measured against a live GitHub App,
where the grant itself kept working. Declared, it gets what invalid_grant gets: no second request
for a minute, and a CredentialError
whose reauthorize is true.
Which token you have
“Expire user authorization tokens” is a checkbox on the GitHub App’s settings
page. With it off, the exchange returns an access token and nothing else; with it
on, an 8-hour access token and a 6-month refresh token. It is the whole
difference between the first two rows, and it is not visible from the token
response until you look for what is missing.
With expiry on, a refresh retires the previous access token at once: “once you
use a refresh token, that refresh token and the old user access token will no
longer work”, in GitHub’s words.
One process notices nothing. Several serving the same user would each refresh
and break the others’ tokens, so they share the whole grant through your store
and take a lock around the refresh:
More than one process.
For a token that never expires there is nothing for
OAuth2Client to do:
github_static_token.py
$GITHUB_TOKEN is also the pack’s fallback, so a script with that variable set
needs no configure() call at all.
Wiring the pack
github_client.py
Accept,
X-GitHub-Api-Version and a User-Agent — GitHub answers 403 without the last
one — are declared once on the pack as static_headers and sent verbatim beside
the token, so nothing about them reaches the model. Serving many end users is
SubjectProvider.
Scopes
Each tool declares what a classic OAuth App or a classic PAT needs for it, from the sentence GitHub’s REST reference gives each endpoint and from GitHub’s scope list:
A public repository needs no scope to read and
public_repo to write, but a
private one needs repo for both, so the repository tools ask for repo. An
agent that only ever works on public repositories can say so, and ask for less:
github_scopes.py
workflow for a write that adds or changes a file under
.github/workflows, a release whose commit does, included. No tool asks for it,
because almost no write does that; an agent that edits workflows derives its
tools with scopes=["repo", "workflow"].
The other token types carry permissions instead of scopes, set where the app
or the token is created rather than asked for on consent, and the pack declares
them too, as two kinds:
github.APPis a GitHub App installation token, which acts as the app.github.FINE_GRAINEDis a fine-grained PAT, or a GitHub App’s user access token, which act as a user.
X-Accepted-GitHub-Permissions header
names for its endpoint, spelled the same way, and
scripts/live_github_scopes_check.py --kind fine_grained asks GitHub for every
tool again. Write access includes read, so a reader takes either:
github_permissions.py
pull_requests on an issue endpoint, but only for a pull
request’s number, so the issue tools ask for issues. Three endpoints serve a
user’s token only (users_get_authenticated, issues_list_for_authenticated_user,
repos_list_for_authenticated_user), and the notification endpoints serve
neither kind; scopes_for names them rather than
build a grant that cannot run them.
A GitHub App ignores the scope parameter on the authorization URL entirely.
Pass scopes_for(github.TOOLS) anyway —
authorize() refuses an empty scope
list, since an authorization request for nothing is a bug upstream — and set the
real permissions on the app.
A call the grant does not cover raises
InsufficientScope. To a classic token
GitHub answers a private repository it will not show with the same 404 as a
missing one, beside X-Accepted-OAuth-Scopes and X-OAuth-Scopes; it counts as
a refusal only when the token holds none of the scopes GitHub accepts.
Getting the first grant
The route, the session andstate stay yours:
github_connect.py
github_callback.py
scope field is space-delimited; GitHub sends
repo,read:user. TokenGrant.scopes splits on commas as well as spaces, so it
arrives as the two scopes, ready to compare. A GitHub App user token reports no
scopes at all, which is correct rather than empty — its permissions live on the
installation.
If the app does not expire its tokens, exchange() raises: no refresh token came
back, and the default expect_refresh_token=True treats that as the silent-death
case. On GitHub it often is not, so say so with
flow.exchange(code, expect_refresh_token=False) and hold the result in a
StaticTokenProvider.
Refresh behaviour
Every refresh rotates. GitHub returns a new refresh token with each refresh and retires the old one, alongsiderefresh_token_expires_in — roughly six
months. Persist the new value through on_refresh or the next refresh fails with
bad_refresh_token, and note that the six-month clock is on the refresh token: an
integration nobody uses for six months needs the user back on the consent screen.
Concurrency across processes is yours to solve. One OAuth2Client
serialises refreshes, so parallel tool calls in one process wait on a single
one. Two processes each holding the same refresh token will invalidate each
other’s. Your store is the shared cache — one writer through on_refresh.
GitHub answers errors with a 200. login/oauth/access_token returns
HTTP 200 with {"error": "bad_verification_code", "error_description": "..."}.
Charter reads the error field regardless of status, so it becomes a
CredentialError with GitHub’s own description attached.
The response is JSON because Charter asks for it. That endpoint defaults to a
form-encoded body; every token request Charter sends carries
Accept: application/json, so the parsing is not a coincidence you need to
reproduce — but it is why a hand-rolled curl of the same endpoint looks
nothing like what you expected.
PKCE buys you nothing here. GitHub does not implement it: code_challenge is
ignored and no code_verifier is ever checked. Charter still sends both by
default, harmlessly. Verifying state at the callback is the CSRF defense that
actually holds on this server.
Disconnecting
GitHub ends a grant through its API:DELETE /applications/{client_id}/grant
on api.github.com, authenticated with the app’s client ID and secret as Basic
auth, given any live access token of the user’s in a JSON body. It deletes
every token the app holds for that user, and the app leaves their list of
authorized apps. A refresh token cannot be presented there, so the declaration
asks for an access token. Which call sends it depends on your row of the table
above.
With expiry on, the client revokes.
OAuth2Client.revoke() sends the
freshest access token it has, and refreshes first if none is still valid:
github_disconnect.py
SubjectProvider, await github_users.revoke(user_id) builds the user’s
client from your store and does the same:
Disconnecting a user.
A token that never expires is revoked as a string. With expiry off, or
from a classic OAuth App, there is no client around it:
github_token_disconnect.py
204 when it deleted the authorization. Its reference lists
one other status, 422, “Validation failed, or the endpoint has been
spammed”, and says nothing of a token it does not know. Measured against a
live GitHub App, that token (revoked, retired by a refresh, or never issued)
gets 404. The declaration counts both as gone, and both calls return
False. A wrong client secret gets 401, which still raises.
That False is only about the token sent, which is why an expiring token goes
through the client: handed to revoke_token, one a refresh has retired gets
404 while the grant lives on. A personal access token is not an
authorization of your app, so there is nothing for the app to revoke; its
owner deletes it in their settings.
Where the rest is
- Getting the grant — the route, the session,
state, PKCE - Authorization servers — discovery, and what varies between servers
- Packs — the twenty-nine GitHub tools, their headers and paging