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_inner() -> Option<(SecretString, TokenSource)> {
156 if let Ok(token) = std::env::var("GH_TOKEN")
158 && !token.is_empty()
159 {
160 debug!("Using token from GH_TOKEN environment variable");
161 return Some((SecretString::from(token), TokenSource::Environment));
162 }
163
164 if let Ok(token) = std::env::var("GITHUB_TOKEN")
166 && !token.is_empty()
167 {
168 debug!("Using token from GITHUB_TOKEN environment variable");
169 return Some((SecretString::from(token), TokenSource::Environment));
170 }
171
172 if let Some(token) = get_token_from_gh_cli() {
174 debug!("Using token from GitHub CLI");
175 return Some((token, TokenSource::GhCli));
176 }
177
178 #[cfg(feature = "keyring")]
180 if let Some(token) = get_stored_token() {
181 debug!("Using token from system keyring");
182 return Some((token, TokenSource::Keyring));
183 }
184
185 debug!("No token found in any source");
186 None
187}
188
189#[instrument]
202pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
203 TOKEN_CACHE
204 .get_or_init(resolve_token_inner)
205 .as_ref()
206 .map(|(token, source)| {
207 debug!(source = %source, "Cache hit for token resolution");
208 (token.clone(), *source)
209 })
210}
211
212#[cfg(feature = "keyring")]
214#[instrument(skip(token))]
215pub fn store_token(token: &SecretString) -> Result<()> {
216 let entry = keyring_entry()?;
217 entry
218 .set_password(token.expose_secret())
219 .context("Failed to store token in keyring")?;
220 info!("Token stored in system keyring");
221 Ok(())
222}
223
224#[instrument]
228pub fn clear_token_cache() {
229 debug!("Token cache cleared (session-scoped)");
233}
234
235#[cfg(feature = "keyring")]
237#[instrument]
238pub fn delete_token() -> Result<()> {
239 let entry = keyring_entry()?;
240 entry
241 .delete_credential()
242 .context("Failed to delete token from keyring")?;
243 clear_token_cache();
244 info!("Token deleted from keyring");
245 Ok(())
246}
247
248#[cfg(feature = "keyring")]
258#[instrument]
259pub async fn authenticate(client_id: &SecretString) -> Result<()> {
260 debug!("Starting OAuth device flow");
261
262 let crab = Octocrab::builder()
264 .base_uri("https://github.com")
265 .context("Failed to set base URI")?
266 .add_header(ACCEPT, "application/json".to_string())
267 .build()
268 .context("Failed to build OAuth client")?;
269
270 let codes = crab
272 .authenticate_as_device(client_id, OAUTH_SCOPES)
273 .await
274 .context("Failed to request device code")?;
275
276 println!();
278 println!("To authenticate, visit:");
279 println!();
280 println!(" {}", codes.verification_uri);
281 println!();
282 println!("And enter the code:");
283 println!();
284 println!(" {}", codes.user_code);
285 println!();
286 println!("Waiting for authorization...");
287
288 let auth = codes
290 .poll_until_available(&crab, client_id)
291 .await
292 .context("Authorization failed or timed out")?;
293
294 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
296 store_token(&token)?;
297
298 info!("Authentication successful");
299 Ok(())
300}
301
302#[instrument]
309pub fn create_client() -> Result<Octocrab> {
310 let (token, source) =
311 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
312
313 info!(source = %source, "Creating GitHub client");
314
315 let client = Octocrab::builder()
316 .personal_token(token.expose_secret().to_string())
317 .build()
318 .context("Failed to build GitHub client")?;
319
320 debug!("Created authenticated GitHub client");
321 Ok(client)
322}
323
324#[instrument(skip(token))]
337pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
338 info!("Creating GitHub client with provided token");
339
340 let client = Octocrab::builder()
341 .personal_token(token.expose_secret().to_string())
342 .build()
343 .context("Failed to build GitHub client")?;
344
345 debug!("Created authenticated GitHub client");
346 Ok(client)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[cfg(feature = "keyring")]
354 #[test]
355 fn test_keyring_entry_creation() {
356 let result = keyring_entry();
358 assert!(result.is_ok());
359 }
360
361 #[test]
362 fn test_token_source_display() {
363 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
364 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
365 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
366 }
367
368 #[test]
369 fn test_token_source_equality() {
370 assert_eq!(TokenSource::Environment, TokenSource::Environment);
371 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
372 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
373 }
374
375 #[test]
376 fn test_gh_cli_not_installed_returns_none() {
377 let result = get_token_from_gh_cli();
382 let _ = result;
385 }
386
387 #[test]
388 fn test_resolve_token_inner_with_env_var() {
389 unsafe {
391 std::env::set_var("GH_TOKEN", "test_token_123");
392 }
393
394 let result = resolve_token_inner();
396
397 assert!(result.is_some());
399 let (token, source) = result.unwrap();
400 assert_eq!(token.expose_secret(), "test_token_123");
401 assert_eq!(source, TokenSource::Environment);
402
403 unsafe {
405 std::env::remove_var("GH_TOKEN");
406 }
407 }
408
409 #[test]
410 fn test_resolve_token_inner_prefers_gh_token_over_github_token() {
411 unsafe {
413 std::env::set_var("GH_TOKEN", "gh_token");
414 std::env::set_var("GITHUB_TOKEN", "github_token");
415 }
416
417 let result = resolve_token_inner();
419
420 assert!(result.is_some());
422 let (token, _) = result.unwrap();
423 assert_eq!(token.expose_secret(), "gh_token");
424
425 unsafe {
427 std::env::remove_var("GH_TOKEN");
428 std::env::remove_var("GITHUB_TOKEN");
429 }
430 }
431}