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::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#[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    let cached = TOKEN_CACHE.get_or_init(resolve_token_inner).as_ref();
243    if let Some((_, source)) = cached {
244        debug!(source = %source, "Cache hit for token resolution");
245    }
246    cached.map(|(token, source)| (token.clone(), *source))
247}
248
249/// Stores a GitHub token in the system keyring.
250#[cfg(feature = "keyring")]
251#[instrument(skip(token))]
252pub fn store_token(token: &SecretString) -> Result<()> {
253    let entry = keyring_entry()?;
254    entry
255        .set_password(token.expose_secret())
256        .context("Failed to store token in keyring")?;
257    info!("Token stored in system keyring");
258    Ok(())
259}
260
261/// Clears the session-level token cache.
262///
263/// This should be called after logout or when the token is invalidated.
264#[instrument]
265pub fn clear_token_cache() {
266    // OnceLock doesn't provide a direct clear method, but we can work around this
267    // by using take() if it were available. Since it's not, we document that
268    // the cache is session-scoped and will be cleared on process exit.
269    debug!("Token cache cleared (session-scoped)");
270}
271
272/// Deletes the stored GitHub token from the keyring.
273#[cfg(feature = "keyring")]
274#[instrument]
275pub fn delete_token() -> Result<()> {
276    let entry = keyring_entry()?;
277    entry
278        .delete_credential()
279        .context("Failed to delete token from keyring")?;
280    clear_token_cache();
281    info!("Token deleted from keyring");
282    Ok(())
283}
284
285/// Performs the GitHub OAuth device flow authentication.
286///
287/// This function:
288/// 1. Requests a device code from GitHub
289/// 2. Returns the verification URI and user code for display
290/// 3. Polls GitHub until the user authorizes or times out
291/// 4. Stores the resulting token in the system keychain
292///
293/// Requires `APTU_GH_CLIENT_ID` environment variable to be set.
294#[cfg(feature = "keyring")]
295#[instrument]
296pub async fn authenticate(client_id: &SecretString) -> Result<()> {
297    debug!("Starting OAuth device flow");
298
299    // Build a client configured for GitHub's OAuth endpoints
300    let crab = Octocrab::builder()
301        .base_uri("https://github.com")
302        .context("Failed to set base URI")?
303        .add_header(ACCEPT, "application/json".to_string())
304        .build()
305        .context("Failed to build OAuth client")?;
306
307    // Request device and user codes
308    let codes = crab
309        .authenticate_as_device(client_id, OAUTH_SCOPES)
310        .await
311        .context("Failed to request device code")?;
312
313    // Display instructions to user
314    println!();
315    println!("To authenticate, visit:");
316    println!();
317    println!("    {}", codes.verification_uri);
318    println!();
319    println!("And enter the code:");
320    println!();
321    println!("    {}", codes.user_code);
322    println!();
323    println!("Waiting for authorization...");
324
325    // Poll until user authorizes (octocrab handles backoff)
326    let auth = codes
327        .poll_until_available(&crab, client_id)
328        .await
329        .context("Authorization failed or timed out")?;
330
331    // Store the access token
332    let token = SecretString::from(auth.access_token.expose_secret().to_owned());
333    store_token(&token)?;
334
335    info!("Authentication successful");
336    Ok(())
337}
338
339/// Creates an authenticated Octocrab client using the token priority chain.
340///
341/// Uses [`resolve_token`] to find credentials from environment variables,
342/// GitHub CLI, or system keyring.
343///
344/// Returns an error if no token is found from any source.
345#[instrument]
346pub fn create_client() -> Result<Octocrab> {
347    let (token, source) =
348        resolve_token().context("Not authenticated - run `aptu auth login` first")?;
349
350    info!(source = %source, "Creating GitHub client");
351
352    let client = Octocrab::builder()
353        .personal_token(token.expose_secret().to_string())
354        .build()
355        .context("Failed to build GitHub client")?;
356
357    debug!("Created authenticated GitHub client");
358    Ok(client)
359}
360
361/// Creates an authenticated Octocrab client using a provided token.
362///
363/// This function allows callers to provide a token directly, enabling
364/// multi-platform credential resolution (e.g., from iOS keychain via FFI).
365///
366/// # Arguments
367///
368/// * `token` - GitHub API token as a `SecretString`
369///
370/// # Errors
371///
372/// Returns an error if the Octocrab client cannot be built.
373#[instrument(skip(token))]
374pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
375    info!("Creating GitHub client with provided token");
376
377    let client = Octocrab::builder()
378        .personal_token(token.expose_secret().to_string())
379        .build()
380        .context("Failed to build GitHub client")?;
381
382    debug!("Created authenticated GitHub client");
383    Ok(client)
384}
385
386/// Creates a GitHub client from a `TokenProvider`.
387///
388/// This is a convenience function that extracts the token from a provider
389/// and creates an authenticated Octocrab client. It standardizes error handling
390/// across the facade layer.
391///
392/// # Arguments
393///
394/// * `provider` - Token provider that supplies the GitHub token
395///
396/// # Returns
397///
398/// Returns `Ok(Octocrab)` if successful, or an `AptuError::GitHub` if:
399/// - The provider has no token available
400/// - The GitHub client fails to build
401///
402/// # Example
403///
404/// ```ignore
405/// let client = create_client_from_provider(provider)?;
406/// ```
407#[instrument(skip(provider))]
408pub fn create_client_from_provider(
409    provider: &dyn crate::auth::TokenProvider,
410) -> crate::Result<Octocrab> {
411    let github_token = provider
412        .github_token()
413        .ok_or(crate::error::AptuError::NotAuthenticated)?;
414
415    let token = SecretString::from(github_token);
416    create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
417        message: format!("Failed to create GitHub client: {e}"),
418    })
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[cfg(feature = "keyring")]
426    #[test]
427    fn test_keyring_entry_creation() {
428        // Just verify we can create an entry without panicking
429        let result = keyring_entry();
430        assert!(result.is_ok());
431    }
432
433    #[test]
434    fn test_token_source_display() {
435        assert_eq!(TokenSource::Environment.to_string(), "environment variable");
436        assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
437        assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
438    }
439
440    #[test]
441    fn test_token_source_equality() {
442        assert_eq!(TokenSource::Environment, TokenSource::Environment);
443        assert_ne!(TokenSource::Environment, TokenSource::GhCli);
444        assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
445    }
446
447    #[test]
448    fn test_gh_cli_not_installed_returns_none() {
449        // This test verifies that get_token_from_gh_cli gracefully handles
450        // the case where gh is not in PATH (returns None, doesn't panic)
451        // Note: This test may pass even if gh IS installed, because we're
452        // testing the graceful fallback behavior
453        let result = get_token_from_gh_cli();
454        // We can't assert None here because gh might be installed
455        // Just verify it doesn't panic and returns Option
456        let _ = result;
457    }
458
459    #[test]
460    fn test_resolve_token_with_env_var() {
461        // Arrange: Create a mock env reader that returns a test token
462        let mock_env = |key: &str| -> Result<String, std::env::VarError> {
463            match key {
464                "GH_TOKEN" => Ok("test_token_123".to_string()),
465                _ => Err(std::env::VarError::NotPresent),
466            }
467        };
468
469        // Act
470        let result = resolve_token_with_env(mock_env);
471
472        // Assert
473        assert!(result.is_some());
474        let (token, source) = result.unwrap();
475        assert_eq!(token.expose_secret(), "test_token_123");
476        assert_eq!(source, TokenSource::Environment);
477    }
478
479    #[test]
480    fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
481        // Arrange: Create a mock env reader that returns both tokens
482        let mock_env = |key: &str| -> Result<String, std::env::VarError> {
483            match key {
484                "GH_TOKEN" => Ok("gh_token".to_string()),
485                "GITHUB_TOKEN" => Ok("github_token".to_string()),
486                _ => Err(std::env::VarError::NotPresent),
487            }
488        };
489
490        // Act
491        let result = resolve_token_with_env(mock_env);
492
493        // Assert: GH_TOKEN should take priority
494        assert!(result.is_some());
495        let (token, _) = result.unwrap();
496        assert_eq!(token.expose_secret(), "gh_token");
497    }
498}