diff --git a/docs/cli/token/github.md b/docs/cli/token/github.md index 4b4f1895de..e3df09db78 100644 --- a/docs/cli/token/github.md +++ b/docs/cli/token/github.md @@ -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 @@ -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) ``` diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 58164022df..deb95ca2ef 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -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 diff --git a/mise.usage.kdl b/mise.usage.kdl index b89b66c227..5a34d99f8c 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -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 } @@ -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 } diff --git a/src/cli/github/token.rs b/src/cli/github/token.rs index eedd2b7904..91fe156a9e 100644 --- a/src/cli/github/token.rs +++ b/src/cli/github/token.rs @@ -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, @@ -35,6 +40,7 @@ impl From for Github { Github { host: t.host, oauth: t.oauth, + refresh: t.refresh, raw: t.raw, unmask: t.unmask, } diff --git a/src/cli/token/github.rs b/src/cli/token/github.rs index 1e6b4907d6..939cf4bf62 100644 --- a/src/cli/token/github.rs +++ b/src/cli/token/github.rs @@ -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, @@ -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, )) @@ -75,5 +83,8 @@ static AFTER_LONG_HELP: &str = color_print::cstr!( $ mise token github github.mycompany.com github.mycompany.com: (none) + + $ mise token github --oauth --refresh + github.com: gho_…xxxx (source: GitHub OAuth) "# ); diff --git a/src/github/oauth.rs b/src/github/oauth.rs index 8a5fdf0636..0e0ce2fb68 100644 --- a/src/github/oauth.rs +++ b/src/github/oauth.rs @@ -20,6 +20,13 @@ 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 { @@ -27,6 +34,7 @@ impl Default for TokenRequest { Self { host: "github.com".to_string(), allow_device_flow: false, + force_refresh: false, } } } @@ -81,6 +89,7 @@ pub fn resolve_token(host: &str) -> Option { token(TokenRequest { host: host.to_string(), allow_device_flow: false, + force_refresh: false, }) .ok() } @@ -207,20 +216,24 @@ async fn token_async(req: TokenRequest) -> Result { 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); } } @@ -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 } @@ -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")); + } } diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index 6f1e2ea413..4e80f81821 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -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",