use core::fmt;
use crate::crypto::constant_time::constant_time_eq;
use crate::util::timestamp::Timestamp;
#[derive(Debug, Clone, Copy)]
pub struct StoredRefreshToken<'a> {
pub client_id: &'a str,
pub revoked: bool,
pub rotated: bool,
pub expires_at: Timestamp,
}
#[doc(alias = "refresh_denied")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RefreshDenied {
ClientAuthenticationFailed,
ClientMismatch,
Revoked,
Expired,
}
impl RefreshDenied {
#[must_use]
#[inline]
pub fn as_str(self) -> &'static str {
match self {
Self::ClientAuthenticationFailed => "client authentication failed",
Self::ClientMismatch => "refresh token issued to a different client",
Self::Revoked => "refresh token revoked",
Self::Expired => "refresh token expired",
}
}
}
impl fmt::Display for RefreshDenied {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "refresh denied: {}", self.as_str())
}
}
impl std::error::Error for RefreshDenied {}
#[doc(alias = "refresh_outcome")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RefreshOutcome {
Rotate,
Denied(RefreshDenied),
ReuseDetected,
}
#[derive(Debug, Clone, Copy)]
pub struct RefreshPresented<'a> {
pub client_id: &'a str,
pub client_auth: super::code::ClientAuthResult,
}
#[must_use]
pub fn evaluate_refresh(
stored: &StoredRefreshToken<'_>,
presented: &RefreshPresented<'_>,
now: Timestamp,
) -> RefreshOutcome {
use super::code::ClientAuthResult;
use RefreshDenied as D;
use RefreshOutcome::{Denied, ReuseDetected, Rotate};
if presented.client_auth != ClientAuthResult::Authenticated {
return Denied(D::ClientAuthenticationFailed);
}
if !constant_time_eq(stored.client_id.as_bytes(), presented.client_id.as_bytes()) {
return Denied(D::ClientMismatch);
}
if stored.rotated {
return ReuseDetected;
}
if stored.revoked {
return Denied(D::Revoked);
}
if stored.expires_at.is_expired(&now) {
return Denied(D::Expired);
}
Rotate
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oauth::server::ClientAuthResult;
fn now() -> Timestamp {
Timestamp::from_unix_secs(2_000_000)
}
fn future() -> Timestamp {
Timestamp::from_unix_secs(2_001_000)
}
fn past() -> Timestamp {
Timestamp::from_unix_secs(1_999_000)
}
fn authed(client_id: &str) -> RefreshPresented<'_> {
RefreshPresented {
client_id,
client_auth: ClientAuthResult::Authenticated,
}
}
#[test]
fn rotates_valid_token() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: false,
expires_at: future(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c1"), now()),
RefreshOutcome::Rotate
);
}
#[test]
fn rotated_token_is_reuse() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: true,
expires_at: future(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c1"), now()),
RefreshOutcome::ReuseDetected,
);
}
#[test]
fn reuse_detected_even_when_also_revoked() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: true,
rotated: true,
expires_at: future(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c1"), now()),
RefreshOutcome::ReuseDetected,
);
}
#[test]
fn reuse_detected_even_when_also_expired() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: true,
expires_at: past(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c1"), now()),
RefreshOutcome::ReuseDetected,
);
}
#[test]
fn revoked_token_denied() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: true,
rotated: false,
expires_at: future(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c1"), now()),
RefreshOutcome::Denied(RefreshDenied::Revoked),
);
}
#[test]
fn expired_token_denied() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: false,
expires_at: past(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c1"), now()),
RefreshOutcome::Denied(RefreshDenied::Expired),
);
}
#[test]
fn failed_client_auth_denied() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: false,
expires_at: future(),
};
let presented = RefreshPresented {
client_id: "c1",
client_auth: ClientAuthResult::Failed,
};
assert_eq!(
evaluate_refresh(&stored, &presented, now()),
RefreshOutcome::Denied(RefreshDenied::ClientAuthenticationFailed),
);
}
#[test]
fn client_mismatch_denied() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: false,
expires_at: future(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c2"), now()),
RefreshOutcome::Denied(RefreshDenied::ClientMismatch),
);
}
#[test]
fn client_mismatch_wins_over_reuse_for_rotated_token() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: true,
expires_at: future(),
};
assert_eq!(
evaluate_refresh(&stored, &authed("c2"), now()),
RefreshOutcome::Denied(RefreshDenied::ClientMismatch),
);
}
#[test]
fn failed_auth_wins_over_reuse_for_rotated_token() {
let stored = StoredRefreshToken {
client_id: "c1",
revoked: false,
rotated: true,
expires_at: future(),
};
let presented = RefreshPresented {
client_id: "c1",
client_auth: ClientAuthResult::Failed,
};
assert_eq!(
evaluate_refresh(&stored, &presented, now()),
RefreshOutcome::Denied(RefreshDenied::ClientAuthenticationFailed),
);
}
#[test]
fn denied_reason_strings() {
assert!(RefreshDenied::Revoked.as_str().contains("revoked"));
assert!(RefreshDenied::Expired.as_str().contains("expired"));
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(RefreshDenied::Revoked);
assert!(err.to_string().contains("revoked"));
}
}