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 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, exp: now + 540, };
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 (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 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 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 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 assert!(now - iat <= 62 && now - iat >= 58);
414 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}