Skip to main content

siderite_sessions/
cache_store.rs

1//! [`CacheStore`]: sessions in any [`siderite_cache::Cache`].
2
3use crate::id::id_hash;
4use crate::store::{SessionRecord, SessionStore, StoreError};
5use async_trait::async_trait;
6use siderite_cache::{Cache, CacheExt};
7use std::sync::Arc;
8use std::time::Duration;
9
10const DEFAULT_PREFIX: &str = "siderite:session:";
11/// How long a deleted id stays refused: longer than the default absolute
12/// lifetime of 12 hours.
13const DEFAULT_TOMBSTONE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
14
15/// Sessions stored as JSON in a [`Cache`], with the store TTL.
16///
17/// Revoking a user is one write: it increments a per-user epoch counter,
18/// and [`load`](SessionStore::load) rejects any record whose
19/// [`auth_epoch`](SessionRecord::auth_epoch) differs from the counter.
20/// That works on `MemoryCache` and Redis alike and also stops a stale
21/// in-flight write from reviving a revoked session.
22///
23/// The epoch counters have no expiry. Use a cache that does not evict
24/// them: Redis with `noeviction` or a `volatile-*` policy, or a
25/// `MemoryCache` sized so it never evicts (it is an LRU, and an evicted
26/// counter would revive revoked sessions). Keys hold the session id hash
27/// and a hash of the user name, never the raw values.
28///
29/// Deleting a session (logout, rotation) also writes a tombstone for its
30/// id, and [`load`](SessionStore::load) refuses an id with a tombstone.
31/// The cache has no conditional write, so a slow request racing a logout
32/// could store the record again; the tombstone keeps that record from
33/// ever being served. A hit therefore costs one more read. The tombstone
34/// lifetime ([`with_tombstone_ttl`](Self::with_tombstone_ttl), 24 hours
35/// by default) must be at least the session's absolute lifetime;
36/// [`update`](SessionStore::update) never writes a record that outlives
37/// it.
38///
39/// Errors from the cache are replaced by a generic [`StoreError`]: cache
40/// error texts can carry addresses.
41pub struct CacheStore<C: Cache> {
42    cache: Arc<C>,
43    prefix: String,
44    tombstone_ttl: Duration,
45}
46
47impl<C: Cache> Clone for CacheStore<C> {
48    fn clone(&self) -> Self {
49        Self {
50            cache: Arc::clone(&self.cache),
51            prefix: self.prefix.clone(),
52            tombstone_ttl: self.tombstone_ttl,
53        }
54    }
55}
56
57impl<C: Cache> std::fmt::Debug for CacheStore<C> {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("CacheStore")
60            .field("prefix", &self.prefix)
61            .finish_non_exhaustive()
62    }
63}
64
65impl<C: Cache> CacheStore<C> {
66    /// Store sessions in `cache` under the `siderite:session:` prefix.
67    #[must_use]
68    pub fn new(cache: C) -> Self {
69        Self {
70            cache: Arc::new(cache),
71            prefix: DEFAULT_PREFIX.to_owned(),
72            tombstone_ttl: DEFAULT_TOMBSTONE_TTL,
73        }
74    }
75
76    /// Keep deleted ids refused for `ttl`. Set it to at least the
77    /// session's [absolute lifetime](crate::SessionConfig::absolute_lifetime).
78    #[must_use]
79    pub fn with_tombstone_ttl(mut self, ttl: Duration) -> Self {
80        self.tombstone_ttl = ttl;
81        self
82    }
83
84    /// Use another key prefix, for example one per application.
85    #[must_use]
86    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
87        self.prefix = prefix.into();
88        self
89    }
90
91    fn record_key(&self, id_hash: &str) -> String {
92        format!("{}s:{id_hash}", self.prefix)
93    }
94
95    fn tombstone_key(&self, id_hash: &str) -> String {
96        format!("{}d:{id_hash}", self.prefix)
97    }
98
99    async fn deleted(&self, id_hash: &str) -> Result<bool, StoreError> {
100        self.cache
101            .get(&self.tombstone_key(id_hash))
102            .await
103            .map(|found| found.is_some())
104            .map_err(|_| unavailable())
105    }
106
107    fn epoch_key(&self, user: &str) -> String {
108        format!("{}e:{}", self.prefix, id_hash(user))
109    }
110
111    async fn epoch(&self, user: &str) -> Result<u64, StoreError> {
112        let bytes = self
113            .cache
114            .get(&self.epoch_key(user))
115            .await
116            .map_err(|_| unavailable())?;
117        let Some(bytes) = bytes else {
118            return Ok(0);
119        };
120        std::str::from_utf8(&bytes)
121            .ok()
122            .and_then(|text| text.parse::<u64>().ok())
123            .ok_or_else(|| StoreError::new("corrupt revocation epoch"))
124    }
125}
126
127fn unavailable() -> StoreError {
128    StoreError::new("cache backend unavailable")
129}
130
131#[async_trait]
132impl<C: Cache> SessionStore for CacheStore<C> {
133    async fn load(
134        &self,
135        id_hash: &str,
136    ) -> Result<Option<SessionRecord>, StoreError> {
137        let key = self.record_key(id_hash);
138        let record = self
139            .cache
140            .get_json::<SessionRecord>(&key)
141            .await
142            .map_err(|_| unavailable())?;
143        let Some(record) = record else {
144            return Ok(None);
145        };
146        if self.deleted(id_hash).await? {
147            return Ok(None);
148        }
149        if let Some(user) = &record.user
150            && self.epoch(user).await? != record.auth_epoch
151        {
152            return Ok(None);
153        }
154        Ok(Some(record))
155    }
156
157    async fn save(
158        &self,
159        id_hash: &str,
160        record: &SessionRecord,
161        ttl: Duration,
162    ) -> Result<(), StoreError> {
163        let key = self.record_key(id_hash);
164        self.cache
165            .set_json(&key, record, Some(ttl))
166            .await
167            .map_err(|_| unavailable())
168    }
169
170    async fn update(
171        &self,
172        id_hash: &str,
173        record: &SessionRecord,
174        ttl: Duration,
175    ) -> Result<bool, StoreError> {
176        let key = self.record_key(id_hash);
177        let stored = self.cache.get(&key).await.map_err(|_| unavailable())?;
178        if stored.is_none() || self.deleted(id_hash).await? {
179            return Ok(false);
180        }
181        let ttl = ttl.min(self.tombstone_ttl);
182        self.cache
183            .set_json(&key, record, Some(ttl))
184            .await
185            .map(|()| true)
186            .map_err(|_| unavailable())
187    }
188
189    async fn delete(&self, id_hash: &str) -> Result<(), StoreError> {
190        // The tombstone first: if removing the record then fails, the id
191        // is already refused.
192        let tombstone = self.tombstone_key(id_hash);
193        let ttl = Some(self.tombstone_ttl);
194        self.cache
195            .set(&tombstone, b"1".to_vec(), ttl)
196            .await
197            .map_err(|_| unavailable())?;
198        let key = self.record_key(id_hash);
199        self.cache
200            .delete(&key)
201            .await
202            .map(|_| ())
203            .map_err(|_| unavailable())
204    }
205
206    async fn revoke_user(&self, user: &str) -> Result<(), StoreError> {
207        self.cache
208            .increment(&self.epoch_key(user), 1)
209            .await
210            .map(|_| ())
211            .map_err(|_| unavailable())
212    }
213
214    async fn auth_epoch(&self, user: &str) -> Result<u64, StoreError> {
215        self.epoch(user).await
216    }
217}