axess_core/session/config.rs
1//! Session configuration: cookie attributes and TTL, with a builder for ergonomic construction.
2//!
3//! # Example
4//!
5//! ```rust
6//! use axess_core::session::config::SessionConfig;
7//! use std::time::Duration;
8//!
9//! let config = SessionConfig::builder()
10//! .ttl(Duration::from_secs(7200))
11//! .cookie_name("my.sid")
12//! .secure(true)
13//! .same_site(axess_core::session::config::SameSite::Strict)
14//! .http_only(true)
15//! .build();
16//! ```
17
18use std::sync::Arc;
19use std::time::Duration;
20
21// Re-export SameSite so callers don't need tower_cookies in their Cargo.toml.
22pub use tower_cookies::cookie::SameSite;
23
24const DEFAULT_COOKIE_NAME: &str = "axess.sid";
25const DEFAULT_TTL: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours
26/// Default maximum size of serialized custom session data (64 KiB).
27const DEFAULT_MAX_CUSTOM_BYTES: usize = 64 * 1024;
28
29/// Session configuration controlling cookie attributes and session lifetime.
30///
31/// Use [`SessionConfig::builder()`] for ergonomic construction, or
32/// [`SessionConfig::default()`] for production-safe defaults.
33#[derive(Debug, Clone)]
34pub struct SessionConfig {
35 /// Session time-to-live in the store and `Max-Age` on the cookie.
36 pub ttl: Duration,
37 /// Cookie name (default: `"axess.sid"`).
38 ///
39 /// Prefer the `__Host-` prefix in production (e.g.,
40 /// `"__Host-axess.sid"`). Browsers refuse to set a `__Host-` cookie
41 /// unless `Secure=true`, `Path="/"`, and the cookie has no `Domain`
42 /// attribute: together these prevent subdomain-scoped overwrites and
43 /// cross-host injection that the bare name does not. Using the prefix
44 /// without `Secure=true` panics in `build()` to fail fast on
45 /// misconfiguration. The `__Secure-` prefix is similar but only
46 /// requires `Secure=true`.
47 pub cookie_name: Arc<str>,
48 /// Set the `Secure` flag on the cookie (default: `true`).
49 ///
50 /// Set to `false` for local HTTP development.
51 pub secure: bool,
52 /// `SameSite` policy (default: `Lax`).
53 ///
54 /// `Lax` is the right default for axess: the OAuth/OIDC callback flow
55 /// depends on the cookie being delivered on the IdP's top-level GET
56 /// redirect back to the application, which `Strict` would strip. `Lax`
57 /// still blocks the cookie from cross-site sub-resource requests
58 /// (`<img>`, `<iframe>`, `fetch()` without credentials), but **does
59 /// deliver the cookie on top-level navigations from a third-party
60 /// origin**. Applications MUST therefore layer their own CSRF defence
61 /// on every state-changing POST/PUT/DELETE; the bundled
62 /// `axess_core::middleware::csrf` middleware does this, but it is opt-in.
63 pub same_site: SameSite,
64 /// Set the `HttpOnly` flag on the cookie (default: `true`).
65 pub http_only: bool,
66 /// Cookie `Path` attribute (default: `"/"`).
67 pub path: Arc<str>,
68 /// Maximum size (in bytes) of serialized custom session data (default: 64 KiB).
69 ///
70 /// Prevents session-bloat DoS where an attacker inflates the custom JSON
71 /// bag to exhaust storage. Set to `0` to disable the limit.
72 pub max_custom_bytes: usize,
73}
74
75impl Default for SessionConfig {
76 fn default() -> Self {
77 Self {
78 ttl: DEFAULT_TTL,
79 cookie_name: DEFAULT_COOKIE_NAME.into(),
80 secure: true,
81 same_site: SameSite::Lax,
82 http_only: true,
83 path: "/".into(),
84 max_custom_bytes: DEFAULT_MAX_CUSTOM_BYTES,
85 }
86 }
87}
88
89impl SessionConfig {
90 /// Create a [`SessionConfigBuilder`] with production-safe defaults.
91 pub fn builder() -> SessionConfigBuilder {
92 SessionConfigBuilder(SessionConfig::default())
93 }
94}
95
96/// Whether the builder should emit the "insecure cookie" warning.
97///
98/// Lifted out of [`SessionConfigBuilder::secure`] so the boolean guard
99/// is observable in a unit test without depending on a tracing capture.
100fn should_warn_insecure_cookie(secure: bool) -> bool {
101 !secure
102}
103
104/// Builder for [`SessionConfig`].
105///
106/// All fields start with production-safe defaults; override only what you need.
107///
108/// ```rust
109/// use axess_core::session::config::SessionConfig;
110/// use std::time::Duration;
111///
112/// let config = SessionConfig::builder()
113/// .ttl(Duration::from_secs(7200)) // 2 hours
114/// .secure(false) // local HTTP dev
115/// .build();
116/// ```
117pub struct SessionConfigBuilder(SessionConfig);
118
119impl SessionConfigBuilder {
120 /// Set the session TTL (default: 24 hours).
121 pub fn ttl(mut self, ttl: Duration) -> Self {
122 self.0.ttl = ttl;
123 self
124 }
125
126 /// Set the cookie name (default: `"axess.sid"`).
127 pub fn cookie_name(mut self, name: impl Into<Arc<str>>) -> Self {
128 self.0.cookie_name = name.into();
129 self
130 }
131
132 /// Set the `Secure` flag (default: `true`).
133 ///
134 /// Set to `false` only for local HTTP development.
135 pub fn secure(mut self, secure: bool) -> Self {
136 if should_warn_insecure_cookie(secure) {
137 tracing::warn!(
138 "SessionConfig: Secure cookie flag disabled; session cookies \
139 will be sent over plain HTTP. Do not use in production."
140 );
141 }
142 self.0.secure = secure;
143 self
144 }
145
146 /// Set the `SameSite` policy (default: `Lax`).
147 pub fn same_site(mut self, same_site: SameSite) -> Self {
148 self.0.same_site = same_site;
149 self
150 }
151
152 /// Set the `HttpOnly` flag (default: `true`).
153 pub fn http_only(mut self, http_only: bool) -> Self {
154 self.0.http_only = http_only;
155 self
156 }
157
158 /// Set the cookie `Path` attribute (default: `"/"`).
159 pub fn path(mut self, path: impl Into<Arc<str>>) -> Self {
160 self.0.path = path.into();
161 self
162 }
163
164 /// Set the maximum size of serialized custom session data (default: 64 KiB).
165 ///
166 /// Set to `0` to disable the limit.
167 pub fn max_custom_bytes(mut self, max: usize) -> Self {
168 self.0.max_custom_bytes = max;
169 self
170 }
171
172 /// Consume the builder and return the finished [`SessionConfig`].
173 ///
174 /// # Panics
175 ///
176 /// Panics if `ttl` is zero (sessions would expire immediately), if
177 /// `cookie_name` is empty, or if a `__Host-` / `__Secure-` cookie name
178 /// prefix is used without `Secure=true` (the prefix would be silently
179 /// dropped by browsers and the security promise lost; better to fail
180 /// fast at construction time than to ship a misconfigured cookie).
181 pub fn build(self) -> SessionConfig {
182 assert!(
183 !self.0.ttl.is_zero(),
184 "SessionConfig: ttl must be > 0 (sessions would expire immediately)"
185 );
186 assert!(
187 !self.0.cookie_name.is_empty(),
188 "SessionConfig: cookie_name must not be empty"
189 );
190 let name: &str = &self.0.cookie_name;
191 if name.starts_with("__Host-") {
192 assert!(
193 self.0.secure,
194 "SessionConfig: cookie name `{name}` requires `secure = true`"
195 );
196 assert!(
197 self.0.path.as_ref() == "/",
198 "SessionConfig: cookie name `{name}` requires `path = \"/\"`"
199 );
200 } else if name.starts_with("__Secure-") {
201 assert!(
202 self.0.secure,
203 "SessionConfig: cookie name `{name}` requires `secure = true`"
204 );
205 }
206 self.0
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn default_config_has_production_safe_values() {
216 let config = SessionConfig::default();
217 assert_eq!(config.ttl, Duration::from_secs(86400));
218 assert_eq!(config.cookie_name.as_ref(), "axess.sid");
219 assert!(config.secure);
220 assert_eq!(config.same_site, SameSite::Lax);
221 assert!(config.http_only);
222 assert_eq!(config.path.as_ref(), "/");
223 // Pin DEFAULT_MAX_CUSTOM_BYTES at exactly 64 KiB.
224 // Kills `64 * 1024` → `+` (1088) and `/` (0) mutations.
225 assert_eq!(
226 config.max_custom_bytes, 65536,
227 "DEFAULT_MAX_CUSTOM_BYTES must be 64 * 1024 = 65536"
228 );
229 }
230
231 #[test]
232 fn builder_overrides_defaults() {
233 let config = SessionConfig::builder()
234 .ttl(Duration::from_secs(7200))
235 .cookie_name("custom.sid")
236 .secure(false)
237 .same_site(SameSite::Strict)
238 .http_only(false)
239 .path("/app")
240 .build();
241
242 assert_eq!(config.ttl, Duration::from_secs(7200));
243 assert_eq!(config.cookie_name.as_ref(), "custom.sid");
244 assert!(!config.secure);
245 assert_eq!(config.same_site, SameSite::Strict);
246 assert!(!config.http_only);
247 assert_eq!(config.path.as_ref(), "/app");
248 }
249
250 #[test]
251 fn should_warn_insecure_cookie_only_for_disabled_flag() {
252 assert!(should_warn_insecure_cookie(false));
253 assert!(!should_warn_insecure_cookie(true));
254 }
255
256 #[test]
257 fn builder_partial_override_keeps_defaults() {
258 let config = SessionConfig::builder()
259 .ttl(Duration::from_secs(3600))
260 .build();
261
262 assert_eq!(config.ttl, Duration::from_secs(3600));
263 // Everything else should be default.
264 assert!(config.secure);
265 assert_eq!(config.same_site, SameSite::Lax);
266 assert!(config.http_only);
267 }
268}