Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions docs/cli/token/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ GitHub hostname

Print only the token value

### `--refresh`

[experimental] Mint a fresh OAuth token even if the cached one has not expired, via the refresh-token grant or a new device-code flow. Use after changing the GitHub App's installations or permissions: cached tokens keep their original access until they expire

### `--unmask`

Show the full unmasked token
Expand All @@ -39,4 +43,7 @@ github.com: ghp_xxxxxxxxxxxx (source: GITHUB_TOKEN)

$ mise token github github.mycompany.com
github.mycompany.com: (none)

$ mise token github --oauth --refresh
github.com: gho_…xxxx (source: GitHub OAuth)
```
3 changes: 3 additions & 0 deletions man/man1/mise.1
Original file line number Diff line number Diff line change
Expand Up @@ -3146,6 +3146,9 @@ GitHub token
\fB\-\-raw\fR
Print only the token value
.TP
\fB\-\-refresh\fR
[experimental] Mint a fresh OAuth token even if the cached one has not expired, via the refresh\-token grant or a new device\-code flow. Use after changing the GitHub App's installations or permissions: cached tokens keep their original access until they expire
.TP
\fB\-\-unmask\fR
Show the full unmasked token
\fBArguments:\fR
Expand Down
5 changes: 5 additions & 0 deletions mise.usage.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,7 @@ Examples:
"""#
flag --oauth help="Force native GitHub OAuth device flow instead of normal token resolution"
flag --raw help="Print only the token value"
flag --refresh help="[experimental] Mint a fresh OAuth token even if the cached one has not expired, via the refresh-token grant or a new device-code flow"
flag --unmask help="Show the full unmasked token"
arg "[HOST]" help="GitHub hostname" required=#false default=github.com
Comment on lines +1010 to 1012

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.

P2 The --refresh help string for the mise github token alias is missing the "Use after changing…" sentence that appears in the mise token github version, giving users of the alias less context about when to use the flag.

Suggested change
flag --refresh help="[experimental] Mint a fresh OAuth token even if the cached one has not expired, via the refresh-token grant or a new device-code flow"
flag --unmask help="Show the full unmasked token"
arg "[HOST]" help="GitHub hostname" required=#false default=github.com
flag --refresh help="[experimental] Mint a fresh OAuth token even if the cached one has not expired, via the refresh-token grant or a new device-code flow. Use after changing the GitHub App's installations or permissions: cached tokens keep their original access until they expire"
flag --unmask help="Show the full unmasked token"
arg "[HOST]" help="GitHub hostname" required=#false default=github.com

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

}
Expand Down Expand Up @@ -3272,9 +3273,13 @@ Examples:
$ mise token github github.mycompany.com
github.mycompany.com: (none)

$ mise token github --oauth --refresh
github.com: gho_…xxxx (source: GitHub OAuth)

"""#
flag --oauth help="[experimental] Resolve only via the native GitHub OAuth source (cache, refresh, or device-code flow), bypassing other token sources"
flag --raw help="Print only the token value"
flag --refresh help="[experimental] Mint a fresh OAuth token even if the cached one has not expired, via the refresh-token grant or a new device-code flow. Use after changing the GitHub App's installations or permissions: cached tokens keep their original access until they expire"
flag --unmask help="Show the full unmasked token"
arg "[HOST]" help="GitHub hostname" required=#false default=github.com
}
Expand Down
6 changes: 6 additions & 0 deletions src/cli/github/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ pub struct Token {
#[clap(long)]
raw: bool,

/// [experimental] Mint a fresh OAuth token even if the cached one has not
/// expired, via the refresh-token grant or a new device-code flow
#[clap(long, requires = "oauth")]
refresh: bool,

/// Show the full unmasked token
#[clap(long)]
unmask: bool,
Expand All @@ -35,6 +40,7 @@ impl From<Token> for Github {
Github {
host: t.host,
oauth: t.oauth,
refresh: t.refresh,
raw: t.raw,
unmask: t.unmask,
}
Expand Down
11 changes: 11 additions & 0 deletions src/cli/token/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ pub struct Github {
#[clap(long)]
pub(crate) raw: bool,

/// [experimental] Mint a fresh OAuth token even if the cached one has not
/// expired, via the refresh-token grant or a new device-code flow.
/// Use after changing the GitHub App's installations or permissions:
/// cached tokens keep their original access until they expire
#[clap(long, requires = "oauth")]
pub(crate) refresh: bool,

/// Show the full unmasked token
#[clap(long)]
pub(crate) unmask: bool,
Expand All @@ -34,6 +41,7 @@ impl Github {
github::oauth::token(github::oauth::TokenRequest {
host: self.host.clone(),
allow_device_flow: true,
force_refresh: self.refresh,
})?,
github::TokenSource::GithubOauth,
))
Expand Down Expand Up @@ -75,5 +83,8 @@ static AFTER_LONG_HELP: &str = color_print::cstr!(

$ <bold>mise token github github.mycompany.com</bold>
github.mycompany.com: (none)

$ <bold>mise token github --oauth --refresh</bold>
github.com: gho_…xxxx (source: GitHub OAuth)
"#
);
85 changes: 82 additions & 3 deletions src/github/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,21 @@ pub struct TokenRequest {
/// reusable cached or refreshed token is available. When false, an
/// uncached host bails instead of prompting the user.
pub allow_device_flow: bool,
/// Mint a fresh token even when the cached one is still time-valid: try
/// the refresh-token grant first, then fall back to the device flow (if
/// allowed). GitHub App user tokens are scoped to the installations that
/// existed when they were minted, so a cached token silently misses
/// permissions granted afterwards (e.g. the app was installed on a repo
/// after authorizing) until it expires hours later.
pub force_refresh: bool,
}

impl Default for TokenRequest {
fn default() -> Self {
Self {
host: "github.com".to_string(),
allow_device_flow: false,
force_refresh: false,
}
}
}
Expand Down Expand Up @@ -81,6 +89,7 @@ pub fn resolve_token(host: &str) -> Option<String> {
token(TokenRequest {
host: host.to_string(),
allow_device_flow: false,
force_refresh: false,
})
.ok()
}
Expand Down Expand Up @@ -207,20 +216,24 @@ async fn token_async(req: TokenRequest) -> Result<String> {
api_host(&settings.github.oauth_api_url).unwrap_or_else(|| req.host.clone());
let cache_key = cache_key(&canonical_host, client_id, scopes);
let mut cache = read_cache();
if let Some(cached) = cache.tokens.get(&cache_key)
if !req.force_refresh
&& let Some(cached) = cache.tokens.get(&cache_key)
&& reusable(cached)
{
return Ok(cached.access_token.clone());
}
if let Some(cached) = cache.tokens.get(&cache_key).cloned() {
match refresh_cached_token(&cache_key, None).await {
// Passing the cached token as "stale" forces the refresh-token grant
// even though the token is still time-valid.
let stale_access_token = req.force_refresh.then_some(cached.access_token.as_str());
match refresh_cached_token(&cache_key, stale_access_token).await {
Ok(Some(token)) => return Ok(token),
Ok(None) => {}
Err(err) => {
debug!("failed to refresh GitHub OAuth token: {err:#}");
}
}
if cached.expires_at > chrono::Utc::now() {
if !req.force_refresh && cached.expires_at > chrono::Utc::now() {
return Ok(cached.access_token);
}
}
Comment on lines 225 to 239

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.

P1 --refresh permanently destroys a valid cached token when no refresh token is available

When force_refresh=true, the current access token is passed as the stale_access_token argument to refresh_cached_token, which sets invalidate_on_none = true. If refresh_token then returns Ok(None) — which happens when cached.refresh_token is None or when refresh_expires_at has passed — the function overwrites the cache entry with expires_at = Utc::now() and returns Ok(None).

Back in token_async, the !req.force_refresh && cached.expires_at > ... guard is skipped (because force_refresh is true), so the still-valid in-memory cached.access_token is never returned. The code falls through to the device-code flow. If the user cancels that prompt (or it times out), token_async returns an error and the previously working token is permanently gone from disk — the user must fully re-authorize.

This matters in practice: a token issued from a GitHub App whose "Expire user authorization tokens" setting is disabled has no refresh token, so --refresh will always hit this path and destroy the valid credential.

Fix in Claude Code

Expand Down Expand Up @@ -549,11 +562,13 @@ mod tests {
"MISE_GITHUB_OAUTH_SCOPES",
std::env::var("MISE_GITHUB_OAUTH_SCOPES").ok(),
),
("MISE_EXPERIMENTAL", std::env::var("MISE_EXPERIMENTAL").ok()),
];
crate::env::set_var("MISE_GITHUB_OAUTH_CLIENT_ID", "Iv1.mock");
crate::env::set_var("MISE_GITHUB_OAUTH_AUTH_URL", auth_url);
crate::env::set_var("MISE_GITHUB_OAUTH_API_URL", "https://api.github.com");
crate::env::remove_var("MISE_GITHUB_OAUTH_SCOPES");
crate::env::set_var("MISE_EXPERIMENTAL", "1");
test_support::set_cache_path(cache_path);
Settings::reset(None);
Self { _lock: lock, vars }
Expand Down Expand Up @@ -613,4 +628,68 @@ refresh_expires_at = "2099-01-01T00:00:00Z"
assert_eq!(cached.access_token, "ghu-stale");
assert_eq!(cached.expires_at, expires_at);
}

#[tokio::test]
async fn force_refresh_mints_new_token_despite_valid_cache() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
if let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 4096];
let _ = sock.read(&mut buf).await;
let body = r#"{"access_token":"ghu-new","expires_in":28800,"refresh_token":"ghr-new","refresh_token_expires_in":15897600}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
}
});
let dir = tempfile::tempdir().unwrap();
let cache_path = dir.path().join("github-oauth-tokens.toml");
let _guard =
OAuthEnvGuard::new(format!("http://127.0.0.1:{port}/login"), cache_path.clone());

let cache_key = cache_key("api.github.com", "Iv1.mock", "");
std::fs::write(
&cache_path,
format!(
r#"[tokens.{cache_key}]
access_token = "ghu-current"
expires_at = "2099-01-01T00:00:00Z"
refresh_token = "ghr-refresh"
refresh_expires_at = "2099-01-01T00:00:00Z"
"#
),
)
.unwrap();

// Without force_refresh the time-valid cached token is reused.
let token = token_async(TokenRequest {
host: "github.com".to_string(),
allow_device_flow: false,
force_refresh: false,
})
.await
.unwrap();
assert_eq!(token, "ghu-current");

// With force_refresh the refresh-token grant mints a new token even
// though the cached one has not expired.
let token = token_async(TokenRequest {
host: "github.com".to_string(),
allow_device_flow: false,
force_refresh: true,
})
.await
.unwrap();
assert_eq!(token, "ghu-new");

let cache = read_cache();
let cached = cache.tokens.get(&cache_key).unwrap();
assert_eq!(cached.access_token, "ghu-new");
assert_eq!(cached.refresh_token.as_deref(), Some("ghr-new"));
}
}
6 changes: 6 additions & 0 deletions xtasks/fig/src/mise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3825,6 +3825,12 @@ const completionSpec: Fig.Spec = {
description: "Print only the token value",
isRepeatable: false,
},
{
name: "--refresh",
description:
"[experimental] Mint a fresh OAuth token even if the cached one has not expired, via the refresh-token grant or a new device-code flow. Use after changing the GitHub App's installations or permissions: cached tokens keep their original access until they expire",
isRepeatable: false,
},
{
name: "--unmask",
description: "Show the full unmasked token",
Expand Down
Loading