fraiseql-auth 2.9.0

Authentication, authorization, and session management for FraiseQL
Documentation
//! Session management — trait definition and helper functions.
#[cfg(test)]
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::error::{AuthError, Result};

/// Session data stored in the backend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
    /// User ID (unique per user)
    pub user_id:            String,
    /// Session issued timestamp (Unix seconds)
    pub issued_at:          u64,
    /// Session expiration timestamp (Unix seconds)
    pub expires_at:         u64,
    /// Hash of the refresh token (stored securely)
    pub refresh_token_hash: String,
}

impl SessionData {
    /// Check if session is expired.
    ///
    /// Fail-closed: if the system clock cannot be read, the session is treated as
    /// expired (rejected) rather than valid — matching the crate's fail-closed rule.
    #[must_use]
    pub fn is_expired(&self) -> bool {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .ok()
            .map(|d| d.as_secs());
        self.is_expired_at(now)
    }

    /// Pure expiry decision against an optional clock reading. `None` (clock failure)
    /// fails closed → expired.
    #[must_use]
    fn is_expired_at(&self, now: Option<u64>) -> bool {
        now.is_none_or(|now| self.expires_at <= now)
    }
}

/// Token pair returned after successful authentication
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenPair {
    /// JWT access token (short-lived, typically 15 min - 1 hour)
    pub access_token:  String,
    /// Refresh token (long-lived, typically 7-30 days)
    pub refresh_token: String,
    /// Time in seconds until access token expires
    pub expires_in:    u64,
}

/// SessionStore trait - implement this for your storage backend
///
/// # Examples
///
/// Implement for PostgreSQL:
/// ```no_run
/// // Requires: sqlx PgPool and live PostgreSQL connection.
/// use async_trait::async_trait;
/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
/// use fraiseql_auth::error::Result;
///
/// pub struct PostgresSessionStore {
///     // pool: sqlx::PgPool,
/// }
///
/// #[async_trait]
/// impl SessionStore for PostgresSessionStore {
///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
///         panic!("example stub")
///     }
///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
///         panic!("example stub")
///     }
///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
///         panic!("example stub")
///     }
///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
///         panic!("example stub")
///     }
/// }
/// ```
///
/// Implement for Redis:
/// ```no_run
/// // Requires: redis crate and live Redis connection.
/// use async_trait::async_trait;
/// use fraiseql_auth::session::{SessionStore, SessionData, TokenPair};
/// use fraiseql_auth::error::Result;
///
/// pub struct RedisSessionStore {
///     // client: redis::Client,
/// }
///
/// #[async_trait]
/// impl SessionStore for RedisSessionStore {
///     async fn create_session(&self, _user_id: &str, _expires_at: u64) -> Result<TokenPair> {
///         panic!("example stub")
///     }
///     async fn get_session(&self, _refresh_token_hash: &str) -> Result<SessionData> {
///         panic!("example stub")
///     }
///     async fn revoke_session(&self, _refresh_token_hash: &str) -> Result<()> {
///         panic!("example stub")
///     }
///     async fn revoke_all_sessions(&self, _user_id: &str) -> Result<()> {
///         panic!("example stub")
///     }
/// }
/// ```
// Reason: used as dyn Trait (Arc<dyn SessionStore>); async_trait ensures Send bounds and
// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
#[async_trait]
pub trait SessionStore: Send + Sync {
    /// Create a new session and return token pair
    ///
    /// # Arguments
    /// * `user_id` - The user identifier
    /// * `expires_at` - When the session should expire (Unix seconds)
    ///
    /// # Returns
    /// TokenPair with access_token and refresh_token
    ///
    /// # Errors
    /// Returns error if session creation fails
    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair>;

    /// Get session data by refresh token hash
    ///
    /// # Arguments
    /// * `refresh_token_hash` - Hash of the refresh token
    ///
    /// # Returns
    /// SessionData if session exists and is not revoked
    ///
    /// # Errors
    /// Returns SessionError if session not found or revoked
    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData>;

    /// Revoke a single session
    ///
    /// # Arguments
    /// * `refresh_token_hash` - Hash of the refresh token to revoke
    ///
    /// # Errors
    /// Returns error if revocation fails
    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()>;

    /// Revoke all sessions for a user
    ///
    /// # Arguments
    /// * `user_id` - The user identifier
    ///
    /// # Errors
    /// Returns error if revocation fails
    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()>;
}

/// Return the current Unix timestamp in seconds, or an error if the system clock
/// is broken (e.g., set before the Unix epoch).
///
/// Using saturating / `unwrap_or_default` is explicitly avoided here: a broken clock
/// would silently produce `issued_at = 0` (far in the past) or `expires_in = max`,
/// which is a fail-open security defect.  Callers must handle this error.
///
/// # Errors
///
/// Returns [`AuthError::SystemTimeError`] if the system clock is before the Unix epoch.
pub fn unix_now() -> Result<u64> {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .map_err(|e| AuthError::SystemTimeError {
            message: format!("System clock is before Unix epoch: {e}"),
        })
}

/// Compute a SHA-256 hex digest of a refresh token for secure storage.
///
/// Refresh tokens are stored only as their SHA-256 hash so that a database
/// breach cannot be used to replay sessions.  The original token is returned
/// to the client and never persisted.
#[must_use]
pub fn hash_token(token: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(token.as_bytes());
    hex::encode(hasher.finalize())
}

/// Generate a cryptographically secure refresh token.
///
/// Returns 32 random bytes from [`rand::rng`] encoded as standard Base64.
/// The resulting token is 44 characters long and has approximately 256 bits of entropy.
#[must_use]
pub fn generate_refresh_token() -> String {
    use base64::Engine;
    use rand::Rng;
    // SECURITY: rand::rng() is backed by OS-level entropy for refresh tokens.
    let random_bytes: Vec<u8> = (0..32).map(|_| rand::rng().random()).collect();
    base64::engine::general_purpose::STANDARD.encode(&random_bytes)
}

/// In-memory session store for testing
#[cfg(test)]
pub struct InMemorySessionStore {
    sessions: Arc<dashmap::DashMap<String, SessionData>>,
}

#[cfg(test)]
impl InMemorySessionStore {
    /// Create a new in-memory session store
    #[must_use]
    pub fn new() -> Self {
        Self {
            sessions: Arc::new(dashmap::DashMap::new()),
        }
    }

    /// Clear all sessions (useful for tests)
    pub fn clear(&self) {
        self.sessions.clear();
    }

    /// Get number of sessions (useful for tests)
    #[must_use]
    pub fn len(&self) -> usize {
        self.sessions.len()
    }

    /// Check if there are no sessions
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.sessions.is_empty()
    }
}

#[cfg(test)]
impl Default for InMemorySessionStore {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
// Reason: SessionStore is defined with #[async_trait]; all implementations must match
// its transformed method signatures to satisfy the trait contract
// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
#[async_trait]
impl SessionStore for InMemorySessionStore {
    async fn create_session(&self, user_id: &str, expires_at: u64) -> Result<TokenPair> {
        let refresh_token = generate_refresh_token();
        let refresh_token_hash = hash_token(&refresh_token);

        // SECURITY: Propagate clock errors rather than accepting a bogus timestamp
        // (fail-closed). A broken clock would produce issued_at = 0, silently
        // extending sessions and defeating expiry checks.
        let now = unix_now()?;

        let session = SessionData {
            user_id: user_id.to_string(),
            issued_at: now,
            expires_at,
            refresh_token_hash: refresh_token_hash.clone(),
        };

        self.sessions.insert(refresh_token_hash, session);

        let expires_in = expires_at.saturating_sub(now);

        // For testing, generate a dummy JWT (in real impl, would come from claims)
        let access_token = format!("access_token_{}", refresh_token);

        Ok(TokenPair {
            access_token,
            refresh_token,
            expires_in,
        })
    }

    async fn get_session(&self, refresh_token_hash: &str) -> Result<SessionData> {
        self.sessions
            .get(refresh_token_hash)
            .map(|entry| entry.clone())
            .ok_or(AuthError::TokenNotFound)
    }

    async fn revoke_session(&self, refresh_token_hash: &str) -> Result<()> {
        self.sessions.remove(refresh_token_hash).ok_or(AuthError::SessionError {
            message: "Session not found".to_string(),
        })?;
        Ok(())
    }

    async fn revoke_all_sessions(&self, user_id: &str) -> Result<()> {
        let mut to_remove = Vec::new();
        for entry in self.sessions.iter() {
            if entry.user_id == user_id {
                to_remove.push(entry.key().clone());
            }
        }

        for key in to_remove {
            self.sessions.remove(&key);
        }

        Ok(())
    }
}

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

    fn session(expires_at: u64) -> SessionData {
        SessionData {
            user_id: "u".to_string(),
            issued_at: 0,
            expires_at,
            refresh_token_hash: "h".to_string(),
        }
    }

    #[test]
    fn clock_failure_treats_session_as_expired() {
        // L-clock-failopen: a None clock reading must fail closed → expired.
        assert!(session(u64::MAX).is_expired_at(None), "clock failure must fail closed");
    }

    #[test]
    fn valid_clock_decides_normally() {
        assert!(session(100).is_expired_at(Some(100)), "expires_at == now → expired");
        assert!(session(100).is_expired_at(Some(101)), "past expiry → expired");
        assert!(!session(100).is_expired_at(Some(99)), "before expiry → valid");
    }
}