Skip to main content

sts_cat/
github.rs

1use crate::error::Error;
2
3const MAX_PAGES: u32 = 50;
4const PER_PAGE: u32 = 100;
5const MAX_RESPONSE_SIZE: usize = 100 * 1024; // 100 KiB
6
7/// Percent-encoding set for URL path segments (RFC 3986 unreserved chars preserved).
8const PATH_SEGMENT_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
9    .remove(b'-')
10    .remove(b'.')
11    .remove(b'_')
12    .remove(b'~');
13
14pub struct GitHubClient {
15    http: reqwest::Client,
16    base_url: String,
17    app_id: String,
18    signer: std::sync::Arc<dyn crate::signer::Signer>,
19}
20
21impl GitHubClient {
22    pub fn new(
23        base_url: &str,
24        app_id: &str,
25        signer: std::sync::Arc<dyn crate::signer::Signer>,
26    ) -> Self {
27        use reqwest::header;
28
29        let mut headers = header::HeaderMap::new();
30        headers.insert(
31            header::ACCEPT,
32            header::HeaderValue::from_static("application/vnd.github+json"),
33        );
34        headers.insert(
35            "X-GitHub-Api-Version",
36            header::HeaderValue::from_static("2026-03-10"),
37        );
38
39        let http = reqwest::Client::builder()
40            .connect_timeout(std::time::Duration::from_secs(10))
41            .timeout(std::time::Duration::from_secs(30))
42            .redirect(reqwest::redirect::Policy::none())
43            .user_agent(format!("sts-cat/{}", env!("CARGO_PKG_VERSION")))
44            .default_headers(headers)
45            .build()
46            .expect("failed to build GitHub HTTP client");
47
48        Self {
49            http,
50            base_url: base_url.trim_end_matches('/').to_owned(),
51            app_id: app_id.to_owned(),
52            signer,
53        }
54    }
55
56    #[tracing::instrument(skip_all)]
57    async fn app_jwt(&self) -> Result<secrecy::SecretString, Error> {
58        use base64::Engine as _;
59        use secrecy::ExposeSecret as _;
60
61        let now = std::time::SystemTime::now()
62            .duration_since(std::time::UNIX_EPOCH)
63            .map_err(|e| Error::Internal(Box::new(e)))?
64            .as_secs();
65
66        let header = JwtHeader {
67            alg: "RS256",
68            typ: "JWT",
69        };
70        let claims = JwtClaims {
71            iss: self.app_id.clone(),
72            iat: now - 60,  // 60s clock skew allowance
73            exp: now + 540, // 10 minutes total (GitHub's max)
74        };
75
76        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
77        let header_b64 = engine.encode(serde_json::to_vec(&header).unwrap());
78        let claims_b64 = engine.encode(serde_json::to_vec(&claims).unwrap());
79        let message = format!("{header_b64}.{claims_b64}");
80
81        let signature = self.signer.sign(message.as_bytes()).await?;
82        let signature_b64 = engine.encode(signature.expose_secret());
83
84        Ok(secrecy::SecretString::from(format!(
85            "{message}.{signature_b64}"
86        )))
87    }
88
89    #[tracing::instrument(skip_all, fields(owner))]
90    pub async fn get_installation_id(&self, owner: &str) -> Result<u64, Error> {
91        use secrecy::ExposeSecret as _;
92        let jwt = self.app_jwt().await?;
93
94        for page in 1..=MAX_PAGES {
95            let url = format!(
96                "{}/app/installations?per_page={PER_PAGE}&page={page}",
97                self.base_url
98            );
99
100            let resp = self
101                .http
102                .get(&url)
103                .bearer_auth(jwt.expose_secret())
104                .send()
105                .await
106                .map_err(Error::GitHubApi)?;
107
108            if !resp.status().is_success() {
109                return Err(handle_github_error(resp).await);
110            }
111
112            let installations: Vec<Installation> = resp.json().await.map_err(Error::GitHubApi)?;
113
114            if let Some(inst) = installations.iter().find(|i| {
115                i.account
116                    .as_ref()
117                    .is_some_and(|a| a.login.eq_ignore_ascii_case(owner))
118            }) {
119                return Ok(inst.id);
120            }
121
122            // If fewer results than per_page, no more pages
123            if (installations.len() as u32) < PER_PAGE {
124                break;
125            }
126        }
127
128        Err(Error::NotFound(format!(
129            "no installation found for owner: {owner}"
130        )))
131    }
132
133    #[tracing::instrument(skip_all, fields(owner, repo, path))]
134    pub async fn get_trust_policy_content(
135        &self,
136        installation_id: u64,
137        owner: &str,
138        repo: &str,
139        path: &str,
140    ) -> Result<String, Error> {
141        use secrecy::ExposeSecret as _;
142
143        let read_permissions = crate::trust_policy::Permissions {
144            inner: [("contents".into(), "read".into())].into(),
145        };
146        let read_token = self
147            .create_installation_token_raw(installation_id, &read_permissions, &[repo.to_owned()])
148            .await?;
149
150        // Encode each path segment individually to preserve `/` separators.
151        let encoded_path = path
152            .split('/')
153            .map(|seg| {
154                percent_encoding::utf8_percent_encode(seg, PATH_SEGMENT_ENCODE_SET).to_string()
155            })
156            .collect::<Vec<_>>()
157            .join("/");
158        let url = format!(
159            "{}/repos/{}/{}/contents/{encoded_path}",
160            self.base_url,
161            percent_encoding::utf8_percent_encode(owner, PATH_SEGMENT_ENCODE_SET),
162            percent_encoding::utf8_percent_encode(repo, PATH_SEGMENT_ENCODE_SET),
163        );
164
165        let resp = self
166            .http
167            .get(&url)
168            .bearer_auth(read_token.expose_secret().as_str())
169            .send()
170            .await
171            .map_err(Error::GitHubApi)?;
172
173        let fetch_result = if !resp.status().is_success() {
174            let status = resp.status();
175            if status == reqwest::StatusCode::NOT_FOUND {
176                Err(Error::NotFound(format!("trust policy not found: {path}")))
177            } else {
178                Err(handle_github_error(resp).await)
179            }
180        } else {
181            let body_bytes =
182                crate::oidc::read_limited_body(resp, MAX_RESPONSE_SIZE, Error::GitHubApi).await?;
183            let file_resp: FileContent =
184                serde_json::from_slice(&body_bytes).map_err(|e| Error::Internal(Box::new(e)))?;
185            decode_content(&file_resp.content)
186        };
187
188        self.revoke_token(&read_token).await;
189
190        fetch_result
191    }
192
193    pub async fn create_installation_token(
194        &self,
195        installation_id: u64,
196        permissions: &crate::trust_policy::Permissions,
197        repositories: &[String],
198    ) -> Result<crate::exchange::GitHubToken, Error> {
199        self.create_installation_token_raw(installation_id, permissions, repositories)
200            .await
201    }
202
203    #[tracing::instrument(skip_all, fields(installation_id))]
204    async fn create_installation_token_raw(
205        &self,
206        installation_id: u64,
207        permissions: &crate::trust_policy::Permissions,
208        repositories: &[String],
209    ) -> Result<crate::exchange::GitHubToken, Error> {
210        use secrecy::ExposeSecret as _;
211        let jwt = self.app_jwt().await?;
212
213        let url = format!(
214            "{}/app/installations/{installation_id}/access_tokens",
215            self.base_url
216        );
217
218        let body = CreateTokenRequest {
219            permissions,
220            repositories,
221        };
222
223        let resp = self
224            .http
225            .post(&url)
226            .bearer_auth(jwt.expose_secret())
227            .json(&body)
228            .send()
229            .await
230            .map_err(Error::GitHubApi)?;
231
232        let status = resp.status();
233        if !status.is_success() {
234            return match status.as_u16() {
235                422 => {
236                    let body = resp.text().await.unwrap_or_default();
237                    tracing::debug!(body = %body, "GitHub API 422 creating installation token");
238                    Err(Error::PermissionDenied(
239                        "invalid permission combination".into(),
240                    ))
241                }
242                403 | 429 => Err(Error::RateLimited),
243                _ => {
244                    let body = resp.text().await.unwrap_or_default();
245                    tracing::debug!(
246                        status = %status,
247                        body = %body,
248                        "GitHub API error creating installation token"
249                    );
250                    Err(Error::Internal(
251                        format!("GitHub API error: HTTP {status}").into(),
252                    ))
253                }
254            };
255        }
256
257        let token_resp: TokenResponse = resp.json().await.map_err(Error::GitHubApi)?;
258
259        Ok(secrecy::SecretBox::new(Box::new(token_resp.token)))
260    }
261
262    #[tracing::instrument(skip_all)]
263    async fn revoke_token(&self, token: &crate::exchange::GitHubToken) {
264        use secrecy::ExposeSecret as _;
265        let resp = self
266            .http
267            .delete(format!("{}/installation/token", self.base_url))
268            .bearer_auth(token.expose_secret().as_str())
269            .send()
270            .await;
271
272        match resp {
273            Ok(r) if r.status() == reqwest::StatusCode::NO_CONTENT => {}
274            Ok(r) => {
275                tracing::warn!(
276                    status = %r.status(),
277                    "failed to revoke installation token"
278                );
279            }
280            Err(e) => {
281                tracing::warn!(error = %e, "failed to revoke installation token");
282            }
283        }
284    }
285}
286
287async fn handle_github_error(resp: reqwest::Response) -> Error {
288    let status = resp.status();
289    match status.as_u16() {
290        403 | 429 => Error::RateLimited,
291        _ => {
292            let body = resp.text().await.unwrap_or_default();
293            tracing::debug!(status = %status, body = %body, "GitHub API error");
294            Error::Internal(format!("GitHub API error: HTTP {status}").into())
295        }
296    }
297}
298
299fn decode_content(encoded: &str) -> Result<String, Error> {
300    use base64::Engine as _;
301    // GitHub returns base64-encoded content with newlines
302    let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
303    let bytes = base64::engine::general_purpose::STANDARD
304        .decode(&cleaned)
305        .map_err(|e| Error::Internal(Box::new(e)))?;
306    String::from_utf8(bytes).map_err(|e| Error::Internal(Box::new(e)))
307}
308
309#[derive(serde::Serialize)]
310struct JwtHeader {
311    alg: &'static str,
312    typ: &'static str,
313}
314
315#[derive(serde::Serialize)]
316struct JwtClaims {
317    iss: String,
318    iat: u64,
319    exp: u64,
320}
321
322#[derive(serde::Serialize)]
323struct CreateTokenRequest<'a> {
324    permissions: &'a crate::trust_policy::Permissions,
325    #[serde(skip_serializing_if = "<[String]>::is_empty")]
326    repositories: &'a [String],
327}
328
329#[derive(serde::Deserialize)]
330struct Installation {
331    id: u64,
332    account: Option<Account>,
333}
334
335#[derive(serde::Deserialize)]
336struct Account {
337    login: String,
338}
339
340#[derive(serde::Deserialize)]
341struct FileContent {
342    content: String,
343}
344
345#[derive(serde::Deserialize)]
346struct TokenResponse {
347    token: crate::exchange::GitHubTokenInner,
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::signer::Signer as _;
354
355    // RSA-2048 test key from RFC 9500
356    const TEST_RSA_PEM: &[u8] = b"-----BEGIN RSA PRIVATE KEY-----
357MIIEowIBAAKCAQEAsPnoGUOnrpiSqt4XynxA+HRP7S+BSObI6qJ7fQAVSPtRkqso
358tWxQYLEYzNEx5ZSHTGypibVsJylvCfuToDTfMul8b/CZjP2Ob0LdpYrNH6l5hvFE
35989FU1nZQF15oVLOpUgA7wGiHuEVawrGfey92UE68mOyUVXGweJIVDdxqdMoPvNNU
360l86BU02vlBiESxOuox+dWmuVV7vfYZ79Toh/LUK43YvJh+rhv4nKuF7iHjVjBd9s
361B6iDjj70HFldzOQ9r8SRI+9NirupPTkF5AKNe6kUhKJ1luB7S27ZkvB3tSTT3P59
3623VVJvnzOjaA1z6Cz+4+eRvcysqhrRgFlwI9TEwIDAQABAoIBAEEYiyDP29vCzx/+
363dS3LqnI5BjUuJhXUnc6AWX/PCgVAO+8A+gZRgvct7PtZb0sM6P9ZcLrweomlGezI
364FrL0/6xQaa8bBr/ve/a8155OgcjFo6fZEw3Dz7ra5fbSiPmu4/b/kvrg+Br1l77J
365aun6uUAs1f5B9wW+vbR7tzbT/mxaUeDiBzKpe15GwcvbJtdIVMa2YErtRjc1/5B2
366BGVXyvlJv0SIlcIEMsHgnAFOp1ZgQ08aDzvilLq8XVMOahAhP1O2A3X8hKdXPyrx
367IVWE9bS9ptTo+eF6eNl+d7htpKGEZHUxinoQpWEBTv+iOoHsVunkEJ3vjLP3lyI/
368fY0NQ1ECgYEA3RBXAjgvIys2gfU3keImF8e/TprLge1I2vbWmV2j6rZCg5r/AS0u
369pii5CvJ5/T5vfJPNgPBy8B/yRDs+6PJO1GmnlhOkG9JAIPkv0RBZvR0PMBtbp6nT
370Y3yo1lwamBVBfY6rc0sLTzosZh2aGoLzrHNMQFMGaauORzBFpY5lU50CgYEAzPHl
371u5DI6Xgep1vr8QvCUuEesCOgJg8Yh1UqVoY/SmQh6MYAv1I9bLGwrb3WW/7kqIoD
372fj0aQV5buVZI2loMomtU9KY5SFIsPV+JuUpy7/+VE01ZQM5FdY8wiYCQiVZYju9X
373Wz5LxMNoz+gT7pwlLCsC4N+R8aoBk404aF1gum8CgYAJ7VTq7Zj4TFV7Soa/T1eE
374k9y8a+kdoYk3BASpCHJ29M5R2KEA7YV9wrBklHTz8VzSTFTbKHEQ5W5csAhoL5Fo
375qoHzFFi3Qx7MHESQb9qHyolHEMNx6QdsHUn7rlEnaTTyrXh3ifQtD6C0yTmFXUIS
376CW9wKApOrnyKJ9nI0HcuZQKBgQCMtoV6e9VGX4AEfpuHvAAnMYQFgeBiYTkBKltQ
377XwozhH63uMMomUmtSG87Sz1TmrXadjAhy8gsG6I0pWaN7QgBuFnzQ/HOkwTm+qKw
378AsrZt4zeXNwsH7QXHEJCFnCmqw9QzEoZTrNtHJHpNboBuVnYcoueZEJrP8OnUG3r
379UjmopwKBgAqB2KYYMUqAOvYcBnEfLDmyZv9BTVNHbR2lKkMYqv5LlvDaBxVfilE0
3802riO4p6BaAdvzXjKeRrGNEKoHNBpOSfYCOM16NjL8hIZB1CaV3WbT5oY+jp7Mzd5
3817d56RZOE+ERK2uz/7JX9VSsM/LbH9pJibd4e8mikDS9ntciqOH/3
382-----END RSA PRIVATE KEY-----";
383
384    fn test_signer() -> std::sync::Arc<dyn crate::signer::Signer> {
385        std::sync::Arc::new(crate::signer::raw::RawSigner::from_pem(TEST_RSA_PEM).unwrap())
386    }
387
388    #[tokio::test]
389    async fn test_app_jwt_structure() {
390        use base64::Engine as _;
391        use secrecy::ExposeSecret as _;
392
393        let client = GitHubClient::new("https://api.github.com", "12345", test_signer());
394        let jwt = client.app_jwt().await.unwrap();
395        let jwt_str = jwt.expose_secret();
396
397        let parts: Vec<&str> = jwt_str.split('.').collect();
398        assert_eq!(parts.len(), 3);
399
400        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
401        let header_bytes = engine.decode(parts[0]).unwrap();
402        let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
403        assert_eq!(header["alg"], "RS256");
404        assert_eq!(header["typ"], "JWT");
405
406        let claims_bytes = engine.decode(parts[1]).unwrap();
407        let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).unwrap();
408        assert_eq!(claims["iss"], "12345");
409
410        let now = std::time::SystemTime::now()
411            .duration_since(std::time::UNIX_EPOCH)
412            .unwrap()
413            .as_secs();
414        let iat = claims["iat"].as_u64().unwrap();
415        let exp = claims["exp"].as_u64().unwrap();
416
417        // iat should be ~60s before now
418        assert!(now - iat <= 62 && now - iat >= 58);
419        // exp should be ~540s after now
420        assert!(exp - now <= 542 && exp - now >= 538);
421    }
422
423    fn test_public_key_pem() -> Vec<u8> {
424        use rsa::pkcs1::DecodeRsaPrivateKey as _;
425        use rsa::pkcs8::EncodePublicKey as _;
426        let private_key =
427            rsa::RsaPrivateKey::from_pkcs1_pem(std::str::from_utf8(TEST_RSA_PEM).unwrap()).unwrap();
428        let public_key = private_key.to_public_key();
429        public_key
430            .to_public_key_pem(rsa::pkcs8::LineEnding::LF)
431            .unwrap()
432            .into_bytes()
433    }
434
435    #[tokio::test]
436    async fn test_app_jwt_verifiable() {
437        use secrecy::ExposeSecret as _;
438
439        let client = GitHubClient::new("https://api.github.com", "99999", test_signer());
440        let jwt = client.app_jwt().await.unwrap();
441
442        let pub_pem = test_public_key_pem();
443        let decoding_key = jsonwebtoken::DecodingKey::from_rsa_pem(&pub_pem).unwrap();
444        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
445        validation.set_issuer(&["99999"]);
446        validation.validate_aud = false;
447
448        let token_data = jsonwebtoken::decode::<serde_json::Value>(
449            jwt.expose_secret(),
450            &decoding_key,
451            &validation,
452        )
453        .unwrap();
454
455        assert_eq!(token_data.claims["iss"], "99999");
456    }
457
458    #[tokio::test]
459    async fn test_raw_signer_produces_valid_rs256() {
460        use base64::Engine as _;
461        use secrecy::ExposeSecret as _;
462        let signer = crate::signer::raw::RawSigner::from_pem(TEST_RSA_PEM).unwrap();
463
464        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
465        let header = engine.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
466        let payload = engine.encode(b"{\"sub\":\"test\"}");
467        let msg = format!("{header}.{payload}");
468
469        let sig = signer.sign(msg.as_bytes()).await.unwrap();
470        assert!(!sig.expose_secret().is_empty());
471
472        let sig_b64 = engine.encode(sig.expose_secret());
473        let token = format!("{msg}.{sig_b64}");
474
475        let pub_pem = test_public_key_pem();
476        let decoding_key = jsonwebtoken::DecodingKey::from_rsa_pem(&pub_pem).unwrap();
477        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
478        validation.validate_aud = false;
479        validation.validate_exp = false;
480        validation.required_spec_claims.clear();
481
482        let result = jsonwebtoken::decode::<serde_json::Value>(&token, &decoding_key, &validation);
483        assert!(result.is_ok(), "JWT verification failed: {result:?}");
484    }
485}