Skip to main content

featherbit/sessions/
mod.rs

1//! Server-side sessions for the interactive auth plugins.
2//!
3//! In `session.storage: redis` mode a plugin's session cookie shrinks to a
4//! bare random 128-bit id; the payload — the same bytes the plugin seals
5//! into the cookie today — is stored sealed-at-rest under that id with a
6//! small unencrypted [`SessionMeta`] envelope for the operator surface
7//! (list/revoke). The store never sees plaintext tokens.
8//!
9//! Backends implement [`SessionStore`]; `RedisSessionStore` (the `redis`
10//! submodule, `redis-store` feature) is the real one, [`FakeSessionStore`]
11//! serves unit tests. Failure semantics are the spec's: a [`StoreError`]
12//! surfaces as a 503 on the plugin's `error` port — never 401, never
13//! fail-open.
14
15use std::time::Duration;
16
17use async_trait::async_trait;
18use ring::rand::{SecureRandom, SystemRandom};
19use serde::{Deserialize, Serialize};
20
21#[cfg(feature = "redis-store")]
22pub mod redis;
23
24/// A 128-bit random session id, hex-encoded (32 chars). The only thing the
25/// browser holds in redis mode, and deliberately unguessable.
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[allow(dead_code)]
28pub struct SessionId(String);
29
30#[allow(dead_code)]
31impl SessionId {
32    /// Fresh random id from the system RNG (house pattern, see
33    /// `authz_casdoor::random_state`).
34    pub fn random() -> Self {
35        let mut bytes = [0u8; 16];
36        SystemRandom::new()
37            .fill(&mut bytes)
38            .expect("system RNG must produce a session id");
39        Self(bytes.iter().map(|b| format!("{b:02x}")).collect())
40    }
41
42    /// Accepts exactly 32 lowercase hex chars; anything else (including a
43    /// sealed legacy cookie value) is not an id. Keeps junk out of store keys.
44    pub fn parse(value: &str) -> Option<Self> {
45        if value.len() == 32 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
46            Some(Self(value.to_ascii_lowercase()))
47        } else {
48            None
49        }
50    }
51
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55}
56
57/// Unencrypted envelope for the operator surface. `id` is left empty on
58/// `put` (the key already carries it) and filled in by `list`.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[allow(dead_code)]
61pub struct SessionMeta {
62    #[serde(default)]
63    pub id: String,
64    /// Authenticated subject; may be empty (e.g. an opaque token with no
65    /// decodable claims) — such sessions are not indexed by subject.
66    #[serde(default)]
67    pub subject: String,
68    pub plugin: String,
69    #[serde(default)]
70    pub policy: String,
71    #[serde(default)]
72    pub route: String,
73    pub created_at: u64,
74    pub expires_at: u64,
75}
76
77/// Listing filter; `cursor` is backend-opaque (Redis SCAN cursor).
78#[derive(Debug, Clone, Default)]
79#[allow(dead_code)]
80pub struct SessionFilter {
81    pub subject: Option<String>,
82    pub plugin: Option<String>,
83    pub limit: usize,
84    pub cursor: Option<String>,
85}
86
87/// One page of session metadata.
88#[derive(Debug, Clone)]
89#[allow(dead_code)]
90pub struct SessionPage {
91    pub sessions: Vec<SessionMeta>,
92    pub next_cursor: Option<String>,
93}
94
95/// Session-store backend failure. Always maps to 503 on the plugin's
96/// `error` port; callers must never treat it as "unauthenticated".
97#[derive(Debug)]
98#[allow(dead_code)]
99pub struct StoreError(pub String);
100
101impl std::fmt::Display for StoreError {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        write!(f, "session store error: {}", self.0)
104    }
105}
106
107/// A backend holding sealed session payloads plus their meta envelopes.
108#[async_trait]
109#[allow(dead_code)]
110pub trait SessionStore: Send + Sync {
111    /// Upserts a session: sealed payload + meta, both expiring after `ttl`.
112    async fn put(
113        &self,
114        id: &SessionId,
115        sealed: &[u8],
116        ttl: Duration,
117        meta: &SessionMeta,
118    ) -> Result<(), StoreError>;
119    /// The sealed payload, or None if absent/expired.
120    async fn get(&self, id: &SessionId) -> Result<Option<Vec<u8>>, StoreError>;
121    /// Revokes one session (payload + meta + subject-index entry).
122    async fn delete(&self, id: &SessionId) -> Result<(), StoreError>;
123    /// Revokes every session for `subject`; returns how many were removed.
124    async fn delete_subject(&self, subject: &str) -> Result<u64, StoreError>;
125    /// One page of metadata matching `filter`.
126    async fn list(&self, filter: &SessionFilter) -> Result<SessionPage, StoreError>;
127    /// Best-effort short lock for refresh coordination: true = acquired.
128    async fn try_lock(&self, id: &SessionId, ttl: Duration) -> Result<bool, StoreError>;
129    async fn unlock(&self, id: &SessionId) -> Result<(), StoreError>;
130}
131
132/// In-memory `SessionStore` for unit tests across the crate.
133#[cfg(test)]
134#[derive(Default)]
135pub struct FakeSessionStore {
136    #[allow(clippy::type_complexity)]
137    inner: std::sync::Mutex<
138        std::collections::HashMap<String, (Vec<u8>, SessionMeta, std::time::Instant)>,
139    >,
140    locks: std::sync::Mutex<std::collections::HashSet<String>>,
141    /// When set, every call fails — for 503-path tests.
142    pub fail: std::sync::atomic::AtomicBool,
143}
144
145#[cfg(test)]
146impl FakeSessionStore {
147    fn check(&self) -> Result<(), StoreError> {
148        if self.fail.load(std::sync::atomic::Ordering::Relaxed) {
149            Err(StoreError("fake store failure".to_string()))
150        } else {
151            Ok(())
152        }
153    }
154}
155
156#[cfg(test)]
157#[async_trait]
158impl SessionStore for FakeSessionStore {
159    async fn put(
160        &self,
161        id: &SessionId,
162        sealed: &[u8],
163        ttl: Duration,
164        meta: &SessionMeta,
165    ) -> Result<(), StoreError> {
166        self.check()?;
167        self.inner.lock().unwrap().insert(
168            id.as_str().to_string(),
169            (
170                sealed.to_vec(),
171                meta.clone(),
172                std::time::Instant::now() + ttl,
173            ),
174        );
175        Ok(())
176    }
177
178    async fn get(&self, id: &SessionId) -> Result<Option<Vec<u8>>, StoreError> {
179        self.check()?;
180        let mut inner = self.inner.lock().unwrap();
181        match inner.get(id.as_str()) {
182            Some((_, _, exp)) if *exp <= std::time::Instant::now() => {
183                inner.remove(id.as_str());
184                Ok(None)
185            }
186            Some((sealed, _, _)) => Ok(Some(sealed.clone())),
187            None => Ok(None),
188        }
189    }
190
191    async fn delete(&self, id: &SessionId) -> Result<(), StoreError> {
192        self.check()?;
193        self.inner.lock().unwrap().remove(id.as_str());
194        Ok(())
195    }
196
197    async fn delete_subject(&self, subject: &str) -> Result<u64, StoreError> {
198        self.check()?;
199        let mut inner = self.inner.lock().unwrap();
200        let before = inner.len();
201        inner.retain(|_, (_, meta, _)| meta.subject != subject);
202        Ok((before - inner.len()) as u64)
203    }
204
205    async fn list(&self, filter: &SessionFilter) -> Result<SessionPage, StoreError> {
206        self.check()?;
207        let inner = self.inner.lock().unwrap();
208        let mut sessions: Vec<SessionMeta> = inner
209            .iter()
210            .map(|(id, (_, meta, _))| {
211                let mut m = meta.clone();
212                m.id = id.clone();
213                m
214            })
215            .filter(|m| filter.subject.as_deref().is_none_or(|s| m.subject == s))
216            .filter(|m| filter.plugin.as_deref().is_none_or(|p| m.plugin == p))
217            .collect();
218        sessions.sort_by(|a, b| a.id.cmp(&b.id));
219        if filter.limit > 0 {
220            sessions.truncate(filter.limit);
221        }
222        Ok(SessionPage {
223            sessions,
224            next_cursor: None,
225        })
226    }
227
228    async fn try_lock(&self, id: &SessionId, _ttl: Duration) -> Result<bool, StoreError> {
229        self.check()?;
230        Ok(self.locks.lock().unwrap().insert(id.as_str().to_string()))
231    }
232
233    async fn unlock(&self, id: &SessionId) -> Result<(), StoreError> {
234        self.check()?;
235        self.locks.lock().unwrap().remove(id.as_str());
236        Ok(())
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_session_id_random_and_parse() {
246        let id = SessionId::random();
247        assert_eq!(id.as_str().len(), 32);
248        assert!(id.as_str().bytes().all(|b| b.is_ascii_hexdigit()));
249        assert_ne!(SessionId::random().as_str(), id.as_str());
250
251        assert_eq!(SessionId::parse(id.as_str()), Some(id.clone()));
252        // Uppercase normalizes; junk and sealed-cookie-shaped values do not parse.
253        assert!(SessionId::parse(&id.as_str().to_uppercase()).is_some());
254        assert!(SessionId::parse("").is_none());
255        assert!(SessionId::parse("nothex-nothex-nothex-nothex-noth").is_none());
256        assert!(SessionId::parse("abcd").is_none());
257    }
258
259    #[tokio::test]
260    async fn test_fake_store_round_trip_and_revocation() {
261        let store = FakeSessionStore::default();
262        let id = SessionId::random();
263        let meta = SessionMeta {
264            id: String::new(),
265            subject: "alice".to_string(),
266            plugin: "openid-connect".to_string(),
267            policy: "p1".to_string(),
268            route: "r1".to_string(),
269            created_at: 1,
270            expires_at: 2,
271        };
272        store
273            .put(&id, b"sealed", Duration::from_secs(60), &meta)
274            .await
275            .unwrap();
276        assert_eq!(
277            store.get(&id).await.unwrap().as_deref(),
278            Some(&b"sealed"[..])
279        );
280
281        let page = store.list(&SessionFilter::default()).await.unwrap();
282        assert_eq!(page.sessions.len(), 1);
283        assert_eq!(page.sessions[0].id, id.as_str());
284        assert_eq!(page.sessions[0].subject, "alice");
285
286        assert_eq!(store.delete_subject("alice").await.unwrap(), 1);
287        assert_eq!(store.get(&id).await.unwrap(), None);
288    }
289
290    #[tokio::test]
291    async fn test_fake_store_lock_and_failure_mode() {
292        let store = FakeSessionStore::default();
293        let id = SessionId::random();
294        assert!(store.try_lock(&id, Duration::from_secs(10)).await.unwrap());
295        assert!(!store.try_lock(&id, Duration::from_secs(10)).await.unwrap());
296        store.unlock(&id).await.unwrap();
297        assert!(store.try_lock(&id, Duration::from_secs(10)).await.unwrap());
298
299        store.fail.store(true, std::sync::atomic::Ordering::Relaxed);
300        assert!(store.get(&id).await.is_err());
301    }
302}