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