1use std::{fmt, time::Duration};
71
72use secrecy::{ExposeSecret, SecretString};
73use serde::Deserialize;
74use url::Url;
75
76use crate::{
77 AppRegistration, ConfigError, DEFAULT_REQUEST_TIMEOUT, Endpoints, Sleeper, USER_AGENT,
78 UserAccessToken,
79};
80
81pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
84
85pub const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5);
92
93pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(5);
95
96pub const MAX_TRANSPORT_RETRIES: u32 = 5;
108
109#[derive(Debug, thiserror::Error)]
121pub enum DeviceFlowError {
122 #[error("the login was declined on GitHub")]
125 AccessDenied,
126
127 #[error("the device code expired before the login was approved; start `auth login` again")]
130 Expired,
131
132 #[error("GitHub did not recognise the device code; start `auth login` again")]
134 IncorrectDeviceCode,
135
136 #[error("the published GitHub App is misconfigured for the device flow: GitHub said {code:?}")]
140 AppMisconfigured { code: String },
141
142 #[error("GitHub returned an unrecognised device-flow error: {code:?}")]
143 Unexpected { code: String },
144
145 #[error(
148 "GitHub returned a verification URL on {origin:?}, which is not the canonical device \
149 page; refusing to display it"
150 )]
151 UntrustedVerificationUri { origin: String },
152
153 #[error("GitHub was unreachable")]
154 Transport(#[source] reqwest::Error),
155
156 #[error("GitHub returned {status} for the device-flow {stage}")]
157 Status { status: u16, stage: &'static str },
158
159 #[error("a device-flow {stage} response could not be decoded")]
160 Decode {
161 stage: &'static str,
162 #[source]
163 source: serde_json::Error,
164 },
165
166 #[error("GitHub returned {value:?} for {what}, which this client cannot use")]
167 Malformed { what: &'static str, value: String },
168
169 #[error(transparent)]
170 Config(#[from] ConfigError),
171}
172
173impl DeviceFlowError {
174 #[must_use]
191 pub fn is_retryable(&self) -> bool {
192 match self {
193 Self::Transport(_) => true,
194 Self::Status { status, .. } => (500..600).contains(status),
197 Self::AccessDenied
198 | Self::Expired
199 | Self::IncorrectDeviceCode
200 | Self::AppMisconfigured { .. }
201 | Self::Unexpected { .. }
202 | Self::UntrustedVerificationUri { .. }
203 | Self::Decode { .. }
204 | Self::Malformed { .. }
205 | Self::Config(_) => false,
206 }
207 }
208
209 #[must_use]
211 pub fn requires_new_login(&self) -> bool {
212 matches!(self, Self::Expired | Self::IncorrectDeviceCode)
213 }
214}
215
216fn transport(err: reqwest::Error) -> DeviceFlowError {
217 DeviceFlowError::Transport(err.without_url())
218}
219
220#[derive(Clone)]
234pub struct DeviceAuthorization {
235 device_code: SecretString,
236 user_code: String,
237 verification_uri: Url,
238 expires_in: Duration,
239 interval: Duration,
240}
241
242impl DeviceAuthorization {
243 #[must_use]
245 pub fn user_code(&self) -> &str {
246 &self.user_code
247 }
248
249 #[must_use]
252 pub fn device_code(&self) -> &SecretString {
253 &self.device_code
254 }
255
256 #[must_use]
259 pub fn verification_uri(&self) -> &Url {
260 &self.verification_uri
261 }
262
263 #[must_use]
265 pub fn expires_in(&self) -> Duration {
266 self.expires_in
267 }
268
269 #[must_use]
271 pub fn interval(&self) -> Duration {
272 self.interval
273 }
274}
275
276impl fmt::Debug for DeviceAuthorization {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 f.debug_struct("DeviceAuthorization")
279 .field("device_code", &"[REDACTED]")
280 .field("user_code", &self.user_code)
281 .field("verification_uri", &self.verification_uri.as_str())
282 .field("expires_in_secs", &self.expires_in.as_secs())
283 .field("interval_secs", &self.interval.as_secs())
284 .finish()
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum PollOutcome {
295 Pending,
297 SlowDown { interval: Duration },
299 Approved(UserAccessToken),
302}
303
304#[derive(Debug, Clone)]
313pub struct DeviceFlow {
314 http: reqwest::Client,
315 app: AppRegistration,
316 endpoints: Endpoints,
317}
318
319impl DeviceFlow {
320 pub fn new(app: AppRegistration, endpoints: Endpoints) -> Result<Self, DeviceFlowError> {
323 let http = reqwest::Client::builder()
324 .timeout(DEFAULT_REQUEST_TIMEOUT)
325 .build()
326 .map_err(transport)?;
327 Ok(Self::with_http_client(http, app, endpoints))
328 }
329
330 pub async fn refresh(
359 &self,
360 refresh_token: &SecretString,
361 ) -> Result<UserAccessToken, DeviceFlowError> {
362 let body = form_body(&[
365 ("client_id", self.app.client_id()),
366 ("refresh_token", refresh_token.expose_secret()),
367 ("grant_type", "refresh_token"),
368 ]);
369
370 let response = self
371 .http
372 .post(self.endpoints.access_token_url())
373 .header(reqwest::header::ACCEPT, "application/json")
374 .header(reqwest::header::USER_AGENT, USER_AGENT)
375 .header(
376 reqwest::header::CONTENT_TYPE,
377 "application/x-www-form-urlencoded",
378 )
379 .body(body)
380 .send()
381 .await
382 .map_err(transport)?;
383
384 let status = response.status();
385 let bytes = response.bytes().await.map_err(transport)?;
386 let raw: RawTokenResponse = match serde_json::from_slice(&bytes) {
387 Ok(raw) => raw,
388 Err(source) => {
389 return if status.is_success() {
390 Err(DeviceFlowError::Decode {
391 stage: "refresh",
392 source,
393 })
394 } else {
395 Err(DeviceFlowError::Status {
396 status: status.as_u16(),
397 stage: "refresh request",
398 })
399 };
400 }
401 };
402
403 if let Some(code) = raw.error {
404 return Err(DeviceFlowError::Malformed {
405 what: "a refresh response",
406 value: code,
411 });
412 }
413 let Some(access_token) = raw.access_token else {
414 return Err(DeviceFlowError::Malformed {
415 what: "a refresh response",
416 value: "neither an access token nor an error".to_string(),
417 });
418 };
419 Ok(UserAccessToken::from_parts(
420 SecretString::from(access_token),
421 raw.token_type.unwrap_or_else(|| "bearer".to_string()),
422 raw.scope.filter(|s| !s.is_empty()),
423 )
424 .with_renewal(
425 raw.refresh_token.map(SecretString::from),
426 raw.expires_in,
427 raw.refresh_token_expires_in,
428 ))
429 }
430
431 #[must_use]
432 pub fn with_http_client(
433 http: reqwest::Client,
434 app: AppRegistration,
435 endpoints: Endpoints,
436 ) -> Self {
437 Self {
438 http,
439 app,
440 endpoints,
441 }
442 }
443
444 #[must_use]
447 pub fn verification_url(&self) -> Url {
448 self.endpoints.verification_url()
449 }
450
451 pub async fn start(&self) -> Result<DeviceAuthorization, DeviceFlowError> {
462 let body = form_body(&[("client_id", self.app.client_id())]);
463 let response = self
464 .http
465 .post(self.endpoints.device_code_url())
466 .header(reqwest::header::ACCEPT, "application/json")
467 .header(reqwest::header::USER_AGENT, USER_AGENT)
468 .header(
469 reqwest::header::CONTENT_TYPE,
470 "application/x-www-form-urlencoded",
471 )
472 .body(body)
473 .send()
474 .await
475 .map_err(transport)?;
476
477 let status = response.status();
478 let bytes = response.bytes().await.map_err(transport)?;
479 if !status.is_success() {
480 return Err(DeviceFlowError::Status {
481 status: status.as_u16(),
482 stage: "device code request",
483 });
484 }
485
486 let raw: RawDeviceCode =
487 serde_json::from_slice(&bytes).map_err(|source| DeviceFlowError::Decode {
488 stage: "device code",
489 source,
490 })?;
491
492 let verification_uri =
493 Url::parse(&raw.verification_uri).map_err(|_| DeviceFlowError::Malformed {
494 what: "a verification URL",
495 value: raw.verification_uri.clone(),
496 })?;
497 if verification_uri.origin() != self.endpoints.web_base().origin() {
500 return Err(DeviceFlowError::UntrustedVerificationUri {
501 origin: verification_uri.origin().ascii_serialization(),
502 });
503 }
504
505 let authorization = DeviceAuthorization {
506 device_code: SecretString::from(raw.device_code),
507 user_code: raw.user_code,
508 verification_uri,
509 expires_in: Duration::from_secs(raw.expires_in.unwrap_or(900)),
510 interval: raw
511 .interval
512 .map_or(DEFAULT_POLL_INTERVAL, Duration::from_secs),
513 };
514
515 tracing::info!(
518 user_code = %authorization.user_code,
519 verification_url = %self.verification_url(),
520 expires_in_secs = authorization.expires_in.as_secs(),
521 "device login started; approve it on GitHub's own device page"
522 );
523
524 Ok(authorization)
525 }
526
527 pub async fn poll_once(
534 &self,
535 authorization: &DeviceAuthorization,
536 ) -> Result<PollOutcome, DeviceFlowError> {
537 self.poll_once_from(authorization, authorization.interval)
538 .await
539 }
540
541 async fn poll_once_from(
542 &self,
543 authorization: &DeviceAuthorization,
544 current_interval: Duration,
545 ) -> Result<PollOutcome, DeviceFlowError> {
546 let body = form_body(&[
550 ("client_id", self.app.client_id()),
551 ("device_code", authorization.device_code.expose_secret()),
552 ("grant_type", DEVICE_GRANT_TYPE),
553 ]);
554
555 let response = self
556 .http
557 .post(self.endpoints.access_token_url())
558 .header(reqwest::header::ACCEPT, "application/json")
559 .header(reqwest::header::USER_AGENT, USER_AGENT)
560 .header(
561 reqwest::header::CONTENT_TYPE,
562 "application/x-www-form-urlencoded",
563 )
564 .body(body)
565 .send()
566 .await
567 .map_err(transport)?;
568
569 let status = response.status();
570 let bytes = response.bytes().await.map_err(transport)?;
571 let mut raw: RawTokenResponse = match serde_json::from_slice(&bytes) {
572 Ok(raw) => raw,
573 Err(source) => {
580 if status.is_success() {
581 return Err(DeviceFlowError::Decode {
582 stage: "access token",
583 source,
584 });
585 }
586 return Err(DeviceFlowError::Status {
587 status: status.as_u16(),
588 stage: "access token request",
589 });
590 }
591 };
592
593 if let Some(code) = raw.error.take() {
597 return self.interpret_error(&code, raw.interval, current_interval);
598 }
599 if !status.is_success() {
600 return Err(DeviceFlowError::Status {
601 status: status.as_u16(),
602 stage: "access token request",
603 });
604 }
605
606 let Some(access_token) = raw.access_token.take() else {
607 return Err(DeviceFlowError::Malformed {
608 what: "an access token response",
609 value: "neither an access token nor an error".to_string(),
610 });
611 };
612
613 let token = UserAccessToken::from_parts(
614 SecretString::from(access_token),
615 raw.token_type.unwrap_or_else(|| "bearer".to_string()),
616 raw.scope.filter(|s| !s.is_empty()),
617 )
618 .with_renewal(
619 raw.refresh_token.map(SecretString::from),
620 raw.expires_in,
621 raw.refresh_token_expires_in,
622 );
623
624 tracing::info!(
628 token_family = token.family(),
629 user_to_server = token.is_user_to_server(),
630 "device login approved; the user access token was returned to the caller"
631 );
632
633 Ok(PollOutcome::Approved(token))
634 }
635
636 fn interpret_error(
637 &self,
638 code: &str,
639 advertised_interval: Option<u64>,
640 current_interval: Duration,
641 ) -> Result<PollOutcome, DeviceFlowError> {
642 match code {
643 "authorization_pending" => Ok(PollOutcome::Pending),
644 "slow_down" => {
645 let interval = slowed(
646 current_interval,
647 advertised_interval.map(Duration::from_secs),
648 );
649 tracing::debug!(
650 from_secs = current_interval.as_secs(),
651 to_secs = interval.as_secs(),
652 "GitHub asked us to slow down; lengthening the poll interval"
653 );
654 Ok(PollOutcome::SlowDown { interval })
655 }
656 "expired_token" => Err(DeviceFlowError::Expired),
657 "access_denied" => Err(DeviceFlowError::AccessDenied),
658 "incorrect_device_code" => Err(DeviceFlowError::IncorrectDeviceCode),
659 "unsupported_grant_type" | "incorrect_client_credentials" | "device_flow_disabled" => {
660 Err(DeviceFlowError::AppMisconfigured {
661 code: code.to_string(),
662 })
663 }
664 other => Err(DeviceFlowError::Unexpected {
665 code: other.to_string(),
666 }),
667 }
668 }
669
670 pub async fn complete(
701 &self,
702 authorization: &DeviceAuthorization,
703 sleeper: &dyn Sleeper,
704 ) -> Result<UserAccessToken, DeviceFlowError> {
705 let mut interval = authorization.interval;
706 let mut elapsed = Duration::ZERO;
707 let mut consecutive_retryable = 0_u32;
708
709 loop {
710 sleeper.sleep(interval).await;
713 elapsed = elapsed.saturating_add(interval);
714
715 let outcome = match self.poll_once_from(authorization, interval).await {
716 Ok(outcome) => {
717 consecutive_retryable = 0;
718 outcome
719 }
720 Err(err) if err.is_retryable() && consecutive_retryable < MAX_TRANSPORT_RETRIES => {
721 consecutive_retryable += 1;
722 tracing::warn!(
723 attempt = consecutive_retryable,
724 max_attempts = MAX_TRANSPORT_RETRIES,
725 error = %err,
726 "a device-flow poll failed in a way that says nothing about the login; \
727 the device code is still live, so polling continues"
728 );
729 PollOutcome::Pending
732 }
733 Err(err) => return Err(err),
734 };
735
736 match outcome {
737 PollOutcome::Approved(token) => return Ok(token),
738 PollOutcome::SlowDown { interval: next } => interval = next,
739 PollOutcome::Pending => {}
740 }
741
742 if elapsed >= authorization.expires_in {
743 tracing::warn!(
744 waited_secs = elapsed.as_secs(),
745 expires_in_secs = authorization.expires_in.as_secs(),
746 "the device code's own lifetime elapsed before approval"
747 );
748 return Err(DeviceFlowError::Expired);
749 }
750 }
751 }
752}
753
754fn slowed(current: Duration, advertised: Option<Duration>) -> Duration {
763 let floor = current.saturating_add(SLOW_DOWN_INCREMENT);
764 advertised.map_or(floor, |advertised| advertised.max(floor))
765}
766
767fn form_body(pairs: &[(&str, &str)]) -> String {
775 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
776 for (key, value) in pairs {
777 serializer.append_pair(key, value);
778 }
779 serializer.finish()
780}
781
782#[derive(Debug, Deserialize)]
787struct RawDeviceCode {
788 device_code: String,
789 user_code: String,
790 verification_uri: String,
791 #[serde(default)]
792 expires_in: Option<u64>,
793 #[serde(default)]
794 interval: Option<u64>,
795}
796
797#[derive(Deserialize)]
801struct RawTokenResponse {
802 #[serde(default)]
803 access_token: Option<String>,
804 #[serde(default)]
805 token_type: Option<String>,
806 #[serde(default)]
807 scope: Option<String>,
808 #[serde(default)]
816 refresh_token: Option<String>,
817 #[serde(default)]
819 expires_in: Option<u64>,
820 #[serde(default)]
822 refresh_token_expires_in: Option<u64>,
823 #[serde(default)]
824 error: Option<String>,
825 #[serde(default)]
827 interval: Option<u64>,
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use crate::testing::{
834 FIXTURE_DEVICE_CODE, FIXTURE_TOKEN, FIXTURE_USER_CODE, RecordingSleeper, Script,
835 device_code_body, error_body, token_body,
836 };
837 use serde_json::json;
838 use wiremock::{
839 Mock, MockServer, ResponseTemplate,
840 matchers::{body_string_contains, header, method, path},
841 };
842
843 fn app() -> AppRegistration {
844 AppRegistration::new("Iv23liTESTCLIENTID", "runner-manager").unwrap()
845 }
846
847 fn flow(server: &MockServer) -> DeviceFlow {
848 DeviceFlow::new(app(), Endpoints::for_test_server(&server.uri()).unwrap()).unwrap()
849 }
850
851 async fn mount_start(server: &MockServer) {
852 Mock::given(method("POST"))
853 .and(path("/login/device/code"))
854 .respond_with(ResponseTemplate::new(200).set_body_json(device_code_body(
855 &server.uri(),
856 5,
857 900,
858 )))
859 .mount(server)
860 .await;
861 }
862
863 async fn mount_token(server: &MockServer, responses: Vec<ResponseTemplate>) {
864 Mock::given(method("POST"))
865 .and(path("/login/oauth/access_token"))
866 .respond_with(Script::new(responses))
867 .mount(server)
868 .await;
869 }
870
871 #[tokio::test]
874 async fn a_device_flow_round_trip_against_fixtures_succeeds() {
875 let server = MockServer::start().await;
876 mount_start(&server).await;
877 mount_token(
878 &server,
879 vec![ResponseTemplate::new(200).set_body_json(token_body())],
880 )
881 .await;
882
883 let flow = flow(&server);
884 let authorization = flow.start().await.expect("device code");
885 assert_eq!(authorization.user_code(), FIXTURE_USER_CODE);
886 assert_eq!(
887 authorization.device_code().expose_secret(),
888 FIXTURE_DEVICE_CODE
889 );
890 assert_eq!(authorization.interval(), Duration::from_secs(5));
891 assert_eq!(authorization.expires_in(), Duration::from_secs(900));
892
893 let sleeper = RecordingSleeper::default();
894 let token = flow
895 .complete(&authorization, &sleeper)
896 .await
897 .expect("approved");
898
899 assert_eq!(token.secret().expose_secret(), FIXTURE_TOKEN);
900 assert_eq!(token.token_type(), "bearer");
901 assert_eq!(token.family(), "ghu_");
902 assert!(
903 token.is_user_to_server(),
904 "the published App issues user-to-server tokens; anything else means the \
905 registration is not the one this product authenticates as"
906 );
907 assert_eq!(sleeper.recorded(), vec![Duration::from_secs(5)]);
908 }
909
910 #[tokio::test]
911 async fn the_start_request_carries_the_public_client_id_and_no_secret() {
912 let server = MockServer::start().await;
913 Mock::given(method("POST"))
914 .and(path("/login/device/code"))
915 .and(header("accept", "application/json"))
916 .and(header("content-type", "application/x-www-form-urlencoded"))
917 .and(body_string_contains("client_id=Iv23liTESTCLIENTID"))
918 .respond_with(ResponseTemplate::new(200).set_body_json(device_code_body(
919 &server.uri(),
920 5,
921 900,
922 )))
923 .expect(1)
924 .mount(&server)
925 .await;
926
927 flow(&server).start().await.expect("device code");
928
929 let sent = server.received_requests().await.unwrap();
930 let body = String::from_utf8(sent[0].body.clone()).unwrap();
931 assert_eq!(
932 body, "client_id=Iv23liTESTCLIENTID",
933 "the start request is the client id and nothing else: no secret, no scope, \
934 no redirect URI"
935 );
936 }
937
938 #[tokio::test]
939 async fn the_device_code_travels_in_the_body_and_never_in_the_url() {
940 let server = MockServer::start().await;
941 mount_start(&server).await;
942 mount_token(
943 &server,
944 vec![ResponseTemplate::new(200).set_body_json(token_body())],
945 )
946 .await;
947
948 let flow = flow(&server);
949 let authorization = flow.start().await.unwrap();
950 flow.complete(&authorization, &RecordingSleeper::default())
951 .await
952 .unwrap();
953
954 let sent = server.received_requests().await.unwrap();
955 let poll = sent
956 .iter()
957 .find(|r| r.url.path() == "/login/oauth/access_token")
958 .expect("the poll happened");
959 assert!(
960 !poll.url.as_str().contains(FIXTURE_DEVICE_CODE),
961 "a query string reaches every proxy and access log: {}",
962 poll.url
963 );
964 let body = String::from_utf8(poll.body.clone()).unwrap();
965 assert!(body.contains(&format!("device_code={FIXTURE_DEVICE_CODE}")));
966 assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"));
967 }
968
969 #[tokio::test]
972 async fn authorization_pending_keeps_polling_at_the_unchanged_interval() {
973 let server = MockServer::start().await;
974 mount_start(&server).await;
975 mount_token(
976 &server,
977 vec![
978 ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
981 ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
982 ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
983 ResponseTemplate::new(200).set_body_json(token_body()),
984 ],
985 )
986 .await;
987
988 let flow = flow(&server);
989 let authorization = flow.start().await.unwrap();
990
991 assert_eq!(
992 flow.poll_once(&authorization).await.unwrap(),
993 PollOutcome::Pending,
994 "pending is an outcome, not a failure"
995 );
996
997 let sleeper = RecordingSleeper::default();
998 flow.complete(&authorization, &sleeper).await.unwrap();
999 assert_eq!(
1000 sleeper.recorded(),
1001 vec![
1002 Duration::from_secs(5),
1003 Duration::from_secs(5),
1004 Duration::from_secs(5)
1005 ],
1006 "authorization_pending must not change the interval"
1007 );
1008 }
1009
1010 #[tokio::test]
1011 async fn slow_down_demonstrably_increases_the_poll_interval() {
1012 let server = MockServer::start().await;
1013 mount_start(&server).await;
1014 mount_token(
1015 &server,
1016 vec![
1017 ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1018 ResponseTemplate::new(200).set_body_json(error_body("slow_down", Some(10))),
1019 ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1020 ResponseTemplate::new(200).set_body_json(error_body("slow_down", Some(15))),
1021 ResponseTemplate::new(200).set_body_json(token_body()),
1022 ],
1023 )
1024 .await;
1025
1026 let flow = flow(&server);
1027 let authorization = flow.start().await.unwrap();
1028 let sleeper = RecordingSleeper::default();
1029 flow.complete(&authorization, &sleeper).await.unwrap();
1030
1031 let waited = sleeper.recorded();
1032 assert_eq!(
1033 waited,
1034 vec![
1035 Duration::from_secs(5), Duration::from_secs(5), Duration::from_secs(10), Duration::from_secs(10),
1039 Duration::from_secs(15), ],
1041 "every slow_down must lengthen the interval used from then on"
1042 );
1043
1044 let mut increases = 0;
1047 for pair in waited.windows(2) {
1048 assert!(
1049 pair[1] >= pair[0],
1050 "the interval must never shrink: {pair:?}"
1051 );
1052 if pair[1] > pair[0] {
1053 increases += 1;
1054 }
1055 }
1056 assert_eq!(increases, 2, "two slow_downs, two increases");
1057 }
1058
1059 #[tokio::test]
1060 async fn a_slow_down_without_an_advertised_interval_still_adds_five_seconds() {
1061 let server = MockServer::start().await;
1062 mount_start(&server).await;
1063 mount_token(
1064 &server,
1065 vec![
1066 ResponseTemplate::new(200).set_body_json(error_body("slow_down", None)),
1067 ResponseTemplate::new(200).set_body_json(token_body()),
1068 ],
1069 )
1070 .await;
1071
1072 let flow = flow(&server);
1073 let authorization = flow.start().await.unwrap();
1074
1075 assert_eq!(
1076 flow.poll_once(&authorization).await.unwrap(),
1077 PollOutcome::SlowDown {
1078 interval: Duration::from_secs(10)
1079 },
1080 "RFC 8628 makes the +5s increase mandatory even with no interval in the body"
1081 );
1082 }
1083
1084 #[test]
1085 fn the_slow_down_interval_never_shrinks_whatever_the_server_advertises() {
1086 assert_eq!(
1087 slowed(Duration::from_secs(5), None),
1088 Duration::from_secs(10)
1089 );
1090 assert_eq!(
1091 slowed(Duration::from_secs(5), Some(Duration::from_secs(10))),
1092 Duration::from_secs(10),
1093 "GitHub advertises exactly the RFC floor, so the two agree"
1094 );
1095 assert_eq!(
1096 slowed(Duration::from_secs(5), Some(Duration::from_secs(30))),
1097 Duration::from_secs(30),
1098 "a server asking for more than the floor gets it"
1099 );
1100 assert_eq!(
1101 slowed(Duration::from_secs(5), Some(Duration::from_secs(1))),
1102 Duration::from_secs(10),
1103 "a server asking for LESS must not be able to speed us up: slow_down means slow down"
1104 );
1105 }
1106
1107 #[tokio::test]
1108 async fn expired_token_is_terminal_and_asks_for_a_whole_new_login() {
1109 let server = MockServer::start().await;
1110 mount_start(&server).await;
1111 mount_token(
1112 &server,
1113 vec![ResponseTemplate::new(200).set_body_json(error_body("expired_token", None))],
1114 )
1115 .await;
1116
1117 let flow = flow(&server);
1118 let authorization = flow.start().await.unwrap();
1119 let err = flow
1120 .complete(&authorization, &RecordingSleeper::default())
1121 .await
1122 .expect_err("expired");
1123
1124 assert!(matches!(err, DeviceFlowError::Expired), "{err:?}");
1125 assert!(!err.is_retryable());
1126 assert!(err.requires_new_login());
1127 assert!(err.to_string().contains("auth login"));
1128 }
1129
1130 #[tokio::test]
1131 async fn access_denied_is_terminal_and_is_not_an_error_to_retry() {
1132 let server = MockServer::start().await;
1133 mount_start(&server).await;
1134 mount_token(
1135 &server,
1136 vec![ResponseTemplate::new(200).set_body_json(error_body("access_denied", None))],
1137 )
1138 .await;
1139
1140 let flow = flow(&server);
1141 let authorization = flow.start().await.unwrap();
1142 let err = flow
1143 .complete(&authorization, &RecordingSleeper::default())
1144 .await
1145 .expect_err("declined");
1146
1147 assert!(matches!(err, DeviceFlowError::AccessDenied), "{err:?}");
1148 assert!(!err.is_retryable(), "the user said no; do not ask again");
1149 assert!(
1150 !err.requires_new_login(),
1151 "a refusal is not an expiry: `auth login` again is the operator's choice, \
1152 not this error's instruction"
1153 );
1154 }
1155
1156 #[tokio::test]
1163 async fn a_gateway_blip_mid_poll_does_not_abort_the_login() {
1164 let server = MockServer::start().await;
1165 mount_start(&server).await;
1166 mount_token(
1167 &server,
1168 vec![
1169 ResponseTemplate::new(502).set_body_string("<html>Bad Gateway</html>"),
1170 ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1171 ResponseTemplate::new(503).set_body_string("<html>Service Unavailable</html>"),
1172 ResponseTemplate::new(200).set_body_json(token_body()),
1173 ],
1174 )
1175 .await;
1176
1177 let flow = flow(&server);
1178 let authorization = flow.start().await.unwrap();
1179 let sleeper = RecordingSleeper::default();
1180 let token = flow
1181 .complete(&authorization, &sleeper)
1182 .await
1183 .expect("two blips must not destroy a login the user is about to approve");
1184
1185 assert_eq!(token.secret().expose_secret(), FIXTURE_TOKEN);
1186 assert_eq!(
1187 sleeper.recorded().len(),
1188 4,
1189 "each absorbed failure costs one more scheduled poll and nothing else"
1190 );
1191 }
1192
1193 #[tokio::test]
1196 async fn the_transport_retry_is_bounded_rather_than_endless() {
1197 let server = MockServer::start().await;
1198 mount_start(&server).await;
1199 mount_token(
1200 &server,
1201 vec![ResponseTemplate::new(503).set_body_string("down")],
1202 )
1203 .await;
1204
1205 let flow = flow(&server);
1206 let authorization = flow.start().await.unwrap();
1207 let sleeper = RecordingSleeper::default();
1208 let err = flow
1209 .complete(&authorization, &sleeper)
1210 .await
1211 .expect_err("a persistently unreachable GitHub is still a failure");
1212
1213 assert!(
1214 matches!(err, DeviceFlowError::Status { status: 503, .. }),
1215 "{err:?}"
1216 );
1217 assert_eq!(
1218 sleeper.recorded().len(),
1219 MAX_TRANSPORT_RETRIES as usize + 1,
1220 "one initial poll plus exactly {MAX_TRANSPORT_RETRIES} absorbed retries"
1221 );
1222 }
1223
1224 #[tokio::test]
1228 async fn a_transport_failure_is_absorbed_and_bounded_like_a_gateway_error() {
1229 let server = MockServer::start().await;
1230 mount_start(&server).await;
1231 let flow = flow(&server);
1232 let authorization = flow.start().await.unwrap();
1233
1234 let dead_port = {
1238 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port");
1239 listener.local_addr().expect("a bound address").port()
1240 };
1241 let unreachable = DeviceFlow::new(
1242 app(),
1243 Endpoints::for_test_server(&format!("http://127.0.0.1:{dead_port}")).unwrap(),
1244 )
1245 .unwrap();
1246
1247 let sleeper = RecordingSleeper::default();
1248 let err = unreachable
1249 .complete(&authorization, &sleeper)
1250 .await
1251 .expect_err("nothing is listening");
1252
1253 assert!(matches!(err, DeviceFlowError::Transport(_)), "{err:?}");
1254 assert!(
1255 err.is_retryable(),
1256 "the device code is still live, so `f1` may present this same login again"
1257 );
1258 assert_eq!(
1259 sleeper.recorded().len(),
1260 MAX_TRANSPORT_RETRIES as usize + 1,
1261 "a dropped connection is absorbed like any other blip, and bounded the same way"
1262 );
1263 }
1264
1265 #[tokio::test]
1269 async fn a_proxy_error_page_is_reported_as_its_status_not_as_a_decode_failure() {
1270 let server = MockServer::start().await;
1271 mount_start(&server).await;
1272 mount_token(
1273 &server,
1274 vec![
1275 ResponseTemplate::new(502)
1276 .insert_header("content-type", "text/html")
1277 .set_body_string("<html><body>502 Bad Gateway</body></html>"),
1278 ],
1279 )
1280 .await;
1281
1282 let flow = flow(&server);
1283 let authorization = flow.start().await.unwrap();
1284 let err = flow
1285 .poll_once(&authorization)
1286 .await
1287 .expect_err("a gateway page is not a token response");
1288
1289 match err {
1290 DeviceFlowError::Status { status, stage } => {
1291 assert_eq!(status, 502);
1292 assert_eq!(stage, "access token request");
1293 }
1294 other => panic!("expected the status, got {other:?}"),
1295 }
1296 }
1297
1298 #[tokio::test]
1302 async fn a_200_that_is_not_the_protocol_is_still_a_decode_failure() {
1303 let server = MockServer::start().await;
1304 mount_start(&server).await;
1305 mount_token(
1306 &server,
1307 vec![ResponseTemplate::new(200).set_body_string("not json at all")],
1308 )
1309 .await;
1310
1311 let flow = flow(&server);
1312 let authorization = flow.start().await.unwrap();
1313 let err = flow.poll_once(&authorization).await.expect_err("garbage");
1314
1315 assert!(matches!(err, DeviceFlowError::Decode { .. }), "{err:?}");
1316 assert!(
1317 !err.is_retryable(),
1318 "a 200 that is not the protocol is a protocol violation, not a blip"
1319 );
1320 }
1321
1322 #[tokio::test]
1334 async fn the_terminal_states_are_never_retried() {
1335 for code in [
1336 "access_denied",
1337 "expired_token",
1338 "incorrect_device_code",
1339 "device_flow_disabled",
1341 "a_code_this_client_has_never_heard_of",
1344 ] {
1345 let server = MockServer::start().await;
1346 mount_start(&server).await;
1347 mount_token(
1348 &server,
1349 vec![ResponseTemplate::new(200).set_body_json(error_body(code, None))],
1350 )
1351 .await;
1352
1353 let flow = flow(&server);
1354 let authorization = flow.start().await.unwrap();
1355 let sleeper = RecordingSleeper::default();
1356 let err = flow
1357 .complete(&authorization, &sleeper)
1358 .await
1359 .expect_err("terminal");
1360
1361 assert!(!err.is_retryable(), "{code} must stay terminal: {err:?}");
1362 assert_eq!(
1363 sleeper.recorded().len(),
1364 1,
1365 "{code} must be answered on the first poll and never polled again"
1366 );
1367 }
1368 }
1369
1370 #[tokio::test]
1374 async fn the_four_documented_errors_produce_four_distinct_outcomes() {
1375 let server = MockServer::start().await;
1376 mount_start(&server).await;
1377 let flow = flow(&server);
1378 let authorization = flow.start().await.unwrap();
1379
1380 let mut described = Vec::new();
1381 for code in [
1382 "authorization_pending",
1383 "slow_down",
1384 "expired_token",
1385 "access_denied",
1386 ] {
1387 let scoped = MockServer::start().await;
1388 Mock::given(method("POST"))
1389 .and(path("/login/oauth/access_token"))
1390 .respond_with(ResponseTemplate::new(200).set_body_json(error_body(code, None)))
1391 .mount(&scoped)
1392 .await;
1393 let scoped_flow =
1394 DeviceFlow::new(app(), Endpoints::for_test_server(&scoped.uri()).unwrap()).unwrap();
1395
1396 described.push(match scoped_flow.poll_once(&authorization).await {
1397 Ok(PollOutcome::Pending) => "pending".to_string(),
1398 Ok(PollOutcome::SlowDown { interval }) => {
1399 format!("slow_down->{}s", interval.as_secs())
1400 }
1401 Ok(PollOutcome::Approved(_)) => "approved".to_string(),
1402 Err(err) => format!("error:{err:?}"),
1403 });
1404 }
1405
1406 assert_eq!(
1407 described,
1408 vec![
1409 "pending",
1410 "slow_down->10s",
1411 "error:Expired",
1412 "error:AccessDenied",
1413 ]
1414 );
1415 let mut unique = described.clone();
1416 unique.sort();
1417 unique.dedup();
1418 assert_eq!(unique.len(), 4, "four codes must not collapse into fewer");
1419 }
1420
1421 #[tokio::test]
1422 async fn an_unrecognised_error_is_reported_as_itself_rather_than_guessed_at() {
1423 let server = MockServer::start().await;
1424 mount_start(&server).await;
1425 mount_token(
1426 &server,
1427 vec![
1428 ResponseTemplate::new(200).set_body_json(error_body("device_flow_disabled", None)),
1429 ],
1430 )
1431 .await;
1432 let flow = flow(&server);
1433 let authorization = flow.start().await.unwrap();
1434
1435 let err = flow.poll_once(&authorization).await.expect_err("disabled");
1436 match err {
1437 DeviceFlowError::AppMisconfigured { code } => {
1438 assert_eq!(code, "device_flow_disabled");
1439 }
1440 other => panic!("expected a registration error, got {other:?}"),
1441 }
1442 }
1443
1444 #[tokio::test]
1445 async fn the_local_deadline_backstops_a_server_that_never_says_expired() {
1446 let server = MockServer::start().await;
1447 Mock::given(method("POST"))
1449 .and(path("/login/device/code"))
1450 .respond_with(ResponseTemplate::new(200).set_body_json(device_code_body(
1451 &server.uri(),
1452 5,
1453 20,
1454 )))
1455 .mount(&server)
1456 .await;
1457 mount_token(
1458 &server,
1459 vec![
1460 ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1461 ],
1462 )
1463 .await;
1464
1465 let flow = flow(&server);
1466 let authorization = flow.start().await.unwrap();
1467 let sleeper = RecordingSleeper::default();
1468 let err = flow
1469 .complete(&authorization, &sleeper)
1470 .await
1471 .expect_err("the device code's own lifetime ran out");
1472
1473 assert!(matches!(err, DeviceFlowError::Expired), "{err:?}");
1474 assert_eq!(
1475 sleeper.recorded().len(),
1476 4,
1477 "the loop stops at the advertised lifetime rather than polling forever"
1478 );
1479 }
1480
1481 #[tokio::test]
1484 async fn a_verification_url_on_another_origin_is_refused_rather_than_displayed() {
1485 let server = MockServer::start().await;
1486 Mock::given(method("POST"))
1487 .and(path("/login/device/code"))
1488 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1489 "device_code": FIXTURE_DEVICE_CODE,
1490 "user_code": FIXTURE_USER_CODE,
1491 "verification_uri": "https://github.com.evil.example/login/device",
1492 "expires_in": 900,
1493 "interval": 5
1494 })))
1495 .mount(&server)
1496 .await;
1497
1498 let err = flow(&server).start().await.expect_err("wrong origin");
1499 match err {
1500 DeviceFlowError::UntrustedVerificationUri { origin } => {
1501 assert!(origin.contains("evil.example"), "{origin}");
1502 }
1503 other => panic!("expected the phishing control to fire, got {other:?}"),
1504 }
1505 }
1506
1507 #[test]
1508 fn the_printed_verification_url_is_the_compiled_in_canonical_one() {
1509 let production = DeviceFlow::new(app(), Endpoints::production()).unwrap();
1510 assert_eq!(
1511 production.verification_url().as_str(),
1512 "https://github.com/login/device",
1513 "the tool prints this and never proxies, embeds, or imitates the approval page"
1514 );
1515 }
1516
1517 #[tokio::test]
1520 async fn neither_code_nor_token_is_rendered_by_debug_or_display() {
1521 let server = MockServer::start().await;
1522 mount_start(&server).await;
1523 mount_token(
1524 &server,
1525 vec![ResponseTemplate::new(200).set_body_json(token_body())],
1526 )
1527 .await;
1528
1529 let flow = flow(&server);
1530 let authorization = flow.start().await.unwrap();
1531 let rendered = format!("{authorization:?}");
1532 assert!(
1533 !rendered.contains(FIXTURE_DEVICE_CODE),
1534 "the device code is never shown: {rendered}"
1535 );
1536 assert!(rendered.contains("[REDACTED]"));
1537 assert!(
1538 rendered.contains(FIXTURE_USER_CODE),
1539 "the user code is displayed by design, so it stays legible: {rendered}"
1540 );
1541
1542 let token = flow
1543 .complete(&authorization, &RecordingSleeper::default())
1544 .await
1545 .unwrap();
1546 assert!(!format!("{token:?}").contains(FIXTURE_TOKEN));
1547 assert!(!format!("{flow:?}").contains(FIXTURE_TOKEN));
1548
1549 for err in [
1552 DeviceFlowError::AccessDenied,
1553 DeviceFlowError::Expired,
1554 DeviceFlowError::IncorrectDeviceCode,
1555 DeviceFlowError::AppMisconfigured {
1556 code: "device_flow_disabled".to_string(),
1557 },
1558 DeviceFlowError::Unexpected {
1559 code: "??".to_string(),
1560 },
1561 ] {
1562 let text = format!("{err} / {err:?}");
1563 assert!(!text.contains(FIXTURE_DEVICE_CODE), "{text}");
1564 assert!(!text.contains(FIXTURE_TOKEN), "{text}");
1565 }
1566 }
1567
1568 #[test]
1569 fn the_token_family_is_diagnostic_and_exposes_nothing_else() {
1570 let token = UserAccessToken::new(SecretString::from("ghu_abcdefghijklmnop"));
1571 assert_eq!(token.family(), "ghu_");
1572 assert!(token.is_user_to_server());
1573
1574 let oauth = UserAccessToken::new(SecretString::from("gho_abcdefghijklmnop"));
1575 assert_eq!(oauth.family(), "gho_");
1576 assert!(
1577 !oauth.is_user_to_server(),
1578 "a `gho_` token means this is not the published App's user-to-server credential"
1579 );
1580
1581 let odd = UserAccessToken::new(SecretString::from("no-underscore-here"));
1582 assert_eq!(
1583 odd.family(),
1584 "",
1585 "never guess, and never return a prefix of the token"
1586 );
1587 }
1588
1589 #[test]
1590 fn the_form_body_encodes_exactly_what_both_spikes_sent() {
1591 assert_eq!(
1592 form_body(&[("client_id", "Iv1"), ("grant_type", DEVICE_GRANT_TYPE)]),
1593 "client_id=Iv1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
1594 );
1595 }
1596}