Skip to main content

rustlavel_auth/
middleware.rs

1//! The `session` middleware: cookie in, session out, and back again.
2//!
3//! ```ignore
4//! let sessions = SessionManager::from_config(&config, FileStore::new("storage/sessions"))?;
5//! router.middleware(sessions);
6//! ```
7//!
8//! After that a handler reaches the session through the request:
9//!
10//! ```ignore
11//! async fn show(req: Request) -> String {
12//!     req.session().put("last_seen", "/dashboard");
13//!     format!("visits: {:?}", req.session().get("visits"))
14//! }
15//! ```
16
17use crate::key::AppKey;
18use crate::session::{Session, is_valid_id};
19use crate::store::{DEFAULT_LIFETIME, SessionStore, SharedStore};
20use crate::{base64, constant_time_eq};
21use hmac::{Hmac, Mac};
22use rustlavel_core::{Config, Json, Result};
23use rustlavel_http::handler::BoxFuture;
24use rustlavel_http::middleware::{Middleware, Next};
25use rustlavel_http::{Cookie, Method, Request, Response, SameSite};
26use sha2::Sha256;
27use std::collections::BTreeMap;
28use std::sync::{Arc, Mutex, MutexGuard};
29use std::time::Duration;
30
31type HmacSha256 = Hmac<Sha256>;
32
33/// The cookie name used when nothing is configured.
34pub const DEFAULT_COOKIE: &str = "rustlavel_session";
35
36/// The request's session, shared between the middleware and the handler.
37///
38/// `Request::extension` hands out a shared reference, but the middleware has to
39/// read the session back *after* the handler has changed it — so the session
40/// lives behind a mutex and both sides hold the same handle. Cloning a handle
41/// is cloning an `Arc`; every clone is the same session.
42#[derive(Clone)]
43pub struct SessionHandle(Arc<Mutex<Session>>);
44
45impl SessionHandle {
46    pub fn new(session: Session) -> Self {
47        SessionHandle(Arc::new(Mutex::new(session)))
48    }
49
50    /// Borrow the session directly.
51    ///
52    /// The guard is not `Send`, so holding it across an `.await` will not
53    /// compile — which is the behaviour we want: a session held open across an
54    /// await is a session held open across another request.
55    pub fn lock(&self) -> MutexGuard<'_, Session> {
56        self.0.lock().expect("session lock poisoned")
57    }
58
59    /// Run a closure against the session, releasing the lock immediately.
60    pub fn with<R>(&self, action: impl FnOnce(&mut Session) -> R) -> R {
61        action(&mut self.lock())
62    }
63
64    pub fn id(&self) -> String {
65        self.lock().id().to_string()
66    }
67
68    pub fn get(&self, key: &str) -> Option<Json> {
69        self.lock().get(key).cloned()
70    }
71
72    pub fn get_string(&self, key: &str) -> Option<String> {
73        self.lock().get_string(key)
74    }
75
76    pub fn has(&self, key: &str) -> bool {
77        self.lock().has(key)
78    }
79
80    pub fn put(&self, key: impl Into<String>, value: impl Into<Json>) {
81        self.lock().put(key, value);
82    }
83
84    pub fn forget(&self, key: &str) -> Option<Json> {
85        self.lock().forget(key)
86    }
87
88    pub fn flush(&self) {
89        self.lock().flush();
90    }
91
92    pub fn all(&self) -> BTreeMap<String, Json> {
93        self.lock().all().clone()
94    }
95
96    pub fn flash(&self, key: impl Into<String>, value: impl Into<Json>) {
97        self.lock().flash(key, value);
98    }
99
100    pub fn keep(&self, key: &str) {
101        self.lock().keep(key);
102    }
103
104    pub fn reflash(&self) {
105        self.lock().reflash();
106    }
107
108    /// Rotate the id, keeping the contents. See [`Session::regenerate`].
109    pub fn regenerate(&self) -> String {
110        self.lock().regenerate().to_string()
111    }
112
113    /// Empty the session and rotate the id.
114    pub fn invalidate(&self) -> String {
115        self.lock().invalidate().to_string()
116    }
117
118    /// The CSRF token, generated on first use.
119    pub fn token(&self) -> String {
120        self.lock().token()
121    }
122}
123
124impl std::fmt::Debug for SessionHandle {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("SessionHandle").field("id", &self.id()).finish()
127    }
128}
129
130/// `req.session()` — reaching the session from a handler.
131pub trait SessionExt {
132    /// The session, or `None` when the `session` middleware is not installed.
133    fn try_session(&self) -> Option<&SessionHandle>;
134
135    /// The session, panicking with an actionable message if it is absent.
136    fn session(&self) -> &SessionHandle;
137}
138
139impl SessionExt for Request {
140    fn try_session(&self) -> Option<&SessionHandle> {
141        self.extension::<SessionHandle>()
142    }
143
144    fn session(&self) -> &SessionHandle {
145        self.try_session().expect(
146            "no session on this request. Add the `session` middleware to the router \
147             (`router.middleware(SessionManager::from_config(&config, store)?)`) before any route \
148             that uses `req.session()`.",
149        )
150    }
151}
152
153/// Loads and stores the session around every request.
154///
155/// Cheap to clone; the clone shares one store.
156#[derive(Clone)]
157pub struct SessionManager {
158    inner: Arc<Inner>,
159}
160
161struct Inner {
162    store: SharedStore,
163    cookie_key: [u8; 32],
164    cookie: String,
165    lifetime: Duration,
166    path: String,
167    secure: bool,
168    same_site: SameSite,
169}
170
171/// The session is where a flashed value lives.
172///
173/// `flash` writes with the session's own one-request lifetime, so a value left
174/// here is gone after the next request whether or not anybody read it — which
175/// is what stops an old error message reappearing on a page three clicks later.
176impl rustlavel_http::Flash for SessionHandle {
177    fn flash(&self, key: &str, value: Json) {
178        self.lock().flash(key, value);
179    }
180
181    fn take(&self, key: &str) -> Option<Json> {
182        self.lock().forget(key)
183    }
184
185    fn peek(&self, key: &str) -> Option<Json> {
186        self.lock().get(key).cloned()
187    }
188}
189
190impl SessionManager {
191    /// Build with an explicit key and store.
192    pub fn new(key: &AppKey, store: impl SessionStore) -> Self {
193        SessionManager { inner: Arc::new(SessionManager::defaults(key, store)) }
194    }
195
196    /// Build from configuration: `app.key`, `session.cookie`, `session.lifetime`.
197    ///
198    /// The store keeps its own lifetime — build it with
199    /// [`SessionManager::lifetime_from_config`] so the cookie and the stored
200    /// session expire together.
201    pub fn from_config(config: &Config, store: impl SessionStore) -> Result<Self> {
202        let mut inner = SessionManager::defaults(&AppKey::from_config(config)?, store);
203        inner.cookie = config.string("session.cookie", DEFAULT_COOKIE);
204        inner.lifetime = SessionManager::lifetime_from_config(config);
205        inner.path = config.string("session.path", "/");
206        // Default on in production, where the site is expected to be HTTPS.
207        inner.secure = config.bool("session.secure", config.is_production());
208
209        Ok(SessionManager { inner: Arc::new(inner) })
210    }
211
212    fn defaults(key: &AppKey, store: impl SessionStore) -> Inner {
213        let lifetime = store.lifetime();
214        Inner {
215            store: Arc::new(store),
216            cookie_key: key.derive("session-cookie"),
217            cookie: DEFAULT_COOKIE.to_string(),
218            lifetime,
219            path: "/".to_string(),
220            // `SameSite=Lax` already blocks the cross-site POST that CSRF
221            // depends on, so the cookie is only marked `Secure` when the
222            // application is actually served over HTTPS — otherwise local
223            // development over http:// would silently lose its session.
224            secure: false,
225            same_site: SameSite::Lax,
226        }
227    }
228
229    /// `session.lifetime`, in minutes, as Laravel writes it.
230    pub fn lifetime_from_config(config: &Config) -> Duration {
231        match config.int("session.lifetime", -1) {
232            minutes if minutes > 0 => Duration::from_secs(minutes as u64 * 60),
233            _ => DEFAULT_LIFETIME,
234        }
235    }
236
237    pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
238        Arc::make_mut(&mut self.inner).cookie = name.into();
239        self
240    }
241
242    pub fn lifetime(mut self, lifetime: Duration) -> Self {
243        Arc::make_mut(&mut self.inner).lifetime = lifetime;
244        self
245    }
246
247    pub fn path(mut self, path: impl Into<String>) -> Self {
248        Arc::make_mut(&mut self.inner).path = path.into();
249        self
250    }
251
252    /// Mark the cookie `Secure`, so browsers only send it over HTTPS.
253    pub fn secure(mut self, secure: bool) -> Self {
254        Arc::make_mut(&mut self.inner).secure = secure;
255        self
256    }
257
258    pub fn same_site(mut self, same_site: SameSite) -> Self {
259        Arc::make_mut(&mut self.inner).same_site = same_site;
260        self
261    }
262
263    pub fn store(&self) -> &SharedStore {
264        &self.inner.store
265    }
266
267    /// The signed cookie value for a session id: `<id>.<mac>`.
268    ///
269    /// Signing does not hide the id — it is `HttpOnly` and travels over TLS —
270    /// it stops the *store* being probed. Without a MAC, anybody could send
271    /// millions of guessed ids and make the server hit the filesystem for each
272    /// one; with it, a forged cookie is rejected before the store is touched.
273    fn sign(&self, id: &str) -> String {
274        let mut mac =
275            HmacSha256::new_from_slice(&self.inner.cookie_key).expect("HMAC accepts any key length");
276        mac.update(id.as_bytes());
277        format!("{id}.{}", base64::encode_url(&mac.finalize().into_bytes()))
278    }
279
280    /// Recover the id from a cookie, or `None` if it was not signed by us.
281    fn unsign(&self, value: &str) -> Option<String> {
282        let (id, signature) = value.rsplit_once('.')?;
283        if !is_valid_id(id) {
284            return None;
285        }
286
287        let expected = self.sign(id);
288        let expected = expected.rsplit_once('.')?.1;
289        constant_time_eq(expected.as_bytes(), signature.as_bytes()).then(|| id.to_string())
290    }
291
292    fn cookie_for(&self, id: &str) -> Cookie {
293        Cookie::new(self.inner.cookie.clone(), self.sign(id))
294            .path(self.inner.path.clone())
295            .max_age(self.inner.lifetime)
296            .http_only(true)
297            .secure(self.inner.secure)
298            .same_site(self.inner.same_site)
299    }
300}
301
302impl Clone for Inner {
303    fn clone(&self) -> Self {
304        Inner {
305            store: Arc::clone(&self.store),
306            cookie_key: self.cookie_key,
307            cookie: self.cookie.clone(),
308            lifetime: self.lifetime,
309            path: self.path.clone(),
310            secure: self.secure,
311            same_site: self.same_site,
312        }
313    }
314}
315
316impl Middleware for SessionManager {
317    fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
318        let manager = self.clone();
319
320        Box::pin(async move {
321            let presented = request
322                .cookie(&manager.inner.cookie)
323                .and_then(|value| manager.unsign(&value));
324
325            let (session, existed) = match &presented {
326                Some(id) => match manager.inner.store.read(id).await {
327                    Ok(Some(session)) => (session, true),
328                    Ok(None) => (Session::new(), false),
329                    Err(error) => {
330                        // A store that is down must not take the site down with
331                        // it: the visitor gets a fresh, empty session.
332                        rustlavel_core::error!("could not read the session: {error}");
333                        (Session::new(), false)
334                    }
335                },
336                None => (Session::new(), false),
337            };
338
339            let loaded_id = session.id().to_string();
340            let handle = SessionHandle::new(session);
341
342            // Read before the request is moved into the pipeline.
343            let method = request.method();
344            let wants_json = request.wants_json();
345            let target = request.target().to_string();
346
347            request.extend(handle.clone());
348            // Registered a second time behind the trait, so validation and the
349            // view layer can leave and read a flash without depending on this
350            // crate. See `rustlavel_http::flash`.
351            let flash: std::sync::Arc<dyn rustlavel_http::Flash> = std::sync::Arc::new(handle.clone());
352            request.extend(flash);
353
354            let response = next.run(request).await;
355
356            // Record where the visitor is, so a form that fails validation
357            // later knows where "back" is.
358            //
359            // After the handler, not before, and only for a session that is
360            // already being written. Recording unconditionally would start a
361            // session — and set a cookie — for everybody who ever loaded a
362            // page, which is both a privacy imposition and the end of caching
363            // anonymous responses. Running afterwards is what makes the common
364            // case work anyway: a page with a form has put a CSRF token in the
365            // session by now, so the request that needs this has a session by
366            // the time we ask. A page that uses no session at all falls back to
367            // the `Referer`.
368            //
369            // Only a page view counts. A POST is not somewhere to return to,
370            // and neither is a redirect or an answer to a request for JSON.
371            if matches!(method, Method::Get)
372                && !wants_json
373                && (200..300).contains(&response.status.code())
374                && handle.lock().is_dirty()
375            {
376                handle.put(rustlavel_http::flash::PREVIOUS_URL_KEY, target.clone());
377            }
378
379            // Age the flash and take a copy, then drop the lock: the guard must
380            // not be alive across the store's `.await`.
381            let (session, dirty) = {
382                let mut session = handle.lock();
383                session.age_flash();
384                (session.clone(), session.is_dirty())
385            };
386
387            // A request that changed nothing writes nothing and sets no cookie.
388            //
389            // `!existed` alone was not enough, and the gap was a real one: for a
390            // signed-in visitor `existed` is always true, so *every* response
391            // rewrote the session and re-sent the cookie — including the CSS,
392            // JavaScript and font requests, which reach here because static
393            // files are the router's fallback and the fallback still runs the
394            // global middleware.
395            //
396            // That made signing in a race. `Guard::login` rotates the id and
397            // destroys the old record; the asset requests the browser fired
398            // alongside the form post are still holding the *old* id, and on
399            // the way out each one wrote that old session back — resurrecting
400            // the record the rotation had just deleted — and handed the browser
401            // a cookie pointing at it. Whichever response landed last decided
402            // which session the visitor kept. When an asset won, the visitor
403            // was returned to a pre-login session and the next protected
404            // request answered 401: signed out seconds after signing in, and
405            // only sometimes, which is the worst way for a bug to behave.
406            //
407            // A crawler still leaves nothing behind, which is what the original
408            // condition was for.
409            if !dirty && session.id() == loaded_id {
410                return response;
411            }
412
413            // `regenerate` gave the session a new id; the old record is now a
414            // valid credential nobody should still be able to present.
415            if existed && session.id() != loaded_id
416                && let Err(error) = manager.inner.store.destroy(&loaded_id).await
417            {
418                rustlavel_core::error!("could not destroy the previous session: {error}");
419            }
420
421            if let Err(error) = manager.inner.store.write(&session).await {
422                rustlavel_core::error!("could not write the session: {error}");
423                return response;
424            }
425
426            response.with_cookie(manager.cookie_for(session.id()))
427        })
428    }
429}
430
431impl std::fmt::Debug for SessionManager {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        f.debug_struct("SessionManager")
434            .field("cookie", &self.inner.cookie)
435            .field("lifetime", &self.inner.lifetime)
436            .field("secure", &self.inner.secure)
437            .finish()
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::store::MemoryStore;
445    use rustlavel_http::{Router, TestClient};
446
447    fn key() -> AppKey {
448        AppKey::from_bytes([3u8; 32])
449    }
450
451    fn manager() -> SessionManager {
452        SessionManager::new(&key(), MemoryStore::new())
453    }
454
455
456    /// A response that changed nothing must not hand back a session cookie.
457    ///
458    /// This is the whole of the sign-in race. Static files are the router's
459    /// fallback and the fallback runs the global middleware, so the CSS and
460    /// JavaScript requests a browser fires alongside a sign-in reach here
461    /// holding the *old* id. While every such response re-sent a cookie, one of
462    /// them could land after the redirect and put the visitor back on the
463    /// session the sign-in had just replaced — signed out seconds later, and
464    /// only sometimes.
465    #[tokio::test]
466    async fn a_request_that_changes_nothing_sets_no_cookie() {
467        let mut router = Router::new();
468        router.middleware(manager());
469        router.get("/touches", |req: Request| async move {
470            req.session().put("something", Json::from("changed"));
471            "wrote"
472        });
473        router.get("/reads-nothing", |_req: Request| async { "untouched" });
474
475        let client = TestClient::new(router);
476
477        // A first request that does write, to get a session at all.
478        let first = client.get("/touches").await;
479        let cookie = cookie_of(&first);
480
481        // And now one that touches nothing, carrying that session.
482        let second = client
483            .send(Request::new(Method::Get, "/reads-nothing").with_header("cookie", cookie))
484            .await;
485        assert!(
486            second.header("set-cookie").is_none(),
487            "a response that changed nothing re-sent the session cookie, which is what let an \
488             asset request hand back an id a concurrent sign-in had already replaced"
489        );
490    }
491
492    /// And a session that did change still writes, or nothing would persist.
493    #[tokio::test]
494    async fn a_request_that_changes_the_session_still_sets_a_cookie() {
495        let mut router = Router::new();
496        router.middleware(manager());
497        router.get("/touches", |req: Request| async move {
498            req.session().put("n", Json::from(1.0));
499            "wrote"
500        });
501
502        let client = TestClient::new(router);
503        let response = client.get("/touches").await;
504        assert!(response.header("set-cookie").is_some(), "a changed session was not persisted");
505    }
506
507    /// Pull the session cookie out of a response, ready to send back.
508    fn cookie_of(response: &rustlavel_http::TestResponse) -> String {
509        let header = response
510            .header("set-cookie")
511            .unwrap_or_else(|| panic!("no set-cookie header on the response"));
512        header.split(';').next().unwrap().to_string()
513    }
514
515    fn counting_router(manager: SessionManager) -> Router {
516        let mut router = Router::new();
517        router.middleware(manager);
518        router.get("/count", |request: Request| async move {
519            let visits = request.session().get("visits").and_then(|v| v.as_i64()).unwrap_or(0) + 1;
520            request.session().put("visits", visits);
521            format!("{visits}")
522        });
523        router.get("/quiet", |_request: Request| async { "nothing to see" });
524        router.get("/flash", |request: Request| async move {
525            request.session().flash("status", "Saved");
526            "flashed"
527        });
528        router.get("/status", |request: Request| async move {
529            request.session().get_string("status").unwrap_or_else(|| "none".into())
530        });
531        router.get("/rotate", |request: Request| async move {
532            request.session().put("kept", "yes");
533            request.session().regenerate()
534        });
535        router
536    }
537
538    #[tokio::test]
539    async fn a_session_survives_between_requests_through_the_cookie() {
540        let client = TestClient::new(counting_router(manager()));
541
542        let first = client.get("/count").await.assert_ok();
543        assert_eq!(first.body(), "1");
544        let cookie = cookie_of(&first);
545        assert!(cookie.starts_with(DEFAULT_COOKIE));
546
547        let second = client
548            .send(Request::new(Method::Get, "/count").with_header("cookie", cookie.clone()))
549            .await;
550        assert_eq!(second.body(), "2");
551
552        let third = client
553            .send(Request::new(Method::Get, "/count").with_header("cookie", cookie))
554            .await;
555        assert_eq!(third.body(), "3");
556    }
557
558    #[tokio::test]
559    async fn the_session_cookie_is_http_only_and_scoped() {
560        let response = TestClient::new(counting_router(manager())).get("/count").await;
561        let header = response.header("set-cookie").unwrap();
562
563        assert!(header.contains("HttpOnly"), "header was {header}");
564        assert!(header.contains("SameSite=Lax"));
565        assert!(header.contains("Path=/"));
566        assert!(header.contains("Max-Age=7200"));
567        assert!(!header.contains("Secure"), "a plain-http development cookie is not Secure");
568    }
569
570    #[tokio::test]
571    async fn a_request_that_never_touches_the_session_gets_no_cookie() {
572        let response = TestClient::new(counting_router(manager())).get("/quiet").await.assert_ok();
573
574        assert_eq!(
575            response.header("set-cookie"),
576            None,
577            "nothing was stored, so there is nothing to hand the client"
578        );
579    }
580
581    #[tokio::test]
582    async fn a_forged_or_unsigned_cookie_starts_a_fresh_session() {
583        let client = TestClient::new(counting_router(manager()));
584
585        let real = client.get("/count").await;
586        let cookie = cookie_of(&real);
587
588        // Same id, signature replaced.
589        let (name_and_id, _) = cookie.rsplit_once('.').unwrap();
590        let forged = format!("{name_and_id}.{}", base64::encode_url(&[0u8; 32]));
591        let response = client
592            .send(Request::new(Method::Get, "/count").with_header("cookie", forged))
593            .await;
594        assert_eq!(response.body(), "1", "a forged cookie must not open the real session");
595
596        // An id-shaped value with no signature at all.
597        let response = client
598            .send(Request::new(Method::Get, "/count").with_header(
599                "cookie",
600                format!("{DEFAULT_COOKIE}={}", Session::new_id()),
601            ))
602            .await;
603        assert_eq!(response.body(), "1");
604    }
605
606    #[test]
607    fn a_cookie_signed_with_another_key_is_refused() {
608        let mine = SessionManager::new(&key(), MemoryStore::new());
609        let theirs = SessionManager::new(&AppKey::from_bytes([4u8; 32]), MemoryStore::new());
610        let id = Session::new_id();
611
612        assert_eq!(mine.unsign(&mine.sign(&id)).as_deref(), Some(id.as_str()));
613        assert!(mine.unsign(&theirs.sign(&id)).is_none());
614        // And a value that is not even shaped like a signed cookie.
615        assert!(mine.unsign("no-dot-here").is_none());
616        assert!(mine.unsign(&format!("not-an-id.{}", base64::encode_url(&[0u8; 32]))).is_none());
617    }
618
619    #[tokio::test]
620    async fn flashed_data_is_readable_on_the_next_request_and_gone_after_it() {
621        let client = TestClient::new(counting_router(manager()));
622
623        let flashed = client.get("/flash").await;
624        let cookie = cookie_of(&flashed);
625
626        let next = client
627            .send(Request::new(Method::Get, "/status").with_header("cookie", cookie.clone()))
628            .await;
629        assert_eq!(next.body(), "Saved");
630
631        let after = client
632            .send(Request::new(Method::Get, "/status").with_header("cookie", cookie))
633            .await;
634        assert_eq!(after.body(), "none");
635    }
636
637    #[tokio::test]
638    async fn regenerating_issues_a_new_cookie_and_drops_the_old_record() {
639        let manager = manager();
640        let store = Arc::clone(manager.store());
641        let client = TestClient::new(counting_router(manager));
642
643        let first = client.get("/count").await;
644        let old_cookie = cookie_of(&first);
645        let old_id = old_cookie.split_once('=').unwrap().1.rsplit_once('.').unwrap().0.to_string();
646        assert!(store.read(&old_id).await.unwrap().is_some());
647
648        let rotated = client
649            .send(Request::new(Method::Get, "/rotate").with_header("cookie", old_cookie.clone()))
650            .await;
651        let new_id = rotated.body();
652        let new_cookie = cookie_of(&rotated);
653
654        assert_ne!(new_id, old_id);
655        assert!(store.read(&old_id).await.unwrap().is_none(), "the old id must stop working");
656
657        let carried = store.read(&new_id).await.unwrap().expect("the new id holds the session");
658        assert_eq!(carried.get_string("kept").as_deref(), Some("yes"));
659        assert_ne!(new_cookie, old_cookie);
660    }
661
662    #[tokio::test]
663    async fn an_expired_stored_session_starts_over() {
664        let manager = SessionManager::new(&key(), MemoryStore::with_lifetime(Duration::ZERO));
665        let client = TestClient::new(counting_router(manager));
666
667        let first = client.get("/count").await;
668        let cookie = cookie_of(&first);
669
670        let second = client
671            .send(Request::new(Method::Get, "/count").with_header("cookie", cookie))
672            .await;
673        assert_eq!(second.body(), "1", "the stored session had already expired");
674    }
675
676    #[test]
677    fn configuration_drives_the_cookie_name_lifetime_and_secure_flag() {
678        let config = Config::new();
679        config.set("app.key", AppKey::generate());
680        config.set("session.cookie", "my_app_session");
681        config.set("session.lifetime", 30);
682        config.set("app.env", "production");
683
684        let manager = SessionManager::from_config(&config, MemoryStore::new()).unwrap();
685
686        assert_eq!(manager.inner.cookie, "my_app_session");
687        assert_eq!(manager.inner.lifetime, Duration::from_secs(30 * 60));
688        assert!(manager.inner.secure, "production cookies default to Secure");
689        assert_eq!(SessionManager::lifetime_from_config(&Config::new()), DEFAULT_LIFETIME);
690    }
691
692    #[test]
693    fn builders_override_configuration() {
694        let manager = manager()
695            .cookie_name("custom")
696            .lifetime(Duration::from_secs(60))
697            .path("/app")
698            .secure(true)
699            .same_site(SameSite::Strict);
700
701        assert_eq!(manager.inner.cookie, "custom");
702        assert_eq!(manager.inner.path, "/app");
703        assert!(manager.inner.secure);
704
705        let header = manager.cookie_for(&Session::new_id()).to_header();
706        assert!(header.contains("SameSite=Strict"));
707        assert!(header.contains("Secure"));
708        assert!(header.starts_with("custom="));
709    }
710
711    #[tokio::test]
712    async fn a_handler_without_the_middleware_says_what_is_missing() {
713        let mut router = Router::new();
714        router.get("/", |request: Request| async move {
715            assert!(request.try_session().is_none());
716            request.session().id()
717        });
718
719        // The router turns the panic into a 500 rather than aborting the test.
720        let response = TestClient::new(router).get("/").await;
721        assert_eq!(response.status(), 500);
722    }
723}