Skip to main content

axess_core/testing/
mock_refresh_store.rs

1//! In-memory [`RefreshTokenStore`] for unit tests and single-node
2//! development.
3//!
4//! Backed by the shared [`crate::store::MemoryStore`]. `store_token`
5//! delegates to `MemoryStore::put` with `ttl = (token.expires_at -
6//! Utc::now()).max(ZERO)` so entries auto-evict at `expires_at`. The
7//! secondary-index operations (`find_token`, `active_tokens`,
8//! `revoke_*`) use the backend's `snapshot()` + `update()` helpers
9//! because the `Store<K, V>` trait carries no iteration primitive.
10//!
11//! **Not suitable for production.** The lookup path is a linear scan
12//! over the snapshot (leaking timing information), there's no
13//! persistence across restarts, and contents are not encrypted at
14//! rest. Matches the positioning of the other `mock_*` doubles in
15//! this module.
16
17use crate::authn::ids::UserId;
18use crate::session::refresh::{RefreshToken, RefreshTokenId, RefreshTokenStore, TokenFamilyId};
19use crate::store::{MemoryStore, Store};
20use chrono::Utc;
21use std::time::Duration;
22
23/// In-memory refresh token store backed by [`MemoryStore`].
24#[derive(Clone, Default)]
25pub struct MemoryRefreshTokenStore {
26    inner: MemoryStore<RefreshTokenId, RefreshToken>,
27}
28
29/// Infallible error for the in-memory refresh token store. Aliased to
30/// `std::convert::Infallible` so the empty error type behaves the
31/// canonical way in `?`-propagation and exhaustive matches.
32pub type MemoryRefreshStoreError = std::convert::Infallible;
33
34impl MemoryRefreshTokenStore {
35    /// Create an empty in-memory refresh token store.
36    pub fn new() -> Self {
37        Self::default()
38    }
39}
40
41impl RefreshTokenStore for MemoryRefreshTokenStore {
42    type Error = MemoryRefreshStoreError;
43
44    async fn store_token(&self, token: &RefreshToken) -> Result<(), Self::Error> {
45        // TTL = expires_at - now, saturating at zero. An
46        // expired-on-arrival token is stored with TTL=0 → invisible to
47        // every subsequent `get`. The store is infallible so the
48        // `unwrap_or` is hit on negative chrono durations only.
49        let ttl = (token.expires_at - Utc::now())
50            .to_std()
51            .unwrap_or(Duration::ZERO);
52        self.inner.put(&token.id, token, ttl).await
53    }
54
55    async fn find_token(&self, token_hash: &str) -> Result<Option<RefreshToken>, Self::Error> {
56        // Linear scan over the snapshot; fine for tests; production
57        // backends index by `token_hash` directly.
58        let found = self
59            .inner
60            .snapshot()
61            .into_iter()
62            .find(|(_, t)| t.token_hash == token_hash)
63            .map(|(_, t)| t);
64        Ok(found)
65    }
66
67    async fn revoke_token(&self, token_id: &RefreshTokenId) -> Result<(), Self::Error> {
68        self.inner.update(token_id, |t| t.revoked = true);
69        Ok(())
70    }
71
72    async fn revoke_user_tokens(&self, user_id: &UserId) -> Result<(), Self::Error> {
73        for (id, token) in self.inner.snapshot() {
74            if &token.user_id == user_id {
75                self.inner.update(&id, |t| t.revoked = true);
76            }
77        }
78        Ok(())
79    }
80
81    async fn revoke_family(
82        &self,
83        user_id: &UserId,
84        family_id: &TokenFamilyId,
85    ) -> Result<(), Self::Error> {
86        for (id, token) in self.inner.snapshot() {
87            if &token.user_id == user_id && token.family_id.as_ref() == Some(family_id) {
88                self.inner.update(&id, |t| t.revoked = true);
89            }
90        }
91        Ok(())
92    }
93
94    async fn active_tokens(&self, user_id: &UserId) -> Result<Vec<RefreshToken>, Self::Error> {
95        let mut tokens: Vec<RefreshToken> = self
96            .inner
97            .snapshot()
98            .into_iter()
99            .filter(|(_, t)| &t.user_id == user_id && !t.revoked)
100            .map(|(_, t)| t)
101            .collect();
102        tokens.sort_by_key(|t| t.issued_at);
103        Ok(tokens)
104    }
105
106    // ── Required transactional methods ──────────────────────────────────
107    //
108    // The in-memory store is single-process and infallible, so atomicity
109    // is trivially satisfied: each individual `update`/`put` call holds
110    // the shared map lock for the duration of its mutation, and the
111    // observable end state matches a transactional revoke+insert.
112
113    async fn issue_with_eviction(
114        &self,
115        evict_ids: &[RefreshTokenId],
116        new_token: &RefreshToken,
117    ) -> Result<(), Self::Error> {
118        for id in evict_ids {
119            self.revoke_token(id).await?;
120        }
121        self.store_token(new_token).await
122    }
123
124    async fn rotate_token(
125        &self,
126        parent_id: &RefreshTokenId,
127        new_token: &RefreshToken,
128    ) -> Result<(), Self::Error> {
129        self.revoke_token(parent_id).await?;
130        self.store_token(new_token).await
131    }
132}