Skip to main content

fnox_core/lease_backends/
github_app.rs

1use 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
11/// Maximum GitHub installation token lifetime (1 hour)
12const MAX_DURATION_SECS: u64 = 3600;
13
14/// JWT expiration — GitHub accepts up to 10 minutes
15const JWT_EXPIRY_SECS: i64 = 600;
16
17/// All env var names the GitHub App backend may consume at runtime.
18pub 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    // The env var is optional when private_key_file is configured, so we
38    // don't unconditionally list it as required. check_prerequisites()
39    // handles the two-path validation correctly.
40    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        // Prefer env var
80        if let Ok(key) = std::env::var("FNOX_GITHUB_APP_PRIVATE_KEY") {
81            return Ok(key);
82        }
83
84        // Fall back to file
85        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    /// Generate a JWT for authenticating as the GitHub App.
104    fn generate_jwt(&self, pem_key: &str) -> Result<String> {
105        let now = chrono::Utc::now();
106        let iat = now.timestamp() - 60; // issued 60s in the past to allow clock drift
107        let exp = iat + JWT_EXPIRY_SECS; // exp - iat must be ≤ 600s (GitHub's limit)
108
109        // JWT RFC 7519 defines `iss` as a StringOrURI — it must be a string.
110        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        // Fall back to now + 1 hour if expires_at is missing or unparseable,
238        // since GitHub hard-expires installation tokens after 1 hour. Without
239        // this, a None expiry would cause the ledger to treat the token as
240        // never-expiring, serving a stale token indefinitely.
241        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        // Use a hash of the token as the lease_id. This is deterministic
251        // (useful for debugging) without leaking the secret in the ledger.
252        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        // GitHub's DELETE /installation/token requires authenticating with the
268        // token being revoked. We retrieve it from the cached credentials
269        // (which are encrypted at rest in the ledger).
270        let Some(token) = credentials.and_then(|creds| creds.get(&self.env_var)) else {
271            // No token available — either already expired/cleaned up or the
272            // encryption provider is unavailable. Skip server-side revocation
273            // silently; the local ledger entry is cleaned up by the caller.
274            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            // 404 = token already expired or revoked — treat as success
298            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}