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