use async_trait::async_trait;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::connector::Connector;
use crate::models::{NewCredential, ProviderKind, RotationError, RotationStyle, Secret};
pub struct GithubAppConnector {
client: reqwest::Client,
app_id: String,
private_key_pem: String,
}
impl GithubAppConnector {
pub fn new(app_id: String, private_key_pem: String) -> Self {
let client = reqwest::Client::builder()
.user_agent("rekey")
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
client,
app_id,
private_key_pem,
}
}
fn url(path: &str) -> String {
format!("https://api.github.com{path}")
}
fn app_jwt(&self) -> Result<String, RotationError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let claims = Claims {
iat: now.saturating_sub(60),
exp: now + 9 * 60,
iss: self.app_id.clone(),
};
let key = EncodingKey::from_rsa_pem(self.private_key_pem.as_bytes())
.map_err(|e| RotationError::NotAuthorized(format!("bad App private key: {e}")))?;
encode(&Header::new(Algorithm::RS256), &claims, &key)
.map_err(|e| RotationError::NotAuthorized(format!("JWT sign failed: {e}")))
}
fn gh(&self, rb: reqwest::RequestBuilder, bearer: &str) -> reqwest::RequestBuilder {
rb.bearer_auth(bearer)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
}
async fn err_body(resp: reqwest::Response) -> RotationError {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
RotationError::Http(format!("{status}: {body}"))
}
}
#[async_trait]
impl Connector for GithubAppConnector {
fn kind(&self) -> ProviderKind {
ProviderKind::GithubApp
}
fn rotation_style(&self) -> RotationStyle {
RotationStyle::Ephemeral
}
async fn connect(&self) -> Result<(), RotationError> {
let jwt = self.app_jwt()?;
let resp = self
.gh(self.client.get(Self::url("/app/installations")), &jwt)
.send()
.await
.map_err(|e| RotationError::Http(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
Err(RotationError::NotAuthorized(
Self::err_body(resp).await.to_string(),
))
}
}
async fn list_keys(&self) -> Result<Vec<Secret>, RotationError> {
let jwt = self.app_jwt()?;
let resp = self
.gh(self.client.get(Self::url("/app/installations")), &jwt)
.send()
.await
.map_err(|e| RotationError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(Self::err_body(resp).await);
}
let installs: Vec<GhInstallation> = resp
.json()
.await
.map_err(|e| RotationError::Http(format!("parse installations: {e}")))?;
Ok(installs
.into_iter()
.map(|i| {
let login = i.account.map(|a| a.login).unwrap_or_default();
let id = i.id.to_string();
Secret::new(
ProviderKind::GithubApp,
id.clone(),
format!("installation for {login}"),
id,
)
})
.collect())
}
async fn create(&self, secret: &Secret) -> Result<NewCredential, RotationError> {
let jwt = self.app_jwt()?;
let path = format!("/app/installations/{}/access_tokens", secret.external_id);
let resp = self
.gh(self.client.post(Self::url(&path)), &jwt)
.send()
.await
.map_err(|e| RotationError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(Self::err_body(resp).await);
}
let minted: GhToken = resp
.json()
.await
.map_err(|e| RotationError::Http(format!("parse token: {e}")))?;
Ok(NewCredential::new(secret.external_id.clone(), minted.token))
}
async fn verify(&self, cred: &NewCredential) -> Result<(), RotationError> {
let resp = self
.gh(
self.client
.get(Self::url("/installation/repositories?per_page=1")),
&cred.secret_value,
)
.send()
.await
.map_err(|e| RotationError::Http(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
Err(RotationError::VerificationFailed(
Self::err_body(resp).await.to_string(),
))
}
}
}
#[derive(Serialize)]
struct Claims {
iat: u64,
exp: u64,
iss: String,
}
#[derive(Deserialize)]
struct GhInstallation {
id: u64,
account: Option<GhAccount>,
}
#[derive(Deserialize)]
struct GhAccount {
login: String,
}
#[derive(Deserialize)]
struct GhToken {
token: String,
}