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