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