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    secure_explicit: bool,
224    http_only: bool,
225    same_site: SameSite,
226    same_site_explicit: bool,
227    path: String,
228    ttl: Duration,
229    ttl_explicit: bool,
230    rolling: bool,
231    save_uninitialized: bool,
232    hook: Option<HookFn>,
233}
234
235impl SessionLayer {
236    /// Wrap a [`KvStore`] (typically `namespace(store, "sess")`) in [`KvSessionStore`].
237    pub fn new(kv: Arc<dyn KvStore>) -> Self {
238        Self::from_store(Arc::new(KvSessionStore::new(kv)))
239    }
240
241    /// Use an arbitrary [`SessionStore`] (e.g. [`SqlSessionStore`]).
242    pub fn from_store(store: Arc<dyn SessionStore>) -> Self {
243        Self {
244            store,
245            cookie_name: "sova_sid".into(),
246            cookie_name_explicit: false,
247            secure: false,
248            secure_explicit: false,
249            http_only: true,
250            same_site: SameSite::Lax,
251            same_site_explicit: false,
252            path: "/".into(),
253            ttl: Duration::from_secs(86400),
254            ttl_explicit: false,
255            rolling: false,
256            save_uninitialized: false,
257            hook: None,
258        }
259    }
260
261    pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
262        self.cookie_name = name.into();
263        self.cookie_name_explicit = true;
264        self
265    }
266
267    pub fn secure(mut self, secure: bool) -> Self {
268        self.secure = secure;
269        self.secure_explicit = true;
270        self
271    }
272
273    pub fn http_only(mut self, on: bool) -> Self {
274        self.http_only = on;
275        self
276    }
277
278    pub fn same_site(mut self, same_site: SameSite) -> Self {
279        self.same_site = same_site;
280        self.same_site_explicit = true;
281        self
282    }
283
284    pub fn path(mut self, path: impl Into<String>) -> Self {
285        self.path = path.into();
286        self
287    }
288
289    pub fn ttl(mut self, ttl: Duration) -> Self {
290        self.ttl = ttl;
291        self.ttl_explicit = true;
292        self
293    }
294
295    /// Refresh store TTL and Set-Cookie on every response.
296    pub fn rolling(mut self, on: bool) -> Self {
297        self.rolling = on;
298        self
299    }
300
301    /// Persist empty new sessions (Express default false).
302    pub fn save_uninitialized(mut self, on: bool) -> Self {
303        self.save_uninitialized = on;
304        self
305    }
306
307    /// After cookie session is loaded (and set on `req`), run app logic.
308    ///
309    /// Clone Arcs from `req` **before** `async move`. Return the (possibly mutated) request:
310    ///
311    /// ```ignore
312    /// SessionLayer::new(store).hook(|sess, mut req| async move {
313    ///     if let Some(id) = sess.get("user_id") {
314    ///         let db = req.state::<Db>().clone();
315    ///         if let Some(u) = find_user(&db, &id).await? {
316    ///             req.set(u);
317    ///         }
318    ///     }
319    ///     Ok(req)
320    /// })
321    /// ```
322    pub fn hook<F, Fut>(mut self, f: F) -> Self
323    where
324        F: Fn(Session, Request) -> Fut + Send + Sync + 'static,
325        Fut: Future<Output = Result<Request>> + Send + 'static,
326    {
327        self.hook = Some(Arc::new(move |sess, req| Box::pin(f(sess, req))));
328        self
329    }
330
331    fn apply_config(&mut self, app: &App) {
332        let Some(doc) = app.config_doc() else {
333            return;
334        };
335        let Some(section) = doc.section("session") else {
336            return;
337        };
338        if !self.cookie_name_explicit {
339            if let Some(n) = section.get("cookie").and_then(|v| v.as_str()) {
340                self.cookie_name = n.to_string();
341            }
342        }
343        if !self.ttl_explicit {
344            if let Some(s) = section.get("ttl").and_then(|v| v.as_str()) {
345                if let Ok(d) = sova_core::extend::parse_duration(s) {
346                    self.ttl = d;
347                }
348            }
349        }
350        if !self.same_site_explicit {
351            if let Some(s) = section.get("same_site").and_then(|v| v.as_str()) {
352                self.same_site = match s.to_ascii_lowercase().as_str() {
353                    "strict" => SameSite::Strict,
354                    "none" => SameSite::None,
355                    _ => SameSite::Lax,
356                };
357            }
358        }
359        if let Some(v) = section.get("secure").and_then(|v| v.as_bool()) {
360            self.secure = v;
361            self.secure_explicit = true;
362        }
363    }
364
365    fn apply_env_defaults(&mut self) {
366        if self.secure_explicit {
367            return;
368        }
369        // HTTPS deployments: prefer Secure cookies unless explicitly disabled.
370        let production = std::env::var("SOVA_ENV")
371            .map(|v| v.eq_ignore_ascii_case("production"))
372            .unwrap_or(false);
373        let session_secure = std::env::var("SESSION_SECURE")
374            .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes"))
375            .unwrap_or(false);
376        if production || session_secure {
377            self.secure = true;
378        }
379    }
380}
381
382impl Plugin for SessionLayer {
383    fn id(&self) -> &'static str {
384        "session"
385    }
386
387    fn meta(&self) -> sova_core::PluginMeta {
388        sova_core::PluginMeta::new("Session")
389            .description("Cookie sessions backed by a SessionStore")
390            .version(env!("CARGO_PKG_VERSION"))
391    }
392
393    fn install(mut self, app: &mut App) {
394        self.apply_config(app);
395        self.apply_env_defaults();
396        if !app.has_plugin("cookies") {
397            app.install(CookieLayer);
398        }
399
400        app.state(SessionStoreHandle(Arc::clone(&self.store)));
401
402        let store_check = Arc::clone(&self.store);
403        app.register_check("session", move |_state| {
404            let store = Arc::clone(&store_check);
405            async move {
406                let probe = "__sova_session_check__";
407                let mut data = HashMap::new();
408                data.insert("_probe".into(), "1".into());
409                store
410                    .save(probe, &data, Duration::from_secs(5))
411                    .await;
412                let got = store.load(probe).await;
413                store.destroy(probe).await;
414                if got.is_none() {
415                    return Err(Error::Internal("session store probe failed".into()));
416                }
417                Ok(())
418            }
419        });
420
421        app.use_middleware(named(
422            "session",
423            with_state(self, move |layer, mut req, next| {
424                async move {
425                    let had_cookie = req
426                        .get::<Cookies>()
427                        .and_then(|c| c.get(&layer.cookie_name).map(|s| s.to_string()));
428                    let is_new = had_cookie.is_none();
429                    let sid = had_cookie.unwrap_or_else(new_sid);
430
431                    let data = if is_new {
432                        HashMap::new()
433                    } else {
434                        layer.store.load(&sid).await.unwrap_or_default()
435                    };
436                    let session = Session {
437                        inner: Arc::new(Mutex::new(SessionInner {
438                            id: sid,
439                            data,
440                            dirty: false,
441                            destroyed: false,
442                            old_id: None,
443                            is_new,
444                        })),
445                    };
446                    req.set(session.clone());
447
448                    let req = if let Some(hook) = &layer.hook {
449                        match hook(session.clone(), req).await {
450                            Ok(r) => r,
451                            Err(err) => return err.into_response(),
452                        }
453                    } else {
454                        req
455                    };
456
457                    let mut res = next(req).await;
458                    let snap = session.snapshot();
459
460                    if snap.destroyed {
461                        layer.store.destroy(&snap.id).await;
462                        if let Some(old) = &snap.old_id {
463                            layer.store.destroy(old).await;
464                        }
465                        let mut builder = Cookie::build((layer.cookie_name.clone(), ""))
466                            .http_only(layer.http_only)
467                            .same_site(layer.same_site)
468                            .path(layer.path.clone())
469                            .max_age(cookie::time::Duration::seconds(0));
470                        if layer.secure {
471                            builder = builder.secure(true);
472                        }
473                        return res.cookie(builder.build());
474                    }
475
476                    let should_persist = snap.dirty
477                        || (snap.is_new && layer.save_uninitialized)
478                        || (layer.rolling && !snap.is_new);
479
480                    if should_persist {
481                        if let Some(old) = &snap.old_id {
482                            layer.store.destroy(old).await;
483                        }
484                        layer
485                            .store
486                            .save(&snap.id, &snap.data, layer.ttl)
487                            .await;
488                    }
489
490                    let should_set_cookie = should_persist || (layer.rolling && !snap.is_new);
491                    if should_set_cookie || (snap.is_new && snap.dirty) {
492                        let mut builder = Cookie::build((layer.cookie_name.clone(), snap.id))
493                            .http_only(layer.http_only)
494                            .same_site(layer.same_site)
495                            .path(layer.path.clone());
496                        if layer.secure {
497                            builder = builder.secure(true);
498                        }
499                        if let Ok(secs) = i64::try_from(layer.ttl.as_secs()) {
500                            builder = builder.max_age(cookie::time::Duration::seconds(secs));
501                        }
502                        res = res.cookie(builder.build());
503                    }
504
505                    res
506                }
507            }),
508        ));
509        app.with(Needs::<CookieLayerPresent>::new());
510    }
511}
512
513fn new_sid() -> String {
514    let mut buf = [0u8; 16];
515    if getrandom::getrandom(&mut buf).is_err() {
516        let t = std::time::SystemTime::now()
517            .duration_since(std::time::UNIX_EPOCH)
518            .map(|d| d.as_nanos())
519            .unwrap_or(0);
520        return format!("{t:032x}");
521    }
522    let mut s = String::with_capacity(32);
523    for b in buf {
524        s.push_str(&format!("{b:02x}"));
525    }
526    s
527}
528
529/// Default in-memory sessions (`sova_store::MemoryStore` under `sess:`).
530pub fn memory_sessions() -> SessionLayer {
531    SessionLayer::new(Arc::new(namespace(Arc::new(KvMemory::new()), "sess")))
532}
533
534/// Convenient access to the request [`Session`] (empty session if the layer is absent).
535pub trait SessionExt {
536    fn session(&self) -> Session;
537
538    fn flash(&self, key: impl Into<String>, value: impl Into<String>) {
539        self.session().flash(key, value);
540    }
541
542    fn flash_status(&self, msg: impl Into<String>) {
543        self.session().flash_status(msg);
544    }
545
546    fn flash_errors(&self, errors: &serde_json::Value) {
547        self.session().flash_errors(errors);
548    }
549
550    fn flash_old(&self, old: &serde_json::Value) {
551        self.session().flash_old(old);
552    }
553
554    /// Invalidate every other session for the bound user; keep the current cookie.
555    ///
556    /// Requires [`Session::bind_user`] (done by Passport/Fortify login).
557    fn logout_other_sessions(
558        &self,
559    ) -> impl std::future::Future<Output = Result<u64>> + Send;
560
561    /// Invalidate all sessions for the bound user, including this one (clears cookie).
562    fn logout_all_sessions(
563        &mut self,
564    ) -> impl std::future::Future<Output = Result<u64>> + Send;
565}
566
567impl SessionExt for sova_core::Request {
568    fn session(&self) -> Session {
569        self.get::<Session>().cloned().unwrap_or_else(Session::empty)
570    }
571
572    async fn logout_other_sessions(&self) -> Result<u64> {
573        let sess = self.session();
574        let uid = sess
575            .user_id()
576            .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
577        let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
578            Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
579        })?;
580        Ok(handle
581            .0
582            .destroy_user(&uid, Some(&sess.id()))
583            .await)
584    }
585
586    async fn logout_all_sessions(&mut self) -> Result<u64> {
587        let sess = self.session();
588        let uid = sess
589            .user_id()
590            .ok_or_else(|| Error::BadRequest("no bound session user".into()))?;
591        let handle = self.try_state::<SessionStoreHandle>().ok_or_else(|| {
592            Error::Internal("SessionStore missing (is SessionLayer installed?)".into())
593        })?;
594        let n = handle.0.destroy_user(&uid, None).await;
595        sess.destroy();
596        let _ = self.take::<sova_core::RateLimitIdentity>();
597        Ok(n)
598    }
599}
600
601#[cfg(test)]
602mod flash_tests {
603    use super::*;
604    use serde_json::json;
605
606    #[test]
607    fn flash_take_is_one_shot() {
608        let s = Session::empty();
609        s.flash_status("Saved");
610        assert_eq!(s.take(FLASH_STATUS), "Saved");
611        assert_eq!(s.take(FLASH_STATUS), "");
612
613        s.flash_old(&json!({ "email": "a@b.c" }));
614        let old = s.take_json(FLASH_OLD);
615        assert_eq!(old["email"], "a@b.c");
616        assert_eq!(s.take_json(FLASH_OLD), json!({}));
617    }
618}