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