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