use anyhow::Context;
#[cfg(test)]
use once_cell::sync::Lazy;
use std::env;
#[cfg(test)]
use std::sync::Mutex;
use std::sync::{Arc, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
#[derive(Debug, Clone, PartialEq, Default)]
pub enum TokenMode {
#[default]
Env,
GitHubApp {
app_id: String,
private_key_path: String,
},
}
impl TokenMode {
#[allow(dead_code)]
pub fn from_config(
mode: &str,
app_id: Option<String>,
private_key_path: Option<String>,
) -> Self {
match mode {
"github_app" => {
if let (Some(app_id), Some(key_path)) = (app_id, private_key_path) {
TokenMode::GitHubApp {
app_id,
private_key_path: key_path,
}
} else {
tracing::warn!(
"github_app mode requires app_id and private_key_path, falling back to env"
);
TokenMode::Env
}
}
_ => TokenMode::Env,
}
}
}
#[derive(Debug, Clone)]
struct CachedToken {
token: String,
expires_at: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct TokenResolver {
mode: TokenMode,
allow_gh_fallback: bool,
cached: Arc<RwLock<Option<CachedToken>>>,
}
impl TokenResolver {
pub fn new(mode: TokenMode) -> Self {
Self {
mode,
allow_gh_fallback: true,
cached: Arc::new(RwLock::new(None)),
}
}
#[allow(dead_code)]
pub fn from_config(
mode_str: Option<&str>,
app_id: Option<String>,
private_key_path: Option<String>,
allow_gh_fallback: bool,
) -> Self {
let mode = TokenMode::from_config(mode_str.unwrap_or("env"), app_id, private_key_path);
Self {
mode,
allow_gh_fallback,
cached: Arc::new(RwLock::new(None)),
}
}
pub fn default_env() -> Self {
Self::new(TokenMode::Env)
}
#[allow(dead_code)]
pub fn with_gh_fallback(mut self, enabled: bool) -> Self {
self.allow_gh_fallback = enabled;
self
}
pub async fn get_token(&self) -> anyhow::Result<Option<String>> {
{
let cached = self.cached.read().await;
if let Some(ref entry) = *cached {
if let Some(expires_at) = entry.expires_at {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now < expires_at {
return Ok(Some(entry.token.clone()));
}
} else {
return Ok(Some(entry.token.clone()));
}
}
}
let token = self.resolve_fresh().await?;
if let Some(ref t) = token {
let expires_at = self.calculate_expiration();
let mut cached = self.cached.write().await;
*cached = Some(CachedToken {
token: t.clone(),
expires_at,
});
}
Ok(token)
}
#[allow(dead_code)]
pub fn get_token_sync(&self) -> anyhow::Result<Option<String>> {
match self.mode {
TokenMode::Env => {
if let Some(t) = Self::resolve_env_token() {
return Ok(Some(t));
}
if self.allow_gh_fallback {
return Self::resolve_gh_cli_token();
}
Ok(None)
}
TokenMode::GitHubApp { .. } => {
Err(anyhow::anyhow!(
"GitHub App token resolution requires async context, use get_token() instead"
))
}
}
}
pub async fn clear_cache(&self) {
let mut cached = self.cached.write().await;
*cached = None;
}
#[allow(dead_code)]
pub fn mode(&self) -> &TokenMode {
&self.mode
}
async fn resolve_fresh(&self) -> anyhow::Result<Option<String>> {
let token = match &self.mode {
TokenMode::Env => Self::resolve_env_token(),
TokenMode::GitHubApp {
app_id,
private_key_path,
} => {
self.resolve_github_app_token(app_id, private_key_path)
.await?
}
};
if token.is_some() {
return Ok(token);
}
if self.allow_gh_fallback {
tracing::debug!("No token in env/config — trying gh auth token CLI");
return Self::resolve_gh_cli_token();
}
Ok(None)
}
fn resolve_env_token() -> Option<String> {
if let Ok(t) = env::var("GH_TOKEN") {
if !t.is_empty() {
tracing::info!("Resolved GitHub token from GH_TOKEN environment variable");
return Some(t);
}
}
if let Ok(t) = env::var("GITHUB_TOKEN") {
if !t.is_empty() {
tracing::info!("Resolved GitHub token from GITHUB_TOKEN environment variable");
return Some(t);
}
}
if let Ok(t) = crate::config::get("gh.auth.token") {
if !t.is_empty() {
tracing::info!("Resolved GitHub token from gh.auth.token config");
return Some(t);
}
}
None
}
fn resolve_gh_cli_token() -> anyhow::Result<Option<String>> {
tracing::debug!("Attempting to resolve GitHub token via gh CLI");
if let Some(token) = Self::try_gh_command("gh")? {
return Ok(Some(token));
}
for path in &["/opt/homebrew/bin/gh", "/usr/local/bin/gh"] {
if let Some(token) = Self::try_gh_command(path)? {
return Ok(Some(token));
}
}
Ok(None)
}
fn try_gh_command(gh_path: &str) -> anyhow::Result<Option<String>> {
match std::process::Command::new(gh_path)
.args(["auth", "token"])
.output()
{
Ok(out) if out.status.success() => {
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !token.is_empty() {
tracing::info!(gh_path, "Resolved GitHub token via gh CLI (keychain)");
return Ok(Some(token));
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::debug!(gh_path, %stderr, "gh auth token failed");
}
Err(e) => {
tracing::debug!(gh_path, error = %e, "Failed to execute gh command");
}
}
Ok(None)
}
async fn resolve_github_app_token(
&self,
app_id: &str,
private_key_path: &str,
) -> anyhow::Result<Option<String>> {
tracing::debug!(app_id, private_key_path, "Generating GitHub App JWT");
let private_key = tokio::fs::read_to_string(private_key_path)
.await
.with_context(|| format!("Failed to read private key from {private_key_path}"))?;
let jwt = generate_github_app_jwt(app_id, &private_key)?;
tracing::warn!(
"GitHub App JWT generation is available but token exchange requires installation_id"
);
Ok(Some(jwt))
}
fn calculate_expiration(&self) -> Option<u64> {
match self.mode {
TokenMode::GitHubApp { .. } => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Some(now + 540)
}
_ => None,
}
}
}
static SHARED_RESOLVER: OnceLock<Arc<TokenResolver>> = OnceLock::new();
pub fn shared() -> Arc<TokenResolver> {
SHARED_RESOLVER
.get_or_init(|| Arc::new(TokenResolver::default_env()))
.clone()
}
impl Default for TokenResolver {
fn default() -> Self {
Self::default_env()
}
}
fn generate_github_app_jwt(app_id: &str, private_key_pem: &str) -> anyhow::Result<String> {
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
iat: u64,
exp: u64,
iss: String,
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let claims = Claims {
iat: now - 60, exp: now + 540, iss: app_id.to_string(),
};
let header = Header::new(Algorithm::RS256);
let encoding_key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
.context("Failed to parse private key PEM")?;
let token = encode(&header, &claims, &encoding_key).context("Failed to encode JWT")?;
Ok(token)
}
#[allow(dead_code)]
pub fn create_token_resolver() -> TokenResolver {
let mode = crate::config::get("github.token_mode").ok();
let app_id = crate::config::get("github.app_id").ok();
let private_key_path = crate::config::get("github.private_key_path").ok();
let allow_gh_fallback = crate::config::get("gh.allow_gh_fallback")
.ok()
.map(|s| s != "false" && s != "0")
.unwrap_or(true);
TokenResolver::from_config(mode.as_deref(), app_id, private_key_path, allow_gh_fallback)
}
#[cfg(test)]
pub(crate) static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_mode_from_config_env() {
let mode = TokenMode::from_config("env", None, None);
assert!(matches!(mode, TokenMode::Env));
}
#[test]
fn token_mode_from_config_unknown_defaults_to_env() {
let mode = TokenMode::from_config("unknown", None, None);
assert!(matches!(mode, TokenMode::Env));
}
#[test]
fn token_mode_from_config_github_app() {
let mode = TokenMode::from_config(
"github_app",
Some("123456".to_string()),
Some("/path/to/key.pem".to_string()),
);
assert!(matches!(mode, TokenMode::GitHubApp { .. }));
}
#[test]
fn token_mode_from_config_github_app_missing_params() {
let mode = TokenMode::from_config("github_app", Some("123456".to_string()), None);
assert!(matches!(mode, TokenMode::Env));
let mode = TokenMode::from_config("github_app", None, Some("/path/to/key.pem".to_string()));
assert!(matches!(mode, TokenMode::Env));
}
#[test]
fn token_resolver_default_is_env() {
let resolver = TokenResolver::default();
assert!(matches!(resolver.mode(), TokenMode::Env));
}
#[test]
fn token_resolver_new_env() {
let resolver = TokenResolver::new(TokenMode::Env);
assert!(matches!(resolver.mode(), TokenMode::Env));
}
#[test]
fn token_resolver_gh_fallback_enabled_by_default() {
let resolver = TokenResolver::new(TokenMode::Env);
assert!(resolver.allow_gh_fallback);
}
#[test]
fn token_resolver_with_gh_fallback() {
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
assert!(!resolver.allow_gh_fallback);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_returns_none_when_no_env_set() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
resolver.clear_cache().await;
let token = resolver.get_token().await.unwrap();
assert!(token.is_none());
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_prefers_gh_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "gh_token_value");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
let token = resolver.get_token().await.unwrap();
assert_eq!(token, Some("gh_token_value".to_string()));
env::remove_var("GH_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_falls_back_to_github_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GITHUB_TOKEN", "github_token_value");
let token = resolver.get_token().await.unwrap();
assert_eq!(token, Some("github_token_value".to_string()));
env::remove_var("GITHUB_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_prefers_gh_token_over_github_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "gh_token_value");
env::set_var("GITHUB_TOKEN", "github_token_value");
let token = resolver.get_token().await.unwrap();
assert_eq!(token, Some("gh_token_value".to_string()));
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_ignores_empty_env_vars() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "");
env::set_var("GITHUB_TOKEN", "");
let token = resolver.get_token().await.unwrap();
assert!(token.is_none());
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn get_token_sync_env_mode() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "sync_test_token");
let token = resolver.get_token_sync().unwrap();
assert_eq!(token, Some("sync_test_token".to_string()));
env::remove_var("GH_TOKEN");
}
#[test]
fn get_token_sync_github_app_requires_async() {
let resolver = TokenResolver::new(TokenMode::GitHubApp {
app_id: "123".to_string(),
private_key_path: "/path/to/key.pem".to_string(),
});
let result = resolver.get_token_sync();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("async"));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "current_thread")]
async fn clear_cache_clears_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let resolver = TokenResolver::new(TokenMode::Env).with_gh_fallback(false);
resolver.clear_cache().await;
env::set_var("GH_TOKEN", "cached_token");
let token1 = resolver.get_token().await.unwrap();
assert_eq!(token1, Some("cached_token".to_string()));
resolver.clear_cache().await;
let token2 = resolver.get_token().await.unwrap();
assert_eq!(token2, Some("cached_token".to_string()));
env::remove_var("GH_TOKEN");
}
#[test]
fn resolve_env_token_returns_none_when_unset() {
let _guard = ENV_LOCK.lock().unwrap();
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
let token = TokenResolver::resolve_env_token();
assert!(token.is_none());
}
#[test]
fn resolve_env_token_prefers_gh_token() {
let _guard = ENV_LOCK.lock().unwrap();
env::set_var("GH_TOKEN", "gh_pref");
env::set_var("GITHUB_TOKEN", "github_pref");
let token = TokenResolver::resolve_env_token();
assert_eq!(token, Some("gh_pref".to_string()));
env::remove_var("GH_TOKEN");
env::remove_var("GITHUB_TOKEN");
}
}