automatons_github/client/
token.rs

1use std::marker::PhantomData;
2use std::ops::Sub;
3use std::sync::Arc;
4
5use anyhow::Context;
6use chrono::{DateTime, Duration, Utc};
7use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
8use parking_lot::Mutex;
9use reqwest::Client;
10use secrecy::{ExposeSecret, SecretString};
11use serde::{Deserialize, Serialize};
12
13use automatons::Error;
14
15use crate::client::{GitHubHost, PrivateKey};
16use crate::resource::{AppId, InstallationId};
17
18/// Marker type for the application scope
19///
20/// GitHub Apps can authenticate either as themselves or as an installation. See the [`Token`] for
21/// more information
22#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
23pub struct AppScope;
24
25/// Marker type for the installation scope
26///
27/// GitHub Apps can authenticate either as themselves or as an installation. See the [`Token`] for
28/// more information
29#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
30pub struct InstallationScope;
31
32/// Authentication token for GitHub Apps
33///
34/// GitHub uses tokens to authenticate requests against against its API. For GitHub Apps, there are
35/// two different kinds of tokens. Both grant the app a different scope, namely the app or the
36/// installation scope. When the app wants to request resources as itself, it uses the app scope. If
37/// it wants to impersonate an installation and access its resource, it uses the installation scope.
38///
39/// The [`Token`] struct is an abstraction around these different tokens. It uses a marker type to
40/// indicate what scope it has so that the Rust compiler can ensure that the token matches the
41/// required scope.
42#[derive(Clone, Debug)]
43pub struct Token<Scope> {
44    scope: PhantomData<Scope>,
45    token: SecretString,
46    expires_at: DateTime<Utc>,
47}
48
49impl<Scope> Token<Scope> {
50    /// Returns the raw token.
51    pub fn get(&self) -> &str {
52        self.token.expose_secret()
53    }
54}
55
56#[derive(Clone, Debug)]
57pub(super) struct TokenFactory {
58    github_host: GitHubHost,
59    app_id: AppId,
60    private_key: PrivateKey,
61    app_token: Arc<Mutex<Token<AppScope>>>,
62    installation_token: Arc<Mutex<Token<InstallationScope>>>,
63}
64
65impl TokenFactory {
66    #[cfg_attr(feature = "tracing", tracing::instrument)]
67    pub fn new(github_host: GitHubHost, app_id: AppId, private_key: PrivateKey) -> Self {
68        let expiration = Utc::now().sub(Duration::days(1));
69
70        let expired_app_token = Token {
71            scope: PhantomData,
72            token: SecretString::new("app_token".into()),
73            expires_at: expiration,
74        };
75        let expired_installation_token = Token {
76            scope: PhantomData,
77            token: SecretString::new("installation_token".into()),
78            expires_at: expiration,
79        };
80
81        Self {
82            github_host,
83            app_id,
84            private_key,
85            app_token: Arc::new(Mutex::new(expired_app_token)),
86            installation_token: Arc::new(Mutex::new(expired_installation_token)),
87        }
88    }
89
90    #[cfg_attr(feature = "tracing", tracing::instrument)]
91    pub fn app(&self) -> Result<Token<AppScope>, Error> {
92        let now = Utc::now();
93
94        {
95            let app_token = self.app_token.lock();
96            if app_token.expires_at > now {
97                return Ok(app_token.clone());
98            }
99        }
100
101        let jwt = self.generate_jwt()?;
102        let token = Token {
103            scope: PhantomData,
104            token: SecretString::new(jwt),
105            expires_at: now,
106        };
107
108        {
109            let mut app_token = self.app_token.lock();
110            *app_token = token.clone();
111        }
112
113        Ok(token)
114    }
115
116    #[cfg_attr(feature = "tracing", tracing::instrument)]
117    pub async fn installation(
118        &self,
119        installation_id: InstallationId,
120    ) -> Result<Token<InstallationScope>, Error> {
121        let now = Utc::now();
122
123        {
124            let installation_token = self.installation_token.lock();
125            if installation_token.expires_at > now {
126                return Ok(installation_token.clone());
127            }
128        }
129
130        let url = format!(
131            "{}/app/installations/{}/access_tokens",
132            self.github_host.get(),
133            installation_id
134        );
135
136        let app_token = self.app()?;
137
138        let response = Client::new()
139            .post(url)
140            .header("Authorization", format!("Bearer {}", app_token.get()))
141            .header("Accept", "application/vnd.github.v3+json")
142            .header("User-Agent", "devxbots/github-parts")
143            .send()
144            .await?;
145
146        let access_token_response: AccessTokensResponse = response
147            .json()
148            .await
149            .map_err(|error| Error::Serialization(error.to_string()))?;
150
151        let token = Token {
152            scope: PhantomData,
153            token: SecretString::new(access_token_response.token),
154            expires_at: now,
155        };
156
157        {
158            let mut installation_token = self.installation_token.lock();
159            *installation_token = token.clone();
160        }
161
162        Ok(token)
163    }
164
165    #[cfg_attr(feature = "tracing", tracing::instrument)]
166    fn generate_jwt(&self) -> Result<String, Error> {
167        let now = Utc::now();
168
169        let issued_at = now
170            .checked_sub_signed(Duration::seconds(60))
171            .context("failed to create timestamp for iat claim in GitHub App token")?;
172
173        let expires_at = now
174            .checked_add_signed(Duration::minutes(10))
175            .context("failed to create timestamp for exp claim in GitHub App token")?;
176
177        let claims = Claims {
178            iat: issued_at.timestamp(),
179            iss: self.app_id.get().to_string(),
180            exp: expires_at.timestamp(),
181        };
182
183        let header = Header::new(Algorithm::RS256);
184        let key =
185            EncodingKey::from_rsa_pem(self.private_key.expose().as_bytes()).map_err(|_error| {
186                Error::Configuration("failed to create encoding key for GitHub App token".into())
187            })?;
188
189        Ok(encode(&header, &claims, &key).context("failed to encode JWT for GitHub App token")?)
190    }
191}
192
193#[derive(Debug, Serialize, Deserialize)]
194struct Claims {
195    iat: i64,
196    iss: String,
197    exp: i64,
198}
199
200#[derive(Deserialize, Serialize)]
201struct AccessTokensResponse {
202    token: String,
203}
204
205#[cfg(test)]
206mod tests {
207    use std::marker::PhantomData;
208    use std::ops::{Add, Sub};
209    use std::sync::Arc;
210
211    use chrono::{Duration, Utc};
212    use mockito::mock;
213    use parking_lot::Mutex;
214    use secrecy::SecretString;
215
216    use crate::client::PrivateKey;
217    use crate::resource::{AppId, InstallationId};
218
219    use super::{AppScope, InstallationScope, Token, TokenFactory};
220
221    fn factory(
222        app_token: Option<Token<AppScope>>,
223        installation_token: Option<Token<InstallationScope>>,
224    ) -> TokenFactory {
225        let expiration = Utc::now().sub(Duration::days(1));
226
227        let app_token = match app_token {
228            Some(token) => token,
229            None => Token {
230                scope: PhantomData,
231                token: SecretString::new("app_token".into()),
232                expires_at: expiration,
233            },
234        };
235        let installation_token = match installation_token {
236            Some(token) => token,
237            None => Token {
238                scope: PhantomData,
239                token: SecretString::new("installation_token".into()),
240                expires_at: expiration,
241            },
242        };
243
244        TokenFactory {
245            github_host: mockito::server_url().into(),
246            app_id: AppId::new(1),
247            private_key: PrivateKey::new(include_str!("../../tests/fixtures/private-key.pem")),
248            app_token: Arc::new(Mutex::new(app_token)),
249            installation_token: Arc::new(Mutex::new(installation_token)),
250        }
251    }
252
253    #[test]
254    fn app_caches_token_while_it_is_not_expired() {
255        let token = Token {
256            scope: PhantomData,
257            token: SecretString::new("app".into()),
258            expires_at: Utc::now().add(Duration::minutes(10)),
259        };
260        let factory = factory(Some(token.clone()), None);
261
262        let new_token = factory.app().unwrap();
263
264        assert_eq!(new_token.get(), token.get());
265    }
266
267    #[test]
268    fn app_generates_new_when_token_expired() {
269        let token = Token {
270            scope: PhantomData,
271            token: SecretString::new("app".into()),
272            expires_at: Utc::now().sub(Duration::minutes(10)),
273        };
274        let factory = factory(Some(token.clone()), None);
275
276        let new_token = factory.app().unwrap();
277
278        assert_ne!(new_token.get(), token.get());
279    }
280
281    #[tokio::test]
282    async fn installation_caches_token_while_it_is_not_expired() {
283        let token = Token {
284            scope: PhantomData,
285            token: SecretString::new("installation".into()),
286            expires_at: Utc::now().add(Duration::minutes(10)),
287        };
288        let factory = factory(None, Some(token.clone()));
289
290        let new_token = factory.installation(InstallationId::new(1)).await.unwrap();
291
292        assert_eq!(new_token.get(), token.get());
293    }
294
295    #[tokio::test]
296    async fn installation_requests_new_when_token_expired() {
297        let _mock = mock("POST", "/app/installations/1/access_tokens")
298            .with_status(200)
299            .with_body(r#"{ "token": "ghs_16C7e42F292c6912E7710c838347Ae178B4a" }"#)
300            .create();
301
302        let app_token = Token {
303            scope: PhantomData,
304            token: SecretString::new("app".into()),
305            expires_at: Utc::now().sub(Duration::minutes(10)),
306        };
307        let installation_token = Token {
308            scope: PhantomData,
309            token: SecretString::new("installation".into()),
310            expires_at: Utc::now().add(Duration::minutes(10)),
311        };
312        let factory = factory(Some(app_token.clone()), Some(installation_token));
313
314        let new_token = factory.installation(InstallationId::new(1)).await.unwrap();
315
316        assert_ne!(new_token.get(), app_token.get());
317    }
318
319    #[test]
320    fn trait_send() {
321        fn assert_send<T: Send>() {}
322
323        assert_send::<Token<AppScope>>();
324        assert_send::<Token<InstallationScope>>();
325    }
326
327    #[test]
328    fn trait_sync() {
329        fn assert_sync<T: Sync>() {}
330
331        assert_sync::<Token<AppScope>>();
332        assert_sync::<Token<InstallationScope>>();
333    }
334}