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