webgates 1.0.0

Application-facing composition crate for webgates authentication and authorization.
Documentation
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
//! Framework-agnostic bearer gate configuration.
//!
//! This module holds the shared builder/configuration for bearer authentication
//! (JWT or static token) without pulling any web-framework or middleware
//! dependencies. Integration crates (e.g., `webgates-axum`) consume these types
//! and adapt them into concrete middleware/layers.
//!
//! The design mirrors the previous Axum-specific gate but keeps the core free of
//! `tower`/`http` dependencies. Adapters can map the configuration into their own
//! middleware types via the `BearerGateAdapter` trait.
//!
//! # Example — Implementing simple in-crate `BearerGateAdapter`s
//!
//! The core crate exposes `BearerGate`, runtime evaluators (`JwtBearerRuntime` and
//! `StaticTokenRuntime`), and the `BearerGateAdapter` trait. Integration code can
//! implement `BearerGateAdapter` to convert a configured `BearerGate` into a
//! framework- or application-specific artifact.
//!
//! The examples below demonstrate minimal, in-crate adapters that return the
//! appropriate runtime evaluators. This keeps the example focused on types within
//! the current crate and shows how adapters can reuse the runtime evaluation
//! without reimplementing validation logic.
//!
//! ```rust
//! use std::sync::Arc;
//! use webgates::accounts::Account;
//! use webgates::authz::access_hierarchy::AccessHierarchy;
//! use webgates::groups::Group;
//! use webgates::roles::Role;
//! use webgates_codecs::jwt::{JsonWebToken, JwtClaims};
//! use webgates::gate::GateExt;
//! use webgates::gate::bearer::{BearerGate, BearerGateAdapter, JwtBearerRuntime, StaticTokenRuntime};
//!
//! /// Adapter producing a JWT bearer runtime evaluator.
//! struct JwtRuntimeAdapter;
//!
//! impl<C, R, G> BearerGateAdapter<C, R, G, webgates::gate::bearer::JwtConfig<R, G>> for JwtRuntimeAdapter
//! where
//!     C: webgates::codecs::Codec<Payload = JwtClaims<Account<R, G>>>,
//!     R: AccessHierarchy + Eq + std::fmt::Display + Clone,
//!     G: Eq + Clone,
//! {
//!     type Output = JwtBearerRuntime<C, R, G>;
//!
//!     fn adapt(&self, gate: BearerGate<C, R, G, webgates::gate::bearer::JwtConfig<R, G>>) -> Self::Output {
//!         // Build and return the runtime evaluator derived from the gate.
//!         gate.runtime()
//!     }
//! }
//!
//! /// Adapter producing a static-token runtime evaluator.
//! struct StaticRuntimeAdapter;
//!
//! impl<C, R, G> BearerGateAdapter<C, R, G, webgates::gate::bearer::StaticTokenConfig> for StaticRuntimeAdapter
//! where
//!     C: webgates::codecs::Codec,
//!     R: AccessHierarchy + Eq + std::fmt::Display + Clone,
//!     G: Eq + Clone,
//! {
//!     type Output = StaticTokenRuntime<R, G>;
//!
//!     fn adapt(&self, gate: BearerGate<C, R, G, webgates::gate::bearer::StaticTokenConfig>) -> Self::Output {
//!         gate.runtime()
//!     }
//! }
//!
//! // Usage sketch:
//! let codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
//! let jwt_gate = BearerGate::new_with_codec("issuer", Arc::clone(&codec)).require_login();
//! let jwt_runtime = jwt_gate.adapt_with(JwtRuntimeAdapter);
//! let eval = jwt_runtime.evaluate(Some("eyJ..."));
//! match eval {
//!     webgates::gate::bearer::BearerEvaluation::JwtAuthorized { account, .. } => {
//!         // authorized — use `account`
//!     }
//!     webgates::gate::bearer::BearerEvaluation::JwtMissingToken => {
//!         // treat as unauthorized (e.g., return 401)
//!     }
//!     _ => { /* handle other outcomes */ }
//! }
//! ```
//!
//! ## JWT validation
//!
//! The codec used by JWT mode (for example, `JsonWebToken`) performs
//! cryptographic validation (signature, expiry, issuer) via the underlying
//! `jsonwebtoken`-based implementation. Adapters and middleware SHOULD NOT
//! re-validate signatures themselves but rely on the runtime's evaluation and
//! map `BearerEvaluation` variants to framework-specific responses (e.g.,
//! 401/403) or request extensions.

use std::fmt::Display;
use std::sync::Arc;

use super::GateExt;
use crate::accounts::Account;
use crate::authz::access_hierarchy::AccessHierarchy;
use crate::authz::access_policy::AccessPolicy;
use crate::authz::authorization_service::AuthorizationService;
use crate::codecs::Codec;
use crate::codecs::jwt::validation_result::JwtValidationResult;
use crate::codecs::jwt::validation_service::JwtValidationService;
use crate::codecs::jwt::{JwtClaims, RegisteredClaims};
use uuid::Uuid;

/// JWT mode configuration (compile-time).
#[derive(Clone)]
pub struct JwtConfig<R, G>
where
    R: AccessHierarchy + Eq + Display,
    G: Eq,
{
    /// Access policy applied in JWT mode.
    policy: AccessPolicy<R, G>,
    /// Whether optional (non-blocking) mode is enabled for JWTs.
    optional: bool,
}

impl<R, G> std::fmt::Debug for JwtConfig<R, G>
where
    R: AccessHierarchy + Eq + Display,
    G: Eq,
{
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("JwtConfig")
            .field("optional", &self.optional)
            .finish_non_exhaustive()
    }
}

/// Static token mode configuration (compile-time).
#[derive(Clone, Debug)]
pub struct StaticTokenConfig {
    /// Exact static bearer token to match.
    token: String,
    /// Whether optional (non-blocking) mode is enabled.
    optional: bool,
}

/// Generic bearer gate with compile-time mode parameter.
#[derive(Clone, Debug)]
pub struct BearerGate<C, R, G, M>
where
    C: Codec,
    R: AccessHierarchy + Eq + Display,
    G: Eq,
{
    /// Issuer value expected when validating bearer JWTs (unused for static token mode).
    issuer: String,
    /// Shared codec used to decode/encode bearer JWTs.
    codec: Arc<C>,
    /// Mode configuration (JWT or static token).
    mode: M,
    /// Marker to retain generic types without storing them at runtime.
    _phantom: std::marker::PhantomData<(R, G)>,
}

impl<C, R, G> BearerGate<C, R, G, JwtConfig<R, G>>
where
    C: Codec,
    R: AccessHierarchy + Eq + Display,
    G: Eq + Clone,
{
    /// Create a JWT-mode bearer gate configuration with a deny-all policy by default.
    pub fn new_with_codec(issuer: &str, codec: Arc<C>) -> Self
    where
        R: Default,
    {
        Self {
            issuer: issuer.to_string(),
            codec,
            mode: JwtConfig {
                policy: AccessPolicy::deny_all(),
                optional: false,
            },
            _phantom: std::marker::PhantomData,
        }
    }

    /// Set access policy (OR semantics between requirements).
    /// Set the access policy (OR semantics between requirements).
    pub fn with_policy(mut self, policy: AccessPolicy<R, G>) -> Self {
        self.mode.policy = policy;
        self
    }

    /// Turn on optional mode (install `Option<Account>` / `Option<RegisteredClaims>` in adapters).
    /// Enable optional mode; adapters should forward requests and inject optional context.
    pub fn allow_anonymous_with_optional_user(mut self) -> Self {
        self.mode.optional = true;
        self
    }

    /// Convenience: allow any authenticated user (baseline role + supervisors).
    /// Convenience: allow the baseline role and all supervisors according to the hierarchy.
    pub fn require_login(mut self) -> Self
    where
        R: Default,
    {
        let baseline = R::default();
        self.mode.policy = AccessPolicy::require_role_or_supervisor(baseline);
        self
    }

    /// Transition to static token mode (drops policies).
    /// Switch to static token mode; policies are dropped in favor of exact token matching.
    pub fn with_static_token(
        self,
        token: impl Into<String>,
    ) -> BearerGate<C, R, G, StaticTokenConfig> {
        BearerGate {
            issuer: self.issuer,
            codec: self.codec,
            mode: StaticTokenConfig {
                token: token.into(),
                optional: false,
            },
            _phantom: std::marker::PhantomData,
        }
    }

    /// Return the configured issuer string.
    pub fn issuer(&self) -> &str {
        &self.issuer
    }

    /// Return the configured codec.
    pub fn codec(&self) -> &Arc<C> {
        &self.codec
    }

    /// Return the configured access policy.
    pub fn policy(&self) -> &AccessPolicy<R, G> {
        &self.mode.policy
    }

    /// Whether optional mode is enabled for this gate.
    pub fn is_optional(&self) -> bool {
        self.mode.optional
    }
}

impl<C, R, G> BearerGate<C, R, G, StaticTokenConfig>
where
    C: Codec,
    R: AccessHierarchy + Eq + Display,
    G: Eq + Clone,
{
    /// Enable optional mode (install StaticTokenAuthorized(bool) in adapters).
    /// Enable optional mode for static token gates; adapters should forward all requests.
    pub fn allow_anonymous_with_optional_user(mut self) -> Self {
        self.mode.optional = true;
        self
    }

    /// Return the configured issuer string.
    pub fn issuer(&self) -> &str {
        &self.issuer
    }

    /// Return the configured codec.
    pub fn codec(&self) -> &Arc<C> {
        &self.codec
    }

    /// Return the configured static bearer token.
    pub fn token(&self) -> &str {
        &self.mode.token
    }

    /// Whether optional mode is enabled for this static token gate.
    pub fn is_optional(&self) -> bool {
        self.mode.optional
    }
}

/// Adapter trait to convert a framework-agnostic `BearerGate` into a concrete
/// middleware/layer for a specific web framework or transport.
///
/// Framework crates implement this trait for their own adapter types to avoid
/// coupling the core crate to tower/http/axum. Example (in an integration
/// crate):
///
/// ```ignore
/// impl<C, R, G, M> BearerGateAdapter<C, R, G, M> for AxumBearerAdapter { .. }
/// ```
pub trait BearerGateAdapter<C, R, G, M>
where
    C: Codec,
    R: AccessHierarchy + Eq + Display,
    G: Eq,
{
    /// Framework-specific output type produced by the adapter (e.g., a middleware layer).
    type Output;

    /// Convert a framework-agnostic `BearerGate` into the framework-specific middleware/layer type.
    fn adapt(&self, gate: BearerGate<C, R, G, M>) -> Self::Output;
}

/// Outcome of evaluating bearer authentication/authorization independent of any HTTP framework.
#[derive(Debug, Clone)]
pub enum BearerEvaluation<R, G>
where
    R: AccessHierarchy + Eq + Display + Clone,
    G: Eq + Clone,
{
    /// Optional JWT mode: no bearer token present.
    JwtOptionalAnonymous,
    /// Optional JWT mode: token validated; policy not enforced.
    JwtOptionalAuthorized {
        /// Decoded account claims.
        account: Account<R, G>,
        /// Registered JWT claims.
        registered_claims: RegisteredClaims,
    },
    /// Strict JWT mode: required token missing.
    JwtMissingToken,
    /// Strict JWT mode: token failed validation.
    JwtInvalidToken,
    /// Strict JWT mode: issuer mismatch.
    JwtInvalidIssuer {
        /// Expected issuer configured on the gate.
        expected: String,
        /// Actual issuer embedded in the token.
        actual: String,
    },
    /// Strict JWT mode: policy denies all access.
    JwtDenyAllPolicy,
    /// Strict JWT mode: decoded token but policy check failed.
    JwtPolicyDenied {
        /// Identifier of the decoded account.
        account_id: Uuid,
    },
    /// Strict JWT mode: token validated and policy passed.
    JwtAuthorized {
        /// Decoded account claims.
        account: Account<R, G>,
        /// Registered JWT claims.
        registered_claims: RegisteredClaims,
    },
    /// Static token mode: authorized.
    StaticAuthorized,
    /// Static token mode: denied.
    StaticDenied,
    /// Static token optional mode: forwarded with match indicator.
    StaticOptionalAuthorized {
        /// Whether the provided token matched the configured static token.
        matched: bool,
    },
}

/// Runtime evaluator for JWT bearer gates.
#[derive(Clone, Debug)]
pub struct JwtBearerRuntime<C, R, G>
where
    C: Codec,
    R: AccessHierarchy + Eq + Display + Clone,
    G: Eq + Clone,
{
    /// Authorization evaluator built from the configured access policy.
    authorization_service: AuthorizationService<R, G>,
    /// Validates and decodes bearer JWTs for this gate.
    jwt_validation_service: JwtValidationService<C>,
    /// Whether the gate runs in optional (non-blocking) mode.
    optional: bool,
}

impl<C, R, G> JwtBearerRuntime<C, R, G>
where
    C: Codec<Payload = JwtClaims<Account<R, G>>>,
    R: AccessHierarchy + Eq + Display + Clone,
    G: Eq + Clone,
{
    /// Build a runtime evaluator from a configured gate.
    pub fn new(issuer: &str, policy: AccessPolicy<R, G>, codec: Arc<C>, optional: bool) -> Self {
        Self {
            authorization_service: AuthorizationService::new(policy),
            jwt_validation_service: JwtValidationService::new(codec, issuer),
            optional,
        }
    }

    /// Evaluate an optional bearer token.
    pub fn evaluate(&self, token: Option<&str>) -> BearerEvaluation<R, G> {
        if self.optional {
            if let Some(token) = token
                && let JwtValidationResult::Valid(jwt) =
                    self.jwt_validation_service.validate_token(token)
            {
                return BearerEvaluation::JwtOptionalAuthorized {
                    account: jwt.custom_claims,
                    registered_claims: jwt.registered_claims,
                };
            }
            return BearerEvaluation::JwtOptionalAnonymous;
        }

        if self.authorization_service.policy_denies_all_access() {
            return BearerEvaluation::JwtDenyAllPolicy;
        }

        let Some(token) = token else {
            return BearerEvaluation::JwtMissingToken;
        };

        match self.jwt_validation_service.validate_token(token) {
            JwtValidationResult::Valid(jwt) => {
                let account = jwt.custom_claims;
                let registered_claims = jwt.registered_claims;
                let account_id = account.account_id;
                if self.authorization_service.is_authorized(&account) {
                    BearerEvaluation::JwtAuthorized {
                        account,
                        registered_claims,
                    }
                } else {
                    BearerEvaluation::JwtPolicyDenied { account_id }
                }
            }
            JwtValidationResult::InvalidToken => BearerEvaluation::JwtInvalidToken,
            JwtValidationResult::InvalidIssuer { expected, actual } => {
                BearerEvaluation::JwtInvalidIssuer { expected, actual }
            }
        }
    }
}

/// Runtime evaluator for static bearer token gates.
#[derive(Clone, Debug)]
pub struct StaticTokenRuntime<R, G>
where
    R: AccessHierarchy + Eq + Display + Clone,
    G: Eq + Clone,
{
    /// Exact static bearer token to match.
    token: String,
    /// Whether optional (non-blocking) mode is enabled.
    optional: bool,
    /// Marker to retain generic types without runtime storage.
    _phantom: std::marker::PhantomData<(R, G)>,
}

impl<R, G> StaticTokenRuntime<R, G>
where
    R: AccessHierarchy + Eq + Display + Clone,
    G: Eq + Clone,
{
    /// Build a runtime evaluator for static token gates.
    pub fn new(token: impl Into<String>, optional: bool) -> Self {
        Self {
            token: token.into(),
            optional,
            _phantom: std::marker::PhantomData,
        }
    }

    /// Evaluate an optional bearer token string.
    ///
    /// Both the strict and optional paths use constant-time byte comparison via
    /// [`subtle::ConstantTimeEq`] to eliminate timing side-channels that could
    /// allow an attacker to reconstruct the static secret character-by-character.
    pub fn evaluate(&self, token: Option<&str>) -> BearerEvaluation<R, G> {
        use subtle::ConstantTimeEq as _;

        if self.optional {
            let matched =
                token.is_some_and(|t| bool::from(t.as_bytes().ct_eq(self.token.as_bytes())));
            return BearerEvaluation::StaticOptionalAuthorized { matched };
        }

        if let Some(t) = token
            && bool::from(t.as_bytes().ct_eq(self.token.as_bytes()))
        {
            return BearerEvaluation::StaticAuthorized;
        }
        BearerEvaluation::StaticDenied
    }
}

impl<C, R, G> BearerGate<C, R, G, JwtConfig<R, G>>
where
    C: Codec<Payload = JwtClaims<Account<R, G>>>,
    R: AccessHierarchy + Eq + Display + Clone,
    G: Eq + Clone,
{
    /// Create a runtime evaluator that performs JWT validation and policy checks.
    pub fn runtime(&self) -> JwtBearerRuntime<C, R, G>
    where
        R: Default,
    {
        JwtBearerRuntime::new(
            &self.issuer,
            self.mode.policy.clone(),
            Arc::clone(&self.codec),
            self.mode.optional,
        )
    }
}

impl<C, R, G> BearerGate<C, R, G, StaticTokenConfig>
where
    C: Codec,
    R: AccessHierarchy + Eq + Display,
    G: Eq + Clone,
{
    /// Create a runtime evaluator for static token gates.
    pub fn runtime(&self) -> StaticTokenRuntime<R, G> {
        StaticTokenRuntime::new(self.mode.token.clone(), self.mode.optional)
    }
}

impl<C, R, Gt, M> GateExt for super::bearer::BearerGate<C, R, Gt, M>
where
    C: Codec,
    R: AccessHierarchy + Eq + Display,
    Gt: Eq,
{
}

impl<C, R, Gt, M2, A> crate::gate::adapter::GateAdapter<BearerGate<C, R, Gt, M2>> for A
where
    A: BearerGateAdapter<C, R, Gt, M2>,
    C: Codec,
    R: AccessHierarchy + Eq + Display,
    Gt: Eq,
{
    type Output = A::Output;

    fn adapt(&self, gate: BearerGate<C, R, Gt, M2>) -> Self::Output {
        A::adapt(self, gate)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::accounts::Account;
    use crate::codecs::jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER as JWT_CRYPTO_PROVIDER;
    use crate::codecs::jwt::{JsonWebToken, JwtClaims, RegisteredClaims};
    use crate::groups::Group;
    use crate::roles::Role;
    use chrono::Utc;

    fn install_jwt_crypto_provider() {
        let _ = JWT_CRYPTO_PROVIDER.install_default();
    }

    #[test]
    fn jwt_runtime_authorizes_when_policy_allows() -> Result<(), Box<dyn std::error::Error>> {
        install_jwt_crypto_provider();
        let codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
        let gate = BearerGate::<_, Role, Group, JwtConfig<Role, Group>>::new_with_codec(
            "issuer",
            Arc::clone(&codec),
        )
        .require_login();

        let account = Account::<Role, Group>::new("user");
        let exp = Utc::now().timestamp() as u64 + 60;
        let claims = JwtClaims::new(account.clone(), RegisteredClaims::new("issuer", exp));
        let encoded = codec
            .encode(&claims)
            .map_err(|e| format!("encode jwt: {e}"))?;
        let token = String::from_utf8(encoded).map_err(|e| format!("utf-8 decode: {e}"))?;

        let runtime = gate.runtime();
        let result = runtime.evaluate(Some(&token));

        match result {
            BearerEvaluation::JwtAuthorized {
                account: acc,
                registered_claims,
            } => {
                assert_eq!(acc.user_id, account.user_id);
                assert_eq!(registered_claims.issuer, "issuer");
            }
            other => return Err(format!("expected JwtAuthorized, got {other:?}").into()),
        }
        Ok(())
    }

    #[test]
    fn static_runtime_matches_token() {
        install_jwt_crypto_provider();
        let codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
        let gate = BearerGate::<_, Role, Group, JwtConfig<Role, Group>>::new_with_codec(
            "issuer",
            Arc::clone(&codec),
        )
        .with_static_token("secret-token");

        let runtime = gate.runtime();

        assert!(matches!(
            runtime.evaluate(Some("secret-token")),
            BearerEvaluation::StaticAuthorized
        ));
        assert!(matches!(
            runtime.evaluate(Some("wrong-token")),
            BearerEvaluation::StaticDenied
        ));
        assert!(matches!(
            runtime.evaluate(None),
            BearerEvaluation::StaticDenied
        ));
    }

    /// A token that differs only in the last byte must still be rejected.
    ///
    /// This test guards against partial-match bugs that would appear if
    /// the comparison were prefix-based or improperly length-checked.
    #[test]
    fn static_runtime_rejects_last_byte_different_token() {
        install_jwt_crypto_provider();
        let codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
        let gate = BearerGate::<_, Role, Group, JwtConfig<Role, Group>>::new_with_codec(
            "issuer",
            Arc::clone(&codec),
        )
        .with_static_token("secret-tokenX");

        let runtime = gate.runtime();

        // Identical prefix, different final byte → must be denied.
        assert!(matches!(
            runtime.evaluate(Some("secret-tokenY")),
            BearerEvaluation::StaticDenied
        ));
        // Correct token → must be authorized.
        assert!(matches!(
            runtime.evaluate(Some("secret-tokenX")),
            BearerEvaluation::StaticAuthorized
        ));
    }

    /// Optional mode: the `matched` flag must reflect constant-time comparison.
    #[test]
    fn static_optional_runtime_constant_time_comparison() {
        install_jwt_crypto_provider();
        let codec = Arc::new(JsonWebToken::<JwtClaims<Account<Role, Group>>>::default());
        let gate = BearerGate::<_, Role, Group, JwtConfig<Role, Group>>::new_with_codec(
            "issuer",
            Arc::clone(&codec),
        )
        .with_static_token("my-secret")
        .allow_anonymous_with_optional_user();

        let runtime = gate.runtime();

        // Correct token in optional mode → matched = true.
        assert!(matches!(
            runtime.evaluate(Some("my-secret")),
            BearerEvaluation::StaticOptionalAuthorized { matched: true }
        ));
        // Wrong token in optional mode → matched = false (not blocked, just unmatched).
        assert!(matches!(
            runtime.evaluate(Some("my-secreX")),
            BearerEvaluation::StaticOptionalAuthorized { matched: false }
        ));
        // No token in optional mode → matched = false.
        assert!(matches!(
            runtime.evaluate(None),
            BearerEvaluation::StaticOptionalAuthorized { matched: false }
        ));
    }
}