Skip to main content

axess_core/session/
layer.rs

1//! Tower middleware layer providing HMAC-signed session cookies and typed session data.
2//!
3//! # Cookie format
4//!
5//! `<session_id_base64url>.<hmac_base64url>`
6//!
7//! The HMAC-SHA256 is computed over the raw 16 bytes of the session UUID.
8//! The cookie contains *only* the session ID; session data lives in the store.
9//!
10//! # Request lifecycle
11//!
12//! 1. Extract and verify the session cookie (HMAC check with constant-time comparison).
13//! 2. Load [`crate::session::SessionData`] from the store (or create an empty default).
14//! 3. Insert `SessionHandle` into request extensions.
15//! 4. Call the inner service.
16//! 5. If the session was modified, save it back to the store (or cycle the ID first).
17//! 6. Set the session cookie on the response.
18
19mod handle;
20mod lifecycle;
21mod service;
22mod signing;
23
24pub(crate) use handle::SessionHandle;
25// `SessionInner` is `pub(crate)` only so test modules and the
26// `testing::*` fixture helpers can construct sessions directly.
27// Production callers go through `SessionHandle`, so gate the
28// re-export to avoid an unused-import warning in plain prod builds.
29#[cfg(any(test, feature = "testing"))]
30pub(crate) use handle::SessionInner;
31pub use service::SessionService;
32
33#[cfg(feature = "device")]
34use crate::device::resolver::{DeviceResolver, ErasedDeviceResolver};
35use crate::session::binding::SessionBinding;
36use crate::session::config::SessionConfig;
37use crate::session::store::SessionStore;
38use signing::{SigningKeys, hkdf_expand_subkey};
39use std::{sync::Arc, time::Duration};
40use tower_cookies::cookie::SameSite;
41
42/// Tower layer that provides signed session cookies and typed [`crate::session::SessionData`].
43///
44/// Add this layer to your Axum router. Handlers receive an [`super::extractor::AuthSession`] extractor
45/// which wraps the `SessionHandle` stored in request extensions.
46///
47/// ```text
48/// let app = Router::new()
49///     .route("/login", post(login_handler))
50///     .layer(SessionLayer::new(store, signing_key));
51/// ```
52///
53/// # Configuration
54///
55/// Cookie attributes and TTL are set via the `with_*` builder methods:
56///
57/// ```text
58/// let layer = SessionLayer::new(store, key)
59///     .with_ttl(Duration::from_secs(7200))
60///     .with_secure(false)
61///     .with_same_site(SameSite::Strict);
62/// ```
63#[derive(Clone)]
64pub struct SessionLayer<S> {
65    pub(super) store: S,
66    pub(super) signing_keys: Arc<SigningKeys>,
67    /// Shared via `Arc` so the `Layer::layer` clone (per inner
68    /// service) and the per-request `Service::call` clone are both
69    /// pointer copies, not full struct copies. `SessionConfig` carries
70    /// several `Arc<str>` fields plus a number of small enums; cloning
71    /// the whole thing on every request added measurable overhead on
72    /// the middleware hot path.
73    pub(super) config: Arc<SessionConfig>,
74    pub(super) binding: Option<Arc<dyn SessionBinding>>,
75    pub(super) metrics: Option<Arc<dyn crate::metrics::AuthnMetrics>>,
76    /// Optional device resolver invoked once per request between
77    /// session-data load and the inner handler. Stamps
78    /// [`SessionData::device_id`](crate::session::SessionData::device_id)
79    /// when the resolver returns `Some`.
80    #[cfg(feature = "device")]
81    pub(super) device_resolver: Option<Arc<dyn ErasedDeviceResolver>>,
82}
83
84impl<S: SessionStore> SessionLayer<S> {
85    /// Create a session layer with the given store and 32-byte HMAC signing key.
86    ///
87    /// Uses [`SessionConfig::default()`]: production-safe defaults (24-hour TTL,
88    /// `Secure`, `HttpOnly`, `SameSite=Lax`).
89    ///
90    /// The signing key derives sub-keys via HKDF for cookie HMAC,
91    /// fingerprint HMAC, and CSRF; keep it in a secret store and rotate
92    /// per your key-management policy (a fresh layer is required on
93    /// rotation since the derived keys are cached).
94    ///
95    /// # Example
96    ///
97    /// ```no_run
98    /// use axess_core::{MemorySessionStore, session::SessionLayer};
99    /// use std::time::Duration;
100    ///
101    /// // Production: load the master key from a secret store (KMS, Vault, …).
102    /// // Below uses a fixed value purely for the construction example.
103    /// let signing_key: [u8; 32] = [0u8; 32];
104    /// let store = MemorySessionStore::default();
105    ///
106    /// let layer = SessionLayer::new(store, signing_key)
107    ///     .with_ttl(Duration::from_secs(3600))
108    ///     .with_cookie_name("myapp.sid");
109    ///
110    /// // Attach to your router:
111    /// //     let app = axum::Router::new().layer(layer);
112    /// # let _ = layer;
113    /// ```
114    pub fn new(store: S, signing_key: [u8; 32]) -> Self {
115        Self {
116            store,
117            // Derive distinct cookie / fingerprint HMAC sub-keys
118            // from the master so a side-channel on one path cannot be
119            // replayed against the other.
120            signing_keys: Arc::new(SigningKeys::from_master(signing_key)),
121            config: Arc::new(SessionConfig::default()),
122            binding: None,
123            metrics: None,
124            #[cfg(feature = "device")]
125            device_resolver: None,
126        }
127    }
128
129    /// Borrow the per-request `SessionConfig`. Mutating
130    /// setters take a fresh copy via `Arc::make_mut`, so production
131    /// builds that only mutate during the construction phase share a
132    /// single `Arc` across every cloned service.
133    ///
134    /// # Footgun
135    ///
136    /// **Mutate before cloning.** If you call `.clone()` on the layer
137    /// (or pass it through `tower::Layer`) and *then* invoke a `with_*`
138    /// setter, `Arc::make_mut` deep-copies the `SessionConfig` so your
139    /// edit only affects the new clone; the previously-cloned service
140    /// still holds the original. Always finish configuration before
141    /// the layer enters the router. Debug builds catch the surprising
142    /// case below.
143    fn config_mut(&mut self) -> &mut SessionConfig {
144        debug_assert!(
145            Arc::strong_count(&self.config) == 1,
146            "SessionLayer setter called after the layer was cloned (strong_count = {}). \
147             The mutation will deep-copy SessionConfig and only affect this clone. Configure \
148             the layer fully before passing it to `tower::Layer` / `Router::layer`.",
149            Arc::strong_count(&self.config),
150        );
151        Arc::make_mut(&mut self.config)
152    }
153
154    /// Derive a 32-byte sub-key from this layer's master signing
155    /// key using a caller-supplied `info` label. Use this to feed
156    /// CSRF token signing, push-notification HMAC, or any other HMAC
157    /// site that would otherwise be tempted to reuse the raw master
158    /// key. Pick a stable byte-string label per use site (e.g.
159    /// `b"my-app.v1.csrf"`); changing the label invalidates every
160    /// value previously derived under it.
161    ///
162    /// The returned bytes are wrapped in [`zeroize::Zeroizing`] so they
163    /// are wiped on drop: a compromise of any derived sub-key with a
164    /// known label lets an attacker forge against that path, so the
165    /// material is held under the same drop discipline as the master
166    /// key. Deref through `*` or `as_ref()` to access the raw `[u8; 32]`.
167    pub fn derive_subkey(&self, info: &'static [u8]) -> zeroize::Zeroizing<[u8; 32]> {
168        zeroize::Zeroizing::new(hkdf_expand_subkey(&self.signing_keys.master, info))
169    }
170
171    /// Override the session TTL (default: 24 hours).
172    pub fn with_ttl(mut self, ttl: Duration) -> Self {
173        self.config_mut().ttl = ttl;
174        self
175    }
176
177    /// Override the cookie name (default: `"axess.sid"`).
178    pub fn with_cookie_name(mut self, name: impl Into<Arc<str>>) -> Self {
179        self.config_mut().cookie_name = name.into();
180        self
181    }
182
183    /// Set the `Secure` flag on the session cookie (default: `true`).
184    ///
185    /// Set to `false` only for local HTTP development. In production,
186    /// cookies without the `Secure` flag can be intercepted on the network.
187    pub fn with_secure(mut self, secure: bool) -> Self {
188        if !secure {
189            tracing::warn!(
190                "SessionLayer: Secure cookie flag disabled; session cookies \
191                 will be sent over plain HTTP. Do not use in production."
192            );
193        }
194        self.config_mut().secure = secure;
195        self
196    }
197
198    /// Set the `SameSite` policy (default: `Lax`).
199    pub fn with_same_site(mut self, same_site: SameSite) -> Self {
200        self.config_mut().same_site = same_site;
201        self
202    }
203
204    /// Set the `HttpOnly` flag on the session cookie (default: `true`).
205    pub fn with_http_only(mut self, http_only: bool) -> Self {
206        self.config_mut().http_only = http_only;
207        self
208    }
209
210    /// Override the maximum size (in bytes) of the JSON-encoded
211    /// custom-data payload. Default: 64 KiB. Set to `0` to disable
212    /// the clamp entirely (do this only if you have your own size
213    /// guarding upstream of the session layer).
214    ///
215    /// When the clamp fires, the offending custom payload is
216    /// dropped to `Value::Null` and a `tracing::warn!` is emitted;
217    /// the session itself is preserved.
218    pub fn with_max_custom_bytes(mut self, max: usize) -> Self {
219        self.config_mut().max_custom_bytes = max;
220        self
221    }
222
223    /// Set the cookie `Path` attribute (default: `"/"`).
224    pub fn with_path(mut self, path: impl Into<Arc<str>>) -> Self {
225        self.config_mut().path = path.into();
226        self
227    }
228
229    /// Enable session-to-client binding for hijacking detection.
230    ///
231    /// When enabled, the library hashes client-specific request properties
232    /// (determined by the [`SessionBinding`] implementation) and stores the
233    /// hash in the session upon authentication. On every subsequent request
234    /// the hash is recomputed and compared; a mismatch resets the session
235    /// to `Guest` (the cookie may have been stolen by a different client).
236    ///
237    /// ```text
238    /// use axess::session::UserAgentBinding;
239    ///
240    /// let layer = SessionLayer::new(store, key)
241    ///     .with_binding(UserAgentBinding);
242    /// ```
243    pub fn with_binding(mut self, binding: impl SessionBinding) -> Self {
244        self.binding = Some(Arc::new(binding));
245        self
246    }
247
248    /// Attach a metrics hook for session-level observability.
249    pub fn with_metrics(mut self, metrics: impl crate::metrics::AuthnMetrics) -> Self {
250        self.metrics = Some(Arc::new(metrics));
251        self
252    }
253
254    /// Configure a [`DeviceResolver`] to stamp
255    /// [`SessionData::device_id`](crate::session::SessionData::device_id)
256    /// on every request before the inner handler runs.
257    ///
258    /// Errors returned by the resolver are logged via `tracing::warn!`
259    /// and treated as `None`; device resolution never fails the request.
260    /// When unset (the default), `device_id` stays at whatever the loaded
261    /// session carried, which for new sessions is `None`.
262    ///
263    /// See [`docs/identity/device.md`](https://github.com/GnomesOfZurich/axess/blob/main/docs/identity/device.md)
264    /// for the full design.
265    #[cfg(feature = "device")]
266    pub fn with_device_resolver<R>(mut self, resolver: R) -> Self
267    where
268        R: DeviceResolver,
269    {
270        self.device_resolver = Some(Arc::new(resolver));
271        self
272    }
273}
274
275// Regression: confirm the `config_mut` debug_assert
276// actually fires when a `SessionLayer` is cloned and *then* mutated.
277// The Arc<SessionConfig> deep-copy in `Arc::make_mut` would otherwise
278// silently divorce the two layers' configs. The assertion is a debug-only
279// guard so the test is gated behind `debug_assertions`.
280#[cfg(all(test, debug_assertions))]
281mod make_mut_tests {
282    use super::*;
283    use crate::session::store::MemorySessionStore;
284
285    #[test]
286    #[should_panic(expected = "SessionLayer setter called after the layer was cloned")]
287    fn setter_after_clone_panics_in_debug() {
288        let store = MemorySessionStore::new();
289        let layer = SessionLayer::new(store, [0u8; 32]);
290        let cloned = layer.clone();
291        // Cloning bumps the Arc strong count to 2; the next `with_ttl`
292        // will trip the assertion before `Arc::make_mut` deep-copies.
293        layer.with_ttl(Duration::from_secs(60));
294        drop(cloned);
295    }
296
297    #[test]
298    fn setter_before_clone_does_not_panic() {
299        let store = MemorySessionStore::new();
300        let layer = SessionLayer::new(store, [0u8; 32])
301            .with_ttl(Duration::from_secs(60))
302            .with_secure(false);
303        // Clone after setters is fine; the configuration is already finalised.
304        drop(layer.clone());
305    }
306}
307
308/// `with_secure(false)` MUST emit a tracing warning so an
309/// operator running on plain HTTP cannot miss the implication
310/// of disabling the cookie's `Secure` flag in production.
311/// `with_secure(true)` MUST stay silent: a warning on the safe
312/// configuration trains operators to ignore the channel.
313///
314/// Pins `delete !` on the `if !secure` guard in `with_secure`:
315/// with the `!` removed the predicate inverts, the warning fires
316/// for `secure=true` (every production deployment) and stays silent
317/// for `secure=false` (the actually-dangerous configuration).
318#[cfg(test)]
319mod with_secure_warning_tests {
320    use super::*;
321    use crate::session::store::MemorySessionStore;
322    use crate::testing::mock_tracing::TracingCapture;
323
324    #[test]
325    fn with_secure_false_emits_warning() {
326        let capture = TracingCapture::install();
327        let store = MemorySessionStore::new();
328        drop(SessionLayer::new(store, [0u8; 32]).with_secure(false));
329        assert!(
330            capture.contains_at_level(tracing::Level::WARN, "Secure cookie flag disabled"),
331            "with_secure(false) must warn about plain-HTTP cookies"
332        );
333    }
334
335    #[test]
336    fn with_secure_true_does_not_emit_warning() {
337        let capture = TracingCapture::install();
338        let store = MemorySessionStore::new();
339        drop(SessionLayer::new(store, [0u8; 32]).with_secure(true));
340        assert!(
341            !capture.contains_at_level(tracing::Level::WARN, "Secure cookie flag disabled"),
342            "with_secure(true) must NOT warn; `delete !` mutation would invert this"
343        );
344    }
345}