Skip to main content

siderite_sessions/
config.rs

1//! [`SessionConfig`]: cookie flags, lifetimes and the CSRF key.
2
3use http::HeaderName;
4use siderite_core::SameSite;
5use std::fmt;
6use std::time::Duration;
7
8const HOST_PREFIX: &str = "__Host-";
9const SECURE_PREFIX: &str = "__Secure-";
10const MIN_CSRF_KEY: usize = 32;
11
12/// An invalid [`SessionConfig`], reported by
13/// [`SessionLayer::new`](crate::SessionLayer::new).
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15#[error("invalid session configuration: {0}")]
16pub struct SessionConfigError(&'static str);
17
18/// How sessions are issued, kept and expired.
19///
20/// The defaults follow the security notes in `docs/plans/SESSIONS_CSRF.md`:
21/// a `__Host-session` cookie that is `HttpOnly`, `Secure`, `Path=/` with
22/// no `Domain` and `SameSite=Lax`, a 30 minute idle timeout, a 12 hour
23/// absolute lifetime and a 2 second bound on every store call.
24#[derive(Clone)]
25pub struct SessionConfig {
26    pub(crate) name: String,
27    pub(crate) secure: bool,
28    pub(crate) same_site: SameSite,
29    pub(crate) idle: Duration,
30    pub(crate) absolute: Duration,
31    pub(crate) write_interval: Option<Duration>,
32    pub(crate) store_timeout: Duration,
33    pub(crate) csrf_key: Option<Vec<u8>>,
34    pub(crate) csrf_header: String,
35}
36
37impl Default for SessionConfig {
38    fn default() -> Self {
39        Self {
40            name: format!("{HOST_PREFIX}session"),
41            secure: true,
42            same_site: SameSite::Lax,
43            idle: Duration::from_secs(30 * 60),
44            absolute: Duration::from_secs(12 * 60 * 60),
45            write_interval: None,
46            store_timeout: Duration::from_secs(2),
47            csrf_key: None,
48            csrf_header: "x-csrf-token".to_owned(),
49        }
50    }
51}
52
53impl fmt::Debug for SessionConfig {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.debug_struct("SessionConfig")
56            .field("name", &self.name)
57            .field("secure", &self.secure)
58            .field("same_site", &self.same_site)
59            .field("idle", &self.idle)
60            .field("absolute", &self.absolute)
61            .field("write_interval", &self.effective_write_interval())
62            .field("store_timeout", &self.store_timeout)
63            .field("csrf_key", &self.csrf_key.as_ref().map(|_| "[REDACTED]"))
64            .field("csrf_header", &self.csrf_header)
65            .finish()
66    }
67}
68
69impl SessionConfig {
70    /// The default configuration (see the type docs).
71    #[must_use]
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Set the cookie name.
77    ///
78    /// A name starting with `__Host-` or `__Secure-` makes browsers insist
79    /// on `Secure`, so it cannot be combined with
80    /// [`insecure_development`](Self::insecure_development).
81    #[must_use]
82    pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
83        self.name = name.into();
84        self
85    }
86
87    /// Set `SameSite`: `Lax` (default), `Strict`, or `None`.
88    ///
89    /// `None` sends the cookie on every cross-site request, so it is only
90    /// accepted together with [`csrf_key`](Self::csrf_key) and `Secure`,
91    /// and the layer then requires a valid CSRF token header on every
92    /// unsafe request that carries a live session.
93    #[must_use]
94    pub fn same_site(mut self, same_site: SameSite) -> Self {
95        self.same_site = same_site;
96        self
97    }
98
99    /// Expire a session this long after its last use (default 30 min).
100    ///
101    /// The last use is recorded at most once per
102    /// [`write_interval`](Self::write_interval), so a session can expire up
103    /// to one interval early, never late.
104    #[must_use]
105    pub fn idle_timeout(mut self, idle: Duration) -> Self {
106        self.idle = idle;
107        self
108    }
109
110    /// Expire a session this long after login (default 12 h), however
111    /// active it is. Rotation does not extend it; login restarts it.
112    #[must_use]
113    pub fn absolute_lifetime(mut self, absolute: Duration) -> Self {
114        self.absolute = absolute;
115        self
116    }
117
118    /// Write the idle timestamp at most this often (default a quarter of
119    /// the idle timeout), so reads do not become writes.
120    #[must_use]
121    pub fn write_interval(mut self, interval: Duration) -> Self {
122        self.write_interval = Some(interval);
123        self
124    }
125
126    /// Bound every store call (default 2 s). A call that does not finish
127    /// fails like any other store error.
128    #[must_use]
129    pub fn store_timeout(mut self, timeout: Duration) -> Self {
130        self.store_timeout = timeout;
131        self
132    }
133
134    /// Plain-HTTP development: drop `Secure` and the `__Host-` or
135    /// `__Secure-` prefix of the cookie name.
136    ///
137    /// This is always explicit; it is never inferred from the request.
138    /// Never enable it in production.
139    #[must_use]
140    pub fn insecure_development(mut self) -> Self {
141        self.secure = false;
142        for prefix in [HOST_PREFIX, SECURE_PREFIX] {
143            if let Some(rest) = self.name.strip_prefix(prefix) {
144                self.name = rest.to_owned();
145                break;
146            }
147        }
148        self
149    }
150
151    /// Key for CSRF tokens (at least 32 random bytes, kept secret).
152    ///
153    /// Without a key [`Session::csrf_token`](crate::Session::csrf_token)
154    /// returns `None`.
155    #[must_use]
156    pub fn csrf_key(mut self, key: impl Into<Vec<u8>>) -> Self {
157        self.csrf_key = Some(key.into());
158        self
159    }
160
161    /// Request header carrying the CSRF token when `SameSite=None`
162    /// requires the layer to check it (default `x-csrf-token`).
163    #[must_use]
164    pub fn csrf_header(mut self, name: impl Into<String>) -> Self {
165        self.csrf_header = name.into();
166        self
167    }
168
169    /// The cookie name.
170    #[must_use]
171    pub fn name(&self) -> &str {
172        &self.name
173    }
174
175    /// Check the configuration.
176    ///
177    /// # Errors
178    /// [`SessionConfigError`] for an invalid cookie name, a prefixed name
179    /// without `Secure`, `SameSite=None` without `Secure` and a CSRF key,
180    /// a short key, an invalid header name, or a zero duration.
181    pub fn validate(&self) -> Result<(), SessionConfigError> {
182        let err = |message| Err(SessionConfigError(message));
183        if !is_token(&self.name) {
184            return err("cookie name is not a valid token");
185        }
186        let prefixed = self.name.starts_with(HOST_PREFIX)
187            || self.name.starts_with(SECURE_PREFIX);
188        if prefixed && !self.secure {
189            return err("a prefixed cookie name requires Secure");
190        }
191        if self.same_site == SameSite::None
192            && (!self.secure || self.csrf_key.is_none())
193        {
194            return err("SameSite=None requires Secure and a csrf_key");
195        }
196        if self
197            .csrf_key
198            .as_ref()
199            .is_some_and(|k| k.len() < MIN_CSRF_KEY)
200        {
201            return err("csrf_key must be at least 32 bytes");
202        }
203        if self.csrf_header.parse::<HeaderName>().is_err() {
204            return err("csrf_header is not a valid header name");
205        }
206        let write = self.effective_write_interval();
207        if self.idle.is_zero() || self.absolute.is_zero() {
208            return err("session lifetimes must be positive");
209        }
210        if self.store_timeout.is_zero() || write.is_zero() {
211            return err("timeouts and intervals must be positive");
212        }
213        if write > self.idle {
214            return err("write_interval must not exceed idle_timeout");
215        }
216        Ok(())
217    }
218
219    /// The effective idle write interval.
220    pub(crate) fn effective_write_interval(&self) -> Duration {
221        self.write_interval.unwrap_or(self.idle / 4)
222    }
223}
224
225/// RFC 6265 cookie names are HTTP tokens.
226fn is_token(name: &str) -> bool {
227    !name.is_empty()
228        && name.bytes().all(|b| {
229            b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)
230        })
231}