use std::collections::HashMap;
use std::sync::Mutex;
use chrono::{Duration, Utc};
pub type Result<T> = std::result::Result<T, SessionStoreError>;
#[derive(Debug, thiserror::Error)]
pub enum SessionStoreError {
#[error("session store lock poisoned")]
Poisoned,
}
#[derive(Debug, Clone)]
struct Entry {
session_id: String,
expires_at: chrono::DateTime<Utc>,
}
pub trait SessionStore: Send + Sync {
fn bind_state(&self, state_token: &str, session_id: &str) -> Result<()>;
fn verify_state(&self, state_token: &str, session_id: &str) -> Result<bool>;
fn revoke_state(&self, state_token: &str) -> Result<()>;
}
#[derive(Debug)]
pub struct InMemorySessionStore {
inner: Mutex<HashMap<String, Entry>>,
ttl: Duration,
}
impl InMemorySessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_ttl(ttl: Duration) -> Self {
Self { inner: Mutex::new(HashMap::new()), ttl }
}
fn evict_expired(map: &mut HashMap<String, Entry>) {
let now = Utc::now();
map.retain(|_, entry| entry.expires_at > now);
}
}
impl Default for InMemorySessionStore {
fn default() -> Self {
Self::with_ttl(Duration::minutes(15))
}
}
impl SessionStore for InMemorySessionStore {
fn bind_state(&self, state_token: &str, session_id: &str) -> Result<()> {
let mut map = self.inner.lock().map_err(|_| SessionStoreError::Poisoned)?;
Self::evict_expired(&mut map);
map.insert(
state_token.to_owned(),
Entry { session_id: session_id.to_owned(), expires_at: Utc::now() + self.ttl },
);
Ok(())
}
fn verify_state(&self, state_token: &str, session_id: &str) -> Result<bool> {
let mut map = self.inner.lock().map_err(|_| SessionStoreError::Poisoned)?;
Self::evict_expired(&mut map);
Ok(map.get(state_token).map(|entry| entry.session_id == session_id).unwrap_or(false))
}
fn revoke_state(&self, state_token: &str) -> Result<()> {
let mut map = self.inner.lock().map_err(|_| SessionStoreError::Poisoned)?;
map.remove(state_token);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn expired_store() -> InMemorySessionStore {
InMemorySessionStore::with_ttl(Duration::seconds(-1))
}
#[test]
fn bind_and_verify_state_succeeds() {
let store = InMemorySessionStore::new();
store.bind_state("state-1", "session-1").unwrap();
assert!(store.verify_state("state-1", "session-1").unwrap());
}
#[test]
fn verify_wrong_session_fails() {
let store = InMemorySessionStore::new();
store.bind_state("state-1", "session-1").unwrap();
assert!(!store.verify_state("state-1", "session-2").unwrap());
}
#[test]
fn verify_missing_state_fails() {
let store = InMemorySessionStore::new();
assert!(!store.verify_state("missing-state", "session-1").unwrap());
}
#[test]
fn revoke_state_removes_binding() {
let store = InMemorySessionStore::new();
store.bind_state("state-1", "session-1").unwrap();
store.revoke_state("state-1").unwrap();
assert!(!store.verify_state("state-1", "session-1").unwrap());
}
#[test]
fn expired_state_is_rejected() {
let store = expired_store();
store.bind_state("state-1", "session-1").unwrap();
assert!(!store.verify_state("state-1", "session-1").unwrap());
}
#[test]
fn rebinding_state_overwrites_previous_session() {
let store = InMemorySessionStore::new();
store.bind_state("state-1", "session-1").unwrap();
store.bind_state("state-1", "session-2").unwrap();
assert!(!store.verify_state("state-1", "session-1").unwrap());
assert!(store.verify_state("state-1", "session-2").unwrap());
}
}