github_app_auth/lib.rs
1//! This crate provides a library for authenticating with the GitHub
2//! API as a GitHub app. See
3//! [Authenticating with GitHub Apps](https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps)
4//! for details about the authentication flow.
5//!
6//! Example:
7//!
8//! ```no_run
9//! use github_app_auth::{GithubAuthParams, InstallationAccessToken};
10//!
11//! # async fn wrapper() {
12//! // The token is mutable because the installation access token must be
13//! // periodically refreshed. See the `GithubAuthParams` documentation
14//! // for details on how to get the private key and the two IDs.
15//! let mut token = InstallationAccessToken::new(GithubAuthParams {
16//! user_agent: "my-cool-user-agent".into(),
17//! private_key: b"my private key".to_vec(),
18//! app_id: 1234,
19//! installation_id: 5678,
20//! }).await.expect("failed to get installation access token");
21//!
22//! // Getting the authentication header will automatically refresh
23//! // the token if necessary, but of course this operation can fail.
24//! let header = token.header().await.expect("failed to get authentication header");
25//!
26//! token.client.post("https://some-github-api-url").headers(header).send().await;
27//! # } // End wrapper
28//! ```
29#![warn(missing_docs)]
30
31use chrono::{DateTime, Duration, Utc};
32use log::info;
33use reqwest::header::HeaderMap;
34use serde::{Deserialize, Serialize};
35use std::time;
36
37const MACHINE_MAN_PREVIEW: &str =
38 "application/vnd.github.machine-man-preview+json";
39
40/// Authentication error enum.
41#[derive(thiserror::Error, Debug)]
42pub enum AuthError {
43 /// An error occurred when trying to encode the JWT.
44 #[error("JWT encoding failed: {0}")]
45 JwtError(#[from] jsonwebtoken::errors::Error),
46
47 /// The token cannot be encoded as an HTTP header.
48 #[error("HTTP header encoding failed: {0}")]
49 InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
50
51 /// An HTTP request failed.
52 #[error("HTTP request failed: {0}")]
53 ReqwestError(#[from] reqwest::Error),
54
55 /// Something very unexpected happened with time itself.
56 #[error("system time error: {0}")]
57 TimeError(#[from] time::SystemTimeError),
58}
59
60#[derive(Debug, Serialize)]
61struct JwtClaims {
62 /// The time that this JWT was issued
63 iat: u64,
64 // JWT expiration time
65 exp: u64,
66 // GitHub App's identifier number
67 iss: u64,
68}
69
70impl JwtClaims {
71 fn new(params: &GithubAuthParams) -> Result<JwtClaims, AuthError> {
72 let now = time::SystemTime::now()
73 .duration_since(time::UNIX_EPOCH)?
74 .as_secs();
75 Ok(JwtClaims {
76 // The time that this JWT was issued (now)
77 iat: now,
78 // JWT expiration time (1 minute from now)
79 exp: now + 60,
80 // GitHub App's identifier number
81 iss: params.app_id,
82 })
83 }
84}
85
86/// This is the structure of the JSON object returned when requesting
87/// an installation access token.
88#[derive(Debug, Deserialize, Eq, PartialEq)]
89struct RawInstallationAccessToken {
90 token: String,
91 expires_at: DateTime<Utc>,
92}
93
94/// Use the app private key to generate a JWT and use the JWT to get
95/// an installation access token.
96///
97/// Reference:
98/// developer.github.com/apps/building-github-apps/authenticating-with-github-apps
99async fn get_installation_token(
100 client: &reqwest::Client,
101 params: &GithubAuthParams,
102) -> Result<RawInstallationAccessToken, AuthError> {
103 let claims = JwtClaims::new(params)?;
104 let header = jsonwebtoken::Header {
105 alg: jsonwebtoken::Algorithm::RS256,
106 ..Default::default()
107 };
108 let private_key =
109 jsonwebtoken::EncodingKey::from_rsa_pem(¶ms.private_key)?;
110 let token = jsonwebtoken::encode(&header, &claims, &private_key)?;
111
112 let url = format!(
113 "https://api.github.com/app/installations/{}/access_tokens",
114 params.installation_id
115 );
116 Ok(client
117 .post(&url)
118 .bearer_auth(token)
119 .header("Accept", MACHINE_MAN_PREVIEW)
120 .send()
121 .await?
122 .error_for_status()?
123 .json()
124 .await?)
125}
126
127/// An installation access token is the primary method for
128/// authenticating with the GitHub API as an application.
129pub struct InstallationAccessToken {
130 /// The [`reqwest::Client`] used to periodically refresh
131 /// the token.
132 ///
133 /// This is made public so that users of the library can re-use
134 /// this client for sending requests, but this is not required.
135 pub client: reqwest::Client,
136
137 /// This time is subtracted from the expiration time to make it less
138 /// likely that the token goes out of date just as a request is
139 /// sent.
140 pub refresh_safety_margin: Duration,
141
142 token: String,
143 expires_at: DateTime<Utc>,
144 params: GithubAuthParams,
145}
146
147impl InstallationAccessToken {
148 /// Fetch an installation access token using the provided
149 /// authentication parameters.
150 pub async fn new(
151 params: GithubAuthParams,
152 ) -> Result<InstallationAccessToken, AuthError> {
153 let client = reqwest::Client::builder()
154 .user_agent(¶ms.user_agent)
155 .build()?;
156 let raw = get_installation_token(&client, ¶ms).await?;
157 Ok(InstallationAccessToken {
158 client,
159 token: raw.token,
160 expires_at: raw.expires_at,
161 params,
162 refresh_safety_margin: Duration::minutes(1),
163 })
164 }
165
166 /// Get an HTTP authentication header for the installation access
167 /// token.
168 ///
169 /// This method is mutable because the installation access token
170 /// must be periodically refreshed.
171 pub async fn header(&mut self) -> Result<HeaderMap, AuthError> {
172 self.refresh().await?;
173 let mut headers = HeaderMap::new();
174 let val = format!("token {}", self.token);
175 headers.insert("Authorization", val.parse()?);
176 Ok(headers)
177 }
178
179 fn needs_refresh(&self) -> bool {
180 let expires_at = self.expires_at - self.refresh_safety_margin;
181 expires_at <= Utc::now()
182 }
183
184 async fn refresh(&mut self) -> Result<(), AuthError> {
185 if self.needs_refresh() {
186 info!("refreshing installation token");
187 let raw =
188 get_installation_token(&self.client, &self.params).await?;
189 self.token = raw.token;
190 self.expires_at = raw.expires_at;
191 }
192 Ok(())
193 }
194}
195
196/// Input parameters for authenticating as a GitHub app. This is used
197/// to get an installation access token.
198#[derive(Clone, Default)]
199pub struct GithubAuthParams {
200 /// User agent set for all requests to GitHub. The API requires
201 /// that a user agent is set:
202 /// <https://docs.github.com/en/rest/overview/resources-in-the-rest-api#user-agent-required>
203 ///
204 /// They "request that you use your GitHub username, or the name
205 /// of your application".
206 pub user_agent: String,
207
208 /// Private key used to sign access token requests. You can
209 /// generate a private key at the bottom of the application's
210 /// settings page.
211 pub private_key: Vec<u8>,
212
213 /// GitHub application installation ID. To find this value you can
214 /// look at the app installation's configuration URL.
215 ///
216 /// - For organizations this is on the "Installed GitHub Apps"
217 /// page in your organization's settings page.
218 ///
219 /// - For personal accounts, go to the "Applications" page and
220 /// select the "Installed GitHub Apps" tab.
221 ///
222 /// The installation ID will be the final component of the path,
223 /// for example "1216616" is the installation ID for
224 /// "github.com/organizations/mycoolorg/settings/installations/1216616".
225 pub installation_id: u64,
226
227 /// GitHub application ID. You can find this in the application
228 /// settings page on GitHub under "App ID".
229 pub app_id: u64,
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use chrono::TimeZone;
236
237 #[test]
238 fn test_raw_installation_access_token_parse() {
239 let resp = r#"{
240 "token": "v1.1f699f1069f60xxx",
241 "expires_at": "2016-07-11T22:14:10Z"
242 }"#;
243 let token =
244 serde_json::from_str::<RawInstallationAccessToken>(resp).unwrap();
245 assert_eq!(
246 token,
247 RawInstallationAccessToken {
248 token: "v1.1f699f1069f60xxx".into(),
249 expires_at: Utc.ymd(2016, 7, 11).and_hms(22, 14, 10),
250 }
251 );
252 }
253
254 #[test]
255 fn test_needs_refresh() {
256 use std::thread::sleep;
257 let mut token = InstallationAccessToken {
258 client: reqwest::Client::new(),
259 token: "myToken".into(),
260 expires_at: Utc::now() + Duration::seconds(2),
261 params: GithubAuthParams::default(),
262 refresh_safety_margin: Duration::seconds(0),
263 };
264 assert!(!token.needs_refresh());
265 sleep(Duration::milliseconds(1500).to_std().unwrap());
266 assert!(!token.needs_refresh());
267 token.refresh_safety_margin = Duration::seconds(1);
268 assert!(token.needs_refresh());
269 }
270}