1use anyhow::Context;
18#[cfg(test)]
19use once_cell::sync::Lazy;
20use std::env;
21#[cfg(test)]
22use std::sync::Mutex;
23use std::sync::{Arc, OnceLock};
24use std::time::{SystemTime, UNIX_EPOCH};
25use tokio::sync::RwLock;
26
27#[derive(Debug, Clone, PartialEq, Default)]
29pub enum TokenMode {
30 #[default]
38 Env,
39 GitHubApp {
41 app_id: String,
43 private_key_path: String,
45 },
46}
47
48impl TokenMode {
49 #[allow(dead_code)]
55 pub fn from_config(
56 mode: &str,
57 app_id: Option<String>,
58 private_key_path: Option<String>,
59 ) -> Self {
60 match mode {
61 "github_app" => {
62 if let (Some(app_id), Some(key_path)) = (app_id, private_key_path) {
63 TokenMode::GitHubApp {
64 app_id,
65 private_key_path: key_path,
66 }
67 } else {
68 tracing::warn!(
69 "github_app mode requires app_id and private_key_path, falling back to env"
70 );
71 TokenMode::Env
72 }
73 }
74 _ => TokenMode::Env,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81struct CachedToken {
82 token: String,
83 expires_at: Option<u64>,
85}
86
87#[derive(Debug, Clone)]
99pub struct TokenResolver {
100 mode: TokenMode,
101 allow_gh_fallback: bool,
106 cached: Arc<RwLock<Option<CachedToken>>>,
108}
109
110impl TokenResolver {
111 pub fn new(mode: TokenMode) -> Self {
116 Self {
117 mode,
118 allow_gh_fallback: true,
119 cached: Arc::new(RwLock::new(None)),
120 }
121 }
122
123 #[allow(dead_code)]
125 pub fn from_config(
126 mode_str: Option<&str>,
127 app_id: Option<String>,
128 private_key_path: Option<String>,
129 allow_gh_fallback: bool,
130 ) -> Self {
131 let mode = TokenMode::from_config(mode_str.unwrap_or("env"), app_id, private_key_path);
132 Self {
133 mode,
134 allow_gh_fallback,
135 cached: Arc::new(RwLock::new(None)),
136 }
137 }
138
139 pub fn default_env() -> Self {
141 Self::new(TokenMode::Env)
142 }
143
144 #[allow(dead_code)]
148 pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
149 self.allow_gh_fallback = enabled;
150 self
151 }
152
153 pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
159 {
161 let cached = self.cached.read().await;
162 if let Some(ref entry) = *cached {
163 if let Some(expires_at) = entry.expires_at {
165 let now = SystemTime::now()
166 .duration_since(UNIX_EPOCH)
167 .unwrap_or_default()
168 .as_secs();
169 if now < expires_at {
170 return Ok(Some(entry.token.clone()));
171 }
172 } else {
174 return Ok(Some(entry.token.clone()));
176 }
177 }
178 }
179
180 let token = self.resolve_fresh().await?;
182
183 if let Some(ref t) = token {
185 let expires_at = self.calculate_expiration();
186 let mut cached = self.cached.write().await;
187 *cached = Some(CachedToken {
188 token: t.clone(),
189 expires_at,
190 });
191 }
192
193 Ok(token)
194 }
195
196 #[allow(dead_code)]
201 pub fn get_token_sync(&self) -> anyhow::Result<Option<String>> {
202 match self.mode {
203 TokenMode::Env => {
204 if let Some(t) = Self::resolve_env_token() {
206 return Ok(Some(t));
207 }
208 if self.allow_gh_fallback {
210 return Self::resolve_gh_cli_token();
211 }
212 Ok(None)
213 }
214 TokenMode::GitHubApp { .. } => {
215 Err(anyhow::anyhow!(
217 "GitHub App token resolution requires async context, use get_token() instead"
218 ))
219 }
220 }
221 }
222
223 pub async fn clear_cache(&self) {
225 let mut cached = self.cached.write().await;
226 *cached = None;
227 }
228
229 #[allow(dead_code)]
231 pub fn mode(&self) -> &TokenMode {
232 &self.mode
233 }
234
235 async fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
237 let token = match &self.mode {
239 TokenMode::Env => Self::resolve_env_token(),
240 TokenMode::GitHubApp {
241 app_id,
242 private_key_path,
243 } => {
244 self.resolve_github_app_token(app_id, private_key_path)
245 .await?
246 }
247 };
248
249 if token.is_some() {
251 return Ok(token);
252 }
253
254 if self.allow_gh_fallback {
256 tracing::debug!("No token in env/config — trying gh auth token CLI");
257 return Self::resolve_gh_cli_token();
258 }
259
260 Ok(None)
261 }
262
263 fn resolve_env_token() -> Option<String> {
270 if let Ok(t) = env::var("GH_TOKEN") {
271 if !t.is_empty() {
272 tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
274 return Some(t);
275 }
276 }
277 if let Ok(t) = env::var("GITHUB_TOKEN") {
278 if !t.is_empty() {
279 tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
280 return Some(t);
281 }
282 }
283 if let Ok(t) = crate::config::get("gh.auth.token") {
285 if !t.is_empty() {
286 tracing::info!("Resolved GitHub token from gh.auth.token config");
287 return Some(t);
288 }
289 }
290 None
291 }
292
293 fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
295 tracing::debug!("Attempting to resolve GitHub token via gh CLI");
296
297 if let Some(token) = Self::try_gh_command("gh")? {
299 return Ok(Some(token));
300 }
301
302 for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
304 if let Some(token) = Self::try_gh_command(path)? {
305 return Ok(Some(token));
306 }
307 }
308
309 Ok(None)
310 }
311
312 fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
326 match std::process::Command::new(gh_path)
327 .args(["auth", "token"])
328 .output()
329 {
330 Ok(out) if out.status.success() => {
331 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
332 if !token.is_empty() {
333 tracing::info!(gh_path, "Resolved GitHub token via gh CLI (keychain)");
334 return Ok(Some(token));
335 }
336 }
337 Ok(out) => {
338 let stderr = String::from_utf8_lossy(&out.stderr);
339 tracing::debug!(gh_path, %stderr, "gh auth token failed");
340 }
341 Err(e) => {
342 tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
343 }
344 }
345 Ok(None)
346 }
347
348 async fn resolve_github_app_token(
350 &self,
351 app_id: &str,
352 private_key_path: &str,
353 ) -> anyhow::Result<Option<String>> {
354 tracing::debug!(app_id, private_key_path, "Generating GitHub App JWT");
355
356 let private_key = tokio::fs::read_to_string(private_key_path)
358 .await
359 .with_context(|| format!("Failed to read private key from {private_key_path}"))?;
360
361 let jwt = generate_github_app_jwt(app_id, &private_key)?;
363
364 tracing::warn!(
367 "GitHub App JWT generation is available but token exchange requires installation_id"
368 );
369
370 Ok(Some(jwt))
371 }
372
373 fn calculate_expiration(&self) -> Option<u64> {
375 match self.mode {
376 TokenMode::GitHubApp { .. } => {
378 let now = SystemTime::now()
379 .duration_since(UNIX_EPOCH)
380 .unwrap_or_default()
381 .as_secs();
382 Some(now + 540)
384 }
385 _ => None,
387 }
388 }
389}
390
391static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
397
398pub fn shared() -> Arc<TokenResolver> {
400 SHARED_RESOLVER
401 .get_or_init(|| Arc::new(TokenResolver::default_env()))
402 .clone()
403}
404
405impl Default for TokenResolver {
406 fn default() -> Self {
407 Self::default_env()
408 }
409}
410
411fn generate_github_app_jwt(app_id: &str, private_key_pem: &str) -> anyhow::Result<String> {
417 use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
418 use serde::{Deserialize, Serialize};
419
420 #[derive(Debug, Serialize, Deserialize)]
421 struct Claims {
422 iat: u64,
424 exp: u64,
426 iss: String,
428 }
429
430 let now = SystemTime::now()
431 .duration_since(UNIX_EPOCH)
432 .unwrap_or_default()
433 .as_secs();
434
435 let claims = Claims {
436 iat: now - 60, exp: now + 540, iss: app_id.to_string(),
439 };
440
441 let header = Header::new(Algorithm::RS256);
442 let encoding_key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
443 .context("Failed to parse private key PEM")?;
444
445 let token = encode(&header, &claims, &encoding_key).context("Failed to encode JWT")?;
446
447 Ok(token)
448}
449
450#[allow(dead_code)]
459pub fn create_token_resolver() -> TokenResolver {
460 let mode = crate::config::get("github.token_mode").ok();
461 let app_id = crate::config::get("github.app_id").ok();
462 let private_key_path = crate::config::get("github.private_key_path").ok();
463 let allow_gh_fallback = crate::config::get("gh.allow_gh_fallback")
465 .ok()
466 .map(|s| s != "false" && s != "0")
467 .unwrap_or(true);
468
469 TokenResolver::from_config(mode.as_deref(), app_id, private_key_path, allow_gh_fallback)
470}
471
472#[cfg(test)]
474pub(crate) static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn token_mode_from_config_env() {
482 let mode = TokenMode::from_config("env", None, None);
483 assert!(matches!(mode, TokenMode::Env));
484 }
485
486 #[test]
487 fn token_mode_from_config_unknown_defaults_to_env() {
488 let mode = TokenMode::from_config("unknown", None, None);
489 assert!(matches!(mode, TokenMode::Env));
490 }
491
492 #[test]
493 fn token_mode_from_config_github_app() {
494 let mode = TokenMode::from_config(
495 "github_app",
496 Some("123456".to_string()),
497 Some("/path/to/key.pem".to_string()),
498 );
499 assert!(matches!(mode, TokenMode::GitHubApp { .. }));
500 }
501
502 #[test]
503 fn token_mode_from_config_github_app_missing_params() {
504 let mode = TokenMode::from_config("github_app", Some("123456".to_string()), None);
506 assert!(matches!(mode, TokenMode::Env));
507
508 let mode = TokenMode::from_config("github_app", None, Some("/path/to/key.pem".to_string()));
509 assert!(matches!(mode, TokenMode::Env));
510 }
511
512 #[test]
513 fn token_resolver_default_is_env() {
514 let resolver = TokenResolver::default();
515 assert!(matches!(resolver.mode(), TokenMode::Env));
516 }
517
518 #[test]
519 fn token_resolver_new_env() {
520 let resolver = TokenResolver::new(TokenMode::Env);
521 assert!(matches!(resolver.mode(), TokenMode::Env));
522 }
523
524 #[test]
525 fn token_resolver_gh_fallback_enabled_by_default() {
526 let resolver = TokenResolver::new(TokenMode::Env);
527 assert!(resolver.allow_gh_fallback);
528 }
529
530 #[test]
531 fn token_resolver_with_gh_fallback() {
532 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
533 assert!(!resolver.allow_gh_fallback);
534 }
535
536 #[allow(clippy::await_holding_lock)]
537 #[tokio::test(flavor = "current_thread")]
538 async fn get_token_returns_none_when_no_env_set() {
539 let _guard = ENV_LOCK.lock().unwrap();
540 env::remove_var("GH_TOKEN");
541 env::remove_var("GITHUB_TOKEN");
542 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
544 resolver.clear_cache().await;
545
546 let token = resolver.get_token().await.unwrap();
547 assert!(token.is_none());
548 }
549
550 #[allow(clippy::await_holding_lock)]
551 #[tokio::test(flavor = "current_thread")]
552 async fn get_token_prefers_gh_token() {
553 let _guard = ENV_LOCK.lock().unwrap();
554 env::remove_var("GH_TOKEN");
555 env::remove_var("GITHUB_TOKEN");
556 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
557 resolver.clear_cache().await;
558
559 env::set_var("GH_TOKEN", "gh_token_value");
560 env::remove_var("GITHUB_TOKEN");
561
562 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
563 let token = resolver.get_token().await.unwrap();
564 assert_eq!(token, Some("gh_token_value".to_string()));
565
566 env::remove_var("GH_TOKEN");
567 }
568
569 #[allow(clippy::await_holding_lock)]
570 #[tokio::test(flavor = "current_thread")]
571 async fn get_token_falls_back_to_github_token() {
572 let _guard = ENV_LOCK.lock().unwrap();
573 env::remove_var("GH_TOKEN");
574 env::remove_var("GITHUB_TOKEN");
575 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
576 resolver.clear_cache().await;
577
578 env::set_var("GITHUB_TOKEN", "github_token_value");
579
580 let token = resolver.get_token().await.unwrap();
581 assert_eq!(token, Some("github_token_value".to_string()));
582
583 env::remove_var("GITHUB_TOKEN");
584 }
585
586 #[allow(clippy::await_holding_lock)]
587 #[tokio::test(flavor = "current_thread")]
588 async fn get_token_prefers_gh_token_over_github_token() {
589 let _guard = ENV_LOCK.lock().unwrap();
590 env::remove_var("GH_TOKEN");
591 env::remove_var("GITHUB_TOKEN");
592 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
593 resolver.clear_cache().await;
594
595 env::set_var("GH_TOKEN", "gh_token_value");
596 env::set_var("GITHUB_TOKEN", "github_token_value");
597
598 let token = resolver.get_token().await.unwrap();
599 assert_eq!(token, Some("gh_token_value".to_string()));
601
602 env::remove_var("GH_TOKEN");
603 env::remove_var("GITHUB_TOKEN");
604 }
605
606 #[allow(clippy::await_holding_lock)]
607 #[tokio::test(flavor = "current_thread")]
608 async fn get_token_ignores_empty_env_vars() {
609 let _guard = ENV_LOCK.lock().unwrap();
610 env::remove_var("GH_TOKEN");
611 env::remove_var("GITHUB_TOKEN");
612 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
613 resolver.clear_cache().await;
614
615 env::set_var("GH_TOKEN", "");
616 env::set_var("GITHUB_TOKEN", "");
617
618 let token = resolver.get_token().await.unwrap();
619 assert!(token.is_none());
620
621 env::remove_var("GH_TOKEN");
622 env::remove_var("GITHUB_TOKEN");
623 }
624
625 #[allow(clippy::await_holding_lock)]
626 #[tokio::test(flavor = "current_thread")]
627 async fn get_token_sync_env_mode() {
628 let _guard = ENV_LOCK.lock().unwrap();
629 env::remove_var("GH_TOKEN");
630 env::remove_var("GITHUB_TOKEN");
631 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
632 resolver.clear_cache().await;
633
634 env::set_var("GH_TOKEN", "sync_test_token");
635
636 let token = resolver.get_token_sync().unwrap();
637 assert_eq!(token, Some("sync_test_token".to_string()));
638
639 env::remove_var("GH_TOKEN");
640 }
641
642 #[test]
643 fn get_token_sync_github_app_requires_async() {
644 let resolver = TokenResolver::new(TokenMode::GitHubApp {
645 app_id: "123".to_string(),
646 private_key_path: "/path/to/key.pem".to_string(),
647 });
648
649 let result = resolver.get_token_sync();
650 assert!(result.is_err());
651 assert!(result.unwrap_err().to_string().contains("async"));
652 }
653
654 #[allow(clippy::await_holding_lock)]
655 #[tokio::test(flavor = "current_thread")]
656 async fn clear_cache_clears_token() {
657 let _guard = ENV_LOCK.lock().unwrap();
658 env::remove_var("GH_TOKEN");
659 env::remove_var("GITHUB_TOKEN");
660 let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
661 resolver.clear_cache().await;
662
663 env::set_var("GH_TOKEN", "cached_token");
664
665 let token1 = resolver.get_token().await.unwrap();
667 assert_eq!(token1, Some("cached_token".to_string()));
668
669 resolver.clear_cache().await;
671
672 let token2 = resolver.get_token().await.unwrap();
674 assert_eq!(token2, Some("cached_token".to_string()));
675
676 env::remove_var("GH_TOKEN");
677 }
678
679 #[test]
680 fn resolve_env_token_returns_none_when_unset() {
681 let _guard = ENV_LOCK.lock().unwrap();
682 env::remove_var("GH_TOKEN");
683 env::remove_var("GITHUB_TOKEN");
684
685 let token = TokenResolver::resolve_env_token();
686 assert!(token.is_none());
687 }
688
689 #[test]
690 fn resolve_env_token_prefers_gh_token() {
691 let _guard = ENV_LOCK.lock().unwrap();
692 env::set_var("GH_TOKEN", "gh_pref");
693 env::set_var("GITHUB_TOKEN", "github_pref");
694
695 let token = TokenResolver::resolve_env_token();
696 assert_eq!(token, Some("gh_pref".to_string()));
697
698 env::remove_var("GH_TOKEN");
699 env::remove_var("GITHUB_TOKEN");
700 }
701}