entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Refresh-token rotation decision (RFC 6749 §6, §10.4; OAuth 2.1 §6.1).
//!
//! [`evaluate_refresh`] is the storage-free state machine an authorization
//! server runs when a client presents a refresh token. It decides, from the
//! token's stored state alone, whether to **rotate** (issue a new
//! refresh/access pair and retire this one), **deny** (a non-suspicious
//! failure), or treat the presentation as **reuse** of an already-rotated
//! token — the signal of a stolen-token replay.
//!
//! # Rotation with reuse detection — threat model
//!
//! Refresh-token rotation (RFC 6749 §10.4, OAuth 2.1 §6.1) issues a fresh
//! refresh token on every use and invalidates the presented one. Each token
//! is therefore single-use. If a token that has *already been rotated* is
//! presented again, exactly one of two things happened:
//!
//! * the legitimate client used it, then an attacker replayed the same
//!   token, or
//! * an attacker used a stolen token, then the legitimate client replayed
//!   its (now-superseded) copy.
//!
//! The server cannot tell which party is which, so the safe response is to
//! assume compromise: [`RefreshOutcome::ReuseDetected`] tells the caller to
//! **revoke the entire token family** (the rotation chain rooted at the
//! original grant), forcing re-authentication and locking the attacker out.
//! This is the behaviour mandated by OAuth 2.1 §6.1 for non-sender-
//! constrained refresh tokens.
//!
//! A token explicitly revoked (logout, admin action) or past its absolute
//! lifetime is a plain [`RefreshOutcome::Denied`] — no family revocation.

use core::fmt;

use crate::crypto::constant_time::constant_time_eq;
use crate::util::timestamp::Timestamp;

// ---------------------------------------------------------------------------
// Stored state
// ---------------------------------------------------------------------------

/// The persisted state of a refresh token, as the server stored it.
///
/// A borrowed view; the caller owns the row. `rotated` is set when the
/// token has already been exchanged for a successor (the reuse signal).
#[derive(Debug, Clone, Copy)]
pub struct StoredRefreshToken<'a> {
    /// The `client_id` the token was issued to.
    pub client_id: &'a str,
    /// Whether the token has been explicitly revoked (logout, admin).
    pub revoked: bool,
    /// Whether the token has already been rotated (exchanged for a
    /// successor). Presenting a rotated token is reuse.
    pub rotated: bool,
    /// The token's absolute expiry.
    pub expires_at: Timestamp,
}

// ---------------------------------------------------------------------------
// Outcome
// ---------------------------------------------------------------------------

/// Why a refresh-token presentation was denied without family revocation.
#[doc(alias = "refresh_denied")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RefreshDenied {
    /// Client authentication failed (RFC 6749 §5.2 `invalid_client`).
    ClientAuthenticationFailed,
    /// The authenticating client is not the one the token was issued to
    /// (§6 `invalid_grant`).
    ClientMismatch,
    /// The token was explicitly revoked (§7 — logout / admin revocation).
    Revoked,
    /// The token has passed its absolute lifetime (`invalid_grant`).
    Expired,
}

impl RefreshDenied {
    /// A short, non-secret diagnostic string for operator logs.
    #[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 {}

/// The decision produced by [`evaluate_refresh`].
#[doc(alias = "refresh_outcome")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RefreshOutcome {
    /// The token is valid and single-use. The caller MUST atomically retire
    /// this token (mark it rotated) and issue a new refresh/access pair
    /// whose parent is this token.
    Rotate,
    /// The presentation must be rejected with the given reason. No family
    /// revocation is required.
    Denied(RefreshDenied),
    /// An already-rotated token was presented — a replay.
    ///
    /// SECURITY: the caller MUST revoke the entire rotation chain (token
    /// family) this token belongs to (RFC 6749 §10.4 / OAuth 2.1 §6.1).
    ReuseDetected,
}

// ---------------------------------------------------------------------------
// Presented request
// ---------------------------------------------------------------------------

/// The token-request context for a refresh-token grant.
#[derive(Debug, Clone, Copy)]
pub struct RefreshPresented<'a> {
    /// The authenticated `client_id`.
    pub client_id: &'a str,
    /// The outcome of authenticating that client.
    pub client_auth: super::code::ClientAuthResult,
}

// ---------------------------------------------------------------------------
// Evaluation
// ---------------------------------------------------------------------------

/// Decides the outcome of a refresh-token presentation as of `now`.
///
/// Pure and side-effect-free. The reuse check is evaluated **before** the
/// revoked/expired checks: a replayed-after-rotation token is a security
/// event that must trigger family revocation even if the token has since
/// also expired or been revoked.
///
/// Client-binding checks come first: a token presented by the wrong client,
/// or with failed client authentication, is denied without leaking whether
/// the token would otherwise have been reusable.
///
/// # Security — caller must bind `stored` to the presented token
///
/// This function does **not** see the raw refresh token; it reasons only about
/// the `StoredRefreshToken` the caller supplies. The reuse-detection guarantee
/// therefore holds **only if** the caller resolves `stored` from the presented
/// bearer token by a collision-free lookup (e.g. keyed on a hash of the token)
/// and has confirmed — in constant time — that the presented secret matches
/// that row before trusting this verdict. A non-constant-time index, a lookup
/// that fails to load the rotated predecessor row, or a token→row mismatch
/// silently defeats family revocation. The `client_id` compared here is the
/// already-authenticated request client, not a substitute for that binding.
#[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};

    // Client authentication and binding first.
    if presented.client_auth != ClientAuthResult::Authenticated {
        return Denied(D::ClientAuthenticationFailed);
    }
    if !constant_time_eq(stored.client_id.as_bytes(), presented.client_id.as_bytes()) {
        // A rotated token presented by the *wrong* client is intentionally
        // `ClientMismatch` (plain denial), not `ReuseDetected`: we cannot
        // confirm the presenter owns the family, so we do not trigger family
        // revocation on a claim we can't attribute. Reuse detection below runs
        // only once the presenter has authenticated as the bound client.
        return Denied(D::ClientMismatch);
    }

    // SECURITY: reuse detection takes priority over revoked/expired. A
    // rotated token presented again is a replay regardless of its other
    // state, and must trigger family revocation.
    if stored.rotated {
        return ReuseDetected;
    }

    if stored.revoked {
        return Denied(D::Revoked);
    }
    if stored.expires_at.is_expired(&now) {
        return Denied(D::Expired);
    }

    Rotate
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[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() {
        // A rotated AND revoked token: reuse takes priority so the family
        // gets 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() {
        // A *rotated* token presented by the wrong client must report
        // ClientMismatch, NOT ReuseDetected — we cannot attribute the family
        // to a presenter that doesn't own it, so we must not trigger family
        // revocation (which would be an attacker-driven DoS on the real owner).
        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() {
        // Failed client authentication must mask reuse state: an unauthenticated
        // caller learns only that auth failed, never whether the token was a
        // reused (rotated) one.
        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"));
    }
}