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.
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.
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
github_scopes.py
- A fine-grained PAT wants read/write on Issues, Pull requests and Contents, plus Metadata.
- A GitHub App derives its permissions from the app’s installation, and
ignores the
scopeparameter on the authorization URL entirely. Passscopes_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.
Getting the first grant
The route, the session andstate stay yours:
github_connect.py
github_callback.py
split is not decoration. RFC 6749 says the scope field is
space-delimited and TokenGrant.scopes splits on whitespace accordingly; GitHub
sends repo,read:user, so grant.scopes arrives as a single comma-joined
element. Split it yourself before comparing against anything. 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
invalid_grant, 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.
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