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;