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