1use std::process::Command;
17use std::sync::OnceLock;
18
19use anyhow::{Context, Result};
20use keyring::Entry;
21use octocrab::Octocrab;
22use reqwest::header::ACCEPT;
23use secrecy::{ExposeSecret, SecretString};
24use serde::Serialize;
25use tracing::{debug, info, instrument};
26
27use super::{KEYRING_SERVICE, KEYRING_USER};
28
29static TOKEN_CACHE: OnceLock<Option<(SecretString, TokenSource)>> = OnceLock::new();
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum TokenSource {
37 Environment,
39 GhCli,
41 Keyring,
43}
44
45impl std::fmt::Display for TokenSource {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 TokenSource::Environment => write!(f, "environment variable"),
49 TokenSource::GhCli => write!(f, "GitHub CLI"),
50 TokenSource::Keyring => write!(f, "system keyring"),
51 }
52 }
53}
54
55const OAUTH_SCOPES: &[&str] = &["repo", "read:user"];
57
58fn keyring_entry() -> Result<Entry> {
60 Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
61}
62
63#[instrument]
67#[allow(clippy::let_and_return)] pub fn is_authenticated() -> bool {
69 let result = resolve_token().is_some();
70 result
71}
72
73#[instrument]
78#[allow(clippy::let_and_return)] pub fn has_keyring_token() -> bool {
80 let result = match keyring_entry() {
81 Ok(entry) => entry.get_password().is_ok(),
82 Err(_) => false,
83 };
84 result
85}
86
87#[instrument]
91pub fn get_stored_token() -> Option<SecretString> {
92 let entry = keyring_entry().ok()?;
93 let password = entry.get_password().ok()?;
94 debug!("Retrieved token from keyring");
95 Some(SecretString::from(password))
96}
97
98#[instrument]
106fn get_token_from_gh_cli() -> Option<SecretString> {
107 debug!("Attempting to get token from gh CLI");
108
109 let output = Command::new("gh").args(["auth", "token"]).output();
111
112 match output {
113 Ok(output) if output.status.success() => {
114 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
115 if token.is_empty() {
116 debug!("gh auth token returned empty output");
117 None
118 } else {
119 debug!("Successfully retrieved token from gh CLI");
120 Some(SecretString::from(token))
121 }
122 }
123 Ok(output) => {
124 let stderr = String::from_utf8_lossy(&output.stderr);
125 debug!(
126 status = ?output.status,
127 stderr = %stderr.trim(),
128 "gh auth token failed"
129 );
130 None
131 }
132 Err(e) => {
133 debug!(error = %e, "Failed to execute gh command");
134 None
135 }
136 }
137}
138
139fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
149 if let Ok(token) = std::env::var("GH_TOKEN")
151 && !token.is_empty()
152 {
153 debug!("Using token from GH_TOKEN environment variable");
154 return Some((SecretString::from(token), TokenSource::Environment));
155 }
156
157 if let Ok(token) = std::env::var("GITHUB_TOKEN")
159 && !token.is_empty()
160 {
161 debug!("Using token from GITHUB_TOKEN environment variable");
162 return Some((SecretString::from(token), TokenSource::Environment));
163 }
164
165 if let Some(token) = get_token_from_gh_cli() {
167 debug!("Using token from GitHub CLI");
168 return Some((token, TokenSource::GhCli));
169 }
170
171 if let Some(token) = get_stored_token() {
173 debug!("Using token from system keyring");
174 return Some((token, TokenSource::Keyring));
175 }
176
177 debug!("No token found in any source");
178 None
179}
180
181#[instrument]
194pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
195 TOKEN_CACHE
196 .get_or_init(resolve_token_inner)
197 .as_ref()
198 .map(|(token, source)| {
199 debug!(source = %source, "Cache hit for token resolution");
200 (token.clone(), *source)
201 })
202}
203
204#[instrument(skip(token))]
206pub fn store_token(token: &SecretString) -> Result<()> {
207 let entry = keyring_entry()?;
208 entry
209 .set_password(token.expose_secret())
210 .context("Failed to store token in keyring")?;
211 info!("Token stored in system keyring");
212 Ok(())
213}
214
215#[instrument]
219pub fn clear_token_cache() {
220 debug!("Token cache cleared (session-scoped)");
224}
225
226#[instrument]
228pub fn delete_token() -> Result<()> {
229 let entry = keyring_entry()?;
230 entry
231 .delete_credential()
232 .context("Failed to delete token from keyring")?;
233 clear_token_cache();
234 info!("Token deleted from keyring");
235 Ok(())
236}
237
238#[instrument]
248pub async fn authenticate(client_id: &SecretString) -> Result<()> {
249 debug!("Starting OAuth device flow");
250
251 let crab = Octocrab::builder()
253 .base_uri("https://github.com")
254 .context("Failed to set base URI")?
255 .add_header(ACCEPT, "application/json".to_string())
256 .build()
257 .context("Failed to build OAuth client")?;
258
259 let codes = crab
261 .authenticate_as_device(client_id, OAUTH_SCOPES)
262 .await
263 .context("Failed to request device code")?;
264
265 println!();
267 println!("To authenticate, visit:");
268 println!();
269 println!(" {}", codes.verification_uri);
270 println!();
271 println!("And enter the code:");
272 println!();
273 println!(" {}", codes.user_code);
274 println!();
275 println!("Waiting for authorization...");
276
277 let auth = codes
279 .poll_until_available(&crab, client_id)
280 .await
281 .context("Authorization failed or timed out")?;
282
283 let token = SecretString::from(auth.access_token.expose_secret().to_owned());
285 store_token(&token)?;
286
287 info!("Authentication successful");
288 Ok(())
289}
290
291#[instrument]
298pub fn create_client() -> Result<Octocrab> {
299 let (token, source) =
300 resolve_token().context("Not authenticated - run `aptu auth login` first")?;
301
302 info!(source = %source, "Creating GitHub client");
303
304 let client = Octocrab::builder()
305 .personal_token(token.expose_secret().to_string())
306 .build()
307 .context("Failed to build GitHub client")?;
308
309 debug!("Created authenticated GitHub client");
310 Ok(client)
311}
312
313#[instrument(skip(token))]
326pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
327 info!("Creating GitHub client with provided token");
328
329 let client = Octocrab::builder()
330 .personal_token(token.expose_secret().to_string())
331 .build()
332 .context("Failed to build GitHub client")?;
333
334 debug!("Created authenticated GitHub client");
335 Ok(client)
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn test_keyring_entry_creation() {
344 let result = keyring_entry();
346 assert!(result.is_ok());
347 }
348
349 #[test]
350 fn test_token_source_display() {
351 assert_eq!(TokenSource::Environment.to_string(), "environment variable");
352 assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
353 assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
354 }
355
356 #[test]
357 fn test_token_source_equality() {
358 assert_eq!(TokenSource::Environment, TokenSource::Environment);
359 assert_ne!(TokenSource::Environment, TokenSource::GhCli);
360 assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
361 }
362
363 #[test]
364 fn test_gh_cli_not_installed_returns_none() {
365 let result = get_token_from_gh_cli();
370 let _ = result;
373 }
374
375 #[test]
376 fn test_resolve_token_inner_with_env_var() {
377 unsafe {
379 std::env::set_var("GH_TOKEN", "test_token_123");
380 }
381
382 let result = resolve_token_inner();
384
385 assert!(result.is_some());
387 let (token, source) = result.unwrap();
388 assert_eq!(token.expose_secret(), "test_token_123");
389 assert_eq!(source, TokenSource::Environment);
390
391 unsafe {
393 std::env::remove_var("GH_TOKEN");
394 }
395 }
396
397 #[test]
398 fn test_resolve_token_inner_prefers_gh_token_over_github_token() {
399 unsafe {
401 std::env::set_var("GH_TOKEN", "gh_token");
402 std::env::set_var("GITHUB_TOKEN", "github_token");
403 }
404
405 let result = resolve_token_inner();
407
408 assert!(result.is_some());
410 let (token, _) = result.unwrap();
411 assert_eq!(token.expose_secret(), "gh_token");
412
413 unsafe {
415 std::env::remove_var("GH_TOKEN");
416 std::env::remove_var("GITHUB_TOKEN");
417 }
418 }
419}