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