Skip to content

feat(github): add native oauth token source#9654

Merged
jdx merged 18 commits into
mainfrom
codex/native-github-oauth
May 10, 2026
Merged

feat(github): add native oauth token source#9654
jdx merged 18 commits into
mainfrom
codex/native-github-oauth

Conversation

@jdx

@jdx jdx commented May 6, 2026

Copy link
Copy Markdown
Owner

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 --oauth and --raw to the GitHub token commands so the same implementation supports both mise-only auth and general development workflows like exporting GITHUB_TOKEN from mise github token --oauth --raw.

Validation

  • cargo fmt --check
  • cargo check
  • cargo test github:: --all-features
  • mise run test:e2e e2e/cli/test_github_token e2e/cli/test_token_github
  • bun xtasks/render/schema.ts
  • pre-commit hk lint during commit

Notes

mise run render:schema blocked in bun i, so I ran bun xtasks/render/schema.ts directly. mise run render:usage failed because the locally installed usage was 2.6.0 and the generated spec needs at least 2.11 for default_subcommand; I kept the generated mise.usage.kdl update and restored the deleted docs/cli output.

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 OAuth entry in the GitHub token resolution priority.

Extends mise token github (and hidden legacy mise github token) with --oauth to force OAuth resolution and --raw for 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 the mise github ... command namespace in favor of mise token github.

Reviewed by Cursor Bugbot for commit 287fb57. Bugbot is set up for automated code reviews on this repo. Configure here.

@jdx jdx marked this pull request as ready for review May 6, 2026 14:31
@greptile-apps

greptile-apps Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds 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 GITHUB_TOKEN). All previously flagged issues have been addressed in this revision.

Confidence Score: 4/5

Safe 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

Filename Overview
src/github/oauth.rs New file: full OAuth device-flow implementation with token caching, refresh, and env-injection. Previously flagged issues (cache write errors, refresh error propagation, polling network resilience, GHE host matching, implicit device-flow prompts) are all addressed in this revision.
src/toolset/toolset_env.rs Four injection points added for inject_token_env, covering all cache-hit and fresh-compute paths; inject_token_env guards with contains_key so project-level GITHUB_TOKEN always wins.
src/cli/token/github.rs Adds --oauth and --raw flags; --raw bails non-zero when no token found; delegated to from the deprecated src/cli/github/token.rs via From impl.
src/cli/github/mod.rs Marks the entire mise github namespace deprecated in favour of mise token github.
settings.toml Adds six new github.oauth_* settings with correct docs; descriptions now reference mise token github --oauth.
e2e/fixtures/mock-github-oauth.py New minimal HTTP mock server for OAuth device-code and token-refresh endpoints.
e2e/cli/test_token_github Adds tests for no-device-flow in automatic path, --raw non-zero exit, full OAuth device flow, auto-export, custom env name, disable export, and project-level token precedence.

Reviews (15): Last reviewed commit: "[autofix.ci] apply automated fixes" | Re-trigger Greptile

Comment thread src/github/oauth.rs Outdated
Comment thread src/github/oauth.rs Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread e2e/cli/test_github_token Outdated
Comment on lines +77 to +133
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Comment thread src/github/oauth.rs Outdated
Comment on lines +284 to +295
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()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. When overriding a method, use the provided parameters instead of re-deriving their values from a more general context.

Comment thread src/github/oauth.rs Outdated
Comment on lines +334 to +346
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())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. When overriding a method, use the provided parameters instead of re-deriving their values from a more general context.

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown

Hyperfine Performance

mise x -- echo

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%

@risu729

risu729 commented May 6, 2026

Copy link
Copy Markdown
Contributor

Aren't we gonna deprecate mise github token? Sorry if it's intentional to make commands shorter.

Comment thread src/github/oauth.rs
Comment thread src/cli/github/token.rs Outdated
jdx and others added 5 commits May 7, 2026 12:11
`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>
Comment thread src/cli/token/github.rs
- 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>
@jdx jdx force-pushed the codex/native-github-oauth branch from 04fbf87 to 770dfea Compare May 7, 2026 12:19
Comment thread src/github/oauth.rs
autofix-ci Bot and others added 2 commits May 7, 2026 12:27
…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>
Comment thread src/github/oauth.rs
Comment thread src/github/oauth.rs Outdated
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>
Comment thread src/github/oauth.rs
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>
Comment thread src/github/oauth.rs
Comment thread src/github/oauth.rs
`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>
Comment thread src/github/oauth.rs Outdated
Comment thread settings.toml Outdated
Comment thread settings.toml Outdated
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>
Comment thread src/github/oauth.rs
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

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

Comment thread src/github/oauth.rs
jdx and others added 4 commits May 9, 2026 12:30
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>
@jdx jdx merged commit bc9f7e4 into main May 10, 2026
41 checks passed
@jdx jdx deleted the codex/native-github-oauth branch May 10, 2026 12:22
mise-en-dev added a commit that referenced this pull request May 11, 2026
### 🚀 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants