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