use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tracing;
use crate::error::{PepError, Result};
use crate::oidc_client::{OidcClient, TokenResponse};
use crate::token_provider::{compute_expires_at_from_jwt, seconds_until_expiry};
use crate::token_store::StoredToken;
#[derive(Clone, Debug)]
struct SessionEntry {
token: StoredToken,
last_accessed: Instant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredSession {
pub token: StoredToken,
pub last_accessed_epoch: u64,
}
pub trait SessionStore: Send + Sync {
fn load_session(&self, session_id: &str) -> Result<Option<StoredSession>>;
fn save_session(&self, session_id: &str, session: &StoredSession) -> Result<()>;
fn delete_session(&self, session_id: &str) -> Result<()>;
}
#[derive(Debug, Clone)]
pub struct FileSessionStore {
dir: PathBuf,
}
impl FileSessionStore {
pub fn new(dir: impl Into<PathBuf>) -> Result<Self> {
let dir = dir.into();
std::fs::create_dir_all(&dir)?;
Ok(Self { dir })
}
fn path_for(&self, session_id: &str) -> PathBuf {
let safe: String = session_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' })
.collect();
self.dir.join(format!("{}.json", safe))
}
}
impl SessionStore for FileSessionStore {
fn load_session(&self, session_id: &str) -> Result<Option<StoredSession>> {
let path = self.path_for(session_id);
match std::fs::read(&path) {
Ok(bytes) => {
let s = serde_json::from_slice::<StoredSession>(&bytes)
.map_err(|e| PepError::Internal(anyhow::anyhow!("corrupt session file {:?}: {}", path, e)))?;
Ok(Some(s))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(PepError::Internal(anyhow::anyhow!("failed to read session file {:?}: {}", path, e))),
}
}
fn save_session(&self, session_id: &str, session: &StoredSession) -> Result<()> {
let path = self.path_for(session_id);
let tmp = path.with_extension("json.tmp");
let bytes = serde_json::to_vec(session)
.map_err(|e| PepError::Internal(anyhow::anyhow!("serialize session: {}", e)))?;
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
fn delete_session(&self, session_id: &str) -> Result<()> {
let path = self.path_for(session_id);
match std::fs::remove_file(&path) {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(PepError::Internal(anyhow::anyhow!("failed to delete session file {:?}: {}", path, e))),
}
}
}
#[derive(Debug, Default)]
pub struct InMemoryTokenStore {
sessions: RwLock<HashMap<String, StoredToken>>,
}
impl InMemoryTokenStore {
pub fn new() -> Self {
Self::default()
}
}
impl crate::token_store::TokenStore for InMemoryTokenStore {
fn load(&self, name: &str) -> Result<Option<StoredToken>> {
let sessions = self.sessions.read().unwrap();
Ok(sessions.get(name).cloned())
}
fn save(&self, name: &str, token: &StoredToken) -> Result<()> {
let mut sessions = self.sessions.write().unwrap();
sessions.insert(name.to_string(), token.clone());
Ok(())
}
fn delete(&self, name: &str) -> Result<()> {
let mut sessions = self.sessions.write().unwrap();
sessions.remove(name);
Ok(())
}
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub struct WebSessionManager {
sessions: Arc<RwLock<HashMap<String, SessionEntry>>>,
store: Option<Arc<dyn SessionStore>>,
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
oidc_client: OidcClient,
issuer_url: String,
client_id: String,
client_secret: Option<String>,
scope: String,
refresh_buffer_secs: u64,
idle_timeout_secs: u64,
sweep_threshold: usize,
}
impl std::fmt::Debug for WebSessionManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebSessionManager")
.field("issuer_url", &self.issuer_url)
.field("client_id", &self.client_id)
.field("scope", &self.scope)
.field("idle_timeout_secs", &self.idle_timeout_secs)
.finish()
}
}
const DEFAULT_REFRESH_BUFFER_SECS: u64 = 120;
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 3600; const DEFAULT_SWEEP_THRESHOLD: usize = 64;
impl WebSessionManager {
pub fn new(
oidc_client: OidcClient,
issuer_url: String,
client_id: String,
client_secret: Option<String>,
scope: String,
) -> Self {
Self {
sessions: Arc::new(RwLock::new(HashMap::new())),
store: None,
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
oidc_client,
issuer_url,
client_id,
client_secret,
scope,
refresh_buffer_secs: DEFAULT_REFRESH_BUFFER_SECS,
idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
sweep_threshold: DEFAULT_SWEEP_THRESHOLD,
}
}
pub fn with_refresh_buffer(mut self, secs: u64) -> Self {
self.refresh_buffer_secs = secs;
self
}
pub fn with_idle_timeout(mut self, secs: u64) -> Self {
self.idle_timeout_secs = secs;
self
}
pub fn with_sweep_threshold(mut self, threshold: usize) -> Self {
self.sweep_threshold = threshold;
self
}
pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
self.store = Some(store);
self
}
fn persist(&self, session_id: &str, entry: &SessionEntry) {
if let Some(ref store) = self.store {
let stored = StoredSession {
token: entry.token.clone(),
last_accessed_epoch: unix_now(),
};
if let Err(e) = store.save_session(session_id, &stored) {
tracing::warn!(session_id = %session_id, error = %e, "session store write failed");
}
}
}
fn unpersist(&self, session_id: &str) {
if let Some(ref store) = self.store {
if let Err(e) = store.delete_session(session_id) {
tracing::warn!(session_id = %session_id, error = %e, "session store delete failed");
}
}
}
fn lookup(&self, session_id: &str) -> Option<SessionEntry> {
if let Some(entry) = self.sessions.read().unwrap().get(session_id) {
return Some(entry.clone());
}
let store = self.store.as_ref()?;
let stored = store.load_session(session_id).ok()??;
let entry = SessionEntry {
token: stored.token,
last_accessed: Instant::now(),
};
self.sessions
.write()
.unwrap()
.insert(session_id.to_string(), entry.clone());
Some(entry)
}
pub async fn create_session(&self, token_response: &TokenResponse) -> Result<String> {
let session_id = uuid::Uuid::new_v4().to_string();
let expires_at =
compute_expires_at_from_jwt(&token_response.access_token, token_response.expires_in);
let stored = StoredToken::new(
&token_response.access_token,
token_response.refresh_token.clone(),
&token_response.token_type,
&expires_at,
token_response.scope.clone(),
);
let entry = SessionEntry {
token: stored,
last_accessed: Instant::now(),
};
{
let mut sessions = self.sessions.write().unwrap();
sessions.insert(session_id.clone(), entry.clone());
}
self.persist(&session_id, &entry);
tracing::debug!(
session_id = %session_id,
expires_at = %expires_at,
"Created web session"
);
Ok(session_id)
}
pub async fn get_token(&self, session_id: &str) -> Result<String> {
self._get_token(session_id, false).await
}
pub async fn force_refresh(&self, session_id: &str) -> Result<String> {
self._get_token(session_id, true).await
}
async fn _get_token(&self, session_id: &str, force_refresh: bool) -> Result<String> {
let stored = {
let mut sessions = self.sessions.write().unwrap();
if sessions.len() > self.sweep_threshold {
self.sweep_idle_sessions(&mut sessions);
}
if !sessions.contains_key(session_id) {
drop(sessions);
let restored = self.lookup(session_id).ok_or_else(|| {
tracing::debug!(session_id = %session_id, "Session not found");
PepError::AuthenticationRequired
})?;
let mut sessions = self.sessions.write().unwrap();
sessions.insert(session_id.to_string(), restored);
let entry = match sessions.get_mut(session_id) {
Some(e) => e,
None => {
tracing::debug!(session_id = %session_id, "Session not found (raced)");
return Err(PepError::AuthenticationRequired);
}
};
entry.last_accessed = Instant::now();
entry.token.clone()
} else {
let entry = match sessions.get_mut(session_id) {
Some(e) => e,
None => {
tracing::debug!(session_id = %session_id, "Session not found");
return Err(PepError::AuthenticationRequired);
}
};
let idle_secs = entry.last_accessed.elapsed().as_secs();
if idle_secs > self.idle_timeout_secs {
tracing::debug!(
session_id = %session_id,
idle_secs = idle_secs,
idle_timeout = self.idle_timeout_secs,
"Session idle-expired"
);
sessions.remove(session_id);
self.unpersist(session_id);
return Err(PepError::AuthenticationRequired);
}
entry.last_accessed = Instant::now();
entry.token.clone()
}
};
let remaining = seconds_until_expiry(&stored.expires_at);
if !force_refresh && remaining > self.refresh_buffer_secs {
return Ok(stored.access_token);
}
tracing::debug!(
session_id = %session_id,
remaining_secs = remaining,
"Token near expiry, attempting refresh"
);
let lock = self.get_refresh_lock(session_id);
let _guard = lock.lock().await;
if !force_refresh {
if let Some(token) = self.try_cached_token(session_id)? {
return Ok(token);
}
}
self.refresh_session(session_id, &stored).await
}
fn get_refresh_lock(&self, session_id: &str) -> Arc<Mutex<()>> {
if let Some(lock) = self.refresh_locks.read().unwrap().get(session_id) {
return lock.clone();
}
let mut locks = self.refresh_locks.write().unwrap();
locks
.entry(session_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
fn try_cached_token(&self, session_id: &str) -> Result<Option<String>> {
let sessions = self.sessions.read().unwrap();
if let Some(entry) = sessions.get(session_id) {
let remaining = seconds_until_expiry(&entry.token.expires_at);
if remaining > self.refresh_buffer_secs {
tracing::debug!(
session_id = %session_id,
remaining_secs = remaining,
"Token already refreshed by concurrent request"
);
return Ok(Some(entry.token.access_token.clone()));
}
}
Ok(None)
}
pub fn destroy_session(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.write().unwrap();
sessions.remove(session_id);
self.unpersist(session_id);
tracing::debug!(session_id = %session_id, "Session destroyed");
Ok(())
}
pub fn session_count(&self) -> usize {
self.sessions.read().unwrap().len()
}
async fn refresh_session(
&self,
session_id: &str,
stored: &StoredToken,
) -> Result<String> {
let refresh_token = match &stored.refresh_token {
Some(rt) => rt.clone(),
None => {
tracing::debug!(session_id = %session_id, "No refresh token, cannot refresh");
let mut sessions = self.sessions.write().unwrap();
sessions.remove(session_id);
return Err(PepError::AuthenticationRequired);
}
};
let response = self
.oidc_client
.refresh_access_token(
&self.issuer_url,
&self.client_id,
self.client_secret.as_deref(),
&refresh_token,
Some(&self.scope),
)
.await;
match response {
Ok(token_response) => {
let new_expires_at = compute_expires_at_from_jwt(
&token_response.access_token,
token_response.expires_in,
);
let updated = StoredToken::new(
&token_response.access_token,
token_response
.refresh_token
.clone()
.or(Some(refresh_token)),
&token_response.token_type,
&new_expires_at,
token_response.scope.clone().or(stored.scope.clone()),
);
let access_token = updated.access_token.clone();
let mut sessions = self.sessions.write().unwrap();
if let Some(entry) = sessions.get_mut(session_id) {
entry.token = updated;
self.persist(session_id, entry);
}
tracing::info!(
session_id = %session_id,
expires_at = %new_expires_at,
"Token refreshed successfully"
);
Ok(access_token)
}
Err(PepError::TokenRefreshFailed { status, detail }) => {
tracing::warn!(
session_id = %session_id,
status = status,
"Token refresh failed: {}", detail
);
let mut sessions = self.sessions.write().unwrap();
sessions.remove(session_id);
Err(PepError::AuthenticationRequired)
}
Err(e) => {
tracing::warn!(
session_id = %session_id,
"Token refresh error: {}", e
);
Err(e)
}
}
}
fn sweep_idle_sessions(&self, sessions: &mut HashMap<String, SessionEntry>) {
let before = sessions.len();
let timeout = std::time::Duration::from_secs(self.idle_timeout_secs);
let expired_ids: Vec<String> = sessions
.iter()
.filter(|(_, entry)| entry.last_accessed.elapsed() >= timeout)
.map(|(id, _)| id.clone())
.collect();
sessions.retain(|_, entry| entry.last_accessed.elapsed() < timeout);
let swept = before - sessions.len();
if swept > 0 {
for id in &expired_ids {
self.unpersist(id);
}
tracing::info!(
swept = swept,
remaining = sessions.len(),
"Idle session sweep complete"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::token_store::TokenStore;
#[test]
fn test_in_memory_store_round_trip() {
let store = InMemoryTokenStore::new();
let token = StoredToken::new(
"access123",
Some("refresh456".to_string()),
"Bearer",
"2099-01-01T00:00:00Z",
Some("openid profile".to_string()),
);
store.save("session1", &token).unwrap();
let loaded = store.load("session1").unwrap().expect("should exist");
assert_eq!(loaded.access_token, "access123");
assert_eq!(loaded.refresh_token.as_deref(), Some("refresh456"));
}
#[test]
fn test_in_memory_store_delete() {
let store = InMemoryTokenStore::new();
let token = StoredToken::new("a", None, "Bearer", "2099-01-01T00:00:00Z", None);
store.save("temp", &token).unwrap();
assert!(store.load("temp").unwrap().is_some());
store.delete("temp").unwrap();
assert!(store.load("temp").unwrap().is_none());
}
#[test]
fn test_in_memory_store_load_nonexistent() {
let store = InMemoryTokenStore::new();
assert!(store.load("ghost").unwrap().is_none());
}
#[test]
fn test_in_memory_store_overwrite() {
let store = InMemoryTokenStore::new();
let token1 = StoredToken::new("first", None, "Bearer", "2099-01-01T00:00:00Z", None);
store.save("key", &token1).unwrap();
let token2 = StoredToken::new("second", None, "Bearer", "2099-01-01T00:00:00Z", None);
store.save("key", &token2).unwrap();
let loaded = store.load("key").unwrap().unwrap();
assert_eq!(loaded.access_token, "second");
}
fn make_test_mgr() -> WebSessionManager {
WebSessionManager::new(
OidcClient::new(),
"https://idm.example.com/oauth2/openid/pdt-api".to_string(),
"pdt-api".to_string(),
None,
"openid profile".to_string(),
)
}
fn make_token_response(access: &str, refresh: Option<&str>) -> TokenResponse {
TokenResponse {
access_token: access.to_string(),
token_type: "Bearer".to_string(),
expires_in: Some(900),
refresh_token: refresh.map(|s| s.to_string()),
id_token: None,
scope: Some("openid profile".to_string()),
}
}
#[test]
fn test_create_session_stores_token() {
let mgr = make_test_mgr();
let rt = tokio::runtime::Runtime::new().unwrap();
let session_id = rt
.block_on(mgr.create_session(&make_token_response("access-abc", Some("refresh-xyz"))))
.unwrap();
assert!(!session_id.is_empty());
assert!(uuid::Uuid::parse_str(&session_id).is_ok());
}
#[test]
fn test_create_two_sessions_have_different_ids() {
let mgr = make_test_mgr();
let rt = tokio::runtime::Runtime::new().unwrap();
let id1 = rt.block_on(mgr.create_session(&make_token_response("a", None))).unwrap();
let id2 = rt.block_on(mgr.create_session(&make_token_response("b", None))).unwrap();
assert_ne!(id1, id2);
}
#[test]
fn test_get_token_valid_returns_access_token() {
let mgr = make_test_mgr();
let rt = tokio::runtime::Runtime::new().unwrap();
let session_id = rt
.block_on(mgr.create_session(&make_token_response("my-access-token", Some("my-refresh"))))
.unwrap();
let token = rt.block_on(mgr.get_token(&session_id)).unwrap();
assert_eq!(token, "my-access-token");
}
#[test]
fn test_get_token_unknown_session_errors() {
let mgr = make_test_mgr();
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(mgr.get_token("nonexistent-session"));
assert!(matches!(result, Err(PepError::AuthenticationRequired)));
}
#[test]
fn test_destroy_session() {
let mgr = make_test_mgr();
let rt = tokio::runtime::Runtime::new().unwrap();
let session_id = rt
.block_on(mgr.create_session(&make_token_response("test-token", None)))
.unwrap();
let token = rt.block_on(mgr.get_token(&session_id)).unwrap();
assert_eq!(token, "test-token");
mgr.destroy_session(&session_id).unwrap();
let result = rt.block_on(mgr.get_token(&session_id));
assert!(matches!(result, Err(PepError::AuthenticationRequired)));
}
#[test]
fn test_get_token_expired_no_refresh_errors() {
let mgr = make_test_mgr();
let rt = tokio::runtime::Runtime::new().unwrap();
let stored = StoredToken::new(
"expired-access",
None,
"Bearer",
"2020-01-01T00:00:00Z",
None,
);
{
let mut sessions = mgr.sessions.write().unwrap();
sessions.insert(
"expired-session".to_string(),
SessionEntry {
token: stored,
last_accessed: Instant::now(),
},
);
}
let result = rt.block_on(mgr.get_token("expired-session"));
assert!(matches!(result, Err(PepError::AuthenticationRequired)));
}
#[test]
fn test_idle_timeout_evicts_session() {
let mgr = make_test_mgr().with_idle_timeout(0);
let rt = tokio::runtime::Runtime::new().unwrap();
let session_id = rt
.block_on(mgr.create_session(&make_token_response("will-expire", None)))
.unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
let result = rt.block_on(mgr.get_token(&session_id));
assert!(matches!(result, Err(PepError::AuthenticationRequired)));
}
#[test]
fn test_idle_timeout_does_not_evict_active_session() {
let mgr = make_test_mgr().with_idle_timeout(3600);
let rt = tokio::runtime::Runtime::new().unwrap();
let session_id = rt
.block_on(mgr.create_session(&make_token_response("active", None)))
.unwrap();
let token = rt.block_on(mgr.get_token(&session_id)).unwrap();
assert_eq!(token, "active");
let token = rt.block_on(mgr.get_token(&session_id)).unwrap();
assert_eq!(token, "active");
}
#[test]
fn test_sweep_removes_idle_sessions() {
let mgr = make_test_mgr()
.with_idle_timeout(0) .with_sweep_threshold(2);
let rt = tokio::runtime::Runtime::new().unwrap();
let id1 = rt.block_on(mgr.create_session(&make_token_response("a", None))).unwrap();
let id2 = rt.block_on(mgr.create_session(&make_token_response("b", None))).unwrap();
let id3 = rt.block_on(mgr.create_session(&make_token_response("c", None))).unwrap();
assert_eq!(mgr.session_count(), 3);
std::thread::sleep(std::time::Duration::from_secs(1));
let result = rt.block_on(mgr.get_token(&id1));
assert!(matches!(result, Err(PepError::AuthenticationRequired)));
assert_eq!(mgr.session_count(), 0);
let _ = (id2, id3);
}
#[test]
fn test_sweep_preserves_active_sessions() {
let mgr = make_test_mgr()
.with_idle_timeout(3600) .with_sweep_threshold(2);
let rt = tokio::runtime::Runtime::new().unwrap();
let id1 = rt.block_on(mgr.create_session(&make_token_response("a", None))).unwrap();
let _id2 = rt.block_on(mgr.create_session(&make_token_response("b", None))).unwrap();
let _id3 = rt.block_on(mgr.create_session(&make_token_response("c", None))).unwrap();
assert_eq!(mgr.session_count(), 3);
let token = rt.block_on(mgr.get_token(&id1)).unwrap();
assert_eq!(token, "a");
assert_eq!(mgr.session_count(), 3); }
#[test]
fn test_session_count() {
let mgr = make_test_mgr();
assert_eq!(mgr.session_count(), 0);
let rt = tokio::runtime::Runtime::new().unwrap();
let _ = rt.block_on(mgr.create_session(&make_token_response("a", None))).unwrap();
let _ = rt.block_on(mgr.create_session(&make_token_response("b", None))).unwrap();
assert_eq!(mgr.session_count(), 2);
mgr.destroy_session(&rt.block_on(mgr.create_session(&make_token_response("c", None))).unwrap()).unwrap();
assert_eq!(mgr.session_count(), 2);
}
#[test]
fn test_session_manager_debug() {
let mgr = make_test_mgr();
let debug_str = format!("{:?}", mgr);
assert!(debug_str.contains("WebSessionManager"));
assert!(debug_str.contains("pdt-api"));
assert!(debug_str.contains("idle_timeout_secs"));
}
#[test]
fn test_get_token_near_expiry_no_refresh() {
let mgr = make_test_mgr();
let stored = StoredToken::new(
"still-valid",
None,
"Bearer",
"2099-01-01T00:00:00Z",
None,
);
{
let mut sessions = mgr.sessions.write().unwrap();
sessions.insert(
"valid-session".to_string(),
SessionEntry {
token: stored,
last_accessed: Instant::now(),
},
);
}
let rt = tokio::runtime::Runtime::new().unwrap();
let token = rt.block_on(mgr.get_token("valid-session")).unwrap();
assert_eq!(token, "still-valid");
}
#[test]
fn test_with_idle_timeout_builder() {
let mgr = make_test_mgr().with_idle_timeout(7200);
assert_eq!(mgr.idle_timeout_secs, 7200);
}
#[test]
fn test_with_sweep_threshold_builder() {
let mgr = make_test_mgr().with_sweep_threshold(128);
assert_eq!(mgr.sweep_threshold, 128);
}
#[test]
fn test_with_refresh_buffer_builder() {
let mgr = make_test_mgr().with_refresh_buffer(120);
assert_eq!(mgr.refresh_buffer_secs, 120);
}
#[test]
fn test_refresh_lock_returns_same_arc() {
let mgr = make_test_mgr();
let lock1 = mgr.get_refresh_lock("session-a");
let lock2 = mgr.get_refresh_lock("session-a");
let lock3 = mgr.get_refresh_lock("session-b");
assert!(Arc::ptr_eq(&lock1, &lock2));
assert!(!Arc::ptr_eq(&lock1, &lock3));
}
use crate::session_manager::FileSessionStore;
fn temp_store_dir() -> PathBuf {
let d = std::env::temp_dir().join(format!("pep_sess_test_{}", uuid::Uuid::new_v4()));
d
}
fn mgr_with_store(dir: &PathBuf) -> WebSessionManager {
let store = FileSessionStore::new(dir).unwrap();
WebSessionManager::new(
OidcClient::new(),
"https://idm.example.test".to_string(),
"test-client".to_string(),
None,
"openid".to_string(),
)
.with_session_store(std::sync::Arc::new(store))
}
fn token_response(exp_in: u64) -> TokenResponse {
TokenResponse {
access_token: format!("fake-access-{}", uuid::Uuid::new_v4()),
refresh_token: Some(format!("fake-refresh-{}", uuid::Uuid::new_v4())),
token_type: "Bearer".to_string(),
expires_in: Some(exp_in),
id_token: None,
scope: Some("openid".to_string()),
}
}
#[tokio::test]
async fn file_store_roundtrip() {
let dir = temp_store_dir();
let store = FileSessionStore::new(&dir).unwrap();
let sess = StoredSession {
token: StoredToken::new("at", Some("rt".to_string()), "Bearer", "2099-01-01T00:00:00Z", None),
last_accessed_epoch: 42,
};
store.save_session("abc-123", &sess).unwrap();
let loaded = store.load_session("abc-123").unwrap().unwrap();
assert_eq!(loaded.token.access_token, "at");
assert_eq!(loaded.last_accessed_epoch, 42);
store.delete_session("abc-123").unwrap();
assert!(store.load_session("abc-123").unwrap().is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn session_survives_process_restart() {
let dir = temp_store_dir();
let sid = {
let mgr = mgr_with_store(&dir);
let sid = mgr.create_session(&token_response(3600)).await.unwrap();
mgr.get_token(&sid).await.unwrap();
sid
};
let mgr2 = mgr_with_store(&dir);
let tok = mgr2.get_token(&sid).await
.expect("session must survive restart via file store");
assert!(tok.starts_with("fake-access-"));
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn session_visible_across_replicas() {
let dir = temp_store_dir();
let replica_a = mgr_with_store(&dir);
let sid = replica_a.create_session(&token_response(3600)).await.unwrap();
let replica_b = mgr_with_store(&dir);
let tok = replica_b.get_token(&sid).await
.expect("replica B must resolve A's session through shared store");
assert!(tok.starts_with("fake-access-"));
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn destroy_session_removes_persisted_file() {
let dir = temp_store_dir();
let mgr = mgr_with_store(&dir);
let sid = mgr.create_session(&token_response(3600)).await.unwrap();
assert!(mgr.get_token(&sid).await.is_ok());
mgr.destroy_session(&sid).unwrap();
let mgr2 = mgr_with_store(&dir);
assert!(mgr2.get_token(&sid).await.is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn file_store_sanitizes_unsafe_ids() {
let dir = temp_store_dir();
let store = FileSessionStore::new(&dir).unwrap();
store.save_session("../../etc/passwd", &StoredSession {
token: StoredToken::new("at", None, "Bearer", "2099-01-01T00:00:00Z", None),
last_accessed_epoch: 1,
}).unwrap();
let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
assert_eq!(entries.len(), 1);
let name = entries[0].file_name().to_string_lossy().to_string();
assert!(name.starts_with(".._.._") || !name.contains('/'),
"file must be sanitized inside the store dir, got {:?}", name);
let loaded = store.load_session("../../etc/passwd").unwrap();
assert!(loaded.is_some(), "deterministic sanitization keeps round-trip working");
assert!(!std::path::Path::new("/etc/passwd.json").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn without_store_stays_in_memory() {
let mgr = WebSessionManager::new(
OidcClient::new(),
"https://idm.example.test".to_string(),
"test-client".to_string(),
None,
"openid".to_string(),
);
let sid = mgr.create_session(&token_response(3600)).await.unwrap();
assert!(mgr.get_token(&sid).await.is_ok());
let mgr2 = WebSessionManager::new(
OidcClient::new(),
"https://idm.example.test".to_string(),
"test-client".to_string(),
None,
"openid".to_string(),
);
assert!(mgr2.get_token(&sid).await.is_err());
}
}