Skip to main content

rmcp_server_kit/
auth.rs

1//! Authentication middleware for MCP servers.
2//!
3//! Supports multiple authentication methods tried in priority order:
4//! 1. mTLS client certificate (if configured and peer cert present)
5//! 2. Bearer token (API key) with Argon2id hash verification
6//!
7//! Includes per-source-IP rate limiting on authentication attempts.
8
9use std::{
10    collections::HashSet,
11    net::SocketAddr,
12    num::{NonZeroU32, NonZeroUsize},
13    path::PathBuf,
14    sync::{
15        Arc, LazyLock, Mutex,
16        atomic::{AtomicU64, Ordering},
17    },
18    time::Duration,
19};
20
21use arc_swap::ArcSwap;
22use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
23use axum::{
24    body::Body,
25    extract::ConnectInfo,
26    http::{Request, StatusCode, header},
27    middleware::Next,
28    response::{IntoResponse, Response},
29};
30use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
31use secrecy::SecretString;
32use serde::Deserialize;
33use x509_parser::prelude::*;
34
35use crate::{
36    bounded_limiter::{BoundedKeyedLimiter, BoundedLimiterDeny, KeyEvictionPolicy},
37    error::RmcpServerKitError,
38    transport::RateLimitKey,
39};
40
41/// Identity of an authenticated caller.
42///
43/// The [`Debug`] impl is **manually written** to redact the raw bearer token
44/// and the JWT `sub` claim. This prevents accidental disclosure if an
45/// `AuthIdentity` is ever logged via `tracing::debug!(?identity, …)` or
46/// `format!("{identity:?}")`. Only `name`, `role`, and `method` are printed
47/// in the clear; `raw_token` and `sub` are rendered as `<redacted>` /
48/// `<present>` / `<none>` markers.
49#[derive(Clone)]
50#[non_exhaustive]
51pub struct AuthIdentity {
52    /// Human-readable identity name (e.g. API key label or cert CN).
53    pub name: String,
54    /// RBAC role associated with this identity.
55    pub role: String,
56    /// Which authentication mechanism produced this identity.
57    pub method: AuthMethod,
58    /// Raw bearer token from the `Authorization` header, wrapped in
59    /// [`SecretString`] so it is never accidentally logged or serialized.
60    /// Present for OAuth JWT; `None` for mTLS and API-key auth.
61    /// Tool handlers use this for downstream token passthrough via
62    /// [`crate::rbac::current_token`].
63    pub raw_token: Option<SecretString>,
64    /// JWT `sub` claim (stable user identifier, e.g. Keycloak UUID).
65    /// Used for token store keying. `None` for non-JWT auth.
66    pub sub: Option<String>,
67}
68
69impl std::fmt::Debug for AuthIdentity {
70    /// Redacts `raw_token` and `sub` to prevent secret leakage via
71    /// `format!("{:?}")` or `tracing::debug!(?identity)`.
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("AuthIdentity")
74            .field("name", &self.name)
75            .field("role", &self.role)
76            .field("method", &self.method)
77            .field(
78                "raw_token",
79                &if self.raw_token.is_some() {
80                    "<redacted>"
81                } else {
82                    "<none>"
83                },
84            )
85            .field(
86                "sub",
87                &if self.sub.is_some() {
88                    "<redacted>"
89                } else {
90                    "<none>"
91                },
92            )
93            .finish()
94    }
95}
96
97/// How the caller authenticated.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum AuthMethod {
101    /// Bearer API key (Argon2id-hashed, configured statically).
102    BearerToken,
103    /// Mutual TLS client certificate.
104    MtlsCertificate,
105    /// OAuth 2.1 JWT bearer token (validated via JWKS).
106    OAuthJwt,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum AuthFailureClass {
111    MissingCredential,
112    InvalidCredential,
113    #[cfg_attr(
114        not(feature = "oauth"),
115        allow(
116            dead_code,
117            reason = "only OAuth JWT validation can report an expired credential; \
118                      the variant is unconstructed in builds without that feature"
119        )
120    )]
121    ExpiredCredential,
122    /// Source IP exceeded the post-failure backoff limit.
123    RateLimited,
124    /// Source IP exceeded the pre-auth abuse gate (rejected before any
125    /// password-hash work — see [`AuthState::pre_auth_limiter`]).
126    PreAuthGate,
127}
128
129impl AuthFailureClass {
130    fn as_str(self) -> &'static str {
131        match self {
132            Self::MissingCredential => "missing_credential",
133            Self::InvalidCredential => "invalid_credential",
134            Self::ExpiredCredential => "expired_credential",
135            Self::RateLimited => "rate_limited",
136            Self::PreAuthGate => "pre_auth_gate",
137        }
138    }
139
140    fn bearer_error(self) -> (&'static str, &'static str) {
141        match self {
142            Self::MissingCredential => (
143                "invalid_request",
144                "missing bearer token or mTLS client certificate",
145            ),
146            Self::InvalidCredential => ("invalid_token", "token is invalid"),
147            Self::ExpiredCredential => ("invalid_token", "token is expired"),
148            Self::RateLimited => ("invalid_request", "too many failed authentication attempts"),
149            Self::PreAuthGate => (
150                "invalid_request",
151                "too many unauthenticated requests from this source",
152            ),
153        }
154    }
155
156    fn response_body(self) -> &'static str {
157        match self {
158            Self::MissingCredential => "unauthorized: missing credential",
159            Self::InvalidCredential => "unauthorized: invalid credential",
160            Self::ExpiredCredential => "unauthorized: expired credential",
161            Self::RateLimited => "rate limited",
162            Self::PreAuthGate => "rate limited (pre-auth)",
163        }
164    }
165}
166
167/// Snapshot of authentication success/failure counters.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
169#[non_exhaustive]
170pub struct AuthCountersSnapshot {
171    /// Successful mTLS authentications.
172    pub success_mtls: u64,
173    /// Successful bearer-token authentications.
174    pub success_bearer: u64,
175    /// Successful OAuth JWT authentications.
176    pub success_oauth_jwt: u64,
177    /// Failures because no credential was presented.
178    pub failure_missing_credential: u64,
179    /// Failures because the credential was malformed or wrong.
180    pub failure_invalid_credential: u64,
181    /// Failures because the credential had expired.
182    pub failure_expired_credential: u64,
183    /// Failures because the source IP was rate-limited (post-failure backoff).
184    pub failure_rate_limited: u64,
185    /// Failures because the source IP exceeded the pre-auth abuse gate.
186    /// These never reach the password-hash verification path.
187    pub failure_pre_auth_gate: u64,
188}
189
190/// Internal atomic counters backing [`AuthCountersSnapshot`].
191#[derive(Debug, Default)]
192pub(crate) struct AuthCounters {
193    success_mtls: AtomicU64,
194    success_bearer: AtomicU64,
195    success_oauth_jwt: AtomicU64,
196    failure_missing_credential: AtomicU64,
197    failure_invalid_credential: AtomicU64,
198    failure_expired_credential: AtomicU64,
199    failure_rate_limited: AtomicU64,
200    failure_pre_auth_gate: AtomicU64,
201}
202
203impl AuthCounters {
204    fn record_success(&self, method: AuthMethod) {
205        match method {
206            AuthMethod::MtlsCertificate => {
207                self.success_mtls.fetch_add(1, Ordering::Relaxed);
208            }
209            AuthMethod::BearerToken => {
210                self.success_bearer.fetch_add(1, Ordering::Relaxed);
211            }
212            AuthMethod::OAuthJwt => {
213                self.success_oauth_jwt.fetch_add(1, Ordering::Relaxed);
214            }
215        }
216    }
217
218    fn record_failure(&self, class: AuthFailureClass) {
219        match class {
220            AuthFailureClass::MissingCredential => {
221                self.failure_missing_credential
222                    .fetch_add(1, Ordering::Relaxed);
223            }
224            AuthFailureClass::InvalidCredential => {
225                self.failure_invalid_credential
226                    .fetch_add(1, Ordering::Relaxed);
227            }
228            AuthFailureClass::ExpiredCredential => {
229                self.failure_expired_credential
230                    .fetch_add(1, Ordering::Relaxed);
231            }
232            AuthFailureClass::RateLimited => {
233                self.failure_rate_limited.fetch_add(1, Ordering::Relaxed);
234            }
235            AuthFailureClass::PreAuthGate => {
236                self.failure_pre_auth_gate.fetch_add(1, Ordering::Relaxed);
237            }
238        }
239    }
240
241    fn snapshot(&self) -> AuthCountersSnapshot {
242        AuthCountersSnapshot {
243            success_mtls: self.success_mtls.load(Ordering::Relaxed),
244            success_bearer: self.success_bearer.load(Ordering::Relaxed),
245            success_oauth_jwt: self.success_oauth_jwt.load(Ordering::Relaxed),
246            failure_missing_credential: self.failure_missing_credential.load(Ordering::Relaxed),
247            failure_invalid_credential: self.failure_invalid_credential.load(Ordering::Relaxed),
248            failure_expired_credential: self.failure_expired_credential.load(Ordering::Relaxed),
249            failure_rate_limited: self.failure_rate_limited.load(Ordering::Relaxed),
250            failure_pre_auth_gate: self.failure_pre_auth_gate.load(Ordering::Relaxed),
251        }
252    }
253}
254
255/// RFC 3339 timestamp, parsed at deserialization time.
256///
257/// Use this for any public field that needs to carry an RFC 3339 timestamp from
258/// TOML/JSON config or builder APIs. Construction is fallible (`parse`); once
259/// constructed the value is guaranteed to be a real RFC 3339 timestamp with a
260/// known offset, so downstream code does not need to handle parse errors.
261///
262/// Wraps [`chrono::DateTime<chrono::FixedOffset>`]; the underlying value is
263/// available via [`Self::as_datetime`] or [`Self::into_inner`]. `Serialize`
264/// emits the canonical RFC 3339 form via [`chrono::DateTime::to_rfc3339`], so
265/// the on-the-wire format for `ApiKeySummary` (admin endpoints) is unchanged.
266#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
267#[non_exhaustive]
268pub struct RfcTimestamp(chrono::DateTime<chrono::FixedOffset>);
269
270impl RfcTimestamp {
271    /// Parse an RFC 3339 timestamp.
272    ///
273    /// # Errors
274    ///
275    /// Returns the underlying [`chrono::ParseError`] when `s` is not a valid
276    /// RFC 3339 timestamp (e.g. missing the `T` separator, missing the offset
277    /// suffix, or out-of-range fields).
278    pub fn parse(s: &str) -> Result<Self, chrono::ParseError> {
279        chrono::DateTime::parse_from_rfc3339(s).map(Self)
280    }
281
282    /// Borrow the underlying [`chrono::DateTime`].
283    #[must_use]
284    pub fn as_datetime(&self) -> &chrono::DateTime<chrono::FixedOffset> {
285        &self.0
286    }
287
288    /// Consume the wrapper and return the underlying [`chrono::DateTime`].
289    #[must_use]
290    pub fn into_inner(self) -> chrono::DateTime<chrono::FixedOffset> {
291        self.0
292    }
293}
294
295impl std::fmt::Display for RfcTimestamp {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        // Canonical RFC 3339 form; matches the deserialization input contract.
298        write!(f, "{}", self.0.to_rfc3339())
299    }
300}
301
302impl std::fmt::Debug for RfcTimestamp {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        // Render as the canonical RFC 3339 string (not chrono's internal
305        // debug form) so existing `ApiKeyEntry` Debug-redaction tests --
306        // which look for the literal `"2030-01-01T00:00:00Z"` form in the
307        // formatted output -- continue to hold without bespoke handling.
308        write!(f, "{}", self.0.to_rfc3339())
309    }
310}
311
312impl<'de> Deserialize<'de> for RfcTimestamp {
313    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
314    where
315        D: serde::Deserializer<'de>,
316    {
317        // Validate at deserialization time: a malformed `expires_at` in
318        // TOML or JSON aborts config load with a clear serde error rather
319        // than silently producing a key that fails open at runtime.
320        let s = String::deserialize(deserializer)?;
321        Self::parse(&s).map_err(serde::de::Error::custom)
322    }
323}
324
325impl serde::Serialize for RfcTimestamp {
326    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
327    where
328        S: serde::Serializer,
329    {
330        serializer.serialize_str(&self.0.to_rfc3339())
331    }
332}
333
334impl From<chrono::DateTime<chrono::FixedOffset>> for RfcTimestamp {
335    fn from(value: chrono::DateTime<chrono::FixedOffset>) -> Self {
336        Self(value)
337    }
338}
339
340/// A single API key entry (stored as Argon2id hash in config).
341///
342/// The [`Debug`] impl is **manually written** to redact the Argon2id hash.
343/// Although the hash is not directly reversible, treating it as a secret
344/// prevents offline brute-force attempts from leaked logs and matches the
345/// defense-in-depth posture used for [`AuthIdentity`].
346#[derive(Clone, Deserialize)]
347#[serde(deny_unknown_fields)]
348#[non_exhaustive]
349pub struct ApiKeyEntry {
350    /// Human-readable key label (used in logs and audit records).
351    pub name: String,
352    /// Argon2id hash of the token (PHC string format).
353    pub hash: String,
354    /// RBAC role granted when this key authenticates successfully.
355    pub role: String,
356    /// Optional expiry, parsed from an RFC 3339 string at deserialization
357    /// time. Construction from a raw string is fallible (see
358    /// [`RfcTimestamp::parse`] and [`ApiKeyEntry::try_with_expiry`]),
359    /// which guarantees `verify_bearer_token` never sees a malformed value.
360    pub expires_at: Option<RfcTimestamp>,
361}
362
363impl std::fmt::Debug for ApiKeyEntry {
364    /// Redacts the Argon2id `hash` to keep it out of logs, panic backtraces,
365    /// and admin-endpoint responses that might `format!("{:?}", …)` an entry.
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        f.debug_struct("ApiKeyEntry")
368            .field("name", &self.name)
369            .field("hash", &"<redacted>")
370            .field("role", &self.role)
371            .field("expires_at", &self.expires_at)
372            .finish()
373    }
374}
375
376impl ApiKeyEntry {
377    /// Create a new API key entry (no expiry).
378    #[must_use]
379    pub fn new(name: impl Into<String>, hash: impl Into<String>, role: impl Into<String>) -> Self {
380        Self {
381            name: name.into(),
382            hash: hash.into(),
383            role: role.into(),
384            expires_at: None,
385        }
386    }
387
388    /// Set an RFC 3339 expiry on this key.
389    ///
390    /// Takes an already-parsed [`RfcTimestamp`]; for ergonomic construction
391    /// from a raw string see [`Self::try_with_expiry`].
392    #[must_use]
393    pub fn with_expiry(mut self, expires_at: RfcTimestamp) -> Self {
394        self.expires_at = Some(expires_at);
395        self
396    }
397
398    /// Set an RFC 3339 expiry on this key from a raw string.
399    ///
400    /// # Errors
401    ///
402    /// Returns the underlying [`chrono::ParseError`] when `expires_at` is
403    /// not a valid RFC 3339 timestamp. This is the fallible counterpart to
404    /// [`Self::with_expiry`].
405    pub fn try_with_expiry(
406        mut self,
407        expires_at: impl AsRef<str>,
408    ) -> Result<Self, chrono::ParseError> {
409        self.expires_at = Some(RfcTimestamp::parse(expires_at.as_ref())?);
410        Ok(self)
411    }
412}
413
414/// mTLS client certificate authentication configuration.
415#[derive(Debug, Clone, Deserialize)]
416#[serde(deny_unknown_fields)]
417#[allow(
418    clippy::struct_excessive_bools,
419    reason = "mTLS CRL behavior is intentionally configured as independent booleans"
420)]
421#[non_exhaustive]
422pub struct MtlsConfig {
423    /// Path to CA certificate(s) for verifying client certs (PEM format).
424    pub ca_cert_path: PathBuf,
425    /// If true, clients MUST present a valid certificate.
426    /// If false, client certs are optional (verified if presented).
427    #[serde(default)]
428    pub required: bool,
429    /// Default RBAC role for mTLS-authenticated clients.
430    /// The client cert CN becomes the identity name.
431    #[serde(default = "default_mtls_role")]
432    pub default_role: String,
433    /// Enable CRL-based certificate revocation checks using CDP URLs from the
434    /// configured CA chain and connecting client certificates.
435    #[serde(default = "default_true")]
436    pub crl_enabled: bool,
437    /// Optional fixed refresh interval for known CRLs. When omitted, refresh
438    /// cadence is derived from `nextUpdate` and clamped internally.
439    #[serde(default, with = "humantime_serde::option")]
440    pub crl_refresh_interval: Option<Duration>,
441    /// Timeout for individual CRL fetches.
442    #[serde(default = "default_crl_fetch_timeout", with = "humantime_serde")]
443    pub crl_fetch_timeout: Duration,
444    /// Retry-retention window: how long a CRL whose refresh keeps failing is
445    /// retained in the cache so the background refresher can keep retrying it,
446    /// measured past the CRL's `nextUpdate`.
447    ///
448    /// This does **not** permit use of an expired CRL. When
449    /// `crl_enforce_expiration` is set (the default), webpki rejects any CRL
450    /// past its `nextUpdate` during validation; this window only bounds how
451    /// long a persistently-failing entry is kept for retry before the verifier
452    /// gives up and evicts it (at which point `crl_deny_on_unavailable` governs
453    /// the handshake outcome).
454    ///
455    /// The preferred config key is `crl_retry_retention`; `crl_stale_grace` is
456    /// accepted as a deprecated alias for backward compatibility.
457    #[serde(
458        default = "default_crl_stale_grace",
459        alias = "crl_retry_retention",
460        with = "humantime_serde"
461    )]
462    pub crl_stale_grace: Duration,
463    /// When true, missing or unavailable CRLs cause revocation checks to fail
464    /// closed.
465    ///
466    /// Defaults to `true`. RFC 5280 §6.3 treats a certificate whose
467    /// revocation status cannot be determined as unverified, so a client
468    /// certificate advertising CRL distribution points is rejected when
469    /// *every* relevant CDP is uncached and unfetchable. Denial requires all
470    /// relevant CDPs to be unavailable, not merely one -- otherwise an
471    /// attacker who blocks a single mirror could deny service.
472    ///
473    /// Set to `false` to restore the pre-3.9 fail-open behaviour, in which an
474    /// unreachable CRL lets the handshake proceed. That is strongly
475    /// discouraged: a revoked certificate is then accepted whenever its CRL
476    /// is unreachable, which is precisely the condition an attacker holding a
477    /// revoked certificate can induce.
478    #[serde(default = "default_true")]
479    pub crl_deny_on_unavailable: bool,
480    /// When true, apply revocation checks only to the end-entity certificate.
481    #[serde(default)]
482    pub crl_end_entity_only: bool,
483    /// Allow HTTP CRL distribution-point URLs in addition to HTTPS.
484    ///
485    /// Defaults to `true` because RFC 5280 §4.2.1.13 designates HTTP (and
486    /// LDAP) as the canonical transport for CRL distribution points.
487    /// SSRF defense for HTTP CDPs is provided by the IP-allowlist guard
488    /// (private/loopback/link-local/multicast/cloud-metadata addresses are
489    /// always rejected), redirect=none, body-size cap, and per-host
490    /// concurrency limit -- not by forcing HTTPS.
491    #[serde(default = "default_true")]
492    pub crl_allow_http: bool,
493    /// Enforce CRL expiration during certificate validation.
494    #[serde(default = "default_true")]
495    pub crl_enforce_expiration: bool,
496    /// Maximum concurrent CRL fetches across all hosts. Defense in depth
497    /// against SSRF amplification: even if many CDPs are discovered, no
498    /// more than this many fetches run in parallel. Per-host concurrency
499    /// is independently capped at 1 regardless of this value.
500    /// Default: `4`.
501    #[serde(default = "default_crl_max_concurrent_fetches")]
502    pub crl_max_concurrent_fetches: usize,
503    /// Hard cap on each CRL response body in bytes. Fetches exceeding this
504    /// are aborted mid-stream to bound memory and prevent gzip-bomb-style
505    /// amplification. Default: 5 MiB (`5 * 1024 * 1024`).
506    #[serde(default = "default_crl_max_response_bytes")]
507    pub crl_max_response_bytes: u64,
508    /// Global CDP discovery rate limit, in URLs per minute. Throttles
509    /// how many *new* CDP URLs the verifier may admit into the fetch
510    /// pipeline across the whole process, bounding asymmetric `DoS`
511    /// amplification when attacker-controlled certificates carry large
512    /// CDP lists. The limit is global (not per-source-IP) in this
513    /// release; per-IP scoping is deferred to a future version because
514    /// it requires plumbing the peer `SocketAddr` through the rustls
515    /// verifier hook (a different subsystem than ordinary request
516    /// middleware). Note: the **bearer pre-auth limiter** that gates
517    /// API-key / OAuth `Authorization` headers is already per-IP — see
518    /// [`RateLimitConfig::pre_auth_max_per_minute`] and the keyed
519    /// governor built by `build_pre_auth_limiter`. URLs that lose the
520    /// rate-limiter race are *not* marked as seen, so subsequent
521    /// handshakes observing the same URL can retry admission.
522    /// Default: `60`.
523    #[serde(default = "default_crl_discovery_rate_per_min")]
524    pub crl_discovery_rate_per_min: u32,
525    /// Maximum number of distinct hosts that may hold a CRL fetch
526    /// semaphore at any time. At the cap, idle entries (no in-flight
527    /// fetch) are evicted on demand so new hosts keep working; only when
528    /// every entry has a concurrent in-flight fetch does the request
529    /// return [`RmcpServerKitError::Config`] containing the literal substring
530    /// `"crl_host_semaphore_cap_exceeded"`. Bounds memory growth from
531    /// attacker-controlled CDP URLs pointing at unique hostnames.
532    /// Default: 1024.
533    #[serde(default = "default_crl_max_host_semaphores")]
534    pub crl_max_host_semaphores: usize,
535    /// Maximum number of distinct URLs tracked in the "seen" set.
536    /// Beyond this, additional discovered URLs are silently dropped
537    /// with a rate-limited warn! log; no error surfaces. Default: 4096.
538    #[serde(default = "default_crl_max_seen_urls")]
539    pub crl_max_seen_urls: usize,
540    /// Maximum number of cached CRL entries. Beyond this, new
541    /// successful fetches are silently dropped with a rate-limited
542    /// warn! log (newest-rejected, not LRU-evicted). Default: 1024.
543    #[serde(default = "default_crl_max_cache_entries")]
544    pub crl_max_cache_entries: usize,
545}
546
547fn default_mtls_role() -> String {
548    "viewer".into()
549}
550
551const fn default_true() -> bool {
552    true
553}
554
555const fn default_crl_fetch_timeout() -> Duration {
556    Duration::from_secs(30)
557}
558
559const fn default_crl_stale_grace() -> Duration {
560    Duration::from_hours(24)
561}
562
563const fn default_crl_max_concurrent_fetches() -> usize {
564    4
565}
566
567const fn default_crl_max_response_bytes() -> u64 {
568    5 * 1024 * 1024
569}
570
571const fn default_crl_discovery_rate_per_min() -> u32 {
572    60
573}
574
575const fn default_crl_max_host_semaphores() -> usize {
576    1024
577}
578
579const fn default_crl_max_seen_urls() -> usize {
580    4096
581}
582
583const fn default_crl_max_cache_entries() -> usize {
584    1024
585}
586
587/// Rate limiting configuration for authentication attempts.
588///
589/// rmcp-server-kit uses two independent per-IP token-bucket limiters for auth:
590///
591/// 1. **Pre-auth abuse gate** ([`Self::pre_auth_max_per_minute`]): consulted
592///    *before* any password-hash work. Throttles unauthenticated traffic from
593///    a single source IP so an attacker cannot pin the CPU on Argon2id by
594///    spraying invalid bearer tokens. Sized generously (default = 10× the
595///    post-failure quota) so legitimate clients are unaffected. mTLS-
596///    authenticated connections bypass this gate entirely (the TLS handshake
597///    already performed expensive crypto with a verified peer).
598/// 2. **Post-failure backoff** ([`Self::max_attempts_per_minute`]): consulted
599///    *after* an authentication attempt fails. Provides explicit backpressure
600///    on bad credentials.
601#[derive(Debug, Clone, Deserialize)]
602#[serde(deny_unknown_fields)]
603#[non_exhaustive]
604pub struct RateLimitConfig {
605    /// Maximum failed authentication attempts per source IP per minute.
606    /// Successful authentications do not consume this budget.
607    #[serde(default = "default_max_attempts")]
608    pub max_attempts_per_minute: u32,
609    /// Maximum *unauthenticated* requests per source IP per minute admitted
610    /// to the password-hash verification path. When `None`, defaults to
611    /// `max_attempts_per_minute * 10` at limiter-construction time.
612    ///
613    /// Set higher than [`Self::max_attempts_per_minute`] so honest clients
614    /// retrying with the wrong key never trip this gate; its purpose is only
615    /// to bound CPU usage under spray attacks.
616    #[serde(default)]
617    pub pre_auth_max_per_minute: Option<u32>,
618    /// Hard cap on the number of distinct source IPs tracked per limiter.
619    /// When reached, idle entries are pruned first; if still full, the
620    /// oldest (LRU) entry is evicted to make room for the new one. This
621    /// bounds memory under IP-spray attacks. Default: `10_000`.
622    #[serde(default = "default_max_tracked_keys")]
623    pub max_tracked_keys: usize,
624    /// Per-IP entries idle for longer than this are eligible for
625    /// opportunistic pruning. Default: 15 minutes.
626    #[serde(default = "default_idle_eviction", with = "humantime_serde")]
627    pub idle_eviction: Duration,
628    /// Burst capacity for the post-failure limiter: the maximum number
629    /// of failed attempts admitted back-to-back before the sustained
630    /// `max_attempts_per_minute` rate applies. `None` (default) keeps
631    /// governor's default of burst = rate. Must be greater than zero
632    /// when set. May be smaller than the rate (smoothing) or larger
633    /// (spike tolerance).
634    #[serde(default)]
635    pub burst: Option<u32>,
636    /// Burst capacity for the pre-auth abuse gate. `None` (default)
637    /// keeps burst = the gate's resolved rate. Legal regardless of
638    /// whether [`Self::pre_auth_max_per_minute`] is set — the gate's
639    /// base rate always resolves (`max_attempts_per_minute * 10` when
640    /// unset). Must be greater than zero when set.
641    #[serde(default)]
642    pub pre_auth_burst: Option<u32>,
643    /// Full-table policy when a rate limiter sees a new source IP after
644    /// reaching [`Self::max_tracked_keys`]. Default: [`KeyEvictionPolicy::EvictLru`].
645    #[serde(default)]
646    pub key_eviction_policy: KeyEvictionPolicy,
647}
648
649impl Default for RateLimitConfig {
650    fn default() -> Self {
651        Self {
652            max_attempts_per_minute: default_max_attempts(),
653            pre_auth_max_per_minute: None,
654            max_tracked_keys: default_max_tracked_keys(),
655            idle_eviction: default_idle_eviction(),
656            burst: None,
657            pre_auth_burst: None,
658            key_eviction_policy: KeyEvictionPolicy::default(),
659        }
660    }
661}
662
663impl RateLimitConfig {
664    /// Create a rate limit config with the given max failed attempts per minute.
665    /// Pre-auth gate defaults to `10x` this value at limiter-construction time.
666    /// Memory-bound defaults are `10_000` tracked keys with 15-minute idle eviction.
667    #[must_use]
668    pub fn new(max_attempts_per_minute: u32) -> Self {
669        Self {
670            max_attempts_per_minute,
671            ..Self::default()
672        }
673    }
674
675    /// Override the pre-auth abuse-gate quota (per source IP per minute).
676    /// When unset, defaults to `max_attempts_per_minute * 10`.
677    #[must_use]
678    pub fn with_pre_auth_max_per_minute(mut self, quota: u32) -> Self {
679        self.pre_auth_max_per_minute = Some(quota);
680        self
681    }
682
683    /// Override the per-limiter cap on tracked source-IP keys (default `10_000`).
684    #[must_use]
685    pub fn with_max_tracked_keys(mut self, max: usize) -> Self {
686        self.max_tracked_keys = max;
687        self
688    }
689
690    /// Override the idle-eviction window (default 15 minutes).
691    #[must_use]
692    pub fn with_idle_eviction(mut self, idle: Duration) -> Self {
693        self.idle_eviction = idle;
694        self
695    }
696
697    /// Set the burst capacity for the post-failure limiter. Must be
698    /// greater than zero (validated at server-config validation time).
699    #[must_use]
700    pub fn with_burst(mut self, burst: u32) -> Self {
701        self.burst = Some(burst);
702        self
703    }
704
705    /// Set the burst capacity for the pre-auth abuse gate. Must be
706    /// greater than zero (validated at server-config validation time).
707    #[must_use]
708    pub fn with_pre_auth_burst(mut self, burst: u32) -> Self {
709        self.pre_auth_burst = Some(burst);
710        self
711    }
712
713    /// Set the tracked-key full-table policy for auth limiters.
714    #[must_use]
715    pub const fn with_key_eviction_policy(mut self, policy: KeyEvictionPolicy) -> Self {
716        self.key_eviction_policy = policy;
717        self
718    }
719}
720
721fn default_max_attempts() -> u32 {
722    30
723}
724
725fn default_max_tracked_keys() -> usize {
726    10_000
727}
728
729fn default_idle_eviction() -> Duration {
730    Duration::from_mins(15)
731}
732
733/// Authentication configuration.
734#[derive(Debug, Clone, Default, Deserialize)]
735#[serde(deny_unknown_fields)]
736#[non_exhaustive]
737pub struct AuthConfig {
738    /// Master switch - when false, all requests are allowed through.
739    #[serde(default)]
740    pub enabled: bool,
741    /// Bearer token API keys.
742    #[serde(default)]
743    pub api_keys: Vec<ApiKeyEntry>,
744    /// mTLS client certificate authentication.
745    pub mtls: Option<MtlsConfig>,
746    /// Rate limiting for auth attempts.
747    pub rate_limit: Option<RateLimitConfig>,
748    /// OAuth 2.1 JWT bearer token authentication.
749    #[cfg(feature = "oauth")]
750    pub oauth: Option<crate::oauth::OAuthConfig>,
751    /// Presence-only placeholder for `auth.oauth` in builds without the
752    /// `oauth` cargo feature.
753    ///
754    /// `deny_unknown_fields` (above) would otherwise reject an `[auth.oauth]`
755    /// table with `unknown field \`oauth\``, which never mentions the feature
756    /// flag and sends operators hunting for a typo that does not exist.
757    /// Accepting the key here and rejecting it in
758    /// [`AuthConfig::check_oauth_feature`] turns that into an actionable
759    /// message. `IgnoredAny` records presence without retaining the value, so
760    /// no OAuth secret is held in memory by a build that cannot use it.
761    #[cfg(not(feature = "oauth"))]
762    #[serde(default)]
763    pub(crate) oauth: Option<serde::de::IgnoredAny>,
764}
765
766impl AuthConfig {
767    /// Create an enabled auth config with the given API keys.
768    #[must_use]
769    pub fn with_keys(keys: Vec<ApiKeyEntry>) -> Self {
770        Self {
771            enabled: true,
772            api_keys: keys,
773            mtls: None,
774            rate_limit: None,
775            #[cfg(feature = "oauth")]
776            oauth: None,
777            #[cfg(not(feature = "oauth"))]
778            oauth: None,
779        }
780    }
781
782    /// Set rate limiting on this auth config.
783    #[must_use]
784    pub fn with_rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
785        self.rate_limit = Some(rate_limit);
786        self
787    }
788
789    /// Reject an `[auth.oauth]` table in a build compiled without the `oauth`
790    /// cargo feature.
791    ///
792    /// Fails closed on purpose. Ignoring the table would start the server with
793    /// OAuth silently disabled while the operator's configuration says it is
794    /// on -- for a bearer-token deployment that is an unauthenticated server.
795    ///
796    /// # Errors
797    ///
798    /// Returns [`RmcpServerKitError::Config`] when `auth.oauth` is present and
799    /// the `oauth` feature is disabled. Always `Ok` when the feature is
800    /// enabled, where the table is parsed into
801    /// [`oauth::OAuthConfig`](crate::oauth::OAuthConfig) instead.
802    pub fn check_oauth_feature(&self) -> Result<(), RmcpServerKitError> {
803        #[cfg(not(feature = "oauth"))]
804        {
805            (self.oauth.is_none()).ok_or_else(|| {
806                RmcpServerKitError::Config(
807                    "auth.oauth is configured but this build of rmcp-server-kit was compiled \
808                     without the `oauth` cargo feature; rebuild with `--features oauth` or \
809                     remove the [auth.oauth] table"
810                        .into(),
811                )
812            })?;
813        }
814        Ok(())
815    }
816}
817
818/// Summary of a single API key suitable for admin endpoints.
819///
820/// Intentionally omits the Argon2id hash - only metadata is exposed.
821#[derive(Debug, Clone, serde::Serialize)]
822#[non_exhaustive]
823pub struct ApiKeySummary {
824    /// Human-readable key label.
825    pub name: String,
826    /// RBAC role granted when this key authenticates.
827    pub role: String,
828    /// Optional RFC 3339 expiry timestamp. Serialized as a canonical
829    /// RFC 3339 string so the admin-endpoint wire format is preserved.
830    pub expires_at: Option<RfcTimestamp>,
831}
832
833/// Snapshot of the enabled authentication methods for admin endpoints.
834#[derive(Debug, Clone, serde::Serialize)]
835#[allow(
836    clippy::struct_excessive_bools,
837    reason = "this is a flat summary of independent auth-method booleans"
838)]
839#[non_exhaustive]
840pub struct AuthConfigSummary {
841    /// Master enabled flag from config.
842    pub enabled: bool,
843    /// Whether API-key bearer auth is configured.
844    pub bearer: bool,
845    /// Whether mTLS client auth is configured.
846    pub mtls: bool,
847    /// Whether OAuth JWT validation is configured.
848    pub oauth: bool,
849    /// Current API-key list (no hashes).
850    pub api_keys: Vec<ApiKeySummary>,
851}
852
853impl AuthConfig {
854    /// Produce a hash-free summary of the auth config for admin endpoints.
855    #[must_use]
856    pub fn summary(&self) -> AuthConfigSummary {
857        AuthConfigSummary {
858            enabled: self.enabled,
859            bearer: !self.api_keys.is_empty(),
860            mtls: self.mtls.is_some(),
861            #[cfg(feature = "oauth")]
862            oauth: self.oauth.is_some(),
863            #[cfg(not(feature = "oauth"))]
864            oauth: false,
865            api_keys: self
866                .api_keys
867                .iter()
868                .map(|k| ApiKeySummary {
869                    name: k.name.clone(),
870                    role: k.role.clone(),
871                    expires_at: k.expires_at,
872                })
873                .collect(),
874        }
875    }
876}
877
878/// Keyed rate limiter type (per source IP). Memory-bounded by
879/// [`RateLimitConfig::max_tracked_keys`] to defend against IP-spray `DoS`.
880pub(crate) type KeyedLimiter = BoundedKeyedLimiter<RateLimitKey>;
881
882/// Connection info for TLS connections, carrying the peer socket address
883/// and (when mTLS is configured) the verified client identity extracted
884/// from the peer certificate during the TLS handshake.
885///
886/// Defined as a local type so we can implement axum's `Connected` trait
887/// for our custom `TlsListener` without orphan rule issues. The `identity`
888/// field travels with the connection itself (via the wrapping IO type),
889/// so there is no shared map to race against, no port-reuse aliasing, and
890/// no eviction policy to maintain.
891#[derive(Clone, Debug)]
892#[non_exhaustive]
893pub(crate) struct TlsConnInfo {
894    /// Remote peer socket address.
895    pub addr: SocketAddr,
896    /// Verified mTLS client identity, if a client certificate was presented
897    /// and successfully extracted during the TLS handshake.
898    pub identity: Option<AuthIdentity>,
899}
900
901impl TlsConnInfo {
902    /// Construct a new [`TlsConnInfo`].
903    #[must_use]
904    pub(crate) const fn new(addr: SocketAddr, identity: Option<AuthIdentity>) -> Self {
905        Self { addr, identity }
906    }
907}
908
909/// Default hard cap on the number of distinct authenticated identities
910/// remembered by [`SeenIdentitySet`].
911///
912/// Sized to comfortably exceed realistic identity churn for an MCP server
913/// while bounding worst-case memory at roughly `4096 * avg_name_len`
914/// (~256 KiB at 64-byte names). Honest clients will never trigger eviction;
915/// hostile churn (rotating mTLS subjects or OAuth `sub` values) is bounded.
916const DEFAULT_SEEN_IDENTITY_CAP: usize = 4096;
917
918/// Bounded set tracking which authenticated identities have already been
919/// logged at INFO level (subsequent auths fall back to DEBUG).
920///
921/// # Why bounded?
922///
923/// `id.name` is attacker-influenced under mTLS (SAN/CN) and OAuth (`sub`).
924/// An unbounded [`std::collections::HashSet`] would grow with churn,
925/// producing both a slow memory leak and unbounded log-cardinality
926/// downstream (Loki/ES). The cap follows the same trade-off documented in
927/// [`crate::bounded_limiter`]: when an evicted identity reappears it
928/// re-fires INFO once. This is acceptable for diagnostic logging.
929///
930/// # Concurrency
931///
932/// Uses [`std::sync::Mutex`] because [`Self::insert_is_first`] is purely
933/// synchronous and the critical section never `.await`s. The mutex is
934/// poison-tolerant: a poisoned set is still logically consistent
935/// (only writer is `insert_is_first`, which performs an atomic insert
936/// + bounded eviction; no torn invariants are possible).
937pub(crate) struct SeenIdentitySet {
938    inner: Mutex<SeenInner>,
939}
940
941struct SeenInner {
942    set: HashSet<String>,
943    /// Insertion-order FIFO used for bounded eviction. Tracking strict LRU
944    /// would require touching the queue on every hit (under the mutex);
945    /// FIFO is sufficient because the contract only promises "bounded
946    /// memory", not "remember the most recently seen identities".
947    order: std::collections::VecDeque<String>,
948    cap: usize,
949}
950
951impl SeenIdentitySet {
952    /// Construct with the default cap of [`DEFAULT_SEEN_IDENTITY_CAP`].
953    #[must_use]
954    pub(crate) fn new() -> Self {
955        Self::with_cap(DEFAULT_SEEN_IDENTITY_CAP)
956    }
957
958    /// Construct with an explicit cap. A `cap` of `0` is silently raised
959    /// to `1` to keep the invariant `set.len() <= cap` non-vacuous.
960    #[must_use]
961    pub(crate) fn with_cap(cap: usize) -> Self {
962        let cap = cap.max(1);
963        Self {
964            inner: Mutex::new(SeenInner {
965                set: HashSet::with_capacity(cap.min(64)),
966                order: std::collections::VecDeque::with_capacity(cap.min(64)),
967                cap,
968            }),
969        }
970    }
971
972    /// Insert `name`. Returns `true` if this is the first time `name` was
973    /// inserted (or it was previously evicted and reinserted), `false`
974    /// if it was already present.
975    ///
976    /// When the cap is reached, the oldest inserted entry is evicted to
977    /// make room. Eviction never blocks the caller.
978    pub(crate) fn insert_is_first(&self, name: &str) -> bool {
979        // SAFETY: the only writer is this method; a poisoned set remains
980        // logically consistent (atomic insert + bounded eviction preserve
981        // the `set.len() <= cap` invariant). Continuing past poison only
982        // affects diagnostic logging granularity, not correctness or
983        // security.
984        let mut guard = self
985            .inner
986            .lock()
987            .unwrap_or_else(std::sync::PoisonError::into_inner);
988
989        if guard.set.contains(name) {
990            return false;
991        }
992        // Cap enforcement: evict-then-insert keeps the invariant
993        // `set.len() <= cap` even when the cap is `1`.
994        if guard.set.len() >= guard.cap
995            && let Some(evicted) = guard.order.pop_front()
996        {
997            guard.set.remove(&evicted);
998        }
999        let owned = name.to_owned();
1000        guard.set.insert(owned.clone());
1001        guard.order.push_back(owned);
1002        true
1003    }
1004
1005    /// Test-only snapshot of the current size.
1006    #[cfg(test)]
1007    pub(crate) fn len(&self) -> usize {
1008        self.inner
1009            .lock()
1010            .unwrap_or_else(std::sync::PoisonError::into_inner)
1011            .set
1012            .len()
1013    }
1014}
1015
1016impl Default for SeenIdentitySet {
1017    fn default() -> Self {
1018        Self::new()
1019    }
1020}
1021
1022/// Shared state for the auth middleware.
1023///
1024/// `api_keys` uses [`ArcSwap`] so the SIGHUP handler can atomically
1025/// swap in a new key list without blocking in-flight requests.
1026#[allow(
1027    missing_debug_implementations,
1028    reason = "contains governor RateLimiter and JwksCache without Debug impls"
1029)]
1030#[non_exhaustive]
1031pub(crate) struct AuthState {
1032    /// Active set of API keys (hot-swappable).
1033    pub api_keys: ArcSwap<Vec<ApiKeyEntry>>,
1034    /// Optional per-IP post-failure rate limiter (consulted *after* auth fails).
1035    pub rate_limiter: Option<Arc<KeyedLimiter>>,
1036    /// Optional per-IP pre-auth abuse gate (consulted *before* password-hash work).
1037    /// mTLS-authenticated connections bypass this gate.
1038    pub pre_auth_limiter: Option<Arc<KeyedLimiter>>,
1039    #[cfg(feature = "oauth")]
1040    /// Optional JWKS cache for OAuth JWT validation.
1041    pub jwks_cache: Option<Arc<crate::oauth::JwksCache>>,
1042    /// Tracks identity names that have already been logged at INFO level.
1043    /// Subsequent auths for the same identity are logged at DEBUG.
1044    /// Bounded to prevent attacker-driven memory growth via churned
1045    /// mTLS subjects or OAuth `sub` claims (see [`SeenIdentitySet`]).
1046    pub seen_identities: SeenIdentitySet,
1047    /// Lightweight in-memory auth success/failure counters for diagnostics.
1048    pub counters: AuthCounters,
1049    /// Absolute URL of this server's RFC 9728 Protected Resource Metadata,
1050    /// advertised in the `WWW-Authenticate` challenge.
1051    ///
1052    /// RFC 9728 5.1 defines `resource_metadata` as a URL; emitting an
1053    /// absolute one lets a client resolve it without knowing the origin it
1054    /// was challenged from. `None` falls back to the well-known path, which
1055    /// stays correct for same-origin clients.
1056    pub resource_metadata_url: Option<String>,
1057}
1058
1059impl AuthState {
1060    /// Atomically replace the API key list (lock-free, wait-free).
1061    ///
1062    /// New requests immediately see the updated keys.
1063    /// In-flight requests that already loaded the old list finish
1064    /// using it -- no torn reads.
1065    pub(crate) fn reload_keys(&self, keys: Vec<ApiKeyEntry>) {
1066        let count = keys.len();
1067        self.api_keys.store(Arc::new(keys));
1068        tracing::info!(keys = count, "API keys reloaded");
1069    }
1070
1071    /// Snapshot auth counters for diagnostics and tests.
1072    #[must_use]
1073    pub(crate) fn counters_snapshot(&self) -> AuthCountersSnapshot {
1074        self.counters.snapshot()
1075    }
1076
1077    /// Produce the admin-endpoint list of API keys (metadata only, no hashes).
1078    #[must_use]
1079    pub(crate) fn api_key_summaries(&self) -> Vec<ApiKeySummary> {
1080        self.api_keys
1081            .load()
1082            .iter()
1083            .map(|k| ApiKeySummary {
1084                name: k.name.clone(),
1085                role: k.role.clone(),
1086                expires_at: k.expires_at,
1087            })
1088            .collect()
1089    }
1090
1091    /// Log auth success: INFO on first occurrence per identity, DEBUG after.
1092    ///
1093    /// Backed by [`SeenIdentitySet`], a bounded FIFO set that caps
1094    /// retained identities to prevent attacker-driven memory growth.
1095    /// FIFO (not LRU) is intentional: this cache de-duplicates INFO logs,
1096    /// not security state, so per-hit eviction-order mutation is not
1097    /// justified. See [`SeenIdentitySet`] for the full trade-off rationale.
1098    fn log_auth(&self, id: &AuthIdentity, method: &str) {
1099        self.counters.record_success(id.method);
1100        let first = self.seen_identities.insert_is_first(&id.name);
1101        if first {
1102            tracing::info!(name = %id.name, role = %id.role, "{method} authenticated");
1103        } else {
1104            tracing::debug!(name = %id.name, role = %id.role, "{method} authenticated");
1105        }
1106    }
1107}
1108
1109/// Default auth rate limit: 30 attempts per minute per source IP.
1110// SAFETY: unwrap() is safe - literal 30 is provably non-zero (const-evaluated).
1111const DEFAULT_AUTH_RATE: NonZeroU32 = NonZeroU32::new(30).unwrap();
1112
1113/// Apply an optional burst capacity to a quota. `None` keeps governor's
1114/// default (burst = rate). Zero values are rejected at config-validation
1115/// time; the `NonZeroU32` filter here is defensive only.
1116fn apply_burst(quota: governor::Quota, burst: Option<u32>) -> governor::Quota {
1117    match burst.and_then(NonZeroU32::new) {
1118        Some(b) => quota.allow_burst(b),
1119        None => quota,
1120    }
1121}
1122
1123/// Create a post-failure rate limiter from config.
1124#[must_use]
1125pub(crate) fn build_rate_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1126    // Defense in depth: `serve()` and `serve_with_listener()` require a
1127    // `Validated<McpServerConfig>` and reject zero before startup, but
1128    // `auth::tests` construct limiters directly from raw `RateLimitConfig`
1129    // values to exercise limiter behavior without building a full server.
1130    let quota = governor::Quota::per_minute(
1131        NonZeroU32::new(config.max_attempts_per_minute).unwrap_or(DEFAULT_AUTH_RATE),
1132    );
1133    let quota = apply_burst(quota, config.burst);
1134    // Defense in depth: Phase-1 config validation rejects `0` upstream, but
1135    // tests can still exercise this helper directly with raw config values.
1136    let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1137    Arc::new(BoundedKeyedLimiter::new_with_policy(
1138        quota,
1139        max_tracked_keys,
1140        config.idle_eviction,
1141        config.key_eviction_policy,
1142    ))
1143}
1144
1145/// Create a pre-auth abuse-gate rate limiter from config.
1146///
1147/// Quota: `pre_auth_max_per_minute` if set, otherwise
1148/// `max_attempts_per_minute * 10` (capped at `u32::MAX`). The 10× factor
1149/// keeps the gate generous enough for honest retries while still bounding
1150/// attacker CPU on Argon2 verification.
1151#[must_use]
1152pub(crate) fn build_pre_auth_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1153    let resolved = config.pre_auth_max_per_minute.unwrap_or_else(|| {
1154        config
1155            .max_attempts_per_minute
1156            .saturating_mul(PRE_AUTH_DEFAULT_MULTIPLIER)
1157    });
1158    let quota =
1159        governor::Quota::per_minute(NonZeroU32::new(resolved).unwrap_or(DEFAULT_PRE_AUTH_RATE));
1160    let quota = apply_burst(quota, config.pre_auth_burst);
1161    // Defense in depth: Phase-1 config validation rejects `0` upstream, but
1162    // tests can still exercise this helper directly with raw config values.
1163    let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1164    Arc::new(BoundedKeyedLimiter::new_with_policy(
1165        quota,
1166        max_tracked_keys,
1167        config.idle_eviction,
1168        config.key_eviction_policy,
1169    ))
1170}
1171
1172/// Default multiplier applied to `max_attempts_per_minute` when the operator
1173/// does not set `pre_auth_max_per_minute` explicitly.
1174const PRE_AUTH_DEFAULT_MULTIPLIER: u32 = 10;
1175
1176/// Default pre-auth abuse-gate rate (used only if both the configured value
1177/// and the multiplied fallback are zero, which `NonZeroU32::new` rejects).
1178// SAFETY: unwrap() is safe - literal 300 is provably non-zero (const-evaluated).
1179const DEFAULT_PRE_AUTH_RATE: NonZeroU32 = NonZeroU32::new(300).unwrap();
1180
1181/// Parse an mTLS client certificate and extract an `AuthIdentity`.
1182///
1183/// Reads the Subject CN as the identity name. Falls back to the first
1184/// DNS SAN if CN is absent. The role is taken from the `MtlsConfig`.
1185#[must_use]
1186pub fn extract_mtls_identity(cert_der: &[u8], default_role: &str) -> Option<AuthIdentity> {
1187    let (_, cert) = X509Certificate::from_der(cert_der).ok()?;
1188
1189    // Try CN from Subject first.
1190    let cn = cert
1191        .subject()
1192        .iter_common_name()
1193        .next()
1194        .and_then(|attr| attr.as_str().ok())
1195        .map(String::from);
1196
1197    // Fall back to first DNS SAN.
1198    let name = cn.or_else(|| {
1199        cert.subject_alternative_name()
1200            .ok()
1201            .flatten()
1202            .and_then(|san| {
1203                #[allow(
1204                    clippy::wildcard_enum_match_arm,
1205                    reason = "x509-parser GeneralName is a large external enum; only DNSName is meaningful here"
1206                )]
1207                san.value.general_names.iter().find_map(|gn| match gn {
1208                    GeneralName::DNSName(dns) => Some((*dns).to_owned()),
1209                    _ => None,
1210                })
1211            })
1212    })?;
1213
1214    // Reject identities with characters unsafe for logging and RBAC matching.
1215    if !name
1216        .chars()
1217        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '.' | '_' | '@'))
1218    {
1219        tracing::warn!(cn = %name, "mTLS identity rejected: invalid characters in CN/SAN");
1220        return None;
1221    }
1222
1223    Some(AuthIdentity {
1224        name,
1225        role: default_role.to_owned(),
1226        method: AuthMethod::MtlsCertificate,
1227        raw_token: None,
1228        sub: None,
1229    })
1230}
1231
1232/// Extract the bearer token from an `Authorization` header value.
1233///
1234/// Implements RFC 7235 §2.1: the auth-scheme token is **case-insensitive**.
1235/// `Bearer`, `bearer`, `BEARER`, and `BeArEr` all parse equivalently. Any
1236/// leading whitespace between the scheme and the token is trimmed (per
1237/// RFC 7235 the separator is one or more SP characters; we accept the
1238/// common single-space form plus tolerate extras).
1239///
1240/// Returns `None` if the header value:
1241/// - does not contain a space (no scheme/credentials boundary), or
1242/// - uses a scheme other than `Bearer` (case-insensitively), or
1243/// - carries a credential containing embedded whitespace.
1244///
1245/// # Why whitespace only, and not full `token68`
1246///
1247/// RFC 7235 §2.1 defines the credential as `token68`, which excludes
1248/// whitespace. Accepting an embedded SP/HTAB creates a parser differential
1249/// against a fronting proxy that splits on any whitespace.
1250///
1251/// Enforcing the whole `token68` character class would be a breaking
1252/// change: [`ApiKeyEntry::new`] accepts an arbitrary caller-supplied hash
1253/// and [`verify_bearer_token`] verifies the raw presented string, so
1254/// consumers may have hashed opaque tokens containing punctuation outside
1255/// `token68`. Those must keep authenticating -- do not "complete" this
1256/// check without a major-version note.
1257///
1258/// ASCII semantics suffice because [`http::HeaderValue::to_str`] rejects
1259/// every non-visible byte before this helper runs.
1260fn extract_bearer(value: &str) -> Option<&str> {
1261    let (scheme, rest) = value.split_once(' ')?;
1262    if !scheme.eq_ignore_ascii_case("Bearer") {
1263        return None;
1264    }
1265    let token = rest.trim_start_matches(' ');
1266    if token.is_empty() || token.bytes().any(|b| b.is_ascii_whitespace()) {
1267        return None;
1268    }
1269    Some(token)
1270}
1271
1272/// Verify a bearer token against configured API keys.
1273///
1274/// Argon2id verification is CPU-intensive, so this should be called via
1275/// `spawn_blocking`. Returns the matching identity if the token is valid.
1276///
1277/// # Timing-side-channel resistance
1278///
1279/// Always performs **exactly one Argon2id verification per configured key**,
1280/// regardless of:
1281///
1282/// * which slot (if any) matches the presented token, or
1283/// * whether a key has expired.
1284///
1285/// Expired and post-match slots are verified against an internal dummy PHC hash,
1286/// a fixed Argon2id PHC string with the same cost parameters as the real
1287/// hashes. This bounds the timing observable to "one Argon2 per configured
1288/// key" regardless of which (if any) slot held the matching credential,
1289/// closing the first-match latency oracle (CWE-208) and the expired-slot
1290/// timing leak.
1291///
1292/// `subtle::ConstantTimeEq` folds each slot's match bit into the running
1293/// result without comparing the token bytes in short-circuiting fashion.
1294///
1295/// The guarantee this function provides is the Argon2 count, not full
1296/// branchlessness: selecting `verify_against` and recording `matched_index`
1297/// are both ordinary data-dependent branches. They are cheap, predictable,
1298/// and operate on locals, so they are dwarfed by the Argon2id verification
1299/// that dominates every iteration -- but the timing claim stops at
1300/// "one verification per configured key". Do not read this as a
1301/// constant-time selection routine.
1302///
1303/// # Panics
1304///
1305/// Panics if the internal dummy PHC hash cannot be parsed as an Argon2id PHC string.
1306/// This is impossible by construction: the static is generated by
1307/// [`argon2::Argon2::hash_password`] which always emits a valid PHC string.
1308#[must_use]
1309pub fn verify_bearer_token(token: &str, keys: &[ApiKeyEntry]) -> Option<AuthIdentity> {
1310    use subtle::ConstantTimeEq as _;
1311
1312    let now = chrono::Utc::now();
1313    #[allow(
1314        clippy::expect_used,
1315        reason = "DUMMY_PHC_HASH is a static LazyLock built from a fixed Argon2id PHC string by construction; PasswordHash::new on it is infallible. See DUMMY_PHC_HASH definition."
1316    )]
1317    let dummy_hash = PasswordHash::new(&DUMMY_PHC_HASH)
1318        .expect("DUMMY_PHC_HASH is a valid Argon2id PHC string by construction");
1319
1320    let mut matched_index: usize = usize::MAX;
1321    let mut any_match: u8 = 0;
1322
1323    for (idx, key) in keys.iter().enumerate() {
1324        let expired = key.expires_at.is_some_and(|exp| exp.as_datetime() < &now);
1325
1326        let real_hash = PasswordHash::new(&key.hash);
1327        let verify_against = match (&real_hash, expired, any_match) {
1328            (Ok(h), false, 0) => h,
1329            _ => &dummy_hash,
1330        };
1331
1332        let slot_ok = u8::from(
1333            Argon2::default()
1334                .verify_password(token.as_bytes(), verify_against)
1335                .is_ok(),
1336        );
1337
1338        let real_match = slot_ok & u8::from(!expired) & u8::from(real_hash.is_ok());
1339        let first_real_match = real_match & (1 - any_match);
1340        if first_real_match.ct_eq(&1).into() {
1341            matched_index = idx;
1342        }
1343        any_match |= real_match;
1344    }
1345
1346    if any_match == 0 {
1347        return None;
1348    }
1349    let key = keys.get(matched_index)?;
1350    Some(AuthIdentity {
1351        name: key.name.clone(),
1352        role: key.role.clone(),
1353        method: AuthMethod::BearerToken,
1354        raw_token: None,
1355        sub: None,
1356    })
1357}
1358
1359/// Fixed Argon2id PHC hash used as a constant-time placeholder when an
1360/// API-key slot is expired, malformed, or follows the matching slot.
1361///
1362/// Generated once on first access using the same default Argon2 cost
1363/// parameters as live verifications, so the dummy verify takes
1364/// indistinguishable wall time from a real one. The plaintext
1365/// (`"rmcp-server-kit-dummy"`) and the fixed salt are unrelated to any
1366/// real credential — randomness is unnecessary because this hash is
1367/// only ever compared against attacker-supplied input on slots that
1368/// will be discarded regardless of match result. Argon2's work factor is
1369/// set by the PHC `m`/`t`/`p` parameters, not by the salt value, so a
1370/// fixed salt costs exactly what a random one would;
1371/// `dummy_and_real_hashes_share_cost_parameters` pins that equivalence.
1372static DUMMY_PHC_HASH: LazyLock<String> = LazyLock::new(|| {
1373    #[allow(
1374        clippy::expect_used,
1375        reason = "Argon2::default() over a fixed plaintext and a fixed 16-byte salt is infallible; it fails only on invalid params or salt length, both constants here"
1376    )]
1377    Argon2::default()
1378        .hash_password_with_salt(b"rmcp-server-kit-dummy", &[0u8; 16])
1379        .expect("Argon2 default params hash a fixed plaintext")
1380        .to_string()
1381});
1382
1383/// Generate a new API key: 256-bit random token + Argon2id hash.
1384///
1385/// Returns `(plaintext_token, argon2id_hash_phc_string)`.
1386/// The plaintext is shown once to the user and never stored.
1387///
1388/// # Errors
1389///
1390/// Returns an error if Argon2id hashing fails (should not happen with valid
1391/// inputs, but we avoid panicking).
1392pub fn generate_api_key() -> Result<(String, String), RmcpServerKitError> {
1393    let mut token_bytes = [0u8; 32];
1394    rand::fill(&mut token_bytes);
1395    let token = URL_SAFE_NO_PAD.encode(token_bytes);
1396
1397    let mut salt_bytes = [0u8; 16];
1398    rand::fill(&mut salt_bytes);
1399    let hash = Argon2::default()
1400        .hash_password_with_salt(token.as_bytes(), &salt_bytes)
1401        .map_err(|e| RmcpServerKitError::Internal(format!("argon2id hashing failed: {e}")))?
1402        .to_string();
1403
1404    Ok((token, hash))
1405}
1406
1407fn build_www_authenticate_value(
1408    resource_metadata: Option<&str>,
1409    failure: AuthFailureClass,
1410) -> String {
1411    let (error, error_description) = failure.bearer_error();
1412    if let Some(url) = resource_metadata {
1413        return format!(
1414            "Bearer resource_metadata=\"{url}\", error=\"{error}\", error_description=\"{error_description}\""
1415        );
1416    }
1417    format!("Bearer error=\"{error}\", error_description=\"{error_description}\"")
1418}
1419
1420fn auth_method_label(method: AuthMethod) -> &'static str {
1421    match method {
1422        AuthMethod::MtlsCertificate => "mTLS",
1423        AuthMethod::BearerToken => "bearer token",
1424        AuthMethod::OAuthJwt => "OAuth JWT",
1425    }
1426}
1427
1428#[cfg_attr(
1429    not(feature = "oauth"),
1430    allow(
1431        unused_variables,
1432        reason = "`state` is only read to decide whether to advertise OAuth \
1433                  protected-resource metadata; without the `oauth` feature that \
1434                  decision is a compile-time `false`"
1435    )
1436)]
1437fn unauthorized_response(state: &AuthState, failure_class: AuthFailureClass) -> Response {
1438    #[cfg(feature = "oauth")]
1439    let advertise_resource_metadata = state.jwks_cache.is_some();
1440    #[cfg(not(feature = "oauth"))]
1441    let advertise_resource_metadata = false;
1442
1443    let resource_metadata = advertise_resource_metadata.then(|| {
1444        state
1445            .resource_metadata_url
1446            .as_deref()
1447            .unwrap_or("/.well-known/oauth-protected-resource")
1448    });
1449    let challenge = build_www_authenticate_value(resource_metadata, failure_class);
1450    (
1451        StatusCode::UNAUTHORIZED,
1452        [(header::WWW_AUTHENTICATE, challenge)],
1453        failure_class.response_body(),
1454    )
1455        .into_response()
1456}
1457
1458// cancel-safe: no shared-state mutation. The Argon2 verification is offloaded
1459// to `spawn_blocking`; dropping its `JoinHandle` on cancellation detaches the
1460// task (the hash completes off-task, harmlessly) rather than tearing partial
1461// state. The OAuth branch delegates to `validate_token_with_reason`, which is
1462// itself cancel-safe (read-only JWKS lookup + pure claim checks).
1463async fn authenticate_bearer_identity(
1464    state: &AuthState,
1465    token: &str,
1466) -> Result<AuthIdentity, AuthFailureClass> {
1467    let mut failure_class = AuthFailureClass::MissingCredential;
1468
1469    #[cfg(feature = "oauth")]
1470    if let Some(ref cache) = state.jwks_cache
1471        && crate::oauth::looks_like_jwt(token)
1472    {
1473        match cache.validate_token_with_reason(token).await {
1474            Ok(mut id) => {
1475                id.raw_token = Some(SecretString::from(token.to_owned()));
1476                return Ok(id);
1477            }
1478            Err(crate::oauth::JwtValidationFailure::Expired) => {
1479                failure_class = AuthFailureClass::ExpiredCredential;
1480            }
1481            Err(crate::oauth::JwtValidationFailure::Invalid) => {
1482                failure_class = AuthFailureClass::InvalidCredential;
1483            }
1484        }
1485    }
1486
1487    let token = token.to_owned();
1488    let keys = state.api_keys.load_full(); // Arc clone, lock-free
1489
1490    // Argon2id is CPU-bound - offload to blocking thread pool.
1491    let identity = tokio::task::spawn_blocking(move || verify_bearer_token(&token, &keys))
1492        .await
1493        .ok()
1494        .flatten();
1495
1496    if let Some(id) = identity {
1497        return Ok(id);
1498    }
1499
1500    if failure_class == AuthFailureClass::MissingCredential {
1501        failure_class = AuthFailureClass::InvalidCredential;
1502    }
1503
1504    Err(failure_class)
1505}
1506
1507/// Consult the pre-auth abuse gate for the given peer.
1508///
1509/// Returns `Some(response)` if the request should be rejected (limiter
1510/// configured AND quota exhausted for this source IP). Returns `None`
1511/// otherwise (limiter absent, peer address unknown, or quota available),
1512/// in which case the caller should proceed with credential verification.
1513///
1514/// Side effects on rejection: increments the `pre_auth_gate` failure
1515/// counter and emits a warn-level log. mTLS-authenticated requests must
1516/// be admitted by the caller *before* invoking this helper.
1517fn pre_auth_gate(state: &AuthState, client_key: Option<&RateLimitKey>) -> Option<Response> {
1518    let limiter = state.pre_auth_limiter.as_ref()?;
1519    let key = client_key?;
1520    match limiter.check_key_detailed(key) {
1521        Ok(()) => None,
1522        Err(BoundedLimiterDeny::RateLimited(wait)) => {
1523            state.counters.record_failure(AuthFailureClass::PreAuthGate);
1524            tracing::warn!(
1525                rate_limit_key = %key,
1526                "auth rate limited by pre-auth gate (request rejected before credential verification)"
1527            );
1528            Some(
1529                RmcpServerKitError::RateLimitedFor {
1530                    message: "too many unauthenticated requests from this source".into(),
1531                    retry_after: wait,
1532                }
1533                .into_response(),
1534            )
1535        }
1536        Err(BoundedLimiterDeny::CapacityFull) => {
1537            tracing::warn!(
1538                rate_limit_key = %key,
1539                "auth pre-auth gate rejected unseen key because tracked-key capacity is full"
1540            );
1541            Some(
1542                (
1543                    StatusCode::SERVICE_UNAVAILABLE,
1544                    "rate limiter capacity exhausted",
1545                )
1546                    .into_response(),
1547            )
1548        }
1549    }
1550}
1551
1552#[cfg_attr(
1553    not(feature = "metrics"),
1554    allow(
1555        unused_variables,
1556        reason = "`extensions` is read only to record the \
1557                  `rmcp_server_kit_rate_limited_total` metric; without the \
1558                  `metrics` feature there is no recording site"
1559    )
1560)]
1561fn post_failure_rate_limit_response(
1562    limiter: &KeyedLimiter,
1563    key: &RateLimitKey,
1564    extensions: &axum::http::Extensions,
1565) -> Option<Response> {
1566    match limiter.check_key_detailed(key) {
1567        Ok(()) => None,
1568        Err(BoundedLimiterDeny::RateLimited(wait)) => {
1569            #[cfg(feature = "metrics")]
1570            crate::metrics::record_rate_limit_deny(extensions, "auth_post");
1571            tracing::warn!(rate_limit_key = %key, "auth rate limited after repeated failures");
1572            Some(
1573                RmcpServerKitError::RateLimitedFor {
1574                    message: "too many failed authentication attempts".into(),
1575                    retry_after: wait,
1576                }
1577                .into_response(),
1578            )
1579        }
1580        Err(BoundedLimiterDeny::CapacityFull) => {
1581            tracing::warn!(
1582                rate_limit_key = %key,
1583                "auth post-failure limiter rejected unseen key because tracked-key capacity is full"
1584            );
1585            Some(
1586                (
1587                    StatusCode::SERVICE_UNAVAILABLE,
1588                    "rate limiter capacity exhausted",
1589                )
1590                    .into_response(),
1591            )
1592        }
1593    }
1594}
1595
1596/// Axum middleware that enforces authentication.
1597///
1598/// Tries authentication methods in priority order:
1599/// 1. mTLS client certificate identity (populated by TLS acceptor)
1600/// 2. Bearer token from `Authorization` header
1601///
1602/// Failed authentication attempts are rate-limited per source IP.
1603/// Successful authentications do not consume rate limit budget.
1604// cancel-safe: `TimeoutLayer` may drop this future, but limiter mutations are
1605// deliberate attempt accounting: pre-auth prices bearer/JWT verification,
1606// post-failure prices failed auth, and identity extensions die with the request.
1607pub(crate) async fn auth_middleware(
1608    state: Arc<AuthState>,
1609    req: Request<Body>,
1610    next: Next,
1611) -> Response {
1612    // Extract the mTLS identity from ConnectInfo (TLS / mTLS:
1613    // ConnectInfo<TlsConnInfo> carries the verified identity directly on
1614    // the connection — no shared map, no port-reuse aliasing) and the
1615    // rate-limit key (resolved client IP when trusted-forwarder mode is
1616    // active, else the direct peer; see transport::limiter_client_ip).
1617    let tls_info = req.extensions().get::<ConnectInfo<TlsConnInfo>>().cloned();
1618    // Resolved only when a limiter will actually consult it, so servers
1619    // with no rate limiting never trip the unattributed-fallback warning.
1620    let client_key = (state.pre_auth_limiter.is_some() || state.rate_limiter.is_some())
1621        .then(|| crate::transport::limiter_client_key(req.extensions()));
1622
1623    // 1. Try mTLS identity (extracted by the TLS acceptor during handshake
1624    //    and attached to the connection itself).
1625    //
1626    //    mTLS connections bypass the pre-auth abuse gate below: the TLS
1627    //    handshake already performed expensive crypto with a verified peer,
1628    //    so we trust them not to be a CPU-spray attacker.
1629    if let Some(id) = tls_info.and_then(|ci| ci.0.identity) {
1630        state.log_auth(&id, "mTLS");
1631        let mut req = req;
1632        req.extensions_mut().insert(id);
1633        return next.run(req).await;
1634    }
1635
1636    // 2. Pre-auth abuse gate: rejects CPU-spray attacks BEFORE the Argon2id
1637    //    verification path runs. Keyed by source IP. mTLS connections (above)
1638    //    are exempt; this gate only protects the bearer/JWT verification path.
1639    if let Some(blocked) = pre_auth_gate(&state, client_key.as_ref()) {
1640        #[cfg(feature = "metrics")]
1641        crate::metrics::record_rate_limit_deny(req.extensions(), "auth_pre");
1642        return blocked;
1643    }
1644
1645    let failure_class = if let Some(value) = req.headers().get(header::AUTHORIZATION) {
1646        match value.to_str().ok().and_then(extract_bearer) {
1647            Some(token) => match authenticate_bearer_identity(&state, token).await {
1648                Ok(id) => {
1649                    state.log_auth(&id, auth_method_label(id.method));
1650                    let mut req = req;
1651                    req.extensions_mut().insert(id);
1652                    return next.run(req).await;
1653                }
1654                Err(class) => class,
1655            },
1656            None => AuthFailureClass::InvalidCredential,
1657        }
1658    } else {
1659        AuthFailureClass::MissingCredential
1660    };
1661
1662    tracing::warn!(failure_class = %failure_class.as_str(), "auth failed");
1663
1664    // Rate limit check (applied after auth failure only).
1665    // Successful authentications do not consume rate limit budget.
1666    if let (Some(limiter), Some(key)) = (&state.rate_limiter, client_key.as_ref())
1667        && let Some(resp) = post_failure_rate_limit_response(limiter, key, req.extensions())
1668    {
1669        if resp.status() == StatusCode::TOO_MANY_REQUESTS {
1670            state.counters.record_failure(AuthFailureClass::RateLimited);
1671        }
1672        return resp;
1673    }
1674
1675    state.counters.record_failure(failure_class);
1676    unauthorized_response(&state, failure_class)
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681    use std::net::IpAddr;
1682
1683    use super::*;
1684    use crate::transport::RateLimitKey;
1685
1686    /// A PHC string produced by **argon2 0.5.3** through the same code path as
1687    /// [`generate_api_key`] (16 salt bytes, `Argon2::default()`).
1688    ///
1689    /// Pinned so the argon2 0.6 upgrade cannot silently invalidate credentials
1690    /// that are already deployed: if this stops verifying, every stored API key
1691    /// stops working. Captured before the upgrade and asserted after it.
1692    const ARGON2_0_5_TOKEN: &str = "golden-vector-token-0p5p3";
1693    const ARGON2_0_5_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$BwcHBwcHBwcHBwcHBwcHBw$spS8B9AhHG1LikfhGlssVMfP8mq37+8/mXnl98ps0NU";
1694
1695    #[test]
1696    fn argon2_0_5_produced_hash_still_verifies() {
1697        let parsed =
1698            PasswordHash::new(ARGON2_0_5_HASH).expect("a 0.5-era PHC string must still parse");
1699        Argon2::default()
1700            .verify_password(ARGON2_0_5_TOKEN.as_bytes(), &parsed)
1701            .expect("already-deployed API keys must keep verifying across the argon2 upgrade");
1702    }
1703
1704    /// The dummy hash burned on a miss must cost the same as a real one.
1705    ///
1706    /// Argon2 work is set by the PHC parameters, not the salt, so asserting the
1707    /// dummy and a freshly generated key share `argon2id`, `v=19` and identical
1708    /// `m`/`t`/`p` pins the constant-time property without a flaky wall-clock
1709    /// measurement. Forcing `DUMMY_PHC_HASH` here also surfaces a `LazyLock`
1710    /// panic in CI rather than at the first production auth.
1711    #[test]
1712    fn dummy_and_real_hashes_share_cost_parameters() {
1713        let (_token, real_hash) = generate_api_key().expect("key generation must succeed");
1714        let real = PasswordHash::new(&real_hash).expect("generated hash must parse");
1715        let dummy = PasswordHash::new(&DUMMY_PHC_HASH).expect("dummy hash must parse");
1716
1717        assert_eq!(dummy.algorithm, real.algorithm, "algorithm must match");
1718        assert_eq!(dummy.version, real.version, "PHC version must match");
1719        assert_eq!(
1720            dummy.params, real.params,
1721            "m/t/p must match or the dummy no longer costs what a real verification costs"
1722        );
1723    }
1724
1725    #[test]
1726    fn generate_and_verify_api_key() {
1727        let (token, hash) = generate_api_key().unwrap();
1728
1729        // Token is 43 chars (256-bit base64url, no padding)
1730        assert_eq!(token.len(), 43);
1731
1732        // Hash is a valid PHC string
1733        assert!(hash.starts_with("$argon2id$"));
1734
1735        // Verification succeeds with correct token
1736        let keys = vec![ApiKeyEntry {
1737            name: "test".into(),
1738            hash,
1739            role: "viewer".into(),
1740            expires_at: None,
1741        }];
1742        let id = verify_bearer_token(&token, &keys);
1743        assert!(id.is_some());
1744        let id = id.unwrap();
1745        assert_eq!(id.name, "test");
1746        assert_eq!(id.role, "viewer");
1747        assert_eq!(id.method, AuthMethod::BearerToken);
1748    }
1749
1750    #[test]
1751    fn wrong_token_rejected() {
1752        let (_token, hash) = generate_api_key().unwrap();
1753        let keys = vec![ApiKeyEntry {
1754            name: "test".into(),
1755            hash,
1756            role: "viewer".into(),
1757            expires_at: None,
1758        }];
1759        assert!(verify_bearer_token("wrong-token", &keys).is_none());
1760    }
1761
1762    #[test]
1763    fn expired_key_rejected() {
1764        let (token, hash) = generate_api_key().unwrap();
1765        let keys = vec![ApiKeyEntry {
1766            name: "test".into(),
1767            hash,
1768            role: "viewer".into(),
1769            expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1770        }];
1771        assert!(verify_bearer_token(&token, &keys).is_none());
1772    }
1773
1774    #[test]
1775    fn match_in_last_slot_still_authenticates() {
1776        let (token, hash) = generate_api_key().unwrap();
1777        let (_other_token, other_hash) = generate_api_key().unwrap();
1778        let keys = vec![
1779            ApiKeyEntry {
1780                name: "first".into(),
1781                hash: other_hash.clone(),
1782                role: "viewer".into(),
1783                expires_at: None,
1784            },
1785            ApiKeyEntry {
1786                name: "second".into(),
1787                hash: other_hash,
1788                role: "viewer".into(),
1789                expires_at: None,
1790            },
1791            ApiKeyEntry {
1792                name: "match".into(),
1793                hash,
1794                role: "ops".into(),
1795                expires_at: None,
1796            },
1797        ];
1798        let id = verify_bearer_token(&token, &keys).expect("last-slot match must authenticate");
1799        assert_eq!(id.name, "match");
1800        assert_eq!(id.role, "ops");
1801    }
1802
1803    #[test]
1804    fn expired_slot_before_valid_match_does_not_short_circuit() {
1805        let (token, hash) = generate_api_key().unwrap();
1806        let (_, other_hash) = generate_api_key().unwrap();
1807        let keys = vec![
1808            ApiKeyEntry {
1809                name: "expired".into(),
1810                hash: other_hash,
1811                role: "viewer".into(),
1812                expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1813            },
1814            ApiKeyEntry {
1815                name: "valid".into(),
1816                hash,
1817                role: "ops".into(),
1818                expires_at: None,
1819            },
1820        ];
1821        let id = verify_bearer_token(&token, &keys)
1822            .expect("valid slot following an expired slot must authenticate");
1823        assert_eq!(id.name, "valid");
1824    }
1825
1826    #[test]
1827    fn malformed_hash_slot_does_not_short_circuit() {
1828        let (token, hash) = generate_api_key().unwrap();
1829        let keys = vec![
1830            ApiKeyEntry {
1831                name: "broken".into(),
1832                hash: "this-is-not-a-phc-string".into(),
1833                role: "viewer".into(),
1834                expires_at: None,
1835            },
1836            ApiKeyEntry {
1837                name: "valid".into(),
1838                hash,
1839                role: "ops".into(),
1840                expires_at: None,
1841            },
1842        ];
1843        let id = verify_bearer_token(&token, &keys)
1844            .expect("valid slot following a malformed-hash slot must authenticate");
1845        assert_eq!(id.name, "valid");
1846    }
1847
1848    // Regression tests for H3 (api_key_expires_at_fail_open).
1849    //
1850    // Prior to 1.6.0 the runtime expiry check used a chained
1851    // `if let Some(_) && let Ok(exp) = parse(_) && exp < now` which
1852    // silently fell through on parse error, letting a key with
1853    // `expires_at = "not-a-date"` authenticate forever. These tests
1854    // pin the type-system fix: malformed RFC 3339 is rejected at
1855    // deserialization time (no `RfcTimestamp` can ever be malformed),
1856    // and the runtime check is a pure comparison with no parse path.
1857
1858    #[test]
1859    fn rfc_timestamp_parse_rejects_malformed() {
1860        for bad in [
1861            "not-a-date",
1862            "",
1863            "2025-13-01T00:00:00Z", // month 13
1864            "2025-01-32T00:00:00Z", // day 32
1865            "2025-01-01T00:00:00",  // missing offset
1866            "01/01/2025",           // wrong format
1867            "2025-01-01T25:00:00Z", // hour 25
1868        ] {
1869            assert!(
1870                RfcTimestamp::parse(bad).is_err(),
1871                "RfcTimestamp::parse must reject {bad:?}"
1872            );
1873        }
1874    }
1875
1876    #[test]
1877    fn rfc_timestamp_parse_accepts_valid() {
1878        for good in [
1879            "2025-01-01T00:00:00Z",
1880            "2025-01-01T00:00:00+00:00",
1881            "2025-12-31T23:59:59-08:00",
1882            "2099-01-01T00:00:00.123456789Z",
1883        ] {
1884            assert!(
1885                RfcTimestamp::parse(good).is_ok(),
1886                "RfcTimestamp::parse must accept {good:?}"
1887            );
1888        }
1889    }
1890
1891    #[test]
1892    fn api_key_entry_deserialize_rejects_malformed_expires_at() {
1893        // TOML with a malformed expires_at must fail to deserialize.
1894        // This is the load-time defense: a typo in auth.toml aborts
1895        // config load with a clear serde error, instead of producing
1896        // a key that authenticates forever (the H3 fail-open).
1897        let toml = r#"
1898            name = "bad-key"
1899            hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1900            role = "viewer"
1901            expires_at = "not-a-date"
1902        "#;
1903        let result: Result<ApiKeyEntry, _> = toml::from_str(toml);
1904        assert!(
1905            result.is_err(),
1906            "deserialization must reject malformed expires_at"
1907        );
1908    }
1909
1910    #[test]
1911    fn api_key_entry_deserialize_accepts_valid_expires_at() {
1912        let toml = r#"
1913            name = "good-key"
1914            hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1915            role = "viewer"
1916            expires_at = "2099-01-01T00:00:00Z"
1917        "#;
1918        let entry: ApiKeyEntry = toml::from_str(toml).expect("valid RFC 3339 must deserialize");
1919        assert!(entry.expires_at.is_some());
1920    }
1921
1922    #[test]
1923    fn api_key_entry_deserialize_accepts_missing_expires_at() {
1924        // Omitting expires_at must continue to mean "no expiry"; this
1925        // is the documented contract and must survive the H3 fix.
1926        let toml = r#"
1927            name = "eternal-key"
1928            hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1929            role = "viewer"
1930        "#;
1931        let entry: ApiKeyEntry = toml::from_str(toml).expect("missing expires_at must deserialize");
1932        assert!(entry.expires_at.is_none());
1933    }
1934
1935    #[test]
1936    fn mtls_crl_deny_on_unavailable_defaults_to_fail_closed() {
1937        // Every in-crate test helper builds MtlsConfig via a struct literal,
1938        // which bypasses serde defaults entirely. Only a deserialization from
1939        // TOML that omits the key exercises the shipped default.
1940        let toml = r#"
1941            ca_cert_path = "/etc/certs/clients-ca.pem"
1942        "#;
1943        let cfg: MtlsConfig = toml::from_str(toml).expect("minimal mtls config must deserialize");
1944        assert!(
1945            cfg.crl_deny_on_unavailable,
1946            "omitting crl_deny_on_unavailable must fail closed (RFC 5280 6.3)"
1947        );
1948    }
1949
1950    #[test]
1951    fn mtls_crl_deny_on_unavailable_opt_out_is_honoured() {
1952        let toml = r#"
1953            ca_cert_path = "/etc/certs/clients-ca.pem"
1954            crl_deny_on_unavailable = false
1955        "#;
1956        let cfg: MtlsConfig = toml::from_str(toml).expect("opt-out config must deserialize");
1957        assert!(
1958            !cfg.crl_deny_on_unavailable,
1959            "an explicit false must still select fail-open"
1960        );
1961    }
1962
1963    #[test]
1964    fn try_with_expiry_rejects_malformed() {
1965        let entry = ApiKeyEntry::new("k", "hash", "viewer");
1966        assert!(entry.try_with_expiry("not-a-date").is_err());
1967    }
1968
1969    #[test]
1970    fn try_with_expiry_accepts_valid() {
1971        let entry = ApiKeyEntry::new("k", "hash", "viewer")
1972            .try_with_expiry("2099-01-01T00:00:00Z")
1973            .expect("valid RFC 3339 must be accepted");
1974        assert!(entry.expires_at.is_some());
1975    }
1976
1977    #[test]
1978    fn api_key_summary_serializes_expires_at_as_rfc3339() {
1979        // The admin endpoint wire format is `{"expires_at": "RFC 3339 str"}`.
1980        // Pinning this prevents an accidental serialization-format change
1981        // (e.g. chrono's debug form, a Unix timestamp) that would silently
1982        // break operator tooling that parses these payloads.
1983        let summary = ApiKeySummary {
1984            name: "k".into(),
1985            role: "viewer".into(),
1986            expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
1987        };
1988        let json = serde_json::to_string(&summary).unwrap();
1989        assert!(
1990            json.contains(r#""expires_at":"2030-01-01T00:00:00+00:00""#),
1991            "wire format regressed: {json}"
1992        );
1993    }
1994
1995    #[test]
1996    fn future_expiry_accepted() {
1997        let (token, hash) = generate_api_key().unwrap();
1998        let keys = vec![ApiKeyEntry {
1999            name: "test".into(),
2000            hash,
2001            role: "viewer".into(),
2002            expires_at: Some(RfcTimestamp::parse("2099-01-01T00:00:00Z").unwrap()),
2003        }];
2004        assert!(verify_bearer_token(&token, &keys).is_some());
2005    }
2006
2007    #[test]
2008    fn multiple_keys_first_match_wins() {
2009        let (token, hash) = generate_api_key().unwrap();
2010        let keys = vec![
2011            ApiKeyEntry {
2012                name: "wrong".into(),
2013                hash: "$argon2id$v=19$m=19456,t=2,p=1$invalid$invalid".into(),
2014                role: "ops".into(),
2015                expires_at: None,
2016            },
2017            ApiKeyEntry {
2018                name: "correct".into(),
2019                hash,
2020                role: "deploy".into(),
2021                expires_at: None,
2022            },
2023        ];
2024        let id = verify_bearer_token(&token, &keys).unwrap();
2025        assert_eq!(id.name, "correct");
2026        assert_eq!(id.role, "deploy");
2027    }
2028
2029    #[test]
2030    fn rate_limiter_allows_within_quota() {
2031        let config = RateLimitConfig {
2032            max_attempts_per_minute: 5,
2033            pre_auth_max_per_minute: None,
2034            max_tracked_keys: default_max_tracked_keys(),
2035            idle_eviction: default_idle_eviction(),
2036            burst: None,
2037            pre_auth_burst: None,
2038            key_eviction_policy: KeyEvictionPolicy::default(),
2039        };
2040        let limiter = build_rate_limiter(&config);
2041        let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2042
2043        // First 5 should succeed.
2044        for _ in 0..5 {
2045            assert!(limiter.check_key(&ip).is_ok());
2046        }
2047        // 6th should fail.
2048        assert!(limiter.check_key(&ip).is_err());
2049    }
2050
2051    #[test]
2052    fn rate_limiter_separate_ips() {
2053        let config = RateLimitConfig {
2054            max_attempts_per_minute: 2,
2055            pre_auth_max_per_minute: None,
2056            max_tracked_keys: default_max_tracked_keys(),
2057            idle_eviction: default_idle_eviction(),
2058            burst: None,
2059            pre_auth_burst: None,
2060            key_eviction_policy: KeyEvictionPolicy::default(),
2061        };
2062        let limiter = build_rate_limiter(&config);
2063        let ip1 = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2064        let ip2 = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2065
2066        // Exhaust ip1's quota.
2067        assert!(limiter.check_key(&ip1).is_ok());
2068        assert!(limiter.check_key(&ip1).is_ok());
2069        assert!(limiter.check_key(&ip1).is_err());
2070
2071        // ip2 should still have quota.
2072        assert!(limiter.check_key(&ip2).is_ok());
2073    }
2074
2075    #[test]
2076    fn extract_mtls_identity_from_cn() {
2077        // Generate a cert with explicit CN.
2078        let mut params = rcgen::CertificateParams::new(vec!["test-client.local".into()]).unwrap();
2079        params.distinguished_name = rcgen::DistinguishedName::new();
2080        params
2081            .distinguished_name
2082            .push(rcgen::DnType::CommonName, "test-client");
2083        let cert = params
2084            .self_signed(&rcgen::KeyPair::generate().unwrap())
2085            .unwrap();
2086        let der = cert.der();
2087
2088        let id = extract_mtls_identity(der, "ops").unwrap();
2089        assert_eq!(id.name, "test-client");
2090        assert_eq!(id.role, "ops");
2091        assert_eq!(id.method, AuthMethod::MtlsCertificate);
2092    }
2093
2094    #[test]
2095    fn extract_mtls_identity_falls_back_to_san() {
2096        // Cert with no CN but has a DNS SAN.
2097        let mut params =
2098            rcgen::CertificateParams::new(vec!["san-only.example.com".into()]).unwrap();
2099        params.distinguished_name = rcgen::DistinguishedName::new();
2100        // No CN set - should fall back to DNS SAN.
2101        let cert = params
2102            .self_signed(&rcgen::KeyPair::generate().unwrap())
2103            .unwrap();
2104        let der = cert.der();
2105
2106        let id = extract_mtls_identity(der, "viewer").unwrap();
2107        assert_eq!(id.name, "san-only.example.com");
2108        assert_eq!(id.role, "viewer");
2109    }
2110
2111    #[test]
2112    fn extract_mtls_identity_invalid_der() {
2113        assert!(extract_mtls_identity(b"not-a-cert", "viewer").is_none());
2114    }
2115
2116    // -- auth_middleware integration tests --
2117
2118    use axum::{
2119        body::Body,
2120        http::{Request, StatusCode},
2121    };
2122    use tower::ServiceExt as _;
2123
2124    fn auth_router(state: Arc<AuthState>) -> axum::Router {
2125        axum::Router::new()
2126            .route("/mcp", axum::routing::post(|| async { "ok" }))
2127            .layer(axum::middleware::from_fn(move |req, next| {
2128                let s = Arc::clone(&state);
2129                auth_middleware(s, req, next)
2130            }))
2131    }
2132
2133    fn test_auth_state(keys: Vec<ApiKeyEntry>) -> Arc<AuthState> {
2134        Arc::new(AuthState {
2135            api_keys: ArcSwap::new(Arc::new(keys)),
2136            rate_limiter: None,
2137            pre_auth_limiter: None,
2138            #[cfg(feature = "oauth")]
2139            jwks_cache: None,
2140            seen_identities: SeenIdentitySet::new(),
2141            counters: AuthCounters::default(),
2142            resource_metadata_url: None,
2143        })
2144    }
2145
2146    #[tokio::test]
2147    async fn middleware_rejects_no_credentials() {
2148        let state = test_auth_state(vec![]);
2149        let app = auth_router(Arc::clone(&state));
2150        let req = Request::builder()
2151            .method(axum::http::Method::POST)
2152            .uri("/mcp")
2153            .body(Body::empty())
2154            .unwrap();
2155        let resp = app.oneshot(req).await.unwrap();
2156        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2157        let challenge = resp
2158            .headers()
2159            .get(header::WWW_AUTHENTICATE)
2160            .unwrap()
2161            .to_str()
2162            .unwrap();
2163        assert!(challenge.contains("error=\"invalid_request\""));
2164
2165        let counters = state.counters_snapshot();
2166        assert_eq!(counters.failure_missing_credential, 1);
2167    }
2168
2169    #[tokio::test]
2170    async fn middleware_accepts_valid_bearer() {
2171        let (token, hash) = generate_api_key().unwrap();
2172        let keys = vec![ApiKeyEntry {
2173            name: "test-key".into(),
2174            hash,
2175            role: "ops".into(),
2176            expires_at: None,
2177        }];
2178        let state = test_auth_state(keys);
2179        let app = auth_router(Arc::clone(&state));
2180        let req = Request::builder()
2181            .method(axum::http::Method::POST)
2182            .uri("/mcp")
2183            .header("authorization", format!("Bearer {token}"))
2184            .body(Body::empty())
2185            .unwrap();
2186        let resp = app.oneshot(req).await.unwrap();
2187        assert_eq!(resp.status(), StatusCode::OK);
2188
2189        let counters = state.counters_snapshot();
2190        assert_eq!(counters.success_bearer, 1);
2191    }
2192
2193    #[tokio::test]
2194    async fn middleware_rejects_wrong_bearer() {
2195        let (_token, hash) = generate_api_key().unwrap();
2196        let keys = vec![ApiKeyEntry {
2197            name: "test-key".into(),
2198            hash,
2199            role: "ops".into(),
2200            expires_at: None,
2201        }];
2202        let state = test_auth_state(keys);
2203        let app = auth_router(Arc::clone(&state));
2204        let req = Request::builder()
2205            .method(axum::http::Method::POST)
2206            .uri("/mcp")
2207            .header("authorization", "Bearer wrong-token-here")
2208            .body(Body::empty())
2209            .unwrap();
2210        let resp = app.oneshot(req).await.unwrap();
2211        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2212        let challenge = resp
2213            .headers()
2214            .get(header::WWW_AUTHENTICATE)
2215            .unwrap()
2216            .to_str()
2217            .unwrap();
2218        assert!(challenge.contains("error=\"invalid_token\""));
2219
2220        let counters = state.counters_snapshot();
2221        assert_eq!(counters.failure_invalid_credential, 1);
2222    }
2223
2224    #[tokio::test]
2225    async fn middleware_rate_limits() {
2226        let state = Arc::new(AuthState {
2227            api_keys: ArcSwap::new(Arc::new(vec![])),
2228            rate_limiter: Some(build_rate_limiter(&RateLimitConfig {
2229                max_attempts_per_minute: 1,
2230                pre_auth_max_per_minute: None,
2231                max_tracked_keys: default_max_tracked_keys(),
2232                idle_eviction: default_idle_eviction(),
2233                burst: None,
2234                pre_auth_burst: None,
2235                key_eviction_policy: KeyEvictionPolicy::default(),
2236            })),
2237            pre_auth_limiter: None,
2238            #[cfg(feature = "oauth")]
2239            jwks_cache: None,
2240            seen_identities: SeenIdentitySet::new(),
2241            counters: AuthCounters::default(),
2242            resource_metadata_url: None,
2243        });
2244        let app = auth_router(state);
2245
2246        // First request: UNAUTHORIZED (no credentials, but not rate limited)
2247        let req = Request::builder()
2248            .method(axum::http::Method::POST)
2249            .uri("/mcp")
2250            .body(Body::empty())
2251            .unwrap();
2252        let resp = app.clone().oneshot(req).await.unwrap();
2253        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2254
2255        // Second request from same "IP" (no ConnectInfo in test, so peer_addr is None
2256        // and rate limiter won't fire). That's expected -- rate limiting requires
2257        // ConnectInfo which isn't available in unit tests without a real server.
2258        // This test verifies the middleware wiring doesn't panic.
2259    }
2260
2261    /// Verify that rate limit semantics: only failed auth attempts consume budget.
2262    ///
2263    /// This is a unit test of the limiter behavior. The middleware integration
2264    /// is that on auth failure, `check_key` is called; on auth success, it is NOT.
2265    /// Full e2e tests verify the middleware routing but require `ConnectInfo`.
2266    #[test]
2267    fn rate_limit_semantics_failed_only() {
2268        let config = RateLimitConfig {
2269            max_attempts_per_minute: 3,
2270            pre_auth_max_per_minute: None,
2271            max_tracked_keys: default_max_tracked_keys(),
2272            idle_eviction: default_idle_eviction(),
2273            burst: None,
2274            pre_auth_burst: None,
2275            key_eviction_policy: KeyEvictionPolicy::default(),
2276        };
2277        let limiter = build_rate_limiter(&config);
2278        let ip = RateLimitKey::Ip("192.168.1.100".parse::<IpAddr>().unwrap());
2279
2280        // Simulate: 3 failed attempts should exhaust quota.
2281        assert!(
2282            limiter.check_key(&ip).is_ok(),
2283            "failure 1 should be allowed"
2284        );
2285        assert!(
2286            limiter.check_key(&ip).is_ok(),
2287            "failure 2 should be allowed"
2288        );
2289        assert!(
2290            limiter.check_key(&ip).is_ok(),
2291            "failure 3 should be allowed"
2292        );
2293        assert!(
2294            limiter.check_key(&ip).is_err(),
2295            "failure 4 should be blocked"
2296        );
2297
2298        // In the actual middleware flow:
2299        // - Successful auth: verify_bearer_token returns Some, we return early
2300        //   WITHOUT calling check_key, so no budget consumed.
2301        // - Failed auth: verify_bearer_token returns None, we call check_key
2302        //   THEN return 401, so budget is consumed.
2303        //
2304        // This means N successful requests followed by M failed requests
2305        // will only count M toward the rate limit, not N+M.
2306    }
2307
2308    // -- pre-auth abuse gate (H-S1) --
2309
2310    /// The pre-auth gate must default to ~10x the post-failure quota so honest
2311    /// retry storms never trip it but a Argon2-spray attacker is throttled.
2312    #[test]
2313    fn pre_auth_default_multiplier_is_10x() {
2314        let config = RateLimitConfig {
2315            max_attempts_per_minute: 5,
2316            pre_auth_max_per_minute: None,
2317            max_tracked_keys: default_max_tracked_keys(),
2318            idle_eviction: default_idle_eviction(),
2319            burst: None,
2320            pre_auth_burst: None,
2321            key_eviction_policy: KeyEvictionPolicy::default(),
2322        };
2323        let limiter = build_pre_auth_limiter(&config);
2324        let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2325
2326        // Quota should be 50 (5 * 10), not 5. We expect the first 50 to pass.
2327        for i in 0..50 {
2328            assert!(
2329                limiter.check_key(&ip).is_ok(),
2330                "pre-auth attempt {i} (of expected 50) should be allowed under default 10x multiplier"
2331            );
2332        }
2333        // The 51st attempt must be blocked: confirms quota is bounded, not infinite.
2334        assert!(
2335            limiter.check_key(&ip).is_err(),
2336            "pre-auth attempt 51 should be blocked (quota is 50, not unbounded)"
2337        );
2338    }
2339
2340    /// An explicit `pre_auth_max_per_minute` override must win over the
2341    /// 10x-multiplier default.
2342    #[test]
2343    fn pre_auth_explicit_override_wins() {
2344        let config = RateLimitConfig {
2345            max_attempts_per_minute: 100,     // would default to 1000 pre-auth quota
2346            pre_auth_max_per_minute: Some(2), // but operator caps at 2
2347            max_tracked_keys: default_max_tracked_keys(),
2348            idle_eviction: default_idle_eviction(),
2349            burst: None,
2350            pre_auth_burst: None,
2351            key_eviction_policy: KeyEvictionPolicy::default(),
2352        };
2353        let limiter = build_pre_auth_limiter(&config);
2354        let ip = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2355
2356        assert!(limiter.check_key(&ip).is_ok(), "attempt 1 allowed");
2357        assert!(limiter.check_key(&ip).is_ok(), "attempt 2 allowed");
2358        assert!(
2359            limiter.check_key(&ip).is_err(),
2360            "attempt 3 must be blocked (explicit override of 2 wins over 10x default of 1000)"
2361        );
2362    }
2363
2364    /// The pre-auth gate's 429 must carry a Retry-After header.
2365    #[test]
2366    fn pre_auth_gate_deny_sets_retry_after() {
2367        let config = RateLimitConfig::new(100).with_pre_auth_max_per_minute(1);
2368        let state = AuthState {
2369            api_keys: ArcSwap::new(Arc::new(vec![])),
2370            rate_limiter: None,
2371            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2372            #[cfg(feature = "oauth")]
2373            jwks_cache: None,
2374            seen_identities: SeenIdentitySet::new(),
2375            counters: AuthCounters::default(),
2376            resource_metadata_url: None,
2377        };
2378        let ip = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2379        assert!(
2380            pre_auth_gate(&state, Some(&ip)).is_none(),
2381            "first request within quota"
2382        );
2383        let resp = pre_auth_gate(&state, Some(&ip)).expect("second request must be gated");
2384        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
2385        let retry_after = resp
2386            .headers()
2387            .get(header::RETRY_AFTER)
2388            .expect("Retry-After present")
2389            .to_str()
2390            .unwrap()
2391            .parse::<u64>()
2392            .unwrap();
2393        assert!(retry_after >= 1, "delta-seconds must be >= 1");
2394    }
2395
2396    #[test]
2397    fn pre_auth_gate_capacity_full_returns_503_without_retry_after() {
2398        let config = RateLimitConfig::new(100)
2399            .with_pre_auth_max_per_minute(10)
2400            .with_max_tracked_keys(1)
2401            .with_key_eviction_policy(KeyEvictionPolicy::RejectNew);
2402        let state = AuthState {
2403            api_keys: ArcSwap::new(Arc::new(vec![])),
2404            rate_limiter: None,
2405            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2406            #[cfg(feature = "oauth")]
2407            jwks_cache: None,
2408            seen_identities: SeenIdentitySet::new(),
2409            counters: AuthCounters::default(),
2410            resource_metadata_url: None,
2411        };
2412        let established = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2413        let unseen = RateLimitKey::Ip("10.7.7.8".parse::<IpAddr>().unwrap());
2414        assert!(pre_auth_gate(&state, Some(&established)).is_none());
2415
2416        let resp = pre_auth_gate(&state, Some(&unseen)).expect("unseen key must be rejected");
2417
2418        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
2419        assert!(resp.headers().get(header::RETRY_AFTER).is_none());
2420    }
2421
2422    /// Post-failure limiter honors an explicit burst capacity.
2423    #[test]
2424    fn post_failure_limiter_burst_allows_initial_spike() {
2425        let config = RateLimitConfig::new(1).with_burst(3);
2426        let limiter = build_rate_limiter(&config);
2427        let ip = RateLimitKey::Ip("10.6.6.6".parse::<IpAddr>().unwrap());
2428        for i in 0..3 {
2429            assert!(limiter.check_key(&ip).is_ok(), "burst attempt {i}");
2430        }
2431        assert!(
2432            limiter.check_key(&ip).is_err(),
2433            "attempt 4 must exceed the burst bucket"
2434        );
2435    }
2436
2437    /// End-to-end: the pre-auth gate must reject before the bearer-verification
2438    /// path runs. We exhaust the gate's quota (Some(1)) with one bad-bearer
2439    /// request, then the second request must be rejected with 429 + the
2440    /// `pre_auth_gate` failure counter incremented (NOT
2441    /// `failure_invalid_credential`, which would prove Argon2 ran).
2442    #[tokio::test]
2443    async fn pre_auth_gate_blocks_before_argon2_verification() {
2444        let (_token, hash) = generate_api_key().unwrap();
2445        let keys = vec![ApiKeyEntry {
2446            name: "test-key".into(),
2447            hash,
2448            role: "ops".into(),
2449            expires_at: None,
2450        }];
2451        let config = RateLimitConfig {
2452            max_attempts_per_minute: 100,
2453            pre_auth_max_per_minute: Some(1),
2454            max_tracked_keys: default_max_tracked_keys(),
2455            idle_eviction: default_idle_eviction(),
2456            burst: None,
2457            pre_auth_burst: None,
2458            key_eviction_policy: KeyEvictionPolicy::default(),
2459        };
2460        let state = Arc::new(AuthState {
2461            api_keys: ArcSwap::new(Arc::new(keys)),
2462            rate_limiter: None,
2463            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2464            #[cfg(feature = "oauth")]
2465            jwks_cache: None,
2466            seen_identities: SeenIdentitySet::new(),
2467            counters: AuthCounters::default(),
2468            resource_metadata_url: None,
2469        });
2470        let app = auth_router(Arc::clone(&state));
2471        let peer: SocketAddr = "10.0.0.10:54321".parse().unwrap();
2472
2473        // First bad-bearer request: gate has quota, bearer verification runs,
2474        // returns 401 (invalid credential).
2475        let mut req1 = Request::builder()
2476            .method(axum::http::Method::POST)
2477            .uri("/mcp")
2478            .header("authorization", "Bearer obviously-not-a-real-token")
2479            .body(Body::empty())
2480            .unwrap();
2481        req1.extensions_mut().insert(ConnectInfo(peer));
2482        let resp1 = app.clone().oneshot(req1).await.unwrap();
2483        assert_eq!(
2484            resp1.status(),
2485            StatusCode::UNAUTHORIZED,
2486            "first attempt: gate has quota, falls through to bearer auth which fails with 401"
2487        );
2488
2489        // Second bad-bearer request from same IP: gate quota exhausted, must
2490        // reject with 429 BEFORE the Argon2 verification path runs.
2491        let mut req2 = Request::builder()
2492            .method(axum::http::Method::POST)
2493            .uri("/mcp")
2494            .header("authorization", "Bearer also-not-a-real-token")
2495            .body(Body::empty())
2496            .unwrap();
2497        req2.extensions_mut().insert(ConnectInfo(peer));
2498        let resp2 = app.oneshot(req2).await.unwrap();
2499        assert_eq!(
2500            resp2.status(),
2501            StatusCode::TOO_MANY_REQUESTS,
2502            "second attempt from same IP: pre-auth gate must reject with 429"
2503        );
2504
2505        let counters = state.counters_snapshot();
2506        assert_eq!(
2507            counters.failure_pre_auth_gate, 1,
2508            "exactly one request must have been rejected by the pre-auth gate"
2509        );
2510        // Critical: Argon2 verification must NOT have run on the gated request.
2511        // The first request's 401 increments `failure_invalid_credential` to 1;
2512        // the second (gated) request must NOT increment it further.
2513        assert_eq!(
2514            counters.failure_invalid_credential, 1,
2515            "bearer verification must run exactly once (only the un-gated first request)"
2516        );
2517    }
2518
2519    /// mTLS-authenticated requests must bypass the pre-auth gate entirely.
2520    /// The TLS handshake already performed expensive crypto with a verified
2521    /// peer, so mTLS callers should never be throttled by this gate.
2522    ///
2523    /// Setup: a pre-auth gate with quota 1 (very tight). Submit two mTLS
2524    /// requests in quick succession from the same IP. Both must succeed.
2525    #[tokio::test]
2526    async fn pre_auth_gate_does_not_throttle_mtls() {
2527        let config = RateLimitConfig {
2528            max_attempts_per_minute: 100,
2529            pre_auth_max_per_minute: Some(1), // tight: would block 2nd plain request
2530            max_tracked_keys: default_max_tracked_keys(),
2531            idle_eviction: default_idle_eviction(),
2532            burst: None,
2533            pre_auth_burst: None,
2534            key_eviction_policy: KeyEvictionPolicy::default(),
2535        };
2536        let state = Arc::new(AuthState {
2537            api_keys: ArcSwap::new(Arc::new(vec![])),
2538            rate_limiter: None,
2539            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2540            #[cfg(feature = "oauth")]
2541            jwks_cache: None,
2542            seen_identities: SeenIdentitySet::new(),
2543            counters: AuthCounters::default(),
2544            resource_metadata_url: None,
2545        });
2546        let app = auth_router(Arc::clone(&state));
2547        let peer: SocketAddr = "10.0.0.20:54321".parse().unwrap();
2548        let identity = AuthIdentity {
2549            name: "cn=test-client".into(),
2550            role: "viewer".into(),
2551            method: AuthMethod::MtlsCertificate,
2552            raw_token: None,
2553            sub: None,
2554        };
2555        let tls_info = TlsConnInfo::new(peer, Some(identity));
2556
2557        for i in 0..3 {
2558            let mut req = Request::builder()
2559                .method(axum::http::Method::POST)
2560                .uri("/mcp")
2561                .body(Body::empty())
2562                .unwrap();
2563            req.extensions_mut().insert(ConnectInfo(tls_info.clone()));
2564            let resp = app.clone().oneshot(req).await.unwrap();
2565            assert_eq!(
2566                resp.status(),
2567                StatusCode::OK,
2568                "mTLS request {i} must succeed: pre-auth gate must not apply to mTLS callers"
2569            );
2570        }
2571
2572        let counters = state.counters_snapshot();
2573        assert_eq!(
2574            counters.failure_pre_auth_gate, 0,
2575            "pre-auth gate counter must remain at zero: mTLS bypasses the gate"
2576        );
2577        assert_eq!(
2578            counters.success_mtls, 3,
2579            "all three mTLS requests must have been counted as successful"
2580        );
2581    }
2582
2583    /// Pre-auth-gate denial must increment the `auth_pre` deny counter
2584    /// via the metrics handle in the request extensions.
2585    #[cfg(feature = "metrics")]
2586    #[tokio::test]
2587    async fn pre_auth_gate_deny_increments_counter() {
2588        let config = RateLimitConfig {
2589            max_attempts_per_minute: 100,
2590            pre_auth_max_per_minute: Some(1),
2591            max_tracked_keys: default_max_tracked_keys(),
2592            idle_eviction: default_idle_eviction(),
2593            burst: None,
2594            pre_auth_burst: None,
2595            key_eviction_policy: KeyEvictionPolicy::default(),
2596        };
2597        let state = Arc::new(AuthState {
2598            api_keys: ArcSwap::new(Arc::new(vec![])),
2599            rate_limiter: None,
2600            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2601            #[cfg(feature = "oauth")]
2602            jwks_cache: None,
2603            seen_identities: SeenIdentitySet::new(),
2604            counters: AuthCounters::default(),
2605            resource_metadata_url: None,
2606        });
2607        let app = auth_router(Arc::clone(&state));
2608        let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2609        let peer: SocketAddr = "10.0.0.30:54321".parse().expect("addr parses");
2610        let mk = || {
2611            let mut req = Request::builder()
2612                .method(axum::http::Method::POST)
2613                .uri("/mcp")
2614                .header("authorization", "Bearer not-a-real-token")
2615                .body(Body::empty())
2616                .expect("request builds");
2617            req.extensions_mut().insert(ConnectInfo(peer));
2618            req.extensions_mut().insert(Arc::clone(&metrics));
2619            req
2620        };
2621        let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2622
2623        let first = app.clone().oneshot(mk()).await.expect("first request");
2624        assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2625        assert_eq!(counter("auth_pre"), 0, "un-gated request must not count");
2626
2627        let gated = app.oneshot(mk()).await.expect("second request");
2628        assert_eq!(gated.status(), StatusCode::TOO_MANY_REQUESTS);
2629        assert_eq!(counter("auth_pre"), 1, "gated request must count once");
2630        assert_eq!(counter("auth_post"), 0, "post limiter never fired");
2631    }
2632
2633    /// Post-failure limiter denial must increment the `auth_post` deny
2634    /// counter via the metrics handle in the request extensions.
2635    #[cfg(feature = "metrics")]
2636    #[tokio::test]
2637    async fn post_failure_limiter_deny_increments_counter() {
2638        let config = RateLimitConfig {
2639            max_attempts_per_minute: 1, // tight: 2nd failure trips the limiter
2640            pre_auth_max_per_minute: None,
2641            max_tracked_keys: default_max_tracked_keys(),
2642            idle_eviction: default_idle_eviction(),
2643            burst: None,
2644            pre_auth_burst: None,
2645            key_eviction_policy: KeyEvictionPolicy::default(),
2646        };
2647        let state = Arc::new(AuthState {
2648            api_keys: ArcSwap::new(Arc::new(vec![])),
2649            rate_limiter: Some(build_rate_limiter(&config)),
2650            pre_auth_limiter: None,
2651            #[cfg(feature = "oauth")]
2652            jwks_cache: None,
2653            seen_identities: SeenIdentitySet::new(),
2654            counters: AuthCounters::default(),
2655            resource_metadata_url: None,
2656        });
2657        let app = auth_router(Arc::clone(&state));
2658        let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2659        let peer: SocketAddr = "10.0.0.31:54321".parse().expect("addr parses");
2660        let mk = || {
2661            let mut req = Request::builder()
2662                .method(axum::http::Method::POST)
2663                .uri("/mcp")
2664                .header("authorization", "Bearer not-a-real-token")
2665                .body(Body::empty())
2666                .expect("request builds");
2667            req.extensions_mut().insert(ConnectInfo(peer));
2668            req.extensions_mut().insert(Arc::clone(&metrics));
2669            req
2670        };
2671        let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2672
2673        // First failure consumes the budget but is NOT itself limited.
2674        let first = app.clone().oneshot(mk()).await.expect("first request");
2675        assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2676        assert_eq!(counter("auth_post"), 0);
2677
2678        // Second failure trips the post-failure limiter.
2679        let limited = app.oneshot(mk()).await.expect("second request");
2680        assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
2681        assert_eq!(counter("auth_post"), 1, "deny must count once");
2682        assert_eq!(counter("auth_pre"), 0, "pre-auth gate disabled here");
2683    }
2684
2685    // -------------------------------------------------------------------
2686    // RFC 7235 §2.1 case-insensitive scheme parsing for `extract_bearer`.
2687    // -------------------------------------------------------------------
2688
2689    #[test]
2690    fn extract_bearer_accepts_canonical_case() {
2691        assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2692    }
2693
2694    #[test]
2695    fn extract_bearer_is_case_insensitive_per_rfc7235() {
2696        // RFC 7235 §2.1: "auth-scheme is case-insensitive".
2697        // Real-world clients (curl, browsers, custom HTTP libs) emit varied
2698        // casings; rejecting any of them is a spec violation.
2699        for header in &[
2700            "bearer abc123",
2701            "BEARER abc123",
2702            "BeArEr abc123",
2703            "bEaReR abc123",
2704        ] {
2705            assert_eq!(
2706                extract_bearer(header),
2707                Some("abc123"),
2708                "header {header:?} must parse as a Bearer token (RFC 7235 §2.1)"
2709            );
2710        }
2711    }
2712
2713    #[test]
2714    fn extract_bearer_rejects_other_schemes() {
2715        assert_eq!(extract_bearer("Basic dXNlcjpwYXNz"), None);
2716        assert_eq!(extract_bearer("Digest username=\"x\""), None);
2717        assert_eq!(extract_bearer("Token abc123"), None);
2718    }
2719
2720    #[test]
2721    fn extract_bearer_rejects_malformed() {
2722        // Empty string, no separator, scheme-only, scheme + only whitespace.
2723        assert_eq!(extract_bearer(""), None);
2724        assert_eq!(extract_bearer("Bearer"), None);
2725        assert_eq!(extract_bearer("Bearer "), None);
2726        assert_eq!(extract_bearer("Bearer    "), None);
2727    }
2728
2729    #[test]
2730    fn extract_bearer_tolerates_extra_separator_whitespace() {
2731        // Some non-conformant clients emit two spaces; we should still parse.
2732        assert_eq!(extract_bearer("Bearer  abc123"), Some("abc123"));
2733        assert_eq!(extract_bearer("Bearer   abc123"), Some("abc123"));
2734    }
2735
2736    #[test]
2737    fn extract_bearer_rejects_embedded_whitespace() {
2738        assert_eq!(extract_bearer("Bearer abc 123"), None);
2739        assert_eq!(extract_bearer("Bearer abc\t123"), None);
2740        assert_eq!(extract_bearer("Bearer abc123 "), None);
2741        assert_eq!(extract_bearer("Bearer abc123\r\n"), None);
2742    }
2743
2744    #[test]
2745    fn extract_bearer_still_accepts_opaque_non_token68_credentials() {
2746        // Compatibility guard. `ApiKeyEntry::new` accepts an arbitrary
2747        // caller-supplied hash, so consumers may have hashed opaque tokens
2748        // using punctuation outside RFC 7235 `token68`. Narrowing this to a
2749        // strict token68 charset would silently 401 them on upgrade.
2750        assert_eq!(
2751            extract_bearer("Bearer aBc!@#$%^&*()"),
2752            Some("aBc!@#$%^&*()")
2753        );
2754        assert_eq!(extract_bearer("Bearer tok{en}|v1"), Some("tok{en}|v1"));
2755    }
2756
2757    #[test]
2758    fn extract_bearer_accepts_generated_key_and_jwt_shapes() {
2759        let (token, _hash) = generate_api_key().unwrap();
2760        let header = format!("Bearer {token}");
2761        assert_eq!(extract_bearer(&header), Some(token.as_str()));
2762
2763        let jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln-_bmF0dXJl";
2764        let jwt_header = format!("Bearer {jwt}");
2765        assert_eq!(extract_bearer(&jwt_header), Some(jwt));
2766    }
2767
2768    // -------------------------------------------------------------------
2769    // Debug redaction: ensure `AuthIdentity` and `ApiKeyEntry` never leak
2770    // secret material via `format!("{:?}", …)` or `tracing::debug!(?…)`.
2771    // -------------------------------------------------------------------
2772
2773    #[test]
2774    fn auth_identity_debug_redacts_raw_token() {
2775        let id = AuthIdentity {
2776            name: "alice".into(),
2777            role: "admin".into(),
2778            method: AuthMethod::OAuthJwt,
2779            raw_token: Some(SecretString::from("super-secret-jwt-payload-xyz")),
2780            sub: Some("keycloak-uuid-2f3c8b".into()),
2781        };
2782        let dbg = format!("{id:?}");
2783
2784        // Plaintext fields must be visible (they are not secrets).
2785        assert!(dbg.contains("alice"), "name should be visible: {dbg}");
2786        assert!(dbg.contains("admin"), "role should be visible: {dbg}");
2787        assert!(dbg.contains("OAuthJwt"), "method should be visible: {dbg}");
2788
2789        // Secret fields must NOT leak.
2790        assert!(
2791            !dbg.contains("super-secret-jwt-payload-xyz"),
2792            "raw_token must be redacted in Debug output: {dbg}"
2793        );
2794        assert!(
2795            !dbg.contains("keycloak-uuid-2f3c8b"),
2796            "sub must be redacted in Debug output: {dbg}"
2797        );
2798        assert!(
2799            dbg.contains("<redacted>"),
2800            "redaction marker missing: {dbg}"
2801        );
2802    }
2803
2804    #[test]
2805    fn auth_identity_debug_marks_absent_secrets() {
2806        // For non-OAuth identities (mTLS / API key) the secret fields are
2807        // None; redacted Debug output should distinguish that from "present".
2808        let id = AuthIdentity {
2809            name: "viewer-key".into(),
2810            role: "viewer".into(),
2811            method: AuthMethod::BearerToken,
2812            raw_token: None,
2813            sub: None,
2814        };
2815        let dbg = format!("{id:?}");
2816        assert!(
2817            dbg.contains("<none>"),
2818            "absent secrets should be marked: {dbg}"
2819        );
2820        assert!(
2821            !dbg.contains("<redacted>"),
2822            "no <redacted> marker when secrets are absent: {dbg}"
2823        );
2824    }
2825
2826    #[test]
2827    fn api_key_entry_debug_redacts_hash() {
2828        let entry = ApiKeyEntry {
2829            name: "viewer-key".into(),
2830            // Realistic Argon2id PHC string (must NOT leak).
2831            hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$h4sh3dPa55w0rd".into(),
2832            role: "viewer".into(),
2833            expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
2834        };
2835        let dbg = format!("{entry:?}");
2836
2837        // Non-secret fields visible.
2838        assert!(dbg.contains("viewer-key"));
2839        assert!(dbg.contains("viewer"));
2840        assert!(dbg.contains("2030-01-01T00:00:00+00:00"));
2841
2842        // Hash material must NOT leak.
2843        assert!(
2844            !dbg.contains("$argon2id$"),
2845            "argon2 hash leaked into Debug output: {dbg}"
2846        );
2847        assert!(
2848            !dbg.contains("h4sh3dPa55w0rd"),
2849            "hash digest leaked into Debug output: {dbg}"
2850        );
2851        assert!(
2852            dbg.contains("<redacted>"),
2853            "redaction marker missing: {dbg}"
2854        );
2855    }
2856
2857    // -- AuthFailureClass exact-string contract tests --
2858    //
2859    // These tests pin the exact wire strings emitted for each failure
2860    // class. They exist to kill mutation-test mutants that replace the
2861    // match-arm string literals (e.g. with `""` or with the value from
2862    // another arm). Operators and dashboards rely on these literals
2863    // for metric labels and audit-log filters; any change is a
2864    // breaking observability change and must be reflected in
2865    // CHANGELOG.md.
2866
2867    #[test]
2868    fn auth_failure_class_as_str_exact_strings() {
2869        assert_eq!(
2870            AuthFailureClass::MissingCredential.as_str(),
2871            "missing_credential"
2872        );
2873        assert_eq!(
2874            AuthFailureClass::InvalidCredential.as_str(),
2875            "invalid_credential"
2876        );
2877        assert_eq!(
2878            AuthFailureClass::ExpiredCredential.as_str(),
2879            "expired_credential"
2880        );
2881        assert_eq!(AuthFailureClass::RateLimited.as_str(), "rate_limited");
2882        assert_eq!(AuthFailureClass::PreAuthGate.as_str(), "pre_auth_gate");
2883    }
2884
2885    #[test]
2886    fn auth_failure_class_response_body_exact_strings() {
2887        assert_eq!(
2888            AuthFailureClass::MissingCredential.response_body(),
2889            "unauthorized: missing credential"
2890        );
2891        assert_eq!(
2892            AuthFailureClass::InvalidCredential.response_body(),
2893            "unauthorized: invalid credential"
2894        );
2895        assert_eq!(
2896            AuthFailureClass::ExpiredCredential.response_body(),
2897            "unauthorized: expired credential"
2898        );
2899        assert_eq!(
2900            AuthFailureClass::RateLimited.response_body(),
2901            "rate limited"
2902        );
2903        assert_eq!(
2904            AuthFailureClass::PreAuthGate.response_body(),
2905            "rate limited (pre-auth)"
2906        );
2907    }
2908
2909    #[test]
2910    fn auth_failure_class_bearer_error_exact_strings() {
2911        assert_eq!(
2912            AuthFailureClass::MissingCredential.bearer_error(),
2913            (
2914                "invalid_request",
2915                "missing bearer token or mTLS client certificate"
2916            )
2917        );
2918        assert_eq!(
2919            AuthFailureClass::InvalidCredential.bearer_error(),
2920            ("invalid_token", "token is invalid")
2921        );
2922        assert_eq!(
2923            AuthFailureClass::ExpiredCredential.bearer_error(),
2924            ("invalid_token", "token is expired")
2925        );
2926        assert_eq!(
2927            AuthFailureClass::RateLimited.bearer_error(),
2928            ("invalid_request", "too many failed authentication attempts")
2929        );
2930        assert_eq!(
2931            AuthFailureClass::PreAuthGate.bearer_error(),
2932            (
2933                "invalid_request",
2934                "too many unauthenticated requests from this source"
2935            )
2936        );
2937    }
2938
2939    // -- AuthConfig::summary boolean-flag contract tests --
2940    //
2941    // These tests pin the boolean flags emitted by `AuthConfig::summary`
2942    // so that mutations like deleting `!` (which would invert the
2943    // semantics of `bearer`) or replacing `is_some()` with `is_none()`
2944    // are caught immediately. The summary is consumed by `/admin/*`
2945    // diagnostics so any inversion is an operator-visible regression.
2946
2947    #[test]
2948    fn auth_config_summary_bearer_true_when_keys_present() {
2949        let (_token, hash) = generate_api_key().unwrap();
2950        let cfg = AuthConfig::with_keys(vec![ApiKeyEntry::new("k", hash, "viewer")]);
2951        let s = cfg.summary();
2952        assert!(s.enabled, "summary.enabled must reflect AuthConfig.enabled");
2953        assert!(
2954            s.bearer,
2955            "summary.bearer must be true when api_keys is non-empty (kills `!` deletion at L615)"
2956        );
2957        assert!(!s.mtls, "summary.mtls must be false when mtls is None");
2958        assert!(!s.oauth, "summary.oauth must be false when oauth is None");
2959        assert_eq!(s.api_keys.len(), 1);
2960        assert_eq!(s.api_keys[0].name, "k");
2961        assert_eq!(s.api_keys[0].role, "viewer");
2962    }
2963
2964    #[test]
2965    fn auth_config_summary_bearer_false_when_no_keys() {
2966        let cfg = AuthConfig::with_keys(vec![]);
2967        let s = cfg.summary();
2968        assert!(
2969            !s.bearer,
2970            "summary.bearer must be false when api_keys is empty (kills `!` deletion at L615)"
2971        );
2972        assert!(s.api_keys.is_empty());
2973    }
2974
2975    #[test]
2976    fn seen_identity_set_first_then_repeat() {
2977        let set = SeenIdentitySet::new();
2978        assert!(set.insert_is_first("alice"), "first sighting is first");
2979        assert!(
2980            !set.insert_is_first("alice"),
2981            "second sighting is not first"
2982        );
2983        assert!(set.insert_is_first("bob"));
2984        assert_eq!(set.len(), 2);
2985    }
2986
2987    #[test]
2988    fn seen_identity_set_evicts_oldest_at_cap() {
2989        let set = SeenIdentitySet::with_cap(2);
2990        assert!(set.insert_is_first("a"));
2991        assert!(set.insert_is_first("b"));
2992        // Cap reached; inserting "c" evicts "a".
2993        assert!(set.insert_is_first("c"));
2994        assert_eq!(set.len(), 2);
2995        // "a" was evicted, so it re-fires as "first" (matches the documented
2996        // bounded trade-off: re-INFO once on reappearance). Inserting "a"
2997        // here evicts "b" (next oldest), leaving {c, a}.
2998        assert!(set.insert_is_first("a"));
2999        assert_eq!(set.len(), 2);
3000        // "b" has now been evicted in turn, so it re-fires as "first" too.
3001        assert!(set.insert_is_first("b"));
3002        // Sanity: cap is never exceeded regardless of churn pattern.
3003        for i in 0..32 {
3004            set.insert_is_first(&format!("churn-{i}"));
3005            assert!(set.len() <= 2, "cap invariant must hold");
3006        }
3007    }
3008
3009    #[test]
3010    fn seen_identity_set_cap_zero_is_raised_to_one() {
3011        let set = SeenIdentitySet::with_cap(0);
3012        assert!(set.insert_is_first("only"));
3013        assert_eq!(set.len(), 1);
3014        // Next insert evicts "only".
3015        assert!(set.insert_is_first("next"));
3016        assert_eq!(set.len(), 1);
3017    }
3018
3019    #[test]
3020    fn seen_identity_set_fifo_does_not_refresh_on_repeat_hit() {
3021        // Locks in the FIFO contract: repeat hits MUST NOT bump an entry
3022        // to the back of the eviction queue (that would be LRU).
3023        let set = SeenIdentitySet::with_cap(2);
3024        assert!(set.insert_is_first("a")); // order=[a]
3025        assert!(set.insert_is_first("b")); // order=[a,b]
3026        // Repeat hit on "a" - if this were LRU, "a" would move to the back
3027        // and "b" would be the next eviction victim. Under FIFO, "a" stays
3028        // at the front (oldest by insertion).
3029        assert!(!set.insert_is_first("a"));
3030        // Insert "c" forces eviction. Under FIFO, "a" (oldest by insertion)
3031        // is evicted; "b" survives. Under LRU, "b" would have been evicted.
3032        assert!(set.insert_is_first("c"));
3033        // Prove "a" was evicted: re-inserting fires as first again.
3034        assert!(set.insert_is_first("a"));
3035        // Prove "b" was NOT evicted: re-inserting does NOT fire as first.
3036        // (If LRU semantics had snuck in, this assertion would fail.)
3037        // After the previous step, "a" eviction pushed out "b" as the new
3038        // oldest, so we must re-add "b" via a fresh insert path. To keep
3039        // the test deterministic we rebuild a small scenario:
3040        let set = SeenIdentitySet::with_cap(2);
3041        assert!(set.insert_is_first("x")); // order=[x]
3042        assert!(set.insert_is_first("y")); // order=[x,y]
3043        assert!(!set.insert_is_first("x")); // repeat hit (under FIFO: order unchanged)
3044        assert!(set.insert_is_first("z")); // evicts "x" under FIFO
3045        assert!(
3046            !set.insert_is_first("y"),
3047            "y must still be present (FIFO did not evict it)"
3048        );
3049        assert!(
3050            set.insert_is_first("x"),
3051            "x must have been evicted by FIFO (would NOT have been evicted under LRU)"
3052        );
3053    }
3054}