summaryrefslogtreecommitdiff
path: root/src/auth.rs
blob: f147f9ff7d61155439537e2d7c0a0d7a7a52eb58 (plain)
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
//! Authentication related types and functions.

use crate::models::AppId;
use crate::Result;
use either::Either;
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::SystemTime;

use snafu::*;

/// The data necessary to authenticate as a Github App
#[derive(Clone)]
pub struct AppAuth {
    /// Github's app ID
    pub app_id: AppId,
    /// The app's RSA private key
    pub key: EncodingKey,
}

impl fmt::Debug for AppAuth {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("AppAuth")
            .field("app_id", &self.app_id)
            .finish_non_exhaustive()
    }
}

/// The forms of authentication we support
pub enum Auth {
    /// No authentication
    None,
    // Basic HTTP authentication (username:password)
    Basic {
        /// Username
        username: String,
        /// Password
        password: String,
    },
    /// Authenticate using a Github personal access token
    PersonalToken(SecretString),
    /// Authenticate as a Github App
    App(AppAuth),
    /// Authenticate as a Github OAuth App
    OAuth(OAuth),
}

impl Default for Auth {
    fn default() -> Self {
        Self::None
    }
}

/// Create a JSON Web Token that can be used to authenticate an a GitHub application.
///
/// See: https://docs.github.com/en/developers/apps/getting-started-with-apps/setting-up-your-development-environment-to-create-a-github-app#authenticating-as-a-github-app
pub fn create_jwt(
    github_app_id: AppId,
    key: &EncodingKey,
) -> Result<String, jsonwebtoken::errors::Error> {
    #[derive(Serialize)]
    struct Claims {
        iss: AppId,
        iat: usize,
        exp: usize,
    }

    let now = SystemTime::UNIX_EPOCH.elapsed().unwrap().as_secs() as usize;

    // Github only allows JWTs that expire in the next 10 minutes.
    // The token is issued 60 seconds in the past and expires in 9 minutes,
    // to allow some clock drift.
    let claims = Claims {
        iss: github_app_id,
        iat: now - 60,
        exp: now + (9 * 60),
    };

    let header = Header::new(Algorithm::RS256);

    jsonwebtoken::encode(&header, &claims, key)
}

impl AppAuth {
    /// Currently we don't cache these, but we could if we want to avoid
    /// an RSA signature operation per App-authorized API call.
    pub fn generate_bearer_token(&self) -> Result<String> {
        create_jwt(self.app_id, &self.key).context(crate::error::JWTSnafu)
    }
}

/// The data necessary to authenticate as a github OAtuh app.
#[derive(Clone, Deserialize)]
#[serde(from = "OAuthWire")]
pub struct OAuth {
    pub access_token: SecretString,
    pub token_type: String,
    pub scope: Vec<String>,
}

/// The wire format of the OAuth struct.
#[derive(Deserialize)]
struct OAuthWire {
    access_token: String,
    token_type: String,
    scope: String,
}

impl From<OAuthWire> for OAuth {
    fn from(value: OAuthWire) -> Self {
        OAuth {
            access_token: SecretString::from(value.access_token),
            token_type: value.token_type,
            scope: value.scope.split(",").map(ToString::to_string).collect(),
        }
    }
}

impl crate::Octocrab {
    /// Authenticate with Github's device flow. This starts the process to obtain a new `OAuth`.
    ///
    /// See https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#device-flow for details.
    ///
    /// Note: To authenticate against public Github, the `Octocrab` that calls this method
    /// *must* be constructed with `base_url: "https://github.com"` and extra header
    /// "ACCEPT: application/json". For example:
    /// ```no_run
    /// # async fn run() -> octocrab::Result<()> {
    /// # use reqwest::header::ACCEPT;
    /// let crab = octocrab::Octocrab::builder()
    /// .base_url("https://github.com")?
    /// .add_header(ACCEPT, "application/json".to_string())
    /// .build()?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn authenticate_as_device<I, S>(
        &self,
        client_id: &SecretString,
        scope: I,
    ) -> Result<DeviceCodes>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        let scope = {
            let mut scopes = scope.into_iter();
            let first = scopes
                .next()
                .map(|s| s.as_ref().to_string())
                .unwrap_or_default();
            scopes.fold(first, |i: String, n| i + "," + n.as_ref())
        };
        let codes: DeviceCodes = self
            .post(
                "login/device/code",
                Some(&DeviceFlow {
                    client_id: client_id.expose_secret(),
                    scope: &scope,
                }),
            )
            .await?;
        Ok(codes)
    }
}

/// The device codes as returned from step 1 of Github's device flow.
///
/// See https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#response-parameters
#[derive(Deserialize, Clone)]
#[non_exhaustive]
pub struct DeviceCodes {
    /// The device verification code is 40 characters and used to verify the device.
    pub device_code: String,
    /// The user verification code is displayed on the device so the user can enter the
    /// code in a browser. This code is 8 characters with a hyphen in the middle.
    pub user_code: String,
    /// The verification URL where users need to enter the user_code: https://github.com/login/device.
    pub verification_uri: String,
    /// The number of seconds before the device_code and user_code expire. The default is
    /// 900 seconds or 15 minutes.
    pub expires_in: u64,
    /// The minimum number of seconds that must pass before you can make a new access
    /// token request (POST https://github.com/login/oauth/access_token) to complete the
    /// device authorization. For example, if the interval is 5, then you cannot make a
    /// new request until 5 seconds pass. If you make more than one request over 5
    /// seconds, then you will hit the rate limit and receive a slow_down error.
    pub interval: u64,
}

impl DeviceCodes {
    /// Poll Github to see if authentication codes are available.
    ///
    /// See `https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#response-parameters` for details.
    pub async fn poll_once(
        &self,
        crab: &crate::Octocrab,
        client_id: &SecretString,
    ) -> Result<Either<OAuth, Continue>> {
        let poll: TokenResponse = crab
            .post(
                "login/oauth/access_token",
                Some(&PollForDevice {
                    client_id: client_id.expose_secret(),
                    device_code: &self.device_code,
                    grant_type: "urn:ietf:params:oauth:grant-type:device_code",
                }),
            )
            .await?;
        Ok(match poll {
            TokenResponse::Ok(k) => Either::Left(k),
            TokenResponse::Contine { error } => Either::Right(error),
        })
    }
}

/// See https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#input-parameters
#[derive(Serialize)]
struct DeviceFlow<'a> {
    client_id: &'a str,
    scope: &'a str,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum TokenResponse {
    // We got the auth information.
    Ok(OAuth),
    // We got an error that allows us to continue polling.
    Contine { error: Continue },
}

/// Control flow when polling the device flow authorization.
#[derive(Deserialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum Continue {
    /// When you receive the slow_down error, 5 extra seconds are added to the minimum
    /// interval or timeframe required between your requests using POST
    /// https://github.com/login/oauth/access_token. For example, if the starting interval
    /// required at least 5 seconds between requests and you get a slow_down error response,
    /// you must now wait a minimum of 10 seconds before making a new request for an OAuth
    /// access token. The error response includes the new interval that you must use.
    SlowDown,
    /// This error occurs when the authorization request is pending and the user hasn't
    /// entered the user code yet. The app is expected to keep polling the POST
    /// https://github.com/login/oauth/access_token request without exceeding the
    /// interval, which requires a minimum number of seconds between each request.
    AuthorizationPending,
}

#[derive(Serialize)]
struct PollForDevice<'a> {
    /// Required. The client ID you received from GitHub for your OAuth App.
    client_id: &'a str,
    /// Required. The device verification code you received from the POST https://github.com/login/device/code request.
    device_code: &'a str,
    /// Required. The grant type must be urn:ietf:params:oauth:grant-type:device_code.
    grant_type: &'static str,
}