1use std::process::Command;
17use std::sync::{PoisonError, RwLock};
18
19use anyhow::{Context, Result};
20#[cfg(feature = "keyring")]
21use keyring_core::Entry;
22use octocrab::Octocrab;
23#[cfg(feature = "keyring")]
24use reqwest::header::ACCEPT;
25use secrecy::{ExposeSecret, SecretString};
26use serde::Serialize;
27use tracing::{debug, info, instrument};
28
29#[cfg(feature = "keyring")]
30use super::{KEYRING_SERVICE, KEYRING_USER};
31
32static TOKEN_CACHE: RwLock<Option<(SecretString, TokenSource)>> = RwLock::new(None);
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "snake_case")]
39pub enum TokenSource {
40 Environment,
42 GhCli,
44 Keyring,
46}
47
48impl std::fmt::Display for TokenSource {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 TokenSource::Environment => write!(f, "environment variable"),
52 TokenSource::GhCli => write!(f, "GitHub CLI"),
53 TokenSource::Keyring => write!(f, "system keyring"),
54 }
55 }
56}
57
58#[cfg(feature = "keyring")]
60const OAUTH_SCOPES: &[&str] = &["public_repo", "read:user"];
61
62#[cfg(feature = "keyring")]
65pub fn keyring_init() -> crate::Result<()> {
66 #[cfg(target_os = "macos")]
67 {
68 use apple_native_keyring_store::keychain::Store;
69 let store = Store::new().map_err(|e| {
70 keyring_core::error::Error::PlatformFailure(
71 format!("Failed to initialize macOS keychain: {e}").into(),
72 )
73 })?;
74 keyring_core::set_default_store(store);
75 }
76
77 #[cfg(target_os = "linux")]
78 {
79 use linux_keyutils_keyring_store::Store;
80 let store = Store::new().map_err(|e| {
81 keyring_core::error::Error::PlatformFailure(
82 format!("Failed to initialize Linux keyutils store: {e}").into(),
83 )
84 })?;
85 keyring_core::set_default_store(store);
86 }
87
88 #[cfg(windows)]
89 {
90 use windows_native_keyring_store::Store;
91 let store = Store::new().map_err(|e| {
92 keyring_core::error::Error::PlatformFailure(
93 format!("Failed to initialize Windows credential store: {e}").into(),
94 )
95 })?;
96 keyring_core::set_default_store(store);
97 }
98
99 Ok(())
100}
101
102#[cfg(feature = "keyring")]
105pub fn keyring_deinit() {
106 keyring_core::unset_default_store();
107}
108
109#[cfg(feature = "keyring")]
111fn keyring_entry() -> Result<Entry> {
112 Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
113}
114
115#[instrument]
119#[allow(clippy::let_and_return)] pub fn is_authenticated() -> bool {
121 let result = resolve_token().is_some();
122 result
123}
124
125#[cfg(feature = "keyring")]
130#[instrument]
131#[allow(clippy::let_and_return)] pub fn has_keyring_token() -> bool {
133 let result = match keyring_entry() {
134 Ok(entry) => entry.get_password().is_ok(),
135 Err(_) => false,
136 };
137 result
138}
139
140#[cfg(feature = "keyring")]
144#[must_use]
145pub fn get_stored_token() -> Option<SecretString> {
146 let entry = keyring_entry().ok()?;
147 Some(SecretString::from(entry.get_password().ok()?))
148}
149
150fn parse_gh_cli_output(output: &std::process::Output) -> Option<SecretString> {
153 if output.status.success() {
154 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
155 if token.is_empty() {
156 debug!("gh auth token returned empty output");
157 None
158 } else {
159 debug!("Successfully retrieved token from gh CLI");
160 Some(SecretString::from(token))
161 }
162 } else {
163 let stderr = String::from_utf8_lossy(&output.stderr);
164 debug!(
165 status = ?output.status,
166 stderr = %stderr.trim(),
167 "gh auth token failed"
168 );
169 None
170 }
171}
172
173#[instrument]
181fn get_token_from_gh_cli() -> Option<SecretString> {
182 debug!("Attempting to get token from gh CLI");
183
184 let output = Command::new("gh").args(["auth", "token"]).output();
186
187 match output {
188 Ok(output) => parse_gh_cli_output(&output),
189 Err(e) => {
190 debug!(error = %e, "Failed to execute gh command");
191 None
192 }
193 }
194}
195
196fn check_env_token<F>(env_reader: &F, var_name: &str) -> Option<SecretString>
198where
199 F: Fn(&str) -> Result<String, std::env::VarError>,
200{
201 env_reader(var_name)
202 .ok()
203 .filter(|token| !token.is_empty())
204 .map(SecretString::from)
205}
206
207fn resolve_token_with_env<F>(env_reader: F) -> Option<(SecretString, TokenSource)>
224where
225 F: Fn(&str) -> Result<String, std::env::VarError>,
226{
227 let token = check_env_token(&env_reader, "GH_TOKEN");
229 if token.is_some() {
230 debug!("Using token from GH_TOKEN environment variable");
231 }
232 let result = token.map(|t| (t, TokenSource::Environment));
233
234 let result = result.or_else(|| {
236 let token = check_env_token(&env_reader, "GITHUB_TOKEN");
237 if token.is_some() {
238 debug!("Using token from GITHUB_TOKEN environment variable");
239 }
240 token.map(|t| (t, TokenSource::Environment))
241 });
242
243 let result = result.or_else(|| {
245 let token = get_token_from_gh_cli();
246 if token.is_some() {
247 debug!("Using token from GitHub CLI");
248 }
249 token.map(|t| (t, TokenSource::GhCli))
250 });
251
252 #[cfg(feature = "keyring")]
254 let result = result.or_else(|| get_stored_token().map(|t| (t, TokenSource::Keyring)));
255
256 if result.is_none() {
257 debug!("No token found in any source");
258 }
259 result
260}
261
262fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
272 resolve_token_with_env(|key| std::env::var(key))
273}
274
275#[instrument]
288pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
289 {
291 let guard = TOKEN_CACHE.read().unwrap_or_else(PoisonError::into_inner);
292 if let Some((token, source)) = guard.as_ref() {
293 debug!(source = %source, "Cache hit for token resolution");
294 return Some((token.clone(), *source));
295 }
296 }
297
298 let resolved = resolve_token_inner();
300 if let Some((token, source)) = resolved.as_ref() {
301 let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
302 *guard = Some((token.clone(), *source));
303 debug!(source = %source, "Resolved and cached token");
304 Some((token.clone(), *source))
305 } else {
306 None
307 }
308}
309
310#[cfg(feature = "keyring")]
312#[instrument(skip(token))]
313pub fn store_token(token: &SecretString) -> Result<()> {
314 let entry = keyring_entry()?;
315 entry
316 .set_password(token.expose_secret())
317 .context("Failed to store token in keyring")?;
318 info!("Token stored in system keyring");
319 Ok(())
320}
321
322#[instrument]
326pub fn clear_token_cache() {
327 let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
328 *guard = None;
329 debug!("Token cache cleared");
330}
331
332#[cfg(feature = "keyring")]
334#[instrument]
335pub fn delete_token() -> Result<()> {
336 let entry = keyring_entry()?;
337 entry
338 .delete_credential()
339 .context("Failed to delete token from keyring")?;
340 clear_token_cache();
341 info!("Token deleted from keyring");
342 Ok(())
343}
344
345#[cfg(feature = "keyring")]
355#[instrument]
356pub async fn authenticate(client_id: &SecretString) -> Result<()> {
357 debug!("Starting OAuth device flow");
358
359 let crab = Octocrab::builder()
361 .base_uri("https://github.com")
362 .context("Failed to set base URI")?
363 .add_header(ACCEPT, "application/json".to_string())
364 .build()
365 .context("Failed to build OAuth client")?;
366
367 let codes = crab
369 .authenticate_as_device(client_id, OAUTH_SCOPES)
370 .await
371 .context("Failed to request device code")?;
372
373 println!();
375 println!("To authenticate, visit:");
376 println!();
377 println!(" {}", codes.verification_uri);
378 println!();
379 println!("And enter the code:");
380 println!();
381 println!(" {}", codes.user_code);
382 println!();
383 println!("Waiting for authorization...");
384
385 let auth = codes
387 .poll_until_available(&crab, client_id)
388 .await
389 .context("Authorization failed or timed out")?;
390
391 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
393 store_token(&token)?;
394
395 info!("Authentication successful");
396 Ok(())
397}
398
399#[instrument]
406pub fn create_client() -> Result<Octocrab> {
407 let (token, source) =
408 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
409
410 info!(source = %source, "Creating GitHub client");
411
412 let client = Octocrab::builder()
413 .personal_token(token.expose_secret().to_string())
414 .build()
415 .context("Failed to build GitHub client")?;
416
417 debug!("Created authenticated GitHub client");
418 Ok(client)
419}
420
421#[instrument(skip(token))]
434pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
435 info!("Creating GitHub client with provided token");
436
437 let client = Octocrab::builder()
438 .personal_token(token.expose_secret().to_string())
439 .build()
440 .context("Failed to build GitHub client")?;
441
442 debug!("Created authenticated GitHub client");
443 Ok(client)
444}
445
446#[instrument(skip(provider))]
468pub fn create_client_from_provider(
469 provider: &dyn crate::auth::TokenProvider,
470) -> crate::Result<Octocrab> {
471 let github_token = provider
472 .github_token()
473 .ok_or(crate::error::AptuError::NotAuthenticated)?;
474
475 let token = SecretString::from(github_token);
476 create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
477 message: format!("Failed to create GitHub client: {e}"),
478 })
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[cfg(feature = "keyring")]
486 #[test]
487 fn test_keyring_entry_creation() {
488 let mock_store = keyring_core::mock::Store::new().expect("Failed to create mock store");
490 keyring_core::set_default_store(mock_store);
491
492 let result = keyring_entry();
494 assert!(result.is_ok());
495
496 keyring_core::unset_default_store();
498 }
499
500 #[test]
501 fn test_token_source_display() {
502 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
503 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
504 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
505 }
506
507 #[test]
508 fn test_token_source_equality() {
509 assert_eq!(TokenSource::Environment, TokenSource::Environment);
510 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
511 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
512 }
513
514 #[test]
515 fn test_gh_cli_not_installed_returns_none() {
516 let result = get_token_from_gh_cli();
521 let _ = result;
524 }
525
526 #[test]
527 fn test_resolve_token_with_env_var() {
528 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
530 match key {
531 "GH_TOKEN" => Ok("test_token_123".to_string()),
532 _ => Err(std::env::VarError::NotPresent),
533 }
534 };
535
536 let result = resolve_token_with_env(mock_env);
538
539 assert!(result.is_some());
541 let (token, source) = result.unwrap();
542 assert_eq!(token.expose_secret(), "test_token_123");
543 assert_eq!(source, TokenSource::Environment);
544 }
545
546 #[test]
547 fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
548 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
550 match key {
551 "GH_TOKEN" => Ok("gh_token".to_string()),
552 "GITHUB_TOKEN" => Ok("github_token".to_string()),
553 _ => Err(std::env::VarError::NotPresent),
554 }
555 };
556
557 let result = resolve_token_with_env(mock_env);
559
560 assert!(result.is_some());
562 let (token, _) = result.unwrap();
563 assert_eq!(token.expose_secret(), "gh_token");
564 }
565
566 #[test]
567 #[serial_test::serial]
568 fn test_clear_token_cache_invalidates() {
569 clear_token_cache();
571
572 let guard = TOKEN_CACHE
574 .read()
575 .unwrap_or_else(std::sync::PoisonError::into_inner);
576
577 assert!(guard.is_none());
579 }
580}