1use std::future::Future;
4use std::time::{Duration, Instant};
5
6use serde_json::Value;
7use tokio_util::sync::CancellationToken;
8
9use super::AuthError;
10
11pub(crate) const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
13
14const TIMEOUT_MESSAGE: &str = "Device flow timed out";
15const SLOW_DOWN_TIMEOUT_MESSAGE: &str = "Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
16const MINIMUM_INTERVAL: Duration = Duration::from_secs(1);
17const DEFAULT_INTERVAL_SECONDS: f64 = 5.0;
19const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5);
21
22pub(crate) fn trusted_url(value: &Value) -> Option<String> {
24 let url = url::Url::parse(value.as_str()?).ok()?;
25 matches!(url.scheme(), "http" | "https").then(|| url.to_string())
26}
27
28pub(crate) fn positive(value: &Value) -> Option<f64> {
30 value
31 .as_f64()
32 .filter(|value| value.is_finite() && *value > 0.0)
33}
34
35#[derive(Debug, PartialEq)]
37pub(crate) enum Poll<T> {
38 Pending,
40 SlowDown(Option<f64>),
42 Failed(String),
44 Complete(T),
46}
47
48fn seconds(value: f64) -> Duration {
49 Duration::try_from_secs_f64(value.max(0.0)).unwrap_or(Duration::MAX)
50}
51
52async fn sleep(duration: Duration, cancel: &CancellationToken) -> Result<(), AuthError> {
53 cancel
54 .run_until_cancelled(tokio::time::sleep(duration))
55 .await
56 .ok_or(AuthError::Cancelled)
57}
58
59pub(crate) async fn poll_device_code<T, F, Fut>(
61 interval_seconds: Option<f64>,
62 expires_in_seconds: Option<f64>,
63 wait_before_first_poll: bool,
64 cancel: &CancellationToken,
65 mut poll: F,
66) -> Result<T, AuthError>
67where
68 F: FnMut() -> Fut,
69 Fut: Future<Output = Result<Poll<T>, AuthError>>,
70{
71 let deadline = expires_in_seconds.map(|expires| Instant::now() + seconds(expires));
72 let remaining = || {
73 deadline.map_or(Duration::MAX, |deadline| {
74 deadline.saturating_duration_since(Instant::now())
75 })
76 };
77 let mut interval =
78 seconds(interval_seconds.unwrap_or(DEFAULT_INTERVAL_SECONDS)).max(MINIMUM_INTERVAL);
79 let mut slow_downs = 0;
80 if wait_before_first_poll && !remaining().is_zero() {
81 sleep(interval.min(remaining()), cancel).await?;
82 }
83 while !remaining().is_zero() {
84 if cancel.is_cancelled() {
85 return Err(AuthError::Cancelled);
86 }
87 match poll().await? {
88 Poll::Complete(value) => return Ok(value),
89 Poll::Failed(message) => return Err(AuthError::Failed(message)),
90 Poll::SlowDown(server_interval) => {
91 slow_downs += 1;
92 interval = match server_interval {
93 Some(value) if value.is_finite() && value > 0.0 => seconds(value),
94 _ => interval + SLOW_DOWN_INCREMENT,
95 }
96 .max(MINIMUM_INTERVAL);
97 }
98 Poll::Pending => {}
99 }
100 if remaining().is_zero() {
101 break;
102 }
103 sleep(interval.min(remaining()), cancel).await?;
104 }
105 Err(AuthError::failed(if slow_downs > 0 {
106 SLOW_DOWN_TIMEOUT_MESSAGE
107 } else {
108 TIMEOUT_MESSAGE
109 }))
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[tokio::test(start_paused = true)]
117 async fn honors_slow_down() {
118 let cancel = CancellationToken::new();
119 let mut calls = 0;
120 let started = tokio::time::Instant::now();
121 let value = poll_device_code(Some(1.0), Some(60.0), true, &cancel, || {
122 calls += 1;
123 let result = match calls {
124 1 => Poll::Pending,
125 2 => Poll::SlowDown(None),
126 _ => Poll::Complete("token"),
127 };
128 async move { Ok(result) }
129 })
130 .await
131 .unwrap();
132 assert_eq!(value, "token");
133 assert_eq!(started.elapsed().as_secs(), 8);
135 }
136
137 #[tokio::test(start_paused = true)]
138 async fn times_out() {
139 let cancel = CancellationToken::new();
140 let result: Result<(), _> =
141 poll_device_code(Some(1.0), Some(3.0), false, &cancel, || async {
142 Ok(Poll::SlowDown(Some(2.0)))
143 })
144 .await;
145 assert_eq!(result, Err(AuthError::failed(SLOW_DOWN_TIMEOUT_MESSAGE)));
146 let failed: Result<(), _> = poll_device_code(None, None, false, &cancel, || async {
147 Ok(Poll::Failed("Device flow failed: access_denied".into()))
148 })
149 .await;
150 assert_eq!(
151 failed,
152 Err(AuthError::failed("Device flow failed: access_denied"))
153 );
154 }
155}