use std::time::Duration;
use serde::{Deserialize, Serialize};
pub const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1);
pub const MAX_POLL_INTERVAL: Duration = Duration::from_secs(60);
pub const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Serialize)]
pub struct AuthorizeRequest {
pub name: String,
pub platform: String,
pub scopes: Vec<String>,
pub client_hint: ClientHint,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientHint {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthorizeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: i64,
pub interval: i32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_in: i64,
#[serde(default)]
pub scope: String,
pub node_id: String,
#[serde(default)]
pub node_name: String,
#[serde(default)]
pub owner_email: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(default)]
pub error_description: Option<String>,
#[serde(default)]
pub interval: Option<i32>,
#[serde(skip)]
pub status: Option<u16>,
}
impl ErrorResponse {
pub fn is_transient(&self) -> bool {
matches!(self.status, Some(status) if status == 429 || (500..600).contains(&status))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollOutcome {
KeepWaiting(Duration),
Expired,
Denied,
Fatal(String),
}
#[derive(Debug, Clone)]
pub struct PollState {
interval: Duration,
}
impl PollState {
pub fn new(interval_secs: i32) -> PollState {
PollState { interval: clamp_interval(Duration::from_secs(interval_secs.max(0) as u64)) }
}
pub fn interval(&self) -> Duration {
self.interval
}
pub fn on_error(&mut self, response: &ErrorResponse) -> PollOutcome {
match response.error.as_str() {
"authorization_pending" => {
if let Some(interval) = response.interval {
self.adopt(interval);
}
PollOutcome::KeepWaiting(self.interval)
}
"slow_down" => {
match response.interval {
Some(interval) => self.adopt(interval),
None => self.interval = clamp_interval(self.interval + SLOW_DOWN_STEP),
}
PollOutcome::KeepWaiting(self.interval)
}
"expired_token" => PollOutcome::Expired,
"access_denied" => PollOutcome::Denied,
_ if response.is_transient() => {
self.interval = clamp_interval(self.interval + SLOW_DOWN_STEP);
PollOutcome::KeepWaiting(self.interval)
}
other => PollOutcome::Fatal(match &response.error_description {
Some(description) => format!("{other}: {description}"),
None => other.to_string(),
}),
}
}
fn adopt(&mut self, interval_secs: i32) {
let proposed = clamp_interval(Duration::from_secs(interval_secs.max(0) as u64));
self.interval = self.interval.max(proposed);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefreshOutcome {
Dead(String),
Unavailable(String),
}
pub fn classify_refresh_error(error: &ErrorResponse) -> RefreshOutcome {
let described = match &error.error_description {
Some(description) => format!("{}: {description}", error.error),
None => error.error.clone(),
};
if error.is_transient() {
return RefreshOutcome::Unavailable(described);
}
match error.error.as_str() {
"invalid_grant" => RefreshOutcome::Dead(described),
_ => RefreshOutcome::Unavailable(described),
}
}
fn clamp_interval(interval: Duration) -> Duration {
interval.clamp(MIN_POLL_INTERVAL, MAX_POLL_INTERVAL)
}
#[cfg(test)]
mod tests {
use super::*;
fn error(kind: &str, interval: Option<i32>) -> ErrorResponse {
ErrorResponse {
error: kind.to_string(),
error_description: Some("because".to_string()),
interval,
status: None,
}
}
#[test]
fn pending_then_slow_down_then_still_pending() {
let mut state = PollState::new(5);
assert_eq!(state.interval(), Duration::from_secs(5));
assert_eq!(
state.on_error(&error("authorization_pending", None)),
PollOutcome::KeepWaiting(Duration::from_secs(5))
);
assert_eq!(
state.on_error(&error("slow_down", Some(10))),
PollOutcome::KeepWaiting(Duration::from_secs(10))
);
assert_eq!(
state.on_error(&error("authorization_pending", Some(10))),
PollOutcome::KeepWaiting(Duration::from_secs(10))
);
}
#[test]
fn slow_down_without_a_hint_still_widens() {
let mut state = PollState::new(5);
assert_eq!(
state.on_error(&error("slow_down", None)),
PollOutcome::KeepWaiting(Duration::from_secs(10))
);
assert_eq!(
state.on_error(&error("slow_down", None)),
PollOutcome::KeepWaiting(Duration::from_secs(15))
);
}
#[test]
fn a_smaller_server_hint_never_undoes_a_backoff() {
let mut state = PollState::new(5);
state.on_error(&error("slow_down", Some(30)));
assert_eq!(state.interval(), Duration::from_secs(30));
state.on_error(&error("authorization_pending", Some(5)));
assert_eq!(state.interval(), Duration::from_secs(30));
}
#[test]
fn intervals_are_clamped_at_both_ends() {
assert_eq!(PollState::new(0).interval(), MIN_POLL_INTERVAL, "0 must not become a hot loop");
assert_eq!(PollState::new(-5).interval(), MIN_POLL_INTERVAL);
assert_eq!(PollState::new(9999).interval(), MAX_POLL_INTERVAL);
let mut state = PollState::new(5);
for _ in 0..100 {
state.on_error(&error("slow_down", None));
}
assert_eq!(state.interval(), MAX_POLL_INTERVAL);
}
#[test]
fn expiry_and_denial_are_distinct() {
let mut state = PollState::new(5);
assert_eq!(state.on_error(&error("expired_token", None)), PollOutcome::Expired);
assert_eq!(state.on_error(&error("access_denied", None)), PollOutcome::Denied);
}
#[test]
fn unknown_errors_are_fatal_and_keep_the_description() {
let mut state = PollState::new(5);
let outcome = state.on_error(&error("invalid_grant", None));
let PollOutcome::Fatal(message) = outcome else { panic!("expected fatal") };
assert!(message.contains("invalid_grant"));
assert!(message.contains("because"), "the server's reason must survive");
}
#[test]
fn a_transient_5xx_keeps_waiting_instead_of_ending_the_attempt() {
for status in [429u16, 500, 502, 503, 504] {
let mut state = PollState::new(5);
let response = ErrorResponse {
error: format!("http_{status}"),
error_description: None,
interval: None,
status: Some(status),
};
let PollOutcome::KeepWaiting(interval) = state.on_error(&response) else {
panic!("{status} ended the enrollment; it says nothing about the grant")
};
assert_eq!(
interval,
Duration::from_secs(10),
"an unreachable server should be asked less often, not given up on"
);
}
}
#[test]
fn a_four_hundred_with_an_unknown_code_is_still_fatal() {
let mut state = PollState::new(5);
let response = ErrorResponse {
error: "unsupported_grant_type".into(),
error_description: Some("because".into()),
interval: None,
status: Some(400),
};
let PollOutcome::Fatal(message) = state.on_error(&response) else {
panic!("a 400 is the server answering, and this client cannot honour that answer")
};
assert!(message.contains("unsupported_grant_type"));
}
#[test]
fn only_invalid_grant_kills_a_stored_credential() {
let outcome = classify_refresh_error(&error("invalid_grant", None));
let RefreshOutcome::Dead(reason) = outcome else { panic!("expected dead") };
assert!(reason.contains("invalid_grant"));
assert!(reason.contains("because"), "the server's reason must survive");
}
#[test]
fn a_five_hundred_cannot_kill_a_credential_whatever_the_body_says() {
let response = ErrorResponse {
error: "invalid_grant".into(),
error_description: Some("because".into()),
interval: None,
status: Some(500),
};
assert!(
matches!(classify_refresh_error(&response), RefreshOutcome::Unavailable(_)),
"a server that could not answer has not told us the grant is dead"
);
}
#[test]
fn a_server_having_a_bad_day_never_discards_a_credential() {
for kind in ["temporarily_unavailable", "slow_down", "server_error", "invalid_request"] {
assert!(
matches!(classify_refresh_error(&error(kind, None)), RefreshOutcome::Unavailable(_)),
"{kind} must not be treated as a dead grant"
);
}
}
#[test]
fn an_unreadable_error_body_is_transient_not_fatal() {
let synthetic =
ErrorResponse { error: "http_503".into(), error_description: None, interval: None, status: Some(503) };
let RefreshOutcome::Unavailable(reason) = classify_refresh_error(&synthetic) else {
panic!("a body we could not read tells us nothing about the grant")
};
assert_eq!(reason, "http_503", "with no description the code stands alone");
}
}