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}