use std::future::Future;
use std::time::{Duration, Instant};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use super::AuthError;
pub(crate) const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
const TIMEOUT_MESSAGE: &str = "Device flow timed out";
const 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.";
const MINIMUM_INTERVAL: Duration = Duration::from_secs(1);
const DEFAULT_INTERVAL_SECONDS: f64 = 5.0;
const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5);
pub(crate) fn trusted_url(value: &Value) -> Option<String> {
let url = url::Url::parse(value.as_str()?).ok()?;
matches!(url.scheme(), "http" | "https").then(|| url.to_string())
}
pub(crate) fn positive(value: &Value) -> Option<f64> {
value
.as_f64()
.filter(|value| value.is_finite() && *value > 0.0)
}
#[derive(Debug, PartialEq)]
pub(crate) enum Poll<T> {
Pending,
SlowDown(Option<f64>),
Failed(String),
Complete(T),
}
fn seconds(value: f64) -> Duration {
Duration::try_from_secs_f64(value.max(0.0)).unwrap_or(Duration::MAX)
}
async fn sleep(duration: Duration, cancel: &CancellationToken) -> Result<(), AuthError> {
cancel
.run_until_cancelled(tokio::time::sleep(duration))
.await
.ok_or(AuthError::Cancelled)
}
pub(crate) async fn poll_device_code<T, F, Fut>(
interval_seconds: Option<f64>,
expires_in_seconds: Option<f64>,
wait_before_first_poll: bool,
cancel: &CancellationToken,
mut poll: F,
) -> Result<T, AuthError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<Poll<T>, AuthError>>,
{
let deadline = expires_in_seconds.map(|expires| Instant::now() + seconds(expires));
let remaining = || {
deadline.map_or(Duration::MAX, |deadline| {
deadline.saturating_duration_since(Instant::now())
})
};
let mut interval =
seconds(interval_seconds.unwrap_or(DEFAULT_INTERVAL_SECONDS)).max(MINIMUM_INTERVAL);
let mut slow_downs = 0;
if wait_before_first_poll && !remaining().is_zero() {
sleep(interval.min(remaining()), cancel).await?;
}
while !remaining().is_zero() {
if cancel.is_cancelled() {
return Err(AuthError::Cancelled);
}
match poll().await? {
Poll::Complete(value) => return Ok(value),
Poll::Failed(message) => return Err(AuthError::Failed(message)),
Poll::SlowDown(server_interval) => {
slow_downs += 1;
interval = match server_interval {
Some(value) if value.is_finite() && value > 0.0 => seconds(value),
_ => interval + SLOW_DOWN_INCREMENT,
}
.max(MINIMUM_INTERVAL);
}
Poll::Pending => {}
}
if remaining().is_zero() {
break;
}
sleep(interval.min(remaining()), cancel).await?;
}
Err(AuthError::failed(if slow_downs > 0 {
SLOW_DOWN_TIMEOUT_MESSAGE
} else {
TIMEOUT_MESSAGE
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn honors_slow_down() {
let cancel = CancellationToken::new();
let mut calls = 0;
let started = tokio::time::Instant::now();
let value = poll_device_code(Some(1.0), Some(60.0), true, &cancel, || {
calls += 1;
let result = match calls {
1 => Poll::Pending,
2 => Poll::SlowDown(None),
_ => Poll::Complete("token"),
};
async move { Ok(result) }
})
.await
.unwrap();
assert_eq!(value, "token");
assert_eq!(started.elapsed().as_secs(), 8);
}
#[tokio::test(start_paused = true)]
async fn times_out() {
let cancel = CancellationToken::new();
let result: Result<(), _> =
poll_device_code(Some(1.0), Some(3.0), false, &cancel, || async {
Ok(Poll::SlowDown(Some(2.0)))
})
.await;
assert_eq!(result, Err(AuthError::failed(SLOW_DOWN_TIMEOUT_MESSAGE)));
let failed: Result<(), _> = poll_device_code(None, None, false, &cancel, || async {
Ok(Poll::Failed("Device flow failed: access_denied".into()))
})
.await;
assert_eq!(
failed,
Err(AuthError::failed("Device flow failed: access_denied"))
);
}
}