Skip to main content

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}