use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::integrations::github::{GithubClient, GithubError};
#[derive(Debug, Serialize, Deserialize)]
pub struct AppJwtClaims {
pub iss: String,
pub iat: u64,
pub exp: u64,
}
#[derive(Debug, Deserialize)]
pub struct InstallationTokenResponse {
pub token: String,
}
pub fn mint_app_jwt(app_id: &str, private_key_pem: &str) -> Result<String, GithubError> {
let encoding_key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
.map_err(|e| GithubError::Auth(format!("invalid App private key PEM: {e}")))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| GithubError::Auth(format!("system clock before Unix epoch: {e}")))?
.as_secs();
let iat = now.saturating_sub(60);
let exp = iat + 660;
let claims = AppJwtClaims {
iss: app_id.to_string(),
iat,
exp,
};
let header = Header::new(Algorithm::RS256);
jsonwebtoken::encode(&header, &claims, &encoding_key)
.map_err(|e| GithubError::Auth(format!("JWT signing failed: {e}")))
}
pub async fn exchange_installation_token(
client: &GithubClient,
app_jwt: &str,
installation_id: u64,
) -> Result<String, GithubError> {
let url = format!("https://api.github.com/app/installations/{installation_id}/access_tokens");
let resp = client
.http
.post(&url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {app_jwt}"))
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", &client.user_agent)
.send()
.await
.map_err(|e| GithubError::Transport(format!("POST {url}: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| GithubError::Transport(format!("read body of {url}: {e}")))?;
if !status.is_success() {
return Err(GithubError::Api {
status: status.as_u16(),
body,
});
}
let token_resp: InstallationTokenResponse = serde_json::from_str(&body).map_err(|e| {
GithubError::Transport(format!("failed to parse installation token response: {e}"))
})?;
Ok(token_resp.token)
}
pub async fn resolve_app_token(
client: &GithubClient,
app_id: Option<&str>,
private_key: Option<&str>,
installations: &[(String, u64)],
owner: &str,
) -> Result<String, GithubError> {
let (Some(app_id), Some(private_key)) = (app_id, private_key) else {
return Err(GithubError::Auth(
"GitHub App credentials (GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY) are required \
in service mode"
.to_string(),
));
};
let matching_id = installations.iter().find_map(|(inst_owner, inst_id)| {
if inst_owner.eq_ignore_ascii_case(owner) {
Some(*inst_id)
} else {
None
}
});
let Some(installation_id) = matching_id else {
tracing::warn!(owner, "no GitHub App installation configured for owner");
return Err(GithubError::MissingToken);
};
let jwt = mint_app_jwt(app_id, private_key)?;
exchange_installation_token(client, &jwt, installation_id).await
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_RSA_PEM: &str = concat!(
"-----BEGIN PRIVATE KEY-----\n", "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCwqJLJt1WufjvL\n", "kCguz23z3rY3tshu9hf95pwe5C2g2VSzMFHRggVTQLUE8ENA6km7vIRxtmwEBTVd\n", "5Yz89dgwO9T2w7yKS1n1HuzSdyLSTNOw0TU+0AKmY45nslLxCnvkYyQbD2BCzlbx\n", "LkDMsBMSlAMrJs2FUfLq1xXn3u+i35vuc2qLAo2p56xVmcs94qLo7UB5y5UC3N7G\n", "yD2GWG99vThkFPj9VYjhwjwfTIfIr/8MTg6X5jNJGzr4ebntsWMfGKgseGOviYze\n", "cS9vmhBLcuV0JAm1h6eIVbAOQHWPdF6lo7XaXc//xuPr8OqtSMAtgDJ06S8REYO2\n", "YkHB+GOhAgMBAAECggEAF4NofkbTtbUBmnemkYx0cxg6orHGfdZtnRLbxtTSKe2j\n", "c3JEAaHPuaQMNAsSuIo2pDFUY5pHSEW1M7lBCc5jJxBfqTSmXLXo1FJ4bQ8EaH9n\n", "UcqWzrR7FdB8fNrkZUbi9KQpgxyJ0HqMYe+pGlV5RGjE/zJb+pnMvmtAdCtdNA1c\n", "o0oaS6jLuC+gRRBKtmL2yin939ZrKTj3LySJTzenm+oq2wIuBS85uIYVQ9O4aMIl\n", "lDjCsb3YawI4j+/69OptBq9c99QXBfxStOTpUi5IDsdt5i7iXaIGZiH8MiK2TFPx\n", "fk5YvXDet2o9Cdt+iujuF7Fu8VgWu1t0jnzDT4TLEQKBgQDXSviIl63sHu+nNEes\n", "zW8rGYmGWnmWSHChgyBdX4oTIigrO9mBlI5Bgilcw6+qxCyzw6PSmKakAg6FqP/5\n", "sANqinY0j2xdL2sgoWnXOr5TSN3QJ5nNJKYpjEBh4TIqTWNNYTvn1K2JIG5+ATS4\n", "Hng1QmaRYlk7DepX6LAYmz6g5QKBgQDSD4u9iXiDHBzHglPqakwqkC5XqnL7XR9s\n", "qFseOqzwV2viINXsLFCg+rScvcB8Ce0GIT21gttcqDN9OOuujB1gaNYdHsMZx5mE\n", "Hvzj9SB2sPO9LeDEUC/g/8ySdu08WSf+RZ0KR39hA0wtGNMiukPC+8iU3tJG2QiX\n", "5IxlbFXYDQKBgQCsBn2cNwaDmxyHD+ENlID1gUxADF8G1A8bHvlnYoWjUDGkigf7\n", "4EXi1ixSsRHWczX81aA7EDpm5jXQWv9d9WRlZwmYadl+g/sncZJupcOaLKkAQARG\n", "xLf4jtaK3zQEVR25oK4LSgb3gPCIwlHrpH0MoWfvVxRReYb8gzLiFnnueQKBgECD\n", "xcdQkVKzL6OWw28bdokb/x+tmeLZlu0oR9Pg8XxfXSL2Mr12Xs0SMqZxIMz3v3RC\n", "gVFd/0FV53puIPRa1CroB9qpuAIS63NIkSLyBiZt8m4HySCCADJ6XboeDH6cY0wU\n", "1UZy7ww8lwjCtxXTXzxjWBdg1/QqdBkyeGwt+a+BAoGATVFBJ+eW2sUuEjaopIiq\n", "9YXh6GtKarglvVny+wd1gz/3/8Oy1Ik7s3mBn7QAiK9BL9B1YpmX7bYNSSTomXqg\n", "oTRnhZb8BGsvbOSrPeHd8O1FzobrPZ8PYl1xVReOByjKw2vR4zVLIq6YvurQNB00\n", "ii7j4jc5884tuleJyyumF4s=\n", "-----END PRIVATE KEY-----", );
#[test]
fn jwt_claims_correctness() {
let app_id = "99999";
let token = mint_app_jwt(app_id, TEST_RSA_PEM).expect("mint_app_jwt should succeed");
let mut validation = jsonwebtoken::Validation::new(Algorithm::RS256);
validation.insecure_disable_signature_validation();
validation.set_required_spec_claims(&[] as &[&str]);
let decoding_key = jsonwebtoken::DecodingKey::from_secret(&[]);
let decoded = jsonwebtoken::decode::<AppJwtClaims>(&token, &decoding_key, &validation)
.expect("decoding JWT claims should succeed");
assert_eq!(decoded.claims.iss, app_id, "iss must equal the App ID");
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(
decoded.claims.iat <= now,
"iat ({}) must be <= now ({})",
decoded.claims.iat,
now
);
assert!(
decoded.claims.iat >= now.saturating_sub(120),
"iat ({}) must be recent",
decoded.claims.iat
);
assert_eq!(
decoded.claims.exp,
decoded.claims.iat + 660,
"exp must be iat + 660"
);
}
#[test]
fn jwt_mint_fails_on_bad_pem() {
let result = mint_app_jwt("123", "not-a-valid-pem");
assert!(result.is_err(), "bad PEM should return Err");
match result.unwrap_err() {
GithubError::Auth(_) => {}
other => panic!("expected Auth error, got {other:?}"),
}
}
#[tokio::test]
async fn resolve_app_token_no_credentials_errors() {
let client = GithubClient::new();
let result = resolve_app_token(&client, None, None, &[], "any-owner").await;
match result {
Err(GithubError::Auth(_)) => {}
other => panic!("expected Auth error when App creds missing, got {other:?}"),
}
}
#[tokio::test]
async fn resolve_app_token_no_installation_errors() {
let client = GithubClient::new();
let installs = vec![("otherorg".to_string(), 123_u64)];
let result = resolve_app_token(
&client,
Some("99999"),
Some(TEST_RSA_PEM),
&installs,
"acme",
)
.await;
match result {
Err(GithubError::MissingToken) => {}
other => panic!("expected MissingToken when no installation matches, got {other:?}"),
}
}
}