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}
25
26#[derive(Debug, Default)]
27struct State {
28    sessions: HashMap<Uuid, Entry>,
29    token_index: HashMap<TokenHash, Uuid>,
30    events: Vec<SessionEvent>,
31    claims: HashMap<(ClaimKey, EventKind), DateTime<Utc>>,
32}
33
34/// An owned [`ClaimScope`].
35#[derive(Debug, PartialEq, Eq, Hash)]
36enum ClaimKey {
37    Session(Uuid),
38    Subject(String),
39}
40
41#[derive(Debug)]
42struct Entry {
43    record: SessionRecord,
44    /// In issue order; the last one is current.
45    tokens: Vec<TokenRecord>,
46}
47
48impl State {
49    fn remove(&mut self, id: Uuid) -> bool {
50        let Some(entry) = self.sessions.remove(&id) else {
51            return false;
52        };
53        for token in entry.tokens {
54            self.token_index.remove(&token.hash);
55        }
56        true
57    }
58
59    fn apply(&mut self, update: &SessionUpdate) -> bool {
60        let Some(entry) = self.sessions.get_mut(&update.session_id) else {
61            return false;
62        };
63        let now = update.now;
64        let mut rotated = false;
65        if let Some(rotation) = &update.rotation
66            && let Some(current) = entry.tokens.last_mut()
67            && current.superseded_at.is_none()
68        {
69            let holds = match rotation.supersede {
70                Supersede::Exact(hash) => current.hash == hash,
71                Supersede::CurrentIssuedBefore(cutoff) => current.issued_at < cutoff,
72                Supersede::Current => true,
73            };
74            if holds && !self.token_index.contains_key(&rotation.new_token) {
75                current.superseded_at = Some(now);
76                entry.tokens.push(TokenRecord {
77                    hash: rotation.new_token,
78                    issued_at: now,
79                    first_used_at: None,
80                    superseded_at: None,
81                });
82                self.token_index.insert(rotation.new_token, update.session_id);
83                let cutoff = rotation.prune_superseded_before;
84                let index = &mut self.token_index;
85                entry.tokens.retain(|t| match t.superseded_at {
86                    Some(at) if at < cutoff => {
87                        index.remove(&t.hash);
88                        false
89                    }
90                    _ => true,
91                });
92                rotated = true;
93            }
94        }
95        if let Some(hash) = update.mark_used
96            && let Some(token) = entry.tokens.iter_mut().find(|t| t.hash == hash)
97            && token.first_used_at.is_none()
98        {
99            token.first_used_at = Some(now);
100        }
101        let record = &mut entry.record;
102        record.last_seen_at = now;
103        if let Some(baseline) = &update.baseline {
104            record.baseline.clone_from(baseline);
105        }
106        if let Some(flag) = update.step_up_required {
107            record.step_up_required = flag;
108        }
109        if let Some(at) = update.authenticated_at {
110            record.authenticated_at = at;
111        }
112        rotated
113    }
114}
115
116impl MemoryStore {
117    /// An empty store.
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Every recorded event, oldest first.
123    pub fn events(&self) -> Vec<SessionEvent> {
124        self.lock().events.clone()
125    }
126
127    /// Number of stored sessions, including expired ones not yet purged.
128    pub fn session_count(&self) -> usize {
129        self.lock().sessions.len()
130    }
131
132    fn lock(&self) -> MutexGuard<'_, State> {
133        // A panic while holding the lock cannot leave State half-updated in a way that breaks
134        // invariants worse than the panic itself, so keep serving.
135        self.state.lock().unwrap_or_else(PoisonError::into_inner)
136    }
137
138    fn with<T>(&self, f: impl FnOnce(&mut State) -> T) -> Ready<Result<T, StoreError>> {
139        ready(Ok(f(&mut self.lock())))
140    }
141}
142
143impl SessionStore for MemoryStore {
144    fn insert_session(
145        &self,
146        session: &SessionRecord,
147        token: &TokenHash,
148    ) -> impl Future<Output = Result<(), StoreError>> + Send {
149        let result = {
150            let mut state = self.lock();
151            if state.token_index.contains_key(token) || state.sessions.contains_key(&session.id) {
152                Err("duplicate session id or token".into())
153            } else {
154                state.token_index.insert(*token, session.id);
155                state.sessions.insert(
156                    session.id,
157                    Entry {
158                        record: session.clone(),
159                        tokens: vec![TokenRecord {
160                            hash: *token,
161                            issued_at: session.created_at,
162                            first_used_at: None,
163                            superseded_at: None,
164                        }],
165                    },
166                );
167                Ok(())
168            }
169        };
170        ready(result)
171    }
172
173    fn find_by_token(&self, token: &TokenHash) -> impl Future<Output = Result<Option<TokenLookup>, StoreError>> + Send {
174        self.with(|state| {
175            let id = state.token_index.get(token)?;
176            let entry = state.sessions.get(id)?;
177            let position = entry.tokens.iter().position(|t| t.hash == *token)?;
178            Some(TokenLookup {
179                session: entry.record.clone(),
180                token: entry.tokens[position],
181                newer_token_used: entry.tokens[position + 1..].iter().any(|t| t.first_used_at.is_some()),
182            })
183        })
184    }
185
186    fn apply(&self, update: &SessionUpdate) -> impl Future<Output = Result<bool, StoreError>> + Send {
187        self.with(|state| state.apply(update))
188    }
189
190    fn delete_session(&self, session_id: Uuid) -> impl Future<Output = Result<bool, StoreError>> + Send {
191        self.with(|state| state.remove(session_id))
192    }
193
194    fn delete_subject_sessions(&self, subject: &str) -> impl Future<Output = Result<Vec<Uuid>, StoreError>> + Send {
195        self.with(|state| {
196            let ids: Vec<Uuid> = state
197                .sessions
198                .values()
199                .filter(|e| e.record.subject == subject)
200                .map(|e| e.record.id)
201                .collect();
202            ids.into_iter().filter(|id| state.remove(*id)).collect()
203        })
204    }
205
206    fn list_subject_sessions(
207        &self,
208        subject: &str,
209    ) -> impl Future<Output = Result<Vec<SessionRecord>, StoreError>> + Send {
210        self.with(|state| {
211            state
212                .sessions
213                .values()
214                .filter(|e| e.record.subject == subject)
215                .map(|e| e.record.clone())
216                .collect()
217        })
218    }
219
220    fn delete_expired(
221        &self,
222        now: DateTime<Utc>,
223        idle_cutoff: DateTime<Utc>,
224    ) -> impl Future<Output = Result<u64, StoreError>> + Send {
225        self.with(|state| {
226            let ids: Vec<Uuid> = state
227                .sessions
228                .values()
229                .filter(|e| e.record.expires_at <= now || e.record.last_seen_at <= idle_cutoff)
230                .map(|e| e.record.id)
231                .collect();
232            ids.into_iter().filter(|id| state.remove(*id)).count() as u64
233        })
234    }
235
236    fn record_event(&self, event: &SessionEvent) -> impl Future<Output = Result<(), StoreError>> + Send {
237        self.with(|state| state.events.push(event.clone()))
238    }
239
240    fn delete_events_before(&self, cutoff: DateTime<Utc>) -> impl Future<Output = Result<u64, StoreError>> + Send {
241        self.with(|state| {
242            let before = state.events.len();
243            state.events.retain(|e| e.at >= cutoff);
244            state.claims.retain(|_, at| *at >= cutoff);
245            (before - state.events.len()) as u64
246        })
247    }
248
249    fn claim_event(
250        &self,
251        scope: ClaimScope<'_>,
252        kind: EventKind,
253        now: DateTime<Utc>,
254        window: TimeDelta,
255    ) -> impl Future<Output = Result<bool, StoreError>> + Send {
256        let key = match scope {
257            ClaimScope::Session(id) => (ClaimKey::Session(id), kind),
258            ClaimScope::Subject(subject) => (ClaimKey::Subject(subject.to_owned()), kind),
259        };
260        self.with(|state| match state.claims.get(&key) {
261            Some(at) if now - *at < window => false,
262            _ => {
263                state.claims.insert(key, now);
264                true
265            }
266        })
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::MemoryStore;
273
274    crate::store_conformance!(|| async { MemoryStore::new() });
275
276    #[tokio::test]
277    async fn duplicate_token_is_rejected() {
278        use crate::{store::SessionStore, testing};
279        let store = MemoryStore::new();
280        let now = testing::now_secs();
281        let token = testing::random_hash();
282        store
283            .insert_session(&testing::sample_session(now), &token)
284            .await
285            .unwrap();
286        assert!(
287            store
288                .insert_session(&testing::sample_session(now), &token)
289                .await
290                .is_err()
291        );
292        assert_eq!(store.session_count(), 1);
293    }
294}