feat(github): add native oauth token source#9654
Conversation
Greptile SummaryAdds a native GitHub OAuth device-flow token source with no external dependency, covering the full lifecycle: device-code initiation, poll-until-authorized, token caching at mode 0o600, automatic refresh, and auto-injection of the cached token into the shell environment under a configurable variable name (default Confidence Score: 4/5Safe to merge; all previously flagged issues have been addressed in this revision and no new blocking bugs were found. The change adds several hundred lines of new OAuth credential-handling code (device flow, token refresh, on-disk caching, shell-env injection across four code paths) that directly affects how GitHub tokens are sourced and exposed to subprocesses. All previously identified robustness gaps are fixed in this head commit. The complexity of the credential lifecycle and the experimental status of the feature warrant a careful human second read before merging. src/github/oauth.rs and src/toolset/toolset_env.rs are the highest-impact files and warrant the most careful second read. Important Files Changed
Reviews (15): Last reviewed commit: "[autofix.ci] apply automated fixes" | Re-trigger Greptile |
There was a problem hiding this comment.
Code Review
This pull request introduces native GitHub OAuth device flow support for obtaining and refreshing access tokens, adding a new mise github login command and updating existing token resolution logic. Documentation, settings, and CLI usage have been updated to accommodate these changes. The review feedback suggests improving maintainability by deduplicating the Python mock server script used in tests and enhancing the testability of internal OAuth functions by passing parameters explicitly instead of relying on global state.
| cat >"$HOME/mock-github-oauth.py" <<'PYEOF' | ||
| import http.server, json, os, urllib.parse | ||
|
|
||
| class Handler(http.server.BaseHTTPRequestHandler): | ||
| def do_POST(self): | ||
| length = int(self.headers.get("Content-Length", "0")) | ||
| body = self.rfile.read(length).decode() | ||
| form = urllib.parse.parse_qs(body) | ||
|
|
||
| if self.path == "/login/oauth/device/code": | ||
| payload = { | ||
| "device_code": "device-mock", | ||
| "user_code": "ABCD-1234", | ||
| "verification_uri": "https://github.com/login/device", | ||
| "expires_in": 600, | ||
| "interval": 1, | ||
| } | ||
| elif self.path == "/login/oauth/access_token": | ||
| grant_type = form.get("grant_type", [""])[0] | ||
| if grant_type == "urn:ietf:params:oauth:grant-type:device_code": | ||
| payload = { | ||
| "access_token": "ghu-native-oauth-token", | ||
| "expires_in": 28800, | ||
| "refresh_token": "ghr-native-refresh-token", | ||
| "refresh_token_expires_in": 15897600, | ||
| "token_type": "bearer", | ||
| "scope": "", | ||
| } | ||
| elif grant_type == "refresh_token": | ||
| payload = { | ||
| "access_token": "ghu-native-oauth-token-refreshed", | ||
| "expires_in": 28800, | ||
| "token_type": "bearer", | ||
| "scope": "", | ||
| } | ||
| else: | ||
| payload = {"error": "unsupported_grant_type"} | ||
| else: | ||
| self.send_response(404) | ||
| self.end_headers() | ||
| return | ||
|
|
||
| data = json.dumps(payload).encode() | ||
| self.send_response(200) | ||
| self.send_header("Content-Type", "application/json") | ||
| self.send_header("Content-Length", str(len(data))) | ||
| self.end_headers() | ||
| self.wfile.write(data) | ||
|
|
||
| def log_message(self, format, *args): | ||
| pass | ||
|
|
||
| server = http.server.HTTPServer(("127.0.0.1", 0), Handler) | ||
| with open(os.path.join(os.environ["HOME"], "mock-github-oauth-port"), "w") as f: | ||
| f.write(str(server.server_address[1])) | ||
| server.serve_forever() | ||
| PYEOF |
There was a problem hiding this comment.
This large Python script for mocking the GitHub OAuth server is duplicated in e2e/cli/test_token_github. To improve maintainability, consider extracting this script into a separate file (e.g., in an e2e/fixtures/ directory) and executing it from both test files.
Additionally, the mock server implementation is inconsistent between the two test files. This file includes logic for grant_type="refresh_token", while e2e/cli/test_token_github does not. Unifying them into a single script would resolve this inconsistency and make the tests more robust.
| fn cache_key(host: &str, client_id: &str) -> String { | ||
| let hash = blake3::hash( | ||
| format!( | ||
| "{}|{}|{}", | ||
| host, | ||
| client_id, | ||
| Settings::get().github.oauth_scopes | ||
| ) | ||
| .as_bytes(), | ||
| ); | ||
| hash.to_hex()[..16].to_string() | ||
| } |
There was a problem hiding this comment.
The cache_key function implicitly depends on global settings by calling Settings::get(). This can make the function harder to test and reason about in isolation. Consider passing the oauth_scopes as an argument to make the dependencies explicit, avoiding re-deriving values from a general context.
| fn cache_key(host: &str, client_id: &str) -> String { | |
| let hash = blake3::hash( | |
| format!( | |
| "{}|{}|{}", | |
| host, | |
| client_id, | |
| Settings::get().github.oauth_scopes | |
| ) | |
| .as_bytes(), | |
| ); | |
| hash.to_hex()[..16].to_string() | |
| } | |
| fn cache_key(host: &str, client_id: &str, scopes: &str) -> String { | |
| let hash = blake3::hash( | |
| format!( | |
| "{}|{}|{}", | |
| host, | |
| client_id, | |
| scopes | |
| ) | |
| .as_bytes(), | |
| ); | |
| hash.to_hex()[..16].to_string() | |
| } |
References
- When overriding a method, use the provided parameters instead of re-deriving their values from a more general context.
| fn host_matches_settings(host: &str) -> bool { | ||
| let Some(api_host) = api_host() else { | ||
| return false; | ||
| }; | ||
| host == api_host || (host == "github.com" && api_host == "api.github.com") | ||
| } | ||
|
|
||
| fn api_host() -> Option<String> { | ||
| url::Url::parse(&Settings::get().github.oauth_api_url) | ||
| .ok()? | ||
| .host_str() | ||
| .map(|h| h.to_string()) | ||
| } |
There was a problem hiding this comment.
The functions host_matches_settings and api_host implicitly depend on global settings by calling Settings::get(). This makes them harder to test and less reusable. Consider passing the required settings as arguments to make their dependencies explicit, avoiding re-deriving values from a general context.
| fn host_matches_settings(host: &str) -> bool { | |
| let Some(api_host) = api_host() else { | |
| return false; | |
| }; | |
| host == api_host || (host == "github.com" && api_host == "api.github.com") | |
| } | |
| fn api_host() -> Option<String> { | |
| url::Url::parse(&Settings::get().github.oauth_api_url) | |
| .ok()? | |
| .host_str() | |
| .map(|h| h.to_string()) | |
| } | |
| fn host_matches_settings(host: &str, oauth_api_url: &str) -> bool { | |
| let Some(api_host) = api_host(oauth_api_url) else { | |
| return false; | |
| }; | |
| host == api_host || (host == "github.com" && api_host == "api.github.com") | |
| } | |
| fn api_host(oauth_api_url: &str) -> Option<String> { | |
| url::Url::parse(oauth_api_url) | |
| .ok()? | |
| .host_str() | |
| .map(|h| h.to_string()) | |
| } |
References
- When overriding a method, use the provided parameters instead of re-deriving their values from a more general context.
Hyperfine Performance
|
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.5.4 x -- echo |
18.8 ± 0.8 | 17.4 | 22.0 | 1.00 |
mise x -- echo |
19.1 ± 0.9 | 17.4 | 26.7 | 1.01 ± 0.06 |
mise env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.5.4 env |
18.5 ± 0.8 | 16.7 | 23.1 | 1.00 |
mise env |
18.7 ± 0.9 | 16.9 | 22.8 | 1.01 ± 0.07 |
mise hook-env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.5.4 hook-env |
19.2 ± 0.8 | 17.6 | 22.6 | 1.00 |
mise hook-env |
19.3 ± 0.9 | 17.6 | 24.2 | 1.01 ± 0.06 |
mise ls
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2026.5.4 ls |
15.8 ± 0.8 | 14.2 | 19.8 | 1.00 |
mise ls |
16.1 ± 0.8 | 14.7 | 19.9 | 1.02 ± 0.07 |
xtasks/test/perf
| Command | mise-2026.5.4 | mise | Variance |
|---|---|---|---|
| install (cached) | 124ms | 126ms | -1% |
| ls (cached) | 57ms | 58ms | -1% |
| bin-paths (cached) | 64ms | 64ms | +0% |
| task-ls (cached) | 495ms | 494ms | +0% |
|
Aren't we gonna deprecate |
`mise token github` (added in #8868) supersedes `mise github token`. Emit a deprecation warning on the parent `mise github` command and drop the new `mise github login` (use `mise token github --oauth` instead). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix `serde_derive` import broken by main rebase (use `serde`)
- normalize cache key to `api_host` so tokens cached via the
user-facing `github.com` form are found by internal `api.github.com`
lookups
- accept `host == "api.{web_host}"` in `host_matches_settings` so GHE
instances with `api.ghe.example.com` work the same way github.com does
- exit non-zero on `mise token github --raw` when no token is found, so
`$(mise token github --raw)` fails loudly instead of binding empty
- delegate `mise github token` (deprecated) to the canonical
`mise token github` impl to drop duplicated logic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
04fbf87 to
770dfea
Compare
…lution When `oauth_client_id` is configured but no token is cached, normal commands (`mise install`, `mise ls-remote`, ...) would unexpectedly drop into an interactive device-flow prompt in terminals — the previous guard only blocked the prompt in non-terminal environments. Now the device flow only runs when explicitly requested via `mise token github --oauth` (`force_device_flow: true`). All other call paths bail when the cache is empty/unrefreshable, which `resolve_token` swallows as `None` and falls through to the next token source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demote write_cache failures in the device-flow and refresh paths to a warning so a freshly-acquired token is still returned to the caller instead of being discarded after the user already authorized. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Some OAuth servers only return a new refresh_token when rotating it, so keep the existing refresh_token and refresh_expires_at on the cached entry when the response leaves them out. Otherwise subsequent refreshes fail and force re-authorization via device flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`mise token github --oauth` now serves a cached or refreshed token when one exists and only falls back to the device-code flow when nothing is cached, so `export GITHUB_TOKEN=\$(mise token github --oauth --raw)` is practical to run repeatedly. Renames the request flag from \`force_device_flow\` to \`allow_device_flow\` to reflect the new semantics. Also makes the device-code polling loop tolerant of transient network errors so a brief connectivity blip no longer invalidates an in-flight authorization. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match the trimmed value used in the cache key when posting to GitHub's OAuth endpoints so configured whitespace can't cause cache misses or authentication failures. Update settings.toml prose to point at \`mise token github --oauth\` since \`mise github login\` was removed earlier in this PR series. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When \`github.oauth_client_id\` is configured and a cached or refreshable token is available, mise now exports the token under \`github.oauth_export_env\` (default \`GITHUB_TOKEN\`) so tools like \`gh\`, \`git\`, and \`cargo publish\` see it via \`mise activate\` / \`mise hook-env\` / \`mise env\` / \`mise exec\` without an explicit \`mise token github --oauth --raw\` capture. The token is injected after the env cache is saved/loaded so the ephemeral value is never persisted to disk, and the resolution path never triggers the device-code flow — uncached configurations stay silent until the user opts in with \`mise token github --oauth\`. Set \`oauth_export_env = ""\` to disable the auto-export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f710962. Configure here.
OAuth client requests now share a `http_client()` helper that respects `http_timeout` for both connect and read, matching the rest of the project's reqwest usage. Without it, an unreachable OAuth server could silently block GitHub API calls for the OS-default TCP timeout. Also, when a cached token is within the reuse buffer and the refresh attempt fails (no refresh token, expired, or transient error), fall back to the still-unexpired access token instead of discarding it and bailing out — the caller can keep working until the access token actually expires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the local `http_client()` helper and use the project's shared `crate::http::HTTP` client so OAuth requests pick up the same timeouts, gzip, and user-agent as everything else. `http::Client` now exposes its underlying `reqwest::Client` for callers (like the OAuth flow) that need form-encoded POSTs, which the higher-level helpers don't cover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Gate `mise token github --oauth` and the auto-injection paths behind `experimental = true` via `ensure_experimental` in `token_async`. - Stop overwriting an existing `GITHUB_TOKEN` (or whichever `oauth_export_env` resolves to) that the user has set in `[env]`. The user-provided value now wins; OAuth only fills the slot when it is empty. - Mark `--oauth`, `github.oauth_client_id`, and the docs section as experimental, and credit ghtkn (https://github.com/suzuki-shunsuke/ghtkn) as the inspiration for this feature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
### 🚀 Features - **(cli)** add minimum release age flag to lock and ls-remote by @risu729 in [#9269](#9269) - **(config)** add run field for hooks by @risu729 in [#9718](#9718) - **(github)** add native oauth token source by @jdx in [#9654](#9654) - **(oci)** scope build to project config by default by @jdx in [#9766](#9766) - add support for prefixed latest version queries in outdated checks by @roele in [#9767](#9767) ### 🐛 Bug Fixes - **(activate)** guard bash chpwd hook under nounset by @risu729 in [#9716](#9716) - **(backend)** date-check latest stable fast path by @risu729 in [#9650](#9650) - **(config)** parse core tool options consistently by @risu729 in [#9742](#9742) - **(exec)** propagate __MISE_DIFF so nested mise recovers pristine PATH by @jdx in [#9765](#9765) - **(forgejo)** include prereleases when opted in by @risu729 in [#9717](#9717) - **(github)** avoid caching empty release assets by @risu729 in [#9616](#9616) - **(java)** resolve lockfile URLs from metadata by @risu729 in [#9719](#9719) - **(lock)** cache unavailable github attestations by @risu729 in [#9741](#9741) - **(pipx)** preserve options when reinstalling tools by @risu729 in [#9663](#9663) - **(python)** skip redundant lockfile provenance verification by @risu729 in [#9739](#9739) - **(vfox)** run pre_uninstall hook by @risu729 in [#9662](#9662) ### 🚜 Refactor - **(schema)** extract tool options definition by @risu729 in [#9649](#9649) ### ⚡ Performance - **(aqua)** bake rkyv aqua package blobs by @risu729 in [#9535](#9535) ### 📦️ Dependency Updates - lock file maintenance by @renovate[bot] in [#9773](#9773) ### 📦 Registry - add vector ([github:vectordotdev/vector](https://github.com/vectordotdev/vector)) by @kquinsland in [#9761](#9761) - add oc and openshift-install (http backend) by @konono in [#9669](#9669) ### New Contributors - @konono made their first contribution in [#9669](#9669) - @kquinsland made their first contribution in [#9761](#9761)

Summary
Adds a native GitHub OAuth device-flow token source for mise with no external gh/ghtkn/fnox dependency. The resolver can use configured OAuth credentials after env vars and credential_command, caches refreshed tokens in mise state, and exposes an explicit login path through
mise github login.Also adds
--oauthand--rawto the GitHub token commands so the same implementation supports both mise-only auth and general development workflows like exportingGITHUB_TOKENfrommise github token --oauth --raw.Validation
cargo fmt --checkcargo checkcargo test github:: --all-featuresmise run test:e2e e2e/cli/test_github_token e2e/cli/test_token_githubbun xtasks/render/schema.tshklint during commitNotes
mise run render:schemablocked inbun i, so I ranbun xtasks/render/schema.tsdirectly.mise run render:usagefailed because the locally installedusagewas 2.6.0 and the generated spec needs at least 2.11 fordefault_subcommand; I kept the generatedmise.usage.kdlupdate and restored the deleteddocs/clioutput.This PR description was generated by an AI coding assistant.
Note
Medium Risk
Adds a new GitHub authentication path that performs OAuth device flow, token caching/refresh, and automatic environment injection, which can affect how GitHub API requests are authorized and what tokens are exposed to subprocesses.
Overview
Adds an experimental native GitHub OAuth device-flow token source, including on-disk caching/refresh support and a new
GitHub OAuthentry in the GitHub token resolution priority.Extends
mise token github(and hidden legacymise github token) with--oauthto force OAuth resolution and--rawfor token-only output (failing when no token is found), and updates environment generation to auto-export a cached OAuth token (configurable env var name) without persisting it in the env cache.Updates settings/schema/docs/man/completions to expose new
github.oauth_*configuration, adds a mock OAuth server fixture plus expanded e2e coverage, and deprecates themise github ...command namespace in favor ofmise token github.Reviewed by Cursor Bugbot for commit 287fb57. Bugbot is set up for automated code reviews on this repo. Configure here.