at-jet 0.7.2

High-performance HTTP + Protobuf API framework for mobile services
Documentation
//! In-memory session store
//!
//! Provides session management with:
//! - Absolute session TTL
//! - Idle timeout
//! - Periodic cleanup of expired sessions
//! - Generic key-value attributes for flexible metadata
//!
//! # Example
//!
//! ```ignore
//! use at_jet::session::SessionStore;
//! use std::collections::HashMap;
//!
//! let store = SessionStore::new(28800, 900); // 8h TTL, 15min idle
//!
//! // Create session
//! let attrs = HashMap::from([("role".into(), "admin".into())]);
//! let token = store.create("user@example.com".to_string(), attrs).await;
//!
//! // Validate session
//! if let Some(session) = store.validate(&token).await {
//!     println!("User: {}", session.identity);
//! }
//! ```

use {chrono::{DateTime,
              Duration,
              Utc},
     std::collections::HashMap,
     tokio::sync::RwLock,
     tracing::{debug,
               info},
     uuid::Uuid};

/// User session data
#[derive(Debug, Clone)]
pub struct Session {
  /// Primary identifier (username, user_id, etc.)
  pub identity:         String,
  /// Flexible key-value metadata
  pub attributes:       HashMap<String, String>,
  /// Absolute expiration time
  pub expires_at:       DateTime<Utc>,
  /// Last activity time (for idle timeout)
  pub last_activity_at: DateTime<Utc>,
}

impl Session {
  /// Check if the session has expired (absolute TTL)
  pub fn is_expired(&self) -> bool {
    Utc::now() > self.expires_at
  }

  /// Check if the session is idle (exceeded idle timeout)
  pub fn is_idle(&self, idle_timeout: Duration) -> bool {
    Utc::now() > self.last_activity_at + idle_timeout
  }
}

/// Thread-safe in-memory session store
pub struct SessionStore {
  sessions:     RwLock<HashMap<String, Session>>,
  session_ttl:  Duration,
  idle_timeout: Duration,
}

impl SessionStore {
  /// Create a new session store with the specified timeouts
  ///
  /// # Arguments
  /// * `session_ttl_secs` - Absolute session timeout in seconds (e.g., 28800 = 8 hours)
  /// * `idle_timeout_secs` - Idle timeout in seconds (e.g., 900 = 15 minutes)
  pub fn new(session_ttl_secs: u64, idle_timeout_secs: u64) -> Self {
    Self {
      sessions:     RwLock::new(HashMap::new()),
      session_ttl:  Duration::seconds(session_ttl_secs as i64),
      idle_timeout: Duration::seconds(idle_timeout_secs as i64),
    }
  }

  /// Create a new session and return the session token
  ///
  /// # Arguments
  /// * `identity` - Primary identifier (username, user_id, etc.)
  /// * `attributes` - Flexible key-value metadata
  pub async fn create(&self, identity: String, attributes: HashMap<String, String>) -> String {
    let token = Uuid::new_v4().to_string();
    let now = Utc::now();

    let session = Session {
      identity: identity.clone(),
      attributes,
      expires_at: now + self.session_ttl,
      last_activity_at: now,
    };

    let mut sessions = self.sessions.write().await;
    sessions.insert(token.clone(), session);

    info!(identity = %identity, "Session created");
    token
  }

  /// Validate a session token and refresh the idle timer
  ///
  /// Returns the session if valid, None otherwise.
  /// Use this for regular API requests.
  pub async fn validate(&self, token: &str) -> Option<Session> {
    let mut sessions = self.sessions.write().await;

    if let Some(session) = sessions.get_mut(token) {
      if session.is_expired() {
        debug!(token = %token, "Session expired (TTL)");
        sessions.remove(token);
        return None;
      }

      if session.is_idle(self.idle_timeout) {
        debug!(token = %token, "Session expired (idle)");
        sessions.remove(token);
        return None;
      }

      // Refresh last activity time
      session.last_activity_at = Utc::now();
      Some(session.clone())
    } else {
      None
    }
  }

  /// Validate a session token without refreshing the idle timer
  ///
  /// Returns the session if valid, None otherwise.
  /// Use this for polling/heartbeat endpoints to prevent idle timer abuse.
  pub async fn validate_without_refresh(&self, token: &str) -> Option<Session> {
    let sessions = self.sessions.read().await;

    if let Some(session) = sessions.get(token) {
      if session.is_expired() {
        return None;
      }

      if session.is_idle(self.idle_timeout) {
        return None;
      }

      Some(session.clone())
    } else {
      None
    }
  }

  /// Invalidate (logout) a session by token
  pub async fn invalidate(&self, token: &str) {
    let mut sessions = self.sessions.write().await;
    if sessions.remove(token).is_some() {
      info!(token = %token, "Session invalidated");
    }
  }

  /// Clean up expired sessions
  ///
  /// Should be called periodically (e.g., every 5 minutes) via a background task.
  pub async fn cleanup_expired(&self) {
    let mut sessions = self.sessions.write().await;
    let initial_count = sessions.len();

    sessions.retain(|_, session| !session.is_expired() && !session.is_idle(self.idle_timeout));

    let removed = initial_count - sessions.len();
    if removed > 0 {
      info!(
        removed = removed,
        remaining = sessions.len(),
        "Cleaned up expired sessions"
      );
    }
  }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
  use super::*;

  #[tokio::test]
  async fn test_create_and_validate_session() {
    let store = SessionStore::new(3600, 900);
    let attrs = HashMap::from([("role".into(), "admin".into())]);
    let token = store.create("testuser".to_string(), attrs).await;

    let session = store.validate(&token).await;
    assert!(session.is_some());
    let session = session.unwrap();
    assert_eq!(session.identity, "testuser");
    assert_eq!(session.attributes.get("role").map(|s| s.as_str()), Some("admin"));
  }

  #[tokio::test]
  async fn test_invalid_token_returns_none() {
    let store = SessionStore::new(3600, 900);
    let session = store.validate("invalid-token").await;
    assert!(session.is_none());
  }

  #[tokio::test]
  async fn test_invalidate_session() {
    let store = SessionStore::new(3600, 900);
    let token = store.create("testuser".to_string(), HashMap::new()).await;

    store.invalidate(&token).await;

    let session = store.validate(&token).await;
    assert!(session.is_none());
  }

  #[tokio::test]
  async fn test_expired_session_returns_none() {
    // Create store with 0 second TTL (immediately expires)
    let store = SessionStore::new(0, 900);
    let token = store.create("testuser".to_string(), HashMap::new()).await;

    let session = store.validate(&token).await;
    assert!(session.is_none());
  }

  #[tokio::test]
  async fn test_validate_without_refresh() {
    let store = SessionStore::new(3600, 900);
    let token = store.create("testuser".to_string(), HashMap::new()).await;

    let session = store.validate_without_refresh(&token).await;
    assert!(session.is_some());
    assert_eq!(session.unwrap().identity, "testuser");
  }

  #[tokio::test]
  async fn test_cleanup_expired() {
    let store = SessionStore::new(0, 900); // 0 TTL = immediate expire
    let _token1 = store.create("user1".to_string(), HashMap::new()).await;
    let _token2 = store.create("user2".to_string(), HashMap::new()).await;

    store.cleanup_expired().await;

    // All sessions should be cleaned up
    let sessions = store.sessions.read().await;
    assert!(sessions.is_empty());
  }
}