Skip to main content

arc_core/
session.rs

1//! # Session Store
2//!
3//! Server-side JWT session registry (HIPAA-4, §164.312(d) Person Authentication).
4//! Where [`AuditMetadata`](crate::audit::AuditMetadata) audits writes and
5//! [`AccessLogger`](crate::access_log::AccessLogger) audits reads, this trait
6//! makes JWTs **revocable**: a stolen token stays valid only until the
7//! corresponding `jti` is removed from the store.
8//!
9//! ## Failure semantics
10//!
11//! `is_valid` MUST fail closed when the underlying sink is unreachable:
12//! revocation is a security control, and we cannot prove a token is *not*
13//! revoked when the store is down. Middleware that consults this trait
14//! returns 503, never 200.
15//!
16//! Note this is the **opposite** of the `AccessLogger` policy where read
17//! audit failures fail open — different concerns, different defaults.
18
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use uuid::Uuid;
23
24/// Errors emitted by [`SessionStore`] implementations.
25#[derive(Debug, Error)]
26pub enum SessionStoreError {
27    #[error("session store sink failure: {0}")]
28    Sink(String),
29    #[error("session not found: {0}")]
30    NotFound(Uuid),
31    #[error("session store validation failure: {0}")]
32    Validation(String),
33}
34
35/// Persistent record of an issued JWT session.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37pub struct SessionRecord {
38    pub jti: Uuid,
39    pub actor_id: String,
40    pub created_at_us: i64,
41    pub expires_at_us: i64,
42    pub revoked_at_us: Option<i64>,
43}
44
45impl SessionRecord {
46    /// True iff the record exists, has not been revoked, and has not expired
47    /// at the supplied wall-clock instant.
48    pub fn is_valid_at(&self, now_us: i64) -> bool {
49        self.revoked_at_us.is_none() && self.expires_at_us > now_us
50    }
51}
52
53/// Server-side registry of issued JWTs.
54///
55/// Implementations:
56/// - [`InMemorySessionStore`] — `Arc<Mutex<HashMap>>`, ships in this crate
57///   behind `test-utils` and is also a viable single-node production option
58/// - `SqliteSessionStore` — in `arc-es-sqlite`, durable
59/// - Future: Postgres, Redis
60#[async_trait]
61pub trait SessionStore: Send + Sync {
62    /// Record a freshly-minted token. Called by the login controller before
63    /// it returns the token to the client; on failure, the controller must
64    /// refuse to hand out the token.
65    async fn record_session(&self, record: SessionRecord) -> Result<(), SessionStoreError>;
66
67    /// True if the `jti` is recorded, not revoked, and not expired.
68    /// Returns `Err(Sink)` on store unavailability — middleware must fail closed.
69    async fn is_valid(&self, jti: Uuid, now_us: i64) -> Result<bool, SessionStoreError>;
70
71    /// Mark the session revoked. Idempotent; revoking an unknown `jti`
72    /// returns `Err(NotFound)` so callers can distinguish "already gone"
73    /// from "double revoke" if they care.
74    async fn revoke(&self, jti: Uuid, now_us: i64) -> Result<(), SessionStoreError>;
75
76    /// Bulk-revoke every active session for an actor (breach response).
77    /// Returns the count of records affected.
78    async fn revoke_all_for_actor(
79        &self,
80        actor_id: &str,
81        now_us: i64,
82    ) -> Result<usize, SessionStoreError>;
83
84    /// Hard-delete records past their `expires_at_us`. Returns rows removed.
85    /// Implementations may run this on a schedule; callers may also invoke
86    /// it inline at startup.
87    async fn prune_expired(&self, now_us: i64) -> Result<usize, SessionStoreError>;
88}
89
90// ─────────────────────────────────────────────────────────────────────────────
91// In-memory implementation. Public behind the `test-utils` feature so
92// downstream tests and small single-node deployments can use it.
93// ─────────────────────────────────────────────────────────────────────────────
94
95#[cfg(any(test, feature = "test-utils"))]
96mod in_memory {
97    use super::*;
98    use std::collections::HashMap;
99    use std::sync::Arc;
100    use tokio::sync::Mutex;
101
102    #[derive(Clone, Default)]
103    pub struct InMemorySessionStore {
104        inner: Arc<Mutex<HashMap<Uuid, SessionRecord>>>,
105    }
106
107    impl InMemorySessionStore {
108        pub fn new() -> Self {
109            Self::default()
110        }
111    }
112
113    #[async_trait]
114    impl SessionStore for InMemorySessionStore {
115        async fn record_session(&self, record: SessionRecord) -> Result<(), SessionStoreError> {
116            if record.actor_id.trim().is_empty() {
117                return Err(SessionStoreError::Validation("actor_id empty".into()));
118            }
119            if record.expires_at_us <= record.created_at_us {
120                return Err(SessionStoreError::Validation(
121                    "expires_at_us must be > created_at_us".into(),
122                ));
123            }
124            self.inner.lock().await.insert(record.jti, record);
125            Ok(())
126        }
127
128        async fn is_valid(&self, jti: Uuid, now_us: i64) -> Result<bool, SessionStoreError> {
129            let g = self.inner.lock().await;
130            Ok(g.get(&jti).map(|r| r.is_valid_at(now_us)).unwrap_or(false))
131        }
132
133        async fn revoke(&self, jti: Uuid, now_us: i64) -> Result<(), SessionStoreError> {
134            let mut g = self.inner.lock().await;
135            match g.get_mut(&jti) {
136                Some(r) => {
137                    r.revoked_at_us = Some(now_us);
138                    Ok(())
139                }
140                None => Err(SessionStoreError::NotFound(jti)),
141            }
142        }
143
144        async fn revoke_all_for_actor(
145            &self,
146            actor_id: &str,
147            now_us: i64,
148        ) -> Result<usize, SessionStoreError> {
149            let mut g = self.inner.lock().await;
150            let mut n = 0;
151            for r in g.values_mut() {
152                if r.actor_id == actor_id && r.revoked_at_us.is_none() {
153                    r.revoked_at_us = Some(now_us);
154                    n += 1;
155                }
156            }
157            Ok(n)
158        }
159
160        async fn prune_expired(&self, now_us: i64) -> Result<usize, SessionStoreError> {
161            let mut g = self.inner.lock().await;
162            let before = g.len();
163            g.retain(|_, r| r.expires_at_us > now_us);
164            Ok(before - g.len())
165        }
166    }
167}
168
169#[cfg(any(test, feature = "test-utils"))]
170pub use in_memory::InMemorySessionStore;
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn rec(jti: Uuid, actor: &str, ttl_us: i64) -> SessionRecord {
177        let now = 1_700_000_000_000_000;
178        SessionRecord {
179            jti,
180            actor_id: actor.to_string(),
181            created_at_us: now,
182            expires_at_us: now + ttl_us,
183            revoked_at_us: None,
184        }
185    }
186
187    #[tokio::test]
188    async fn test_record_then_is_valid_true() {
189        let s = InMemorySessionStore::new();
190        let id = Uuid::new_v4();
191        s.record_session(rec(id, "alice", 1_000_000)).await.unwrap();
192        assert!(s.is_valid(id, 1_700_000_000_000_001).await.unwrap());
193    }
194
195    #[tokio::test]
196    async fn test_unknown_jti_is_invalid() {
197        let s = InMemorySessionStore::new();
198        assert!(!s.is_valid(Uuid::new_v4(), 0).await.unwrap());
199    }
200
201    #[tokio::test]
202    async fn test_revoke_makes_invalid() {
203        let s = InMemorySessionStore::new();
204        let id = Uuid::new_v4();
205        s.record_session(rec(id, "alice", 1_000_000)).await.unwrap();
206        s.revoke(id, 1_700_000_000_000_500).await.unwrap();
207        assert!(!s.is_valid(id, 1_700_000_000_000_600).await.unwrap());
208    }
209
210    #[tokio::test]
211    async fn test_revoke_unknown_returns_not_found() {
212        let s = InMemorySessionStore::new();
213        let id = Uuid::new_v4();
214        let err = s.revoke(id, 0).await.unwrap_err();
215        assert!(matches!(err, SessionStoreError::NotFound(j) if j == id));
216    }
217
218    #[tokio::test]
219    async fn test_expired_session_is_invalid_without_revoke() {
220        let s = InMemorySessionStore::new();
221        let id = Uuid::new_v4();
222        let now = 1_700_000_000_000_000;
223        let r = SessionRecord {
224            jti: id,
225            actor_id: "alice".into(),
226            created_at_us: now,
227            expires_at_us: now + 100,
228            revoked_at_us: None,
229        };
230        s.record_session(r).await.unwrap();
231        assert!(s.is_valid(id, now + 50).await.unwrap());
232        assert!(!s.is_valid(id, now + 200).await.unwrap());
233    }
234
235    #[tokio::test]
236    async fn test_revoke_all_for_actor_only_targets_that_actor() {
237        let s = InMemorySessionStore::new();
238        let alice1 = Uuid::new_v4();
239        let alice2 = Uuid::new_v4();
240        let bob = Uuid::new_v4();
241        s.record_session(rec(alice1, "alice", 1_000_000))
242            .await
243            .unwrap();
244        s.record_session(rec(alice2, "alice", 1_000_000))
245            .await
246            .unwrap();
247        s.record_session(rec(bob, "bob", 1_000_000)).await.unwrap();
248
249        let now = 1_700_000_000_000_500;
250        let n = s.revoke_all_for_actor("alice", now).await.unwrap();
251        assert_eq!(n, 2);
252
253        assert!(!s.is_valid(alice1, now + 1).await.unwrap());
254        assert!(!s.is_valid(alice2, now + 1).await.unwrap());
255        assert!(s.is_valid(bob, now + 1).await.unwrap());
256    }
257
258    #[tokio::test]
259    async fn test_prune_expired_only_removes_expired() {
260        let s = InMemorySessionStore::new();
261        let now = 1_700_000_000_000_000;
262
263        let live = Uuid::new_v4();
264        let expired = Uuid::new_v4();
265        s.record_session(SessionRecord {
266            jti: live,
267            actor_id: "a".into(),
268            created_at_us: now,
269            expires_at_us: now + 1_000_000,
270            revoked_at_us: None,
271        })
272        .await
273        .unwrap();
274        s.record_session(SessionRecord {
275            jti: expired,
276            actor_id: "a".into(),
277            created_at_us: now - 2000,
278            expires_at_us: now - 1000,
279            revoked_at_us: None,
280        })
281        .await
282        .unwrap();
283
284        let removed = s.prune_expired(now).await.unwrap();
285        assert_eq!(removed, 1);
286        assert!(s.is_valid(live, now + 1).await.unwrap());
287        assert!(!s.is_valid(expired, now + 1).await.unwrap());
288    }
289
290    #[tokio::test]
291    async fn test_record_session_validates_inputs() {
292        let s = InMemorySessionStore::new();
293        let bad = SessionRecord {
294            jti: Uuid::new_v4(),
295            actor_id: "  ".into(),
296            created_at_us: 100,
297            expires_at_us: 200,
298            revoked_at_us: None,
299        };
300        assert!(matches!(
301            s.record_session(bad).await.unwrap_err(),
302            SessionStoreError::Validation(_)
303        ));
304    }
305
306    #[tokio::test]
307    async fn test_record_session_rejects_inverted_expiry() {
308        let s = InMemorySessionStore::new();
309        let bad = SessionRecord {
310            jti: Uuid::new_v4(),
311            actor_id: "alice".into(),
312            created_at_us: 200,
313            expires_at_us: 100,
314            revoked_at_us: None,
315        };
316        assert!(matches!(
317            s.record_session(bad).await.unwrap_err(),
318            SessionStoreError::Validation(_)
319        ));
320    }
321}