diff --git a/src/gitlab.rs b/src/gitlab.rs index 85b5f02b55..19534b991d 100644 --- a/src/gitlab.rs +++ b/src/gitlab.rs @@ -5,6 +5,7 @@ use heck::ToKebabCase; use reqwest::IntoUrl; use reqwest::header::{HeaderMap, HeaderValue}; use serde_derive::{Deserialize, Serialize}; +use serde_yaml::Value; use std::collections::HashMap; use std::fmt; use std::path::PathBuf; @@ -387,6 +388,7 @@ fn read_glab_hosts() -> Option> { return None; } }; + warn_glab_expired_tokens(&contents); match tokens::yaml_hosts_to_tokens(&contents) { Some(tokens) => Some(tokens), None => { @@ -396,6 +398,50 @@ fn read_glab_hosts() -> Option> { } } +/// Warn if any glab OAuth2 tokens are expired. +/// +/// glab stores `oauth2_expiry_date` alongside `oauth2_refresh_token`. Current glab +/// versions write RFC3339; older versions used RFC822. We only check RFC3339 since +/// that is the correct format going forward--old tokens will simply not trigger the +/// warning. mise cannot refresh OAuth2 tokens itself, so we warn the user to run a +/// glab command (e.g. `glab api user`) which will trigger a silent token refresh. +fn warn_glab_expired_tokens(contents: &str) { + for (host, expiry_str) in find_expired_glab_tokens(contents) { + warn!( + "glab OAuth2 token for {host} expired at {expiry_str}. Run a glab command (e.g. `glab api user`) to refresh it." + ); + } +} + +/// Returns `(host, expiry_str)` pairs for every glab host whose OAuth2 token is expired. +fn find_expired_glab_tokens(contents: &str) -> Vec<(String, String)> { + let Ok(yaml) = serde_yaml::from_str::(contents) else { + return vec![]; + }; + let Some(hosts) = yaml.get("hosts").and_then(Value::as_mapping) else { + return vec![]; + }; + + let mut expired = vec![]; + let now = chrono::Utc::now(); + for (k, entry) in hosts { + let Some(host) = k.as_str() else { continue }; + if entry.get("oauth2_refresh_token").is_none() { + continue; + } + let Some(expiry_str) = entry.get("oauth2_expiry_date").and_then(Value::as_str) else { + continue; + }; + let Ok(expiry_date) = chrono::DateTime::parse_from_rfc3339(expiry_str) else { + continue; + }; + if expiry_date < now { + expired.push((host.to_string(), expiry_str.to_string())); + } + } + expired +} + #[cfg(test)] mod tests { use super::*; @@ -435,4 +481,106 @@ something_else = "value" let result = tokens::parse_tokens_toml(toml).unwrap(); assert!(result.is_empty()); } + + #[test] + fn test_find_expired_glab_tokens_expired() { + let yaml = r#" +hosts: + gitlab.com: + oauth_token: gloas-abc123 + oauth2_refresh_token: refresh_token + oauth2_expiry_date: "2023-03-13T15:47:00Z" +"#; + let expired = find_expired_glab_tokens(yaml); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].0, "gitlab.com"); + assert_eq!(expired[0].1, "2023-03-13T15:47:00Z"); + } + + #[test] + fn test_find_expired_glab_tokens_not_expired() { + let yaml = r#" +hosts: + gitlab.com: + oauth_token: gloas-abc123 + oauth2_refresh_token: refresh_token + oauth2_expiry_date: "2050-01-01T00:00:00Z" +"#; + let expired = find_expired_glab_tokens(yaml); + assert!(expired.is_empty()); + } + + #[test] + fn test_find_expired_glab_tokens_no_expiry_field() { + // PATs have no expiry date--should not be flagged + let yaml = r#" +hosts: + gitlab.com: + token: glpat-abc123 +"#; + let expired = find_expired_glab_tokens(yaml); + assert!(expired.is_empty()); + } + + #[test] + fn test_find_expired_glab_tokens_multiple_hosts() { + let yaml = r#" +hosts: + gitlab.com: + oauth_token: gloas-abc123 + oauth2_refresh_token: refresh1 + oauth2_expiry_date: "2023-03-13T15:47:00Z" + gitlab.mycompany.com: + oauth_token: gloas-def456 + oauth2_refresh_token: refresh2 + oauth2_expiry_date: "2050-01-01T00:00:00Z" +"#; + let expired = find_expired_glab_tokens(yaml); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].0, "gitlab.com"); + } + + #[test] + fn test_find_expired_glab_tokens_old_format_skipped() { + // Old RFC822 format is not parsed--no false positives + let yaml = r#" +hosts: + gitlab.com: + oauth_token: gloas-abc123 + oauth2_expiry_date: "13 Mar 23 15:47 GMT" +"#; + let expired = find_expired_glab_tokens(yaml); + assert!(expired.is_empty()); + } + + #[test] + fn test_find_expired_glab_tokens_invalid_date() { + let yaml = r#" +hosts: + gitlab.com: + oauth_token: gloas-abc123 + oauth2_expiry_date: "not-a-date" +"#; + let expired = find_expired_glab_tokens(yaml); + assert!(expired.is_empty()); + } + + #[test] + fn test_find_expired_glab_tokens_no_refresh_token_skipped() { + // No oauth2_refresh_token means reauthentication is needed, not a refresh—don't warn. + let yaml = r#" +hosts: + gitlab.com: + oauth_token: gloas-abc123 + oauth2_expiry_date: "2023-03-13T15:47:00Z" +"#; + let expired = find_expired_glab_tokens(yaml); + assert!(expired.is_empty()); + } + + #[test] + fn test_find_expired_glab_tokens_empty() { + assert!(find_expired_glab_tokens("").is_empty()); + assert!(find_expired_glab_tokens("hosts: {}").is_empty()); + } }