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