Skip to main content

orch/github/
token.rs

1//! Centralized GitHub token resolution from env vars, config, and the gh CLI.
2//!
3//! Out of the box, `TokenResolver::default()` works with just `gh auth login`, no extra config needed.
4//! Resolution order:
5//! 1. `GH_TOKEN` environment variable
6//! 2. `GITHUB_TOKEN` environment variable
7//! 3. `gh.auth.token` config value
8//! 4. `gh auth token` CLI (via `allow_gh_fallback = true`)
9
10#[cfg(test)]
11use once_cell::sync::Lazy;
12use std::env;
13#[cfg(test)]
14use std::sync::Mutex;
15use std::sync::{Arc, OnceLock};
16use tokio::sync::RwLock;
17
18/// Centralized token resolver for GitHub authentication.
19///
20/// ## Default setup
21///
22/// ```rust,ignore
23/// let resolver = TokenResolver::default(); // uses gh auth token if no env var set
24/// let token = resolver.get_token().await?;
25/// ```
26#[derive(Debug, Clone)]
27pub struct TokenResolver {
28    /// Whether to fall back to `gh auth token` CLI when env vars and config are unset.
29    ///
30    /// Defaults to `true` — just run `gh auth login` and everything works.
31    allow_gh_fallback: bool,
32    /// Cached token, kept until `clear_cache` drops it.
33    cached: Arc<RwLock<Option<String>>>,
34}
35
36impl TokenResolver {
37    /// Create a default TokenResolver using environment variables with gh CLI fallback.
38    pub fn default_env() -> Self {
39        Self {
40            allow_gh_fallback: true,
41            cached: Arc::new(RwLock::new(None)),
42        }
43    }
44
45    /// Enable or disable `gh auth token` CLI fallback.
46    #[allow(dead_code)]
47    pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
48        self.allow_gh_fallback = enabled;
49        self
50    }
51
52    /// Resolve the GitHub token asynchronously.
53    ///
54    /// Returns `Ok(Some(token))` if a token is available,
55    /// `Ok(None)` if no token could be resolved,
56    /// or `Err` if an error occurred during resolution.
57    pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
58        if let Some(ref t) = *self.cached.read().await {
59            return Ok(Some(t.clone()));
60        }
61
62        let token = self.resolve_fresh()?;
63        if let Some(ref t) = token {
64            *self.cached.write().await = Some(t.clone());
65        }
66
67        Ok(token)
68    }
69
70    /// Clear the cached token (useful when token is known to be invalid).
71    pub async fn clear_cache(&self) {
72        let mut cached = self.cached.write().await;
73        *cached = None;
74    }
75
76    /// Resolve a fresh token: env vars and config first, then the gh CLI.
77    fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
78        if let Some(t) = Self::resolve_env_token() {
79            return Ok(Some(t));
80        }
81
82        // gh CLI fallback — the default when nothing else is configured
83        if self.allow_gh_fallback {
84            tracing::debug!("No token in env/config — trying gh auth token CLI");
85            return Self::resolve_gh_cli_token();
86        }
87
88        Ok(None)
89    }
90
91    /// Resolve token from environment variables and config.
92    ///
93    /// Checks (in order):
94    /// 1. `GH_TOKEN` env var
95    /// 2. `GITHUB_TOKEN` env var
96    /// 3. `gh.auth.token` config value
97    fn resolve_env_token() -> Option<String> {
98        if let Ok(t) = env::var("GH_TOKEN") {
99            if !t.is_empty() {
100                // Info, not debug: when a token turns out bad, the source is the first thing to check
101                tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
102                return Some(t);
103            }
104        }
105        if let Ok(t) = env::var("GITHUB_TOKEN") {
106            if !t.is_empty() {
107                tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
108                return Some(t);
109            }
110        }
111        // Config-based token (explicit override in ~/.orch/config.yml)
112        if let Ok(t) = crate::config::get("gh.auth.token") {
113            if !t.is_empty() {
114                tracing::info!("Resolved GitHub token from gh.auth.token config");
115                return Some(t);
116            }
117        }
118        None
119    }
120
121    /// Resolve token using `gh auth token` CLI.
122    fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
123        tracing::debug!("Attempting to resolve GitHub token via gh CLI");
124
125        // Try gh in PATH first
126        if let Some(token) = Self::try_gh_command("gh")? {
127            return Ok(Some(token));
128        }
129
130        // Try common gh install paths for launchd/Homebrew environments
131        for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
132            if let Some(token) = Self::try_gh_command(path)? {
133                return Ok(Some(token));
134            }
135        }
136
137        Ok(None)
138    }
139
140    /// Try to get token from a specific gh binary path.
141    ///
142    /// # Why `std::process::Command` (blocking) is intentional here
143    ///
144    /// This looks like a "blocking call in async context" antipattern, but it is not a problem:
145    /// - `resolve_fresh()` is only called from `get_token()` on a cache miss
146    /// - The result is cached until a 401 clears it, so this runs once at startup
147    ///   and again after each 401
148    /// - A one-time ~100ms call does not meaningfully block the Tokio runtime
149    ///
150    /// DO NOT replace this with `spawn_blocking` or `tokio::process::Command` —
151    /// the added complexity is not justified and previous attempts to "fix" this
152    /// broke the auth flow. See issues #418 and #421 (closed as invalid).
153    fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
154        match std::process::Command::new(gh_path)
155            .args(["auth", "token"])
156            .output()
157        {
158            Ok(out) if out.status.success() => {
159                let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
160                if !token.is_empty() {
161                    tracing::info!(gh_path, "Resolved GitHub token via gh CLI (keychain)");
162                    return Ok(Some(token));
163                }
164            }
165            Ok(out) => {
166                let stderr = String::from_utf8_lossy(&out.stderr);
167                tracing::debug!(gh_path, %stderr, "gh auth token failed");
168            }
169            Err(e) => {
170                tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
171            }
172        }
173        Ok(None)
174    }
175}
176
177/// Process-wide shared [`TokenResolver`].
178///
179/// Initialized once on first call. All callers (GhHttp, agent runner, etc.)
180/// share the same instance so the token is resolved once — via GH_TOKEN,
181/// GITHUB_TOKEN, or `gh auth token` CLI — and cached for the life of the process.
182static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
183
184/// Get the process-wide shared token resolver.
185pub fn shared() -> Arc<TokenResolver> {
186    SHARED_RESOLVER
187        .get_or_init(|| Arc::new(TokenResolver::default_env()))
188        .clone()
189}
190
191impl Default for TokenResolver {
192    fn default() -> Self {
193        Self::default_env()
194    }
195}
196
197/// Serializes tests that mutate `GH_TOKEN`/`GITHUB_TOKEN`, shared with http.rs tests
198#[cfg(test)]
199pub(crate) static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn token_resolver_gh_fallback_enabled_by_default() {
207        let resolver = TokenResolver::default_env();
208        assert!(resolver.allow_gh_fallback);
209    }
210
211    #[test]
212    fn token_resolver_with_gh_fallback() {
213        let resolver = TokenResolver::default_env().with_gh_fallback(false);
214        assert!(!resolver.allow_gh_fallback);
215    }
216
217    #[allow(clippy::await_holding_lock)]
218    #[tokio::test(flavor = "current_thread")]
219    async fn get_token_returns_none_when_no_env_set() {
220        let _guard = ENV_LOCK.lock().unwrap();
221        env::remove_var("GH_TOKEN");
222        env::remove_var("GITHUB_TOKEN");
223        // Disable gh fallback so we don't actually call gh CLI in tests
224        let resolver = TokenResolver::default_env().with_gh_fallback(false);
225        resolver.clear_cache().await;
226
227        let token = resolver.get_token().await.unwrap();
228        assert!(token.is_none());
229    }
230
231    #[allow(clippy::await_holding_lock)]
232    #[tokio::test(flavor = "current_thread")]
233    async fn get_token_prefers_gh_token() {
234        let _guard = ENV_LOCK.lock().unwrap();
235        env::remove_var("GH_TOKEN");
236        env::remove_var("GITHUB_TOKEN");
237        let resolver = TokenResolver::default_env().with_gh_fallback(false);
238        resolver.clear_cache().await;
239
240        env::set_var("GH_TOKEN", "gh_token_value");
241        env::remove_var("GITHUB_TOKEN");
242
243        let resolver = TokenResolver::default_env().with_gh_fallback(false);
244        let token = resolver.get_token().await.unwrap();
245        assert_eq!(token, Some("gh_token_value".to_string()));
246
247        env::remove_var("GH_TOKEN");
248    }
249
250    #[allow(clippy::await_holding_lock)]
251    #[tokio::test(flavor = "current_thread")]
252    async fn get_token_falls_back_to_github_token() {
253        let _guard = ENV_LOCK.lock().unwrap();
254        env::remove_var("GH_TOKEN");
255        env::remove_var("GITHUB_TOKEN");
256        let resolver = TokenResolver::default_env().with_gh_fallback(false);
257        resolver.clear_cache().await;
258
259        env::set_var("GITHUB_TOKEN", "github_token_value");
260
261        let token = resolver.get_token().await.unwrap();
262        assert_eq!(token, Some("github_token_value".to_string()));
263
264        env::remove_var("GITHUB_TOKEN");
265    }
266
267    #[allow(clippy::await_holding_lock)]
268    #[tokio::test(flavor = "current_thread")]
269    async fn get_token_prefers_gh_token_over_github_token() {
270        let _guard = ENV_LOCK.lock().unwrap();
271        env::remove_var("GH_TOKEN");
272        env::remove_var("GITHUB_TOKEN");
273        let resolver = TokenResolver::default_env().with_gh_fallback(false);
274        resolver.clear_cache().await;
275
276        env::set_var("GH_TOKEN", "gh_token_value");
277        env::set_var("GITHUB_TOKEN", "github_token_value");
278
279        let token = resolver.get_token().await.unwrap();
280        // GH_TOKEN should be preferred
281        assert_eq!(token, Some("gh_token_value".to_string()));
282
283        env::remove_var("GH_TOKEN");
284        env::remove_var("GITHUB_TOKEN");
285    }
286
287    #[allow(clippy::await_holding_lock)]
288    #[tokio::test(flavor = "current_thread")]
289    async fn get_token_ignores_empty_env_vars() {
290        let _guard = ENV_LOCK.lock().unwrap();
291        env::remove_var("GH_TOKEN");
292        env::remove_var("GITHUB_TOKEN");
293        let resolver = TokenResolver::default_env().with_gh_fallback(false);
294        resolver.clear_cache().await;
295
296        env::set_var("GH_TOKEN", "");
297        env::set_var("GITHUB_TOKEN", "");
298
299        let token = resolver.get_token().await.unwrap();
300        assert!(token.is_none());
301
302        env::remove_var("GH_TOKEN");
303        env::remove_var("GITHUB_TOKEN");
304    }
305
306    #[allow(clippy::await_holding_lock)]
307    #[tokio::test(flavor = "current_thread")]
308    async fn clear_cache_clears_token() {
309        let _guard = ENV_LOCK.lock().unwrap();
310        env::remove_var("GH_TOKEN");
311        env::remove_var("GITHUB_TOKEN");
312        let resolver = TokenResolver::default_env().with_gh_fallback(false);
313        resolver.clear_cache().await;
314
315        env::set_var("GH_TOKEN", "cached_token");
316
317        // First call should cache the token
318        let token1 = resolver.get_token().await.unwrap();
319        assert_eq!(token1, Some("cached_token".to_string()));
320
321        // Clear cache
322        resolver.clear_cache().await;
323
324        // Token should still resolve (fresh lookup)
325        let token2 = resolver.get_token().await.unwrap();
326        assert_eq!(token2, Some("cached_token".to_string()));
327
328        env::remove_var("GH_TOKEN");
329    }
330
331    #[test]
332    fn resolve_env_token_returns_none_when_unset() {
333        let _guard = ENV_LOCK.lock().unwrap();
334        env::remove_var("GH_TOKEN");
335        env::remove_var("GITHUB_TOKEN");
336
337        let token = TokenResolver::resolve_env_token();
338        assert!(token.is_none());
339    }
340
341    #[test]
342    fn resolve_env_token_prefers_gh_token() {
343        let _guard = ENV_LOCK.lock().unwrap();
344        env::set_var("GH_TOKEN", "gh_pref");
345        env::set_var("GITHUB_TOKEN", "github_pref");
346
347        let token = TokenResolver::resolve_env_token();
348        assert_eq!(token, Some("gh_pref".to_string()));
349
350        env::remove_var("GH_TOKEN");
351        env::remove_var("GITHUB_TOKEN");
352    }
353}