Skip to main content

session_guard/
config.rs

1//! Guard configuration.
2
3use std::{net::IpAddr, time::Duration};
4
5use chrono::TimeDelta;
6use ipnet::IpNet;
7
8use crate::{
9    checks::{Checks, Thresholds},
10    cookie::CookieConfig,
11    error::ConfigError,
12};
13
14/// Longest accepted duration (100 years), so timestamp arithmetic cannot overflow.
15const MAX_DURATION: Duration = Duration::from_hours(100 * 365 * 24);
16
17/// Whether risk decisions are enforced.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
19pub enum Mode {
20    /// Step-up and revoke decisions take effect.
21    #[default]
22    Enforce,
23    /// Decisions are only recorded as events with `enforced = false`; requests pass. Use this to
24    /// tune weights on real traffic before enforcing. Token reuse is always enforced.
25    Shadow,
26}
27
28/// What a step-up decision (score between the step-up and revoke thresholds) does.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30#[non_exhaustive]
31pub enum StepUpPolicy {
32    /// Reject with `step_up_required` until the app re-verifies the subject and calls
33    /// [`Guard::complete_step_up`](crate::Guard::complete_step_up).
34    #[default]
35    Require,
36    /// Revoke the session, as if the revoke threshold had been reached. For apps without a
37    /// re-authentication flow that prefer a fresh login.
38    Revoke,
39    /// Let the request through and record a `step_up_required` event with `enforced = false`.
40    /// For apps without a re-authentication flow that only want revoke-level protection.
41    Allow,
42}
43
44/// Token rotation settings.
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct RotationConfig {
47    /// How old the current token may get before a request rotates it. Default 10 minutes.
48    pub interval: Duration,
49    /// How long a superseded token stays valid, for parallel requests and lost responses.
50    /// Default 30 seconds. Must be shorter than `interval`.
51    pub grace: Duration,
52    /// How long superseded tokens are kept to detect reuse. A stolen token replayed after this
53    /// is simply unknown. Default 7 days.
54    pub reuse_detection: Duration,
55}
56
57impl Default for RotationConfig {
58    fn default() -> Self {
59        Self {
60            interval: Duration::from_mins(10),
61            grace: Duration::from_secs(30),
62            reuse_detection: Duration::from_hours(7 * 24),
63        }
64    }
65}
66
67/// Validated guard configuration. Build with [`GuardConfig::builder`] or take the
68/// [`Default`].
69#[derive(Clone, Debug)]
70pub struct GuardConfig {
71    pub(crate) cookie: CookieConfig,
72    pub(crate) session_lifetime: TimeDelta,
73    pub(crate) idle_timeout: TimeDelta,
74    pub(crate) trusted_proxies: Vec<IpNet>,
75    pub(crate) tls_fingerprint_header: Option<String>,
76    pub(crate) rotation_interval: TimeDelta,
77    pub(crate) rotation_grace: TimeDelta,
78    pub(crate) reuse_detection: TimeDelta,
79    pub(crate) checks: Checks,
80    pub(crate) thresholds: Thresholds,
81    pub(crate) mode: Mode,
82    pub(crate) step_up_policy: StepUpPolicy,
83    pub(crate) ping_pong_window: TimeDelta,
84    pub(crate) last_seen_write_interval: TimeDelta,
85    pub(crate) event_retention: TimeDelta,
86    pub(crate) minimize_event_ip: bool,
87    pub(crate) shadow_dedup_window: TimeDelta,
88    pub(crate) max_header_len: usize,
89    pub(crate) token_prefix: Option<String>,
90}
91
92impl Default for GuardConfig {
93    fn default() -> Self {
94        GuardConfigBuilder::default()
95            .build()
96            .unwrap_or_else(|_| unreachable!("default configuration is valid"))
97    }
98}
99
100impl GuardConfig {
101    /// Starts from the defaults.
102    pub fn builder() -> GuardConfigBuilder {
103        GuardConfigBuilder::default()
104    }
105
106    /// Cookie attributes.
107    pub fn cookie(&self) -> &CookieConfig {
108        &self.cookie
109    }
110
111    /// Absolute session lifetime.
112    pub fn session_lifetime(&self) -> Duration {
113        to_std(self.session_lifetime)
114    }
115
116    /// Inactivity after which a session expires.
117    pub fn idle_timeout(&self) -> Duration {
118        to_std(self.idle_timeout)
119    }
120
121    /// Proxies whose `X-Forwarded-For` is honoured.
122    pub fn trusted_proxies(&self) -> &[IpNet] {
123        &self.trusted_proxies
124    }
125
126    /// Request header carrying a TLS fingerprint (e.g. JA4) set by the proxy, lowercase.
127    pub fn tls_fingerprint_header(&self) -> Option<&str> {
128        self.tls_fingerprint_header.as_deref()
129    }
130
131    /// Rotation settings.
132    pub fn rotation(&self) -> RotationConfig {
133        RotationConfig {
134            interval: to_std(self.rotation_interval),
135            grace: to_std(self.rotation_grace),
136            reuse_detection: to_std(self.reuse_detection),
137        }
138    }
139
140    /// Default checks (routes can override).
141    pub fn checks(&self) -> &Checks {
142        &self.checks
143    }
144
145    /// Score thresholds.
146    pub fn thresholds(&self) -> Thresholds {
147        self.thresholds
148    }
149
150    /// Enforcement mode.
151    pub fn mode(&self) -> Mode {
152        self.mode
153    }
154
155    /// What a step-up decision does.
156    pub fn step_up_policy(&self) -> StepUpPolicy {
157        self.step_up_policy
158    }
159
160    /// Window in which returning to the previous network counts as ping-pong.
161    pub fn ping_pong_window(&self) -> Duration {
162        to_std(self.ping_pong_window)
163    }
164
165    /// How stale `last_seen` may get before an otherwise unchanged request writes it.
166    pub fn last_seen_write_interval(&self) -> Duration {
167        to_std(self.last_seen_write_interval)
168    }
169
170    /// How long events are kept before [`Guard::purge_expired`](crate::Guard::purge_expired)
171    /// deletes them.
172    pub fn event_retention(&self) -> Duration {
173        to_std(self.event_retention)
174    }
175
176    /// Whether events store only the IP prefix (/24 or /48) instead of the full address.
177    pub fn minimize_event_ip(&self) -> bool {
178        self.minimize_event_ip
179    }
180
181    /// Window in which repeated shadow events for the same session and kind are dropped.
182    pub fn shadow_dedup_window(&self) -> Duration {
183        to_std(self.shadow_dedup_window)
184    }
185
186    /// Maximum length of a header value considered for fingerprinting.
187    pub fn max_header_len(&self) -> usize {
188        self.max_header_len
189    }
190
191    /// Prefix of issued tokens, if any.
192    pub fn token_prefix(&self) -> Option<&str> {
193        self.token_prefix.as_deref()
194    }
195}
196
197fn to_std(delta: TimeDelta) -> Duration {
198    delta.to_std().unwrap_or_default()
199}
200
201/// Builder for [`GuardConfig`].
202#[derive(Clone, Debug)]
203#[must_use]
204pub struct GuardConfigBuilder {
205    cookie: CookieConfig,
206    session_lifetime: Duration,
207    idle_timeout: Duration,
208    trusted_proxies: Vec<IpNet>,
209    tls_fingerprint_header: Option<String>,
210    rotation: RotationConfig,
211    checks: Checks,
212    thresholds: Thresholds,
213    mode: Mode,
214    step_up_policy: StepUpPolicy,
215    ping_pong_window: Duration,
216    last_seen_write_interval: Duration,
217    event_retention: Duration,
218    minimize_event_ip: bool,
219    shadow_dedup_window: Duration,
220    max_header_len: usize,
221    token_prefix: Option<String>,
222}
223
224impl Default for GuardConfigBuilder {
225    fn default() -> Self {
226        const MINUTE: u64 = 60;
227        const DAY: u64 = 24 * 60 * MINUTE;
228        Self {
229            cookie: CookieConfig::default(),
230            session_lifetime: Duration::from_secs(30 * DAY),
231            idle_timeout: Duration::from_secs(7 * DAY),
232            trusted_proxies: Vec::new(),
233            tls_fingerprint_header: None,
234            rotation: RotationConfig::default(),
235            checks: Checks::default(),
236            thresholds: Thresholds::default(),
237            mode: Mode::Enforce,
238            step_up_policy: StepUpPolicy::Require,
239            ping_pong_window: Duration::from_secs(5 * MINUTE),
240            last_seen_write_interval: Duration::from_secs(MINUTE),
241            event_retention: Duration::from_secs(90 * DAY),
242            minimize_event_ip: false,
243            shadow_dedup_window: Duration::from_secs(10 * MINUTE),
244            max_header_len: 512,
245            token_prefix: None,
246        }
247    }
248}
249
250impl GuardConfigBuilder {
251    /// Cookie attributes. See [`CookieConfig`].
252    pub fn cookie(mut self, cookie: CookieConfig) -> Self {
253        self.cookie = cookie;
254        self
255    }
256
257    /// Shorthand for changing only the cookie name.
258    pub fn cookie_name(mut self, name: impl Into<String>) -> Self {
259        self.cookie = self.cookie.name(name);
260        self
261    }
262
263    /// Absolute session lifetime, counted from login. Activity, rotation and step-up never extend
264    /// it; the subject must log in again. Default 30 days.
265    pub fn session_lifetime(mut self, lifetime: Duration) -> Self {
266        self.session_lifetime = lifetime;
267        self
268    }
269
270    /// Inactivity timeout. Default 7 days.
271    pub fn idle_timeout(mut self, timeout: Duration) -> Self {
272        self.idle_timeout = timeout;
273        self
274    }
275
276    /// Reverse proxies (IP addresses or CIDR ranges) whose `X-Forwarded-For` is honoured.
277    /// Without any, the TCP peer address is the client address.
278    ///
279    /// # Errors
280    ///
281    /// [`ConfigError::InvalidProxy`] for an entry that is not an IP or CIDR range.
282    pub fn trusted_proxies<I, T>(mut self, proxies: I) -> Result<Self, ConfigError>
283    where
284        I: IntoIterator<Item = T>,
285        T: AsRef<str>,
286    {
287        self.trusted_proxies = proxies
288            .into_iter()
289            .map(|p| {
290                let p = p.as_ref().trim();
291                p.parse::<IpNet>()
292                    .or_else(|_| p.parse::<IpAddr>().map(IpNet::from))
293                    .map(|net| net.trunc())
294                    .map_err(|_| ConfigError::InvalidProxy(p.to_owned()))
295            })
296            .collect::<Result<_, _>>()?;
297        Ok(self)
298    }
299
300    /// Header with a TLS fingerprint (e.g. JA4) set by your proxy. Enables
301    /// [`Check::TlsChanged`](crate::Check::TlsChanged). Only use a header your proxy
302    /// overwrites on every request.
303    pub fn tls_fingerprint_header(mut self, header: impl Into<String>) -> Self {
304        self.tls_fingerprint_header = Some(header.into().to_ascii_lowercase());
305        self
306    }
307
308    /// Token rotation settings.
309    pub fn rotation(mut self, rotation: RotationConfig) -> Self {
310        self.rotation = rotation;
311        self
312    }
313
314    /// Checks and weights. Default: all checks at default weights.
315    pub fn checks(mut self, checks: Checks) -> Self {
316        self.checks = checks;
317        self
318    }
319
320    /// Score thresholds. Default: step-up at 40, revoke at 80.
321    pub fn thresholds(mut self, thresholds: Thresholds) -> Self {
322        self.thresholds = thresholds;
323        self
324    }
325
326    /// Enforce or shadow mode. Default enforce.
327    pub fn mode(mut self, mode: Mode) -> Self {
328        self.mode = mode;
329        self
330    }
331
332    /// What a step-up decision does. Default [`StepUpPolicy::Require`].
333    pub fn step_up_policy(mut self, policy: StepUpPolicy) -> Self {
334        self.step_up_policy = policy;
335        self
336    }
337
338    /// Ping-pong window. Default 5 minutes. Zero disables ping-pong detection.
339    pub fn ping_pong_window(mut self, window: Duration) -> Self {
340        self.ping_pong_window = window;
341        self
342    }
343
344    /// How stale `last_seen` may get before an unchanged request writes it. Default 60 seconds.
345    /// Zero writes on every request.
346    pub fn last_seen_write_interval(mut self, interval: Duration) -> Self {
347        self.last_seen_write_interval = interval;
348        self
349    }
350
351    /// Event retention. Default 90 days.
352    pub fn event_retention(mut self, retention: Duration) -> Self {
353        self.event_retention = retention;
354        self
355    }
356
357    /// Store only the IP prefix (/24 or /48) in events. Default off.
358    pub fn minimize_event_ip(mut self, minimize: bool) -> Self {
359        self.minimize_event_ip = minimize;
360        self
361    }
362
363    /// Shadow event dedup window. Default 10 minutes. Zero records every shadow event.
364    pub fn shadow_dedup_window(mut self, window: Duration) -> Self {
365        self.shadow_dedup_window = window;
366        self
367    }
368
369    /// Maximum header value length considered. Longer values are truncated. Default 512.
370    pub fn max_header_len(mut self, len: usize) -> Self {
371        self.max_header_len = len;
372        self
373    }
374
375    /// Prefix for issued tokens, such as `"sess_"`. Default none.
376    ///
377    /// The cookie value becomes the prefix followed by the usual 43 characters, and any presented
378    /// token without the prefix is rejected before the store is consulted. Use it to tell browser
379    /// sessions apart from other credentials at a glance (for example `pat_` and `svc_` API keys
380    /// handled elsewhere), and to let secret scanners recognise leaked tokens. The prefix is not
381    /// secret and is not part of the stored hash.
382    pub fn token_prefix(mut self, prefix: impl Into<String>) -> Self {
383        self.token_prefix = Some(prefix.into());
384        self
385    }
386
387    /// Validates and builds.
388    ///
389    /// # Errors
390    ///
391    /// [`ConfigError`] for invalid cookie attributes, zero or oversized durations, a grace not
392    /// shorter than the rotation interval, invalid thresholds, header names or token prefix.
393    pub fn build(self) -> Result<GuardConfig, ConfigError> {
394        self.cookie.validate()?;
395        if !self.thresholds.is_valid() {
396            return Err(ConfigError::InvalidThresholds);
397        }
398        if self.rotation.grace >= self.rotation.interval {
399            return Err(ConfigError::GraceTooLong);
400        }
401        if self.max_header_len == 0 {
402            return Err(ConfigError::InvalidDuration("max_header_len"));
403        }
404        if let Some(h) = &self.tls_fingerprint_header
405            && (h.is_empty() || !h.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
406        {
407            return Err(ConfigError::InvalidHeaderName(h.clone()));
408        }
409        if let Some(p) = &self.token_prefix
410            && (p.is_empty() || p.len() > 32 || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
411        {
412            return Err(ConfigError::InvalidTokenPrefix(p.clone()));
413        }
414        Ok(GuardConfig {
415            cookie: self.cookie,
416            session_lifetime: positive(self.session_lifetime, "session_lifetime")?,
417            idle_timeout: positive(self.idle_timeout, "idle_timeout")?,
418            trusted_proxies: self.trusted_proxies,
419            tls_fingerprint_header: self.tls_fingerprint_header,
420            rotation_interval: positive(self.rotation.interval, "rotation.interval")?,
421            rotation_grace: bounded(self.rotation.grace, "rotation.grace")?,
422            reuse_detection: positive(self.rotation.reuse_detection, "rotation.reuse_detection")?,
423            checks: self.checks,
424            thresholds: self.thresholds,
425            mode: self.mode,
426            step_up_policy: self.step_up_policy,
427            ping_pong_window: bounded(self.ping_pong_window, "ping_pong_window")?,
428            last_seen_write_interval: bounded(self.last_seen_write_interval, "last_seen_write_interval")?,
429            event_retention: positive(self.event_retention, "event_retention")?,
430            minimize_event_ip: self.minimize_event_ip,
431            shadow_dedup_window: bounded(self.shadow_dedup_window, "shadow_dedup_window")?,
432            max_header_len: self.max_header_len,
433            token_prefix: self.token_prefix,
434        })
435    }
436}
437
438fn positive(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
439    if d.is_zero() {
440        return Err(ConfigError::InvalidDuration(name));
441    }
442    bounded(d, name)
443}
444
445fn bounded(d: Duration, name: &'static str) -> Result<TimeDelta, ConfigError> {
446    if d > MAX_DURATION {
447        return Err(ConfigError::InvalidDuration(name));
448    }
449    TimeDelta::from_std(d).map_err(|_| ConfigError::InvalidDuration(name))
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::checks::Check;
456
457    #[test]
458    fn defaults_match_the_spec() {
459        let c = GuardConfig::default();
460        assert_eq!(c.cookie().cookie_name(), "__Host-sid");
461        assert_eq!(c.session_lifetime(), Duration::from_hours(30 * 24));
462        assert_eq!(c.idle_timeout(), Duration::from_hours(7 * 24));
463        assert_eq!(c.rotation(), RotationConfig::default());
464        assert_eq!(c.rotation().interval, Duration::from_secs(600));
465        assert_eq!(c.rotation().grace, Duration::from_secs(30));
466        assert_eq!(c.mode(), Mode::Enforce);
467        assert_eq!(c.step_up_policy(), StepUpPolicy::Require);
468        assert_eq!(c.checks(), &Checks::default());
469        assert_eq!(c.thresholds(), Thresholds::default());
470        assert_eq!(c.event_retention(), Duration::from_hours(90 * 24));
471        assert!(!c.minimize_event_ip());
472        assert_eq!(c.trusted_proxies(), &[]);
473    }
474
475    #[test]
476    fn builder_sets_everything() {
477        let c = GuardConfig::builder()
478            .cookie_name("__Host-app")
479            .session_lifetime(Duration::from_secs(3600))
480            .idle_timeout(Duration::from_secs(600))
481            .trusted_proxies(["10.0.0.0/8", "::1", " 192.168.1.7 "])
482            .unwrap()
483            .tls_fingerprint_header("X-JA4")
484            .checks(Checks::default().disable(Check::AsnChanged))
485            .thresholds(Thresholds {
486                step_up: 30,
487                revoke: 90,
488            })
489            .mode(Mode::Shadow)
490            .step_up_policy(StepUpPolicy::Allow)
491            .minimize_event_ip(true)
492            .build()
493            .unwrap();
494        assert_eq!(c.cookie().cookie_name(), "__Host-app");
495        assert_eq!(c.trusted_proxies().len(), 3);
496        assert!(
497            c.trusted_proxies()
498                .iter()
499                .any(|n| n.contains(&"10.1.2.3".parse::<IpAddr>().unwrap()))
500        );
501        assert!(
502            c.trusted_proxies()
503                .iter()
504                .any(|n| n.contains(&"192.168.1.7".parse::<IpAddr>().unwrap()))
505        );
506        assert!(
507            !c.trusted_proxies()
508                .iter()
509                .any(|n| n.contains(&"192.168.1.8".parse::<IpAddr>().unwrap()))
510        );
511        assert_eq!(c.tls_fingerprint_header(), Some("x-ja4"));
512        assert_eq!(c.mode(), Mode::Shadow);
513        assert_eq!(c.step_up_policy(), StepUpPolicy::Allow);
514        assert!(c.minimize_event_ip());
515    }
516
517    #[test]
518    fn invalid_configs_are_rejected() {
519        let b = GuardConfig::builder;
520        assert_eq!(
521            b().trusted_proxies(["nope"]).unwrap_err(),
522            ConfigError::InvalidProxy("nope".into())
523        );
524        assert!(matches!(
525            b().cookie(CookieConfig::default().secure(false)).build(),
526            Err(ConfigError::InvalidCookie(_))
527        ));
528        assert_eq!(
529            b().thresholds(Thresholds {
530                step_up: 90,
531                revoke: 80
532            })
533            .build()
534            .unwrap_err(),
535            ConfigError::InvalidThresholds
536        );
537        assert_eq!(
538            b().rotation(RotationConfig {
539                grace: Duration::from_secs(600),
540                ..RotationConfig::default()
541            })
542            .build()
543            .unwrap_err(),
544            ConfigError::GraceTooLong
545        );
546        assert_eq!(
547            b().session_lifetime(Duration::ZERO).build().unwrap_err(),
548            ConfigError::InvalidDuration("session_lifetime")
549        );
550        assert_eq!(
551            b().idle_timeout(Duration::MAX).build().unwrap_err(),
552            ConfigError::InvalidDuration("idle_timeout")
553        );
554        assert!(matches!(
555            b().tls_fingerprint_header("x ja4").build(),
556            Err(ConfigError::InvalidHeaderName(_))
557        ));
558    }
559
560    #[test]
561    fn token_prefix_is_optional_and_validated() {
562        assert_eq!(GuardConfig::default().token_prefix(), None);
563        let c = GuardConfig::builder().token_prefix("sess_").build().unwrap();
564        assert_eq!(c.token_prefix(), Some("sess_"));
565        for bad in ["", "se ss_", "sess;", "sess=", "é_", &"a".repeat(33)] {
566            assert_eq!(
567                GuardConfig::builder().token_prefix(bad).build().unwrap_err(),
568                ConfigError::InvalidTokenPrefix(bad.to_owned()),
569                "{bad:?}"
570            );
571        }
572    }
573
574    #[test]
575    fn zero_is_allowed_where_it_means_off() {
576        let c = GuardConfig::builder()
577            .ping_pong_window(Duration::ZERO)
578            .last_seen_write_interval(Duration::ZERO)
579            .shadow_dedup_window(Duration::ZERO)
580            .rotation(RotationConfig {
581                grace: Duration::ZERO,
582                ..RotationConfig::default()
583            })
584            .build()
585            .unwrap();
586        assert_eq!(c.rotation().grace, Duration::ZERO);
587    }
588}