automatons_github/client/
token.rs1use 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#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
23pub struct AppScope;
24
25#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
30pub struct InstallationScope;
31
32#[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 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}