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;
17
18use anyhow::{Context, Result};
19use keyring::Entry;
20use octocrab::Octocrab;
21use reqwest::header::ACCEPT;
22use secrecy::{ExposeSecret, SecretString};
23use tracing::{debug, info, instrument};
24
25use super::{KEYRING_SERVICE, KEYRING_USER};
26
27/// Source of the GitHub authentication token.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TokenSource {
30    /// Token from `GH_TOKEN` or `GITHUB_TOKEN` environment variable.
31    Environment,
32    /// Token from `gh auth token` command.
33    GhCli,
34    /// Token from system keyring (native aptu auth).
35    Keyring,
36}
37
38impl std::fmt::Display for TokenSource {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            TokenSource::Environment => write!(f, "environment variable"),
42            TokenSource::GhCli => write!(f, "GitHub CLI"),
43            TokenSource::Keyring => write!(f, "system keyring"),
44        }
45    }
46}
47
48/// OAuth scopes required for Aptu functionality.
49const OAUTH_SCOPES: &[&str] = &["repo", "read:user"];
50
51/// Creates a keyring entry for the GitHub token.
52fn keyring_entry() -> Result<Entry> {
53    Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
54}
55
56/// Checks if a GitHub token is available from any source.
57///
58/// Uses the token resolution priority chain to check for authentication.
59#[instrument]
60#[allow(clippy::let_and_return)] // Intentional: Rust 2024 drop order compliance
61pub fn is_authenticated() -> bool {
62    let result = resolve_token().is_some();
63    result
64}
65
66/// Checks if a GitHub token is stored in the keyring specifically.
67///
68/// Returns `true` only if a token exists in the system keyring,
69/// ignoring environment variables and `gh` CLI.
70#[instrument]
71#[allow(clippy::let_and_return)] // Intentional: Rust 2024 drop order compliance
72pub fn has_keyring_token() -> bool {
73    let result = match keyring_entry() {
74        Ok(entry) => entry.get_password().is_ok(),
75        Err(_) => false,
76    };
77    result
78}
79
80/// Retrieves the stored GitHub token from the keyring.
81///
82/// Returns `None` if no token is stored or if keyring access fails.
83#[instrument]
84pub fn get_stored_token() -> Option<SecretString> {
85    let entry = keyring_entry().ok()?;
86    let password = entry.get_password().ok()?;
87    debug!("Retrieved token from keyring");
88    Some(SecretString::from(password))
89}
90
91/// Attempts to get a token from the GitHub CLI (`gh auth token`).
92///
93/// Returns `None` if:
94/// - `gh` is not installed
95/// - `gh` is not authenticated
96/// - The command times out (5 seconds)
97/// - Any other error occurs
98#[instrument]
99fn get_token_from_gh_cli() -> Option<SecretString> {
100    debug!("Attempting to get token from gh CLI");
101
102    // Use wait-timeout crate pattern with std::process
103    let output = Command::new("gh").args(["auth", "token"]).output();
104
105    match output {
106        Ok(output) 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        }
116        Ok(output) => {
117            let stderr = String::from_utf8_lossy(&output.stderr);
118            debug!(
119                status = ?output.status,
120                stderr = %stderr.trim(),
121                "gh auth token failed"
122            );
123            None
124        }
125        Err(e) => {
126            debug!(error = %e, "Failed to execute gh command");
127            None
128        }
129    }
130}
131
132/// Resolves a GitHub token using the priority chain.
133///
134/// Checks sources in order:
135/// 1. `GH_TOKEN` environment variable
136/// 2. `GITHUB_TOKEN` environment variable
137/// 3. GitHub CLI (`gh auth token`)
138/// 4. System keyring (native aptu auth)
139///
140/// Returns the token and its source, or `None` if no token is found.
141#[instrument]
142pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
143    // Priority 1: GH_TOKEN environment variable
144    if let Ok(token) = std::env::var("GH_TOKEN")
145        && !token.is_empty()
146    {
147        debug!("Using token from GH_TOKEN environment variable");
148        return Some((SecretString::from(token), TokenSource::Environment));
149    }
150
151    // Priority 2: GITHUB_TOKEN environment variable
152    if let Ok(token) = std::env::var("GITHUB_TOKEN")
153        && !token.is_empty()
154    {
155        debug!("Using token from GITHUB_TOKEN environment variable");
156        return Some((SecretString::from(token), TokenSource::Environment));
157    }
158
159    // Priority 3: GitHub CLI
160    if let Some(token) = get_token_from_gh_cli() {
161        debug!("Using token from GitHub CLI");
162        return Some((token, TokenSource::GhCli));
163    }
164
165    // Priority 4: System keyring
166    if let Some(token) = get_stored_token() {
167        debug!("Using token from system keyring");
168        return Some((token, TokenSource::Keyring));
169    }
170
171    debug!("No token found in any source");
172    None
173}
174
175/// Stores a GitHub token in the system keyring.
176#[instrument(skip(token))]
177pub fn store_token(token: &SecretString) -> Result<()> {
178    let entry = keyring_entry()?;
179    entry
180        .set_password(token.expose_secret())
181        .context("Failed to store token in keyring")?;
182    info!("Token stored in system keyring");
183    Ok(())
184}
185
186/// Deletes the stored GitHub token from the keyring.
187#[instrument]
188pub fn delete_token() -> Result<()> {
189    let entry = keyring_entry()?;
190    entry
191        .delete_credential()
192        .context("Failed to delete token from keyring")?;
193    info!("Token deleted from keyring");
194    Ok(())
195}
196
197/// Performs the GitHub OAuth device flow authentication.
198///
199/// This function:
200/// 1. Requests a device code from GitHub
201/// 2. Returns the verification URI and user code for display
202/// 3. Polls GitHub until the user authorizes or times out
203/// 4. Stores the resulting token in the system keychain
204///
205/// Requires `APTU_GH_CLIENT_ID` environment variable to be set.
206#[instrument]
207pub async fn authenticate(client_id: &SecretString) -> Result<()> {
208    debug!("Starting OAuth device flow");
209
210    // Build a client configured for GitHub's OAuth endpoints
211    let crab = Octocrab::builder()
212        .base_uri("https://github.com")
213        .context("Failed to set base URI")?
214        .add_header(ACCEPT, "application/json".to_string())
215        .build()
216        .context("Failed to build OAuth client")?;
217
218    // Request device and user codes
219    let codes = crab
220        .authenticate_as_device(client_id, OAUTH_SCOPES)
221        .await
222        .context("Failed to request device code")?;
223
224    // Display instructions to user
225    println!();
226    println!("To authenticate, visit:");
227    println!();
228    println!("    {}", codes.verification_uri);
229    println!();
230    println!("And enter the code:");
231    println!();
232    println!("    {}", codes.user_code);
233    println!();
234    println!("Waiting for authorization...");
235
236    // Poll until user authorizes (octocrab handles backoff)
237    let auth = codes
238        .poll_until_available(&crab, client_id)
239        .await
240        .context("Authorization failed or timed out")?;
241
242    // Store the access token
243    let token = SecretString::from(auth.access_token.expose_secret().to_owned());
244    store_token(&token)?;
245
246    info!("Authentication successful");
247    Ok(())
248}
249
250/// Creates an authenticated Octocrab client using the token priority chain.
251///
252/// Uses [`resolve_token`] to find credentials from environment variables,
253/// GitHub CLI, or system keyring.
254///
255/// Returns an error if no token is found from any source.
256#[instrument]
257pub fn create_client() -> Result<Octocrab> {
258    let (token, source) =
259        resolve_token().context("Not authenticated - run `aptu auth login` first")?;
260
261    info!(source = %source, "Creating GitHub client");
262
263    let client = Octocrab::builder()
264        .personal_token(token.expose_secret().to_string())
265        .build()
266        .context("Failed to build GitHub client")?;
267
268    debug!("Created authenticated GitHub client");
269    Ok(client)
270}
271
272/// Creates an authenticated Octocrab client using a provided token.
273///
274/// This function allows callers to provide a token directly, enabling
275/// multi-platform credential resolution (e.g., from iOS keychain via FFI).
276///
277/// # Arguments
278///
279/// * `token` - GitHub API token as a `SecretString`
280///
281/// # Errors
282///
283/// Returns an error if the Octocrab client cannot be built.
284#[instrument(skip(token))]
285pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
286    info!("Creating GitHub client with provided token");
287
288    let client = Octocrab::builder()
289        .personal_token(token.expose_secret().to_string())
290        .build()
291        .context("Failed to build GitHub client")?;
292
293    debug!("Created authenticated GitHub client");
294    Ok(client)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_keyring_entry_creation() {
303        // Just verify we can create an entry without panicking
304        let result = keyring_entry();
305        assert!(result.is_ok());
306    }
307
308    #[test]
309    fn test_token_source_display() {
310        assert_eq!(TokenSource::Environment.to_string(), "environment variable");
311        assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
312        assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
313    }
314
315    #[test]
316    fn test_token_source_equality() {
317        assert_eq!(TokenSource::Environment, TokenSource::Environment);
318        assert_ne!(TokenSource::Environment, TokenSource::GhCli);
319        assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
320    }
321
322    #[test]
323    fn test_gh_cli_not_installed_returns_none() {
324        // This test verifies that get_token_from_gh_cli gracefully handles
325        // the case where gh is not in PATH (returns None, doesn't panic)
326        // Note: This test may pass even if gh IS installed, because we're
327        // testing the graceful fallback behavior
328        let result = get_token_from_gh_cli();
329        // We can't assert None here because gh might be installed
330        // Just verify it doesn't panic and returns Option
331        let _ = result;
332    }
333}