1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
//! For performing functions related to authentication for the API.
use std::{
    fmt,
    sync::{Arc, Mutex},
    time,
};

use crate::ClientResult;
use jsonwebtoken as jwt;
use serde::Serialize;
use tokio::sync::RwLock;

// We use 9 minutes for the life to give some buffer for clock drift between
// our clock and GitHub's. The absolute max is 10 minutes.
const MAX_JWT_TOKEN_LIFE: time::Duration = time::Duration::from_secs(60 * 9);
// 8 minutes so we refresh sooner than it actually expires
const JWT_TOKEN_REFRESH_PERIOD: time::Duration = time::Duration::from_secs(60 * 8);

// Installation tokens are valid for one hour.
// We'll refresh sooner to avoid problems with clock drift.
const INSTALLATION_TOKEN_REFRESH_PERIOD: time::Duration = time::Duration::from_secs(60 * 58);

/// Controls what sort of authentication is required for this request.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthenticationConstraint {
    /// No constraint
    Unconstrained,
    /// Must be JWT
    JWT,
}

/// Various forms of authentication credentials supported by GitHub.
#[derive(PartialEq, Clone)]
pub enum Credentials {
    /// Oauth token string
    /// https://developer.github.com/v3/#oauth2-token-sent-in-a-header
    Token(String),
    /// Oauth client id and secret
    /// https://developer.github.com/v3/#oauth2-keysecret
    Client(String, String),
    /// JWT token exchange, to be performed transparently in the
    /// background. app-id, DER key-file.
    /// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/
    JWT(JWTCredentials),
    /// JWT-based App Installation Token
    /// https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/
    InstallationToken(InstallationTokenGenerator),
}

impl fmt::Debug for Credentials {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Credentials::Token(value) => f
                .debug_tuple("Credentials::Token")
                .field(&"*".repeat(value.len()))
                .finish(),
            Credentials::Client(id, secret) => f
                .debug_tuple("Credentials::Client")
                .field(&id)
                .field(&"*".repeat(secret.len()))
                .finish(),
            Credentials::JWT(jwt) => f
                .debug_struct("Credentials::JWT")
                .field("app_id", &jwt.app_id)
                .field("private_key", &"vec![***]")
                .finish(),
            Credentials::InstallationToken(generator) => f
                .debug_struct("Credentials::InstallationToken")
                .field("installation_id", &generator.installation_id)
                .field("jwt_credential", &"***")
                .finish(),
        }
    }
}

/// JSON Web Token authentication mechanism.
///
/// The GitHub client methods are all &self, but the dynamically
/// generated JWT token changes regularly. The token is also a bit
/// expensive to regenerate, so we do want to have a mutable cache.
///
/// We use a token inside a Mutex so we can have interior mutability
/// even though JWTCredentials is not mutable.
#[derive(Clone)]
pub struct JWTCredentials {
    pub app_id: i64,
    /// DER RSA key. Generate with
    /// `openssl rsa -in private_rsa_key.pem -outform DER -out private_rsa_key.der`
    pub private_key: Vec<u8>,
    cache: Arc<Mutex<ExpiringJWTCredential>>,
}

impl JWTCredentials {
    pub fn new(app_id: i64, private_key: Vec<u8>) -> ClientResult<JWTCredentials> {
        let creds = ExpiringJWTCredential::calculate(app_id, &private_key)?;

        Ok(JWTCredentials {
            app_id,
            private_key,
            cache: Arc::new(Mutex::new(creds)),
        })
    }

    /// Fetch a valid JWT token, regenerating it if necessary
    pub fn token(&self) -> String {
        let mut expiring = self.cache.lock().unwrap();
        if expiring.is_stale() {
            *expiring = ExpiringJWTCredential::calculate(self.app_id, &self.private_key)
                .expect("JWT private key worked before, it should work now...");
        }

        expiring.token.clone()
    }
}

impl PartialEq for JWTCredentials {
    fn eq(&self, other: &JWTCredentials) -> bool {
        self.app_id == other.app_id && self.private_key == other.private_key
    }
}

#[derive(Debug)]
struct ExpiringJWTCredential {
    token: String,
    created_at: tokio::time::Instant,
}

#[derive(Serialize)]
struct JWTCredentialClaim {
    iat: u64,
    exp: u64,
    iss: i64,
}

impl ExpiringJWTCredential {
    fn calculate(app_id: i64, private_key: &[u8]) -> ClientResult<ExpiringJWTCredential> {
        // SystemTime can go backwards, Instant can't, so always use
        // Instant for ensuring regular cycling.
        let created_at = tokio::time::Instant::now();
        let now = time::SystemTime::now()
            .duration_since(time::UNIX_EPOCH)
            .unwrap();
        let expires = now + MAX_JWT_TOKEN_LIFE;

        let payload = JWTCredentialClaim {
            // GitHub recommends specifying this as 60 seconds in the past to avoid problems with clock drift.
            iat: now.as_secs().saturating_sub(60),
            exp: expires.as_secs(),
            iss: app_id,
        };
        let header = jwt::Header::new(jwt::Algorithm::RS256);
        let jwt = jwt::encode(
            &header,
            &payload,
            &jsonwebtoken::EncodingKey::from_rsa_der(private_key),
        )?;

        Ok(ExpiringJWTCredential {
            created_at,
            token: jwt,
        })
    }

    fn is_stale(&self) -> bool {
        self.created_at.elapsed() >= JWT_TOKEN_REFRESH_PERIOD
    }
}

#[derive(Debug, Clone)]
pub(crate) struct ExpiringInstallationToken {
    token: String,
    created_at: tokio::time::Instant,
}

impl ExpiringInstallationToken {
    pub fn new(token: String, created_at: tokio::time::Instant) -> Self {
        Self { token, created_at }
    }

    pub fn token(&self) -> Option<&str> {
        if self.created_at.elapsed() < INSTALLATION_TOKEN_REFRESH_PERIOD {
            Some(&self.token)
        } else {
            None
        }
    }
}

/// A caching token "generator" which contains JWT credentials.
///
/// The authentication mechanism in the GitHub client library
/// determines if the token is stale, and if so, uses the contained
/// JWT credentials to fetch a new installation token.
///
/// The RwLock<Option> access key is for interior mutability.
#[derive(Debug, Clone)]
pub struct InstallationTokenGenerator {
    pub installation_id: i64,
    pub jwt_credential: Box<Credentials>,
    pub(crate) access_key: Arc<RwLock<Option<ExpiringInstallationToken>>>,
}

impl InstallationTokenGenerator {
    pub fn new(installation_id: i64, creds: JWTCredentials) -> InstallationTokenGenerator {
        InstallationTokenGenerator {
            installation_id,
            jwt_credential: Box::new(Credentials::JWT(creds)),
            access_key: Arc::new(RwLock::new(None)),
        }
    }

    pub async fn token(&self) -> Option<String> {
        self.access_key
            .read()
            .await
            .as_ref()
            .and_then(|t| t.token())
            .map(|t| t.to_owned())
    }

    pub fn jwt(&self) -> &Credentials {
        &self.jwt_credential
    }
}

impl PartialEq for InstallationTokenGenerator {
    fn eq(&self, other: &InstallationTokenGenerator) -> bool {
        self.installation_id == other.installation_id && self.jwt_credential == other.jwt_credential
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::RngCore;
    use rsa::{pkcs1::EncodeRsaPrivateKey, RsaPrivateKey};
    use std::time::Duration;

    fn app_id() -> i64 {
        let mut rng = rand::thread_rng();
        rng.next_u32() as i64
    }

    fn installation_id() -> i64 {
        let mut rng = rand::thread_rng();
        rng.next_u32() as i64
    }

    fn private_key() -> Vec<u8> {
        let mut rng = rand::thread_rng();
        let private_key = RsaPrivateKey::new(&mut rng, 2048)
            .unwrap()
            .to_pkcs1_der()
            .unwrap()
            .to_bytes();
        private_key.to_vec()
    }

    #[tokio::test(start_paused = true)]
    async fn jwt_credentials_refreshes_when_necessary() {
        let credentials = JWTCredentials::new(app_id(), private_key())
            .expect("Should be able to create credentials");

        assert!(
            !credentials.cache.lock().unwrap().is_stale(),
            "Credentials should not be initially stale",
        );
        let initial_token = credentials.token();

        // Sleep 1 real second so the token content changes on refresh.
        std::thread::sleep(Duration::from_secs(1));
        // Sleep fake time to make the current token stale.
        tokio::time::advance(JWT_TOKEN_REFRESH_PERIOD).await;

        assert!(
            credentials.cache.lock().unwrap().is_stale(),
            "Credentials should be stale after refresh period",
        );

        let new_token = credentials.token();
        assert_ne!(initial_token, new_token, "New token should be generated");
    }

    #[tokio::test(start_paused = true)]
    async fn installation_token_generator_expires_after_token_interval() {
        let jwt_credentials = JWTCredentials::new(app_id(), private_key())
            .expect("Should be able to create JWT credentials");

        let generator = InstallationTokenGenerator::new(installation_id(), jwt_credentials.clone());

        assert_eq!(
            None,
            generator.token().await,
            "Generator should initially have no token",
        );
        *generator.access_key.write().await = Some(ExpiringInstallationToken::new(
            "initial token".to_owned(),
            tokio::time::Instant::now(),
        ));
        assert_eq!(
            Some("initial token"),
            generator.token().await.as_deref(),
            "Generator should have token after set",
        );

        // Sleep fake time to make the current token stale.
        tokio::time::advance(INSTALLATION_TOKEN_REFRESH_PERIOD).await;

        assert_eq!(
            None,
            generator.token().await,
            "Generator token should expire after interval",
        );
    }
}