Skip to main content

axess_core/session/
store.rs

1//! Session storage and registry traits, plus in-memory implementations.
2
3#[cfg(any(test, feature = "memory"))]
4use crate::health::{HealthCheck, HealthStatus};
5use crate::session::{data::SessionData, id::SessionId};
6#[cfg(any(test, feature = "memory"))]
7use crate::store::{MemoryStore, Store};
8#[cfg(any(test, feature = "memory"))]
9use dashmap::DashMap;
10use std::future::Future;
11use std::pin::Pin;
12#[cfg(any(test, feature = "memory"))]
13use std::sync::Arc;
14#[cfg(any(test, feature = "memory"))]
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::Duration;
17
18// ── SessionStore ──────────────────────────────────────────────────────────────
19
20/// Typed, async session storage backend.
21///
22/// Implementors: [`MemorySessionStore`], `SqliteSessionStore`, `ValkeySessionStore`.
23///
24/// All methods accept `&self`; implementations are expected to use
25/// interior mutability (`Arc<DashMap<...>>` for memory, connection
26/// pool for SQL/Valkey).
27pub trait SessionStore: Send + Sync + Clone + 'static {
28    /// The error type returned by storage operations.
29    type Error: std::error::Error + Send + Sync + 'static;
30
31    /// Load the session data for the given ID. Returns `None` if the session
32    /// does not exist or has expired.
33    fn load(
34        &self,
35        id: &SessionId,
36    ) -> impl std::future::Future<Output = Result<Option<SessionData>, Self::Error>> + Send;
37
38    /// Persist session data with a time-to-live.
39    fn save(
40        &self,
41        id: &SessionId,
42        data: &SessionData,
43        ttl: Duration,
44    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
45
46    /// Delete the session. Idempotent; does not error if the session is absent.
47    fn delete(
48        &self,
49        id: &SessionId,
50    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
51
52    /// Atomically delete the old session row and store the data under the
53    /// caller-supplied new id.
54    ///
55    /// Used for session fixation prevention. The new id is supplied by the
56    /// caller (rather than minted by the store) so that handler-side code
57    /// can register the *post-rotation* id with `SessionRegistry` *before*
58    /// the layer persists the rotation; without this ordering, every
59    /// authenticated request fails its next `is_valid` check because the
60    /// registry holds the pre-cycle id while the cookie carries the
61    /// post-cycle one. See the internal `SessionInner::rotate_id` helper.
62    ///
63    /// Implementations MUST perform the delete and insert atomically (single
64    /// transaction) so a crash mid-cycle never leaves the user with both
65    /// rows or neither.
66    ///
67    /// # Orphan window
68    ///
69    /// There is a narrow race where `cycle` commits successfully but the
70    /// task is cancelled (or the connection drops) before the new id is
71    /// returned to the client. The new row exists but no client knows
72    /// about it. The row is bounded by the session TTL (it dies on its
73    /// own); applications that want to reclaim the storage faster
74    /// should call [`prune_expired`](Self::prune_expired) on startup
75    /// and on a schedule.
76    fn cycle(
77        &self,
78        old_id: &SessionId,
79        new_id: &SessionId,
80        data: &SessionData,
81        ttl: Duration,
82    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
83
84    /// Bulk-delete every session row whose TTL has elapsed.
85    /// Returns the number of rows reclaimed.
86    ///
87    /// Provides a cheap recovery path for orphans created by a cancelled
88    /// [`cycle`](Self::cycle) (the row exists in the store but no client
89    /// holds the id); those rows naturally die at TTL, and this method
90    /// is what an operator runs to evict them sooner. Recommended cadence:
91    /// once on application startup, then every TTL window.
92    ///
93    /// # Required
94    ///
95    /// Every `SessionStore` MUST implement this; there is no safe
96    /// default. A silent `Ok(0)` would let a misconfigured deployment
97    /// believe eviction is running while sessions accumulate forever.
98    /// Backends that rely on the underlying datastore for TTL eviction
99    /// (Valkey/Redis) implement this as a documented no-op returning
100    /// `Ok(0)`. Backends with their own row table (SQLite, Postgres,
101    /// in-memory) actually delete.
102    fn prune_expired(&self) -> impl std::future::Future<Output = Result<u64, Self::Error>> + Send;
103
104    /// Return active (non-expired) sessions for the given user, newest first.
105    ///
106    /// `limit` caps how many entries are returned; pass a small value
107    /// (e.g. 100) for breach-response UIs and admin "view active devices"
108    /// pages, since a malicious or curious caller could otherwise scan
109    /// every session for a high-cardinality user.
110    ///
111    /// # When to override
112    ///
113    /// Override on backends that can answer the query efficiently, typically
114    /// by indexing a denormalised `user_id` column extracted from
115    /// [`SessionData::auth_state`] at write time. Without an index this is
116    /// an O(N) scan over the entire session table, which is why the
117    /// default returns an empty `Vec` instead of doing the scan implicitly.
118    ///
119    /// # Use cases
120    ///
121    /// - **Breach response**: list every active session for an account
122    ///   so an admin can revoke them via [`delete`](Self::delete).
123    /// - **"Devices" UI**: let a user see their own active sessions
124    ///   (mobile / desktop / tablet) and selectively log out.
125    /// - **Concurrent-session enforcement**: count active sessions
126    ///   before allowing a new login to exceed a per-user cap.
127    ///
128    /// # Default
129    ///
130    /// Returns `Ok(Vec::new())`. **Deliberately informational, not
131    /// security-bearing.** Enumeration is hard for backends that store
132    /// sessions as encrypted blobs keyed by `SessionId` (every in-tree
133    /// SQL/Valkey backend), since the `user_id` lives inside the
134    /// ciphertext and a naive scan would need to decrypt every row.
135    /// Backends that don't support cheap enumeration return empty and
136    /// the admin UI shows "no sessions"; operationally fine because the
137    /// caller has no recovery path for "unsupported" that differs from
138    /// "no sessions found."
139    ///
140    /// The security-relevant enumeration is
141    /// [`SessionRegistry::active_sessions`], which IS required and
142    /// drives concurrent-session-limit enforcement.
143    fn find_sessions_for_user(
144        &self,
145        user_id: &crate::authn::ids::UserId,
146        limit: usize,
147    ) -> impl std::future::Future<Output = Result<Vec<(SessionId, SessionData)>, Self::Error>> + Send
148    {
149        let _ = (user_id, limit);
150        std::future::ready(Ok(Vec::new()))
151    }
152}
153
154// ── SessionRegistry ───────────────────────────────────────────────────────────
155
156/// Tracks which sessions are valid for each user, enabling forced logout.
157///
158/// Implementors: [`MemorySessionRegistry`], `ValkeySessionRegistry`.
159///
160/// All methods accept the typed [`UserId`](crate::authn::ids::UserId) so the
161/// trait surface cannot accidentally degrade to `&str` at the storage
162/// boundary; implementations that need the underlying string can call
163/// `.as_str()`.
164pub trait SessionRegistry: Send + Sync + Clone + 'static {
165    /// The error type returned by registry operations.
166    type Error: std::error::Error + Send + Sync + 'static;
167
168    /// Register a session ID as belonging to a user.
169    fn register(
170        &self,
171        user_id: &crate::authn::ids::UserId,
172        session_id: &SessionId,
173    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
174
175    /// Return `true` if the given session ID is still valid for the user.
176    fn is_valid(
177        &self,
178        user_id: &crate::authn::ids::UserId,
179        session_id: &SessionId,
180    ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
181
182    /// Invalidate all sessions for a user (e.g. global logout, credential rotation).
183    fn invalidate_user(
184        &self,
185        user_id: &crate::authn::ids::UserId,
186    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
187
188    /// Invalidate a single session for a user.
189    fn invalidate_session(
190        &self,
191        user_id: &crate::authn::ids::UserId,
192        session_id: &SessionId,
193    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
194
195    /// Return all active session IDs for a user, ordered oldest first.
196    ///
197    /// Used by the concurrent-session-limit enforcement on
198    /// [`AuthnService::with_max_sessions_per_user`](crate::authn::AuthnService::with_max_sessions_per_user)
199    /// to FIFO-evict the oldest session when a new login would cross the
200    /// cap. **This method is required**: a silent empty result here
201    /// disables the limit without any warning, which is a security
202    /// regression on every deployment using the cap. Backends that
203    /// genuinely cannot enumerate (rare) should panic with
204    /// `unimplemented!` so misconfiguration surfaces loudly, not
205    /// silently.
206    fn active_sessions(
207        &self,
208        user_id: &crate::authn::ids::UserId,
209    ) -> impl std::future::Future<Output = Result<Vec<SessionId>, Self::Error>> + Send;
210
211    /// Resolve when the named session is invalidated (on this or another
212    /// node), otherwise stay pending. Lets long-lived connections
213    /// (WebSocket, SSE) react proactively to revocation rather than
214    /// discovering it on the next message attempt. The future never
215    /// resolves except on actual revocation, so `select!` callers don't
216    /// need to guard against spurious wakeups.
217    ///
218    /// The default impl returns `std::future::pending()`; backends
219    /// without push support (memory, SQLite without LISTEN/NOTIFY) thus
220    /// compile but won't surface revocations. `ValkeySessionRegistry`
221    /// overrides via Redis pub/sub.
222    fn watch_revocation(
223        &self,
224        user_id: &crate::authn::ids::UserId,
225        session_id: &SessionId,
226    ) -> impl std::future::Future<Output = ()> + Send {
227        let _ = (user_id, session_id);
228        std::future::pending()
229    }
230}
231
232// Dyn-safe adapters over `SessionRegistry`. The trait uses RPITIT + an
233// associated `Error` so it is not dyn-safe; `SessionRevoker` narrows to
234// the logout surface (`invalidate_*`) and `SessionRegistryHandle` adds
235// the full registry surface used by `AuthnService`. Error handling is
236// baked in: `is_valid` fails closed, `invalidate_*` log-and-swallow,
237// `register` returns `bool`, `active_sessions` fails open with `vec![]`
238// so registry outages don't lock users out.
239
240/// Dyn-safe narrow handle for session revocation. Implemented by
241/// [`SessionRegistryAdapter`]; consumers (logout handlers) take
242/// `Arc<dyn SessionRevoker>` so they can't accidentally call broader
243/// registry operations.
244pub trait SessionRevoker: Send + Sync + 'static {
245    /// Invalidate every session belonging to the user.
246    fn invalidate_user<'a>(
247        &'a self,
248        user_id: &'a crate::authn::ids::UserId,
249    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
250
251    /// Invalidate a single session for the user.
252    fn invalidate_session<'a>(
253        &'a self,
254        user_id: &'a crate::authn::ids::UserId,
255        session_id: &'a SessionId,
256    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
257}
258
259/// Dyn-safe full handle for the session registry. Adds session
260/// registration + validity-check + active-session listing on top of
261/// [`SessionRevoker`].
262pub trait SessionRegistryHandle: SessionRevoker {
263    /// Return `true` if the named session is still valid for the user.
264    /// Fails closed on registry error.
265    fn is_valid<'a>(
266        &'a self,
267        user_id: &'a crate::authn::ids::UserId,
268        session_id: &'a SessionId,
269    ) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
270
271    /// Register a session under the user. Returns `true` on success,
272    /// `false` on registry error. Callers that *require* the registry
273    /// to track the session (signup, factor completion) should react to
274    /// `false` by clearing the session and refusing authentication.
275    fn register<'a>(
276        &'a self,
277        user_id: &'a crate::authn::ids::UserId,
278        session_id: &'a SessionId,
279    ) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
280
281    /// Return all active session IDs for the user, oldest first.
282    /// Fails open with an empty vec on registry error so the
283    /// concurrent-session limit doesn't lock users out during outage.
284    fn active_sessions<'a>(
285        &'a self,
286        user_id: &'a crate::authn::ids::UserId,
287    ) -> Pin<Box<dyn Future<Output = Vec<SessionId>> + Send + 'a>>;
288}
289
290/// Type-erasing adapter that turns any concrete [`SessionRegistry`] into
291/// `dyn SessionRevoker` *and* `dyn SessionRegistryHandle`. One wrapper
292/// for both shapes; pick the trait that matches the call site's needs.
293pub struct SessionRegistryAdapter<T: SessionRegistry>(pub T);
294
295impl<T: SessionRegistry + 'static> SessionRevoker for SessionRegistryAdapter<T> {
296    fn invalidate_user<'a>(
297        &'a self,
298        user_id: &'a crate::authn::ids::UserId,
299    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
300        Box::pin(async move {
301            if let Err(e) = self.0.invalidate_user(user_id).await {
302                tracing::warn!(
303                    user_id = %user_id,
304                    error = %e,
305                    "session registry invalidate_user failed; user sessions may remain active"
306                );
307            }
308        })
309    }
310
311    fn invalidate_session<'a>(
312        &'a self,
313        user_id: &'a crate::authn::ids::UserId,
314        session_id: &'a SessionId,
315    ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
316        Box::pin(async move {
317            if let Err(e) = self.0.invalidate_session(user_id, session_id).await {
318                tracing::warn!(
319                    user_id = %user_id,
320                    error = %e,
321                    "session registry invalidate_session failed"
322                );
323            }
324        })
325    }
326}
327
328impl<T: SessionRegistry + 'static> SessionRegistryHandle for SessionRegistryAdapter<T> {
329    fn is_valid<'a>(
330        &'a self,
331        user_id: &'a crate::authn::ids::UserId,
332        session_id: &'a SessionId,
333    ) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
334        Box::pin(async move {
335            match self.0.is_valid(user_id, session_id).await {
336                Ok(valid) => valid,
337                Err(e) => {
338                    tracing::warn!(
339                        user_id = %user_id,
340                        error = %e,
341                        "session registry is_valid check failed; failing closed"
342                    );
343                    false
344                }
345            }
346        })
347    }
348
349    fn register<'a>(
350        &'a self,
351        user_id: &'a crate::authn::ids::UserId,
352        session_id: &'a SessionId,
353    ) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
354        Box::pin(async move {
355            match self.0.register(user_id, session_id).await {
356                Ok(()) => true,
357                Err(e) => {
358                    tracing::warn!(
359                        user_id = %user_id,
360                        error = %e,
361                        "session registry register failed; caller must clear the session \
362                         to avoid an Authenticated-but-untracked outcome"
363                    );
364                    false
365                }
366            }
367        })
368    }
369
370    fn active_sessions<'a>(
371        &'a self,
372        user_id: &'a crate::authn::ids::UserId,
373    ) -> Pin<Box<dyn Future<Output = Vec<SessionId>> + Send + 'a>> {
374        Box::pin(async move {
375            match self.0.active_sessions(user_id).await {
376                Ok(sessions) => sessions,
377                Err(e) => {
378                    tracing::error!(
379                        user_id = %user_id,
380                        error = %e,
381                        "session registry active_sessions failed; concurrent session limit disabled for this request"
382                    );
383                    Vec::new()
384                }
385            }
386        })
387    }
388}
389
390// ── MemorySessionStore ────────────────────────────────────────────────────────
391//
392// The in-memory store + registry below are gated behind
393// `#[cfg(any(test, feature = "memory"))]` so production builds that don't
394// opt in cannot accidentally ship `MemorySessionStore` / `MemorySessionRegistry`
395//; both are dev/test-only (data lost on restart, no cross-node coordination).
396// `cfg(test)` keeps them available for the in-crate test suite without
397// requiring `--features memory` on every `cargo test` invocation.
398
399/// In-memory session store backed by [`DashMap`].
400///
401/// **For testing and single-node development only.** Not suitable for production:
402/// - Data is lost on process restart.
403/// - No encryption at rest.
404///
405/// Expired sessions are automatically purged every 1024 write operations.
406/// Call [`MemorySessionStore::purge_expired`] for on-demand cleanup.
407///
408/// # Time injection
409///
410/// Stores `created_at` as `chrono::DateTime<Utc>` against the injected
411/// [`Clock`](axess_clock::Clock) (default
412/// [`SystemClock`](axess_clock::SystemClock)). DST tests can
413/// drive expiry deterministically by constructing the store with
414/// [`with_clock`](Self::with_clock) and a `MockClock`.
415/// In-memory session store. Suitable for tests and single-process
416/// examples; not durable across restarts and not shared across nodes.
417///
418/// Thin newtype around [`MemoryStore<SessionId, SessionData>`] (the
419/// shared [`Store<K, V>`] backend from
420/// [`axess_core::store`](crate::store)). The session-domain
421/// `SessionStore::cycle` lives on this wrapper because `Store<K, V>`
422/// has no equivalent (cycle is a session-domain rename primitive, not
423/// a kv primitive); everything else delegates to the inner backend.
424#[cfg(any(test, feature = "memory"))]
425#[derive(Clone)]
426pub struct MemorySessionStore {
427    inner: MemoryStore<SessionId, SessionData>,
428    /// Write counter for lazy auto-purge scheduling. Sits outside the
429    /// shared backend because auto-purge is a session-specific
430    /// ergonomic; backends like `MemoryStore` leave eviction to the
431    /// caller.
432    write_count: Arc<AtomicU64>,
433}
434
435#[cfg(any(test, feature = "memory"))]
436impl Default for MemorySessionStore {
437    fn default() -> Self {
438        Self {
439            inner: MemoryStore::new(),
440            write_count: Arc::new(AtomicU64::new(0)),
441        }
442    }
443}
444
445#[cfg(any(test, feature = "memory"))]
446impl MemorySessionStore {
447    /// Create an empty in-memory session store.
448    pub fn new() -> Self {
449        Self::default()
450    }
451
452    /// Inject a [`Clock`](axess_clock::Clock) for deterministic
453    /// simulation testing. In production, leave at the default
454    /// [`SystemClock`](axess_clock::SystemClock). Flows through to
455    /// the underlying [`MemoryStore`] so TTL bookkeeping uses the
456    /// same time source.
457    pub fn with_clock(mut self, clock: Arc<dyn axess_clock::Clock>) -> Self {
458        self.inner = self.inner.with_clock(clock);
459        self
460    }
461
462    /// Remove all expired sessions. Call from a background task or let
463    /// the auto-purge handle it (runs every 1024 writes).
464    pub fn purge_expired(&self) {
465        self.inner.prune_expired_sync();
466    }
467
468    /// Conditionally run auto-purge every 1024 writes.
469    fn maybe_auto_purge(&self) {
470        let count = self.write_count.fetch_add(1, Ordering::Relaxed);
471        if count.is_multiple_of(1024) && !self.inner.is_empty() {
472            self.purge_expired();
473        }
474    }
475}
476
477/// Infallible error for the in-memory store. The previous
478/// `Serialization(serde_json::Error)` variant has been removed since
479/// `MemoryStore<K, V>` holds values by-clone, never serialises, so the
480/// variant was unreachable in practice. Adopters who matched on it
481/// can drop the arm.
482#[cfg(any(test, feature = "memory"))]
483pub type MemoryStoreError = std::convert::Infallible;
484
485#[cfg(any(test, feature = "memory"))]
486impl SessionStore for MemorySessionStore {
487    type Error = MemoryStoreError;
488
489    async fn load(&self, id: &SessionId) -> Result<Option<SessionData>, Self::Error> {
490        self.inner.get(id).await
491    }
492
493    async fn save(
494        &self,
495        id: &SessionId,
496        data: &SessionData,
497        ttl: Duration,
498    ) -> Result<(), Self::Error> {
499        self.inner.put(id, data, ttl).await?;
500        self.maybe_auto_purge();
501        Ok(())
502    }
503
504    async fn delete(&self, id: &SessionId) -> Result<(), Self::Error> {
505        self.inner.delete(id).await
506    }
507
508    async fn cycle(
509        &self,
510        old_id: &SessionId,
511        new_id: &SessionId,
512        data: &SessionData,
513        ttl: Duration,
514    ) -> Result<(), Self::Error> {
515        // `Store<K, V>` has no atomic-rename primitive; cycle is a
516        // session-domain operation (rotate the id, preserve the data).
517        // Two operations on the same DashMap-backed backend are
518        // effectively atomic from the perspective of a single-process
519        // session store; multi-node backends override `SessionStore::cycle`
520        // with whatever native atomic the backend exposes.
521        self.inner.delete(old_id).await?;
522        self.inner.put(new_id, data, ttl).await?;
523        self.maybe_auto_purge();
524        Ok(())
525    }
526
527    async fn prune_expired(&self) -> Result<u64, Self::Error> {
528        self.inner.prune_expired().await
529    }
530}
531
532#[cfg(any(test, feature = "memory"))]
533impl HealthCheck for MemorySessionStore {
534    fn check(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
535        Box::pin(async { HealthStatus::Healthy })
536    }
537}
538
539// ── MemorySessionRegistry ─────────────────────────────────────────────────────
540
541/// In-memory session registry backed by [`DashMap`].
542///
543/// Maps `user_id -> Vec<SessionId>` (insertion-ordered). **For testing
544/// and single-node development only.**
545///
546/// Uses a `Vec<SessionId>` whose push order tracks insertion order so
547/// [`Self::active_sessions`] returns sessions oldest-first, satisfying
548/// the [`SessionRegistry::active_sessions`] "ordered oldest first"
549/// contract (load-bearing for the
550/// [`max_sessions_per_user`](crate::authn::service::AuthnService::with_max_sessions_per_user)
551/// FIFO eviction in `complete_factor_step`). The cost is O(N) lookup
552/// for `is_valid` and `invalidate_session`, but N is bounded by the
553/// concurrent-session limit (typically ≤10) and the Memory registry
554/// is testing-only.
555#[cfg(any(test, feature = "memory"))]
556#[derive(Clone, Default)]
557pub struct MemorySessionRegistry {
558    valid: Arc<DashMap<Arc<str>, Vec<SessionId>>>,
559}
560
561#[cfg(any(test, feature = "memory"))]
562impl MemorySessionRegistry {
563    /// Create an empty in-memory session registry.
564    pub fn new() -> Self {
565        Self::default()
566    }
567}
568
569/// Infallible error for the in-memory registry. The in-memory backend
570/// cannot fail at the storage boundary, so callers can prove statically
571/// that no error case is possible. Aliased to `std::convert::Infallible`
572/// rather than carrying an empty enum so `?`-propagation and exhaustive
573/// matches behave the canonical way.
574#[cfg(any(test, feature = "memory"))]
575pub type MemoryRegistryError = std::convert::Infallible;
576
577#[cfg(any(test, feature = "memory"))]
578impl SessionRegistry for MemorySessionRegistry {
579    type Error = MemoryRegistryError;
580
581    async fn register(
582        &self,
583        user_id: &crate::authn::ids::UserId,
584        session_id: &SessionId,
585    ) -> Result<(), Self::Error> {
586        // Dedup-on-insert preserves FIFO semantics; a re-
587        // register of an existing id is a no-op (keeps the original
588        // position) rather than appending a duplicate.
589        let mut entry = self
590            .valid
591            .entry(Arc::from(user_id.to_string()))
592            .or_default();
593        if !entry.contains(session_id) {
594            entry.push(*session_id);
595        }
596        Ok(())
597    }
598
599    async fn is_valid(
600        &self,
601        user_id: &crate::authn::ids::UserId,
602        session_id: &SessionId,
603    ) -> Result<bool, Self::Error> {
604        Ok(self
605            .valid
606            .get(user_id.to_string().as_str())
607            .is_some_and(|sessions| sessions.contains(session_id)))
608    }
609
610    async fn invalidate_user(
611        &self,
612        user_id: &crate::authn::ids::UserId,
613    ) -> Result<(), Self::Error> {
614        self.valid.remove(user_id.to_string().as_str());
615        Ok(())
616    }
617
618    async fn invalidate_session(
619        &self,
620        user_id: &crate::authn::ids::UserId,
621        session_id: &SessionId,
622    ) -> Result<(), Self::Error> {
623        if let Some(mut sessions) = self.valid.get_mut(user_id.to_string().as_str()) {
624            sessions.retain(|s| s != session_id);
625        }
626        Ok(())
627    }
628
629    async fn active_sessions(
630        &self,
631        user_id: &crate::authn::ids::UserId,
632    ) -> Result<Vec<SessionId>, Self::Error> {
633        Ok(self
634            .valid
635            .get(user_id.to_string().as_str())
636            .map(|sessions| sessions.clone())
637            .unwrap_or_default())
638    }
639}
640
641#[cfg(any(test, feature = "memory"))]
642impl HealthCheck for MemorySessionRegistry {
643    fn check(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>> {
644        Box::pin(async { HealthStatus::Healthy })
645    }
646}
647
648#[cfg(test)]
649mod memory_store_clock_tests;
650
651#[cfg(test)]
652mod memory_registry_tests;