use anyhow::{Context, Result};
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use serde::{Deserialize, Serialize};
use std::env;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
iat: u64,
exp: u64,
iss: String,
}
#[derive(Debug, Deserialize)]
struct InstallationToken {
token: String,
expires_at: String,
}
#[derive(Debug, Clone)]
struct CachedToken {
token: String,
expires_at: SystemTime,
}
lazy_static::lazy_static! {
static ref TOKEN_CACHE: Arc<Mutex<Option<CachedToken>>> = Arc::new(Mutex::new(None));
}
fn is_app_configured_with(
app_id: Option<&str>,
installation_id: Option<&str>,
private_key: Option<&str>,
) -> bool {
app_id.is_some() && installation_id.is_some() && private_key.is_some()
}
pub fn is_app_configured() -> bool {
is_app_configured_with(
env::var("MX_GITHUB_APP_ID").ok().as_deref(),
env::var("MX_GITHUB_INSTALLATION_ID").ok().as_deref(),
env::var("MX_GITHUB_PRIVATE_KEY").ok().as_deref(),
)
}
pub fn generate_jwt(app_id: &str, private_key: &str) -> Result<String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("System time before UNIX epoch")?
.as_secs();
let claims = Claims {
iat: now - 60, exp: now + 600, iss: app_id.to_string(),
};
let header = Header::new(Algorithm::RS256);
let pem = pem::parse(private_key.as_bytes()).context("Failed to parse PEM format")?;
let encoding_key = EncodingKey::from_rsa_der(pem.contents());
encode(&header, &claims, &encoding_key).context("Failed to encode JWT")
}
pub fn get_installation_token() -> Result<String> {
{
let cache = TOKEN_CACHE.lock().unwrap();
if let Some(cached) = cache.as_ref() {
let now = SystemTime::now();
if cached.expires_at > now + Duration::from_secs(300) {
return Ok(cached.token.clone());
}
}
}
let app_id =
env::var("MX_GITHUB_APP_ID").context("MX_GITHUB_APP_ID environment variable not set")?;
let installation_id = env::var("MX_GITHUB_INSTALLATION_ID")
.context("MX_GITHUB_INSTALLATION_ID environment variable not set")?;
let private_key = env::var("MX_GITHUB_PRIVATE_KEY")
.context("MX_GITHUB_PRIVATE_KEY environment variable not set")?;
let jwt = generate_jwt(&app_id, &private_key)?;
let client = reqwest::blocking::Client::new();
let url = format!(
"https://api.github.com/app/installations/{}/access_tokens",
installation_id
);
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", jwt))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "mx-cli")
.send()
.context("Failed to request installation token")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().unwrap_or_default();
anyhow::bail!("GitHub API returned error {}: {}", status, body);
}
let token_response: InstallationToken = response
.json()
.context("Failed to parse installation token response")?;
let expires_at = chrono::DateTime::parse_from_rfc3339(&token_response.expires_at)
.context("Failed to parse token expiry time")?
.with_timezone(&chrono::Utc);
let expires_at_system = UNIX_EPOCH + Duration::from_secs(expires_at.timestamp() as u64);
{
let mut cache = TOKEN_CACHE.lock().unwrap();
*cache = Some(CachedToken {
token: token_response.token.clone(),
expires_at: expires_at_system,
});
}
Ok(token_response.token)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_app_configured_missing_vars() {
assert!(!is_app_configured_with(None, None, None));
assert!(!is_app_configured_with(Some("123"), None, None));
assert!(!is_app_configured_with(Some("123"), Some("456"), None));
assert!(!is_app_configured_with(None, Some("456"), Some("key")));
assert!(is_app_configured_with(
Some("123"),
Some("456"),
Some("key")
));
}
#[test]
#[ignore]
fn test_generate_jwt_integration() {
let app_id = env::var("MX_GITHUB_APP_ID").expect("MX_GITHUB_APP_ID not set");
let private_key = env::var("MX_GITHUB_PRIVATE_KEY").expect("MX_GITHUB_PRIVATE_KEY not set");
let jwt = generate_jwt(&app_id, &private_key).expect("JWT generation failed");
assert!(!jwt.is_empty());
assert_eq!(jwt.matches('.').count(), 2);
}
#[test]
#[ignore]
fn test_get_installation_token_integration() {
let token = get_installation_token().expect("Token fetch failed");
assert!(!token.is_empty());
assert!(token.starts_with("ghs_"));
}
}