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#[instrument]
98pub fn get_stored_token() -> Option<SecretString> {
99 let entry = keyring_entry().ok()?;
100 let password = entry.get_password().ok()?;
101 debug!("Retrieved token from keyring");
102 Some(SecretString::from(password))
103}
104
105#[instrument]
113fn get_token_from_gh_cli() -> Option<SecretString> {
114 debug!("Attempting to get token from gh CLI");
115
116 let output = Command::new("gh").args(["auth", "token"]).output();
118
119 match output {
120 Ok(output) if output.status.success() => {
121 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
122 if token.is_empty() {
123 debug!("gh auth token returned empty output");
124 None
125 } else {
126 debug!("Successfully retrieved token from gh CLI");
127 Some(SecretString::from(token))
128 }
129 }
130 Ok(output) => {
131 let stderr = String::from_utf8_lossy(&output.stderr);
132 debug!(
133 status = ?output.status,
134 stderr = %stderr.trim(),
135 "gh auth token failed"
136 );
137 None
138 }
139 Err(e) => {
140 debug!(error = %e, "Failed to execute gh command");
141 None
142 }
143 }
144}
145
146fn resolve_token_with_env<F>(env_reader: F) -> Option<(SecretString, TokenSource)>
163where
164 F: Fn(&str) -> Result<String, std::env::VarError>,
165{
166 if let Ok(token) = env_reader("GH_TOKEN")
168 && !token.is_empty()
169 {
170 debug!("Using token from GH_TOKEN environment variable");
171 return Some((SecretString::from(token), TokenSource::Environment));
172 }
173
174 if let Ok(token) = env_reader("GITHUB_TOKEN")
176 && !token.is_empty()
177 {
178 debug!("Using token from GITHUB_TOKEN environment variable");
179 return Some((SecretString::from(token), TokenSource::Environment));
180 }
181
182 if let Some(token) = get_token_from_gh_cli() {
184 debug!("Using token from GitHub CLI");
185 return Some((token, TokenSource::GhCli));
186 }
187
188 #[cfg(feature = "keyring")]
190 if let Some(token) = get_stored_token() {
191 debug!("Using token from system keyring");
192 return Some((token, TokenSource::Keyring));
193 }
194
195 debug!("No token found in any source");
196 None
197}
198
199fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
209 resolve_token_with_env(|key| std::env::var(key))
210}
211
212#[instrument]
225pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
226 TOKEN_CACHE
227 .get_or_init(resolve_token_inner)
228 .as_ref()
229 .map(|(token, source)| {
230 debug!(source = %source, "Cache hit for token resolution");
231 (token.clone(), *source)
232 })
233}
234
235#[cfg(feature = "keyring")]
237#[instrument(skip(token))]
238pub fn store_token(token: &SecretString) -> Result<()> {
239 let entry = keyring_entry()?;
240 entry
241 .set_password(token.expose_secret())
242 .context("Failed to store token in keyring")?;
243 info!("Token stored in system keyring");
244 Ok(())
245}
246
247#[instrument]
251pub fn clear_token_cache() {
252 debug!("Token cache cleared (session-scoped)");
256}
257
258#[cfg(feature = "keyring")]
260#[instrument]
261pub fn delete_token() -> Result<()> {
262 let entry = keyring_entry()?;
263 entry
264 .delete_credential()
265 .context("Failed to delete token from keyring")?;
266 clear_token_cache();
267 info!("Token deleted from keyring");
268 Ok(())
269}
270
271#[cfg(feature = "keyring")]
281#[instrument]
282pub async fn authenticate(client_id: &SecretString) -> Result<()> {
283 debug!("Starting OAuth device flow");
284
285 let crab = Octocrab::builder()
287 .base_uri("https://github.com")
288 .context("Failed to set base URI")?
289 .add_header(ACCEPT, "application/json".to_string())
290 .build()
291 .context("Failed to build OAuth client")?;
292
293 let codes = crab
295 .authenticate_as_device(client_id, OAUTH_SCOPES)
296 .await
297 .context("Failed to request device code")?;
298
299 println!();
301 println!("To authenticate, visit:");
302 println!();
303 println!(" {}", codes.verification_uri);
304 println!();
305 println!("And enter the code:");
306 println!();
307 println!(" {}", codes.user_code);
308 println!();
309 println!("Waiting for authorization...");
310
311 let auth = codes
313 .poll_until_available(&crab, client_id)
314 .await
315 .context("Authorization failed or timed out")?;
316
317 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
319 store_token(&token)?;
320
321 info!("Authentication successful");
322 Ok(())
323}
324
325#[instrument]
332pub fn create_client() -> Result<Octocrab> {
333 let (token, source) =
334 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
335
336 info!(source = %source, "Creating GitHub client");
337
338 let client = Octocrab::builder()
339 .personal_token(token.expose_secret().to_string())
340 .build()
341 .context("Failed to build GitHub client")?;
342
343 debug!("Created authenticated GitHub client");
344 Ok(client)
345}
346
347#[instrument(skip(token))]
360pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
361 info!("Creating GitHub client with provided token");
362
363 let client = Octocrab::builder()
364 .personal_token(token.expose_secret().to_string())
365 .build()
366 .context("Failed to build GitHub client")?;
367
368 debug!("Created authenticated GitHub client");
369 Ok(client)
370}
371
372#[instrument(skip(provider))]
394pub fn create_client_from_provider(
395 provider: &dyn crate::auth::TokenProvider,
396) -> crate::Result<Octocrab> {
397 let github_token = provider
398 .github_token()
399 .ok_or(crate::error::AptuError::NotAuthenticated)?;
400
401 let token = SecretString::from(github_token);
402 create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
403 message: format!("Failed to create GitHub client: {e}"),
404 })
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[cfg(feature = "keyring")]
412 #[test]
413 fn test_keyring_entry_creation() {
414 let result = keyring_entry();
416 assert!(result.is_ok());
417 }
418
419 #[test]
420 fn test_token_source_display() {
421 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
422 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
423 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
424 }
425
426 #[test]
427 fn test_token_source_equality() {
428 assert_eq!(TokenSource::Environment, TokenSource::Environment);
429 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
430 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
431 }
432
433 #[test]
434 fn test_gh_cli_not_installed_returns_none() {
435 let result = get_token_from_gh_cli();
440 let _ = result;
443 }
444
445 #[test]
446 fn test_resolve_token_with_env_var() {
447 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
449 match key {
450 "GH_TOKEN" => Ok("test_token_123".to_string()),
451 _ => Err(std::env::VarError::NotPresent),
452 }
453 };
454
455 let result = resolve_token_with_env(mock_env);
457
458 assert!(result.is_some());
460 let (token, source) = result.unwrap();
461 assert_eq!(token.expose_secret(), "test_token_123");
462 assert_eq!(source, TokenSource::Environment);
463 }
464
465 #[test]
466 fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
467 let mock_env = |key: &str| -> Result<String, std::env::VarError> {
469 match key {
470 "GH_TOKEN" => Ok("gh_token".to_string()),
471 "GITHUB_TOKEN" => Ok("github_token".to_string()),
472 _ => Err(std::env::VarError::NotPresent),
473 }
474 };
475
476 let result = resolve_token_with_env(mock_env);
478
479 assert!(result.is_some());
481 let (token, _) = result.unwrap();
482 assert_eq!(token.expose_secret(), "gh_token");
483 }
484}