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(&params.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(&params.user_agent)
155            .build()?;
156        let raw = get_installation_token(&client, &params).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}