1use std::time::{SystemTime, UNIX_EPOCH};
2
3use reqwest::blocking::Client;
4use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
5use serde::Deserialize;
6use serde_json::Value;
7
8use crate::auth::jwt;
9use crate::error::ViaError;
10use crate::redaction::Redactor;
11use crate::secrets::SecretValue;
12
13pub fn installation_access_token(
14 client: &Client,
15 api_base_url: &str,
16 credential: &SecretValue,
17 private_key: Option<&SecretValue>,
18 redactor: &mut Redactor,
19) -> Result<String, ViaError> {
20 redactor.add(credential.expose());
21 if let Some(private_key) = private_key {
22 redactor.add(private_key.expose());
23 }
24
25 let bundle =
26 CredentialBundle::parse(credential.expose(), private_key.map(SecretValue::expose))?;
27 bundle.validate_kind()?;
28
29 redactor.add(&bundle.private_key);
30 let jwt = app_jwt(&bundle)?;
31 redactor.add(&jwt);
32
33 let url = format!(
34 "{}/app/installations/{}/access_tokens",
35 api_base_url.trim_end_matches('/'),
36 bundle.installation_id
37 );
38 let response = client
39 .post(url)
40 .headers(token_exchange_headers(&jwt)?)
41 .send()?;
42 let status = response.status();
43 let body = response.text()?;
44 let body = redactor.redact(&body);
45
46 if !status.is_success() {
47 return Err(ViaError::InvalidArgument(format!(
48 "GitHub App token exchange failed with status {status}: {body}"
49 )));
50 }
51
52 let response: InstallationTokenResponse = serde_json::from_str(&body)?;
53 redactor.add(&response.token);
54 Ok(response.token)
55}
56
57pub fn validate_credential_bundle(raw: &str, private_key: Option<&str>) -> Result<(), ViaError> {
58 let bundle = CredentialBundle::parse(raw, private_key)?;
59 bundle.validate_kind()?;
60 app_jwt(&bundle)?;
61 Ok(())
62}
63
64fn app_jwt(bundle: &CredentialBundle) -> Result<String, ViaError> {
65 let now = unix_timestamp()?;
66 let claims = serde_json::json!({
67 "iat": now - 60,
68 "exp": now + 540,
69 "iss": bundle.issuer,
70 });
71 jwt::sign_rs256(&claims, &bundle.private_key)
72}
73
74fn token_exchange_headers(jwt: &str) -> Result<HeaderMap, ViaError> {
75 let mut headers = HeaderMap::new();
76 headers.insert(
77 ACCEPT,
78 HeaderValue::from_static("application/vnd.github+json"),
79 );
80 headers.insert(USER_AGENT, HeaderValue::from_static("via-cli"));
81 headers.insert(
82 "X-GitHub-Api-Version",
83 HeaderValue::from_static("2022-11-28"),
84 );
85 headers.insert(
86 AUTHORIZATION,
87 HeaderValue::from_str(&format!("Bearer {jwt}"))
88 .map_err(|_| ViaError::InvalidConfig("invalid GitHub App JWT".to_owned()))?,
89 );
90 Ok(headers)
91}
92
93fn unix_timestamp() -> Result<i64, ViaError> {
94 let duration = SystemTime::now()
95 .duration_since(UNIX_EPOCH)
96 .map_err(|_| ViaError::InvalidConfig("system clock is before UNIX epoch".to_owned()))?;
97 i64::try_from(duration.as_secs())
98 .map_err(|_| ViaError::InvalidConfig("system clock timestamp is too large".to_owned()))
99}
100
101#[derive(Debug, PartialEq, Eq)]
102struct CredentialBundle {
103 kind: String,
104 issuer: String,
105 installation_id: String,
106 private_key: String,
107}
108
109impl CredentialBundle {
110 fn parse(raw: &str, private_key: Option<&str>) -> Result<Self, ViaError> {
111 let value: Value = serde_json::from_str(raw).map_err(credential_json_error)?;
112
113 Ok(Self {
114 kind: required_string(&value, "type")?,
115 issuer: required_app_id(&value)?,
116 installation_id: required_string_or_number(&value, "installation_id")?,
117 private_key: match private_key {
118 Some(private_key) => private_key.to_owned(),
119 None => required_string(&value, "private_key")?,
120 },
121 })
122 }
123
124 fn validate_kind(&self) -> Result<(), ViaError> {
125 if self.kind == "github_app" {
126 return Ok(());
127 }
128
129 Err(ViaError::InvalidConfig(
130 "GitHub App credential bundle must set type = \"github_app\"".to_owned(),
131 ))
132 }
133}
134
135#[derive(Debug, Deserialize)]
136struct InstallationTokenResponse {
137 token: String,
138}
139
140fn credential_json_error(error: serde_json::Error) -> ViaError {
141 let mut message = format!("GitHub App credential bundle must be valid JSON: {error}");
142 if error.to_string().contains("control character") {
143 message.push_str(
144 "; private_key must escape PEM newlines as `\\n`, not contain raw line breaks inside the JSON string",
145 );
146 }
147 ViaError::InvalidConfig(message)
148}
149
150fn required_string(value: &Value, field: &str) -> Result<String, ViaError> {
151 value
152 .get(field)
153 .and_then(Value::as_str)
154 .filter(|value| !value.trim().is_empty())
155 .map(str::to_owned)
156 .ok_or_else(|| {
157 ViaError::InvalidConfig(format!(
158 "GitHub App credential bundle must include non-empty `{field}`"
159 ))
160 })
161}
162
163fn required_app_id(value: &Value) -> Result<String, ViaError> {
164 if let Some(number) = value.get("app_id").and_then(Value::as_u64) {
165 return Ok(number.to_string());
166 }
167 if let Some(app_id) = value
168 .get("app_id")
169 .and_then(Value::as_str)
170 .filter(|value| value.chars().all(|character| character.is_ascii_digit()))
171 {
172 return Ok(app_id.to_owned());
173 }
174
175 if value.get("client_id").is_some() {
176 return Err(ViaError::InvalidConfig(
177 "GitHub App credential bundle must include numeric `app_id`; `client_id` is metadata only and is not used for this token exchange".to_owned(),
178 ));
179 }
180
181 Err(ViaError::InvalidConfig(
182 "GitHub App credential bundle must include numeric `app_id`".to_owned(),
183 ))
184}
185
186fn required_string_or_number(value: &Value, field: &str) -> Result<String, ViaError> {
187 if let Some(value) = value
188 .get(field)
189 .and_then(Value::as_str)
190 .filter(|value| !value.trim().is_empty())
191 {
192 return Ok(value.to_owned());
193 }
194 if let Some(number) = value.get(field).and_then(Value::as_u64) {
195 return Ok(number.to_string());
196 }
197
198 Err(ViaError::InvalidConfig(format!(
199 "GitHub App credential bundle must include non-empty `{field}`"
200 )))
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 const PRIVATE_KEY: &str = include_str!("../../tests/fixtures/rsa-private-key.pkcs1.pem");
208
209 #[test]
210 fn parses_bundle_with_app_id_string() {
211 let bundle = CredentialBundle::parse(
212 &serde_json::json!({
213 "type": "github_app",
214 "app_id": "42",
215 "installation_id": "123",
216 "private_key": PRIVATE_KEY,
217 })
218 .to_string(),
219 None,
220 )
221 .unwrap();
222
223 assert_eq!(
224 bundle,
225 CredentialBundle {
226 kind: "github_app".to_owned(),
227 issuer: "42".to_owned(),
228 installation_id: "123".to_owned(),
229 private_key: PRIVATE_KEY.to_owned(),
230 }
231 );
232 }
233
234 #[test]
235 fn parses_numeric_app_and_installation_ids() {
236 let bundle = CredentialBundle::parse(
237 &serde_json::json!({
238 "type": "github_app",
239 "app_id": 42,
240 "installation_id": 123,
241 "private_key": PRIVATE_KEY,
242 })
243 .to_string(),
244 None,
245 )
246 .unwrap();
247
248 assert_eq!(bundle.issuer, "42");
249 assert_eq!(bundle.installation_id, "123");
250 }
251
252 #[test]
253 fn rejects_missing_private_key() {
254 let error = CredentialBundle::parse(
255 &serde_json::json!({
256 "type": "github_app",
257 "app_id": 42,
258 "installation_id": "123",
259 })
260 .to_string(),
261 None,
262 )
263 .unwrap_err();
264
265 assert!(
266 matches!(error, ViaError::InvalidConfig(message) if message.contains("private_key"))
267 );
268 }
269
270 #[test]
271 fn explains_raw_newlines_inside_private_key_json() {
272 let error = CredentialBundle::parse(
273 r#"{
274 "type": "github_app",
275 "app_id": 42,
276 "installation_id": "123",
277 "private_key": "-----BEGIN RSA PRIVATE KEY-----
278abc
279-----END RSA PRIVATE KEY-----"
280}"#,
281 None,
282 )
283 .unwrap_err();
284
285 assert!(
286 matches!(error, ViaError::InvalidConfig(message) if message.contains("escape PEM newlines"))
287 );
288 }
289
290 #[test]
291 fn validates_bundle_and_private_key() {
292 validate_credential_bundle(
293 &serde_json::json!({
294 "type": "github_app",
295 "app_id": 42,
296 "installation_id": "123",
297 "private_key": PRIVATE_KEY,
298 })
299 .to_string(),
300 None,
301 )
302 .unwrap();
303 }
304
305 #[test]
306 fn validates_split_metadata_and_private_key() {
307 validate_credential_bundle(
308 &serde_json::json!({
309 "type": "github_app",
310 "app_id": 42,
311 "installation_id": "123",
312 })
313 .to_string(),
314 Some(PRIVATE_KEY),
315 )
316 .unwrap();
317 }
318
319 #[test]
320 fn creates_app_jwt() {
321 let bundle = CredentialBundle {
322 kind: "github_app".to_owned(),
323 issuer: "42".to_owned(),
324 installation_id: "123".to_owned(),
325 private_key: PRIVATE_KEY.to_owned(),
326 };
327
328 let token = app_jwt(&bundle).unwrap();
329
330 assert_eq!(token.split('.').count(), 3);
331 }
332
333 #[test]
334 fn rejects_client_id_without_app_id() {
335 let error = CredentialBundle::parse(
336 &serde_json::json!({
337 "type": "github_app",
338 "client_id": "Iv1.client",
339 "installation_id": "123",
340 "private_key": PRIVATE_KEY,
341 })
342 .to_string(),
343 None,
344 )
345 .unwrap_err();
346
347 assert!(matches!(
348 error,
349 ViaError::InvalidConfig(message)
350 if message.contains("numeric `app_id`") && message.contains("client_id")
351 ));
352 }
353}