entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
//! Session token management with expiry and idle timeout.
//!
//! Provides session creation, validation, and refresh operations. Each session
//! stores a SHA-256 hash of the opaque token (never the token itself) and
//! tracks creation time, expiry, and last-activity timestamps for idle timeout
//! enforcement.
//!
//! # Security
//!
//! - Session tokens are generated from the platform CSPRNG via [`generate_token_with_hash`].
//! - Only the SHA-256 hash of the token is stored in the session; the raw token
//!   is returned to the caller exactly once at creation (and optionally at refresh).
//! - Token validation re-hashes the presented token and compares in constant time.
//! - Expired and idle sessions are rejected without leaking timing information
//!   about which check failed first.

pub mod token;

use std::fmt;

use crate::crypto::constant_time::constant_time_eq;
use crate::crypto::zeroize::Zeroizing;
use crate::session::token::{generate_token_with_hash, hash_token_for_lookup};
use crate::util::log::{debug, info, warn};
use crate::util::timestamp::Timestamp;

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

/// Configuration for session creation and management.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionConfig {
    /// Number of random bytes for the session token. Default: 32.
    token_bytes: usize,
    /// Session lifetime in seconds from creation. Default: 3600 (1 hour).
    lifetime_secs: u64,
    /// Idle timeout in seconds since last activity. Default: 1800 (30 minutes).
    idle_timeout_secs: u64,
    /// Whether sessions can be refreshed with a new token. Default: true.
    allow_refresh: bool,
    /// Maximum number of times a session can be refreshed. Default: 5.
    max_refreshes: u32,
}

impl Default for SessionConfig {
    fn default() -> Self {
        Self {
            token_bytes: 32,
            lifetime_secs: 3600,
            idle_timeout_secs: 1800,
            allow_refresh: true,
            max_refreshes: 5,
        }
    }
}

impl SessionConfig {
    /// Creates a new `SessionConfig` with sensible defaults.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Minimum token size in bytes (128 bits).
    const MIN_TOKEN_BYTES: usize = 16;

    /// Sets the token size in random bytes.
    ///
    /// Values below the minimum of 16 bytes (128 bits) are clamped to 16.
    #[must_use]
    pub fn with_token_bytes(mut self, token_bytes: usize) -> Self {
        self.token_bytes = token_bytes.max(Self::MIN_TOKEN_BYTES);
        self
    }

    /// Sets the session lifetime in seconds from creation.
    ///
    /// A value of `0` makes every session expire the instant it is created
    /// (the absolute deadline equals `created_at`, and expiry is checked as
    /// "deadline at or before now"), so a session minted with `0` never
    /// validates. Use a positive value.
    #[must_use]
    pub fn with_lifetime_secs(mut self, lifetime_secs: u64) -> Self {
        self.lifetime_secs = lifetime_secs;
        self
    }

    /// Sets the idle timeout in seconds since last activity.
    ///
    /// As with [`with_lifetime_secs`](Self::with_lifetime_secs), a value of
    /// `0` causes a freshly-minted session to be treated as idle-expired
    /// immediately. Use a positive value.
    #[must_use]
    pub fn with_idle_timeout_secs(mut self, idle_timeout_secs: u64) -> Self {
        self.idle_timeout_secs = idle_timeout_secs;
        self
    }

    /// Sets whether sessions can be refreshed.
    #[must_use]
    pub fn with_allow_refresh(mut self, allow_refresh: bool) -> Self {
        self.allow_refresh = allow_refresh;
        self
    }

    /// Sets the maximum number of refreshes.
    #[must_use]
    pub fn with_max_refreshes(mut self, max_refreshes: u32) -> Self {
        self.max_refreshes = max_refreshes;
        self
    }

    /// Returns the configured token size in bytes.
    #[must_use]
    #[inline]
    pub fn token_bytes(&self) -> usize {
        self.token_bytes
    }

    /// Returns the configured session lifetime in seconds.
    #[must_use]
    #[inline]
    pub fn lifetime_secs(&self) -> u64 {
        self.lifetime_secs
    }

    /// Returns the configured idle timeout in seconds.
    #[must_use]
    #[inline]
    pub fn idle_timeout_secs(&self) -> u64 {
        self.idle_timeout_secs
    }

    /// Returns whether refresh is allowed.
    #[must_use]
    #[inline]
    pub fn allow_refresh(&self) -> bool {
        self.allow_refresh
    }

    /// Returns the maximum refresh count.
    #[must_use]
    #[inline]
    pub fn max_refreshes(&self) -> u32 {
        self.max_refreshes
    }
}

// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------

/// A server-side session that stores the token hash, not the token itself.
///
/// The opaque token is returned to the caller only at creation time (and
/// optionally at refresh). The server stores this struct and validates
/// incoming tokens by re-hashing and comparing in constant time.
#[derive(Debug, Clone)]
pub struct Session {
    token_hash: [u8; 32],
    created_at: Timestamp,
    expires_at: Timestamp,
    last_activity: Timestamp,
    refresh_count: u32,
    max_refreshes: u32,
    allow_refresh: bool,
    idle_timeout_secs: u64,
}

/// Result of validating a session token.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionValidation {
    /// The token is valid and the session is active.
    Valid,
    /// The session has exceeded its absolute lifetime.
    Expired,
    /// The session has exceeded its idle timeout.
    IdleTimeout,
    /// The presented token does not match the stored hash.
    InvalidToken,
}

impl Session {
    /// Creates a new session and returns the opaque token string.
    ///
    /// The token is generated from the platform CSPRNG and its SHA-256
    /// hash is stored in the session. The raw token is returned to the
    /// caller for transmission to the client.
    ///
    /// # Errors
    ///
    /// Returns [`SessionError`] if the CSPRNG is unavailable.
    pub fn create(config: &SessionConfig) -> Result<(Zeroizing<String>, Self), SessionError> {
        let (token, token_hash) = generate_token_with_hash(config.token_bytes)
            .map_err(|_| SessionError::new(SessionErrorKind::RandomFailure))?;

        let now = Timestamp::now();
        let expires_at =
            Timestamp::from_unix_secs(now.unix_epoch_secs().saturating_add(config.lifetime_secs));

        let session = Self {
            token_hash,
            created_at: now,
            expires_at,
            last_activity: now,
            refresh_count: 0,
            max_refreshes: config.max_refreshes,
            allow_refresh: config.allow_refresh,
            idle_timeout_secs: config.idle_timeout_secs,
        };

        // SECURITY: Do NOT log the token value.
        info!(
            lifetime_secs = config.lifetime_secs,
            idle_timeout_secs = config.idle_timeout_secs,
            "session: created"
        );

        Ok((token, session))
    }

    /// Validates a presented token against this session.
    ///
    /// Checks (in order):
    /// 1. Token hash matches (constant-time comparison).
    /// 2. Session has not exceeded its absolute lifetime.
    /// 3. Session has not exceeded its idle timeout.
    ///
    /// Returns a [`SessionValidation`] variant indicating the result.
    ///
    /// This takes `&self` and **does not** count as activity: a successful
    /// `validate` leaves `last_activity` unchanged, so the idle window is
    /// measured from session creation or the last [`refresh`](Self::refresh),
    /// not from the last validation. Call `refresh` to record activity and
    /// slide the idle window forward.
    // SECURITY: The token is hashed with SHA-256 before comparison and
    // the comparison uses constant_time_eq to prevent timing attacks.
    // All checks are performed regardless of earlier failures to avoid
    // leaking information about which check would have failed.
    #[must_use]
    #[inline]
    pub fn validate(&self, token: &str, now: &Timestamp) -> SessionValidation {
        // SECURITY: Hash the presented token and compare in constant time.
        let token_valid = self.verify_token(token);

        // Check absolute expiry.
        let expired = self.expires_at.is_expired(now);

        // Check idle timeout.
        let idle_deadline = Timestamp::from_unix_secs(
            self.last_activity
                .unix_epoch_secs()
                .saturating_add(self.idle_timeout_secs),
        );
        let idle_expired = idle_deadline.is_expired(now);

        // Return the most specific failure reason, but only after all
        // checks have been performed to avoid timing side-channels.
        if !token_valid {
            // SECURITY: Do NOT log the token value.
            warn!("session: validation failed (invalid token)");
            SessionValidation::InvalidToken
        } else if expired {
            warn!("session: validation failed (expired)");
            SessionValidation::Expired
        } else if idle_expired {
            warn!("session: validation failed (idle timeout)");
            SessionValidation::IdleTimeout
        } else {
            debug!("session: validated successfully");
            SessionValidation::Valid
        }
    }

    /// Refreshes the session with a new token.
    ///
    /// Generates a new random token, updates the stored hash, resets the
    /// last-activity timestamp, and returns the new token to the caller.
    ///
    /// # Errors
    ///
    /// Returns [`SessionError`] if:
    /// - Refresh is not allowed for this session.
    /// - The maximum number of refreshes has been reached.
    /// - The session is already past its absolute lifetime or idle timeout.
    /// - The CSPRNG is unavailable.
    ///
    /// # Security
    ///
    /// Refresh is rejected for a session that has already lapsed (absolute
    /// expiry or idle timeout). Without this, refreshing a dead session would
    /// silently resurrect it — sliding the idle window forward from an
    /// already-timed-out session and defeating the inactivity re-auth control.
    /// (The absolute `expires_at` is never extended by refresh regardless.)
    pub fn refresh(
        &mut self,
        config: &SessionConfig,
        now: &Timestamp,
    ) -> Result<Zeroizing<String>, SessionError> {
        if !self.allow_refresh {
            warn!("session: refresh rejected (not allowed)");
            return Err(SessionError::new(SessionErrorKind::RefreshNotAllowed));
        }

        if self.refresh_count >= self.max_refreshes {
            warn!("session: refresh rejected (max refreshes exceeded)");
            return Err(SessionError::new(SessionErrorKind::MaxRefreshesExceeded));
        }

        // SECURITY: never resurrect a lapsed session. A session past its
        // absolute lifetime or idle deadline must re-authenticate.
        if self.expires_at.is_expired(now) {
            warn!("session: refresh rejected (expired)");
            return Err(SessionError::new(SessionErrorKind::Expired));
        }
        let idle_deadline = Timestamp::from_unix_secs(
            self.last_activity
                .unix_epoch_secs()
                .saturating_add(self.idle_timeout_secs),
        );
        if idle_deadline.is_expired(now) {
            warn!("session: refresh rejected (idle timeout)");
            return Err(SessionError::new(SessionErrorKind::IdleTimeout));
        }

        let (token, token_hash) = generate_token_with_hash(config.token_bytes)
            .map_err(|_| SessionError::new(SessionErrorKind::RandomFailure))?;

        self.token_hash = token_hash;
        self.last_activity = *now;
        self.refresh_count += 1;

        // SECURITY: Do NOT log the token value.
        debug!(
            count = self.refresh_count,
            max = self.max_refreshes,
            "session: refreshed"
        );

        Ok(token)
    }

    /// Returns the SHA-256 hash of the session token.
    #[must_use]
    pub fn token_hash(&self) -> &[u8; 32] {
        &self.token_hash
    }

    /// Returns the timestamp when the session was created.
    #[must_use]
    pub fn created_at(&self) -> &Timestamp {
        &self.created_at
    }

    /// Returns the timestamp when the session expires.
    #[must_use]
    pub fn expires_at(&self) -> &Timestamp {
        &self.expires_at
    }

    /// Verifies a hex-encoded token against the stored hash.
    ///
    /// Returns `true` if the token matches, `false` otherwise.
    // SECURITY: delegate to `hash_token_for_lookup` so mint, server-side lookup,
    // and this in-process check all share one hashing scheme — they can never
    // drift apart. The final comparison is constant time. The hashing step
    // itself is *not* constant time across hex vs non-hex input (the hex path
    // decodes then hashes len/2 bytes; the UTF-8 fallback hashes len bytes and
    // hex decoding short-circuits on the first invalid nibble) — but that
    // timing reveals only token *well-formedness*, never any secret byte of the
    // stored hash, which is the property that matters here.
    fn verify_token(&self, token: &str) -> bool {
        constant_time_eq(&hash_token_for_lookup(token), &self.token_hash)
    }
}

// ---------------------------------------------------------------------------
// Error Type
// ---------------------------------------------------------------------------

/// Kinds of session errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionErrorKind {
    /// The platform CSPRNG is unavailable.
    RandomFailure,
    /// Refresh is not allowed for this session.
    RefreshNotAllowed,
    /// The maximum number of refreshes has been reached.
    MaxRefreshesExceeded,
    /// The session is past its absolute lifetime and cannot be refreshed.
    Expired,
    /// The session has crossed its idle timeout and cannot be refreshed.
    IdleTimeout,
}

/// Error returned by session operations.
///
/// Error messages never contain secret material (tokens, hashes).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionError {
    kind: SessionErrorKind,
}

impl SessionError {
    const fn new(kind: SessionErrorKind) -> Self {
        Self { kind }
    }
}

impl fmt::Display for SessionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.kind {
            SessionErrorKind::RandomFailure => {
                write!(f, "session: random number generation failed")
            }
            SessionErrorKind::RefreshNotAllowed => {
                write!(f, "session: refresh not allowed")
            }
            SessionErrorKind::MaxRefreshesExceeded => {
                write!(f, "session: maximum refresh count exceeded")
            }
            SessionErrorKind::Expired => {
                write!(f, "session: cannot refresh an expired session")
            }
            SessionErrorKind::IdleTimeout => {
                write!(f, "session: cannot refresh an idle-timed-out session")
            }
        }
    }
}

impl std::error::Error for SessionError {}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    /// Default test config with short lifetime for testing.
    fn test_config() -> SessionConfig {
        SessionConfig::new()
            .with_token_bytes(32)
            .with_lifetime_secs(3600)
            .with_idle_timeout_secs(1800)
            .with_allow_refresh(true)
            .with_max_refreshes(5)
    }

    // --- Create and Validate ---

    #[test]
    fn create_and_validate_valid_session() {
        let config = test_config();
        let (token, session) = Session::create(&config).unwrap();

        // Validate immediately (should be valid).
        let now = Timestamp::now();
        assert_eq!(session.validate(&token, &now), SessionValidation::Valid);
    }

    #[test]
    fn create_produces_non_empty_token() {
        let config = test_config();
        let (token, _session) = Session::create(&config).unwrap();
        assert!(!token.is_empty());
        assert_eq!(
            token.len(),
            config.token_bytes * 2,
            "hex-encoded token length"
        );
    }

    #[test]
    fn validate_rejects_invalid_token() {
        let config = test_config();
        let (_token, session) = Session::create(&config).unwrap();

        let now = Timestamp::now();
        assert_eq!(
            session.validate(
                "0000000000000000000000000000000000000000000000000000000000000000",
                &now
            ),
            SessionValidation::InvalidToken,
        );
    }

    #[test]
    fn validate_rejects_malformed_token() {
        let config = test_config();
        let (_token, session) = Session::create(&config).unwrap();

        let now = Timestamp::now();
        assert_eq!(
            session.validate("not-valid-hex!!!", &now),
            SessionValidation::InvalidToken,
        );
    }

    // --- Expiry ---

    #[test]
    fn validate_detects_expired_session() {
        let config = test_config();
        let (token, session) = Session::create(&config).unwrap();

        // Simulate time far in the future (beyond lifetime).
        let future = Timestamp::from_unix_secs(
            session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
        );
        assert_eq!(
            session.validate(&token, &future),
            SessionValidation::Expired
        );
    }

    // --- Idle Timeout ---

    #[test]
    fn validate_detects_idle_timeout() {
        let config = test_config()
            .with_idle_timeout_secs(60)
            .with_lifetime_secs(3600);
        let (token, session) = Session::create(&config).unwrap();

        // Simulate time just past idle timeout but within lifetime.
        let idle_expired = Timestamp::from_unix_secs(session.created_at().unix_epoch_secs() + 61);
        assert_eq!(
            session.validate(&token, &idle_expired),
            SessionValidation::IdleTimeout,
        );
    }

    // --- Failure-reason precedence ---
    //
    // `validate` runs all three checks but reports a fixed-priority reason
    // (token > absolute expiry > idle). These pin that ordering so a refactor
    // of the `if/else if` chain cannot silently change which reason callers
    // see when more than one condition fails at once.

    #[test]
    fn validate_invalid_token_wins_over_expiry() {
        let config = test_config();
        let (_token, session) = Session::create(&config).unwrap();

        // Past lifetime AND wrong token: the token check takes precedence.
        let future = Timestamp::from_unix_secs(
            session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
        );
        assert_eq!(
            session.validate(
                "0000000000000000000000000000000000000000000000000000000000000000",
                &future
            ),
            SessionValidation::InvalidToken,
        );
    }

    #[test]
    fn validate_expiry_wins_over_idle() {
        // Lifetime (3600) > idle (1800); a time past both must report
        // `Expired`, not `IdleTimeout`.
        let config = test_config();
        let (token, session) = Session::create(&config).unwrap();

        let past_both = Timestamp::from_unix_secs(
            session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
        );
        assert_eq!(
            session.validate(&token, &past_both),
            SessionValidation::Expired,
        );
    }

    // --- Refresh ---

    #[test]
    fn refresh_produces_new_valid_token() {
        let config = test_config();
        let (old_token, mut session) = Session::create(&config).unwrap();

        let now = Timestamp::now();
        let new_token = session.refresh(&config, &now).unwrap();

        // Old token should no longer work.
        assert_eq!(
            session.validate(&old_token, &now),
            SessionValidation::InvalidToken,
        );

        // New token should work.
        assert_eq!(session.validate(&new_token, &now), SessionValidation::Valid);
    }

    #[test]
    fn refresh_respects_max_refreshes() {
        let config = SessionConfig {
            max_refreshes: 2,
            ..test_config()
        };
        let (_token, mut session) = Session::create(&config).unwrap();
        let now = Timestamp::now();

        // First two refreshes should succeed.
        session.refresh(&config, &now).unwrap();
        session.refresh(&config, &now).unwrap();

        // Third refresh should fail.
        assert_eq!(
            session.refresh(&config, &now).unwrap_err(),
            SessionError::new(SessionErrorKind::MaxRefreshesExceeded),
        );
    }

    #[test]
    fn refresh_rejected_when_not_allowed() {
        let config = SessionConfig {
            allow_refresh: false,
            ..test_config()
        };
        let (_token, mut session) = Session::create(&config).unwrap();
        let now = Timestamp::now();

        assert_eq!(
            session.refresh(&config, &now).unwrap_err(),
            SessionError::new(SessionErrorKind::RefreshNotAllowed),
        );
    }

    #[test]
    fn refresh_rejected_when_expired() {
        // SECURITY: refreshing a session past its absolute lifetime must fail,
        // not resurrect it.
        let config = test_config();
        let (_token, mut session) = Session::create(&config).unwrap();
        let future = Timestamp::from_unix_secs(
            session.created_at().unix_epoch_secs() + config.lifetime_secs + 1,
        );
        assert_eq!(
            session.refresh(&config, &future).unwrap_err(),
            SessionError::new(SessionErrorKind::Expired),
        );
    }

    #[test]
    fn refresh_rejected_when_idle_timed_out() {
        // SECURITY: an idle-timed-out session must re-authenticate, not slide
        // its idle window forward via refresh.
        let config = test_config()
            .with_idle_timeout_secs(60)
            .with_lifetime_secs(3600);
        let (_token, mut session) = Session::create(&config).unwrap();
        let idle = Timestamp::from_unix_secs(session.created_at().unix_epoch_secs() + 61);
        assert_eq!(
            session.refresh(&config, &idle).unwrap_err(),
            SessionError::new(SessionErrorKind::IdleTimeout),
        );
    }

    // --- Accessors ---

    #[test]
    fn accessors_return_expected_values() {
        let config = test_config();
        let (_token, session) = Session::create(&config).unwrap();

        assert_eq!(session.token_hash().len(), 32);
        assert!(session.created_at().unix_epoch_secs() > 0);
        assert!(session.expires_at().unix_epoch_secs() > session.created_at().unix_epoch_secs());
    }

    // --- Error Display ---

    #[test]
    fn error_display_no_secrets() {
        let errors = [
            SessionError::new(SessionErrorKind::RandomFailure),
            SessionError::new(SessionErrorKind::RefreshNotAllowed),
            SessionError::new(SessionErrorKind::MaxRefreshesExceeded),
        ];
        for err in &errors {
            let msg = err.to_string();
            assert!(
                msg.starts_with("session:"),
                "error should be prefixed: {msg}"
            );
        }
    }

    #[test]
    fn error_implements_std_error() {
        let err: Box<dyn std::error::Error> =
            Box::new(SessionError::new(SessionErrorKind::RandomFailure));
        let _ = err.to_string();
    }

    // --- Default Config ---

    #[test]
    fn default_config_values() {
        let config = SessionConfig::default();
        assert_eq!(config.token_bytes, 32);
        assert_eq!(config.lifetime_secs, 3600);
        assert_eq!(config.idle_timeout_secs, 1800);
        assert!(config.allow_refresh);
        assert_eq!(config.max_refreshes, 5);
    }

    // --- Config Builder ---

    #[test]
    fn config_builder_pattern() {
        let config = SessionConfig::new()
            .with_token_bytes(64)
            .with_lifetime_secs(7200)
            .with_idle_timeout_secs(900)
            .with_allow_refresh(false)
            .with_max_refreshes(10);
        assert_eq!(config.token_bytes, 64);
        assert_eq!(config.lifetime_secs, 7200);
        assert_eq!(config.idle_timeout_secs, 900);
        assert!(!config.allow_refresh);
        assert_eq!(config.max_refreshes, 10);
    }

    #[test]
    fn with_token_bytes_clamps_to_minimum() {
        // SECURITY: token entropy must never drop below 128 bits, so values
        // under 16 bytes are clamped up rather than honored.
        assert_eq!(SessionConfig::new().with_token_bytes(0).token_bytes(), 16);
        assert_eq!(SessionConfig::new().with_token_bytes(1).token_bytes(), 16);
        assert_eq!(SessionConfig::new().with_token_bytes(15).token_bytes(), 16);
        // Values at or above the floor pass through unchanged.
        assert_eq!(SessionConfig::new().with_token_bytes(16).token_bytes(), 16);
        assert_eq!(SessionConfig::new().with_token_bytes(48).token_bytes(), 48);
    }
}