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

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
GitHub also wants 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.APP is a GitHub App installation token, which acts as the app.
  • github.FINE_GRAINED is a fine-grained PAT, or a GitHub App’s user access token, which act as a user.
Each tool’s permissions are what GitHub’s 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
That list is the checklist for the app’s Permissions page, or the token’s. GitHub also takes 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 and state stay yours:
github_connect.py
At the callback:
github_callback.py
RFC 6749 says the 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, alongside refresh_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
A GitHub App’s user token expires after eight hours, and every refresh retires the one before it while the grant lives on, so an access token GitHub calls dead is renewed and sent once more. Serving many users through 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
GitHub answers 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