Skip to main content

autumn_web/
session.rs

1//! Cookie-based session management for Autumn applications.
2//!
3//! Provides a [`Session`] extractor that gives handlers access to a
4//! per-user key-value store backed by a server-side [`SessionStore`].
5//! Session IDs are transmitted via a configurable cookie (default:
6//! `autumn.sid`).
7//!
8//! ## Quick start
9//!
10//! ```rust,no_run
11//! use autumn_web::prelude::*;
12//! use autumn_web::session::Session;
13//!
14//! #[get("/dashboard")]
15//! async fn dashboard(session: Session) -> AutumnResult<String> {
16//!     let user = session.get("user_id").await.unwrap_or_default();
17//!     Ok(format!("Hello, {user}"))
18//! }
19//!
20//! #[post("/login")]
21//! async fn login(session: Session) -> &'static str {
22//!     session.insert("user_id", "alice").await;
23//!     "logged in"
24//! }
25//!
26//! #[post("/logout")]
27//! async fn logout(session: Session) -> &'static str {
28//!     session.clear().await;
29//!     "logged out"
30//! }
31//! ```
32//!
33//! ## Architecture
34//!
35//! The session system has three components:
36//!
37//! 1. **[`SessionStore`]** trait -- pluggable storage backend.
38//! 2. **[`MemoryStore`]** -- default in-memory implementation (suitable for
39//!    development; data is lost on restart).
40//! 3. **[`SessionLayer`]** -- Tower middleware that loads/saves sessions and
41//!    manages the session cookie.
42//!
43//! ## Configuration
44//!
45//! Configure via `autumn.toml`:
46//!
47//! ```toml
48//! [session]
49//! backend = "memory"
50//! cookie_name = "autumn.sid"
51//! max_age_secs = 86400       # 24 hours
52//! secure = true               # true by default
53//! same_site = "Lax"
54//!
55//! [session.redis]
56//! url = "redis://127.0.0.1:6379"
57//! key_prefix = "autumn:sessions"
58//! ```
59//!
60//! Or via environment variables: `AUTUMN_SESSION__BACKEND`,
61//! `AUTUMN_SESSION__COOKIE_NAME`, `AUTUMN_SESSION__MAX_AGE_SECS`,
62//! `AUTUMN_SESSION__REDIS__URL`, etc.
63
64// autumn-determinism-gate: production code in this module must read time and
65// mint identifiers through the framework's injected seams (ClockSource /
66// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
67// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
68// (issue #1797). Justify exceptions with
69// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
70#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
71// autumn-panic-gate: request-path module — production code path must be panic-free.
72// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
73// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
74#![cfg_attr(
75    not(test),
76    deny(
77        clippy::unwrap_used,
78        clippy::expect_used,
79        clippy::panic,
80        clippy::unreachable,
81        clippy::todo,
82        clippy::unimplemented,
83        clippy::indexing_slicing,
84        clippy::string_slice,
85        clippy::arithmetic_side_effects,
86    )
87)]
88
89use std::collections::HashMap;
90use std::future::Future;
91use std::pin::Pin;
92use std::sync::Arc;
93use std::task::{Context, Poll};
94
95use axum::extract::{FromRequestParts, Request};
96use axum::response::{IntoResponse, Response};
97use http::HeaderValue;
98use http::StatusCode;
99use http::header::{COOKIE, SET_COOKIE};
100use http::request::Parts;
101use thiserror::Error;
102use tokio::sync::RwLock;
103use tower::{Layer, Service};
104#[cfg(test)]
105use uuid::Uuid;
106
107// ── Session data ────────────────────────────────────────────────
108
109/// A handle to the current request's session data.
110///
111/// Obtained via the [`Session`] Axum extractor. All reads and writes
112/// go through interior mutability so the extractor can be shared.
113///
114/// Changes are written back to the store automatically when the
115/// response is sent (via [`SessionLayer`]).
116#[derive(Clone, Debug)]
117pub struct Session {
118    inner: Arc<RwLock<SessionInner>>,
119    /// Entropy source used to mint rotated session ids. Defaults to
120    /// [`crate::entropy::OsEntropy`]; the session layer threads the app's seeded
121    /// source in so id rotation replays deterministically under a fixed seed.
122    entropy: Arc<dyn crate::entropy::Entropy>,
123}
124
125#[derive(Debug)]
126struct SessionInner {
127    id: String,
128    old_id: Option<String>,
129    /// Every id the session held before the current one, oldest first.
130    prior_ids: Vec<String>,
131    data: HashMap<String, String>,
132    cookie_backed: bool,
133    dirty: bool,
134    destroyed: bool,
135}
136
137impl Session {
138    /// Create a session for testing purposes.
139    #[doc(hidden)]
140    #[must_use]
141    pub fn new_for_test(id: String, data: HashMap<String, String>) -> Self {
142        Self::new_cookie_backed(id, data)
143    }
144
145    /// Create a fresh, non-cookie-backed session for testing purposes (i.e.
146    /// as [`Session::is_cookie_backed`] would report for a request with no
147    /// prior session cookie).
148    #[doc(hidden)]
149    #[must_use]
150    pub fn new_for_test_without_cookie(id: String, data: HashMap<String, String>) -> Self {
151        Self::new(id, data)
152    }
153
154    fn new(id: String, data: HashMap<String, String>) -> Self {
155        Self::with_cookie_state(id, data, false)
156    }
157
158    fn new_cookie_backed(id: String, data: HashMap<String, String>) -> Self {
159        Self::with_cookie_state(id, data, true)
160    }
161
162    fn with_cookie_state(id: String, data: HashMap<String, String>, cookie_backed: bool) -> Self {
163        Self {
164            inner: Arc::new(RwLock::new(SessionInner {
165                id,
166                old_id: None,
167                prior_ids: Vec::new(),
168                data,
169                cookie_backed,
170                dirty: false,
171                destroyed: false,
172            })),
173            entropy: Arc::new(crate::entropy::OsEntropy),
174        }
175    }
176
177    /// Attach the entropy source used to mint rotated session ids.
178    ///
179    /// The session layer calls this with the app's injected source so a
180    /// simulation replays session-id rotation deterministically.
181    #[must_use]
182    fn with_entropy(mut self, entropy: Arc<dyn crate::entropy::Entropy>) -> Self {
183        self.entropy = entropy;
184        self
185    }
186
187    /// Returns the session ID.
188    pub async fn id(&self) -> String {
189        self.inner.read().await.id.clone()
190    }
191
192    /// Returns whether this session ID came from a valid request cookie rather
193    /// than being generated for the current request.
194    pub async fn is_cookie_backed(&self) -> bool {
195        self.inner.read().await.cookie_backed
196    }
197
198    /// Force this session to be persisted and its cookie (re)issued on the
199    /// response, even though no session data was read or written.
200    ///
201    /// `SessionLayer` only saves the session and sends `Set-Cookie` when it
202    /// is dirty; a request that only calls [`Session::id`] never dirties it.
203    /// Call this before handing [`Session::id`]'s value to something that
204    /// will later be used to identify this session (e.g. binding an external
205    /// capability to it) — otherwise, for a session with no cookie yet, the
206    /// browser never receives the cookie needed to present that same id on a
207    /// later request.
208    pub async fn touch(&self) {
209        self.inner.write().await.dirty = true;
210    }
211
212    pub(crate) async fn has_pending_changes(&self) -> bool {
213        let inner = self.inner.read().await;
214        inner.dirty || inner.destroyed
215    }
216
217    /// Get a value from the session.
218    pub async fn get(&self, key: &str) -> Option<String> {
219        self.inner.read().await.data.get(key).cloned()
220    }
221
222    /// Insert a key-value pair into the session.
223    pub async fn insert(&self, key: impl Into<String>, value: impl Into<String>) {
224        let mut inner = self.inner.write().await;
225        inner.data.insert(key.into(), value.into());
226        inner.dirty = true;
227    }
228
229    /// Remove a key from the session, returning the previous value.
230    pub async fn remove(&self, key: &str) -> Option<String> {
231        let mut inner = self.inner.write().await;
232        let val = inner.data.remove(key);
233        if val.is_some() {
234            inner.dirty = true;
235        }
236        val
237    }
238
239    /// Remove all data from the session (but keep the session ID).
240    pub async fn clear(&self) {
241        let mut inner = self.inner.write().await;
242        inner.data.clear();
243        inner.dirty = true;
244    }
245
246    /// Rotate the session ID, generating a new ID for the same data.
247    ///
248    /// This is critical to call during privilege elevation (e.g., login)
249    /// to prevent Session Fixation attacks.
250    ///
251    /// State keyed by the old id can follow the new id with a
252    /// [`SessionRotationHooks`] hook.
253    pub async fn rotate_id(&self) {
254        let new_id = self.entropy.uuid_v4().to_string();
255        let mut inner = self.inner.write().await;
256        if inner.old_id.is_none() {
257            inner.old_id = Some(inner.id.clone());
258        }
259        let previous = std::mem::replace(&mut inner.id, new_id);
260        inner.prior_ids.push(previous);
261        inner.dirty = true;
262    }
263
264    /// The id just before the latest rotation, if the id was rotated.
265    pub(crate) async fn previous_id(&self) -> Option<String> {
266        self.inner.read().await.prior_ids.last().cloned()
267    }
268
269    /// Destroy the session entirely. A new session ID will be issued
270    /// on the next request.
271    pub async fn destroy(&self) {
272        let mut inner = self.inner.write().await;
273        inner.data.clear();
274        inner.destroyed = true;
275        inner.dirty = true;
276    }
277
278    /// Returns `true` if this session contains the given key.
279    pub async fn contains_key(&self, key: &str) -> bool {
280        self.inner.read().await.data.contains_key(key)
281    }
282}
283
284impl<S> FromRequestParts<S> for Session
285where
286    S: Send + Sync,
287{
288    type Rejection = std::convert::Infallible;
289
290    fn from_request_parts(
291        parts: &mut Parts,
292        _state: &S,
293    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
294        #[allow(
295            clippy::expect_used,
296            reason = "misconfiguration: missing SessionLayer is a setup error, surfaced eagerly"
297        )]
298        let session = parts
299            .extensions
300            .get::<Self>()
301            .cloned()
302            .expect("SessionLayer must be installed to use the Session extractor");
303        async move { Ok(session) }
304    }
305}
306
307// ── Rotation hooks ──────────────────────────────────────────────
308
309/// A committed session-id rotation: the net hop for one request.
310///
311/// Several `rotate_id` calls in one request give one event, from the id the
312/// request started with to the id it ended with.
313#[derive(Clone, Debug, PartialEq, Eq)]
314#[non_exhaustive]
315pub struct SessionRotation {
316    /// The id the request started with.
317    pub old_id: String,
318    /// The id now stored and sent to the client.
319    pub new_id: String,
320    /// Every id the session held during the request before `new_id`, oldest
321    /// first. It starts with `old_id`. Several rotations in one request leave
322    /// state under any of them, for example after a flow that moved it by hand.
323    pub earlier_ids: Vec<String>,
324}
325
326/// Error type a rotation hook returns.
327pub type SessionRotationError = Box<dyn std::error::Error + Send + Sync>;
328
329type RotationHookFn = dyn Fn(SessionRotation) -> Pin<Box<dyn Future<Output = Result<(), SessionRotationError>> + Send>>
330    + Send
331    + Sync;
332
333/// Hooks that run after a session-id rotation is stored.
334///
335/// `Session::rotate_id` changes the id that server-side rows are keyed by (for
336/// example a tracked-session row keyed by a digest of the id). A hook moves
337/// that state to the new id, so the next request does not find a missing row.
338///
339/// Hooks run after the new session is saved and before the response returns.
340/// They do not run for a destroyed session. A hook error is logged and does
341/// not change the response.
342///
343/// Get the shared registry with
344/// [`AppState::session_rotation_hooks`](crate::state::AppState::session_rotation_hooks).
345#[derive(Clone, Default)]
346pub struct SessionRotationHooks {
347    hooks: Arc<std::sync::RwLock<Vec<Arc<RotationHookFn>>>>,
348}
349
350impl std::fmt::Debug for SessionRotationHooks {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        f.debug_struct("SessionRotationHooks")
353            .field("len", &self.len())
354            .finish()
355    }
356}
357
358impl SessionRotationHooks {
359    /// Add a hook. Hooks run in registration order.
360    pub fn register<F, Fut>(&self, hook: F)
361    where
362        F: Fn(SessionRotation) -> Fut + Send + Sync + 'static,
363        Fut: Future<Output = Result<(), SessionRotationError>> + Send + 'static,
364    {
365        self.hooks
366            .write()
367            .unwrap_or_else(std::sync::PoisonError::into_inner)
368            .push(Arc::new(move |rotation| Box::pin(hook(rotation))));
369    }
370
371    pub(crate) fn len(&self) -> usize {
372        self.hooks
373            .read()
374            .unwrap_or_else(std::sync::PoisonError::into_inner)
375            .len()
376    }
377
378    pub(crate) fn is_empty(&self) -> bool {
379        self.len() == 0
380    }
381
382    async fn run(&self, rotation: &SessionRotation) {
383        if self.is_empty() {
384            return;
385        }
386        let hooks: Vec<_> = self
387            .hooks
388            .read()
389            .unwrap_or_else(std::sync::PoisonError::into_inner)
390            .clone();
391        for hook in hooks {
392            if let Err(error) = hook(rotation.clone()).await {
393                tracing::error!(%error, "session rotation hook failed");
394            }
395        }
396    }
397}
398
399// ── Session store trait ─────────────────────────────────────────
400
401/// Pluggable session storage backend.
402///
403/// Implement this trait to store sessions in Redis, a database, etc.
404/// The default implementation is [`MemoryStore`].
405pub trait SessionStore: Send + Sync + 'static {
406    /// Load session data for the given ID. Returns `None` if the session
407    /// does not exist or has expired.
408    fn load(
409        &self,
410        id: &str,
411    ) -> impl Future<Output = Result<Option<HashMap<String, String>>, SessionStoreError>> + Send;
412
413    /// Save session data under the given ID.
414    fn save(
415        &self,
416        id: &str,
417        data: HashMap<String, String>,
418    ) -> impl Future<Output = Result<(), SessionStoreError>> + Send;
419
420    /// Delete session data for the given ID.
421    fn destroy(&self, id: &str) -> impl Future<Output = Result<(), SessionStoreError>> + Send;
422}
423
424/// An error that occurred during a session store operation.
425#[derive(Debug, Clone, Error, PartialEq, Eq)]
426#[error("{message}")]
427pub struct SessionStoreError {
428    message: String,
429}
430
431impl SessionStoreError {
432    /// Create a new session store error from an underlying backend error.
433    #[must_use]
434    pub fn backend(operation: &'static str, error: impl std::fmt::Display) -> Self {
435        Self {
436            message: format!("{operation} failed: {error}"),
437        }
438    }
439}
440
441// ── In-memory store ─────────────────────────────────────────────
442
443/// In-memory session store. Suitable for development and testing.
444///
445/// All data is lost when the process exits. For production use,
446/// implement [`SessionStore`] backed by Redis or a database.
447#[derive(Clone, Debug, Default)]
448pub struct MemoryStore {
449    sessions: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
450}
451
452impl MemoryStore {
453    /// Create a new empty in-memory session store.
454    #[must_use]
455    pub fn new() -> Self {
456        Self::default()
457    }
458}
459
460impl SessionStore for MemoryStore {
461    async fn load(&self, id: &str) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
462        Ok(self.sessions.read().await.get(id).cloned())
463    }
464
465    async fn save(&self, id: &str, data: HashMap<String, String>) -> Result<(), SessionStoreError> {
466        self.sessions.write().await.insert(id.to_owned(), data);
467        Ok(())
468    }
469
470    async fn destroy(&self, id: &str) -> Result<(), SessionStoreError> {
471        self.sessions.write().await.remove(id);
472        Ok(())
473    }
474}
475
476// ── Erasure bridge for runtime-installed custom stores ─────────
477//
478// `SessionStore` uses RPIT (`-> impl Future + Send`) and is therefore not
479// dyn-compatible. To let `AppBuilder::with_session_store(impl SessionStore)`
480// erase the concrete type into something `AppBuilder` can store and
481// `build_session_layer` can wrap into a `SessionLayer`, we keep a
482// pub(crate) dyn-compatible `BoxedSessionStore` shadow trait with a blanket
483// impl over any `SessionStore`, plus an `ArcSessionStore` newtype that
484// satisfies `SessionStore` by delegating through the trait object. Users
485// only see `SessionStore`; the bridge stays an implementation detail.
486
487pub(crate) type BoxedLoadFuture<'a> = Pin<
488    Box<
489        dyn Future<Output = Result<Option<HashMap<String, String>>, SessionStoreError>> + Send + 'a,
490    >,
491>;
492pub(crate) type BoxedUnitFuture<'a> =
493    Pin<Box<dyn Future<Output = Result<(), SessionStoreError>> + Send + 'a>>;
494
495pub(crate) trait BoxedSessionStore: Send + Sync + 'static {
496    fn boxed_load<'a>(&'a self, id: &'a str) -> BoxedLoadFuture<'a>;
497
498    fn boxed_save<'a>(&'a self, id: &'a str, data: HashMap<String, String>) -> BoxedUnitFuture<'a>;
499
500    fn boxed_destroy<'a>(&'a self, id: &'a str) -> BoxedUnitFuture<'a>;
501}
502
503impl<S: SessionStore> BoxedSessionStore for S {
504    fn boxed_load<'a>(&'a self, id: &'a str) -> BoxedLoadFuture<'a> {
505        Box::pin(SessionStore::load(self, id))
506    }
507
508    fn boxed_save<'a>(&'a self, id: &'a str, data: HashMap<String, String>) -> BoxedUnitFuture<'a> {
509        Box::pin(SessionStore::save(self, id, data))
510    }
511
512    fn boxed_destroy<'a>(&'a self, id: &'a str) -> BoxedUnitFuture<'a> {
513        Box::pin(SessionStore::destroy(self, id))
514    }
515}
516
517#[derive(Clone)]
518pub(crate) struct ArcSessionStore(pub(crate) Arc<dyn BoxedSessionStore>);
519
520impl SessionStore for ArcSessionStore {
521    async fn load(&self, id: &str) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
522        self.0.boxed_load(id).await
523    }
524
525    async fn save(&self, id: &str, data: HashMap<String, String>) -> Result<(), SessionStoreError> {
526        self.0.boxed_save(id, data).await
527    }
528
529    async fn destroy(&self, id: &str) -> Result<(), SessionStoreError> {
530        self.0.boxed_destroy(id).await
531    }
532}
533
534// ── Session configuration ───────────────────────────────────────
535
536/// Configuration for session management.
537///
538/// # Defaults
539///
540/// | Field | Default |
541/// |-------|---------|
542/// | `cookie_name` | `"autumn.sid"` |
543/// | `max_age_secs` | `86400` (24 hours) |
544/// | `backend` | `memory` |
545/// | `secure` | `true` |
546/// | `same_site` | `"Lax"` |
547/// | `http_only` | `true` |
548/// | `path` | `"/"` |
549#[derive(Debug, Clone, serde::Deserialize)]
550pub struct SessionConfig {
551    /// Storage backend used for session data.
552    #[serde(default)]
553    pub backend: SessionBackend,
554
555    /// Name of the session cookie.
556    #[serde(default = "default_cookie_name")]
557    pub cookie_name: String,
558
559    /// Maximum age of the session cookie in seconds.
560    #[serde(default = "default_max_age_secs")]
561    pub max_age_secs: u64,
562
563    /// Whether the cookie should only be sent over HTTPS.
564    #[serde(default = "default_true")]
565    pub secure: bool,
566
567    /// `SameSite` attribute for the cookie.
568    #[serde(default = "default_same_site")]
569    pub same_site: String,
570
571    /// Whether the cookie should be inaccessible to JavaScript.
572    #[serde(default = "default_true")]
573    pub http_only: bool,
574
575    /// Path scope for the cookie.
576    #[serde(default = "default_path")]
577    pub path: String,
578
579    /// Suppress the production warning for process-local session storage.
580    #[serde(default)]
581    pub allow_memory_in_production: bool,
582
583    /// Redis session backend configuration.
584    #[serde(default)]
585    pub redis: SessionRedisConfig,
586}
587
588/// Supported session storage backends.
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
590#[serde(rename_all = "lowercase")]
591#[non_exhaustive]
592pub enum SessionBackend {
593    /// In-memory storage. Resets on application restart.
594    #[default]
595    Memory,
596    /// Redis-backed storage. Suitable for production and multi-instance deployments.
597    Redis,
598}
599
600impl SessionBackend {
601    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
602        match value.trim().to_ascii_lowercase().as_str() {
603            "memory" => Some(Self::Memory),
604            "redis" => Some(Self::Redis),
605            _ => None,
606        }
607    }
608}
609
610/// Configuration specific to the Redis session backend.
611#[derive(Debug, Clone, serde::Deserialize)]
612pub struct SessionRedisConfig {
613    /// The Redis connection URL (e.g. `redis://127.0.0.1:6379`).
614    #[serde(default)]
615    pub url: Option<String>,
616
617    /// Prefix used for session keys in Redis. Defaults to `autumn:sessions`.
618    #[serde(default = "default_redis_key_prefix")]
619    pub key_prefix: String,
620}
621
622impl Default for SessionRedisConfig {
623    fn default() -> Self {
624        Self {
625            url: None,
626            key_prefix: default_redis_key_prefix(),
627        }
628    }
629}
630
631fn default_redis_key_prefix() -> String {
632    "autumn:sessions".to_owned()
633}
634
635/// Represents the resolved plan for which session backend to initialize.
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum SessionBackendPlan {
638    /// Use the in-memory store.
639    Memory {
640        /// Whether to log a warning because memory sessions are used in production.
641        warn_in_production: bool,
642    },
643    /// Use the Redis store.
644    Redis {
645        /// The validated Redis connection URL.
646        url: String,
647        /// The prefix to use for session keys.
648        key_prefix: String,
649    },
650}
651
652/// Errors that can occur when resolving the session backend configuration.
653#[derive(Debug, Error, PartialEq, Eq)]
654#[non_exhaustive]
655pub enum SessionBackendConfigError {
656    /// Redis was selected, but no URL was provided.
657    #[error("session.backend=redis requires session.redis.url")]
658    MissingRedisUrl,
659    /// The provided Redis URL could not be parsed.
660    #[error("session.redis.url is not a valid Redis URL: {0}")]
661    InvalidRedisUrl(String),
662    /// Redis was selected, but the `redis` crate feature is not enabled.
663    #[error("session.backend=redis requires the `redis` feature")]
664    RedisFeatureDisabled,
665}
666
667fn default_cookie_name() -> String {
668    "autumn.sid".to_owned()
669}
670const fn default_max_age_secs() -> u64 {
671    86400
672}
673fn default_same_site() -> String {
674    "Lax".to_owned()
675}
676const fn default_true() -> bool {
677    true
678}
679fn default_path() -> String {
680    "/".to_owned()
681}
682
683impl Default for SessionConfig {
684    fn default() -> Self {
685        Self {
686            backend: SessionBackend::default(),
687            cookie_name: default_cookie_name(),
688            max_age_secs: default_max_age_secs(),
689            secure: true,
690            same_site: default_same_site(),
691            http_only: default_true(),
692            path: default_path(),
693            allow_memory_in_production: false,
694            redis: SessionRedisConfig::default(),
695        }
696    }
697}
698
699impl SessionConfig {
700    /// Resolve the concrete session backend plan from config.
701    ///
702    /// # Errors
703    ///
704    /// Returns [`SessionBackendConfigError`] when the configured backend is
705    /// incomplete or invalid, such as Redis without a URL.
706    pub fn backend_plan(
707        &self,
708        profile: Option<&str>,
709    ) -> Result<SessionBackendPlan, SessionBackendConfigError> {
710        match self.backend {
711            SessionBackend::Memory => Ok(SessionBackendPlan::Memory {
712                warn_in_production: is_production_profile(profile)
713                    && !self.allow_memory_in_production,
714            }),
715            SessionBackend::Redis => {
716                let Some(url) = self.redis.url.clone().filter(|url| !url.trim().is_empty()) else {
717                    return Err(SessionBackendConfigError::MissingRedisUrl);
718                };
719
720                #[cfg(feature = "redis")]
721                {
722                    if let Err(error) = crate::redis_tls::open_client(&url) {
723                        return Err(SessionBackendConfigError::InvalidRedisUrl(
724                            error.to_string(),
725                        ));
726                    }
727
728                    Ok(SessionBackendPlan::Redis {
729                        url,
730                        key_prefix: self.redis.key_prefix.clone(),
731                    })
732                }
733
734                #[cfg(not(feature = "redis"))]
735                {
736                    let _ = url;
737                    Err(SessionBackendConfigError::RedisFeatureDisabled)
738                }
739            }
740        }
741    }
742}
743
744fn is_production_profile(profile: Option<&str>) -> bool {
745    matches!(profile, Some("prod" | "production"))
746}
747
748// ── Cookie helpers ──────────────────────────────────────────────
749
750/// Extract a named cookie value from the Cookie header.
751pub(crate) fn get_cookie(headers: &http::HeaderMap, name: &str) -> Option<String> {
752    let mut found_token = None;
753
754    for cookie_header in headers.get_all(COOKIE) {
755        let Ok(cookie_str) = cookie_header.to_str() else {
756            continue;
757        };
758
759        for pair in cookie_str.split(';') {
760            let pair = pair.trim();
761            let Some((k, v)) = pair.split_once('=') else {
762                continue;
763            };
764
765            if k.trim() != name {
766                continue;
767            }
768
769            if found_token.is_some() {
770                // Multiple cookies with the same name found.
771                // This indicates a potential Cookie Tossing attack!
772                // Reject by returning None.
773                return None;
774            }
775
776            found_token = Some(v.trim().to_owned());
777        }
778    }
779    found_token
780}
781
782/// Resolve a session id using the canonical cookie parser and signing-key
783/// rotation rules.  Keeping this helper here prevents edge identity adapters
784/// and backend plugins from growing subtly different authentication rules.
785pub(crate) fn verified_session_id(
786    headers: &http::HeaderMap,
787    cookie_name: &str,
788    signing_keys: &crate::security::config::ResolvedSigningKeys,
789) -> Option<String> {
790    let raw = get_cookie(headers, cookie_name)?;
791    let (id, signature) = raw.split_once('.')?;
792    signing_keys
793        .verify(id.as_bytes(), signature)
794        .then(|| id.to_owned())
795}
796
797/// Apply the exact optional-signing policy used by [`SessionLayer`].
798pub(crate) fn session_id_from_headers(
799    headers: &http::HeaderMap,
800    cookie_name: &str,
801    signing_keys: Option<&crate::security::config::ResolvedSigningKeys>,
802) -> Option<String> {
803    signing_keys.map_or_else(
804        || get_cookie(headers, cookie_name),
805        |keys| verified_session_id(headers, cookie_name, keys),
806    )
807}
808
809/// Fuzzing seam: exercise the cookie-header parser plus the signed-session
810/// cookie verification path (`{session_id}.{hmac_hex}` split + HMAC verify)
811/// over arbitrary bytes. Mirrors the decode performed by `SessionLayer`.
812///
813/// Compiled only under `--cfg fuzzing`, so the published crate is unaffected.
814/// See `fuzz/fuzz_targets/session.rs`.
815#[cfg(fuzzing)]
816pub fn __fuzz_decode_cookie(
817    cookie_header: &[u8],
818    cookie_name: &str,
819    secret: &[u8],
820) -> Option<String> {
821    let mut headers = http::HeaderMap::new();
822    if let Ok(value) = http::HeaderValue::from_bytes(cookie_header) {
823        headers.append(COOKIE, value);
824    }
825    let raw = get_cookie(&headers, cookie_name)?;
826    let keys = crate::security::config::ResolvedSigningKeys::new(secret.to_vec(), Vec::new());
827    let (id, sig) = raw.split_once('.')?;
828    keys.verify(id.as_bytes(), sig).then(|| id.to_owned())
829}
830
831/// Build a Set-Cookie header value.
832fn build_set_cookie(config: &SessionConfig, session_id: &str) -> String {
833    use std::fmt::Write;
834    let mut cookie = format!(
835        "{}={}; Path={}",
836        config.cookie_name, session_id, config.path
837    );
838    let _ = write!(cookie, "; Max-Age={}", config.max_age_secs);
839    if config.http_only {
840        cookie.push_str("; HttpOnly");
841    }
842    if config.secure {
843        cookie.push_str("; Secure");
844    }
845    let _ = write!(cookie, "; SameSite={}", config.same_site);
846    cookie
847}
848
849/// Build a Set-Cookie header that expires the cookie immediately.
850fn build_expire_cookie(config: &SessionConfig) -> String {
851    format!(
852        "{}=; Path={}; Max-Age=0; HttpOnly; SameSite={}",
853        config.cookie_name, config.path, config.same_site
854    )
855}
856
857// ── Session layer (Tower middleware) ────────────────────────────
858
859/// Tower [`Layer`] that manages session loading, saving, and cookie handling.
860///
861/// Install this on the Autumn router to enable the [`Session`] extractor.
862/// The framework installs it automatically when a session store is configured.
863///
864/// # Examples
865///
866/// ```rust,no_run
867/// use autumn_web::session::{SessionLayer, MemoryStore, SessionConfig};
868///
869/// let layer = SessionLayer::new(MemoryStore::new(), SessionConfig::default());
870/// ```
871#[derive(Clone)]
872pub struct SessionLayer<S: SessionStore> {
873    store: Arc<S>,
874    config: Arc<SessionConfig>,
875    signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
876    entropy: Arc<dyn crate::entropy::Entropy>,
877    tenancy_session_key: Option<Arc<str>>,
878    rotation_hooks: Option<SessionRotationHooks>,
879}
880
881impl<S: SessionStore> SessionLayer<S> {
882    /// Create a new session layer with the given store and configuration.
883    pub fn new(store: S, config: SessionConfig) -> Self {
884        Self {
885            store: Arc::new(store),
886            config: Arc::new(config),
887            signing_keys: None,
888            entropy: Arc::new(crate::entropy::OsEntropy),
889            tenancy_session_key: None,
890            rotation_hooks: None,
891        }
892    }
893
894    /// Run `hooks` after each stored session-id rotation.
895    ///
896    /// `AppBuilder` sets this from the app's shared registry.
897    #[must_use]
898    pub fn with_rotation_hooks(mut self, hooks: SessionRotationHooks) -> Self {
899        self.rotation_hooks = Some(hooks);
900        self
901    }
902
903    /// Inject the entropy source used to mint new and rotated session ids.
904    ///
905    /// Defaults to [`crate::entropy::OsEntropy`]; the framework threads the
906    /// app's seeded source here so session ids replay deterministically under a
907    /// fixed simulation seed.
908    #[must_use]
909    pub fn with_entropy(mut self, entropy: Arc<dyn crate::entropy::Entropy>) -> Self {
910        self.entropy = entropy;
911        self
912    }
913
914    /// Attach signing keys so session cookies are HMAC-signed.
915    ///
916    /// When set, the cookie value becomes `{session_id}.{hmac_hex}`. Cookies
917    /// without a valid HMAC are treated as absent (new session started).
918    /// Previous keys (see `ResolvedSigningKeys`) are tried during verification
919    /// so existing sessions remain valid across a key rotation.
920    #[must_use]
921    pub fn with_signing_keys(
922        mut self,
923        keys: Arc<crate::security::config::ResolvedSigningKeys>,
924    ) -> Self {
925        self.signing_keys = Some(keys);
926        self
927    }
928
929    /// Name of the session key `[tenancy] source = "session"` reads to
930    /// resolve the tenant (`[tenancy] session_key`, e.g. `"tenant_id"`).
931    ///
932    /// `AppBuilder` sets this automatically when `[tenancy] source ==
933    /// "session"`. An app that composes `SessionLayer`,
934    /// [`crate::tenancy::tenancy_middleware`] and
935    /// [`crate::idempotency::IdempotencyLayer`] directly as plain tower
936    /// layers, rather than through `AppBuilder`, must set it too if it also
937    /// uses session-sourced tenancy — otherwise this composition is
938    /// indistinguishable from one that doesn't use tenancy, and the deferred
939    /// idempotency alias falls back to the tenant captured before the handler
940    /// ran.
941    ///
942    /// When set, a request that mutates this key — an org switch, a
943    /// tenant-scoped login — has its deferred idempotency replay alias keyed
944    /// by the *finalized* value rather than the tenant resolved before the
945    /// handler ran. Without this, a retry presenting the rotated session
946    /// lands in the pre-mutation tenant's storage slot, misses the cached
947    /// response, and re-executes the mutation.
948    #[must_use]
949    pub fn with_tenancy_session_key(mut self, key: Option<Arc<str>>) -> Self {
950        self.tenancy_session_key = key;
951        self
952    }
953}
954
955impl<S: SessionStore + Clone, Inner> Layer<Inner> for SessionLayer<S> {
956    type Service = SessionService<S, Inner>;
957
958    fn layer(&self, inner: Inner) -> Self::Service {
959        SessionService {
960            inner,
961            store: Arc::clone(&self.store),
962            config: Arc::clone(&self.config),
963            signing_keys: self.signing_keys.clone(),
964            entropy: self.entropy.clone(),
965            tenancy_session_key: self.tenancy_session_key.clone(),
966            rotation_hooks: self.rotation_hooks.clone(),
967        }
968    }
969}
970
971/// Tower [`Service`] produced by [`SessionLayer`].
972#[derive(Clone)]
973pub struct SessionService<S: SessionStore, Inner> {
974    inner: Inner,
975    store: Arc<S>,
976    config: Arc<SessionConfig>,
977    signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
978    entropy: Arc<dyn crate::entropy::Entropy>,
979    tenancy_session_key: Option<Arc<str>>,
980    rotation_hooks: Option<SessionRotationHooks>,
981}
982
983/// `true` when the response was produced by the request-timeout layer
984/// cancelling the handler future (it stamps [`RequestDeadlineCancelled`]).
985///
986/// The session layer is applied *outer* to the timeout layer, so a cancelled
987/// handler can leave the shared `Session` handle dirty with a partial mutation.
988/// Persisting it would commit half-finished state (e.g. a login that set the
989/// user id but never completed), so the caller skips session persistence
990/// entirely when this returns `true`.
991///
992/// [`RequestDeadlineCancelled`]: crate::router::RequestDeadlineCancelled
993fn response_was_deadline_cancelled(response: &Response) -> bool {
994    response
995        .extensions()
996        .get::<crate::router::RequestDeadlineCancelled>()
997        .is_some()
998}
999
1000impl<St, Inner> Service<Request> for SessionService<St, Inner>
1001where
1002    St: SessionStore + Clone,
1003    Inner: Service<Request, Response = Response> + Clone + Send + 'static,
1004    Inner::Future: Send + 'static,
1005    Inner::Error: Send + 'static,
1006{
1007    type Response = Response;
1008    type Error = Inner::Error;
1009    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
1010
1011    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1012        self.inner.poll_ready(cx)
1013    }
1014
1015    #[allow(clippy::too_many_lines)]
1016    fn call(&mut self, mut req: Request) -> Self::Future {
1017        let store = Arc::clone(&self.store);
1018        let config = Arc::clone(&self.config);
1019        let signing_keys = self.signing_keys.clone();
1020        let entropy = self.entropy.clone();
1021        let tenancy_session_key = self.tenancy_session_key.clone();
1022        let rotation_hooks = self.rotation_hooks.clone();
1023        let mut inner = self.inner.clone();
1024        // Swap to ensure correct poll_ready semantics
1025        std::mem::swap(&mut self.inner, &mut inner);
1026
1027        Box::pin(async move {
1028            // 1. Extract or create session ID (verify HMAC if signing is active)
1029            let existing_id = session_id_from_headers(
1030                req.headers(),
1031                &config.cookie_name,
1032                signing_keys.as_deref(),
1033            );
1034
1035            let mut stale_cookie_session_id = None;
1036            let (session_id, data) = if let Some(ref id) = existing_id {
1037                match store.load(id).await {
1038                    Ok(Some(data)) => (id.clone(), data),
1039                    Ok(None) => {
1040                        stale_cookie_session_id = Some(id.clone());
1041                        (entropy.uuid_v4().to_string(), HashMap::new())
1042                    }
1043                    Err(error) => return Ok(session_store_unavailable_response(&error)),
1044                }
1045            } else {
1046                (entropy.uuid_v4().to_string(), HashMap::new())
1047            };
1048
1049            // 2. Create session handle and insert into extensions
1050            let cookie_backed = existing_id.as_ref().is_some_and(|id| id == &session_id);
1051            let session = if cookie_backed {
1052                Session::new_cookie_backed(session_id.clone(), data)
1053            } else {
1054                Session::new(session_id.clone(), data)
1055            }
1056            .with_entropy(entropy.clone());
1057            let current_session_scope = cookie_backed.then(|| session_id.clone());
1058            req.extensions_mut()
1059                .insert(crate::idempotency::IdempotencySessionScope::new(
1060                    current_session_scope,
1061                    stale_cookie_session_id,
1062                ));
1063            req.extensions_mut().insert(session.clone());
1064
1065            // 3. Call inner service
1066            let mut response = inner.call(req).await?;
1067
1068            // 4. Save or destroy session — but skip persistence when the deadline
1069            // cancelled the handler, so a partial mutation isn't committed.
1070            if response_was_deadline_cancelled(&response) {
1071                return Ok(response);
1072            }
1073
1074            let inner_guard = session.inner.read().await;
1075            if inner_guard.destroyed {
1076                if let Err(error) = store.destroy(&session_id).await {
1077                    crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1078                    return Ok(session_store_unavailable_response(&error));
1079                }
1080                if let Ok(val) = HeaderValue::from_str(&build_expire_cookie(&config)) {
1081                    response.headers_mut().append(SET_COOKIE, val);
1082                }
1083                // No lock here: a destroyed session saves nothing.
1084                crate::idempotency::add_deferred_session_replay_key(
1085                    &response,
1086                    None,
1087                    inner_guard.cookie_backed,
1088                    None,
1089                );
1090            } else if inner_guard.dirty {
1091                let data = inner_guard.data.clone();
1092                let sid = inner_guard.id.clone();
1093                let primary_replay_after_guard_denial =
1094                    inner_guard.cookie_backed && inner_guard.old_id.is_some();
1095                // A session with no stored row has nothing keyed to its old id.
1096                let rotation = inner_guard
1097                    .old_id
1098                    .clone()
1099                    .filter(|_| inner_guard.cookie_backed)
1100                    .map(|old_id| SessionRotation {
1101                        old_id,
1102                        new_id: sid.clone(),
1103                        earlier_ids: inner_guard.prior_ids.clone(),
1104                    });
1105                // The tenant `[tenancy] source = "session"` will resolve for a
1106                // retry presenting this finalized session — read live from the
1107                // data just written rather than reusing the tenant captured
1108                // before the handler ran, which a handler like an org switch
1109                // may have just changed.
1110                let finalized_tenant = tenancy_session_key
1111                    .as_deref()
1112                    .and_then(|key| data.get(key).cloned());
1113                // Lock the alias before the session is saved. A request with the
1114                // saved session then finds its key in flight. If a later step
1115                // fails, the lock stays until its TTL ends.
1116                if let Some(reserve) = crate::idempotency::reserve_deferred_session_replay_key(
1117                    &response,
1118                    Some(&sid),
1119                    primary_replay_after_guard_denial,
1120                    finalized_tenant.as_deref(),
1121                ) && let Err(error) = reserve.await
1122                {
1123                    drop(inner_guard);
1124                    tracing::error!("session alias idempotency lock failed: {error}");
1125                    crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1126                    return Ok(crate::idempotency::persistence_failed_response());
1127                }
1128                if let Some(ref old_id) = inner_guard.old_id
1129                    && let Err(error) = store.destroy(old_id).await
1130                {
1131                    drop(inner_guard);
1132                    crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1133                    return Ok(session_store_unavailable_response(&error));
1134                }
1135                drop(inner_guard);
1136                if let Err(error) = store.save(&sid, data).await {
1137                    crate::idempotency::keep_deferred_session_commit_locked(&mut response);
1138                    return Ok(session_store_unavailable_response(&error));
1139                }
1140                if let (Some(hooks), Some(rotation)) = (&rotation_hooks, &rotation) {
1141                    hooks.run(rotation).await;
1142                }
1143                // Sign the session ID when signing keys are active
1144                let cookie_value = signing_keys.as_ref().map_or_else(
1145                    || sid.clone(),
1146                    |keys| format!("{sid}.{}", keys.sign(sid.as_bytes())),
1147                );
1148                if let Ok(val) = HeaderValue::from_str(&build_set_cookie(&config, &cookie_value)) {
1149                    response.headers_mut().append(SET_COOKIE, val);
1150                }
1151            }
1152
1153            if let Some(finalize) =
1154                crate::idempotency::finalize_deferred_session_commit(&mut response)
1155                && finalize.await.is_err()
1156            {
1157                return Ok(crate::idempotency::persistence_failed_response());
1158            }
1159
1160            Ok(response)
1161        })
1162    }
1163}
1164
1165fn session_store_unavailable_response(error: &SessionStoreError) -> Response {
1166    tracing::error!("session store unavailable: {error}");
1167    (StatusCode::SERVICE_UNAVAILABLE, "Session store unavailable").into_response()
1168}
1169
1170/// Build the configured session layer, **monomorphized to a single type** by
1171/// erasing every backend behind [`ArcSessionStore`].
1172///
1173/// # Why the store type is erased here
1174///
1175/// Each backend produces a differently-typed `SessionLayer<Store>`, so before
1176/// this the session had to be applied through its own `Router::layer` call —
1177/// nothing else in a `tower-layer` tuple can have a type that varies at
1178/// runtime. Returning one concrete `SessionLayer<ArcSessionStore>` lets the
1179/// caller fold the session into `apply_middleware`'s single merged
1180/// `Router::layer((..))` application (issues #2193, #2198).
1181///
1182/// # What that costs, and why it is worth it
1183///
1184/// The `Arc<dyn BoxedSessionStore>` bridge adds one `Box::pin` per store
1185/// operation — 1-2 per request (a `load`, plus a `save` or `destroy` only when
1186/// the session is dirty). In exchange it removes one whole `Router::layer`
1187/// nesting level, and a nesting level is not a one-off cost: `Route::call`
1188/// deep-clones everything below it on *every* request, so each level is
1189/// re-cloned by every level above it. One boxed future per store call is
1190/// strictly cheaper than that.
1191///
1192/// The custom-store arm already paid this cost (a user store arrives as
1193/// `Arc<dyn BoxedSessionStore>` and has always been wrapped); the memory and
1194/// Redis arms now wrap their concrete store the same way.
1195///
1196/// # Errors
1197///
1198/// Returns [`SessionBackendConfigError`] when the configured backend cannot be
1199/// built: a Redis backend requested without the `redis` feature compiled in, an
1200/// unparseable Redis URL, or the production in-memory guard rejecting the
1201/// configuration.
1202pub(crate) fn build_session_layer(
1203    config: &SessionConfig,
1204    profile: Option<&str>,
1205    custom_store: Option<Arc<dyn BoxedSessionStore>>,
1206    signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
1207    entropy: &Arc<dyn crate::entropy::Entropy>,
1208    tenancy_session_key: Option<Arc<str>>,
1209) -> Result<SessionLayer<ArcSessionStore>, SessionBackendConfigError> {
1210    // Shared tail of all three arms: entropy is always injected (determinism
1211    // seam, #1797), signing keys only when the app configured a secret.
1212    fn finish(
1213        store: ArcSessionStore,
1214        config: &SessionConfig,
1215        signing_keys: Option<Arc<crate::security::config::ResolvedSigningKeys>>,
1216        entropy: &Arc<dyn crate::entropy::Entropy>,
1217        tenancy_session_key: Option<Arc<str>>,
1218    ) -> SessionLayer<ArcSessionStore> {
1219        let mut layer = SessionLayer::new(store, config.clone())
1220            .with_entropy(Arc::clone(entropy))
1221            .with_tenancy_session_key(tenancy_session_key);
1222        if let Some(keys) = signing_keys {
1223            layer = layer.with_signing_keys(keys);
1224        }
1225        layer
1226    }
1227
1228    if let Some(store) = custom_store {
1229        tracing::debug!(
1230            "Custom session store installed via with_session_store(); skipping config-driven backend selection"
1231        );
1232        // Already an `Arc<dyn BoxedSessionStore>` — no second Arc.
1233        return Ok(finish(
1234            ArcSessionStore(store),
1235            config,
1236            signing_keys,
1237            entropy,
1238            tenancy_session_key,
1239        ));
1240    }
1241
1242    match config.backend_plan(profile)? {
1243        SessionBackendPlan::Memory { warn_in_production } => {
1244            if warn_in_production {
1245                tracing::warn!(
1246                    "prod profile is using in-memory sessions; set session.backend=redis or \
1247                     session.allow_memory_in_production=true to acknowledge the risk"
1248                );
1249            }
1250            Ok(finish(
1251                ArcSessionStore(Arc::new(MemoryStore::new())),
1252                config,
1253                signing_keys,
1254                entropy,
1255                tenancy_session_key,
1256            ))
1257        }
1258        SessionBackendPlan::Redis { .. } => {
1259            #[cfg(feature = "redis")]
1260            {
1261                let store = crate::session_redis::RedisStore::from_config(config)?;
1262                Ok(finish(
1263                    ArcSessionStore(Arc::new(store)),
1264                    config,
1265                    signing_keys,
1266                    entropy,
1267                    tenancy_session_key,
1268                ))
1269            }
1270
1271            #[cfg(not(feature = "redis"))]
1272            {
1273                Err(SessionBackendConfigError::RedisFeatureDisabled)
1274            }
1275        }
1276    }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282    use axum::Router;
1283    use axum::body::Body;
1284    use axum::routing::get;
1285    use http::Request as HttpRequest;
1286    use tower::ServiceExt;
1287
1288    #[tokio::test]
1289    async fn touch_marks_the_session_dirty_without_reading_or_writing_data() {
1290        let session = Session::new_for_test_without_cookie("fresh-id".to_owned(), HashMap::new());
1291        assert!(
1292            !session.has_pending_changes().await,
1293            "sanity: not dirty yet"
1294        );
1295
1296        session.touch().await;
1297
1298        assert!(
1299            session.has_pending_changes().await,
1300            "touch() must dirty the session so SessionLayer persists it and sets the cookie"
1301        );
1302        assert_eq!(
1303            session.id().await,
1304            "fresh-id",
1305            "touch() must not change the id"
1306        );
1307    }
1308
1309    /// W3 (issue #1797): a rotated session id is minted from the injected
1310    /// entropy source, so a fixed seed reproduces the exact rotated id and a
1311    /// different seed diverges.
1312    #[tokio::test]
1313    async fn rotate_id_is_deterministic_under_seeded_entropy() {
1314        async fn rotated(seed: u64) -> String {
1315            let session = Session::new_for_test_without_cookie("orig".to_owned(), HashMap::new())
1316                .with_entropy(crate::entropy::SeededEntropy::shared(seed));
1317            session.rotate_id().await;
1318            session.id().await
1319        }
1320
1321        let first = rotated(0x5eed).await;
1322        assert_ne!(first, "orig", "rotation changes the id");
1323        assert!(Uuid::parse_str(&first).is_ok(), "rotated id is a v4 UUID");
1324        assert_eq!(
1325            first,
1326            rotated(0x5eed).await,
1327            "same seed ⇒ identical rotated session id"
1328        );
1329        assert_ne!(
1330            rotated(1).await,
1331            rotated(2).await,
1332            "a different seed ⇒ different rotated id"
1333        );
1334    }
1335
1336    /// Sentinel store for verifying that the type-erased `BoxedSessionStore`
1337    /// bridge actually delegates back to the user's `SessionStore` impl
1338    /// instead of silently picking up the default memory store.
1339    #[derive(Clone, Default)]
1340    struct SentinelStore {
1341        load_calls: Arc<RwLock<u32>>,
1342    }
1343
1344    impl SessionStore for SentinelStore {
1345        async fn load(
1346            &self,
1347            _id: &str,
1348        ) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
1349            *self.load_calls.write().await += 1;
1350            // Return a recognisable session payload so the test can prove the
1351            // wrapper actually went through this impl.
1352            let mut data = HashMap::new();
1353            data.insert("from".to_owned(), "sentinel".to_owned());
1354            Ok(Some(data))
1355        }
1356
1357        async fn save(
1358            &self,
1359            _id: &str,
1360            _data: HashMap<String, String>,
1361        ) -> Result<(), SessionStoreError> {
1362            Ok(())
1363        }
1364
1365        async fn destroy(&self, _id: &str) -> Result<(), SessionStoreError> {
1366            Ok(())
1367        }
1368    }
1369
1370    #[tokio::test]
1371    async fn arc_session_store_wrapper_delegates_to_inner_session_store() {
1372        let inner = SentinelStore::default();
1373        let load_counter = inner.load_calls.clone();
1374        let arc: Arc<dyn BoxedSessionStore> = Arc::new(inner);
1375        let wrapper = ArcSessionStore(arc);
1376
1377        let result = wrapper
1378            .load("session-id")
1379            .await
1380            .expect("wrapped store should succeed");
1381
1382        assert_eq!(*load_counter.read().await, 1);
1383        assert_eq!(
1384            result
1385                .as_ref()
1386                .and_then(|m| m.get("from"))
1387                .map(String::as_str),
1388            Some("sentinel"),
1389            "wrapper must return data from the wrapped impl, not a default"
1390        );
1391    }
1392
1393    #[tokio::test]
1394    async fn boxed_session_store_blanket_impl_works_for_any_session_store() {
1395        // Any SessionStore type erases via the BoxedSessionStore blanket impl.
1396        let store = SentinelStore::default();
1397        let boxed: Arc<dyn BoxedSessionStore> = Arc::new(store);
1398        let result = boxed.boxed_load("session-id").await.unwrap();
1399        assert!(result.is_some());
1400    }
1401
1402    #[derive(Clone)]
1403    struct FailingStore {
1404        fail_on_load: bool,
1405        fail_on_save: bool,
1406        fail_on_destroy: bool,
1407    }
1408
1409    impl SessionStore for FailingStore {
1410        async fn load(
1411            &self,
1412            _id: &str,
1413        ) -> Result<Option<HashMap<String, String>>, SessionStoreError> {
1414            if self.fail_on_load {
1415                Err(SessionStoreError::backend("load", "boom"))
1416            } else {
1417                Ok(None)
1418            }
1419        }
1420
1421        async fn save(
1422            &self,
1423            _id: &str,
1424            _data: HashMap<String, String>,
1425        ) -> Result<(), SessionStoreError> {
1426            if self.fail_on_save {
1427                Err(SessionStoreError::backend("save", "boom"))
1428            } else {
1429                Ok(())
1430            }
1431        }
1432
1433        async fn destroy(&self, _id: &str) -> Result<(), SessionStoreError> {
1434            if self.fail_on_destroy {
1435                Err(SessionStoreError::backend("destroy", "boom"))
1436            } else {
1437                Ok(())
1438            }
1439        }
1440    }
1441
1442    #[tokio::test]
1443    async fn memory_store_save_and_load() {
1444        let store = MemoryStore::new();
1445        let mut data = HashMap::new();
1446        data.insert("user".into(), "alice".into());
1447        store.save("sess1", data).await.unwrap();
1448
1449        let loaded = store.load("sess1").await.unwrap();
1450        assert!(loaded.is_some());
1451        assert_eq!(loaded.unwrap().get("user").unwrap(), "alice");
1452    }
1453
1454    #[tokio::test]
1455    async fn memory_store_destroy() {
1456        let store = MemoryStore::new();
1457        store.save("sess1", HashMap::new()).await.unwrap();
1458        store.destroy("sess1").await.unwrap();
1459        assert!(store.load("sess1").await.unwrap().is_none());
1460    }
1461
1462    #[tokio::test]
1463    async fn memory_store_load_missing() {
1464        let store = MemoryStore::new();
1465        assert!(store.load("nonexistent").await.unwrap().is_none());
1466    }
1467
1468    #[tokio::test]
1469    async fn session_insert_and_get() {
1470        let session = Session::new("test".into(), HashMap::new());
1471        session.insert("key", "value").await;
1472        assert_eq!(session.get("key").await, Some("value".to_owned()));
1473    }
1474
1475    #[tokio::test]
1476    async fn session_remove() {
1477        let mut data = HashMap::new();
1478        data.insert("key".into(), "value".into());
1479        let session = Session::new("test".into(), data);
1480        let removed = session.remove("key").await;
1481        assert_eq!(removed, Some("value".to_owned()));
1482        assert!(session.get("key").await.is_none());
1483    }
1484
1485    #[tokio::test]
1486    async fn session_clear() {
1487        let mut data = HashMap::new();
1488        data.insert("a".into(), "1".into());
1489        data.insert("b".into(), "2".into());
1490        let session = Session::new("test".into(), data);
1491        session.clear().await;
1492        assert!(session.get("a").await.is_none());
1493        assert!(session.get("b").await.is_none());
1494    }
1495
1496    #[tokio::test]
1497    async fn session_contains_key() {
1498        let mut data = HashMap::new();
1499        data.insert("exists".into(), "yes".into());
1500        let session = Session::new("test".into(), data);
1501        assert!(session.contains_key("exists").await);
1502        assert!(!session.contains_key("missing").await);
1503    }
1504
1505    #[tokio::test]
1506    async fn session_destroy_marks_destroyed() {
1507        let session = Session::new("test".into(), HashMap::new());
1508        session.insert("key", "value").await;
1509        session.destroy().await;
1510        let inner = session.inner.read().await;
1511        let destroyed = inner.destroyed;
1512        let empty = inner.data.is_empty();
1513        drop(inner);
1514        assert!(destroyed);
1515        assert!(empty);
1516    }
1517
1518    #[test]
1519    fn get_cookie_extracts_value() {
1520        let mut headers = http::HeaderMap::new();
1521        headers.insert(
1522            COOKIE,
1523            HeaderValue::from_static("autumn.sid=abc123; other=xyz"),
1524        );
1525        assert_eq!(get_cookie(&headers, "autumn.sid"), Some("abc123".into()));
1526        assert_eq!(get_cookie(&headers, "other"), Some("xyz".into()));
1527        assert_eq!(get_cookie(&headers, "missing"), None);
1528    }
1529
1530    #[test]
1531    fn get_cookie_rejects_multiple_cookies() {
1532        let mut headers = http::HeaderMap::new();
1533        headers.insert(
1534            COOKIE,
1535            HeaderValue::from_static("autumn.sid=abc123; autumn.sid=xyz456"),
1536        );
1537        assert_eq!(get_cookie(&headers, "autumn.sid"), None);
1538
1539        let mut headers2 = http::HeaderMap::new();
1540        headers2.append(COOKIE, HeaderValue::from_static("autumn.sid=abc123"));
1541        headers2.append(COOKIE, HeaderValue::from_static("autumn.sid=xyz456"));
1542        assert_eq!(get_cookie(&headers2, "autumn.sid"), None);
1543    }
1544
1545    #[test]
1546    fn build_set_cookie_contains_required_parts() {
1547        let config = SessionConfig::default();
1548        let cookie = build_set_cookie(&config, "test-id");
1549        assert!(cookie.contains("autumn.sid=test-id"));
1550        assert!(cookie.contains("Path=/"));
1551        assert!(cookie.contains("HttpOnly"));
1552        assert!(cookie.contains("SameSite=Lax"));
1553        assert!(cookie.contains("Max-Age=86400"));
1554    }
1555
1556    #[test]
1557    fn build_expire_cookie_has_zero_max_age() {
1558        let config = SessionConfig::default();
1559        let cookie = build_expire_cookie(&config);
1560        assert!(cookie.contains("Max-Age=0"));
1561    }
1562
1563    #[test]
1564    fn session_config_defaults() {
1565        let config = SessionConfig::default();
1566        assert_eq!(config.backend, SessionBackend::Memory);
1567        assert_eq!(config.cookie_name, "autumn.sid");
1568        assert_eq!(config.max_age_secs, 86400);
1569        assert!(config.secure);
1570        assert_eq!(config.same_site, "Lax");
1571        assert!(config.http_only);
1572        assert_eq!(config.path, "/");
1573        assert!(!config.allow_memory_in_production);
1574        assert!(config.redis.url.is_none());
1575        assert_eq!(config.redis.key_prefix, "autumn:sessions");
1576    }
1577
1578    #[test]
1579    fn session_backend_plan_warns_for_prod_memory_without_ack() {
1580        let config = SessionConfig::default();
1581        let plan = config.backend_plan(Some("prod")).unwrap();
1582        assert_eq!(
1583            plan,
1584            SessionBackendPlan::Memory {
1585                warn_in_production: true
1586            }
1587        );
1588    }
1589
1590    #[test]
1591    fn session_backend_plan_suppresses_prod_warning_when_acknowledged() {
1592        let config = SessionConfig {
1593            allow_memory_in_production: true,
1594            ..SessionConfig::default()
1595        };
1596        let plan = config.backend_plan(Some("prod")).unwrap();
1597        assert_eq!(
1598            plan,
1599            SessionBackendPlan::Memory {
1600                warn_in_production: false
1601            }
1602        );
1603    }
1604
1605    #[test]
1606    fn session_backend_plan_requires_redis_url() {
1607        let config = SessionConfig {
1608            backend: SessionBackend::Redis,
1609            ..SessionConfig::default()
1610        };
1611        let error = config.backend_plan(None).unwrap_err();
1612        assert_eq!(error, SessionBackendConfigError::MissingRedisUrl);
1613    }
1614
1615    #[tokio::test]
1616    async fn session_layer_sets_cookie_on_new_session() {
1617        use crate::state::AppState;
1618        async fn handler(session: Session) -> String {
1619            session.insert("visited", "true").await;
1620            "ok".to_owned()
1621        }
1622
1623        let state = AppState::test_default();
1624
1625        let app = Router::new()
1626            .route("/", get(handler))
1627            .layer(SessionLayer::new(
1628                MemoryStore::new(),
1629                SessionConfig::default(),
1630            ))
1631            .with_state(state);
1632
1633        let response = app
1634            .oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
1635            .await
1636            .unwrap();
1637
1638        assert_eq!(response.status(), http::StatusCode::OK);
1639        let set_cookie = response
1640            .headers()
1641            .get(SET_COOKIE)
1642            .expect("should set session cookie");
1643        let cookie_str = set_cookie.to_str().unwrap();
1644        assert!(cookie_str.contains("autumn.sid="));
1645    }
1646
1647    fn test_state() -> crate::state::AppState {
1648        crate::state::AppState::test_default()
1649    }
1650
1651    #[tokio::test]
1652    async fn session_layer_persists_data_across_requests() {
1653        async fn write_handler(session: Session) -> String {
1654            session.insert("user", "alice").await;
1655            "saved".to_owned()
1656        }
1657
1658        async fn read_handler(session: Session) -> String {
1659            session.get("user").await.unwrap_or_default()
1660        }
1661
1662        let store = MemoryStore::new();
1663        let config = SessionConfig::default();
1664        let state = test_state();
1665
1666        let app = Router::new()
1667            .route("/write", get(write_handler))
1668            .route("/read", get(read_handler))
1669            .layer(SessionLayer::new(store, config))
1670            .with_state(state);
1671
1672        // First request: write to session
1673        let resp1 = app
1674            .clone()
1675            .oneshot(
1676                HttpRequest::builder()
1677                    .uri("/write")
1678                    .body(Body::empty())
1679                    .unwrap(),
1680            )
1681            .await
1682            .unwrap();
1683
1684        let cookie = resp1
1685            .headers()
1686            .get(SET_COOKIE)
1687            .unwrap()
1688            .to_str()
1689            .unwrap()
1690            .to_owned();
1691        // Extract just the cookie value for the next request
1692        let session_cookie = cookie.split(';').next().unwrap();
1693
1694        // Second request: read from session
1695        let resp2 = app
1696            .oneshot(
1697                HttpRequest::builder()
1698                    .uri("/read")
1699                    .header(COOKIE, session_cookie)
1700                    .body(Body::empty())
1701                    .unwrap(),
1702            )
1703            .await
1704            .unwrap();
1705
1706        let body = axum::body::to_bytes(resp2.into_body(), usize::MAX)
1707            .await
1708            .unwrap();
1709        assert_eq!(std::str::from_utf8(&body).unwrap(), "alice");
1710    }
1711
1712    #[tokio::test]
1713    async fn session_destroy_expires_cookie() {
1714        async fn handler(session: Session) -> String {
1715            session.destroy().await;
1716            "destroyed".to_owned()
1717        }
1718
1719        let state = test_state();
1720
1721        let store = MemoryStore::new();
1722        store
1723            .save("existing-id", HashMap::from([("k".into(), "v".into())]))
1724            .await
1725            .unwrap();
1726
1727        let app = Router::new()
1728            .route("/", get(handler))
1729            .layer(SessionLayer::new(store.clone(), SessionConfig::default()))
1730            .with_state(state);
1731
1732        let response = app
1733            .oneshot(
1734                HttpRequest::builder()
1735                    .uri("/")
1736                    .header(COOKIE, "autumn.sid=existing-id")
1737                    .body(Body::empty())
1738                    .unwrap(),
1739            )
1740            .await
1741            .unwrap();
1742
1743        let cookie = response
1744            .headers()
1745            .get(SET_COOKIE)
1746            .unwrap()
1747            .to_str()
1748            .unwrap();
1749        assert!(cookie.contains("Max-Age=0"), "cookie should be expired");
1750
1751        // Store should no longer have the session
1752        assert!(store.load("existing-id").await.unwrap().is_none());
1753    }
1754
1755    #[tokio::test]
1756    async fn session_layer_returns_503_when_store_load_fails() {
1757        let state = test_state();
1758
1759        let app = Router::new()
1760            .route("/", get(|| async { "ok" }))
1761            .layer(SessionLayer::new(
1762                FailingStore {
1763                    fail_on_load: true,
1764                    fail_on_save: false,
1765                    fail_on_destroy: false,
1766                },
1767                SessionConfig::default(),
1768            ))
1769            .with_state(state);
1770
1771        let response = app
1772            .oneshot(
1773                HttpRequest::builder()
1774                    .uri("/")
1775                    .header(COOKIE, "autumn.sid=existing-id")
1776                    .body(Body::empty())
1777                    .unwrap(),
1778            )
1779            .await
1780            .unwrap();
1781
1782        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1783    }
1784
1785    #[tokio::test]
1786    async fn session_layer_returns_503_when_store_save_fails() {
1787        let state = test_state();
1788
1789        let app = Router::new()
1790            .route(
1791                "/",
1792                get(|session: Session| async move {
1793                    session.insert("user", "alice").await;
1794                    "ok"
1795                }),
1796            )
1797            .layer(SessionLayer::new(
1798                FailingStore {
1799                    fail_on_load: false,
1800                    fail_on_save: true,
1801                    fail_on_destroy: false,
1802                },
1803                SessionConfig::default(),
1804            ))
1805            .with_state(state);
1806
1807        let response = app
1808            .oneshot(HttpRequest::builder().uri("/").body(Body::empty()).unwrap())
1809            .await
1810            .unwrap();
1811
1812        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1813    }
1814
1815    #[tokio::test]
1816    async fn deadline_cancelled_response_skips_partial_session_save() {
1817        let state = test_state();
1818        let store = MemoryStore::new();
1819        // Seed an existing cookie-backed session with no application data.
1820        store
1821            .save("existing-id", HashMap::new())
1822            .await
1823            .expect("seed save");
1824
1825        // A handler that mutates the session and then exceeds an inner deadline,
1826        // standing in for the real request-timeout layer (which is applied inner
1827        // to the session layer and stamps `RequestDeadlineCancelled` on its 503).
1828        let timeout_layer = axum::middleware::from_fn(
1829            |req: HttpRequest<Body>, next: axum::middleware::Next| async move {
1830                tokio::time::timeout(std::time::Duration::from_millis(50), next.run(req))
1831                    .await
1832                    .unwrap_or_else(|_| {
1833                        let mut resp = StatusCode::SERVICE_UNAVAILABLE.into_response();
1834                        resp.extensions_mut()
1835                            .insert(crate::router::RequestDeadlineCancelled);
1836                        resp
1837                    })
1838            },
1839        );
1840
1841        let app = Router::new()
1842            .route(
1843                "/",
1844                get(|session: Session| async move {
1845                    session.insert("user", "alice").await;
1846                    // Far exceeds the 50ms inner deadline; the handler future is
1847                    // cancelled before it returns.
1848                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1849                    "ok"
1850                }),
1851            )
1852            .layer(timeout_layer)
1853            .layer(SessionLayer::new(store.clone(), SessionConfig::default()))
1854            .with_state(state);
1855
1856        let response = app
1857            .oneshot(
1858                HttpRequest::builder()
1859                    .uri("/")
1860                    .header(COOKIE, "autumn.sid=existing-id")
1861                    .body(Body::empty())
1862                    .unwrap(),
1863            )
1864            .await
1865            .unwrap();
1866
1867        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1868        // The partial mutation must NOT have been persisted: the stored session
1869        // is still the seeded empty map, with no `user` key.
1870        let saved = store
1871            .load("existing-id")
1872            .await
1873            .expect("load")
1874            .expect("session still present");
1875        assert!(
1876            !saved.contains_key("user"),
1877            "a deadline-cancelled request must not persist partial session changes"
1878        );
1879        // The session layer must also not emit a Set-Cookie for the skipped save.
1880        assert!(
1881            response.headers().get(SET_COOKIE).is_none(),
1882            "no Set-Cookie should be written when the partial save is skipped"
1883        );
1884    }
1885
1886    #[tokio::test]
1887    async fn a_handler_returning_a_deadline_stop_skips_partial_session_save() {
1888        let state = test_state();
1889        let store = MemoryStore::new();
1890        store
1891            .save("existing-id", HashMap::new())
1892            .await
1893            .expect("seed save");
1894
1895        // The handler changes the session, then returns the error of a call
1896        // the request deadline stopped (issue #3058).
1897        let app = Router::new()
1898            .route(
1899                "/",
1900                get(|session: Session| async move {
1901                    session.insert("user", "alice").await;
1902                    Err::<&str, crate::AutumnError>(crate::deadline::DeadlineExceeded.into())
1903                }),
1904            )
1905            .layer(SessionLayer::new(store.clone(), SessionConfig::default()))
1906            .with_state(state);
1907
1908        let response = app
1909            .oneshot(
1910                HttpRequest::builder()
1911                    .uri("/")
1912                    .header(COOKIE, "autumn.sid=existing-id")
1913                    .body(Body::empty())
1914                    .unwrap(),
1915            )
1916            .await
1917            .unwrap();
1918
1919        assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
1920        let saved = store
1921            .load("existing-id")
1922            .await
1923            .expect("load")
1924            .expect("session still present");
1925        assert!(!saved.contains_key("user"), "no partial session save");
1926        assert!(response.headers().get(SET_COOKIE).is_none());
1927    }
1928
1929    // ── Signed session cookies (RED phase) ─────────────────────────────────
1930
1931    #[tokio::test]
1932    async fn session_cookie_is_signed_when_signing_keys_set() {
1933        use crate::security::config::{SigningSecretConfig, resolve_signing_keys};
1934        use std::sync::Arc;
1935
1936        let config = SigningSecretConfig {
1937            secret: Some("k".repeat(32)),
1938            previous_secrets: vec![],
1939        };
1940        let keys = Arc::new(resolve_signing_keys(&config));
1941
1942        let app = Router::new()
1943            .route(
1944                "/",
1945                get(|s: Session| async move {
1946                    s.insert("k", "v").await;
1947                    "ok"
1948                }),
1949            )
1950            .layer(
1951                SessionLayer::new(MemoryStore::new(), SessionConfig::default())
1952                    .with_signing_keys(keys),
1953            );
1954
1955        let req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
1956        let resp = app.oneshot(req).await.unwrap();
1957
1958        let set_cookie = resp
1959            .headers()
1960            .get("set-cookie")
1961            .expect("should set cookie")
1962            .to_str()
1963            .unwrap();
1964        let cookie_value = set_cookie
1965            .split('=')
1966            .nth(1)
1967            .unwrap()
1968            .split(';')
1969            .next()
1970            .unwrap()
1971            .trim();
1972
1973        assert!(
1974            cookie_value.contains('.'),
1975            "signed session cookie must be {{id}}.{{hmac}}, got: {cookie_value}"
1976        );
1977        let (id_part, sig_part) = cookie_value.split_once('.').unwrap();
1978        assert!(!id_part.is_empty());
1979        assert_eq!(sig_part.len(), 64, "HMAC-SHA256 hex must be 64 chars");
1980    }
1981
1982    #[tokio::test]
1983    async fn session_layer_rejects_tampered_cookie() {
1984        use crate::security::config::{SigningSecretConfig, resolve_signing_keys};
1985        use std::sync::Arc;
1986
1987        let keys = Arc::new(resolve_signing_keys(&SigningSecretConfig {
1988            secret: Some("k".repeat(32)),
1989            previous_secrets: vec![],
1990        }));
1991
1992        let store = MemoryStore::new();
1993        let session_id = Uuid::new_v4().to_string();
1994        let mut data = HashMap::new();
1995        data.insert("user".to_owned(), "alice".to_owned());
1996        store.save(&session_id, data).await.unwrap();
1997
1998        let app = Router::new()
1999            .route(
2000                "/",
2001                get(|s: Session| async move {
2002                    s.get("user").await.unwrap_or_else(|| "none".to_owned())
2003                }),
2004            )
2005            .layer(SessionLayer::new(store, SessionConfig::default()).with_signing_keys(keys));
2006
2007        // Valid UUID but bad 64-char hex HMAC
2008        let bad_sig = "0".repeat(64);
2009        let bad_cookie = format!("autumn.sid={session_id}.{bad_sig}");
2010        let req = HttpRequest::builder()
2011            .uri("/")
2012            .header("cookie", bad_cookie)
2013            .body(Body::empty())
2014            .unwrap();
2015        let resp = app.oneshot(req).await.unwrap();
2016        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
2017        assert_eq!(&body[..], b"none", "tampered cookie must not load session");
2018    }
2019
2020    #[tokio::test]
2021    async fn session_layer_accepts_previous_key_signed_cookie() {
2022        use crate::security::config::{
2023            ResolvedSigningKeys, SigningSecretConfig, resolve_signing_keys,
2024        };
2025        use std::sync::Arc;
2026
2027        let old_secret = "old-key".repeat(5); // 35 bytes
2028        let old_keys = resolve_signing_keys(&SigningSecretConfig {
2029            secret: Some(old_secret.clone()),
2030            previous_secrets: vec![],
2031        });
2032
2033        let session_id = Uuid::new_v4().to_string();
2034        let old_sig = old_keys.sign(session_id.as_bytes());
2035        let signed_value = format!("{session_id}.{old_sig}");
2036
2037        let new_keys = Arc::new(ResolvedSigningKeys::new(
2038            "new-key".repeat(5).into_bytes(),
2039            vec![old_secret.into_bytes()],
2040        ));
2041
2042        let store = MemoryStore::new();
2043        let mut data = HashMap::new();
2044        data.insert("user".to_owned(), "bob".to_owned());
2045        store.save(&session_id, data).await.unwrap();
2046
2047        let app = Router::new()
2048            .route(
2049                "/",
2050                get(|s: Session| async move {
2051                    s.get("user").await.unwrap_or_else(|| "none".to_owned())
2052                }),
2053            )
2054            .layer(SessionLayer::new(store, SessionConfig::default()).with_signing_keys(new_keys));
2055
2056        let req = HttpRequest::builder()
2057            .uri("/")
2058            .header("cookie", format!("autumn.sid={signed_value}"))
2059            .body(Body::empty())
2060            .unwrap();
2061        let resp = app.oneshot(req).await.unwrap();
2062        let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
2063        assert_eq!(
2064            &body[..],
2065            b"bob",
2066            "previous-key-signed cookie must load session"
2067        );
2068    }
2069
2070    // ── Rotation hooks (issue #2347) ──
2071
2072    type RotationLog = Arc<std::sync::Mutex<Vec<(String, String)>>>;
2073
2074    fn rotation_log() -> (SessionRotationHooks, RotationLog) {
2075        let hooks = SessionRotationHooks::default();
2076        let log = Arc::new(std::sync::Mutex::new(Vec::new()));
2077        let sink = Arc::clone(&log);
2078        hooks.register(move |rotation| {
2079            let sink = Arc::clone(&sink);
2080            async move {
2081                sink.lock()
2082                    .unwrap()
2083                    .push((rotation.old_id, rotation.new_id));
2084                Ok(())
2085            }
2086        });
2087        (hooks, log)
2088    }
2089
2090    async fn rotation_app(hooks: SessionRotationHooks, store: MemoryStore) -> (Router, String) {
2091        async fn seed(session: Session) -> String {
2092            session.insert("k", "v").await;
2093            session.id().await
2094        }
2095        async fn rotate(session: Session) -> String {
2096            session.rotate_id().await;
2097            session.rotate_id().await;
2098            session.id().await
2099        }
2100        async fn destroy(session: Session) -> &'static str {
2101            session.rotate_id().await;
2102            session.destroy().await;
2103            "gone"
2104        }
2105        let app = Router::new()
2106            .route("/seed", get(seed))
2107            .route("/rotate", get(rotate))
2108            .route("/destroy", get(destroy))
2109            .route("/noop", get(|| async { "ok" }))
2110            .layer(SessionLayer::new(store, SessionConfig::default()).with_rotation_hooks(hooks))
2111            .with_state(test_state());
2112        let response = app
2113            .clone()
2114            .oneshot(
2115                HttpRequest::builder()
2116                    .uri("/seed")
2117                    .body(Body::empty())
2118                    .unwrap(),
2119            )
2120            .await
2121            .unwrap();
2122        let cookie = response
2123            .headers()
2124            .get(SET_COOKIE)
2125            .unwrap()
2126            .to_str()
2127            .unwrap();
2128        (app, cookie.split(';').next().unwrap().to_owned())
2129    }
2130
2131    async fn hit(app: &Router, uri: &str, cookie: &str) -> Response {
2132        app.clone()
2133            .oneshot(
2134                HttpRequest::builder()
2135                    .uri(uri)
2136                    .header(COOKIE, cookie)
2137                    .body(Body::empty())
2138                    .unwrap(),
2139            )
2140            .await
2141            .unwrap()
2142    }
2143
2144    #[tokio::test]
2145    async fn rotation_hook_gets_the_net_old_and_new_id_once() {
2146        let (hooks, log) = rotation_log();
2147        let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2148        let old_id = cookie.trim_start_matches("autumn.sid=").to_owned();
2149
2150        let response = hit(&app, "/rotate", &cookie).await;
2151        let body = axum::body::to_bytes(response.into_body(), 1024)
2152            .await
2153            .unwrap();
2154        let new_id = String::from_utf8(body.to_vec()).unwrap();
2155
2156        let seen = log.lock().unwrap().clone();
2157        assert_eq!(seen, vec![(old_id, new_id)], "two rotations, one net hop");
2158    }
2159
2160    #[tokio::test]
2161    async fn rotation_event_lists_every_earlier_id() {
2162        let hooks = SessionRotationHooks::default();
2163        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2164        let sink = Arc::clone(&seen);
2165        hooks.register(move |rotation| {
2166            let sink = Arc::clone(&sink);
2167            async move {
2168                sink.lock().unwrap().push(rotation);
2169                Ok(())
2170            }
2171        });
2172        let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2173        let old_id = cookie.trim_start_matches("autumn.sid=").to_owned();
2174
2175        hit(&app, "/rotate", &cookie).await;
2176
2177        let events = seen.lock().unwrap().clone();
2178        assert_eq!(events.len(), 1);
2179        let event = &events[0];
2180        assert_eq!(event.earlier_ids.len(), 2, "the start id and one hop");
2181        assert_eq!(event.earlier_ids[0], old_id);
2182        assert_ne!(event.earlier_ids[1], event.new_id);
2183    }
2184
2185    #[tokio::test]
2186    async fn rotation_hook_is_silent_without_a_rotation() {
2187        let (hooks, log) = rotation_log();
2188        let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2189        hit(&app, "/noop", &cookie).await;
2190        assert!(log.lock().unwrap().is_empty());
2191    }
2192
2193    #[tokio::test]
2194    async fn rotation_hook_is_silent_for_a_session_with_no_cookie() {
2195        let (hooks, log) = rotation_log();
2196        let (app, _) = rotation_app(hooks, MemoryStore::new()).await;
2197        let response = app
2198            .oneshot(
2199                HttpRequest::builder()
2200                    .uri("/rotate")
2201                    .body(Body::empty())
2202                    .unwrap(),
2203            )
2204            .await
2205            .unwrap();
2206        assert_eq!(response.status(), http::StatusCode::OK);
2207        assert!(log.lock().unwrap().is_empty());
2208    }
2209
2210    #[tokio::test]
2211    async fn rotation_hook_is_silent_when_the_session_is_destroyed() {
2212        let (hooks, log) = rotation_log();
2213        let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2214        hit(&app, "/destroy", &cookie).await;
2215        assert!(log.lock().unwrap().is_empty());
2216    }
2217
2218    #[tokio::test]
2219    async fn rotation_hook_runs_after_the_new_session_is_saved() {
2220        let store = MemoryStore::new();
2221        let hooks = SessionRotationHooks::default();
2222        let check = store.clone();
2223        let saw_new = Arc::new(std::sync::atomic::AtomicBool::new(false));
2224        let flag = Arc::clone(&saw_new);
2225        hooks.register(move |rotation| {
2226            let check = check.clone();
2227            let flag = Arc::clone(&flag);
2228            async move {
2229                let saved = check.load(&rotation.new_id).await.unwrap().is_some();
2230                let gone = check.load(&rotation.old_id).await.unwrap().is_none();
2231                flag.store(saved && gone, std::sync::atomic::Ordering::SeqCst);
2232                Ok(())
2233            }
2234        });
2235        let (app, cookie) = rotation_app(hooks, store).await;
2236        hit(&app, "/rotate", &cookie).await;
2237        assert!(saw_new.load(std::sync::atomic::Ordering::SeqCst));
2238    }
2239
2240    #[tokio::test]
2241    async fn a_failing_rotation_hook_does_not_fail_the_response() {
2242        let hooks = SessionRotationHooks::default();
2243        hooks.register(|_| async { Err("db down".into()) });
2244        let ran_after = Arc::new(std::sync::atomic::AtomicBool::new(false));
2245        let flag = Arc::clone(&ran_after);
2246        hooks.register(move |_| {
2247            let flag = Arc::clone(&flag);
2248            async move {
2249                flag.store(true, std::sync::atomic::Ordering::SeqCst);
2250                Ok(())
2251            }
2252        });
2253        let (app, cookie) = rotation_app(hooks, MemoryStore::new()).await;
2254        let response = hit(&app, "/rotate", &cookie).await;
2255        assert_eq!(response.status(), http::StatusCode::OK);
2256        assert!(response.headers().get(SET_COOKIE).is_some());
2257        assert!(
2258            ran_after.load(std::sync::atomic::Ordering::SeqCst),
2259            "a later hook still runs after an earlier one fails"
2260        );
2261    }
2262}