Skip to main content

aptu_core/github/
auth.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! GitHub OAuth device flow authentication.
4//!
5//! Implements the OAuth device flow for CLI authentication:
6//! 1. Request device code from GitHub
7//! 2. Display verification URL and user code to user
8//! 3. Poll for access token after user authorizes
9//! 4. Store token securely in system keychain
10//!
11//! Also provides a token resolution priority chain:
12//! 1. Environment variable (`GH_TOKEN` or `GITHUB_TOKEN`)
13//! 2. GitHub CLI (`gh auth token`)
14//! 3. System keyring (native aptu auth)
15
16#[cfg(not(target_arch = "wasm32"))]
17use std::process::Command;
18use std::sync::{PoisonError, RwLock};
19
20use anyhow::{Context, Result};
21#[cfg(feature = "keyring")]
22use keyring_core::Entry;
23#[cfg(not(target_arch = "wasm32"))]
24use octocrab::Octocrab;
25#[cfg(feature = "keyring")]
26use reqwest::header::ACCEPT;
27use secrecy::{ExposeSecret, SecretString};
28use serde::Serialize;
29use tracing::{debug, info, instrument};
30
31#[cfg(feature = "keyring")]
32use super::{KEYRING_SERVICE, KEYRING_USER};
33
34/// Session-level cache for resolved GitHub tokens.
35/// Stores the token and its source to avoid repeated subprocess calls to `gh auth token`.
36static TOKEN_CACHE: RwLock<Option<(SecretString, TokenSource)>> = RwLock::new(None);
37
38/// Source of the GitHub authentication token.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "snake_case")]
41pub enum TokenSource {
42    /// Token from `GH_TOKEN` or `GITHUB_TOKEN` environment variable.
43    Environment,
44    /// Token from `gh auth token` command.
45    GhCli,
46    /// Token from system keyring (native aptu auth).
47    Keyring,
48}
49
50impl std::fmt::Display for TokenSource {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            TokenSource::Environment => write!(f, "environment variable"),
54            TokenSource::GhCli => write!(f, "GitHub CLI"),
55            TokenSource::Keyring => write!(f, "system keyring"),
56        }
57    }
58}
59
60/// OAuth scopes required for Aptu functionality.
61#[cfg(feature = "keyring")]
62const OAUTH_SCOPES: &[&str] = &["public_repo", "read:user"];
63
64/// Initialize the platform keyring store. Must be called once at application startup
65/// before any keyring operations. Returns an error if the platform store cannot be opened.
66#[cfg(feature = "keyring")]
67pub fn keyring_init() -> crate::Result<()> {
68    #[cfg(target_os = "macos")]
69    {
70        use apple_native_keyring_store::keychain::Store;
71        let store = Store::new().map_err(|e| {
72            keyring_core::error::Error::PlatformFailure(
73                format!("Failed to initialize macOS keychain: {e}").into(),
74            )
75        })?;
76        keyring_core::set_default_store(store);
77    }
78
79    #[cfg(target_os = "linux")]
80    {
81        use linux_keyutils_keyring_store::Store;
82        let store = Store::new().map_err(|e| {
83            keyring_core::error::Error::PlatformFailure(
84                format!("Failed to initialize Linux keyutils store: {e}").into(),
85            )
86        })?;
87        keyring_core::set_default_store(store);
88    }
89
90    #[cfg(windows)]
91    {
92        use windows_native_keyring_store::Store;
93        let store = Store::new().map_err(|e| {
94            keyring_core::error::Error::PlatformFailure(
95                format!("Failed to initialize Windows credential store: {e}").into(),
96            )
97        })?;
98        keyring_core::set_default_store(store);
99    }
100
101    Ok(())
102}
103
104/// Tear down the platform keyring store. Call at application shutdown after all
105/// keyring operations are complete.
106#[cfg(feature = "keyring")]
107pub fn keyring_deinit() {
108    keyring_core::unset_default_store();
109}
110
111/// Creates a keyring entry for the GitHub token.
112#[cfg(feature = "keyring")]
113fn keyring_entry() -> Result<Entry> {
114    Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
115}
116
117/// Checks if a GitHub token is available from any source.
118///
119/// Uses the token resolution priority chain to check for authentication.
120#[instrument]
121#[allow(clippy::let_and_return)] // Intentional: Rust 2024 drop order compliance
122pub fn is_authenticated() -> bool {
123    let result = resolve_token().is_some();
124    result
125}
126
127/// Checks if a GitHub token is stored in the keyring specifically.
128///
129/// Returns `true` only if a token exists in the system keyring,
130/// ignoring environment variables and `gh` CLI.
131#[cfg(feature = "keyring")]
132#[instrument]
133#[allow(clippy::let_and_return)] // Intentional: Rust 2024 drop order compliance
134pub fn has_keyring_token() -> bool {
135    let result = match keyring_entry() {
136        Ok(entry) => entry.get_password().is_ok(),
137        Err(_) => false,
138    };
139    result
140}
141
142/// Retrieves the stored GitHub token from the keyring.
143///
144/// Returns `None` if no token is stored or if keyring access fails.
145#[cfg(feature = "keyring")]
146#[must_use]
147pub fn get_stored_token() -> Option<SecretString> {
148    let entry = keyring_entry().ok()?;
149    Some(SecretString::from(entry.get_password().ok()?))
150}
151
152/// Parse GitHub CLI output and extract token.
153/// Returns Some(token) if output is successful and non-empty, None otherwise.
154fn parse_gh_cli_output(output: &std::process::Output) -> Option<SecretString> {
155    if output.status.success() {
156        let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
157        if token.is_empty() {
158            debug!("gh auth token returned empty output");
159            None
160        } else {
161            debug!("Successfully retrieved token from gh CLI");
162            Some(SecretString::from(token))
163        }
164    } else {
165        let stderr = String::from_utf8_lossy(&output.stderr);
166        debug!(
167            status = ?output.status,
168            stderr = %stderr.trim(),
169            "gh auth token failed"
170        );
171        None
172    }
173}
174
175/// Attempts to get a token from the GitHub CLI (`gh auth token`).
176///
177/// Returns `None` if:
178/// - `gh` is not installed
179/// - `gh` is not authenticated
180/// - The command times out (5 seconds)
181/// - Any other error occurs
182#[cfg(not(target_arch = "wasm32"))]
183#[instrument]
184fn get_token_from_gh_cli() -> Option<SecretString> {
185    debug!("Attempting to get token from gh CLI");
186
187    // Use wait-timeout crate pattern with std::process
188    let output = Command::new("gh").args(["auth", "token"]).output();
189
190    match output {
191        Ok(output) => parse_gh_cli_output(&output),
192        Err(e) => {
193            debug!(error = %e, "Failed to execute gh command");
194            None
195        }
196    }
197}
198
199/// Check environment variable for token.
200fn check_env_token<F>(env_reader: &F, var_name: &str) -> Option<SecretString>
201where
202    F: Fn(&str) -> Result<String, std::env::VarError>,
203{
204    env_reader(var_name)
205        .ok()
206        .filter(|token| !token.is_empty())
207        .map(SecretString::from)
208}
209
210/// Generic token resolution logic that accepts an environment variable reader.
211///
212/// This function enables dependency injection of the environment reader,
213/// allowing tests to pass mock values without manipulating the real environment.
214///
215/// Checks sources in order:
216/// 1. `GH_TOKEN` environment variable (via provided reader)
217/// 2. `GITHUB_TOKEN` environment variable (via provided reader)
218/// 3. GitHub CLI (`gh auth token`)
219/// 4. System keyring (native aptu auth)
220///
221/// # Arguments
222///
223/// * `env_reader` - A function that reads environment variables, returning `Ok(value)` or `Err(_)`
224///
225/// Returns the token and its source, or `None` if no token is found.
226fn resolve_token_with_env<F>(env_reader: F) -> Option<(SecretString, TokenSource)>
227where
228    F: Fn(&str) -> Result<String, std::env::VarError>,
229{
230    // Priority 1: GH_TOKEN environment variable
231    let token = check_env_token(&env_reader, "GH_TOKEN");
232    if token.is_some() {
233        debug!("Using token from GH_TOKEN environment variable");
234    }
235    let result = token.map(|t| (t, TokenSource::Environment));
236
237    // Priority 2: GITHUB_TOKEN environment variable
238    let result = result.or_else(|| {
239        let token = check_env_token(&env_reader, "GITHUB_TOKEN");
240        if token.is_some() {
241            debug!("Using token from GITHUB_TOKEN environment variable");
242        }
243        token.map(|t| (t, TokenSource::Environment))
244    });
245
246    // Priority 3: GitHub CLI
247    #[cfg(not(target_arch = "wasm32"))]
248    let result = result.or_else(|| {
249        let token = get_token_from_gh_cli();
250        if token.is_some() {
251            debug!("Using token from GitHub CLI");
252        }
253        token.map(|t| (t, TokenSource::GhCli))
254    });
255
256    // Priority 4: System keyring
257    #[cfg(feature = "keyring")]
258    let result = result.or_else(|| get_stored_token().map(|t| (t, TokenSource::Keyring)));
259
260    if result.is_none() {
261        debug!("No token found in any source");
262    }
263    result
264}
265
266/// Internal token resolution logic without caching.
267///
268/// Checks sources in order:
269/// 1. `GH_TOKEN` environment variable
270/// 2. `GITHUB_TOKEN` environment variable
271/// 3. GitHub CLI (`gh auth token`)
272/// 4. System keyring (native aptu auth)
273///
274/// Returns the token and its source, or `None` if no token is found.
275fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
276    resolve_token_with_env(|key| std::env::var(key))
277}
278
279/// Resolves a GitHub token using the priority chain with session-level caching.
280///
281/// Caches the resolved token to avoid repeated subprocess calls to `gh auth token`.
282/// The cache is valid for the lifetime of the session (CLI invocation).
283///
284/// Checks sources in order:
285/// 1. `GH_TOKEN` environment variable
286/// 2. `GITHUB_TOKEN` environment variable
287/// 3. GitHub CLI (`gh auth token`)
288/// 4. System keyring (native aptu auth)
289///
290/// Returns the token and its source, or `None` if no token is found.
291#[instrument]
292pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
293    // Try read lock first
294    {
295        let guard = TOKEN_CACHE.read().unwrap_or_else(PoisonError::into_inner);
296        if let Some((token, source)) = guard.as_ref() {
297            debug!(source = %source, "Cache hit for token resolution");
298            return Some((token.clone(), *source));
299        }
300    }
301
302    // Cache miss: acquire write lock and resolve
303    let resolved = resolve_token_inner();
304    if let Some((token, source)) = resolved.as_ref() {
305        let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
306        *guard = Some((token.clone(), *source));
307        debug!(source = %source, "Resolved and cached token");
308        Some((token.clone(), *source))
309    } else {
310        None
311    }
312}
313
314/// Stores a GitHub token in the system keyring.
315#[cfg(feature = "keyring")]
316#[instrument(skip(token))]
317pub fn store_token(token: &SecretString) -> Result<()> {
318    let entry = keyring_entry()?;
319    entry
320        .set_password(token.expose_secret())
321        .context("Failed to store token in keyring")?;
322    info!("Token stored in system keyring");
323    Ok(())
324}
325
326/// Clears the session-level token cache.
327///
328/// This should be called after logout or when the token is invalidated.
329#[instrument]
330pub fn clear_token_cache() {
331    let mut guard = TOKEN_CACHE.write().unwrap_or_else(PoisonError::into_inner);
332    *guard = None;
333    debug!("Token cache cleared");
334}
335
336/// Deletes the stored GitHub token from the keyring.
337#[cfg(feature = "keyring")]
338#[instrument]
339pub fn delete_token() -> Result<()> {
340    let entry = keyring_entry()?;
341    entry
342        .delete_credential()
343        .context("Failed to delete token from keyring")?;
344    clear_token_cache();
345    info!("Token deleted from keyring");
346    Ok(())
347}
348
349/// Performs the GitHub OAuth device flow authentication.
350///
351/// This function:
352/// 1. Requests a device code from GitHub
353/// 2. Returns the verification URI and user code for display
354/// 3. Polls GitHub until the user authorizes or times out
355/// 4. Stores the resulting token in the system keychain
356///
357/// Requires `APTU_GH_CLIENT_ID` environment variable to be set.
358#[cfg(feature = "keyring")]
359#[instrument]
360pub async fn authenticate(client_id: &SecretString) -> Result<()> {
361    debug!("Starting OAuth device flow");
362
363    // Build a client configured for GitHub's OAuth endpoints
364    let crab = Octocrab::builder()
365        .base_uri("https://github.com")
366        .context("Failed to set base URI")?
367        .add_header(ACCEPT, "application/json".to_string())
368        .build()
369        .context("Failed to build OAuth client")?;
370
371    // Request device and user codes
372    let codes = crab
373        .authenticate_as_device(client_id, OAUTH_SCOPES)
374        .await
375        .context("Failed to request device code")?;
376
377    // Display instructions to user
378    println!();
379    println!("To authenticate, visit:");
380    println!();
381    println!("    {}", codes.verification_uri);
382    println!();
383    println!("And enter the code:");
384    println!();
385    println!("    {}", codes.user_code);
386    println!();
387    println!("Waiting for authorization...");
388
389    // Poll until user authorizes (octocrab handles backoff)
390    let auth = codes
391        .poll_until_available(&crab, client_id)
392        .await
393        .context("Authorization failed or timed out")?;
394
395    // Store the access token
396    let token = SecretString::from(auth.access_token.expose_secret().to_owned());
397    store_token(&token)?;
398
399    info!("Authentication successful");
400    Ok(())
401}
402
403/// Creates an authenticated Octocrab client using the token priority chain.
404///
405/// Uses [`resolve_token`] to find credentials from environment variables,
406/// GitHub CLI, or system keyring.
407///
408/// Returns an error if no token is found from any source.
409#[cfg(not(target_arch = "wasm32"))]
410#[instrument]
411pub fn create_client() -> Result<Octocrab> {
412    let (token, source) =
413        resolve_token().context("Not authenticated - run `aptu auth login` first")?;
414
415    info!(source = %source, "Creating GitHub client");
416
417    let client = Octocrab::builder()
418        .personal_token(token.expose_secret().to_string())
419        .build()
420        .context("Failed to build GitHub client")?;
421
422    debug!("Created authenticated GitHub client");
423    Ok(client)
424}
425
426/// Creates an authenticated Octocrab client using a provided token.
427///
428/// This function allows callers to provide a token directly, enabling
429/// multi-platform credential resolution (e.g., from iOS keychain via FFI).
430///
431/// # Arguments
432///
433/// * `token` - GitHub API token as a `SecretString`
434///
435/// # Errors
436///
437/// Returns an error if the Octocrab client cannot be built.
438#[cfg(not(target_arch = "wasm32"))]
439#[instrument(skip(token))]
440pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
441    info!("Creating GitHub client with provided token");
442
443    let client = Octocrab::builder()
444        .personal_token(token.expose_secret().to_string())
445        .build()
446        .context("Failed to build GitHub client")?;
447
448    debug!("Created authenticated GitHub client");
449    Ok(client)
450}
451
452/// Creates a GitHub client from a `TokenProvider`.
453///
454/// This is a convenience function that extracts the token from a provider
455/// and creates an authenticated Octocrab client. It standardizes error handling
456/// across the facade layer.
457///
458/// # Arguments
459///
460/// * `provider` - Token provider that supplies the GitHub token
461///
462/// # Returns
463///
464/// Returns `Ok(Octocrab)` if successful, or an `AptuError::GitHub` if:
465/// - The provider has no token available
466/// - The GitHub client fails to build
467///
468/// # Example
469///
470/// ```ignore
471/// let client = create_client_from_provider(provider)?;
472/// ```
473#[cfg(not(target_arch = "wasm32"))]
474#[instrument(skip(provider))]
475pub fn create_client_from_provider(
476    provider: &dyn crate::auth::TokenProvider,
477) -> crate::Result<Octocrab> {
478    let github_token = provider
479        .github_token()
480        .ok_or(crate::error::AptuError::NotAuthenticated)?;
481
482    let token = SecretString::from(github_token);
483    create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
484        message: format!("Failed to create GitHub client: {e}"),
485    })
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[cfg(feature = "keyring")]
493    #[test]
494    fn test_keyring_entry_creation() {
495        // Set up mock store before creating entry
496        let mock_store = keyring_core::mock::Store::new().expect("Failed to create mock store");
497        keyring_core::set_default_store(mock_store);
498
499        // Verify we can create an entry without panicking
500        let result = keyring_entry();
501        assert!(result.is_ok());
502
503        // Clean up
504        keyring_core::unset_default_store();
505    }
506
507    #[test]
508    fn test_token_source_display() {
509        assert_eq!(TokenSource::Environment.to_string(), "environment variable");
510        assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
511        assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
512    }
513
514    #[test]
515    fn test_token_source_equality() {
516        assert_eq!(TokenSource::Environment, TokenSource::Environment);
517        assert_ne!(TokenSource::Environment, TokenSource::GhCli);
518        assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
519    }
520
521    #[test]
522    #[cfg(not(target_arch = "wasm32"))]
523    fn test_gh_cli_not_installed_returns_none() {
524        // This test verifies that get_token_from_gh_cli gracefully handles
525        // the case where gh is not in PATH (returns None, doesn't panic)
526        // Note: This test may pass even if gh IS installed, because we're
527        // testing the graceful fallback behavior
528        let result = get_token_from_gh_cli();
529        // We can't assert None here because gh might be installed
530        // Just verify it doesn't panic and returns Option
531        let _ = result;
532    }
533
534    #[test]
535    fn test_resolve_token_with_env_var() {
536        // Arrange: Create a mock env reader that returns a test token
537        let mock_env = |key: &str| -> Result<String, std::env::VarError> {
538            match key {
539                "GH_TOKEN" => Ok("test_token_123".to_string()),
540                _ => Err(std::env::VarError::NotPresent),
541            }
542        };
543
544        // Act
545        let result = resolve_token_with_env(mock_env);
546
547        // Assert
548        assert!(result.is_some());
549        let (token, source) = result.unwrap();
550        assert_eq!(token.expose_secret(), "test_token_123");
551        assert_eq!(source, TokenSource::Environment);
552    }
553
554    #[test]
555    fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
556        // Arrange: Create a mock env reader that returns both tokens
557        let mock_env = |key: &str| -> Result<String, std::env::VarError> {
558            match key {
559                "GH_TOKEN" => Ok("gh_token".to_string()),
560                "GITHUB_TOKEN" => Ok("github_token".to_string()),
561                _ => Err(std::env::VarError::NotPresent),
562            }
563        };
564
565        // Act
566        let result = resolve_token_with_env(mock_env);
567
568        // Assert: GH_TOKEN should take priority
569        assert!(result.is_some());
570        let (token, _) = result.unwrap();
571        assert_eq!(token.expose_secret(), "gh_token");
572    }
573
574    #[test]
575    #[serial_test::serial]
576    fn test_clear_token_cache_invalidates() {
577        // Arrange: Clear the cache
578        clear_token_cache();
579
580        // Act: Read the cache after clearing
581        let guard = TOKEN_CACHE
582            .read()
583            .unwrap_or_else(std::sync::PoisonError::into_inner);
584
585        // Assert: Cache should be None
586        assert!(guard.is_none());
587    }
588}