use {chrono::{DateTime,
Duration,
Utc},
std::collections::HashMap,
tokio::sync::RwLock,
tracing::{debug,
info},
uuid::Uuid};
#[derive(Debug, Clone)]
pub struct Session {
pub identity: String,
pub attributes: HashMap<String, String>,
pub expires_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
}
impl Session {
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_idle(&self, idle_timeout: Duration) -> bool {
Utc::now() > self.last_activity_at + idle_timeout
}
}
pub struct SessionStore {
sessions: RwLock<HashMap<String, Session>>,
session_ttl: Duration,
idle_timeout: Duration,
}
impl SessionStore {
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),
}
}
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
}
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;
}
session.last_activity_at = Utc::now();
Some(session.clone())
} else {
None
}
}
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
}
}
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");
}
}
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() {
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); let _token1 = store.create("user1".to_string(), HashMap::new()).await;
let _token2 = store.create("user2".to_string(), HashMap::new()).await;
store.cleanup_expired().await;
let sessions = store.sessions.read().await;
assert!(sessions.is_empty());
}
}