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