Skip to main content

grit_lib/
github_auth.rs

1//! GitHub sign-in with the OAuth 2.0 **device authorization grant** (RFC 8628),
2//! plus the credential shape that makes the resulting token usable for Git over
3//! HTTPS.
4//!
5//! The device flow needs no client secret, so it suits command-line and
6//! headless programs:
7//!
8//! 1. [`DeviceFlow::request_device_code`] asks GitHub for a device code and a
9//!    short user code the person enters at the verification page.
10//! 2. [`DeviceFlow::wait_for_token`] polls the token endpoint, honoring the
11//!    server's polling interval and `slow_down` back-off, until the person
12//!    authorizes, denies, or the code expires. [`DeviceFlow::poll`] performs a
13//!    single poll for callers that drive their own loop.
14//! 3. [`github_token_credential`] builds the [`Credential`] a
15//!    [`CredentialProvider`](crate::credentials::CredentialProvider) should
16//!    `approve` (store) or `reject` (erase), so later HTTPS fetches and pushes
17//!    to `github.com` send the token as the password.
18//!
19//! Every request goes through the caller's [`HttpClient`], and waiting goes
20//! through a [`PollClock`], so the whole flow runs against fakes in tests.
21//! Nothing here prompts or prints: showing the user code is the caller's job.
22
23use std::time::{Duration, Instant};
24
25use thiserror::Error;
26
27use crate::credentials::Credential;
28use crate::error::Error;
29use crate::transport::http::HttpClient;
30
31/// Host name of GitHub's web and Git endpoints.
32pub const GITHUB_HOST: &str = "github.com";
33
34/// GitHub endpoint that issues device and user codes.
35pub const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
36
37/// GitHub endpoint polled for the access token.
38pub const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
39
40/// Page where the user enters the code; used when GitHub omits
41/// `verification_uri` from its response.
42pub const VERIFICATION_URL: &str = "https://github.com/login/device";
43
44/// OAuth scope requested by default. `repo` covers fetching and pushing
45/// private repositories over HTTPS.
46pub const DEFAULT_SCOPE: &str = "repo";
47
48/// Placeholder username GitHub documents for token authentication over HTTPS;
49/// GitHub authenticates by the token (the password) regardless of the name.
50pub const TOKEN_USERNAME: &str = "x-access-token";
51
52const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
53const DEVICE_CODE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
54const DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
55const DEFAULT_EXPIRES_IN: Duration = Duration::from_secs(900);
56/// Extra wait RFC 8628 asks for when the server answers `slow_down` without
57/// naming a new interval.
58const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
59
60/// The device and user codes GitHub issued in step 1 of the flow.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct DeviceAuthorization {
63    /// Opaque code sent back while polling; never shown to the user.
64    pub device_code: String,
65    /// Short code the user types on the verification page (e.g. `WDJB-MJHT`).
66    pub user_code: String,
67    /// Page where the user enters [`user_code`](Self::user_code).
68    pub verification_uri: String,
69    /// Minimum wait between polls (GitHub's `interval`, default 5 seconds).
70    pub interval: Duration,
71    /// How long the codes stay valid (GitHub's `expires_in`, default 15 minutes).
72    pub expires_in: Duration,
73}
74
75/// Result of one poll of the token endpoint that did not end the flow.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum PollStatus {
78    /// The user authorized the app; carries the access token.
79    Authorized(String),
80    /// The user has not finished authorizing yet; poll again after the same
81    /// interval.
82    Pending,
83    /// The server asked the client to poll less often; poll again after
84    /// `interval`.
85    SlowDown {
86        /// The new minimum wait between polls.
87        interval: Duration,
88    },
89}
90
91/// Which request of the flow failed at the transport level.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum DeviceFlowStep {
94    /// The device-code request ([`DEVICE_CODE_URL`]).
95    RequestDeviceCode,
96    /// A poll of the token endpoint ([`ACCESS_TOKEN_URL`]).
97    PollToken,
98}
99
100/// Ways the device flow can fail.
101#[non_exhaustive]
102#[derive(Debug, Error)]
103pub enum DeviceFlowError {
104    /// The HTTP request itself failed (network, TLS, non-success status).
105    #[error("device flow HTTP request failed during {step:?}")]
106    Http {
107        /// Which request failed.
108        step: DeviceFlowStep,
109        /// The transport error from the [`HttpClient`].
110        #[source]
111        source: Error,
112    },
113    /// GitHub answered the device-code request with an OAuth `error` code
114    /// (for example an unknown client id).
115    #[error("device-code request rejected: {0}")]
116    DeviceCodeRejected(String),
117    /// A required field was absent from GitHub's device-code response.
118    #[error("device-code response is missing `{0}`")]
119    MissingField(&'static str),
120    /// The device code expired before the user authorized (`expired_token`).
121    #[error("device code expired before authorization")]
122    Expired,
123    /// The user declined to authorize the app (`access_denied`).
124    #[error("authorization was denied")]
125    AccessDenied,
126    /// [`DeviceAuthorization::expires_in`] elapsed while waiting.
127    #[error("timed out waiting for authorization")]
128    TimedOut,
129    /// The token endpoint answered with an OAuth `error` code the flow does not
130    /// handle (for example `incorrect_device_code`).
131    #[error("token request failed: {0}")]
132    TokenRejected(String),
133    /// The token endpoint answered with neither a token nor an `error` code.
134    #[error("unexpected response from the token endpoint")]
135    UnexpectedResponse,
136}
137
138/// Time source and sleeper for [`DeviceFlow::wait_for_token`].
139///
140/// Injected so tests can run the poll loop without real waiting; production
141/// callers use [`SystemPollClock`].
142pub trait PollClock {
143    /// The current instant.
144    fn now(&self) -> Instant;
145    /// Block for `duration` before the next poll.
146    fn sleep(&mut self, duration: Duration);
147}
148
149/// [`PollClock`] backed by [`Instant::now`] and [`std::thread::sleep`].
150#[derive(Debug, Default, Clone, Copy)]
151pub struct SystemPollClock;
152
153impl PollClock for SystemPollClock {
154    fn now(&self) -> Instant {
155        Instant::now()
156    }
157
158    fn sleep(&mut self, duration: Duration) {
159        std::thread::sleep(duration);
160    }
161}
162
163/// A GitHub OAuth device-flow client for one OAuth App.
164pub struct DeviceFlow<'a> {
165    http: &'a dyn HttpClient,
166    client_id: &'a str,
167    scope: &'a str,
168}
169
170impl<'a> DeviceFlow<'a> {
171    /// Create a flow for the OAuth App `client_id`, sending requests through
172    /// `http` and requesting [`DEFAULT_SCOPE`].
173    #[must_use]
174    pub fn new(http: &'a dyn HttpClient, client_id: &'a str) -> Self {
175        Self {
176            http,
177            client_id,
178            scope: DEFAULT_SCOPE,
179        }
180    }
181
182    /// Request `scope` (space-separated OAuth scopes) instead of
183    /// [`DEFAULT_SCOPE`].
184    #[must_use]
185    pub fn with_scope(mut self, scope: &'a str) -> Self {
186        self.scope = scope;
187        self
188    }
189
190    /// Step 1: ask GitHub for a device code and the user code to display.
191    ///
192    /// Missing `interval` / `expires_in` fall back to 5 seconds / 15 minutes,
193    /// and a missing `verification_uri` to [`VERIFICATION_URL`].
194    ///
195    /// # Errors
196    ///
197    /// [`DeviceFlowError::Http`] when the request fails,
198    /// [`DeviceFlowError::DeviceCodeRejected`] when GitHub returns an OAuth
199    /// error, and [`DeviceFlowError::MissingField`] when `device_code` or
200    /// `user_code` is absent.
201    pub fn request_device_code(&self) -> Result<DeviceAuthorization, DeviceFlowError> {
202        let body = form_encode(&[("client_id", self.client_id), ("scope", self.scope)]);
203        let fields = self.post_form(DEVICE_CODE_URL, &body, DeviceFlowStep::RequestDeviceCode)?;
204        if let Some(code) = form_field(&fields, "error") {
205            return Err(DeviceFlowError::DeviceCodeRejected(code.to_owned()));
206        }
207        let required = |key: &'static str| {
208            form_field(&fields, key)
209                .map(str::to_owned)
210                .ok_or(DeviceFlowError::MissingField(key))
211        };
212        Ok(DeviceAuthorization {
213            device_code: required("device_code")?,
214            user_code: required("user_code")?,
215            verification_uri: form_field(&fields, "verification_uri")
216                .unwrap_or(VERIFICATION_URL)
217                .to_owned(),
218            interval: seconds_field(&fields, "interval").unwrap_or(DEFAULT_INTERVAL),
219            expires_in: seconds_field(&fields, "expires_in").unwrap_or(DEFAULT_EXPIRES_IN),
220        })
221    }
222
223    /// Poll the token endpoint once for `device`.
224    ///
225    /// `current_interval` is the wait in effect; it is the base for the
226    /// RFC 8628 back-off when GitHub answers `slow_down` without a new
227    /// `interval`.
228    ///
229    /// # Errors
230    ///
231    /// [`DeviceFlowError::Http`] when the request fails,
232    /// [`DeviceFlowError::Expired`] / [`DeviceFlowError::AccessDenied`] for the
233    /// terminal OAuth errors, [`DeviceFlowError::TokenRejected`] for other
234    /// OAuth errors, and [`DeviceFlowError::UnexpectedResponse`] when the
235    /// response carries neither a token nor an error.
236    pub fn poll(
237        &self,
238        device: &DeviceAuthorization,
239        current_interval: Duration,
240    ) -> Result<PollStatus, DeviceFlowError> {
241        let body = form_encode(&[
242            ("client_id", self.client_id),
243            ("device_code", &device.device_code),
244            ("grant_type", DEVICE_CODE_GRANT_TYPE),
245        ]);
246        let fields = self.post_form(ACCESS_TOKEN_URL, &body, DeviceFlowStep::PollToken)?;
247        if let Some(token) = form_field(&fields, "access_token").filter(|t| !t.is_empty()) {
248            return Ok(PollStatus::Authorized(token.to_owned()));
249        }
250        match form_field(&fields, "error") {
251            Some("authorization_pending") => Ok(PollStatus::Pending),
252            Some("slow_down") => Ok(PollStatus::SlowDown {
253                interval: seconds_field(&fields, "interval")
254                    .unwrap_or(current_interval + SLOW_DOWN_STEP),
255            }),
256            Some("expired_token") => Err(DeviceFlowError::Expired),
257            Some("access_denied") => Err(DeviceFlowError::AccessDenied),
258            Some(other) => Err(DeviceFlowError::TokenRejected(other.to_owned())),
259            None => Err(DeviceFlowError::UnexpectedResponse),
260        }
261    }
262
263    /// Step 2: poll until the user authorizes and return the access token.
264    ///
265    /// Waits at least `device.interval` (never less than one second) before
266    /// every poll, adopts the longer interval on `slow_down`, and gives up once
267    /// `device.expires_in` has elapsed since the call started.
268    ///
269    /// # Errors
270    ///
271    /// [`DeviceFlowError::TimedOut`] when the codes expire locally, plus every
272    /// error [`poll`](Self::poll) returns.
273    pub fn wait_for_token(
274        &self,
275        device: &DeviceAuthorization,
276        clock: &mut dyn PollClock,
277    ) -> Result<String, DeviceFlowError> {
278        let deadline = clock.now() + device.expires_in;
279        let mut interval = device.interval.max(Duration::from_secs(1));
280        loop {
281            clock.sleep(interval);
282            if clock.now() >= deadline {
283                return Err(DeviceFlowError::TimedOut);
284            }
285            match self.poll(device, interval)? {
286                PollStatus::Authorized(token) => return Ok(token),
287                PollStatus::Pending => {}
288                PollStatus::SlowDown { interval: next } => interval = next,
289            }
290        }
291    }
292
293    fn post_form(
294        &self,
295        url: &str,
296        body: &str,
297        step: DeviceFlowStep,
298    ) -> Result<Vec<(String, String)>, DeviceFlowError> {
299        let response = self
300            .http
301            .post(
302                url,
303                FORM_CONTENT_TYPE,
304                FORM_CONTENT_TYPE,
305                body.as_bytes(),
306                None,
307            )
308            .map_err(|source| DeviceFlowError::Http { step, source })?;
309        Ok(parse_form(&String::from_utf8_lossy(&response)))
310    }
311}
312
313/// The credential under which a GitHub token is stored for HTTPS Git access.
314///
315/// Pass `Some(token)` to build the credential to
316/// [`approve`](crate::credentials::CredentialProvider::approve) (store), or
317/// `None` for the credential to
318/// [`reject`](crate::credentials::CredentialProvider::reject) (erase) — the
319/// same protocol, host and [`TOKEN_USERNAME`], so erasing removes exactly what
320/// storing wrote.
321#[must_use]
322pub fn github_token_credential(token: Option<&str>) -> Credential {
323    Credential {
324        protocol: Some("https".to_owned()),
325        host: Some(GITHUB_HOST.to_owned()),
326        username: Some(TOKEN_USERNAME.to_owned()),
327        password: token.map(str::to_owned),
328        ..Default::default()
329    }
330}
331
332/// Whether `url` is an `https://` URL whose host is `github.com` (or
333/// `www.github.com`), ignoring case, userinfo and port.
334///
335/// These are the only remotes where a token stored by
336/// [`github_token_credential`] is sent as the HTTP password.
337#[must_use]
338pub fn is_github_https_url(url: &str) -> bool {
339    let Some(rest) = url.strip_prefix("https://") else {
340        return false;
341    };
342    let authority = rest.split('/').next().unwrap_or_default();
343    let host_port = authority
344        .rsplit_once('@')
345        .map_or(authority, |(_, host)| host);
346    let host = host_port.split(':').next().unwrap_or_default();
347    host.eq_ignore_ascii_case(GITHUB_HOST) || host.eq_ignore_ascii_case("www.github.com")
348}
349
350fn form_field<'f>(fields: &'f [(String, String)], key: &str) -> Option<&'f str> {
351    fields
352        .iter()
353        .find(|(k, _)| k == key)
354        .map(|(_, v)| v.as_str())
355}
356
357fn seconds_field(fields: &[(String, String)], key: &str) -> Option<Duration> {
358    form_field(fields, key)
359        .and_then(|v| v.parse().ok())
360        .map(Duration::from_secs)
361}
362
363/// Encode key/value pairs as an `application/x-www-form-urlencoded` body.
364fn form_encode(pairs: &[(&str, &str)]) -> String {
365    pairs
366        .iter()
367        .map(|(k, v)| format!("{}={}", form_component_encode(k), form_component_encode(v)))
368        .collect::<Vec<_>>()
369        .join("&")
370}
371
372/// Parse an `application/x-www-form-urlencoded` body into ordered pairs.
373fn parse_form(body: &str) -> Vec<(String, String)> {
374    body.split('&')
375        .filter(|s| !s.is_empty())
376        .map(|pair| match pair.split_once('=') {
377            Some((k, v)) => (form_component_decode(k), form_component_decode(v)),
378            None => (form_component_decode(pair), String::new()),
379        })
380        .collect()
381}
382
383/// Percent-encode everything outside the RFC 3986 unreserved set; spaces
384/// become `+`.
385fn form_component_encode(input: &str) -> String {
386    let mut out = String::with_capacity(input.len());
387    for &byte in input.as_bytes() {
388        match byte {
389            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
390                out.push(char::from(byte));
391            }
392            b' ' => out.push('+'),
393            _ => out.push_str(&format!("%{byte:02X}")),
394        }
395    }
396    out
397}
398
399/// Decode `+` as space and `%XX` as a byte; malformed escapes stay literal.
400fn form_component_decode(input: &str) -> String {
401    let bytes = input.as_bytes();
402    let mut out = Vec::with_capacity(bytes.len());
403    let mut idx = 0;
404    while idx < bytes.len() {
405        match bytes[idx] {
406            b'+' => {
407                out.push(b' ');
408                idx += 1;
409            }
410            b'%' => {
411                let escaped = bytes
412                    .get(idx + 1..idx + 3)
413                    .filter(|hex| hex.iter().all(u8::is_ascii_hexdigit))
414                    .and_then(|hex| std::str::from_utf8(hex).ok())
415                    .and_then(|hex| u8::from_str_radix(hex, 16).ok());
416                match escaped {
417                    Some(byte) => {
418                        out.push(byte);
419                        idx += 3;
420                    }
421                    None => {
422                        out.push(b'%');
423                        idx += 1;
424                    }
425                }
426            }
427            other => {
428                out.push(other);
429                idx += 1;
430            }
431        }
432    }
433    String::from_utf8_lossy(&out).into_owned()
434}
435
436#[cfg(test)]
437#[allow(clippy::unwrap_used, clippy::expect_used)]
438mod tests {
439    use std::collections::VecDeque;
440    use std::sync::Mutex;
441
442    use super::*;
443
444    /// One recorded `POST`: URL and decoded form body.
445    type Request = (String, Vec<(String, String)>);
446
447    /// [`HttpClient`] that answers `POST`s from a script and records them.
448    struct ScriptedHttp {
449        responses: Mutex<VecDeque<crate::error::Result<String>>>,
450        requests: Mutex<Vec<Request>>,
451    }
452
453    impl ScriptedHttp {
454        fn new(responses: Vec<crate::error::Result<&str>>) -> Self {
455            Self {
456                responses: Mutex::new(
457                    responses
458                        .into_iter()
459                        .map(|r| r.map(str::to_owned))
460                        .collect(),
461                ),
462                requests: Mutex::new(Vec::new()),
463            }
464        }
465
466        fn requests(&self) -> Vec<Request> {
467            self.requests.lock().unwrap().clone()
468        }
469    }
470
471    impl HttpClient for ScriptedHttp {
472        fn get(&self, url: &str, _git_protocol: Option<&str>) -> crate::error::Result<Vec<u8>> {
473            panic!("unexpected GET {url}")
474        }
475
476        fn post(
477            &self,
478            url: &str,
479            content_type: &str,
480            accept: &str,
481            body: &[u8],
482            _git_protocol: Option<&str>,
483        ) -> crate::error::Result<Vec<u8>> {
484            assert_eq!(content_type, FORM_CONTENT_TYPE);
485            assert_eq!(accept, FORM_CONTENT_TYPE);
486            self.requests.lock().unwrap().push((
487                url.to_owned(),
488                parse_form(std::str::from_utf8(body).unwrap()),
489            ));
490            self.responses
491                .lock()
492                .unwrap()
493                .pop_front()
494                .expect("scripted response")
495                .map(String::into_bytes)
496        }
497    }
498
499    /// [`PollClock`] whose time only advances when the flow sleeps.
500    struct FakeClock {
501        start: Instant,
502        elapsed: Duration,
503        sleeps: Vec<Duration>,
504    }
505
506    impl FakeClock {
507        fn new() -> Self {
508            Self {
509                start: Instant::now(),
510                elapsed: Duration::ZERO,
511                sleeps: Vec::new(),
512            }
513        }
514    }
515
516    impl PollClock for FakeClock {
517        fn now(&self) -> Instant {
518            self.start + self.elapsed
519        }
520
521        fn sleep(&mut self, duration: Duration) {
522            self.elapsed += duration;
523            self.sleeps.push(duration);
524        }
525    }
526
527    fn device(interval: u64, expires_in: u64) -> DeviceAuthorization {
528        DeviceAuthorization {
529            device_code: "dev-123".to_owned(),
530            user_code: "WDJB-MJHT".to_owned(),
531            verification_uri: VERIFICATION_URL.to_owned(),
532            interval: Duration::from_secs(interval),
533            expires_in: Duration::from_secs(expires_in),
534        }
535    }
536
537    fn secs(s: u64) -> Duration {
538        Duration::from_secs(s)
539    }
540
541    #[test]
542    fn form_round_trips_reserved_characters() {
543        let encoded = form_encode(&[
544            ("grant_type", DEVICE_CODE_GRANT_TYPE),
545            ("scope", "repo read:org"),
546            ("odd", "a&b=c%d~"),
547        ]);
548        assert_eq!(
549            encoded,
550            "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code\
551             &scope=repo+read%3Aorg&odd=a%26b%3Dc%25d~"
552        );
553        let parsed = parse_form(&encoded);
554        assert_eq!(
555            form_field(&parsed, "grant_type"),
556            Some(DEVICE_CODE_GRANT_TYPE)
557        );
558        assert_eq!(form_field(&parsed, "scope"), Some("repo read:org"));
559        assert_eq!(form_field(&parsed, "odd"), Some("a&b=c%d~"));
560    }
561
562    #[test]
563    fn form_decode_keeps_malformed_escapes_literal() {
564        assert_eq!(form_component_decode("100%"), "100%");
565        assert_eq!(form_component_decode("%zz%4"), "%zz%4");
566        assert_eq!(form_component_decode("%+4"), "% 4");
567        assert_eq!(form_component_decode("%41%62"), "Ab");
568        let parsed = parse_form("flag&&k=v");
569        assert_eq!(form_field(&parsed, "flag"), Some(""));
570        assert_eq!(form_field(&parsed, "k"), Some("v"));
571    }
572
573    #[test]
574    fn request_device_code_parses_github_response() {
575        let http = ScriptedHttp::new(vec![Ok("device_code=3584d83&user_code=WDJB-MJHT\
576             &verification_uri=https%3A%2F%2Fgithub.com%2Flogin%2Fdevice\
577             &expires_in=600&interval=7")]);
578        let flow = DeviceFlow::new(&http, "client-abc");
579        let device = flow.request_device_code().unwrap();
580        assert_eq!(
581            device,
582            DeviceAuthorization {
583                device_code: "3584d83".to_owned(),
584                user_code: "WDJB-MJHT".to_owned(),
585                verification_uri: "https://github.com/login/device".to_owned(),
586                interval: secs(7),
587                expires_in: secs(600),
588            }
589        );
590        let requests = http.requests();
591        assert_eq!(requests.len(), 1);
592        assert_eq!(requests[0].0, DEVICE_CODE_URL);
593        assert_eq!(form_field(&requests[0].1, "client_id"), Some("client-abc"));
594        assert_eq!(form_field(&requests[0].1, "scope"), Some(DEFAULT_SCOPE));
595    }
596
597    #[test]
598    fn request_device_code_defaults_optional_fields() {
599        let http = ScriptedHttp::new(vec![Ok("device_code=d&user_code=u&interval=soon")]);
600        let device = DeviceFlow::new(&http, "id")
601            .with_scope("read:user")
602            .request_device_code()
603            .unwrap();
604        assert_eq!(device.verification_uri, VERIFICATION_URL);
605        assert_eq!(device.interval, DEFAULT_INTERVAL);
606        assert_eq!(device.expires_in, DEFAULT_EXPIRES_IN);
607        assert_eq!(
608            form_field(&http.requests()[0].1, "scope"),
609            Some("read:user")
610        );
611    }
612
613    #[test]
614    fn request_device_code_reports_rejection_and_missing_fields() {
615        let http = ScriptedHttp::new(vec![
616            Ok("error=unauthorized_client&error_description=nope"),
617            Ok("user_code=u"),
618            Ok("device_code=d"),
619        ]);
620        let flow = DeviceFlow::new(&http, "id");
621        assert!(matches!(
622            flow.request_device_code(),
623            Err(DeviceFlowError::DeviceCodeRejected(code)) if code == "unauthorized_client"
624        ));
625        assert!(matches!(
626            flow.request_device_code(),
627            Err(DeviceFlowError::MissingField("device_code"))
628        ));
629        assert!(matches!(
630            flow.request_device_code(),
631            Err(DeviceFlowError::MissingField("user_code"))
632        ));
633    }
634
635    #[test]
636    fn transport_errors_name_the_failing_step() {
637        let http = ScriptedHttp::new(vec![
638            Err(Error::Auth {
639                url: "https://github.com".to_owned(),
640                reason: crate::error::AuthFailure::Rejected,
641            }),
642            Err(Error::Message("offline".to_owned())),
643        ]);
644        let flow = DeviceFlow::new(&http, "id");
645        assert!(matches!(
646            flow.request_device_code(),
647            Err(DeviceFlowError::Http {
648                step: DeviceFlowStep::RequestDeviceCode,
649                source: Error::Auth { .. },
650            })
651        ));
652        assert!(matches!(
653            flow.poll(&device(5, 900), secs(5)),
654            Err(DeviceFlowError::Http {
655                step: DeviceFlowStep::PollToken,
656                source: Error::Message(_),
657            })
658        ));
659    }
660
661    #[test]
662    fn poll_sends_device_grant_and_classifies_responses() {
663        let http = ScriptedHttp::new(vec![
664            Ok("error=authorization_pending"),
665            Ok("error=slow_down&interval=12"),
666            Ok("error=slow_down"),
667            Ok("access_token=&error=authorization_pending"),
668            Ok("access_token=gho_abc&token_type=bearer&scope=repo"),
669        ]);
670        let flow = DeviceFlow::new(&http, "id");
671        let dev = device(5, 900);
672        assert_eq!(flow.poll(&dev, secs(5)).unwrap(), PollStatus::Pending);
673        assert_eq!(
674            flow.poll(&dev, secs(5)).unwrap(),
675            PollStatus::SlowDown { interval: secs(12) }
676        );
677        assert_eq!(
678            flow.poll(&dev, secs(8)).unwrap(),
679            PollStatus::SlowDown { interval: secs(13) }
680        );
681        assert_eq!(flow.poll(&dev, secs(5)).unwrap(), PollStatus::Pending);
682        assert_eq!(
683            flow.poll(&dev, secs(5)).unwrap(),
684            PollStatus::Authorized("gho_abc".to_owned())
685        );
686        let request = &http.requests()[0];
687        assert_eq!(request.0, ACCESS_TOKEN_URL);
688        assert_eq!(form_field(&request.1, "client_id"), Some("id"));
689        assert_eq!(form_field(&request.1, "device_code"), Some("dev-123"));
690        assert_eq!(
691            form_field(&request.1, "grant_type"),
692            Some(DEVICE_CODE_GRANT_TYPE)
693        );
694    }
695
696    #[test]
697    fn poll_maps_terminal_oauth_errors() {
698        let http = ScriptedHttp::new(vec![
699            Ok("error=expired_token"),
700            Ok("error=access_denied"),
701            Ok("error=incorrect_device_code"),
702            Ok("token_type=bearer"),
703        ]);
704        let flow = DeviceFlow::new(&http, "id");
705        let dev = device(5, 900);
706        assert!(matches!(
707            flow.poll(&dev, secs(5)),
708            Err(DeviceFlowError::Expired)
709        ));
710        assert!(matches!(
711            flow.poll(&dev, secs(5)),
712            Err(DeviceFlowError::AccessDenied)
713        ));
714        assert!(matches!(
715            flow.poll(&dev, secs(5)),
716            Err(DeviceFlowError::TokenRejected(code)) if code == "incorrect_device_code"
717        ));
718        assert!(matches!(
719            flow.poll(&dev, secs(5)),
720            Err(DeviceFlowError::UnexpectedResponse)
721        ));
722    }
723
724    #[test]
725    fn wait_for_token_honors_interval_and_slow_down() {
726        let http = ScriptedHttp::new(vec![
727            Ok("error=authorization_pending"),
728            Ok("error=slow_down"),
729            Ok("error=slow_down&interval=20"),
730            Ok("access_token=gho_tok"),
731        ]);
732        let mut clock = FakeClock::new();
733        let token = DeviceFlow::new(&http, "id")
734            .wait_for_token(&device(5, 900), &mut clock)
735            .unwrap();
736        assert_eq!(token, "gho_tok");
737        assert_eq!(clock.sleeps, vec![secs(5), secs(5), secs(10), secs(20)]);
738        assert_eq!(http.requests().len(), 4);
739    }
740
741    #[test]
742    fn wait_for_token_waits_at_least_one_second() {
743        let http = ScriptedHttp::new(vec![Ok("access_token=t")]);
744        let mut clock = FakeClock::new();
745        DeviceFlow::new(&http, "id")
746            .wait_for_token(&device(0, 900), &mut clock)
747            .unwrap();
748        assert_eq!(clock.sleeps, vec![secs(1)]);
749    }
750
751    #[test]
752    fn wait_for_token_times_out_without_polling_past_expiry() {
753        let http = ScriptedHttp::new(vec![
754            Ok("error=authorization_pending"),
755            Ok("error=authorization_pending"),
756        ]);
757        let mut clock = FakeClock::new();
758        let result = DeviceFlow::new(&http, "id").wait_for_token(&device(5, 12), &mut clock);
759        assert!(matches!(result, Err(DeviceFlowError::TimedOut)));
760        assert_eq!(http.requests().len(), 2);
761        assert_eq!(clock.sleeps, vec![secs(5), secs(5), secs(5)]);
762    }
763
764    #[test]
765    fn wait_for_token_stops_on_denial() {
766        let http = ScriptedHttp::new(vec![
767            Ok("error=authorization_pending"),
768            Ok("error=access_denied"),
769        ]);
770        let mut clock = FakeClock::new();
771        let result = DeviceFlow::new(&http, "id").wait_for_token(&device(5, 900), &mut clock);
772        assert!(matches!(result, Err(DeviceFlowError::AccessDenied)));
773    }
774
775    #[test]
776    fn token_credential_targets_github_https() {
777        let store = github_token_credential(Some("gho_x"));
778        assert_eq!(store.protocol.as_deref(), Some("https"));
779        assert_eq!(store.host.as_deref(), Some(GITHUB_HOST));
780        assert_eq!(store.username.as_deref(), Some(TOKEN_USERNAME));
781        assert_eq!(store.password.as_deref(), Some("gho_x"));
782        assert!(store.is_complete());
783        assert_eq!(
784            store.target_url().as_deref(),
785            Some("https://x-access-token@github.com")
786        );
787
788        let erase = github_token_credential(None);
789        assert_eq!(erase.password, None);
790        assert_eq!(erase.target_url(), store.target_url());
791    }
792
793    #[test]
794    fn recognizes_https_github_urls() {
795        assert!(is_github_https_url("https://github.com/owner/repo.git"));
796        assert!(is_github_https_url(
797            "https://x-access-token@github.com/owner/repo.git"
798        ));
799        assert!(is_github_https_url("https://user:pw@GitHub.com:443/o/r"));
800        assert!(is_github_https_url("https://www.github.com/owner/repo"));
801        assert!(is_github_https_url("https://github.com"));
802        assert!(!is_github_https_url("https://gitlab.com/owner/repo.git"));
803        assert!(!is_github_https_url("http://github.com/owner/repo.git"));
804        assert!(!is_github_https_url("git@github.com:owner/repo.git"));
805        assert!(!is_github_https_url("https://evil.com/github.com"));
806        assert!(!is_github_https_url("https://github.com.evil.com/o/r"));
807        assert!(!is_github_https_url("https://evil.com/x@github.com/o/r"));
808    }
809}