1use crate::error::Error;
2
3const MAX_PAGES: u32 = 50;
4const PER_PAGE: u32 = 100;
5const MAX_RESPONSE_SIZE: usize = 100 * 1024; const 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, exp: now + 540, };
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 (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 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 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 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 assert!(now - iat <= 62 && now - iat >= 58);
419 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}