Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ rattler_virtual_packages = { version = "2", default-features = false }
regex = "1"
reqwest = { version = "0.13", default-features = false, features = [
"json",
"form",
"gzip",
"zstd",
"charset",
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Can also use `MISE_NO_HOOKS=1`
- [`mise test-tool [FLAGS] [TOOLS]…`](/cli/test-tool.md)
- [`mise token <SUBCOMMAND>`](/cli/token.md)
- [`mise token forgejo [--unmask] [HOST]`](/cli/token/forgejo.md)
- [`mise token github [--unmask] [HOST]`](/cli/token/github.md)
- [`mise token github [FLAGS] [HOST]`](/cli/token/github.md)
- [`mise token gitlab [--unmask] [HOST]`](/cli/token/gitlab.md)
- [`mise tool [FLAGS] <TOOL>`](/cli/tool.md)
- [`mise tool-stub <FILE> [ARGS]…`](/cli/tool-stub.md)
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/token.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ Display git provider tokens mise will use
## Subcommands

- [`mise token forgejo [--unmask] [HOST]`](/cli/token/forgejo.md)
- [`mise token github [--unmask] [HOST]`](/cli/token/github.md)
- [`mise token github [FLAGS] [HOST]`](/cli/token/github.md)
- [`mise token gitlab [--unmask] [HOST]`](/cli/token/gitlab.md)
10 changes: 9 additions & 1 deletion docs/cli/token/github.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- @generated by usage-cli from usage spec -->
# `mise token github`

- **Usage**: `mise token github [--unmask] [HOST]`
- **Usage**: `mise token github [FLAGS] [HOST]`
- **Source code**: [`src/cli/token/github.rs`](https://github.com/jdx/mise/blob/main/src/cli/token/github.rs)

GitHub token
Expand All @@ -16,6 +16,14 @@ GitHub hostname

## Flags

### `--oauth`

Resolve only via the native GitHub OAuth source (cache, refresh, or device-code flow), bypassing other token sources

### `--raw`

Print only the token value

### `--unmask`

Show the full unmasked token
Expand Down
73 changes: 58 additions & 15 deletions docs/dev-tools/github-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ mise checks the following sources in order. The first token found wins:

**github.com:**

| Priority | Source |
| -------- | ---------------------------------- |
| 1 | `MISE_GITHUB_TOKEN` env var |
| 2 | `GITHUB_API_TOKEN` env var |
| 3 | `GITHUB_TOKEN` env var |
| 4 | `credential_command` (if set) |
| 5 | `github_tokens.toml` (per-host) |
| 6 | gh CLI token (from `hosts.yml`) |
| 7 | `git credential fill` (if enabled) |
| Priority | Source |
| -------- | ----------------------------------- |
| 1 | `MISE_GITHUB_TOKEN` env var |
| 2 | `GITHUB_API_TOKEN` env var |
| 3 | `GITHUB_TOKEN` env var |
| 4 | `credential_command` (if set) |
| 5 | native GitHub OAuth (if configured) |
| 6 | `github_tokens.toml` (per-host) |
| 7 | gh CLI token (from `hosts.yml`) |
| 8 | `git credential fill` (if enabled) |

**GitHub Enterprise hosts:**

Expand All @@ -25,9 +26,10 @@ mise checks the following sources in order. The first token found wins:
| 1 | `MISE_GITHUB_ENTERPRISE_TOKEN` env var |
| 2 | `MISE_GITHUB_TOKEN` / `GITHUB_API_TOKEN` / `GITHUB_TOKEN` env vars |
| 3 | `credential_command` (if set) |
| 4 | `github_tokens.toml` (per-host) |
| 5 | gh CLI token (from `hosts.yml`, matched by hostname) |
| 6 | `git credential fill` (if enabled) |
| 4 | native GitHub OAuth (if configured) |
| 5 | `github_tokens.toml` (per-host) |
| 6 | gh CLI token (from `hosts.yml`, matched by hostname) |
| 7 | `git credential fill` (if enabled) |

::: tip
The github.com env vars (`MISE_GITHUB_TOKEN`, etc.) are also used as a fallback for GHE when `MISE_GITHUB_ENTERPRISE_TOKEN` is not set. If you need different tokens for github.com and a GHE instance, set `MISE_GITHUB_ENTERPRISE_TOKEN` explicitly or use the gh CLI integration.
Expand Down Expand Up @@ -136,6 +138,46 @@ Use `mise token github` to confirm mise can resolve the token:
mise token github
```

## Native GitHub OAuth

mise can create short-lived GitHub App user access tokens directly with GitHub's OAuth device flow. This does not require a personal access token, GitHub App private key, app client secret, `gh`, `ghtkn`, or any other external credential command.

Create a GitHub App with device flow enabled, then configure its client ID:

```sh
mise settings set github.oauth_client_id Iv1.yourgithubappclientid
```

Authorize once:

```sh
mise token github --oauth
```

After that, mise reuses the cached token for its own GitHub API calls and refreshes it when GitHub returns a refresh token. This is the best setup when you want the token used only by mise.

For general development, print a raw token and export it for other tools:

```sh
export GITHUB_TOKEN="$(mise token github --oauth --raw)"
gh pr list
```

Or use the `MISE_GITHUB_TOKEN` name if you only want child mise processes to see it:

```sh
export MISE_GITHUB_TOKEN="$(mise token github --oauth --raw)"
```

Optional settings:

```toml
[settings.github]
oauth_client_id = "Iv1.yourgithubappclientid"
oauth_scopes = "" # usually empty for GitHub App user access tokens
oauth_open_browser = true
```

## Git Credential Helpers

mise can use your existing git credential helpers to obtain GitHub tokens. This is **opt-in** and acts as a last-resort fallback after all other token sources.
Expand Down Expand Up @@ -179,9 +221,10 @@ For authentication, mise checks (in order):
1. `MISE_GITHUB_ENTERPRISE_TOKEN` env var
2. `MISE_GITHUB_TOKEN` / `GITHUB_API_TOKEN` / `GITHUB_TOKEN` env vars
3. `credential_command` for the API hostname
4. `github_tokens.toml` for the API hostname
5. gh CLI token for the API hostname
6. `git credential fill` for the API hostname
4. native GitHub OAuth for the configured API hostname
5. `github_tokens.toml` for the API hostname
6. gh CLI token for the API hostname
7. `git credential fill` for the API hostname

If you have **multiple** GHE instances, `MISE_GITHUB_ENTERPRISE_TOKEN` (a single value) won't work. Use `github_tokens.toml`, the gh CLI integration, `credential_command`, or git credential helpers instead:

Expand Down
94 changes: 0 additions & 94 deletions e2e/cli/test_github_token

This file was deleted.

28 changes: 26 additions & 2 deletions e2e/cli/test_token_github
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ unset GITHUB_TOKEN GITHUB_API_TOKEN MISE_GITHUB_TOKEN MISE_GITHUB_ENTERPRISE_TOK
# Test 1: No token configured — should return (none)
assert_contains "mise token github" "(none)"

# --raw with no token should exit non-zero (so $(...) capture fails loudly)
assert_fail "mise token github --raw"

# Test 2: GITHUB_TOKEN env var
assert_contains "GITHUB_TOKEN=env-test-token mise token github --unmask" "env-test-token"
assert_contains "GITHUB_TOKEN=env-test-token mise token github --unmask" "GITHUB_TOKEN"
Expand Down Expand Up @@ -73,7 +76,28 @@ assert_contains "GITHUB_TOKEN=env-wins MISE_GITHUB_CREDENTIAL_COMMAND='echo cred
# Test 8e: credential_command receives host as $1
assert_contains "MISE_GITHUB_CREDENTIAL_COMMAND='echo token-for-\$1' mise token github ghe.example.com --unmask" "token-for-ghe.example.com"

# Test 9: git credential fill
# Test 9a: OAuth configured but no cached token — must not prompt for device flow
# during normal token resolution (would hang on the unreachable URL otherwise).
NO_DEVICE_OAUTH="MISE_GITHUB_OAUTH_CLIENT_ID=Iv1.mock MISE_GITHUB_OAUTH_OPEN_BROWSER=false MISE_GITHUB_OAUTH_AUTH_URL=http://127.0.0.1:1/login/oauth MISE_GITHUB_OAUTH_API_URL=http://127.0.0.1:1/api"
assert_contains "$NO_DEVICE_OAUTH mise token github 127.0.0.1" "(none)"

# Test 9: native GitHub OAuth device flow via token alias
MOCK_PORT_FILE="$HOME/mock-github-oauth-port"
MOCK_GITHUB_OAUTH_PORT_FILE="$MOCK_PORT_FILE" python3 "$ROOT/e2e/fixtures/mock-github-oauth.py" &
MOCK_PID=$!
for _ in $(seq 1 30); do
if [ -f "$MOCK_PORT_FILE" ]; then
break
fi
sleep 0.1
done
MOCK_PORT="$(cat "$MOCK_PORT_FILE")"
OAUTH_ENV="MISE_GITHUB_OAUTH_CLIENT_ID=Iv1.mock MISE_GITHUB_OAUTH_OPEN_BROWSER=false MISE_GITHUB_OAUTH_AUTH_URL=http://127.0.0.1:$MOCK_PORT/login/oauth MISE_GITHUB_OAUTH_API_URL=http://127.0.0.1:$MOCK_PORT/api"
assert_contains "$OAUTH_ENV mise token github 127.0.0.1 --oauth --raw" "ghu-native-oauth-token"
assert_contains "$OAUTH_ENV mise token github 127.0.0.1 --unmask" "GitHub OAuth"
kill "$MOCK_PID"

# Test 10: git credential fill

# Create a standalone credential helper script
cat >"$HOME/git-cred-helper" <<'SCRIPT'
Expand All @@ -90,5 +114,5 @@ GIT_CRED_ENV="GIT_CONFIG_NOSYSTEM=1 MISE_GITHUB_USE_GIT_CREDENTIALS=true"
assert_contains "$GIT_CRED_ENV mise token github --unmask" "git-cred-token"
assert_contains "$GIT_CRED_ENV mise token github --unmask" "git credential fill"

# Test 10: git credential disabled via setting
# Test 11: git credential disabled via setting
assert_contains "GIT_CONFIG_NOSYSTEM=1 MISE_GITHUB_USE_GIT_CREDENTIALS=false mise token github" "(none)"
79 changes: 79 additions & 0 deletions e2e/fixtures/mock-github-oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import http.server
import json
import os
import urllib.parse


TOKEN = os.environ.get("MOCK_GITHUB_OAUTH_TOKEN", "ghu-native-oauth-token")
REFRESH_TOKEN = os.environ.get("MOCK_GITHUB_OAUTH_REFRESH_TOKEN", "ghr-native-refresh-token")
REFRESHED_TOKEN = os.environ.get(
"MOCK_GITHUB_OAUTH_REFRESHED_TOKEN", f"{TOKEN}-refreshed"
)
PORT_FILE = os.environ.get(
"MOCK_GITHUB_OAUTH_PORT_FILE",
os.path.join(os.environ["HOME"], "mock-github-oauth-port"),
)


class Handler(http.server.BaseHTTPRequestHandler):
device_token_count = 0

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":
payload = self.token_payload(form)
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 token_payload(self, form):
grant_type = form.get("grant_type", [""])[0]
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
Handler.device_token_count += 1
token = TOKEN
if Handler.device_token_count > 1:
token = f"{TOKEN}-{Handler.device_token_count}"
return {
"access_token": token,
"expires_in": 28800,
"refresh_token": REFRESH_TOKEN,
"refresh_token_expires_in": 15897600,
"token_type": "bearer",
"scope": "",
}
if grant_type == "refresh_token":
return {
"access_token": REFRESHED_TOKEN,
"expires_in": 28800,
"token_type": "bearer",
"scope": "",
}
return {"error": "unsupported_grant_type"}

def log_message(self, format, *args):
pass


server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
with open(PORT_FILE, "w") as f:
f.write(str(server.server_address[1]))
server.serve_forever()
6 changes: 6 additions & 0 deletions man/man1/mise.1
Original file line number Diff line number Diff line change
Expand Up @@ -3075,6 +3075,12 @@ GitHub token
\fBOptions:\fR
.PP
.TP
\fB\-\-oauth\fR
Resolve only via the native GitHub OAuth source (cache, refresh, or device\-code flow), bypassing other token sources
.TP
\fB\-\-raw\fR
Print only the token value
.TP
\fB\-\-unmask\fR
Show the full unmasked token
\fBArguments:\fR
Expand Down
Loading
Loading