Skip to main content

arcature/auth/
session.rs

1//! Session cookie configuration and the 64-byte signing key.
2//!
3//! This is resolved configuration for the [tower-sessions] cookie attributes:
4//! name, `SameSite`, `Secure`, `HttpOnly`, path, domain, `Max-Age`, and the
5//! cookie signing key. Arcature does not own the session store; the
6//! application wires any `tower_sessions::SessionStore`. The signing key is
7//! held in a [`secrecy::SecretSlice`] and never appears in `Debug`.
8//!
9//! [tower-sessions]: https://docs.rs/tower-sessions
10
11use std::fmt;
12use std::time::Duration;
13
14use secrecy::{ExposeSecret, SecretSlice};
15use tower_sessions::cookie::Key;
16use tower_sessions::{Expiry, SessionManagerLayer};
17
18use crate::auth::{SessionBuildError, SessionConfigError, SigningKeyReason};
19
20/// A signed-cookie session layer built from resolved [`SessionConfig`] and a
21/// [`tower_sessions::SessionStore`].
22///
23/// This is `tower_sessions::SessionManagerLayer<Store,
24/// tower_sessions::service::SignedCookie>` -- the upstream layer with the
25/// `SignedCookie` controller. Apply it on an Axum router with `.layer(...)`.
26pub type SessionLayer<Store> = SessionManagerLayer<Store, tower_sessions::service::SignedCookie>;
27
28/// SameSite cookie attribute.
29///
30/// Defaults to [`SameSite::Strict`] for the strongest browser default. An
31/// application doing third-party-initiated logins (e.g. OAuth callbacks across
32/// a redirect) may need [`SameSite::Lax`]; never use
33/// [`SameSite::None`](Self::None) without also enabling `Secure`.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum SameSite {
36    /// `SameSite=Strict` -- the cookie is not sent on cross-site requests.
37    Strict,
38    /// `SameSite=Lax` -- sent on top-level cross-site navigations (GET).
39    Lax,
40    /// `SameSite=None` -- sent on all cross-site requests; requires `Secure`.
41    None,
42}
43
44impl SameSite {
45    pub(crate) fn as_tower(&self) -> tower_sessions::cookie::SameSite {
46        match self {
47            Self::Strict => tower_sessions::cookie::SameSite::Strict,
48            Self::Lax => tower_sessions::cookie::SameSite::Lax,
49            Self::None => tower_sessions::cookie::SameSite::None,
50        }
51    }
52}
53
54/// Resolved session configuration.
55///
56/// Construct with [`SessionConfig::new`] (a signed-cookie layer), then pass
57/// to [`SessionConfig::into_layer`] with a session store to build a
58/// [`SessionLayer`] for Axum. Configuration is explicit and resolved; the
59/// library never reads environment variables inside layer construction or
60/// request handling.
61///
62/// # Signing key
63///
64/// The cookie signing key must be exactly 64 bytes, the master-key length
65/// required by the certified `cookie` crate's signed jar. Use
66/// [`SessionKey::generate`] to produce a cryptographically random key. The key
67/// is held in a [`secrecy::SecretSlice`]; its `Debug` output never exposes the
68/// bytes.
69///
70/// # Two lifetimes
71///
72/// A session has two independent expiry bounds:
73///
74/// - **Idle/inactivity** (`max_age`, [`Self::with_max_age`]) -- a sliding
75///   window mapped to `tower_sessions::Expiry::OnInactivity`: each request
76///   that saves the session resets it.
77/// - **Absolute** (`absolute_max_age`, [`Self::with_absolute_max_age`]) -- the
78///   maximum authenticated lifetime measured from the authentication timestamp
79///   stored in the session at login, enforced at the auth boundary.
80#[derive(Clone)]
81pub struct SessionConfig {
82    cookie_name: String,
83    same_site: SameSite,
84    secure: bool,
85    http_only: bool,
86    path: String,
87    domain: Option<String>,
88    max_age: Duration,
89    absolute_max_age: Duration,
90    signing_key: SecretSlice<u8>,
91}
92
93impl SessionConfig {
94    /// Build session configuration with a signed-cookie key.
95    ///
96    /// `signing_key` must be exactly 64 bytes. The cookie attributes default
97    /// to secure values: name `"__Host-id"`, `SameSite=Strict`, `Secure=true`,
98    /// `HttpOnly=true`, path `"/"`, no domain, idle `Max-Age` 14 days, absolute
99    /// lifetime 30 days. Override any with the `with_*` builder methods.
100    ///
101    /// # The `__Host-` prefix
102    ///
103    /// The default cookie name is `__Host-id`. A `__Host-` prefix mandates
104    /// `Secure`, no `Domain`, and path `/` (RFC 6265bis), which the default
105    /// attributes already satisfy, so the rename is strictly tighter. It
106    /// defeats session-fixation/cookie-tossing from a sibling subdomain. For
107    /// development over plain HTTP use [`SessionConfig::dev`] (a `__Host-`
108    /// cookie is silently dropped by the browser when it is not `Secure`).
109    ///
110    /// # Errors
111    ///
112    /// Returns [`SessionConfigError::InvalidSigningKey`] if the key is not
113    /// exactly 64 bytes.
114    pub fn new(signing_key: &[u8]) -> Result<Self, SessionConfigError> {
115        if signing_key.len() != 64 {
116            return Err(SessionConfigError::InvalidSigningKey {
117                reason: SigningKeyReason::WrongLength,
118            });
119        }
120        Ok(Self {
121            cookie_name: "__Host-id".to_string(),
122            same_site: SameSite::Strict,
123            secure: true,
124            http_only: true,
125            path: "/".to_string(),
126            domain: None,
127            max_age: Duration::from_secs(60 * 60 * 24 * 14),
128            absolute_max_age: Duration::from_secs(60 * 60 * 24 * 30),
129            signing_key: SecretSlice::from(signing_key.to_vec()),
130        })
131    }
132
133    /// Build session configuration with the **development** defaults: cookie
134    /// name `arcature-id` (no `__Host-` prefix), `SameSite=Strict`,
135    /// `Secure = false`, `HttpOnly=true`, path `"/"`, no domain, idle
136    /// `Max-Age` 14 days, absolute lifetime 30 days.
137    ///
138    /// A development server on plain HTTP cannot use the `__Host-` prefix
139    /// (the browser drops a non-`Secure` `__Host-` cookie); this policy uses
140    /// a plain cookie name so the session cookie reaches the browser over
141    /// HTTP. Production keeps [`SessionConfig::new`] (`__Host-id`,
142    /// `Secure = true`).
143    ///
144    /// # Errors
145    ///
146    /// Returns [`SessionConfigError::InvalidSigningKey`] if the key is not
147    /// exactly 64 bytes.
148    pub fn dev(signing_key: &[u8]) -> Result<Self, SessionConfigError> {
149        if signing_key.len() != 64 {
150            return Err(SessionConfigError::InvalidSigningKey {
151                reason: SigningKeyReason::WrongLength,
152            });
153        }
154        Ok(Self {
155            cookie_name: "arcature-id".to_string(),
156            same_site: SameSite::Strict,
157            secure: false,
158            http_only: true,
159            path: "/".to_string(),
160            domain: None,
161            max_age: Duration::from_secs(60 * 60 * 24 * 14),
162            absolute_max_age: Duration::from_secs(60 * 60 * 24 * 30),
163            signing_key: SecretSlice::from(signing_key.to_vec()),
164        })
165    }
166
167    /// Override the session cookie name.
168    #[must_use]
169    pub fn with_cookie_name(mut self, name: impl Into<String>) -> Self {
170        self.cookie_name = name.into();
171        self
172    }
173
174    /// Override the `SameSite` attribute. Default [`SameSite::Strict`].
175    #[must_use]
176    pub fn with_same_site(mut self, same_site: SameSite) -> Self {
177        self.same_site = same_site;
178        self
179    }
180
181    /// Override the `Secure` attribute (default `true`).
182    #[must_use]
183    pub fn with_secure(mut self, secure: bool) -> Self {
184        self.secure = secure;
185        self
186    }
187
188    /// Override the `HttpOnly` attribute (default `true`).
189    #[must_use]
190    pub fn with_http_only(mut self, http_only: bool) -> Self {
191        self.http_only = http_only;
192        self
193    }
194
195    /// Override the cookie `Path` attribute (default `"/"`).
196    #[must_use]
197    pub fn with_path(mut self, path: impl Into<String>) -> Self {
198        self.path = path.into();
199        self
200    }
201
202    /// Override the cookie `Domain` attribute (default: none).
203    #[must_use]
204    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
205        self.domain = Some(domain.into());
206        self
207    }
208
209    /// Override the session **idle/inactivity** timeout. Default 14 days.
210    ///
211    /// This is a *sliding* window: it is mapped to
212    /// `tower_sessions::Expiry::OnInactivity`, so each request that saves the
213    /// session resets the clock. The maximum authenticated lifetime is a
214    /// separate bound -- see [`Self::with_absolute_max_age`].
215    #[must_use]
216    pub fn with_max_age(mut self, max_age: Duration) -> Self {
217        self.max_age = max_age;
218        self
219    }
220
221    /// Override the **absolute** authenticated session lifetime. Default 30
222    /// days.
223    #[must_use]
224    pub fn with_absolute_max_age(mut self, absolute_max_age: Duration) -> Self {
225        self.absolute_max_age = absolute_max_age;
226        self
227    }
228
229    /// The configured **absolute** authenticated session lifetime.
230    #[must_use]
231    pub fn absolute_max_age(&self) -> Duration {
232        self.absolute_max_age
233    }
234
235    pub(crate) fn cookie_name(&self) -> &str {
236        &self.cookie_name
237    }
238
239    pub(crate) fn same_site(&self) -> SameSite {
240        self.same_site
241    }
242
243    pub(crate) fn secure(&self) -> bool {
244        self.secure
245    }
246
247    pub(crate) fn http_only(&self) -> bool {
248        self.http_only
249    }
250
251    pub(crate) fn path(&self) -> &str {
252        &self.path
253    }
254
255    pub(crate) fn domain(&self) -> Option<&str> {
256        self.domain.as_deref()
257    }
258
259    pub(crate) fn max_age(&self) -> Duration {
260        self.max_age
261    }
262
263    pub(crate) fn signing_key(&self) -> &[u8] {
264        self.signing_key.expose_secret()
265    }
266
267    pub(crate) fn validate(&self) -> Result<(), SessionConfigError> {
268        if self.cookie_name.is_empty() {
269            return Err(SessionConfigError::EmptyCookieAttribute { attribute: "name" });
270        }
271        if self.path.is_empty() {
272            return Err(SessionConfigError::EmptyCookieAttribute { attribute: "path" });
273        }
274        if self.max_age.is_zero() {
275            return Err(SessionConfigError::ZeroDuration { field: "max_age" });
276        }
277        if self.absolute_max_age.is_zero() {
278            return Err(SessionConfigError::ZeroDuration {
279                field: "absolute_max_age",
280            });
281        }
282        if self.signing_key().len() != 64 {
283            return Err(SessionConfigError::InvalidSigningKey {
284                reason: SigningKeyReason::WrongLength,
285            });
286        }
287        // A __Host- prefixed cookie mandates Secure = true (RFC 6265bis); a
288        // non-Secure __Host- cookie is silently dropped by the browser, so
289        // the combination is invalid.
290        if !self.secure && self.cookie_name.starts_with("__Host-") {
291            return Err(SessionConfigError::InsecureHostPrefixedCookie {
292                cookie_name: self.cookie_name.clone(),
293            });
294        }
295        Ok(())
296    }
297
298    /// Build a [`SessionLayer`] over `store`. Validates the configuration
299    /// before constructing the tower-sessions layer.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`SessionBuildError`] if the configuration is internally
304    /// inconsistent (empty name/path, zero max-age, wrong key length, a
305    /// `__Host-` cookie combined with `Secure = false`).
306    pub fn into_layer<Store>(self, store: Store) -> Result<SessionLayer<Store>, SessionBuildError>
307    where
308        Store: tower_sessions::SessionStore,
309    {
310        self.validate().map_err(SessionBuildError::new)?;
311        Ok(assemble_layer(self, store))
312    }
313}
314
315/// Manual `Debug` -- the signing key is never exposed.
316impl fmt::Debug for SessionConfig {
317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318        formatter
319            .debug_struct("SessionConfig")
320            .field("cookie_name", &self.cookie_name)
321            .field("same_site", &self.same_site)
322            .field("secure", &self.secure)
323            .field("http_only", &self.http_only)
324            .field("path", &self.path)
325            .field("domain", &self.domain)
326            .field("max_age_secs", &self.max_age.as_secs())
327            .field("absolute_max_secs", &self.absolute_max_age.as_secs())
328            .field("signing_key", &"<redacted 64-byte secret>")
329            .finish()
330    }
331}
332
333fn assemble_layer<Store: tower_sessions::SessionStore>(
334    config: SessionConfig,
335    store: Store,
336) -> SessionLayer<Store> {
337    let key = Key::from(config.signing_key());
338    let max_age_secs: i64 = config.max_age().as_secs().try_into().unwrap_or(i64::MAX);
339    let expiry = Expiry::OnInactivity(time::Duration::seconds(max_age_secs));
340    let layer = SessionManagerLayer::new(store)
341        .with_name(config.cookie_name().to_string())
342        .with_same_site(config.same_site().as_tower())
343        .with_secure(config.secure())
344        .with_http_only(config.http_only())
345        .with_path(config.path().to_string())
346        .with_expiry(expiry)
347        .with_signed(key);
348    match config.domain() {
349        Some(domain) => layer.with_domain(domain.to_string()),
350        None => layer,
351    }
352}
353
354/// A 64-byte session cookie signing key, zeroize-on-drop and redacted in
355/// `Debug`.
356///
357/// This is the master key for the tower-cookies signed jar; it must be kept
358/// secret and stable across requests for sessions to persist. Generate one
359/// per deployment (or derive from a deployment secret) and pass it to
360/// [`SessionConfig::new`].
361#[derive(Clone)]
362pub struct SessionKey {
363    inner: SecretSlice<u8>,
364}
365
366impl SessionKey {
367    /// Generate a 64-byte key from the certified `getrandom` OS RNG.
368    ///
369    /// # Errors
370    ///
371    /// Returns [`SessionConfigError::InvalidSigningKey`] only if the OS RNG
372    /// fails.
373    pub fn generate() -> Result<Self, SessionConfigError> {
374        let mut bytes = vec![0u8; 64];
375        getrandom::fill(&mut bytes).map_err(|_| SessionConfigError::InvalidSigningKey {
376            reason: SigningKeyReason::WrongLength,
377        })?;
378        Ok(Self {
379            inner: SecretSlice::from(bytes),
380        })
381    }
382
383    /// Restore a key from 64 raw bytes.
384    ///
385    /// # Errors
386    ///
387    /// Returns [`SessionConfigError::InvalidSigningKey`] if the slice is not
388    /// exactly 64 bytes.
389    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SessionConfigError> {
390        if bytes.len() != 64 {
391            return Err(SessionConfigError::InvalidSigningKey {
392                reason: SigningKeyReason::WrongLength,
393            });
394        }
395        Ok(Self {
396            inner: SecretSlice::from(bytes.to_vec()),
397        })
398    }
399
400    /// Expose the raw 64 bytes for constructing a `cookie::Key` or a
401    /// [`SessionConfig`].
402    #[must_use]
403    pub fn as_bytes(&self) -> &[u8] {
404        self.inner.expose_secret()
405    }
406}
407
408impl fmt::Debug for SessionKey {
409    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
410        write!(formatter, "SessionKey(<redacted 64-byte key>)")
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use tower_sessions_memory_store::MemoryStore;
418
419    fn fresh_config() -> SessionConfig {
420        SessionConfig::new(&[0u8; 64]).expect("valid key")
421    }
422
423    #[test]
424    fn defaults_are_secure() {
425        let config = fresh_config();
426        assert_eq!(config.cookie_name(), "__Host-id");
427        assert_eq!(config.same_site(), SameSite::Strict);
428        assert!(config.secure());
429        assert!(config.http_only());
430        assert_eq!(config.path(), "/");
431        assert!(config.domain().is_none());
432        assert_eq!(config.max_age(), Duration::from_secs(60 * 60 * 24 * 14));
433        assert_eq!(
434            config.absolute_max_age(),
435            Duration::from_secs(60 * 60 * 24 * 30)
436        );
437        assert!(config.absolute_max_age() > config.max_age());
438    }
439
440    #[test]
441    fn with_absolute_max_age_overrides_default() {
442        let config = fresh_config().with_absolute_max_age(Duration::from_secs(60));
443        assert_eq!(config.absolute_max_age(), Duration::from_secs(60));
444    }
445
446    #[test]
447    fn dev_defaults_are_for_plain_http() {
448        let key = SessionKey::generate().expect("rng");
449        let config = SessionConfig::dev(key.as_bytes()).expect("valid key");
450        assert_eq!(config.cookie_name(), "arcature-id");
451        assert!(!config.secure(), "dev Secure defaults to false");
452        assert!(config.http_only());
453        assert_eq!(config.path(), "/");
454        assert!(config.domain().is_none());
455    }
456
457    #[test]
458    fn debug_redacts_signing_key() {
459        let config = SessionConfig::new(&[0xAB; 64]).expect("valid key");
460        let debug = format!("{config:?}");
461        assert!(debug.contains("<redacted"));
462        assert!(
463            !debug.contains("abab"),
464            "hex key bytes must not leak: {debug}"
465        );
466        assert!(
467            !debug.contains("171"),
468            "decimal key bytes must not leak: {debug}"
469        );
470    }
471
472    #[test]
473    fn rejects_wrong_key_length() {
474        assert!(matches!(
475            SessionConfig::new(&[0u8; 32]),
476            Err(SessionConfigError::InvalidSigningKey { .. })
477        ));
478    }
479
480    #[test]
481    fn rejects_empty_name() {
482        let config = fresh_config().with_cookie_name("");
483        assert!(config.into_layer(MemoryStore::default()).is_err());
484    }
485
486    #[test]
487    fn rejects_zero_max_age() {
488        let config = fresh_config().with_max_age(Duration::ZERO);
489        assert!(config.into_layer(MemoryStore::default()).is_err());
490    }
491
492    #[test]
493    fn rejects_zero_absolute_max_age() {
494        let config = fresh_config().with_absolute_max_age(Duration::ZERO);
495        assert!(config.into_layer(MemoryStore::default()).is_err());
496    }
497
498    #[test]
499    fn production_cookie_name_is_host_prefixed() {
500        let config = fresh_config();
501        assert_eq!(config.cookie_name(), "__Host-id");
502        assert!(config.cookie_name().starts_with("__Host-"));
503        assert!(config.secure());
504    }
505
506    #[test]
507    fn rejects_host_prefixed_cookie_with_secure_false() {
508        let config = fresh_config().with_secure(false);
509        let result = config.into_layer(MemoryStore::default());
510        assert!(result.is_err(), "__Host-id + Secure=false must be rejected");
511    }
512
513    #[test]
514    fn accepts_non_host_cookie_with_secure_false() {
515        let config = fresh_config().with_cookie_name("sid").with_secure(false);
516        assert!(config.into_layer(MemoryStore::default()).is_ok());
517    }
518
519    #[test]
520    fn key_generate_produces_64_bytes() {
521        let key = SessionKey::generate().expect("rng");
522        assert_eq!(key.as_bytes().len(), 64);
523    }
524
525    #[test]
526    fn key_from_bytes_rejects_wrong_length() {
527        assert!(matches!(
528            SessionKey::from_bytes(&[0u8; 32]),
529            Err(SessionConfigError::InvalidSigningKey { .. })
530        ));
531        assert!(SessionKey::from_bytes(&[0u8; 64]).is_ok());
532    }
533
534    #[test]
535    fn key_debug_redacts() {
536        let key = SessionKey::from_bytes(&[0xf0; 64]).expect("64 bytes");
537        let debug = format!("{key:?}");
538        assert!(debug.contains("redacted"));
539        assert!(!debug.contains("f0"), "Debug leaked individual key byte");
540    }
541
542    #[test]
543    fn key_clone_preserves_bytes() {
544        let key = SessionKey::generate().expect("rng");
545        let clone = key.clone();
546        assert_eq!(key.as_bytes(), clone.as_bytes());
547    }
548}