1use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use uuid::Uuid;
23
24#[derive(Debug, Error)]
26pub enum SessionStoreError {
27 #[error("session store sink failure: {0}")]
28 Sink(String),
29 #[error("session not found: {0}")]
30 NotFound(Uuid),
31 #[error("session store validation failure: {0}")]
32 Validation(String),
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct SessionRecord {
38 pub jti: Uuid,
39 pub actor_id: String,
40 pub created_at_us: i64,
41 pub expires_at_us: i64,
42 pub revoked_at_us: Option<i64>,
43}
44
45impl SessionRecord {
46 pub fn is_valid_at(&self, now_us: i64) -> bool {
49 self.revoked_at_us.is_none() && self.expires_at_us > now_us
50 }
51}
52
53#[async_trait]
61pub trait SessionStore: Send + Sync {
62 async fn record_session(&self, record: SessionRecord) -> Result<(), SessionStoreError>;
66
67 async fn is_valid(&self, jti: Uuid, now_us: i64) -> Result<bool, SessionStoreError>;
70
71 async fn revoke(&self, jti: Uuid, now_us: i64) -> Result<(), SessionStoreError>;
75
76 async fn revoke_all_for_actor(
79 &self,
80 actor_id: &str,
81 now_us: i64,
82 ) -> Result<usize, SessionStoreError>;
83
84 async fn prune_expired(&self, now_us: i64) -> Result<usize, SessionStoreError>;
88}
89
90#[cfg(any(test, feature = "test-utils"))]
96mod in_memory {
97 use super::*;
98 use std::collections::HashMap;
99 use std::sync::Arc;
100 use tokio::sync::Mutex;
101
102 #[derive(Clone, Default)]
103 pub struct InMemorySessionStore {
104 inner: Arc<Mutex<HashMap<Uuid, SessionRecord>>>,
105 }
106
107 impl InMemorySessionStore {
108 pub fn new() -> Self {
109 Self::default()
110 }
111 }
112
113 #[async_trait]
114 impl SessionStore for InMemorySessionStore {
115 async fn record_session(&self, record: SessionRecord) -> Result<(), SessionStoreError> {
116 if record.actor_id.trim().is_empty() {
117 return Err(SessionStoreError::Validation("actor_id empty".into()));
118 }
119 if record.expires_at_us <= record.created_at_us {
120 return Err(SessionStoreError::Validation(
121 "expires_at_us must be > created_at_us".into(),
122 ));
123 }
124 self.inner.lock().await.insert(record.jti, record);
125 Ok(())
126 }
127
128 async fn is_valid(&self, jti: Uuid, now_us: i64) -> Result<bool, SessionStoreError> {
129 let g = self.inner.lock().await;
130 Ok(g.get(&jti).map(|r| r.is_valid_at(now_us)).unwrap_or(false))
131 }
132
133 async fn revoke(&self, jti: Uuid, now_us: i64) -> Result<(), SessionStoreError> {
134 let mut g = self.inner.lock().await;
135 match g.get_mut(&jti) {
136 Some(r) => {
137 r.revoked_at_us = Some(now_us);
138 Ok(())
139 }
140 None => Err(SessionStoreError::NotFound(jti)),
141 }
142 }
143
144 async fn revoke_all_for_actor(
145 &self,
146 actor_id: &str,
147 now_us: i64,
148 ) -> Result<usize, SessionStoreError> {
149 let mut g = self.inner.lock().await;
150 let mut n = 0;
151 for r in g.values_mut() {
152 if r.actor_id == actor_id && r.revoked_at_us.is_none() {
153 r.revoked_at_us = Some(now_us);
154 n += 1;
155 }
156 }
157 Ok(n)
158 }
159
160 async fn prune_expired(&self, now_us: i64) -> Result<usize, SessionStoreError> {
161 let mut g = self.inner.lock().await;
162 let before = g.len();
163 g.retain(|_, r| r.expires_at_us > now_us);
164 Ok(before - g.len())
165 }
166 }
167}
168
169#[cfg(any(test, feature = "test-utils"))]
170pub use in_memory::InMemorySessionStore;
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn rec(jti: Uuid, actor: &str, ttl_us: i64) -> SessionRecord {
177 let now = 1_700_000_000_000_000;
178 SessionRecord {
179 jti,
180 actor_id: actor.to_string(),
181 created_at_us: now,
182 expires_at_us: now + ttl_us,
183 revoked_at_us: None,
184 }
185 }
186
187 #[tokio::test]
188 async fn test_record_then_is_valid_true() {
189 let s = InMemorySessionStore::new();
190 let id = Uuid::new_v4();
191 s.record_session(rec(id, "alice", 1_000_000)).await.unwrap();
192 assert!(s.is_valid(id, 1_700_000_000_000_001).await.unwrap());
193 }
194
195 #[tokio::test]
196 async fn test_unknown_jti_is_invalid() {
197 let s = InMemorySessionStore::new();
198 assert!(!s.is_valid(Uuid::new_v4(), 0).await.unwrap());
199 }
200
201 #[tokio::test]
202 async fn test_revoke_makes_invalid() {
203 let s = InMemorySessionStore::new();
204 let id = Uuid::new_v4();
205 s.record_session(rec(id, "alice", 1_000_000)).await.unwrap();
206 s.revoke(id, 1_700_000_000_000_500).await.unwrap();
207 assert!(!s.is_valid(id, 1_700_000_000_000_600).await.unwrap());
208 }
209
210 #[tokio::test]
211 async fn test_revoke_unknown_returns_not_found() {
212 let s = InMemorySessionStore::new();
213 let id = Uuid::new_v4();
214 let err = s.revoke(id, 0).await.unwrap_err();
215 assert!(matches!(err, SessionStoreError::NotFound(j) if j == id));
216 }
217
218 #[tokio::test]
219 async fn test_expired_session_is_invalid_without_revoke() {
220 let s = InMemorySessionStore::new();
221 let id = Uuid::new_v4();
222 let now = 1_700_000_000_000_000;
223 let r = SessionRecord {
224 jti: id,
225 actor_id: "alice".into(),
226 created_at_us: now,
227 expires_at_us: now + 100,
228 revoked_at_us: None,
229 };
230 s.record_session(r).await.unwrap();
231 assert!(s.is_valid(id, now + 50).await.unwrap());
232 assert!(!s.is_valid(id, now + 200).await.unwrap());
233 }
234
235 #[tokio::test]
236 async fn test_revoke_all_for_actor_only_targets_that_actor() {
237 let s = InMemorySessionStore::new();
238 let alice1 = Uuid::new_v4();
239 let alice2 = Uuid::new_v4();
240 let bob = Uuid::new_v4();
241 s.record_session(rec(alice1, "alice", 1_000_000))
242 .await
243 .unwrap();
244 s.record_session(rec(alice2, "alice", 1_000_000))
245 .await
246 .unwrap();
247 s.record_session(rec(bob, "bob", 1_000_000)).await.unwrap();
248
249 let now = 1_700_000_000_000_500;
250 let n = s.revoke_all_for_actor("alice", now).await.unwrap();
251 assert_eq!(n, 2);
252
253 assert!(!s.is_valid(alice1, now + 1).await.unwrap());
254 assert!(!s.is_valid(alice2, now + 1).await.unwrap());
255 assert!(s.is_valid(bob, now + 1).await.unwrap());
256 }
257
258 #[tokio::test]
259 async fn test_prune_expired_only_removes_expired() {
260 let s = InMemorySessionStore::new();
261 let now = 1_700_000_000_000_000;
262
263 let live = Uuid::new_v4();
264 let expired = Uuid::new_v4();
265 s.record_session(SessionRecord {
266 jti: live,
267 actor_id: "a".into(),
268 created_at_us: now,
269 expires_at_us: now + 1_000_000,
270 revoked_at_us: None,
271 })
272 .await
273 .unwrap();
274 s.record_session(SessionRecord {
275 jti: expired,
276 actor_id: "a".into(),
277 created_at_us: now - 2000,
278 expires_at_us: now - 1000,
279 revoked_at_us: None,
280 })
281 .await
282 .unwrap();
283
284 let removed = s.prune_expired(now).await.unwrap();
285 assert_eq!(removed, 1);
286 assert!(s.is_valid(live, now + 1).await.unwrap());
287 assert!(!s.is_valid(expired, now + 1).await.unwrap());
288 }
289
290 #[tokio::test]
291 async fn test_record_session_validates_inputs() {
292 let s = InMemorySessionStore::new();
293 let bad = SessionRecord {
294 jti: Uuid::new_v4(),
295 actor_id: " ".into(),
296 created_at_us: 100,
297 expires_at_us: 200,
298 revoked_at_us: None,
299 };
300 assert!(matches!(
301 s.record_session(bad).await.unwrap_err(),
302 SessionStoreError::Validation(_)
303 ));
304 }
305
306 #[tokio::test]
307 async fn test_record_session_rejects_inverted_expiry() {
308 let s = InMemorySessionStore::new();
309 let bad = SessionRecord {
310 jti: Uuid::new_v4(),
311 actor_id: "alice".into(),
312 created_at_us: 200,
313 expires_at_us: 100,
314 revoked_at_us: None,
315 };
316 assert!(matches!(
317 s.record_session(bad).await.unwrap_err(),
318 SessionStoreError::Validation(_)
319 ));
320 }
321}