Skip to main content

via/auth/
github_app.rs

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}