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::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
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: OnceLock<Option<(SecretString, TokenSource)>> = OnceLock::new();
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] = &["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#[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/// Attempts to get a token from the GitHub CLI (`gh auth token`).
106///
107/// Returns `None` if:
108/// - `gh` is not installed
109/// - `gh` is not authenticated
110/// - The command times out (5 seconds)
111/// - Any other error occurs
112#[instrument]
113fn get_token_from_gh_cli() -> Option<SecretString> {
114    debug!("Attempting to get token from gh CLI");
115
116    // Use wait-timeout crate pattern with std::process
117    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
146/// Generic token resolution logic that accepts an environment variable reader.
147///
148/// This function enables dependency injection of the environment reader,
149/// allowing tests to pass mock values without manipulating the real environment.
150///
151/// Checks sources in order:
152/// 1. `GH_TOKEN` environment variable (via provided reader)
153/// 2. `GITHUB_TOKEN` environment variable (via provided reader)
154/// 3. GitHub CLI (`gh auth token`)
155/// 4. System keyring (native aptu auth)
156///
157/// # Arguments
158///
159/// * `env_reader` - A function that reads environment variables, returning `Ok(value)` or `Err(_)`
160///
161/// Returns the token and its source, or `None` if no token is found.
162fn resolve_token_with_env<F>(env_reader: F) -> Option<(SecretString, TokenSource)>
163where
164    F: Fn(&str) -> Result<String, std::env::VarError>,
165{
166    // Priority 1: GH_TOKEN environment variable
167    if let Ok(token) = env_reader("GH_TOKEN")
168        && !token.is_empty()
169    {
170        debug!("Using token from GH_TOKEN environment variable");
171        return Some((SecretString::from(token), TokenSource::Environment));
172    }
173
174    // Priority 2: GITHUB_TOKEN environment variable
175    if let Ok(token) = env_reader("GITHUB_TOKEN")
176        && !token.is_empty()
177    {
178        debug!("Using token from GITHUB_TOKEN environment variable");
179        return Some((SecretString::from(token), TokenSource::Environment));
180    }
181
182    // Priority 3: GitHub CLI
183    if let Some(token) = get_token_from_gh_cli() {
184        debug!("Using token from GitHub CLI");
185        return Some((token, TokenSource::GhCli));
186    }
187
188    // Priority 4: System keyring
189    #[cfg(feature = "keyring")]
190    if let Some(token) = get_stored_token() {
191        debug!("Using token from system keyring");
192        return Some((token, TokenSource::Keyring));
193    }
194
195    debug!("No token found in any source");
196    None
197}
198
199/// Internal token resolution logic without caching.
200///
201/// Checks sources in order:
202/// 1. `GH_TOKEN` environment variable
203/// 2. `GITHUB_TOKEN` environment variable
204/// 3. GitHub CLI (`gh auth token`)
205/// 4. System keyring (native aptu auth)
206///
207/// Returns the token and its source, or `None` if no token is found.
208fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
209    resolve_token_with_env(|key| std::env::var(key))
210}
211
212/// Resolves a GitHub token using the priority chain with session-level caching.
213///
214/// Caches the resolved token to avoid repeated subprocess calls to `gh auth token`.
215/// The cache is valid for the lifetime of the session (CLI invocation).
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.
224#[instrument]
225pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
226    TOKEN_CACHE
227        .get_or_init(resolve_token_inner)
228        .as_ref()
229        .map(|(token, source)| {
230            debug!(source = %source, "Cache hit for token resolution");
231            (token.clone(), *source)
232        })
233}
234
235/// Stores a GitHub token in the system keyring.
236#[cfg(feature = "keyring")]
237#[instrument(skip(token))]
238pub fn store_token(token: &SecretString) -> Result<()> {
239    let entry = keyring_entry()?;
240    entry
241        .set_password(token.expose_secret())
242        .context("Failed to store token in keyring")?;
243    info!("Token stored in system keyring");
244    Ok(())
245}
246
247/// Clears the session-level token cache.
248///
249/// This should be called after logout or when the token is invalidated.
250#[instrument]
251pub fn clear_token_cache() {
252    // OnceLock doesn't provide a direct clear method, but we can work around this
253    // by using take() if it were available. Since it's not, we document that
254    // the cache is session-scoped and will be cleared on process exit.
255    debug!("Token cache cleared (session-scoped)");
256}
257
258/// Deletes the stored GitHub token from the keyring.
259#[cfg(feature = "keyring")]
260#[instrument]
261pub fn delete_token() -> Result<()> {
262    let entry = keyring_entry()?;
263    entry
264        .delete_credential()
265        .context("Failed to delete token from keyring")?;
266    clear_token_cache();
267    info!("Token deleted from keyring");
268    Ok(())
269}
270
271/// Performs the GitHub OAuth device flow authentication.
272///
273/// This function:
274/// 1. Requests a device code from GitHub
275/// 2. Returns the verification URI and user code for display
276/// 3. Polls GitHub until the user authorizes or times out
277/// 4. Stores the resulting token in the system keychain
278///
279/// Requires `APTU_GH_CLIENT_ID` environment variable to be set.
280#[cfg(feature = "keyring")]
281#[instrument]
282pub async fn authenticate(client_id: &SecretString) -> Result<()> {
283    debug!("Starting OAuth device flow");
284
285    // Build a client configured for GitHub's OAuth endpoints
286    let crab = Octocrab::builder()
287        .base_uri("https://github.com")
288        .context("Failed to set base URI")?
289        .add_header(ACCEPT, "application/json".to_string())
290        .build()
291        .context("Failed to build OAuth client")?;
292
293    // Request device and user codes
294    let codes = crab
295        .authenticate_as_device(client_id, OAUTH_SCOPES)
296        .await
297        .context("Failed to request device code")?;
298
299    // Display instructions to user
300    println!();
301    println!("To authenticate, visit:");
302    println!();
303    println!("    {}", codes.verification_uri);
304    println!();
305    println!("And enter the code:");
306    println!();
307    println!("    {}", codes.user_code);
308    println!();
309    println!("Waiting for authorization...");
310
311    // Poll until user authorizes (octocrab handles backoff)
312    let auth = codes
313        .poll_until_available(&crab, client_id)
314        .await
315        .context("Authorization failed or timed out")?;
316
317    // Store the access token
318    let token = SecretString::from(auth.access_token.expose_secret().to_owned());
319    store_token(&token)?;
320
321    info!("Authentication successful");
322    Ok(())
323}
324
325/// Creates an authenticated Octocrab client using the token priority chain.
326///
327/// Uses [`resolve_token`] to find credentials from environment variables,
328/// GitHub CLI, or system keyring.
329///
330/// Returns an error if no token is found from any source.
331#[instrument]
332pub fn create_client() -> Result<Octocrab> {
333    let (token, source) =
334        resolve_token().context("Not authenticated - run `aptu auth login` first")?;
335
336    info!(source = %source, "Creating GitHub client");
337
338    let client = Octocrab::builder()
339        .personal_token(token.expose_secret().to_string())
340        .build()
341        .context("Failed to build GitHub client")?;
342
343    debug!("Created authenticated GitHub client");
344    Ok(client)
345}
346
347/// Creates an authenticated Octocrab client using a provided token.
348///
349/// This function allows callers to provide a token directly, enabling
350/// multi-platform credential resolution (e.g., from iOS keychain via FFI).
351///
352/// # Arguments
353///
354/// * `token` - GitHub API token as a `SecretString`
355///
356/// # Errors
357///
358/// Returns an error if the Octocrab client cannot be built.
359#[instrument(skip(token))]
360pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
361    info!("Creating GitHub client with provided token");
362
363    let client = Octocrab::builder()
364        .personal_token(token.expose_secret().to_string())
365        .build()
366        .context("Failed to build GitHub client")?;
367
368    debug!("Created authenticated GitHub client");
369    Ok(client)
370}
371
372/// Creates a GitHub client from a `TokenProvider`.
373///
374/// This is a convenience function that extracts the token from a provider
375/// and creates an authenticated Octocrab client. It standardizes error handling
376/// across the facade layer.
377///
378/// # Arguments
379///
380/// * `provider` - Token provider that supplies the GitHub token
381///
382/// # Returns
383///
384/// Returns `Ok(Octocrab)` if successful, or an `AptuError::GitHub` if:
385/// - The provider has no token available
386/// - The GitHub client fails to build
387///
388/// # Example
389///
390/// ```ignore
391/// let client = create_client_from_provider(provider)?;
392/// ```
393#[instrument(skip(provider))]
394pub fn create_client_from_provider(
395    provider: &dyn crate::auth::TokenProvider,
396) -> crate::Result<Octocrab> {
397    let github_token = provider
398        .github_token()
399        .ok_or(crate::error::AptuError::NotAuthenticated)?;
400
401    let token = SecretString::from(github_token);
402    create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
403        message: format!("Failed to create GitHub client: {e}"),
404    })
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[cfg(feature = "keyring")]
412    #[test]
413    fn test_keyring_entry_creation() {
414        // Just verify we can create an entry without panicking
415        let result = keyring_entry();
416        assert!(result.is_ok());
417    }
418
419    #[test]
420    fn test_token_source_display() {
421        assert_eq!(TokenSource::Environment.to_string(), "environment variable");
422        assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
423        assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
424    }
425
426    #[test]
427    fn test_token_source_equality() {
428        assert_eq!(TokenSource::Environment, TokenSource::Environment);
429        assert_ne!(TokenSource::Environment, TokenSource::GhCli);
430        assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
431    }
432
433    #[test]
434    fn test_gh_cli_not_installed_returns_none() {
435        // This test verifies that get_token_from_gh_cli gracefully handles
436        // the case where gh is not in PATH (returns None, doesn't panic)
437        // Note: This test may pass even if gh IS installed, because we're
438        // testing the graceful fallback behavior
439        let result = get_token_from_gh_cli();
440        // We can't assert None here because gh might be installed
441        // Just verify it doesn't panic and returns Option
442        let _ = result;
443    }
444
445    #[test]
446    fn test_resolve_token_with_env_var() {
447        // Arrange: Create a mock env reader that returns a test token
448        let mock_env = |key: &str| -> Result<String, std::env::VarError> {
449            match key {
450                "GH_TOKEN" => Ok("test_token_123".to_string()),
451                _ => Err(std::env::VarError::NotPresent),
452            }
453        };
454
455        // Act
456        let result = resolve_token_with_env(mock_env);
457
458        // Assert
459        assert!(result.is_some());
460        let (token, source) = result.unwrap();
461        assert_eq!(token.expose_secret(), "test_token_123");
462        assert_eq!(source, TokenSource::Environment);
463    }
464
465    #[test]
466    fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
467        // Arrange: Create a mock env reader that returns both tokens
468        let mock_env = |key: &str| -> Result<String, std::env::VarError> {
469            match key {
470                "GH_TOKEN" => Ok("gh_token".to_string()),
471                "GITHUB_TOKEN" => Ok("github_token".to_string()),
472                _ => Err(std::env::VarError::NotPresent),
473            }
474        };
475
476        // Act
477        let result = resolve_token_with_env(mock_env);
478
479        // Assert: GH_TOKEN should take priority
480        assert!(result.is_some());
481        let (token, _) = result.unwrap();
482        assert_eq!(token.expose_secret(), "gh_token");
483    }
484}