1use std::process::Command;
17use std::sync::OnceLock;
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: OnceLock<Option<(SecretString, TokenSource)>> = OnceLock::new();
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] = &["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 let cached = TOKEN_CACHE.get_or_init(resolve_token_inner).as_ref();
243 if let Some((_, source)) = cached {
244 debug!(source = %source, "Cache hit for token resolution");
245 }
246 cached.map(|(token, source)| (token.clone(), *source))
247}
248
249#[cfg(feature = "keyring")]
251#[instrument(skip(token))]
252pub fn store_token(token: &SecretString) -> Result<()> {
253 let entry = keyring_entry()?;
254 entry
255 .set_password(token.expose_secret())
256 .context("Failed to store token in keyring")?;
257 info!("Token stored in system keyring");
258 Ok(())
259}
260
261#[instrument]
265pub fn clear_token_cache() {
266 debug!("Token cache cleared (session-scoped)");
270}
271
272#[cfg(feature = "keyring")]
274#[instrument]
275pub fn delete_token() -> Result<()> {
276 let entry = keyring_entry()?;
277 entry
278 .delete_credential()
279 .context("Failed to delete token from keyring")?;
280 clear_token_cache();
281 info!("Token deleted from keyring");
282 Ok(())
283}
284
285#[cfg(feature = "keyring")]
295#[instrument]
296pub async fn authenticate(client_id: &SecretString) -> Result<()> {
297 debug!("Starting OAuth device flow");
298
299 let crab = Octocrab::builder()
301 .base_uri("https://github.com")
302 .context("Failed to set base URI")?
303 .add_header(ACCEPT, "application/json".to_string())
304 .build()
305 .context("Failed to build OAuth client")?;
306
307 let codes = crab
309 .authenticate_as_device(client_id, OAUTH_SCOPES)
310 .await
311 .context("Failed to request device code")?;
312
313 println!();
315 println!("To authenticate, visit:");
316 println!();
317 println!(" {}", codes.verification_uri);
318 println!();
319 println!("And enter the code:");
320 println!();
321 println!(" {}", codes.user_code);
322 println!();
323 println!("Waiting for authorization...");
324
325 let auth = codes
327 .poll_until_available(&crab, client_id)
328 .await
329 .context("Authorization failed or timed out")?;
330
331 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
333 store_token(&token)?;
334
335 info!("Authentication successful");
336 Ok(())
337}
338
339#[instrument]
346pub fn create_client() -> Result<Octocrab> {
347 let (token, source) =
348 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
349
350 info!(source = %source, "Creating GitHub client");
351
352 let client = Octocrab::builder()
353 .personal_token(token.expose_secret().to_string())
354 .build()
355 .context("Failed to build GitHub client")?;
356
357 debug!("Created authenticated GitHub client");
358 Ok(client)
359}
360
361#[instrument(skip(token))]
374pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
375 info!("Creating GitHub client with provided token");
376
377 let client = Octocrab::builder()
378 .personal_token(token.expose_secret().to_string())
379 .build()
380 .context("Failed to build GitHub client")?;
381
382 debug!("Created authenticated GitHub client");
383 Ok(client)
384}
385
386#[instrument(skip(provider))]
408pub fn create_client_from_provider(
409 provider: &dyn crate::auth::TokenProvider,
410) -> crate::Result<Octocrab> {
411 let github_token = provider
412 .github_token()
413 .ok_or(crate::error::AptuError::NotAuthenticated)?;
414
415 let token = SecretString::from(github_token);
416 create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
417 message: format!("Failed to create GitHub client: {e}"),
418 })
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[cfg(feature = "keyring")]
426 #[test]
427 fn test_keyring_entry_creation() {
428 let result = keyring_entry();
430 assert!(result.is_ok());
431 }
432
433 #[test]
434 fn test_token_source_display() {
435 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
436 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
437 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
438 }
439
440 #[test]
441 fn test_token_source_equality() {
442 assert_eq!(TokenSource::Environment, TokenSource::Environment);
443 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
444 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
445 }
446
447 #[test]
448 fn test_gh_cli_not_installed_returns_none() {
449 let result = get_token_from_gh_cli();
454 let _ = result;
457 }
458
459 #[test]
460 fn test_resolve_token_with_env_var() {
461 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
463 match key {
464 "GH_TOKEN" => Ok("test_token_123".to_string()),
465 _ => Err(std::env::VarError::NotPresent),
466 }
467 };
468
469 let result = resolve_token_with_env(mock_env);
471
472 assert!(result.is_some());
474 let (token, source) = result.unwrap();
475 assert_eq!(token.expose_secret(), "test_token_123");
476 assert_eq!(source, TokenSource::Environment);
477 }
478
479 #[test]
480 fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
481 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
483 match key {
484 "GH_TOKEN" => Ok("gh_token".to_string()),
485 "GITHUB_TOKEN" => Ok("github_token".to_string()),
486 _ => Err(std::env::VarError::NotPresent),
487 }
488 };
489
490 let result = resolve_token_with_env(mock_env);
492
493 assert!(result.is_some());
495 let (token, _) = result.unwrap();
496 assert_eq!(token.expose_secret(), "gh_token");
497 }
498}