Skip to main content
Most GitHub integrations never refresh anything: a classic OAuth App token and a personal access token both live until somebody revokes them. Refresh exists for exactly one shape — a GitHub App user-to-server token on an app that expires its tokens — and that is the shape OAuth2Client is for.

The server

github_server.py
GitHub publishes no authorization-server metadata for these endpoints, so 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
The bearer token is only half of what GitHub wants on a request. 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
Those are the scopes a classic OAuth App or a classic PAT needs for the pack’s twenty-nine tools. The other two token types do not use them:
  • 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 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.

Getting the first grant

The route, the session and state stay yours:
github_connect.py
At the callback:
github_callback.py
That 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, alongside refresh_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