use std::process::Command;
use std::sync::OnceLock;
use anyhow::{Context, Result};
#[cfg(feature = "keyring")]
use keyring::Entry;
use octocrab::Octocrab;
#[cfg(feature = "keyring")]
use reqwest::header::ACCEPT;
use secrecy::{ExposeSecret, SecretString};
use serde::Serialize;
use tracing::{debug, info, instrument};
#[cfg(feature = "keyring")]
use super::{KEYRING_SERVICE, KEYRING_USER};
static TOKEN_CACHE: OnceLock<Option<(SecretString, TokenSource)>> = OnceLock::new();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenSource {
Environment,
GhCli,
Keyring,
}
impl std::fmt::Display for TokenSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TokenSource::Environment => write!(f, "environment variable"),
TokenSource::GhCli => write!(f, "GitHub CLI"),
TokenSource::Keyring => write!(f, "system keyring"),
}
}
}
#[cfg(feature = "keyring")]
const OAUTH_SCOPES: &[&str] = &["repo", "read:user"];
#[cfg(feature = "keyring")]
fn keyring_entry() -> Result<Entry> {
Entry::new(KEYRING_SERVICE, KEYRING_USER).context("Failed to create keyring entry")
}
#[instrument]
#[allow(clippy::let_and_return)] pub fn is_authenticated() -> bool {
let result = resolve_token().is_some();
result
}
#[cfg(feature = "keyring")]
#[instrument]
#[allow(clippy::let_and_return)] pub fn has_keyring_token() -> bool {
let result = match keyring_entry() {
Ok(entry) => entry.get_password().is_ok(),
Err(_) => false,
};
result
}
#[cfg(feature = "keyring")]
#[must_use]
pub fn get_stored_token() -> Option<SecretString> {
let entry = keyring_entry().ok()?;
Some(SecretString::from(entry.get_password().ok()?))
}
fn parse_gh_cli_output(output: &std::process::Output) -> Option<SecretString> {
if output.status.success() {
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() {
debug!("gh auth token returned empty output");
None
} else {
debug!("Successfully retrieved token from gh CLI");
Some(SecretString::from(token))
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
debug!(
status = ?output.status,
stderr = %stderr.trim(),
"gh auth token failed"
);
None
}
}
#[instrument]
fn get_token_from_gh_cli() -> Option<SecretString> {
debug!("Attempting to get token from gh CLI");
let output = Command::new("gh").args(["auth", "token"]).output();
match output {
Ok(output) => parse_gh_cli_output(&output),
Err(e) => {
debug!(error = %e, "Failed to execute gh command");
None
}
}
}
fn check_env_token<F>(env_reader: &F, var_name: &str) -> Option<SecretString>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
env_reader(var_name)
.ok()
.filter(|token| !token.is_empty())
.map(SecretString::from)
}
fn resolve_token_with_env<F>(env_reader: F) -> Option<(SecretString, TokenSource)>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
let token = check_env_token(&env_reader, "GH_TOKEN");
if token.is_some() {
debug!("Using token from GH_TOKEN environment variable");
}
let result = token.map(|t| (t, TokenSource::Environment));
let result = result.or_else(|| {
let token = check_env_token(&env_reader, "GITHUB_TOKEN");
if token.is_some() {
debug!("Using token from GITHUB_TOKEN environment variable");
}
token.map(|t| (t, TokenSource::Environment))
});
let result = result.or_else(|| {
let token = get_token_from_gh_cli();
if token.is_some() {
debug!("Using token from GitHub CLI");
}
token.map(|t| (t, TokenSource::GhCli))
});
#[cfg(feature = "keyring")]
let result = result.or_else(|| get_stored_token().map(|t| (t, TokenSource::Keyring)));
if result.is_none() {
debug!("No token found in any source");
}
result
}
fn resolve_token_inner() -> Option<(SecretString, TokenSource)> {
resolve_token_with_env(|key| std::env::var(key))
}
#[instrument]
pub fn resolve_token() -> Option<(SecretString, TokenSource)> {
let cached = TOKEN_CACHE.get_or_init(resolve_token_inner).as_ref();
if let Some((_, source)) = cached {
debug!(source = %source, "Cache hit for token resolution");
}
cached.map(|(token, source)| (token.clone(), *source))
}
#[cfg(feature = "keyring")]
#[instrument(skip(token))]
pub fn store_token(token: &SecretString) -> Result<()> {
let entry = keyring_entry()?;
entry
.set_password(token.expose_secret())
.context("Failed to store token in keyring")?;
info!("Token stored in system keyring");
Ok(())
}
#[instrument]
pub fn clear_token_cache() {
debug!("Token cache cleared (session-scoped)");
}
#[cfg(feature = "keyring")]
#[instrument]
pub fn delete_token() -> Result<()> {
let entry = keyring_entry()?;
entry
.delete_credential()
.context("Failed to delete token from keyring")?;
clear_token_cache();
info!("Token deleted from keyring");
Ok(())
}
#[cfg(feature = "keyring")]
#[instrument]
pub async fn authenticate(client_id: &SecretString) -> Result<()> {
debug!("Starting OAuth device flow");
let crab = Octocrab::builder()
.base_uri("https://github.com")
.context("Failed to set base URI")?
.add_header(ACCEPT, "application/json".to_string())
.build()
.context("Failed to build OAuth client")?;
let codes = crab
.authenticate_as_device(client_id, OAUTH_SCOPES)
.await
.context("Failed to request device code")?;
println!();
println!("To authenticate, visit:");
println!();
println!(" {}", codes.verification_uri);
println!();
println!("And enter the code:");
println!();
println!(" {}", codes.user_code);
println!();
println!("Waiting for authorization...");
let auth = codes
.poll_until_available(&crab, client_id)
.await
.context("Authorization failed or timed out")?;
let token = SecretString::from(auth.access_token.expose_secret().to_owned());
store_token(&token)?;
info!("Authentication successful");
Ok(())
}
#[instrument]
pub fn create_client() -> Result<Octocrab> {
let (token, source) =
resolve_token().context("Not authenticated - run `aptu auth login` first")?;
info!(source = %source, "Creating GitHub client");
let client = Octocrab::builder()
.personal_token(token.expose_secret().to_string())
.build()
.context("Failed to build GitHub client")?;
debug!("Created authenticated GitHub client");
Ok(client)
}
#[instrument(skip(token))]
pub fn create_client_with_token(token: &SecretString) -> Result<Octocrab> {
info!("Creating GitHub client with provided token");
let client = Octocrab::builder()
.personal_token(token.expose_secret().to_string())
.build()
.context("Failed to build GitHub client")?;
debug!("Created authenticated GitHub client");
Ok(client)
}
#[instrument(skip(provider))]
pub fn create_client_from_provider(
provider: &dyn crate::auth::TokenProvider,
) -> crate::Result<Octocrab> {
let github_token = provider
.github_token()
.ok_or(crate::error::AptuError::NotAuthenticated)?;
let token = SecretString::from(github_token);
create_client_with_token(&token).map_err(|e| crate::error::AptuError::GitHub {
message: format!("Failed to create GitHub client: {e}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "keyring")]
#[test]
fn test_keyring_entry_creation() {
let result = keyring_entry();
assert!(result.is_ok());
}
#[test]
fn test_token_source_display() {
assert_eq!(TokenSource::Environment.to_string(), "environment variable");
assert_eq!(TokenSource::GhCli.to_string(), "GitHub CLI");
assert_eq!(TokenSource::Keyring.to_string(), "system keyring");
}
#[test]
fn test_token_source_equality() {
assert_eq!(TokenSource::Environment, TokenSource::Environment);
assert_ne!(TokenSource::Environment, TokenSource::GhCli);
assert_ne!(TokenSource::GhCli, TokenSource::Keyring);
}
#[test]
fn test_gh_cli_not_installed_returns_none() {
let result = get_token_from_gh_cli();
let _ = result;
}
#[test]
fn test_resolve_token_with_env_var() {
let mock_env = |key: &str| -> Result<String, std::env::VarError> {
match key {
"GH_TOKEN" => Ok("test_token_123".to_string()),
_ => Err(std::env::VarError::NotPresent),
}
};
let result = resolve_token_with_env(mock_env);
assert!(result.is_some());
let (token, source) = result.unwrap();
assert_eq!(token.expose_secret(), "test_token_123");
assert_eq!(source, TokenSource::Environment);
}
#[test]
fn test_resolve_token_with_env_prefers_gh_token_over_github_token() {
let mock_env = |key: &str| -> Result<String, std::env::VarError> {
match key {
"GH_TOKEN" => Ok("gh_token".to_string()),
"GITHUB_TOKEN" => Ok("github_token".to_string()),
_ => Err(std::env::VarError::NotPresent),
}
};
let result = resolve_token_with_env(mock_env);
assert!(result.is_some());
let (token, _) = result.unwrap();
assert_eq!(token.expose_secret(), "gh_token");
}
}