fnox_core/lease_backends/
github_app.rs1use crate::error::{FnoxError, Result};
2use crate::lease_backends::{Lease, LeaseBackend};
3use async_trait::async_trait;
4use indexmap::IndexMap;
5
6use std::time::Duration;
7
8const URL: &str = "https://fnox.jdx.dev/leases/github-app";
9const API_BASE: &str = "https://api.github.com";
10
11const MAX_DURATION_SECS: u64 = 3600;
13
14const JWT_EXPIRY_SECS: i64 = 600;
16
17pub const CONSUMED_ENV_VARS: &[&str] = &["FNOX_GITHUB_APP_PRIVATE_KEY"];
19
20pub fn check_prerequisites(private_key_file: &Option<String>) -> Option<String> {
21 let has_key = std::env::var("FNOX_GITHUB_APP_PRIVATE_KEY").is_ok()
22 || private_key_file
23 .as_ref()
24 .is_some_and(|f| std::path::Path::new(&shellexpand::tilde(f).into_owned()).exists());
25 if has_key {
26 None
27 } else {
28 Some(
29 "GitHub App private key not found. Set FNOX_GITHUB_APP_PRIVATE_KEY \
30 or configure private_key_file pointing to a PEM file."
31 .to_string(),
32 )
33 }
34}
35
36pub fn required_env_vars() -> Vec<(&'static str, &'static str)> {
37 vec![]
41}
42
43pub struct GitHubAppBackend {
44 app_id: String,
45 installation_id: String,
46 private_key_file: Option<String>,
47 env_var: String,
48 permissions: Option<IndexMap<String, String>>,
49 repositories: Option<Vec<String>>,
50 api_base: Option<String>,
51}
52
53impl GitHubAppBackend {
54 pub fn new(
55 app_id: String,
56 installation_id: String,
57 private_key_file: Option<String>,
58 env_var: String,
59 permissions: Option<IndexMap<String, String>>,
60 repositories: Option<Vec<String>>,
61 api_base: Option<String>,
62 ) -> Self {
63 Self {
64 app_id,
65 installation_id,
66 private_key_file,
67 env_var,
68 permissions,
69 repositories,
70 api_base,
71 }
72 }
73
74 fn api_base(&self) -> &str {
75 self.api_base.as_deref().unwrap_or(API_BASE)
76 }
77
78 fn load_private_key(&self) -> Result<String> {
79 if let Ok(key) = std::env::var("FNOX_GITHUB_APP_PRIVATE_KEY") {
81 return Ok(key);
82 }
83
84 if let Some(ref path) = self.private_key_file {
86 let expanded = shellexpand::tilde(path).into_owned();
87 std::fs::read_to_string(&expanded).map_err(|e| FnoxError::ProviderAuthFailed {
88 provider: "GitHub App".to_string(),
89 details: format!("Failed to read private key from {expanded}: {e}"),
90 hint: "Check that private_key_file points to a valid PEM file".to_string(),
91 url: URL.to_string(),
92 })
93 } else {
94 Err(FnoxError::ProviderAuthFailed {
95 provider: "GitHub App".to_string(),
96 details: "No private key available".to_string(),
97 hint: "Set FNOX_GITHUB_APP_PRIVATE_KEY or configure private_key_file".to_string(),
98 url: URL.to_string(),
99 })
100 }
101 }
102
103 fn generate_jwt(&self, pem_key: &str) -> Result<String> {
105 let now = chrono::Utc::now();
106 let iat = now.timestamp() - 60; let exp = iat + JWT_EXPIRY_SECS; let claims = serde_json::json!({
111 "iat": iat,
112 "exp": exp,
113 "iss": &self.app_id,
114 });
115
116 let key = jsonwebtoken::EncodingKey::from_rsa_pem(pem_key.as_bytes()).map_err(|e| {
117 FnoxError::ProviderAuthFailed {
118 provider: "GitHub App".to_string(),
119 details: format!("Invalid RSA private key: {e}"),
120 hint: "Check that the private key is a valid RSA PEM file".to_string(),
121 url: URL.to_string(),
122 }
123 })?;
124
125 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
126 jsonwebtoken::encode(&header, &claims, &key).map_err(|e| FnoxError::ProviderAuthFailed {
127 provider: "GitHub App".to_string(),
128 details: format!("Failed to sign JWT: {e}"),
129 hint: "Check the private key format".to_string(),
130 url: URL.to_string(),
131 })
132 }
133}
134
135#[async_trait]
136impl LeaseBackend for GitHubAppBackend {
137 async fn create_lease(&self, _duration: Duration, _label: &str) -> Result<Lease> {
138 let pem_key = self.load_private_key()?;
139 let jwt = self.generate_jwt(&pem_key)?;
140 let api_base = self.api_base();
141
142 let mut body = serde_json::Map::new();
143 if let Some(ref permissions) = self.permissions {
144 body.insert(
145 "permissions".to_string(),
146 serde_json::to_value(permissions).unwrap(),
147 );
148 }
149 if let Some(ref repositories) = self.repositories {
150 body.insert(
151 "repositories".to_string(),
152 serde_json::to_value(repositories).unwrap(),
153 );
154 }
155
156 let client = crate::http::http_client();
157 let url = format!(
158 "{api_base}/app/installations/{}/access_tokens",
159 self.installation_id
160 );
161
162 let response = client
163 .post(&url)
164 .header("Accept", "application/vnd.github+json")
165 .header("X-GitHub-Api-Version", "2022-11-28")
166 .bearer_auth(&jwt)
167 .json(&body)
168 .send()
169 .await
170 .map_err(|e| FnoxError::ProviderApiError {
171 provider: "GitHub App".to_string(),
172 details: e.to_string(),
173 hint: "Failed to connect to GitHub API".to_string(),
174 url: URL.to_string(),
175 })?;
176
177 let status = response.status();
178 let resp: serde_json::Value =
179 response
180 .json()
181 .await
182 .map_err(|e| FnoxError::ProviderInvalidResponse {
183 provider: "GitHub App".to_string(),
184 details: e.to_string(),
185 hint: "Unexpected response from GitHub API".to_string(),
186 url: URL.to_string(),
187 })?;
188
189 if !status.is_success() {
190 let message = resp["message"]
191 .as_str()
192 .unwrap_or(&format!("HTTP {status}"))
193 .to_string();
194
195 if status.as_u16() == 401 || status.as_u16() == 403 {
196 return Err(FnoxError::ProviderAuthFailed {
197 provider: "GitHub App".to_string(),
198 details: message,
199 hint: "Check app_id, installation_id, and private key".to_string(),
200 url: URL.to_string(),
201 });
202 }
203 if status.as_u16() == 404 {
204 return Err(FnoxError::ProviderApiError {
205 provider: "GitHub App".to_string(),
206 details: message,
207 hint: "Check that the installation_id is correct and the app is installed"
208 .to_string(),
209 url: URL.to_string(),
210 });
211 }
212 if status.as_u16() == 422 {
213 return Err(FnoxError::ProviderApiError {
214 provider: "GitHub App".to_string(),
215 details: message,
216 hint: "Check permissions and repositories configuration".to_string(),
217 url: URL.to_string(),
218 });
219 }
220 return Err(FnoxError::ProviderApiError {
221 provider: "GitHub App".to_string(),
222 details: message,
223 hint: "Failed to create installation access token".to_string(),
224 url: URL.to_string(),
225 });
226 }
227
228 let token = resp["token"]
229 .as_str()
230 .ok_or_else(|| FnoxError::ProviderInvalidResponse {
231 provider: "GitHub App".to_string(),
232 details: "Response missing 'token' field".to_string(),
233 hint: "Unexpected response from GitHub API".to_string(),
234 url: URL.to_string(),
235 })?;
236
237 let expires_at = resp["expires_at"]
242 .as_str()
243 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
244 .map(|dt| dt.with_timezone(&chrono::Utc))
245 .or_else(|| Some(chrono::Utc::now() + chrono::Duration::hours(1)));
246
247 let mut credentials = IndexMap::new();
248 credentials.insert(self.env_var.clone(), token.to_string());
249
250 let hash = blake3::hash(token.as_bytes());
253 let lease_id = format!("github-app-{}", &hash.to_hex()[..16]);
254
255 Ok(Lease {
256 credentials,
257 expires_at,
258 lease_id,
259 })
260 }
261
262 async fn revoke_lease(
263 &self,
264 _lease_id: &str,
265 credentials: Option<&IndexMap<String, String>>,
266 ) -> Result<()> {
267 let Some(token) = credentials.and_then(|creds| creds.get(&self.env_var)) else {
271 return Ok(());
275 };
276
277 let api_base = self.api_base();
278 let url = format!("{api_base}/installation/token");
279
280 let client = crate::http::http_client();
281 let response = client
282 .delete(&url)
283 .header("Accept", "application/vnd.github+json")
284 .header("X-GitHub-Api-Version", "2022-11-28")
285 .bearer_auth(token)
286 .send()
287 .await
288 .map_err(|e| FnoxError::ProviderApiError {
289 provider: "GitHub App".to_string(),
290 details: e.to_string(),
291 hint: "Failed to connect to GitHub API for token revocation".to_string(),
292 url: URL.to_string(),
293 })?;
294
295 let status = response.status();
296 if !status.is_success() {
297 if status.as_u16() != 404 {
299 let body_text = response.text().await.unwrap_or_default();
300 return Err(FnoxError::ProviderApiError {
301 provider: "GitHub App".to_string(),
302 details: format!("HTTP {status}: {body_text}"),
303 hint: "Failed to revoke GitHub installation token".to_string(),
304 url: URL.to_string(),
305 });
306 }
307 }
308
309 Ok(())
310 }
311
312 fn max_lease_duration(&self) -> Duration {
313 Duration::from_secs(MAX_DURATION_SECS)
314 }
315}