1#[cfg(not(target_arch = "wasm32"))]
17use std::process::Command;
18use std::sync::{PoisonError, RwLock};
19
20use anyhow::{Context, Result};
21#[cfg(feature = "keyring")]
22use keyring_core::Entry;
23#[cfg(not(target_arch = "wasm32"))]
24use octocrab::Octocrab;
25#[cfg(feature = "keyring")]
26use reqwest::header::ACCEPT;
27use secrecy::{ExposeSecret, SecretString};
28use serde::Serialize;
29use tracing::{debug, info, instrument};
30
31#[cfg(feature = "keyring")]
32use super::{KEYRING_SERVICE, KEYRING_USER};
33
34static TOKEN_CACHE: RwLock<Option<(SecretString, TokenSource)>> = RwLock::new(None);
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum TokenSource {
42 Environment,
44 GhCli,
46 Keyring,
48}
49
50impl std::fmt::Display for TokenSource {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 TokenSource::Environment => write!(f, "environment variable"),
54 TokenSource::GhCli => write!(f, "GitHub CLI"),
55 TokenSource::Keyring => write!(f, "system keyring"),
56 }
57 }
58}
59
60#[cfg(feature = "keyring")]
62const OAUTH_SCOPES: &[&str] = &["public_repo", "read:user"];
63
64#[cfg(feature = "keyring")]
67pub fn keyring_init() -> crate::Result<()> {
68 #[cfg(target_os = "macos")]
69 {
70 use apple_native_keyring_store::keychain::Store;
71 let store = Store::new().map_err(|e| {
72 keyring_core::error::Error::PlatformFailure(
73 format!("Failed to initialize macOS keychain: {e}").into(),
74 )
75 })?;
76 keyring_core::set_default_store(store);
77 }
78
79 #[cfg(target_os = "linux")]
80 {
81 use linux_keyutils_keyring_store::Store;
82 let store = Store::new().map_err(|e| {
83 keyring_core::error::Error::PlatformFailure(
84 format!("Failed to initialize Linux keyutils store: {e}").into(),
85 )
86 })?;
87 keyring_core::set_default_store(store);
88 }
89
90 #[cfg(windows)]
91 {
92 use windows_native_keyring_store::Store;
93 let store = Store::new().map_err(|e| {
94 keyring_core::error::Error::PlatformFailure(
95 format!("Failed to initialize Windows credential store: {e}").into(),
96 )
97 })?;
98 keyring_core::set_default_store(store);
99 }
100
101 Ok(())
102}
103
104#[cfg(feature = "keyring")]
107pub fn keyring_deinit() {
108 keyring_core::unset_default_store();
109}
110
111#[cfg(feature = "keyring")]
113fn keyring_entry() -> Result<Entry> {
114 Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
115}
116
117#[instrument]
121#[allow(clippy::let_and_return)] pub fn is_authenticated() -> bool {
123 let result = resolve_token().is_some();
124 result
125}
126
127#[cfg(feature = "keyring")]
132#[instrument]
133#[allow(clippy::let_and_return)] pub fn has_keyring_token() -> bool {
135 let result = match keyring_entry() {
136 Ok(entry) => entry.get_password().is_ok(),
137 Err(_) => false,
138 };
139 result
140}
141
142#[cfg(feature = "keyring")]
146#[must_use]
147pub fn get_stored_token() -> Option<SecretString> {
148 let entry = keyring_entry().ok()?;
149 Some(SecretString::from(entry.get_password().ok()?))
150}
151
152fn parse_gh_cli_output(output: &std::process::Output) -> Option<SecretString> {
155 if output.status.success() {
156 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
157 if token.is_empty() {
158 debug!("gh auth token returned empty output");
159 None
160 } else {
161 debug!("Successfully retrieved token from gh CLI");
162 Some(SecretString::from(token))
163 }
164 } else {
165 let stderr = String::from_utf8_lossy(&output.stderr);
166 debug!(
167 status = ?output.status,
168 stderr = %stderr.trim(),
169 "gh auth token failed"
170 );
171 None
172 }
173}
174
175#[cfg(not(target_arch = "wasm32"))]
183#[instrument]
184fn get_token_from_gh_cli() -> Option<SecretString> {
185 debug!("Attempting to get token from gh CLI");
186
187 let output = Command::new("gh").args(["auth", "token"]).output();
189
190 match output {
191 Ok(output) => parse_gh_cli_output(&output),
192 Err(e) => {
193 debug!(error = %e, "Failed to execute gh command");
194 None
195 }
196 }
197}
198
199fn check_env_token<F>(env_reader: &F, var_name: &str) -> Option<SecretString>
201where
202 F: Fn(&str) -> Result<String, std::env::VarError>,
203{
204 env_reader(var_name)
205 .ok()
206 .filter(|token| !token.is_empty())
207 .map(SecretString::from)
208}
209
210fn resolve_token_with_env<F>(env_reader: F) -> Option<(SecretString, TokenSource)>
227where
228 F: Fn(&str) -> Result<String, std::env::VarError>,
229{
230 let token = check_env_token(&env_reader, "GH_TOKEN");
232 if token.is_some() {
233 debug!("Using token from GH_TOKEN environment variable");
234 }
235 let result = token.map(|t| (t, TokenSource::Environment));
236
237 let result = result.or_else(|| {
239 let token = check_env_token(&env_reader, "GITHUB_TOKEN");
240 if token.is_some() {
241 debug!("Using token from GITHUB_TOKEN environment variable");
242 }
243 token.map(|t| (t, TokenSource::Environment))
244 });
245
246 #[cfg(not(target_arch = "wasm32"))]
248 let result = result.or_else(|| {
249 let token = get_token_from_gh_cli();
250 if token.is_some() {
251 debug!("Using token from GitHub CLI");
252 }
253 token.map(|t| (t, TokenSource::GhCli))
254 });
255
256 #[cfg(feature = "keyring")]
258 let result = result.or_else(|| get_stored_token().map(|t| (t, TokenSource::Keyring)));
259
260 if result.is_none() {
261 debug!("No token found in any source");
262 }
263 result
264}
265
266fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
276 resolve_token_with_env(|key| std::env::var(key))
277}
278
279#[instrument]
292pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
293 {
295 let guard = TOKEN_CACHE.read().unwrap_or_else(PoisonError::into_inner);
296 if let Some((token, source)) = guard.as_ref() {
297 debug!(source = %source, "Cache hit for token resolution");
298 return Some((token.clone(), *source));
299 }
300 }
301
302 let resolved = resolve_token_inner();
304 if let Some((token, source)) = resolved.as_ref() {
305 let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
306 *guard = Some((token.clone(), *source));
307 debug!(source = %source, "Resolved and cached token");
308 Some((token.clone(), *source))
309 } else {
310 None
311 }
312}
313
314#[cfg(feature = "keyring")]
316#[instrument(skip(token))]
317pub fn store_token(token: &SecretString) -> Result<()> {
318 let entry = keyring_entry()?;
319 entry
320 .set_password(token.expose_secret())
321 .context("Failed to store token in keyring")?;
322 info!("Token stored in system keyring");
323 Ok(())
324}
325
326#[instrument]
330pub fn clear_token_cache() {
331 let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
332 *guard = None;
333 debug!("Token cache cleared");
334}
335
336#[cfg(feature = "keyring")]
338#[instrument]
339pub fn delete_token() -> Result<()> {
340 let entry = keyring_entry()?;
341 entry
342 .delete_credential()
343 .context("Failed to delete token from keyring")?;
344 clear_token_cache();
345 info!("Token deleted from keyring");
346 Ok(())
347}
348
349#[cfg(feature = "keyring")]
359#[instrument]
360pub async fn authenticate(client_id: &SecretString) -> Result<()> {
361 debug!("Starting OAuth device flow");
362
363 let crab = Octocrab::builder()
365 .base_uri("https://github.com")
366 .context("Failed to set base URI")?
367 .add_header(ACCEPT, "application/json".to_string())
368 .build()
369 .context("Failed to build OAuth client")?;
370
371 let codes = crab
373 .authenticate_as_device(client_id, OAUTH_SCOPES)
374 .await
375 .context("Failed to request device code")?;
376
377 println!();
379 println!("To authenticate, visit:");
380 println!();
381 println!(" {}", codes.verification_uri);
382 println!();
383 println!("And enter the code:");
384 println!();
385 println!(" {}", codes.user_code);
386 println!();
387 println!("Waiting for authorization...");
388
389 let auth = codes
391 .poll_until_available(&crab, client_id)
392 .await
393 .context("Authorization failed or timed out")?;
394
395 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
397 store_token(&token)?;
398
399 info!("Authentication successful");
400 Ok(())
401}
402
403#[cfg(not(target_arch = "wasm32"))]
410#[instrument]
411pub fn create_client() -> Result<Octocrab> {
412 let (token, source) =
413 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
414
415 info!(source = %source, "Creating GitHub client");
416
417 let client = Octocrab::builder()
418 .personal_token(token.expose_secret().to_string())
419 .build()
420 .context("Failed to build GitHub client")?;
421
422 debug!("Created authenticated GitHub client");
423 Ok(client)
424}
425
426#[cfg(not(target_arch = "wasm32"))]
439#[instrument(skip(token))]
440pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
441 info!("Creating GitHub client with provided token");
442
443 let client = Octocrab::builder()
444 .personal_token(token.expose_secret().to_string())
445 .build()
446 .context("Failed to build GitHub client")?;
447
448 debug!("Created authenticated GitHub client");
449 Ok(client)
450}
451
452#[cfg(not(target_arch = "wasm32"))]
474#[instrument(skip(provider))]
475pub fn create_client_from_provider(
476 provider: &dyn crate::auth::TokenProvider,
477) -> crate::Result<Octocrab> {
478 let github_token = provider
479 .github_token()
480 .ok_or(crate::error::AptuError::NotAuthenticated)?;
481
482 let token = SecretString::from(github_token);
483 create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
484 message: format!("Failed to create GitHub client: {e}"),
485 })
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[cfg(feature = "keyring")]
493 #[test]
494 fn test_keyring_entry_creation() {
495 let mock_store = keyring_core::mock::Store::new().expect("Failed to create mock store");
497 keyring_core::set_default_store(mock_store);
498
499 let result = keyring_entry();
501 assert!(result.is_ok());
502
503 keyring_core::unset_default_store();
505 }
506
507 #[test]
508 fn test_token_source_display() {
509 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
510 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
511 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
512 }
513
514 #[test]
515 fn test_token_source_equality() {
516 assert_eq!(TokenSource::Environment, TokenSource::Environment);
517 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
518 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
519 }
520
521 #[test]
522 #[cfg(not(target_arch = "wasm32"))]
523 fn test_gh_cli_not_installed_returns_none() {
524 let result = get_token_from_gh_cli();
529 let _ = result;
532 }
533
534 #[test]
535 fn test_resolve_token_with_env_var() {
536 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
538 match key {
539 "GH_TOKEN" => Ok("test_token_123".to_string()),
540 _ => Err(std::env::VarError::NotPresent),
541 }
542 };
543
544 let result = resolve_token_with_env(mock_env);
546
547 assert!(result.is_some());
549 let (token, source) = result.unwrap();
550 assert_eq!(token.expose_secret(), "test_token_123");
551 assert_eq!(source, TokenSource::Environment);
552 }
553
554 #[test]
555 fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
556 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
558 match key {
559 "GH_TOKEN" => Ok("gh_token".to_string()),
560 "GITHUB_TOKEN" => Ok("github_token".to_string()),
561 _ => Err(std::env::VarError::NotPresent),
562 }
563 };
564
565 let result = resolve_token_with_env(mock_env);
567
568 assert!(result.is_some());
570 let (token, _) = result.unwrap();
571 assert_eq!(token.expose_secret(), "gh_token");
572 }
573
574 #[test]
575 #[serial_test::serial]
576 fn test_clear_token_cache_invalidates() {
577 clear_token_cache();
579
580 let guard = TOKEN_CACHE
582 .read()
583 .unwrap_or_else(std::sync::PoisonError::into_inner);
584
585 assert!(guard.is_none());
587 }
588}