Skip to main content

session_guard/store/
memory.rs

1//! In-process store for tests, development and single-instance apps.
2
3use 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/// Keeps everything in memory behind one mutex. Nothing survives a restart and nothing is shared
20/// between processes, so use it for tests, development and single-instance apps only.
21#[derive(Debug, Default)]
22pub struct MemoryStore {
23    state: Mutex<State>,
24    /// Test double for a sharded store: records writes whose token and session buckets differ.
25    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/// An owned [`ClaimScope`].
38#[derive(Debug, PartialEq, Eq, Hash)]
39enum ClaimKey {
40    Session(Uuid),
41    Subject(String),
42}
43
44#[derive(Debug)]
45struct Entry {
46    record: SessionRecord,
47    /// In issue order; the last one is current.
48    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    /// An empty store.
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Every recorded event, oldest first.
126    pub fn events(&self) -> Vec<SessionEvent> {
127        self.lock().events.clone()
128    }
129
130    /// Number of stored sessions, including expired ones not yet purged.
131    pub fn session_count(&self) -> usize {
132        self.lock().sessions.len()
133    }
134
135    fn lock(&self) -> MutexGuard<'_, State> {
136        // A panic while holding the lock cannot leave State half-updated in a way that breaks
137        // invariants worse than the panic itself, so keep serving.
138        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    /// Reports itself as [sharded](SessionStore::sharded) and records every session whose id and
149    /// token buckets differ.
150    pub(crate) fn sharded() -> Self {
151        Self {
152            sharded: true,
153            ..Self::default()
154        }
155    }
156
157    /// Sessions that were written with a token outside their bucket.
158    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}