1use std::{
4 collections::HashMap,
5 future::{Future, Ready, ready},
6 sync::{Mutex, MutexGuard, PoisonError},
7};
8
9use chrono::{DateTime, TimeDelta, Utc};
10use uuid::Uuid;
11
12use super::{ClaimScope, SessionRecord, SessionStore, SessionUpdate, Supersede, TokenLookup, TokenRecord};
13use crate::{
14 error::StoreError,
15 event::{EventKind, SessionEvent},
16 token::TokenHash,
17};
18
19#[derive(Debug, Default)]
22pub struct MemoryStore {
23 state: Mutex<State>,
24 sharded: bool,
26}
27
28#[derive(Debug, Default)]
29struct State {
30 sessions: HashMap<Uuid, Entry>,
31 token_index: HashMap<TokenHash, Uuid>,
32 events: Vec<SessionEvent>,
33 claims: HashMap<(ClaimKey, EventKind), DateTime<Utc>>,
34 misaligned: Vec<Uuid>,
35}
36
37#[derive(Debug, PartialEq, Eq, Hash)]
39enum ClaimKey {
40 Session(Uuid),
41 Subject(String),
42}
43
44#[derive(Debug)]
45struct Entry {
46 record: SessionRecord,
47 tokens: Vec<TokenRecord>,
49}
50
51impl State {
52 fn remove(&mut self, id: Uuid) -> bool {
53 let Some(entry) = self.sessions.remove(&id) else {
54 return false;
55 };
56 for token in entry.tokens {
57 self.token_index.remove(&token.hash);
58 }
59 true
60 }
61
62 fn apply(&mut self, update: &SessionUpdate) -> bool {
63 let Some(entry) = self.sessions.get_mut(&update.session_id) else {
64 return false;
65 };
66 let now = update.now;
67 let mut rotated = false;
68 if let Some(rotation) = &update.rotation
69 && let Some(current) = entry.tokens.last_mut()
70 && current.superseded_at.is_none()
71 {
72 let holds = match rotation.supersede {
73 Supersede::Exact(hash) => current.hash == hash,
74 Supersede::CurrentIssuedBefore(cutoff) => current.issued_at < cutoff,
75 Supersede::Current => true,
76 };
77 if holds && !self.token_index.contains_key(&rotation.new_token) {
78 current.superseded_at = Some(now);
79 entry.tokens.push(TokenRecord {
80 hash: rotation.new_token,
81 issued_at: now,
82 first_used_at: None,
83 superseded_at: None,
84 });
85 self.token_index.insert(rotation.new_token, update.session_id);
86 let cutoff = rotation.prune_superseded_before;
87 let index = &mut self.token_index;
88 entry.tokens.retain(|t| match t.superseded_at {
89 Some(at) if at < cutoff => {
90 index.remove(&t.hash);
91 false
92 }
93 _ => true,
94 });
95 rotated = true;
96 }
97 }
98 if let Some(hash) = update.mark_used
99 && let Some(token) = entry.tokens.iter_mut().find(|t| t.hash == hash)
100 && token.first_used_at.is_none()
101 {
102 token.first_used_at = Some(now);
103 }
104 let record = &mut entry.record;
105 record.last_seen_at = now;
106 if let Some(baseline) = &update.baseline {
107 record.baseline.clone_from(baseline);
108 }
109 if let Some(flag) = update.step_up_required {
110 record.step_up_required = flag;
111 }
112 if let Some(at) = update.authenticated_at {
113 record.authenticated_at = at;
114 }
115 rotated
116 }
117}
118
119impl MemoryStore {
120 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn events(&self) -> Vec<SessionEvent> {
127 self.lock().events.clone()
128 }
129
130 pub fn session_count(&self) -> usize {
132 self.lock().sessions.len()
133 }
134
135 fn lock(&self) -> MutexGuard<'_, State> {
136 self.state.lock().unwrap_or_else(PoisonError::into_inner)
139 }
140
141 fn with<T>(&self, f: impl FnOnce(&mut State) -> T) -> Ready<Result<T, StoreError>> {
142 ready(Ok(f(&mut self.lock())))
143 }
144}
145
146#[cfg(test)]
147impl MemoryStore {
148 pub(crate) fn sharded() -> Self {
151 Self {
152 sharded: true,
153 ..Self::default()
154 }
155 }
156
157 pub(crate) fn misaligned(&self) -> Vec<Uuid> {
159 self.lock().misaligned.clone()
160 }
161}
162
163impl SessionStore for MemoryStore {
164 fn sharded(&self) -> bool {
165 self.sharded
166 }
167
168 fn insert_session(
169 &self,
170 session: &SessionRecord,
171 token: &TokenHash,
172 ) -> impl Future<Output = Result<(), StoreError>> + Send {
173 let result = {
174 let mut state = self.lock();
175 if self.sharded && super::token_bucket(token) != super::session_bucket(session.id) {
176 state.misaligned.push(session.id);
177 }
178 if state.token_index.contains_key(token) || state.sessions.contains_key(&session.id) {
179 Err("duplicate session id or token".into())
180 } else {
181 state.token_index.insert(*token, session.id);
182 state.sessions.insert(
183 session.id,
184 Entry {
185 record: session.clone(),
186 tokens: vec![TokenRecord {
187 hash: *token,
188 issued_at: session.created_at,
189 first_used_at: None,
190 superseded_at: None,
191 }],
192 },
193 );
194 Ok(())
195 }
196 };
197 ready(result)
198 }
199
200 fn find_by_token(&self, token: &TokenHash) -> impl Future<Output = Result<Option<TokenLookup>, StoreError>> + Send {
201 self.with(|state| {
202 let id = state.token_index.get(token)?;
203 let entry = state.sessions.get(id)?;
204 let position = entry.tokens.iter().position(|t| t.hash == *token)?;
205 Some(TokenLookup {
206 session: entry.record.clone(),
207 token: entry.tokens[position],
208 newer_token_used: entry.tokens[position + 1..].iter().any(|t| t.first_used_at.is_some()),
209 })
210 })
211 }
212
213 fn apply(&self, update: &SessionUpdate) -> impl Future<Output = Result<bool, StoreError>> + Send {
214 self.with(|state| {
215 if self.sharded
216 && let Some(rotation) = &update.rotation
217 && super::token_bucket(&rotation.new_token) != super::session_bucket(update.session_id)
218 {
219 state.misaligned.push(update.session_id);
220 }
221 state.apply(update)
222 })
223 }
224
225 fn delete_session(&self, session_id: Uuid) -> impl Future<Output = Result<bool, StoreError>> + Send {
226 self.with(|state| state.remove(session_id))
227 }
228
229 fn delete_subject_sessions(&self, subject: &str) -> impl Future<Output = Result<Vec<Uuid>, StoreError>> + Send {
230 self.with(|state| {
231 let ids: Vec<Uuid> = state
232 .sessions
233 .values()
234 .filter(|e| e.record.subject == subject)
235 .map(|e| e.record.id)
236 .collect();
237 ids.into_iter().filter(|id| state.remove(*id)).collect()
238 })
239 }
240
241 fn list_subject_sessions(
242 &self,
243 subject: &str,
244 ) -> impl Future<Output = Result<Vec<SessionRecord>, StoreError>> + Send {
245 self.with(|state| {
246 state
247 .sessions
248 .values()
249 .filter(|e| e.record.subject == subject)
250 .map(|e| e.record.clone())
251 .collect()
252 })
253 }
254
255 fn delete_expired(
256 &self,
257 now: DateTime<Utc>,
258 idle_cutoff: DateTime<Utc>,
259 ) -> impl Future<Output = Result<u64, StoreError>> + Send {
260 self.with(|state| {
261 let ids: Vec<Uuid> = state
262 .sessions
263 .values()
264 .filter(|e| e.record.expires_at <= now || e.record.last_seen_at <= idle_cutoff)
265 .map(|e| e.record.id)
266 .collect();
267 ids.into_iter().filter(|id| state.remove(*id)).count() as u64
268 })
269 }
270
271 fn record_event(&self, event: &SessionEvent) -> impl Future<Output = Result<(), StoreError>> + Send {
272 self.with(|state| state.events.push(event.clone()))
273 }
274
275 fn delete_events_before(&self, cutoff: DateTime<Utc>) -> impl Future<Output = Result<u64, StoreError>> + Send {
276 self.with(|state| {
277 let before = state.events.len();
278 state.events.retain(|e| e.at >= cutoff);
279 state.claims.retain(|_, at| *at >= cutoff);
280 (before - state.events.len()) as u64
281 })
282 }
283
284 fn claim_event(
285 &self,
286 scope: ClaimScope<'_>,
287 kind: EventKind,
288 now: DateTime<Utc>,
289 window: TimeDelta,
290 ) -> impl Future<Output = Result<bool, StoreError>> + Send {
291 let key = match scope {
292 ClaimScope::Session(id) => (ClaimKey::Session(id), kind),
293 ClaimScope::Subject(subject) => (ClaimKey::Subject(subject.to_owned()), kind),
294 };
295 self.with(|state| match state.claims.get(&key) {
296 Some(at) if now - *at < window => false,
297 _ => {
298 state.claims.insert(key, now);
299 true
300 }
301 })
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::MemoryStore;
308
309 crate::store_conformance!(|| async { MemoryStore::new() });
310
311 #[tokio::test]
312 async fn duplicate_token_is_rejected() {
313 use crate::{store::SessionStore, testing};
314 let store = MemoryStore::new();
315 let now = testing::now_secs();
316 let token = testing::random_hash();
317 store
318 .insert_session(&testing::sample_session(now), &token)
319 .await
320 .unwrap();
321 assert!(
322 store
323 .insert_session(&testing::sample_session(now), &token)
324 .await
325 .is_err()
326 );
327 assert_eq!(store.session_count(), 1);
328 }
329
330 #[tokio::test]
331 async fn sharded_double_records_misaligned_writes() {
332 use crate::{
333 Rotation, SessionUpdate, Supersede, TokenHash,
334 store::{SessionStore, token_bucket},
335 testing,
336 };
337 let store = MemoryStore::sharded();
338 let now = testing::now_secs();
339 let session = testing::sample_session(now);
340 let bucket = crate::store::session_bucket(session.id);
341 let mut bytes = *testing::random_hash().as_bytes();
342 bytes[0] = bucket.wrapping_add(1);
343 let off = TokenHash::from_bytes(bytes);
344 assert_ne!(token_bucket(&off), bucket);
345
346 store.insert_session(&session, &off).await.unwrap();
347 assert_eq!(store.misaligned(), [session.id]);
348
349 let update = SessionUpdate {
350 rotation: Some(Rotation {
351 new_token: off,
352 supersede: Supersede::Current,
353 prune_superseded_before: now,
354 }),
355 ..SessionUpdate::touch(session.id, now)
356 };
357 store.apply(&update).await.unwrap();
358 assert_eq!(store.misaligned(), [session.id, session.id]);
359 }
360}