1use std::process::Command;
17use std::sync::{PoisonError, RwLock};
18
19use anyhow::{Context, Result};
20#[cfg(feature = "keyring")]
21use keyring::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")]
64fn keyring_entry() -> Result<Entry> {
65 Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
66}
67
68#[instrument]
72#[allow(clippy::let_and_return)] pub fn is_authenticated() -> bool {
74 let result = resolve_token().is_some();
75 result
76}
77
78#[cfg(feature = "keyring")]
83#[instrument]
84#[allow(clippy::let_and_return)] pub fn has_keyring_token() -> bool {
86 let result = match keyring_entry() {
87 Ok(entry) => entry.get_password().is_ok(),
88 Err(_) => false,
89 };
90 result
91}
92
93#[cfg(feature = "keyring")]
97#[must_use]
98pub fn get_stored_token() -> Option<SecretString> {
99 let entry = keyring_entry().ok()?;
100 Some(SecretString::from(entry.get_password().ok()?))
101}
102
103fn parse_gh_cli_output(output: &std::process::Output) -> Option<SecretString> {
106 if output.status.success() {
107 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
108 if token.is_empty() {
109 debug!("gh auth token returned empty output");
110 None
111 } else {
112 debug!("Successfully retrieved token from gh CLI");
113 Some(SecretString::from(token))
114 }
115 } else {
116 let stderr = String::from_utf8_lossy(&output.stderr);
117 debug!(
118 status = ?output.status,
119 stderr = %stderr.trim(),
120 "gh auth token failed"
121 );
122 None
123 }
124}
125
126#[instrument]
134fn get_token_from_gh_cli() -> Option<SecretString> {
135 debug!("Attempting to get token from gh CLI");
136
137 let output = Command::new("gh").args(["auth", "token"]).output();
139
140 match output {
141 Ok(output) => parse_gh_cli_output(&output),
142 Err(e) => {
143 debug!(error = %e, "Failed to execute gh command");
144 None
145 }
146 }
147}
148
149fn check_env_token<F>(env_reader: &F, var_name: &str) -> Option<SecretString>
151where
152 F: Fn(&str) -> Result<String, std::env::VarError>,
153{
154 env_reader(var_name)
155 .ok()
156 .filter(|token| !token.is_empty())
157 .map(SecretString::from)
158}
159
160fn resolve_token_with_env<F>(env_reader: F) -> Option<(SecretString, TokenSource)>
177where
178 F: Fn(&str) -> Result<String, std::env::VarError>,
179{
180 let token = check_env_token(&env_reader, "GH_TOKEN");
182 if token.is_some() {
183 debug!("Using token from GH_TOKEN environment variable");
184 }
185 let result = token.map(|t| (t, TokenSource::Environment));
186
187 let result = result.or_else(|| {
189 let token = check_env_token(&env_reader, "GITHUB_TOKEN");
190 if token.is_some() {
191 debug!("Using token from GITHUB_TOKEN environment variable");
192 }
193 token.map(|t| (t, TokenSource::Environment))
194 });
195
196 let result = result.or_else(|| {
198 let token = get_token_from_gh_cli();
199 if token.is_some() {
200 debug!("Using token from GitHub CLI");
201 }
202 token.map(|t| (t, TokenSource::GhCli))
203 });
204
205 #[cfg(feature = "keyring")]
207 let result = result.or_else(|| get_stored_token().map(|t| (t, TokenSource::Keyring)));
208
209 if result.is_none() {
210 debug!("No token found in any source");
211 }
212 result
213}
214
215fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
225 resolve_token_with_env(|key| std::env::var(key))
226}
227
228#[instrument]
241pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
242 {
244 let guard = TOKEN_CACHE.read().unwrap_or_else(PoisonError::into_inner);
245 if let Some((token, source)) = guard.as_ref() {
246 debug!(source = %source, "Cache hit for token resolution");
247 return Some((token.clone(), *source));
248 }
249 }
250
251 let resolved = resolve_token_inner();
253 if let Some((token, source)) = resolved.as_ref() {
254 let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
255 *guard = Some((token.clone(), *source));
256 debug!(source = %source, "Resolved and cached token");
257 Some((token.clone(), *source))
258 } else {
259 None
260 }
261}
262
263#[cfg(feature = "keyring")]
265#[instrument(skip(token))]
266pub fn store_token(token: &SecretString) -> Result<()> {
267 let entry = keyring_entry()?;
268 entry
269 .set_password(token.expose_secret())
270 .context("Failed to store token in keyring")?;
271 info!("Token stored in system keyring");
272 Ok(())
273}
274
275#[instrument]
279pub fn clear_token_cache() {
280 let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
281 *guard = None;
282 debug!("Token cache cleared");
283}
284
285#[cfg(feature = "keyring")]
287#[instrument]
288pub fn delete_token() -> Result<()> {
289 let entry = keyring_entry()?;
290 entry
291 .delete_credential()
292 .context("Failed to delete token from keyring")?;
293 clear_token_cache();
294 info!("Token deleted from keyring");
295 Ok(())
296}
297
298#[cfg(feature = "keyring")]
308#[instrument]
309pub async fn authenticate(client_id: &SecretString) -> Result<()> {
310 debug!("Starting OAuth device flow");
311
312 let crab = Octocrab::builder()
314 .base_uri("https://github.com")
315 .context("Failed to set base URI")?
316 .add_header(ACCEPT, "application/json".to_string())
317 .build()
318 .context("Failed to build OAuth client")?;
319
320 let codes = crab
322 .authenticate_as_device(client_id, OAUTH_SCOPES)
323 .await
324 .context("Failed to request device code")?;
325
326 println!();
328 println!("To authenticate, visit:");
329 println!();
330 println!(" {}", codes.verification_uri);
331 println!();
332 println!("And enter the code:");
333 println!();
334 println!(" {}", codes.user_code);
335 println!();
336 println!("Waiting for authorization...");
337
338 let auth = codes
340 .poll_until_available(&crab, client_id)
341 .await
342 .context("Authorization failed or timed out")?;
343
344 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
346 store_token(&token)?;
347
348 info!("Authentication successful");
349 Ok(())
350}
351
352#[instrument]
359pub fn create_client() -> Result<Octocrab> {
360 let (token, source) =
361 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
362
363 info!(source = %source, "Creating GitHub client");
364
365 let client = Octocrab::builder()
366 .personal_token(token.expose_secret().to_string())
367 .build()
368 .context("Failed to build GitHub client")?;
369
370 debug!("Created authenticated GitHub client");
371 Ok(client)
372}
373
374#[instrument(skip(token))]
387pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
388 info!("Creating GitHub client with provided token");
389
390 let client = Octocrab::builder()
391 .personal_token(token.expose_secret().to_string())
392 .build()
393 .context("Failed to build GitHub client")?;
394
395 debug!("Created authenticated GitHub client");
396 Ok(client)
397}
398
399#[instrument(skip(provider))]
421pub fn create_client_from_provider(
422 provider: &dyn crate::auth::TokenProvider,
423) -> crate::Result<Octocrab> {
424 let github_token = provider
425 .github_token()
426 .ok_or(crate::error::AptuError::NotAuthenticated)?;
427
428 let token = SecretString::from(github_token);
429 create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
430 message: format!("Failed to create GitHub client: {e}"),
431 })
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[cfg(feature = "keyring")]
439 #[test]
440 fn test_keyring_entry_creation() {
441 let result = keyring_entry();
443 assert!(result.is_ok());
444 }
445
446 #[test]
447 fn test_token_source_display() {
448 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
449 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
450 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
451 }
452
453 #[test]
454 fn test_token_source_equality() {
455 assert_eq!(TokenSource::Environment, TokenSource::Environment);
456 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
457 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
458 }
459
460 #[test]
461 fn test_gh_cli_not_installed_returns_none() {
462 let result = get_token_from_gh_cli();
467 let _ = result;
470 }
471
472 #[test]
473 fn test_resolve_token_with_env_var() {
474 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
476 match key {
477 "GH_TOKEN" => Ok("test_token_123".to_string()),
478 _ => Err(std::env::VarError::NotPresent),
479 }
480 };
481
482 let result = resolve_token_with_env(mock_env);
484
485 assert!(result.is_some());
487 let (token, source) = result.unwrap();
488 assert_eq!(token.expose_secret(), "test_token_123");
489 assert_eq!(source, TokenSource::Environment);
490 }
491
492 #[test]
493 fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
494 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
496 match key {
497 "GH_TOKEN" => Ok("gh_token".to_string()),
498 "GITHUB_TOKEN" => Ok("github_token".to_string()),
499 _ => Err(std::env::VarError::NotPresent),
500 }
501 };
502
503 let result = resolve_token_with_env(mock_env);
505
506 assert!(result.is_some());
508 let (token, _) = result.unwrap();
509 assert_eq!(token.expose_secret(), "gh_token");
510 }
511
512 #[test]
513 #[serial_test::serial]
514 fn test_clear_token_cache_invalidates() {
515 clear_token_cache();
517
518 let guard = TOKEN_CACHE.read().unwrap_or_else(|e| e.into_inner());
520
521 assert!(guard.is_none());
523 }
524}