1use std::time::{Duration, Instant};
24
25use thiserror::Error;
26
27use crate::credentials::Credential;
28use crate::error::Error;
29use crate::transport::http::HttpClient;
30
31pub const GITHUB_HOST: &str = "github.com";
33
34pub const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
36
37pub const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
39
40pub const VERIFICATION_URL: &str = "https://github.com/login/device";
43
44pub const DEFAULT_SCOPE: &str = "repo";
47
48pub 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);
56const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct DeviceAuthorization {
63 pub device_code: String,
65 pub user_code: String,
67 pub verification_uri: String,
69 pub interval: Duration,
71 pub expires_in: Duration,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum PollStatus {
78 Authorized(String),
80 Pending,
83 SlowDown {
86 interval: Duration,
88 },
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum DeviceFlowStep {
94 RequestDeviceCode,
96 PollToken,
98}
99
100#[non_exhaustive]
102#[derive(Debug, Error)]
103pub enum DeviceFlowError {
104 #[error("device flow HTTP request failed during {step:?}")]
106 Http {
107 step: DeviceFlowStep,
109 #[source]
111 source: Error,
112 },
113 #[error("device-code request rejected: {0}")]
116 DeviceCodeRejected(String),
117 #[error("device-code response is missing `{0}`")]
119 MissingField(&'static str),
120 #[error("device code expired before authorization")]
122 Expired,
123 #[error("authorization was denied")]
125 AccessDenied,
126 #[error("timed out waiting for authorization")]
128 TimedOut,
129 #[error("token request failed: {0}")]
132 TokenRejected(String),
133 #[error("unexpected response from the token endpoint")]
135 UnexpectedResponse,
136}
137
138pub trait PollClock {
143 fn now(&self) -> Instant;
145 fn sleep(&mut self, duration: Duration);
147}
148
149#[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
163pub struct DeviceFlow<'a> {
165 http: &'a dyn HttpClient,
166 client_id: &'a str,
167 scope: &'a str,
168}
169
170impl<'a> DeviceFlow<'a> {
171 #[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 #[must_use]
185 pub fn with_scope(mut self, scope: &'a str) -> Self {
186 self.scope = scope;
187 self
188 }
189
190 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 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 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#[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#[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
363fn 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
372fn 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
383fn 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
399fn 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 type Request = (String, Vec<(String, String)>);
446
447 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 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}