entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! OAuth state parameter for CSRF protection.
//!
//! The state parameter is a cryptographically random value included in the
//! authorization URL and verified when the callback is received. This
//! prevents cross-site request forgery attacks where an attacker tricks a
//! user into completing an OAuth flow that binds the attacker's account.
//!
//! # Security
//!
//! SECURITY: State verification uses constant-time comparison via
//! [`constant_time_eq`] to prevent timing side-channel attacks. An
//! attacker who can measure response times must not be able to
//! iteratively guess the state value byte by byte.
//!
//! [`constant_time_eq`]: crate::crypto::constant_time::constant_time_eq

use std::fmt;

use crate::crypto::constant_time::constant_time_eq;
use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{RandomError, fill_random};
use crate::encoding::base64url_encode;
use crate::util::log::debug;

// ---------------------------------------------------------------------------
// OAuthState
// ---------------------------------------------------------------------------

/// A cryptographically random state parameter for CSRF protection.
///
/// Generated via [`OAuthState::generate`] and included in the
/// authorization URL. After the callback, use [`OAuthState::verify`]
/// to confirm the returned state matches.
#[doc(alias = "csrf_state")]
pub struct OAuthState {
    // SECURITY: State is a CSRF token — wrap in Zeroizing to clear on drop.
    value: Zeroizing<String>,
}

impl OAuthState {
    /// Generates a new random state parameter.
    ///
    /// The state is 32 random bytes base64url-encoded (43 characters),
    /// providing 256 bits of entropy.
    ///
    /// # Errors
    ///
    /// Returns [`RandomError`] if the platform CSPRNG is unavailable.
    pub fn generate() -> Result<Self, RandomError> {
        let mut buf = [0u8; 32];
        fill_random(&mut buf)?;
        let value = base64url_encode(&buf);
        // SECURITY: Zeroize the raw random bytes after encoding.
        crate::crypto::zeroize::zeroize(&mut buf);

        // SECURITY: Never log the state value — it is a CSRF token.
        debug!("oauth: state parameter generated");

        // SECURITY: Wrap state value in Zeroizing immediately.
        Ok(Self {
            value: Zeroizing::new(value),
        })
    }

    /// Returns the state parameter value.
    ///
    /// This value is included in the authorization URL and should be
    /// stored (e.g. in a session or cookie) for later verification.
    #[must_use]
    #[inline]
    pub fn value(&self) -> &str {
        &self.value
    }

    /// Verifies that the received state matches this state parameter.
    ///
    /// # Security
    ///
    /// SECURITY: Uses constant-time comparison to prevent timing
    /// side-channel attacks on the CSRF token.
    #[must_use]
    #[inline]
    pub fn verify(&self, received: &str) -> bool {
        constant_time_eq(self.value.as_bytes(), received.as_bytes())
    }
}

impl fmt::Debug for OAuthState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // SECURITY: Redact the CSRF token value to prevent accidental
        // exposure via debug logging or error messages.
        f.debug_struct("OAuthState")
            .field("value", &"[REDACTED]")
            .finish()
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;

    // --- Generation ---

    #[test]
    fn generate_produces_nonempty_value() {
        let state = OAuthState::generate().unwrap();
        assert!(!state.value().is_empty(), "state should not be empty");
    }

    #[test]
    fn generate_produces_base64url_value() {
        let state = OAuthState::generate().unwrap();
        assert!(
            state
                .value()
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
            "state should contain only base64url characters: {}",
            state.value(),
        );
    }

    #[test]
    fn generate_produces_43_char_value() {
        let state = OAuthState::generate().unwrap();
        assert_eq!(
            state.value().len(),
            43,
            "32 bytes base64url-encoded = 43 characters",
        );
    }

    // --- Verification ---

    #[test]
    fn verify_correct_state() {
        let state = OAuthState::generate().unwrap();
        let value = state.value().to_owned();
        assert!(
            state.verify(&value),
            "verify should succeed for matching state"
        );
    }

    #[test]
    fn verify_wrong_state_fails() {
        let state = OAuthState::generate().unwrap();
        assert!(
            !state.verify("wrong-state-value"),
            "verify should fail for non-matching state",
        );
    }

    #[test]
    fn verify_empty_state_fails() {
        let state = OAuthState::generate().unwrap();
        assert!(
            !state.verify(""),
            "verify should fail for empty received state",
        );
    }

    #[test]
    fn verify_similar_state_fails() {
        let state = OAuthState::generate().unwrap();
        // Modify the last character.
        let mut tampered = state.value().to_owned();
        tampered.push('X');
        assert!(
            !state.verify(&tampered),
            "verify should fail for tampered state",
        );
    }

    // --- Debug redaction ---

    #[test]
    fn debug_redacts_state_value() {
        let state = OAuthState::generate().unwrap();
        let debug = format!("{state:?}");
        assert!(
            debug.contains("[REDACTED]"),
            "Debug should redact the state value: {debug}",
        );
        // SECURITY: Verify the actual value is NOT in the debug output.
        assert!(
            !debug.contains(state.value()),
            "Debug must not contain the raw state value: {debug}",
        );
    }

    // --- Uniqueness ---

    #[test]
    fn two_states_differ() {
        let a = OAuthState::generate().unwrap();
        let b = OAuthState::generate().unwrap();
        assert_ne!(a.value(), b.value(), "two generated states should differ");
    }

    // --- Cross-verification ---

    #[test]
    fn different_states_do_not_verify() {
        let a = OAuthState::generate().unwrap();
        let b = OAuthState::generate().unwrap();
        assert!(
            !a.verify(b.value()),
            "state A should not verify state B's value",
        );
    }
}