Skip to main content

issuerd_server/
session_cache.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (C) 2026 Dmitry Andreev. <da@issuerd.org>
3//
4// Session-validity cache-aside layer: keeps the per-request "does this
5// session still exist" check off the database (userinfo / introspect / token
6// resolution hot paths).
7
8//! Cache-aside snapshots of user-session validity.
9//!
10//! Every `userinfo` and `introspect` call must confirm the token's user
11//! session still exists in storage; that check used to cost two SQL queries
12//! per request. This module caches the answer under
13//! `cache_keys::session(realm, sid)` as a small JSON value:
14//!
15//! - positive: `{"u": "<user_id>", "v": <u64 version>}`, TTL
16//!   `[cache] read_cache_ttl_secs` (default 60 s, `0` disables the
17//!   cache entirely — pure-DB behavior);
18//! - negative: `{"x":1}` with a short TTL (absorbs replay floods of
19//!   deleted-session tokens; forged sids are impossible because token
20//!   signature validation runs before this layer).
21//!
22//! Invalidation:
23//! - single-session deletes (logout, admin revoke, idle cleanup) call
24//!   [`invalidate_session`] synchronously — revocation is immediate;
25//! - bulk per-user revocation (user delete, which DB-cascades sessions
26//!   without touching Rust) bumps the `sessv:{realm}:{user_id}` counter via
27//!   [`bump_user_session_version`]; cached snapshots carrying an older
28//!   version are treated as misses and re-read from storage;
29//! - anything that still bypasses both (e.g. a realm delete cascade on a
30//!   node that missed invalidation) is bounded by the positive TTL —
31//!   the accepted staleness window, same class as Keycloak's Infinispan
32//!   invalidation latency. Realm deletes are additionally covered because
33//!   `resolve_issuer_realm` invalidation makes the issuer unresolvable.
34//!
35//! Cache outages degrade, never fail: every cache error falls back to the
36//! storage path with a WARN, so a Redis hiccup costs latency, not answers.
37
38use std::sync::Arc;
39use std::time::Duration;
40
41use issuerd_cluster::cache_keys;
42use issuerd_core::{DistributedCache, RealmId, SessionId, UserId};
43use tracing::{debug, warn};
44
45use crate::state::ServerState;
46
47/// TTL of the negative ("session does not exist") marker. Short on purpose:
48/// it only needs to absorb replay bursts of a deleted-session token.
49const NEGATIVE_TTL: Duration = Duration::from_secs(5);
50
51/// The validity-relevant slice of a user session.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct SessionSnapshot {
54    pub user_id: UserId,
55}
56
57/// Cached entry: positive (`u`/`v`) or the negative marker (`x`).
58#[derive(Debug, serde::Serialize, serde::Deserialize)]
59#[serde(untagged)]
60enum CacheEntry {
61    Negative { x: u8 },
62    Positive { u: String, v: u64 },
63}
64
65fn parse_entry(bytes: &[u8]) -> Option<CacheEntry> {
66    serde_json::from_slice(bytes).ok()
67}
68
69/// Read the per-user session version counter; a missing key is version 0.
70/// Cache errors report `None` so callers fall back to the database.
71async fn current_version(
72    cache: &Arc<dyn DistributedCache>,
73    realm_id: &RealmId,
74    user_id: &UserId,
75) -> Option<u64> {
76    let key = cache_keys::session_version(realm_id.as_ref(), user_id.as_ref());
77    match cache.get(&key).await {
78        Ok(Some(bytes)) => match std::str::from_utf8(&bytes).ok()?.parse::<u64>() {
79            Ok(v) => Some(v),
80            Err(_) => {
81                warn!(realm = %realm_id, "session version counter unparsable; treating as 0");
82                Some(0)
83            }
84        },
85        Ok(None) => Some(0),
86        Err(e) => {
87            warn!(realm = %realm_id, error = %e, "session version read failed; falling back to storage");
88            None
89        }
90    }
91}
92
93/// Resolve a session's validity snapshot, cache-aside.
94///
95/// `Some` means the session exists (presence is the validity signal, exactly
96/// as the previous direct `get_user_session` check defined it); `None` means
97/// missing, unreadable, or stale-versioned — callers keep their existing
98/// fail-closed handling. Storage errors also surface as `None` here; the
99/// caller's `is_sessionless_client_token` logic decides the final verdict.
100pub async fn session_snapshot(
101    state: &Arc<ServerState>,
102    realm_id: &RealmId,
103    sid: &SessionId,
104) -> Option<SessionSnapshot> {
105    let ttl_secs = state.config.cache.read_cache_ttl_secs;
106    if ttl_secs == 0 {
107        // Cache disabled: pure-DB behavior.
108        return match state.storage.get_user_session(realm_id, sid).await {
109            Ok(Some(session)) => Some(SessionSnapshot {
110                user_id: session.user_id,
111            }),
112            Ok(None) => None,
113            Err(e) => {
114                debug!(realm = %realm_id, error = %e, "session read failed");
115                None
116            }
117        };
118    }
119
120    let key = cache_keys::session(realm_id.as_ref(), sid.as_ref());
121    match state.cache.get(&key).await {
122        Ok(Some(bytes)) => match parse_entry(&bytes) {
123            Some(CacheEntry::Negative { .. }) => {
124                debug!(realm = %realm_id, "session cache: negative hit");
125                None
126            }
127            Some(CacheEntry::Positive { u, v }) => {
128                let Ok(user_id) = UserId::new(&u) else {
129                    debug!(realm = %realm_id, "session cache: malformed user id; re-reading");
130                    return load_and_cache(state, realm_id, sid, ttl_secs).await;
131                };
132                match current_version(&state.cache, realm_id, &user_id).await {
133                    Some(current) if current == v => {
134                        debug!(realm = %realm_id, "session cache: hit");
135                        Some(SessionSnapshot { user_id })
136                    }
137                    // Version mismatch (bulk revocation) or version read
138                    // failure: re-read from storage.
139                    _ => load_and_cache(state, realm_id, sid, ttl_secs).await,
140                }
141            }
142            None => {
143                debug!(realm = %realm_id, "session cache: malformed entry; re-reading");
144                load_and_cache(state, realm_id, sid, ttl_secs).await
145            }
146        },
147        Ok(None) => load_and_cache(state, realm_id, sid, ttl_secs).await,
148        Err(e) => {
149            warn!(realm = %realm_id, error = %e, "session cache read failed; falling back to storage");
150            load_and_cache(state, realm_id, sid, ttl_secs).await
151        }
152    }
153}
154
155/// Storage read + cache populate behind [`session_snapshot`].
156async fn load_and_cache(
157    state: &Arc<ServerState>,
158    realm_id: &RealmId,
159    sid: &SessionId,
160    ttl_secs: u64,
161) -> Option<SessionSnapshot> {
162    let key = cache_keys::session(realm_id.as_ref(), sid.as_ref());
163    match state.storage.get_user_session(realm_id, sid).await {
164        Ok(Some(session)) => {
165            // Best-effort version tag: an unreadable counter stores v=0, which
166            // simply re-validates against storage after any bump (a real
167            // version >0 never matches the stored 0, so the entry misses).
168            let version =
169                current_version(&state.cache, realm_id, &session.user_id).await.unwrap_or(0);
170            let entry = CacheEntry::Positive {
171                u: session.user_id.to_string(),
172                v: version,
173            };
174            if let Ok(bytes) = serde_json::to_vec(&entry) {
175                if let Err(e) =
176                    state.cache.set(&key, bytes, Some(Duration::from_secs(ttl_secs))).await
177                {
178                    warn!(realm = %realm_id, error = %e, "session cache write failed");
179                }
180            }
181            Some(SessionSnapshot {
182                user_id: session.user_id,
183            })
184        }
185        Ok(None) => {
186            if let Ok(bytes) = serde_json::to_vec(&CacheEntry::Negative { x: 1 }) {
187                if let Err(e) = state.cache.set(&key, bytes, Some(NEGATIVE_TTL)).await {
188                    warn!(realm = %realm_id, error = %e, "session cache write failed");
189                }
190            }
191            None
192        }
193        Err(e) => {
194            debug!(realm = %realm_id, error = %e, "session read failed");
195            None
196        }
197    }
198}
199
200/// Drop a session's cached snapshot after its deletion (logout, admin
201/// revoke, idle cleanup). Best-effort: the positive TTL bounds staleness if
202/// the delete fails.
203pub async fn invalidate_session(state: &Arc<ServerState>, realm_id: &RealmId, sid: &SessionId) {
204    invalidate_session_entry(state.cache.as_ref(), realm_id, sid).await;
205}
206
207/// [`invalidate_session`] over a bare cache reference — shared by callers
208/// that do not hold a full [`ServerState`].
209pub async fn invalidate_session_entry(
210    cache: &dyn DistributedCache,
211    realm_id: &RealmId,
212    sid: &SessionId,
213) {
214    let key = cache_keys::session(realm_id.as_ref(), sid.as_ref());
215    if let Err(e) = cache.delete(&key).await {
216        warn!(realm = %realm_id, error = %e, "session cache invalidation failed");
217    }
218}
219
220/// Bump the per-user session-validity version counter (bulk revocation:
221/// user delete / logout-all). Every cached snapshot of this user's sessions
222/// misses on its next read. No TTL: the counter must outlive any cached
223/// snapshot it invalidates. Best-effort: snapshots keep their TTL as the
224/// fail-safe bound if the bump fails.
225pub async fn bump_user_session_version(
226    cache: &dyn DistributedCache,
227    realm_id: &RealmId,
228    user_id: &UserId,
229) {
230    let key = cache_keys::session_version(realm_id.as_ref(), user_id.as_ref());
231    if let Err(e) = cache.increment(&key, None).await {
232        warn!(realm = %realm_id, error = %e, "session version bump failed");
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use issuerd_core::{AuthMethod, UserSession, Username};
240
241    async fn setup() -> (Arc<ServerState>, issuerd_core::RealmId, UserId, SessionId) {
242        let state = Arc::new(
243            ServerState::from_config(&crate::config::ServerConfig::default()).await.unwrap(),
244        );
245        let realm_id = RealmId::new("master").unwrap();
246        let user_id = UserId::new("admin").unwrap();
247        let sid = SessionId::new(issuerd_core::utils::generate_id()).unwrap();
248        (state, realm_id, user_id, sid)
249    }
250
251    fn session(realm_id: &RealmId, user_id: &UserId, sid: &SessionId) -> UserSession {
252        UserSession {
253            id: sid.clone(),
254            realm_id: realm_id.clone(),
255            user_id: user_id.clone(),
256            login_username: Username::new("admin").unwrap(),
257            ip_address: "127.0.0.1".parse().unwrap(),
258            auth_method: AuthMethod::Password,
259            remember_me: false,
260            offline: false,
261            started: chrono::Utc::now(),
262            last_session_refresh: chrono::Utc::now(),
263            auth_time: chrono::Utc::now(),
264            impersonator: None,
265            clients: vec![],
266        }
267    }
268
269    fn cache_key(realm_id: &RealmId, sid: &SessionId) -> String {
270        cache_keys::session(realm_id.as_ref(), sid.as_ref())
271    }
272
273    #[tokio::test]
274    async fn snapshot_caches_positive_entry() {
275        let (state, realm_id, user_id, sid) = setup().await;
276        state
277            .storage
278            .create_user_session(&realm_id, &session(&realm_id, &user_id, &sid))
279            .await
280            .unwrap();
281
282        let snap = session_snapshot(&state, &realm_id, &sid).await.expect("snapshot");
283        assert_eq!(snap.user_id, user_id);
284
285        // Second read must come from the cache: deleting the row directly
286        // (bypassing invalidation) still answers within the TTL window —
287        // the documented bounded-staleness trade-off.
288        state.storage.delete_user_session(&realm_id, &sid).await.unwrap();
289        let snap = session_snapshot(&state, &realm_id, &sid).await;
290        assert!(snap.is_some(), "stale positive entry served within TTL");
291
292        // Synchronous invalidation takes effect immediately.
293        state
294            .storage
295            .create_user_session(&realm_id, &session(&realm_id, &user_id, &sid))
296            .await
297            .unwrap();
298        invalidate_session(&state, &realm_id, &sid).await;
299        state.storage.delete_user_session(&realm_id, &sid).await.unwrap();
300        assert!(session_snapshot(&state, &realm_id, &sid).await.is_none());
301    }
302
303    #[tokio::test]
304    async fn snapshot_negative_entry_absorbs_replays() {
305        let (state, realm_id, _, sid) = setup().await;
306
307        // First miss hits storage and caches the negative marker.
308        assert!(session_snapshot(&state, &realm_id, &sid).await.is_none());
309        let raw = state
310            .cache
311            .get(&cache_key(&realm_id, &sid))
312            .await
313            .unwrap()
314            .expect("negative marker cached");
315        assert_eq!(std::str::from_utf8(&raw).unwrap(), r#"{"x":1}"#);
316
317        // A session appearing under the same id within the negative window is
318        // NOT seen (replay absorption); after the marker is dropped the new
319        // session resolves.
320        let user_id = UserId::new("admin").unwrap();
321        state
322            .storage
323            .create_user_session(&realm_id, &session(&realm_id, &user_id, &sid))
324            .await
325            .unwrap();
326        assert!(session_snapshot(&state, &realm_id, &sid).await.is_none());
327        state.cache.delete(&cache_key(&realm_id, &sid)).await.unwrap();
328        assert!(session_snapshot(&state, &realm_id, &sid).await.is_some());
329    }
330
331    #[tokio::test]
332    async fn version_bump_invalidates_cached_snapshot() {
333        let (state, realm_id, user_id, sid) = setup().await;
334        state
335            .storage
336            .create_user_session(&realm_id, &session(&realm_id, &user_id, &sid))
337            .await
338            .unwrap();
339        assert!(session_snapshot(&state, &realm_id, &sid).await.is_some());
340
341        // Bulk per-user revocation: sessions cascade away in storage and the
342        // version bump makes every cached snapshot of the user miss.
343        state.storage.delete_user(&realm_id, &user_id).await.unwrap();
344        bump_user_session_version(state.cache.as_ref(), &realm_id, &user_id).await;
345        assert!(session_snapshot(&state, &realm_id, &sid).await.is_none());
346
347        // The version counter persists (no TTL).
348        let v = state
349            .cache
350            .get(&cache_keys::session_version(realm_id.as_ref(), user_id.as_ref()))
351            .await
352            .unwrap()
353            .expect("version counter present");
354        assert_eq!(std::str::from_utf8(&v).unwrap(), "1");
355    }
356
357    #[tokio::test]
358    async fn version_bump_alone_does_not_hide_live_session() {
359        let (state, realm_id, user_id, sid) = setup().await;
360        state
361            .storage
362            .create_user_session(&realm_id, &session(&realm_id, &user_id, &sid))
363            .await
364            .unwrap();
365        assert!(session_snapshot(&state, &realm_id, &sid).await.is_some());
366
367        // A bump without an underlying delete re-reads from storage and
368        // re-caches at the new version — the session stays valid.
369        bump_user_session_version(state.cache.as_ref(), &realm_id, &user_id).await;
370        assert!(session_snapshot(&state, &realm_id, &sid).await.is_some());
371        assert!(session_snapshot(&state, &realm_id, &sid).await.is_some());
372    }
373
374    #[tokio::test]
375    async fn disabled_cache_is_pure_db() {
376        let mut cfg = crate::config::ServerConfig::default();
377        cfg.cache.read_cache_ttl_secs = 0;
378        let state = Arc::new(ServerState::from_config(&cfg).await.unwrap());
379        let realm_id = RealmId::new("master").unwrap();
380        let user_id = UserId::new("admin").unwrap();
381        let sid = SessionId::new(issuerd_core::utils::generate_id()).unwrap();
382        state
383            .storage
384            .create_user_session(&realm_id, &session(&realm_id, &user_id, &sid))
385            .await
386            .unwrap();
387
388        assert!(session_snapshot(&state, &realm_id, &sid).await.is_some());
389        // Nothing was cached.
390        assert!(state.cache.get(&cache_key(&realm_id, &sid)).await.unwrap().is_none());
391        // A direct storage delete is seen immediately (no TTL window).
392        state.storage.delete_user_session(&realm_id, &sid).await.unwrap();
393        assert!(session_snapshot(&state, &realm_id, &sid).await.is_none());
394        assert!(state.cache.get(&cache_key(&realm_id, &sid)).await.unwrap().is_none());
395    }
396
397    #[tokio::test]
398    async fn cache_outage_falls_back_to_storage() {
399        #[derive(Debug)]
400        struct DeadCache;
401        #[async_trait::async_trait]
402        impl DistributedCache for DeadCache {
403            async fn get(&self, _: &str) -> Result<Option<Vec<u8>>, issuerd_core::IssuerdError> {
404                Err(issuerd_core::IssuerdError::ServerError("cache down".into()))
405            }
406            async fn set(
407                &self,
408                _: &str,
409                _: Vec<u8>,
410                _: Option<Duration>,
411            ) -> Result<(), issuerd_core::IssuerdError> {
412                Err(issuerd_core::IssuerdError::ServerError("cache down".into()))
413            }
414            async fn delete(&self, _: &str) -> Result<(), issuerd_core::IssuerdError> {
415                Err(issuerd_core::IssuerdError::ServerError("cache down".into()))
416            }
417            async fn compare_and_swap(
418                &self,
419                _: &str,
420                _: Option<Vec<u8>>,
421                _: Vec<u8>,
422            ) -> Result<bool, issuerd_core::IssuerdError> {
423                Err(issuerd_core::IssuerdError::ServerError("cache down".into()))
424            }
425            async fn publish(&self, _: &str, _: Vec<u8>) -> Result<(), issuerd_core::IssuerdError> {
426                Err(issuerd_core::IssuerdError::ServerError("cache down".into()))
427            }
428            async fn subscribe(
429                &self,
430                _: &str,
431                _: Box<dyn Fn(Vec<u8>) + Send + Sync>,
432            ) -> Result<(), issuerd_core::IssuerdError> {
433                Err(issuerd_core::IssuerdError::ServerError("cache down".into()))
434            }
435        }
436
437        let cfg = crate::config::ServerConfig::default();
438        let storage: Arc<dyn issuerd_core::Storage> =
439            Arc::new(issuerd_storage::InMemoryStorage::new());
440        let state = Arc::new(
441            ServerState::from_components(&cfg, storage, Arc::new(DeadCache)).await.unwrap(),
442        );
443        let realm_id = RealmId::new("master").unwrap();
444        let user_id = UserId::new("admin").unwrap();
445        let sid = SessionId::new(issuerd_core::utils::generate_id()).unwrap();
446        state
447            .storage
448            .create_user_session(&realm_id, &session(&realm_id, &user_id, &sid))
449            .await
450            .unwrap();
451
452        // Reads and writes fail; answers still come from storage.
453        assert!(session_snapshot(&state, &realm_id, &sid).await.is_some());
454        state.storage.delete_user_session(&realm_id, &sid).await.unwrap();
455        assert!(session_snapshot(&state, &realm_id, &sid).await.is_none());
456    }
457}