Skip to main content

featherbit/plugins/util/
server_session.rs

1//! Shared session-backend plumbing for the interactive auth plugins.
2//!
3//! One config surface (`session.storage: cookie|redis` + `session.store:
4//! <name>`, with the flat `session_storage`/`session_store` UI fallbacks)
5//! and one establish/load/destroy seam, so the five session plugins differ
6//! only in payload shape and flow, not in storage mechanics. Cookie mode is
7//! byte-identical to the pre-existing behavior; redis mode puts the SAME
8//! sealed bytes server-side and hands the browser a bare 128-bit id.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use crate::context::Context;
15use crate::plugins::resources::PluginResources;
16use crate::sessions::{SessionId, SessionMeta, SessionStore, StoreError};
17
18use super::cookie_session::{build_set_cookie, delete_cookie, CookieAttrs, CookieSealer};
19
20pub enum SessionBackend {
21    Cookie,
22    Store {
23        store: Arc<dyn SessionStore>,
24        store_name: String,
25    },
26}
27
28impl std::fmt::Debug for SessionBackend {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::Cookie => write!(f, "SessionBackend::Cookie"),
32            Self::Store { store_name, .. } => {
33                write!(f, "SessionBackend::Store({store_name})")
34            }
35        }
36    }
37}
38
39fn nested_or_flat<'a>(
40    config: &'a HashMap<String, serde_json::Value>,
41    nested: &str,
42    flat: &str,
43) -> Option<&'a str> {
44    config
45        .get("session")
46        .and_then(|s| s.get(nested))
47        .or_else(|| config.get(flat))
48        .and_then(|v| v.as_str())
49        .filter(|s| !s.is_empty())
50}
51
52/// Parses `session.storage` / `session.store` and resolves the named store
53/// at construction time — a bad reference fails policy compilation.
54pub fn parse_backend(
55    config: &HashMap<String, serde_json::Value>,
56    resources: &Arc<PluginResources>,
57    plugin: &str,
58) -> Result<SessionBackend, String> {
59    match nested_or_flat(config, "storage", "session_storage").unwrap_or("cookie") {
60        "cookie" => Ok(SessionBackend::Cookie),
61        "redis" => {
62            let name = nested_or_flat(config, "store", "session_store").ok_or_else(|| {
63                format!(
64                    "{plugin}: session.storage 'redis' requires 'session.store' naming a declared stores: entry"
65                )
66            })?;
67            let store = resources.stores.load().session_store(name)?;
68            Ok(SessionBackend::Store {
69                store,
70                store_name: name.to_string(),
71            })
72        }
73        other => Err(format!(
74            "{plugin}: unknown session.storage '{other}' — supported: cookie, redis"
75        )),
76    }
77}
78
79fn now_unix() -> u64 {
80    SystemTime::now()
81        .duration_since(UNIX_EPOCH)
82        .map(|d| d.as_secs())
83        .unwrap_or(0)
84}
85
86/// Builds the meta envelope from the context's `__route`/`__policy` vars.
87pub fn meta_now(ctx: &Context, plugin: &str, subject: &str, ttl: Duration) -> SessionMeta {
88    let var = |k: &str| {
89        ctx.message
90            .get(k)
91            .and_then(|v| v.as_str())
92            .unwrap_or("")
93            .to_string()
94    };
95    let now = now_unix();
96    SessionMeta {
97        id: String::new(),
98        subject: subject.to_string(),
99        plugin: plugin.to_string(),
100        policy: var("__policy"),
101        route: var("__route"),
102        created_at: now,
103        expires_at: now.saturating_add(ttl.as_secs()),
104    }
105}
106
107/// Seals `payload` and returns the session Set-Cookie value. Cookie mode:
108/// the sealed blob IS the cookie. Redis mode: the sealed blob goes under a
109/// fresh random id; the cookie carries the bare id.
110pub async fn establish(
111    backend: &SessionBackend,
112    sealer: &CookieSealer,
113    payload: &[u8],
114    ttl: Duration,
115    meta: SessionMeta,
116    cookie_name: &str,
117    attrs: &CookieAttrs<'_>,
118) -> Result<String, StoreError> {
119    let sealed = sealer.seal(payload, ttl);
120    match backend {
121        SessionBackend::Cookie => Ok(build_set_cookie(cookie_name, &sealed, attrs)),
122        SessionBackend::Store { store, .. } => {
123            let id = SessionId::random();
124            store.put(&id, sealed.as_bytes(), ttl, &meta).await?;
125            Ok(build_set_cookie(cookie_name, id.as_str(), attrs))
126        }
127    }
128}
129
130/// Opens a session cookie value. `Ok(None)` = treat as unauthenticated
131/// (absent/expired/tampered/junk id); `Err` = store outage (503, never 401).
132pub async fn load(
133    backend: &SessionBackend,
134    sealer: &CookieSealer,
135    cookie_value: &str,
136) -> Result<Option<Vec<u8>>, StoreError> {
137    match backend {
138        SessionBackend::Cookie => Ok(sealer.open(cookie_value).ok()),
139        SessionBackend::Store { store, .. } => {
140            let Some(id) = SessionId::parse(cookie_value) else {
141                return Ok(None);
142            };
143            let Some(sealed) = store.get(&id).await? else {
144                return Ok(None);
145            };
146            let sealed = String::from_utf8(sealed).unwrap_or_default();
147            Ok(sealer.open(&sealed).ok())
148        }
149    }
150}
151
152/// Revokes the session and returns the delete-cookie header value. A store
153/// delete failure is an Err — server-side revocation is the entire point of
154/// redis mode, so a logout that silently leaves the session live must fail
155/// loudly (503) instead.
156pub async fn destroy(
157    backend: &SessionBackend,
158    cookie_value: Option<&str>,
159    cookie_name: &str,
160    path: &str,
161) -> Result<String, StoreError> {
162    if let (SessionBackend::Store { store, .. }, Some(value)) = (backend, cookie_value) {
163        if let Some(id) = SessionId::parse(value) {
164            store.delete(&id).await?;
165        }
166    }
167    Ok(delete_cookie(cookie_name, path))
168}
169
170/// Rewrites an existing session's payload (token refresh). Redis mode: put
171/// under the SAME id (cookie unchanged → returns None). Cookie mode: the
172/// caller must send a fresh cookie (returns Some(set_cookie)).
173#[allow(clippy::too_many_arguments)] // matches the locked interface in the task-5 brief
174pub async fn update(
175    backend: &SessionBackend,
176    sealer: &CookieSealer,
177    cookie_value: &str,
178    payload: &[u8],
179    ttl: Duration,
180    meta: SessionMeta,
181    cookie_name: &str,
182    attrs: &CookieAttrs<'_>,
183) -> Result<Option<String>, StoreError> {
184    let sealed = sealer.seal(payload, ttl);
185    match backend {
186        SessionBackend::Cookie => Ok(Some(build_set_cookie(cookie_name, &sealed, attrs))),
187        SessionBackend::Store { store, .. } => {
188            let Some(id) = SessionId::parse(cookie_value) else {
189                return Ok(None);
190            };
191            store.put(&id, sealed.as_bytes(), ttl, &meta).await?;
192            Ok(None)
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::sessions::FakeSessionStore;
201
202    fn store_backend(fake: Arc<FakeSessionStore>) -> SessionBackend {
203        SessionBackend::Store {
204            store: fake,
205            store_name: "s1".to_string(),
206        }
207    }
208
209    fn meta() -> SessionMeta {
210        SessionMeta {
211            id: String::new(),
212            subject: "alice".to_string(),
213            plugin: "test".to_string(),
214            policy: String::new(),
215            route: String::new(),
216            created_at: 0,
217            expires_at: 0,
218        }
219    }
220
221    #[tokio::test]
222    async fn test_cookie_mode_matches_legacy_shape() {
223        let sealer = CookieSealer::new("k");
224        let set = establish(
225            &SessionBackend::Cookie,
226            &sealer,
227            b"payload",
228            Duration::from_secs(60),
229            meta(),
230            "test_session",
231            &CookieAttrs::default(),
232        )
233        .await
234        .unwrap();
235        // Legacy shape: the sealed blob is the cookie value itself.
236        let value = set
237            .strip_prefix("test_session=")
238            .unwrap()
239            .split(';')
240            .next()
241            .unwrap()
242            .to_string();
243        assert_eq!(
244            load(&SessionBackend::Cookie, &sealer, &value)
245                .await
246                .unwrap()
247                .as_deref(),
248            Some(&b"payload"[..])
249        );
250    }
251
252    #[tokio::test]
253    async fn test_store_mode_round_trip_id_cookie_and_revocation() {
254        let fake = Arc::new(FakeSessionStore::default());
255        let backend = store_backend(fake.clone());
256        let sealer = CookieSealer::new("k");
257        let set = establish(
258            &backend,
259            &sealer,
260            b"payload",
261            Duration::from_secs(60),
262            meta(),
263            "test_session",
264            &CookieAttrs::default(),
265        )
266        .await
267        .unwrap();
268        let value = set
269            .strip_prefix("test_session=")
270            .unwrap()
271            .split(';')
272            .next()
273            .unwrap()
274            .to_string();
275        // The cookie is a bare 32-hex id, far under 4 KB, not the payload.
276        assert_eq!(value.len(), 32);
277        assert!(crate::sessions::SessionId::parse(&value).is_some());
278
279        assert_eq!(
280            load(&backend, &sealer, &value).await.unwrap().as_deref(),
281            Some(&b"payload"[..])
282        );
283        // Junk cookie values are unauthenticated, not errors.
284        assert_eq!(load(&backend, &sealer, "not-an-id").await.unwrap(), None);
285
286        // Destroy revokes server-side.
287        destroy(&backend, Some(&value), "test_session", "/")
288            .await
289            .unwrap();
290        assert_eq!(load(&backend, &sealer, &value).await.unwrap(), None);
291    }
292
293    #[tokio::test]
294    async fn test_store_outage_is_error_not_unauthenticated() {
295        let fake = Arc::new(FakeSessionStore::default());
296        fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
297        let backend = store_backend(fake);
298        let sealer = CookieSealer::new("k");
299        let id = crate::sessions::SessionId::random();
300        assert!(load(&backend, &sealer, id.as_str()).await.is_err());
301        assert!(destroy(&backend, Some(id.as_str()), "n", "/")
302            .await
303            .is_err());
304    }
305
306    #[tokio::test]
307    async fn test_update_keeps_id_in_store_mode() {
308        let fake = Arc::new(FakeSessionStore::default());
309        let backend = store_backend(fake);
310        let sealer = CookieSealer::new("k");
311        let set = establish(
312            &backend,
313            &sealer,
314            b"v1",
315            Duration::from_secs(60),
316            meta(),
317            "n",
318            &CookieAttrs::default(),
319        )
320        .await
321        .unwrap();
322        let value = set
323            .strip_prefix("n=")
324            .unwrap()
325            .split(';')
326            .next()
327            .unwrap()
328            .to_string();
329        let out = update(
330            &backend,
331            &sealer,
332            &value,
333            b"v2",
334            Duration::from_secs(60),
335            meta(),
336            "n",
337            &CookieAttrs::default(),
338        )
339        .await
340        .unwrap();
341        assert!(out.is_none(), "store mode must not reissue the cookie");
342        assert_eq!(
343            load(&backend, &sealer, &value).await.unwrap().as_deref(),
344            Some(&b"v2"[..])
345        );
346    }
347
348    #[test]
349    fn test_parse_backend_errors() {
350        use crate::plugins::resources::PluginResources;
351        let resources = PluginResources::empty();
352        let cfg = |json: serde_json::Value| -> HashMap<String, serde_json::Value> {
353            serde_json::from_value(json).unwrap()
354        };
355        assert!(matches!(
356            parse_backend(&cfg(serde_json::json!({})), &resources, "p").unwrap(),
357            SessionBackend::Cookie
358        ));
359        let err = parse_backend(
360            &cfg(serde_json::json!({"session": {"storage": "redis"}})),
361            &resources,
362            "p",
363        )
364        .unwrap_err();
365        assert!(err.contains("requires 'session.store'"), "{err}");
366        let err = parse_backend(
367            &cfg(serde_json::json!({"session": {"storage": "memcached"}})),
368            &resources,
369            "p",
370        )
371        .unwrap_err();
372        assert!(err.contains("unknown session.storage"), "{err}");
373        let err = parse_backend(
374            &cfg(serde_json::json!({"session_storage": "redis", "session_store": "nope"})),
375            &resources,
376            "p",
377        )
378        .unwrap_err();
379        assert!(err.contains("'nope'"), "{err}");
380    }
381}