Skip to main content

sova_session/
lib.rs

1//! Cookie-backed sessions for Sova (Express [`express-session`](https://expressjs.com/en/resources/middleware/session.html)-style).
2//!
3//! Flash helpers ([`Session::flash`], [`Session::take`]) store one-shot values for the next
4//! request (status messages, validation errors, old form input).
5//!
6//! Persistence is [`SessionStore`]: [`KvSessionStore`], [`SqlSessionStore`], or
7//! [`RedisSessionStore`]. Logout others/all:
8//! [`SessionExt::logout_other_sessions`] / [`SessionExt::logout_all_sessions`].
9
10mod store;
11#[cfg(feature = "sql")]
12mod sql;
13#[cfg(feature = "redis")]
14mod redis_store;
15
16use cookie::Cookie;
17pub use cookie::SameSite;
18use sova_cookies::{CookieLayer, CookieLayerPresent, Cookies, ResponseCookieExt};
19use sova_core::extend::{named, BoxFuture, Needs};
20use sova_core::{with_state, App, Error, Plugin, Request, Result};
21use sova_store::{namespace, KvStore, MemoryStore as KvMemory};
22use std::collections::HashMap;
23use std::future::Future;
24use std::sync::{Arc, Mutex};
25use std::time::Duration;
26
27pub use store::{KvSessionStore, SessionStore, SessionStoreHandle, SESSION_USER_KEY};
28#[cfg(feature = "sql")]
29pub use sql::SqlSessionStore;
30#[cfg(feature = "redis")]
31pub use redis_store::RedisSessionStore;
32
33/// Session key for a one-line success/status message (templates: `status`).
34pub const FLASH_STATUS: &str = "flash_status";
35/// Session key for validation error map JSON (templates: `errors`).
36pub const FLASH_ERRORS: &str = "flash_errors";
37/// Session key for old form input JSON (templates: `old`).
38pub const FLASH_OLD: &str = "flash_old";
39
40#[derive(Debug)]
41struct SessionInner {
42    id: String,
43    data: HashMap<String, String>,
44    dirty: bool,
45    /// Destroyed — remove from store and clear cookie.
46    destroyed: bool,
47    /// Sid rotated; old id should be removed after save.
48    old_id: Option<String>,
49    /// Fresh sid not yet persisted (for save_uninitialized).
50    is_new: bool,
51}
52
53/// Shared per-request session (cheap to clone into middleware after `next`).
54#[derive(Clone, Debug)]
55pub struct Session {
56    inner: Arc<Mutex<SessionInner>>,
57}
58
59impl Session {
60    pub fn id(&self) -> String {
61        self.inner.lock().unwrap().id.clone()
62    }
63
64    pub fn get(&self, key: &str) -> Option<String> {
65        self.inner.lock().unwrap().data.get(key).cloned()
66    }
67
68    pub fn get_or(&self, key: &str, default: impl Into<String>) -> String {
69        self.get(key).unwrap_or_else(|| default.into())
70    }
71
72    pub fn set(&self, key: impl Into<String>, value: impl Into<String>) {
73        let mut g = self.inner.lock().unwrap();
74        g.data.insert(key.into(), value.into());
75        g.dirty = true;
76    }
77
78    /// One-shot value: survives until the next request that [`Self::take`]s it.
79    pub fn flash(&self, key: impl Into<String>, value: impl Into<String>) {
80        self.set(key, value);
81    }
82
83    /// Convenience for [`FLASH_STATUS`].
84    pub fn flash_status(&self, msg: impl Into<String>) {
85        self.flash(FLASH_STATUS, msg);
86    }
87
88    /// JSON flash (errors / old maps).
89    pub fn flash_json(&self, key: &str, value: &serde_json::Value) {
90        self.flash(
91            key,
92            serde_json::to_string(value).unwrap_or_else(|_| "{}".into()),
93        );
94    }
95
96    /// Flash validation-style errors map (`{ field: message }`).
97    pub fn flash_errors(&self, errors: &serde_json::Value) {
98        self.flash_json(FLASH_ERRORS, errors);
99    }
100
101    /// Flash old form input map.
102    pub fn flash_old(&self, old: &serde_json::Value) {
103        self.flash_json(FLASH_OLD, old);
104    }
105
106    /// Read and clear a session key (empty string if missing).
107    pub fn take(&self, key: &str) -> String {
108        let mut g = self.inner.lock().unwrap();
109        let val = g.data.remove(key).unwrap_or_default();
110        if !val.is_empty() {
111            g.dirty = true;
112        }
113        val
114    }
115
116    /// Parse JSON from [`Self::take`], or `{}` on empty/invalid.
117    pub fn take_json(&self, key: &str) -> serde_json::Value {
118        let raw = self.take(key);
119        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
120    }
121
122    pub fn remove(&self, key: &str) {
123        let mut g = self.inner.lock().unwrap();
124        if g.data.remove(key).is_some() {
125            g.dirty = true;
126        }
127    }
128
129    pub fn clear(&self) {
130        let mut g = self.inner.lock().unwrap();
131        if !g.data.is_empty() {
132            g.data.clear();
133            g.dirty = true;
134        }
135    }
136
137    /// Replace the entire session bag (marks dirty).
138    pub fn replace(&self, data: HashMap<String, String>) {
139        let mut g = self.inner.lock().unwrap();
140        g.data = data;
141        g.dirty = true;
142    }
143
144    /// Snapshot of all key/value pairs.
145    pub fn data(&self) -> HashMap<String, String> {
146        self.inner.lock().unwrap().data.clone()
147    }
148
149    /// Bind this session to a user id (for logout-others/all via [`SessionStore`]).
150    pub fn bind_user(&self, user_id: impl Into<String>) {
151        self.set(SESSION_USER_KEY, user_id);
152    }
153
154    /// User id from [`Self::bind_user`], if any.
155    pub fn user_id(&self) -> Option<String> {
156        self.get(SESSION_USER_KEY).filter(|s| !s.is_empty())
157    }
158
159    /// Drop session data and mark for store removal + cookie clear.
160    pub fn destroy(&self) {
161        let mut g = self.inner.lock().unwrap();
162        g.data.clear();
163        g.dirty = false;
164        g.destroyed = true;
165    }
166
167    /// Issue a new session id, keep data, delete the old store entry.
168    pub fn regenerate(&self) {
169        let mut g = self.inner.lock().unwrap();
170        let old = std::mem::replace(&mut g.id, new_sid());
171        g.old_id = Some(old);
172        g.dirty = true;
173        g.is_new = true;
174        g.destroyed = false;
175    }
176
177    fn snapshot(&self) -> SessionSnapshot {
178        let g = self.inner.lock().unwrap();
179        SessionSnapshot {
180            id: g.id.clone(),
181            data: g.data.clone(),
182            dirty: g.dirty,
183            destroyed: g.destroyed,
184            old_id: g.old_id.clone(),
185            is_new: g.is_new,
186        }
187    }
188
189    /// Detached empty session (not backed by a store — writes are local only).
190    fn empty() -> Self {
191        Self {
192            inner: Arc::new(Mutex::new(SessionInner {
193                id: String::new(),
194                data: HashMap::new(),
195                dirty: false,
196                destroyed: false,
197                old_id: None,
198                is_new: true,
199            })),
200        }
201    }
202}
203
204struct SessionSnapshot {
205    id: String,
206    data: HashMap<String, String>,
207    dirty: bool,
208    destroyed: bool,
209    old_id: Option<String>,
210    is_new: bool,
211}
212
213type HookFn = Arc<dyn Fn(Session, Request) -> BoxFuture<Result<Request>> + Send + Sync>;
214
215/// Session middleware over a [`SessionStore`].
216///
217/// Cookie load/save stays here. App logic (hydrate user, ACL, …) goes in [`SessionLayer::hook`].
218pub struct SessionLayer {
219    store: Arc<dyn SessionStore>,
220    cookie_name: String,
221    cookie_name_explicit: bool,
222    secure: bool,
223    http_only: bool,
224    same_site: SameSite,
225    same_site_explicit: bool,
226    path: String,
227    ttl: Duration,
228    ttl_explicit: bool,
229    rolling: bool,
230    save_uninitialized: bool,
231    hook: Option<HookFn>,
232}
233
234impl SessionLayer {
235    /// Wrap a [`KvStore`] (typically `namespace(store, "sess")`) in [`KvSessionStore`].
236    pub fn new(kv: Arc<dyn KvStore>) -> Self {
237        Self::from_store(Arc::new(KvSessionStore::new(kv)))
238    }
239
240    /// Use an arbitrary [`SessionStore`] (e.g. [`SqlSessionStore`]).
241    pub fn from_store(store: Arc<dyn SessionStore>) -> Self {
242        Self {
243            store,
244            cookie_name: "sova_sid".into(),
245            cookie_name_explicit: false,
246            secure: false,
247            http_only: true,
248            same_site: SameSite::Lax,
249            same_site_explicit: false,
250            path: "/".into(),
251            ttl: Duration::from_secs(86400),
252            ttl_explicit: false,
253            rolling: false,
254            save_uninitialized: false,
255            hook: None,
256        }
257    }
258
259    pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
260        self.cookie_name = name.into();
261        self.cookie_name_explicit = true;
262        self
263    }
264
265    pub fn secure(mut self, secure: bool) -> Self {
266        self.secure = secure;
267        self
268    }
269
270    pub fn http_only(mut self, on: bool) -> Self {
271        self.http_only = on;
272        self
273    }
274
275    pub fn same_site(mut self, same_site: SameSite) -> Self {
276        self.same_site = same_site;
277        self.same_site_explicit = true;
278        self
279    }
280
281    pub fn path(mut self, path: impl Into<String>) -> Self {
282        self.path = path.into();
283        self
284    }
285
286    pub fn ttl(mut self, ttl: Duration) -> Self {
287        self.ttl = ttl;
288        self.ttl_explicit = true;
289        self
290    }
291
292    /// Refresh store TTL and Set-Cookie on every response.
293    pub fn rolling(mut self, on: bool) -> Self {
294        self.rolling = on;
295        self
296    }
297
298    /// Persist empty new sessions (Express default false).
299    pub fn save_uninitialized(mut self, on: bool) -> Self {
300        self.save_uninitialized = on;
301        self
302    }
303
304    /// After cookie session is loaded (and set on `req`), run app logic.
305    ///
306    /// Clone Arcs from `req` **before** `async move`. Return the (possibly mutated) request:
307    ///
308    /// ```ignore
309    /// SessionLayer::new(store).hook(|sess, mut req| async move {
310    ///     if let Some(id) = sess.get("user_id") {
311    ///         let db = req.state::<Db>().clone();
312    ///         if let Some(u) = find_user(&db, &id).await? {
313    ///             req.set(u);
314    ///         }
315    ///     }
316    ///     Ok(req)
317    /// })
318    /// ```
319    pub fn hook<F, Fut>(mut self, f: F) -> Self
320    where
321        F: Fn(Session, Request) -> Fut + Send + Sync + 'static,
322        Fut: Future<Output = Result<Request>> + Send + 'static,
323    {
324        self.hook = Some(Arc::new(move |sess, req| Box::pin(f(sess, req))));
325        self
326    }
327
328    fn apply_config(&mut self, app: &App) {
329        let Some(doc) = app.config_doc() else {
330            return;
331        };
332        let Some(section) = doc.section("session") else {
333            return;
334        };
335        if !self.cookie_name_explicit {
336            if let Some(n) = section.get("cookie").and_then(|v| v.as_str()) {
337                self.cookie_name = n.to_string();
338            }
339        }
340        if !self.ttl_explicit {
341            if let Some(s) = section.get("ttl").and_then(|v| v.as_str()) {
342                if let Ok(d) = sova_core::extend::parse_duration(s) {
343                    self.ttl = d;
344                }
345            }
346        }
347        if !self.same_site_explicit {
348            if let Some(s) = section.get("same_site").and_then(|v| v.as_str()) {
349                self.same_site = match s.to_ascii_lowercase().as_str() {
350                    "strict" => SameSite::Strict,
351                    "none" => SameSite::None,
352                    _ => SameSite::Lax,
353                };
354            }
355        }
356        if let Some(v) = section.get("secure").and_then(|v| v.as_bool()) {
357            self.secure = v;
358        }
359    }
360}
361
362impl Plugin for SessionLayer {
363    fn id(&self) -> &'static str {
364        "session"
365    }
366
367    fn meta(&self) -> sova_core::PluginMeta {
368        sova_core::PluginMeta::new("Session")
369            .description("Cookie sessions backed by a SessionStore")
370            .version(env!("CARGO_PKG_VERSION"))
371    }
372
373    fn install(mut self, app: &mut App) {
374        self.apply_config(app);
375        if !app.has_plugin("cookies") {
376            app.install(CookieLayer);
377        }
378
379        app.state(SessionStoreHandle(Arc::clone(&self.store)));
380
381        let store_check = Arc::clone(&self.store);
382        app.register_check("session", move |_state| {
383            let store = Arc::clone(&store_check);
384            async move {
385                let probe = "__sova_session_check__";
386                let mut data = HashMap::new();
387                data.insert("_probe".into(), "1".into());
388                store
389                    .save(probe, &data, Duration::from_secs(5))
390                    .await;
391                let got = store.load(probe).await;
392                store.destroy(probe).await;
393                if got.is_none() {
394                    return Err(Error::Internal("session store probe failed".into()));
395                }
396                Ok(())
397            }
398        });
399
400        app.use_middleware(named(
401            "session",
402            with_state(self, move |layer, mut req, next| {
403                async move {
404                    let had_cookie = req
405                        .get::<Cookies>()
406                        .and_then(|c| c.get(&layer.cookie_name).map(|s| s.to_string()));
407                    let is_new = had_cookie.is_none();
408                    let sid = had_cookie.unwrap_or_else(new_sid);
409
410                    let data = if is_new {
411                        HashMap::new()
412                    } else {
413                        layer.store.load(&sid).await.unwrap_or_default()
414                    };
415                    let session = Session {
416                        inner: Arc::new(Mutex::new(SessionInner {
417                            id: sid,
418                            data,
419                            dirty: false,
420                            destroyed: false,
421                            old_id: None,
422                            is_new,
423                        })),
424                    };
425                    req.set(session.clone());
426
427                    let req = if let Some(hook) = &layer.hook {
428                        match hook(session.clone(), req).await {
429                            Ok(r) => r,
430                            Err(err) => return err.into_response(),
431                        }
432                    } else {
433                        req
434                    };
435
436                    let mut res = next(req).await;
437                    let snap = session.snapshot();
438
439                    if snap.destroyed {
440                        layer.store.destroy(&snap.id).await;
441                        if let Some(old) = &snap.old_id {
442                            layer.store.destroy(old).await;
443                        }
444                        let mut builder = Cookie::build((layer.cookie_name.clone(), ""))
445                            .http_only(layer.http_only)
446                            .same_site(layer.same_site)
447                            .path(layer.path.clone())
448                            .max_age(cookie::time::Duration::seconds(0));
449                        if layer.secure {
450                            builder = builder.secure(true);
451                        }
452                        return res.cookie(builder.build());
453                    }
454
455                    let should_persist = snap.dirty
456                        || (snap.is_new && layer.save_uninitialized)
457                        || (layer.rolling && !snap.is_new);
458
459                    if should_persist {
460                        if let Some(old) = &snap.old_id {
461                            layer.store.destroy(old).await;
462                        }
463                        layer
464                            .store
465                            .save(&snap.id, &snap.data, layer.ttl)
466                            .await;
467                    }
468
469                    let should_set_cookie = should_persist || (layer.rolling && !snap.is_new);
470                    if should_set_cookie || (snap.is_new && snap.dirty) {
471                        let mut builder = Cookie::build((layer.cookie_name.clone(), snap.id))
472                            .http_only(layer.http_only)
473                            .same_site(layer.same_site)
474                            .path(layer.path.clone());
475                        if layer.secure {
476                            builder = builder.secure(true);
477                        }
478                        if let Ok(secs) = i64::try_from(layer.ttl.as_secs()) {
479                            builder = builder.max_age(cookie::time::Duration::seconds(secs));
480                        }
481                        res = res.cookie(builder.build());
482                    }
483
484                    res
485                }
486            }),
487        ));
488        app.with(Needs::<CookieLayerPresent>::new());
489    }
490}
491
492fn new_sid() -> String {
493    let mut buf = [0u8; 16];
494    if getrandom::getrandom(&mut buf).is_err() {
495        let t = std::time::SystemTime::now()
496            .duration_since(std::time::UNIX_EPOCH)
497            .map(|d| d.as_nanos())
498            .unwrap_or(0);
499        return format!("{t:032x}");
500    }
501    let mut s = String::with_capacity(32);
502    for b in buf {
503        s.push_str(&format!("{b:02x}"));
504    }
505    s
506}
507
508/// Default in-memory sessions (`sova_store::MemoryStore` under `sess:`).
509pub fn memory_sessions() -> SessionLayer {
510    SessionLayer::new(Arc::new(namespace(Arc::new(KvMemory::new()), "sess")))
511}
512
513/// Convenient access to the request [`Session`] (empty session if the layer is absent).
514pub trait SessionExt {
515    fn session(&self) -> Session;
516
517    fn flash(&self, key: impl Into<String>, value: impl Into<String>) {
518        self.session().flash(key, value);
519    }
520
521    fn flash_status(&self, msg: impl Into<String>) {
522        self.session().flash_status(msg);
523    }
524
525    fn flash_errors(&self, errors: &serde_json::Value) {
526        self.session().flash_errors(errors);
527    }
528
529    fn flash_old(&self, old: &serde_json::Value) {
530        self.session().flash_old(old);
531    }
532
533    /// Invalidate every other session for the bound user; keep the current cookie.
534    ///
535    /// Requires [`Session::bind_user`] (done by Passport/Fortify login).
536    fn logout_other_sessions(
537        &self,
538    ) -> impl std::future::Future<Output = Result<u64>> + Send;
539
540    /// Invalidate all sessions for the bound user, including this one (clears cookie).
541    fn logout_all_sessions(
542        &mut self,
543    ) -> impl std::future::Future<Output = Result<u64>> + Send;
544}
545
546impl SessionExt for sova_core::Request {
547    fn session(&self) -> Session {
548        self.get::<Session>().cloned().unwrap_or_else(Session::empty)
549    }
550
551    async fn logout_other_sessions(&self) -> Result<u64> {
552        let sess = self.session();
553        let uid = sess
554            .user_id()
555            .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
556        let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
557            Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
558        })?;
559        Ok(handle
560            .0
561            .destroy_user(&uid, Some(&sess.id()))
562            .await)
563    }
564
565    async fn logout_all_sessions(&mut self) -> Result<u64> {
566        let sess = self.session();
567        let uid = sess
568            .user_id()
569            .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
570        let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
571            Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
572        })?;
573        let n = handle.0.destroy_user(&uid, None).await;
574        sess.destroy();
575        let _ = self.take::<sova_core::RateLimitIdentity>();
576        Ok(n)
577    }
578}
579
580#[cfg(test)]
581mod flash_tests {
582    use super::*;
583    use serde_json::json;
584
585    #[test]
586    fn flash_take_is_one_shot() {
587        let s = Session::empty();
588        s.flash_status("Saved");
589        assert_eq!(s.take(FLASH_STATUS), "Saved");
590        assert_eq!(s.take(FLASH_STATUS), "");
591
592        s.flash_old(&json!({ "email": "a@b.c" }));
593        let old = s.take_json(FLASH_OLD);
594        assert_eq!(old["email"], "a@b.c");
595        assert_eq!(s.take_json(FLASH_OLD), json!({}));
596    }
597}