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.8 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
766/// Reject any API key in `keys` whose `name` is blank (empty or
767/// whitespace-only), naming the first offending index.
768///
769/// Shared by [`AuthConfig::validate_api_key_names`] (startup validation) and
770/// [`AuthState::try_reload_keys`] (hot-reload validation) so both surfaces
771/// enforce the same rule: a blank name is the bearer session-binding stable
772/// id, so two blank-named keys collide to one fingerprint (CWE-384).
773pub(crate) fn check_api_key_names(keys: &[ApiKeyEntry]) -> Result<(), RmcpServerKitError> {
774    for (index, key) in keys.iter().enumerate() {
775        if key.name.trim().is_empty() {
776            return Err(RmcpServerKitError::Config(format!(
777                "auth.api_keys[{index}] has a blank name; each API-key name must be \
778                 non-empty and not whitespace-only (it is the session-binding identity)"
779            )));
780        }
781    }
782    Ok(())
783}
784
785impl AuthConfig {
786    /// Create an enabled auth config with the given API keys.
787    #[must_use]
788    pub fn with_keys(keys: Vec<ApiKeyEntry>) -> Self {
789        Self {
790            enabled: true,
791            api_keys: keys,
792            mtls: None,
793            rate_limit: None,
794            #[cfg(feature = "oauth")]
795            oauth: None,
796            #[cfg(not(feature = "oauth"))]
797            oauth: None,
798        }
799    }
800
801    /// Set rate limiting on this auth config.
802    #[must_use]
803    pub fn with_rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
804        self.rate_limit = Some(rate_limit);
805        self
806    }
807
808    /// Reject an `[auth.oauth]` table in a build compiled without the `oauth`
809    /// cargo feature.
810    ///
811    /// Fails closed on purpose. Ignoring the table would start the server with
812    /// OAuth silently disabled while the operator's configuration says it is
813    /// on -- for a bearer-token deployment that is an unauthenticated server.
814    ///
815    /// # Errors
816    ///
817    /// Returns [`RmcpServerKitError::Config`] when `auth.oauth` is present and
818    /// the `oauth` feature is disabled. Always `Ok` when the feature is
819    /// enabled, where the table is parsed into
820    /// [`oauth::OAuthConfig`](crate::oauth::OAuthConfig) instead.
821    pub fn check_oauth_feature(&self) -> Result<(), RmcpServerKitError> {
822        #[cfg(not(feature = "oauth"))]
823        {
824            (self.oauth.is_none()).ok_or_else(|| {
825                RmcpServerKitError::Config(
826                    "auth.oauth is configured but this build of rmcp-server-kit was compiled \
827                     without the `oauth` cargo feature; rebuild with `--features oauth` or \
828                     remove the [auth.oauth] table"
829                        .into(),
830                )
831            })?;
832        }
833        Ok(())
834    }
835
836    /// Reject any configured API key whose `name` is blank (empty or
837    /// whitespace-only).
838    ///
839    /// The key name is the session-binding fingerprint's stable id for bearer
840    /// auth, so two blank-named keys hash identically and one key's session
841    /// becomes usable by the other (CWE-384). The first offending index is
842    /// named so an operator can locate the entry in their key list.
843    ///
844    /// # Errors
845    ///
846    /// Returns [`RmcpServerKitError::Config`] naming the first API-key index
847    /// whose `name` is blank.
848    pub fn validate_api_key_names(&self) -> Result<(), RmcpServerKitError> {
849        check_api_key_names(&self.api_keys)
850    }
851}
852
853/// Summary of a single API key suitable for admin endpoints.
854///
855/// Intentionally omits the Argon2id hash - only metadata is exposed.
856#[derive(Debug, Clone, serde::Serialize)]
857#[non_exhaustive]
858pub struct ApiKeySummary {
859    /// Human-readable key label.
860    pub name: String,
861    /// RBAC role granted when this key authenticates.
862    pub role: String,
863    /// Optional RFC 3339 expiry timestamp. Serialized as a canonical
864    /// RFC 3339 string so the admin-endpoint wire format is preserved.
865    pub expires_at: Option<RfcTimestamp>,
866}
867
868/// Snapshot of the enabled authentication methods for admin endpoints.
869#[derive(Debug, Clone, serde::Serialize)]
870#[allow(
871    clippy::struct_excessive_bools,
872    reason = "this is a flat summary of independent auth-method booleans"
873)]
874#[non_exhaustive]
875pub struct AuthConfigSummary {
876    /// Master enabled flag from config.
877    pub enabled: bool,
878    /// Whether API-key bearer auth is configured.
879    pub bearer: bool,
880    /// Whether mTLS client auth is configured.
881    pub mtls: bool,
882    /// Whether OAuth JWT validation is configured.
883    pub oauth: bool,
884    /// Current API-key list (no hashes).
885    pub api_keys: Vec<ApiKeySummary>,
886}
887
888impl AuthConfig {
889    /// Produce a hash-free summary of the auth config for admin endpoints.
890    #[must_use]
891    pub fn summary(&self) -> AuthConfigSummary {
892        AuthConfigSummary {
893            enabled: self.enabled,
894            bearer: !self.api_keys.is_empty(),
895            mtls: self.mtls.is_some(),
896            #[cfg(feature = "oauth")]
897            oauth: self.oauth.is_some(),
898            #[cfg(not(feature = "oauth"))]
899            oauth: false,
900            api_keys: self
901                .api_keys
902                .iter()
903                .map(|k| ApiKeySummary {
904                    name: k.name.clone(),
905                    role: k.role.clone(),
906                    expires_at: k.expires_at,
907                })
908                .collect(),
909        }
910    }
911}
912
913/// Keyed rate limiter type (per source IP). Memory-bounded by
914/// [`RateLimitConfig::max_tracked_keys`] to defend against IP-spray `DoS`.
915pub(crate) type KeyedLimiter = BoundedKeyedLimiter<RateLimitKey>;
916
917/// Connection info for TLS connections, carrying the peer socket address
918/// and (when mTLS is configured) the verified client identity extracted
919/// from the peer certificate during the TLS handshake.
920///
921/// Defined as a local type so we can implement axum's `Connected` trait
922/// for our custom `TlsListener` without orphan rule issues. The `identity`
923/// field travels with the connection itself (via the wrapping IO type),
924/// so there is no shared map to race against, no port-reuse aliasing, and
925/// no eviction policy to maintain.
926#[derive(Clone, Debug)]
927#[non_exhaustive]
928pub(crate) struct TlsConnInfo {
929    /// Remote peer socket address.
930    pub addr: SocketAddr,
931    /// Verified mTLS client identity, if a client certificate was presented
932    /// and successfully extracted during the TLS handshake.
933    pub identity: Option<AuthIdentity>,
934}
935
936impl TlsConnInfo {
937    /// Construct a new [`TlsConnInfo`].
938    #[must_use]
939    pub(crate) const fn new(addr: SocketAddr, identity: Option<AuthIdentity>) -> Self {
940        Self { addr, identity }
941    }
942}
943
944/// Default hard cap on the number of distinct authenticated identities
945/// remembered by [`SeenIdentitySet`].
946///
947/// Sized to comfortably exceed realistic identity churn for an MCP server
948/// while bounding worst-case memory at roughly `4096 * avg_name_len`
949/// (~256 KiB at 64-byte names). Honest clients will never trigger eviction;
950/// hostile churn (rotating mTLS subjects or OAuth `sub` values) is bounded.
951const DEFAULT_SEEN_IDENTITY_CAP: usize = 4096;
952
953/// Bounded set tracking which authenticated identities have already been
954/// logged at INFO level (subsequent auths fall back to DEBUG).
955///
956/// # Why bounded?
957///
958/// `id.name` is attacker-influenced under mTLS (SAN/CN) and OAuth (`sub`).
959/// An unbounded [`std::collections::HashSet`] would grow with churn,
960/// producing both a slow memory leak and unbounded log-cardinality
961/// downstream (Loki/ES). The cap follows the same trade-off documented in
962/// [`crate::bounded_limiter`]: when an evicted identity reappears it
963/// re-fires INFO once. This is acceptable for diagnostic logging.
964///
965/// # Concurrency
966///
967/// Uses [`std::sync::Mutex`] because [`Self::insert_is_first`] is purely
968/// synchronous and the critical section never `.await`s. The mutex is
969/// poison-tolerant: a poisoned set is still logically consistent
970/// (only writer is `insert_is_first`, which performs an atomic insert
971/// + bounded eviction; no torn invariants are possible).
972pub(crate) struct SeenIdentitySet {
973    inner: Mutex<SeenInner>,
974}
975
976struct SeenInner {
977    set: HashSet<String>,
978    /// Insertion-order FIFO used for bounded eviction. Tracking strict LRU
979    /// would require touching the queue on every hit (under the mutex);
980    /// FIFO is sufficient because the contract only promises "bounded
981    /// memory", not "remember the most recently seen identities".
982    order: std::collections::VecDeque<String>,
983    cap: usize,
984}
985
986impl SeenIdentitySet {
987    /// Construct with the default cap of [`DEFAULT_SEEN_IDENTITY_CAP`].
988    #[must_use]
989    pub(crate) fn new() -> Self {
990        Self::with_cap(DEFAULT_SEEN_IDENTITY_CAP)
991    }
992
993    /// Construct with an explicit cap. A `cap` of `0` is silently raised
994    /// to `1` to keep the invariant `set.len() <= cap` non-vacuous.
995    #[must_use]
996    pub(crate) fn with_cap(cap: usize) -> Self {
997        let cap = cap.max(1);
998        Self {
999            inner: Mutex::new(SeenInner {
1000                set: HashSet::with_capacity(cap.min(64)),
1001                order: std::collections::VecDeque::with_capacity(cap.min(64)),
1002                cap,
1003            }),
1004        }
1005    }
1006
1007    /// Insert `name`. Returns `true` if this is the first time `name` was
1008    /// inserted (or it was previously evicted and reinserted), `false`
1009    /// if it was already present.
1010    ///
1011    /// When the cap is reached, the oldest inserted entry is evicted to
1012    /// make room. Eviction never blocks the caller.
1013    pub(crate) fn insert_is_first(&self, name: &str) -> bool {
1014        // SAFETY: the only writer is this method; a poisoned set remains
1015        // logically consistent (atomic insert + bounded eviction preserve
1016        // the `set.len() <= cap` invariant). Continuing past poison only
1017        // affects diagnostic logging granularity, not correctness or
1018        // security.
1019        let mut guard = self
1020            .inner
1021            .lock()
1022            .unwrap_or_else(std::sync::PoisonError::into_inner);
1023
1024        if guard.set.contains(name) {
1025            return false;
1026        }
1027        // Cap enforcement: evict-then-insert keeps the invariant
1028        // `set.len() <= cap` even when the cap is `1`.
1029        if guard.set.len() >= guard.cap
1030            && let Some(evicted) = guard.order.pop_front()
1031        {
1032            guard.set.remove(&evicted);
1033        }
1034        let owned = name.to_owned();
1035        guard.set.insert(owned.clone());
1036        guard.order.push_back(owned);
1037        true
1038    }
1039
1040    /// Test-only snapshot of the current size.
1041    #[cfg(test)]
1042    pub(crate) fn len(&self) -> usize {
1043        self.inner
1044            .lock()
1045            .unwrap_or_else(std::sync::PoisonError::into_inner)
1046            .set
1047            .len()
1048    }
1049}
1050
1051impl Default for SeenIdentitySet {
1052    fn default() -> Self {
1053        Self::new()
1054    }
1055}
1056
1057/// Shared state for the auth middleware.
1058///
1059/// `api_keys` uses [`ArcSwap`] so the SIGHUP handler can atomically
1060/// swap in a new key list without blocking in-flight requests.
1061#[allow(
1062    missing_debug_implementations,
1063    reason = "contains governor RateLimiter and JwksCache without Debug impls"
1064)]
1065#[non_exhaustive]
1066pub(crate) struct AuthState {
1067    /// Active set of API keys (hot-swappable).
1068    pub api_keys: ArcSwap<Vec<ApiKeyEntry>>,
1069    /// Optional per-IP post-failure rate limiter (consulted *after* auth fails).
1070    pub rate_limiter: Option<Arc<KeyedLimiter>>,
1071    /// Optional per-IP pre-auth abuse gate (consulted *before* password-hash work).
1072    /// mTLS-authenticated connections bypass this gate.
1073    pub pre_auth_limiter: Option<Arc<KeyedLimiter>>,
1074    #[cfg(feature = "oauth")]
1075    /// Optional JWKS cache for OAuth JWT validation.
1076    pub jwks_cache: Option<Arc<crate::oauth::JwksCache>>,
1077    /// Tracks identity names that have already been logged at INFO level.
1078    /// Subsequent auths for the same identity are logged at DEBUG.
1079    /// Bounded to prevent attacker-driven memory growth via churned
1080    /// mTLS subjects or OAuth `sub` claims (see [`SeenIdentitySet`]).
1081    pub seen_identities: SeenIdentitySet,
1082    /// Lightweight in-memory auth success/failure counters for diagnostics.
1083    pub counters: AuthCounters,
1084    /// Absolute URL of this server's RFC 9728 Protected Resource Metadata,
1085    /// advertised in the `WWW-Authenticate` challenge.
1086    ///
1087    /// RFC 9728 5.1 defines `resource_metadata` as a URL; emitting an
1088    /// absolute one lets a client resolve it without knowing the origin it
1089    /// was challenged from. `None` falls back to the well-known path, which
1090    /// stays correct for same-origin clients.
1091    pub resource_metadata_url: Option<String>,
1092}
1093
1094impl AuthState {
1095    /// Validate and atomically replace the API key list.
1096    ///
1097    /// Rejects a blank-named key before installing anything; on error the
1098    /// previous key list stays in place, so a failed hot reload never leaves
1099    /// the server serving blank-named (session-binding-colliding) keys.
1100    ///
1101    /// # Errors
1102    ///
1103    /// Returns [`RmcpServerKitError::Config`] naming the first blank-named
1104    /// index; the current key list is left untouched.
1105    pub(crate) fn try_reload_keys(&self, keys: Vec<ApiKeyEntry>) -> Result<(), RmcpServerKitError> {
1106        check_api_key_names(&keys)?;
1107        self.reload_keys_unchecked(keys);
1108        Ok(())
1109    }
1110
1111    /// Atomically replace the API key list **without validation** (lock-free,
1112    /// wait-free).
1113    ///
1114    /// New requests immediately see the updated keys.
1115    /// In-flight requests that already loaded the old list finish
1116    /// using it -- no torn reads.
1117    ///
1118    /// Private and unchecked on purpose: it does not reject a blank-named key
1119    /// (session-binding-colliding, CWE-384). The only caller is
1120    /// [`Self::try_reload_keys`], which validates first; all reload entry
1121    /// points must go through that.
1122    fn reload_keys_unchecked(&self, keys: Vec<ApiKeyEntry>) {
1123        let count = keys.len();
1124        self.api_keys.store(Arc::new(keys));
1125        tracing::info!(keys = count, "API keys reloaded");
1126    }
1127
1128    /// Snapshot auth counters for diagnostics and tests.
1129    #[must_use]
1130    pub(crate) fn counters_snapshot(&self) -> AuthCountersSnapshot {
1131        self.counters.snapshot()
1132    }
1133
1134    /// Produce the admin-endpoint list of API keys (metadata only, no hashes).
1135    #[must_use]
1136    pub(crate) fn api_key_summaries(&self) -> Vec<ApiKeySummary> {
1137        self.api_keys
1138            .load()
1139            .iter()
1140            .map(|k| ApiKeySummary {
1141                name: k.name.clone(),
1142                role: k.role.clone(),
1143                expires_at: k.expires_at,
1144            })
1145            .collect()
1146    }
1147
1148    /// Log auth success: INFO on first occurrence per identity, DEBUG after.
1149    ///
1150    /// Backed by [`SeenIdentitySet`], a bounded FIFO set that caps
1151    /// retained identities to prevent attacker-driven memory growth.
1152    /// FIFO (not LRU) is intentional: this cache de-duplicates INFO logs,
1153    /// not security state, so per-hit eviction-order mutation is not
1154    /// justified. See [`SeenIdentitySet`] for the full trade-off rationale.
1155    fn log_auth(&self, id: &AuthIdentity, method: &str) {
1156        self.counters.record_success(id.method);
1157        let first = self.seen_identities.insert_is_first(&id.name);
1158        if first {
1159            tracing::info!(name = %id.name, role = %id.role, "{method} authenticated");
1160        } else {
1161            tracing::debug!(name = %id.name, role = %id.role, "{method} authenticated");
1162        }
1163    }
1164}
1165
1166/// Default auth rate limit: 30 attempts per minute per source IP.
1167// SAFETY: unwrap() is safe - literal 30 is provably non-zero (const-evaluated).
1168const DEFAULT_AUTH_RATE: NonZeroU32 = NonZeroU32::new(30).unwrap();
1169
1170/// Apply an optional burst capacity to a quota. `None` keeps governor's
1171/// default (burst = rate). Zero values are rejected at config-validation
1172/// time; the `NonZeroU32` filter here is defensive only.
1173fn apply_burst(quota: governor::Quota, burst: Option<u32>) -> governor::Quota {
1174    match burst.and_then(NonZeroU32::new) {
1175        Some(b) => quota.allow_burst(b),
1176        None => quota,
1177    }
1178}
1179
1180/// Create a post-failure rate limiter from config.
1181#[must_use]
1182pub(crate) fn build_rate_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1183    // Defense in depth: `serve()` and `serve_with_listener()` require a
1184    // `Validated<McpServerConfig>` and reject zero before startup, but
1185    // `auth::tests` construct limiters directly from raw `RateLimitConfig`
1186    // values to exercise limiter behavior without building a full server.
1187    let quota = governor::Quota::per_minute(
1188        NonZeroU32::new(config.max_attempts_per_minute).unwrap_or(DEFAULT_AUTH_RATE),
1189    );
1190    let quota = apply_burst(quota, config.burst);
1191    // Defense in depth: Phase-1 config validation rejects `0` upstream, but
1192    // tests can still exercise this helper directly with raw config values.
1193    let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1194    Arc::new(BoundedKeyedLimiter::new_with_policy(
1195        quota,
1196        max_tracked_keys,
1197        config.idle_eviction,
1198        config.key_eviction_policy,
1199    ))
1200}
1201
1202/// Create a pre-auth abuse-gate rate limiter from config.
1203///
1204/// Quota: `pre_auth_max_per_minute` if set, otherwise
1205/// `max_attempts_per_minute * 10` (capped at `u32::MAX`). The 10× factor
1206/// keeps the gate generous enough for honest retries while still bounding
1207/// attacker CPU on Argon2 verification.
1208#[must_use]
1209pub(crate) fn build_pre_auth_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1210    let resolved = config.pre_auth_max_per_minute.unwrap_or_else(|| {
1211        config
1212            .max_attempts_per_minute
1213            .saturating_mul(PRE_AUTH_DEFAULT_MULTIPLIER)
1214    });
1215    let quota =
1216        governor::Quota::per_minute(NonZeroU32::new(resolved).unwrap_or(DEFAULT_PRE_AUTH_RATE));
1217    let quota = apply_burst(quota, config.pre_auth_burst);
1218    // Defense in depth: Phase-1 config validation rejects `0` upstream, but
1219    // tests can still exercise this helper directly with raw config values.
1220    let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1221    Arc::new(BoundedKeyedLimiter::new_with_policy(
1222        quota,
1223        max_tracked_keys,
1224        config.idle_eviction,
1225        config.key_eviction_policy,
1226    ))
1227}
1228
1229/// Default multiplier applied to `max_attempts_per_minute` when the operator
1230/// does not set `pre_auth_max_per_minute` explicitly.
1231const PRE_AUTH_DEFAULT_MULTIPLIER: u32 = 10;
1232
1233/// Default pre-auth abuse-gate rate (used only if both the configured value
1234/// and the multiplied fallback are zero, which `NonZeroU32::new` rejects).
1235// SAFETY: unwrap() is safe - literal 300 is provably non-zero (const-evaluated).
1236const DEFAULT_PRE_AUTH_RATE: NonZeroU32 = NonZeroU32::new(300).unwrap();
1237
1238/// Parse an mTLS client certificate and extract an `AuthIdentity`.
1239///
1240/// Uses the first non-blank Subject CN as the identity name, else the first
1241/// non-blank DNS SAN. A blank (empty or whitespace-only) CN is treated as
1242/// absent rather than shadowing a usable SAN. Returns `None` if neither
1243/// yields a non-blank name that also passes the character guard. The role is
1244/// taken from the `MtlsConfig`.
1245#[must_use]
1246pub fn extract_mtls_identity(cert_der: &[u8], default_role: &str) -> Option<AuthIdentity> {
1247    let (_, cert) = X509Certificate::from_der(cert_der).ok()?;
1248
1249    // First non-blank CN, else first non-blank DNS SAN: a blank stable id
1250    // collapses distinct principals to one session-binding fingerprint (CWE-384),
1251    // and a present-but-blank CN must not shadow a usable SAN.
1252    let cn = cert
1253        .subject()
1254        .iter_common_name()
1255        .filter_map(|attr| attr.as_str().ok())
1256        .find(|value| !value.trim().is_empty())
1257        .map(String::from);
1258
1259    let name = cn.or_else(|| {
1260        cert.subject_alternative_name()
1261            .ok()
1262            .flatten()
1263            .and_then(|san| {
1264                #[allow(
1265                    clippy::wildcard_enum_match_arm,
1266                    reason = "x509-parser GeneralName is a large external enum; only DNSName is meaningful here"
1267                )]
1268                san.value.general_names.iter().find_map(|gn| match gn {
1269                    GeneralName::DNSName(dns) if !dns.trim().is_empty() => Some((*dns).to_owned()),
1270                    _ => None,
1271                })
1272            })
1273    });
1274
1275    let Some(name) = name else {
1276        tracing::warn!("mTLS identity rejected: no non-blank CN or DNS SAN present");
1277        return None;
1278    };
1279
1280    // Reject identities with characters unsafe for logging and RBAC matching.
1281    if !name
1282        .chars()
1283        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '.' | '_' | '@'))
1284    {
1285        tracing::warn!(cn = %name, "mTLS identity rejected: invalid characters in CN/SAN");
1286        return None;
1287    }
1288
1289    Some(AuthIdentity {
1290        name,
1291        role: default_role.to_owned(),
1292        method: AuthMethod::MtlsCertificate,
1293        raw_token: None,
1294        sub: None,
1295    })
1296}
1297
1298/// Extract the bearer token from an `Authorization` header value.
1299///
1300/// Implements RFC 7235 §2.1: the auth-scheme token is **case-insensitive**.
1301/// `Bearer`, `bearer`, `BEARER`, and `BeArEr` all parse equivalently. Any
1302/// leading whitespace between the scheme and the token is trimmed (per
1303/// RFC 7235 the separator is one or more SP characters; we accept the
1304/// common single-space form plus tolerate extras).
1305///
1306/// Returns `None` if the header value:
1307/// - does not contain a space (no scheme/credentials boundary), or
1308/// - uses a scheme other than `Bearer` (case-insensitively), or
1309/// - carries a credential containing embedded whitespace.
1310///
1311/// # Why whitespace only, and not full `token68`
1312///
1313/// RFC 7235 §2.1 defines the credential as `token68`, which excludes
1314/// whitespace. Accepting an embedded SP/HTAB creates a parser differential
1315/// against a fronting proxy that splits on any whitespace.
1316///
1317/// Enforcing the whole `token68` character class would be a breaking
1318/// change: [`ApiKeyEntry::new`] accepts an arbitrary caller-supplied hash
1319/// and [`verify_bearer_token`] verifies the raw presented string, so
1320/// consumers may have hashed opaque tokens containing punctuation outside
1321/// `token68`. Those must keep authenticating -- do not "complete" this
1322/// check without a major-version note.
1323///
1324/// ASCII semantics suffice because [`http::HeaderValue::to_str`] rejects
1325/// every non-visible byte before this helper runs.
1326fn extract_bearer(value: &str) -> Option<&str> {
1327    let (scheme, rest) = value.split_once(' ')?;
1328    if !scheme.eq_ignore_ascii_case("Bearer") {
1329        return None;
1330    }
1331    let token = rest.trim_start_matches(' ');
1332    if token.is_empty() || token.bytes().any(|b| b.is_ascii_whitespace()) {
1333        return None;
1334    }
1335    Some(token)
1336}
1337
1338/// Verify a bearer token against configured API keys.
1339///
1340/// Argon2id verification is CPU-intensive, so this should be called via
1341/// `spawn_blocking`. Returns the matching identity if the token is valid.
1342///
1343/// # Timing-side-channel resistance
1344///
1345/// Always performs **exactly one Argon2id verification per configured key**,
1346/// regardless of:
1347///
1348/// * which slot (if any) matches the presented token, or
1349/// * whether a key has expired.
1350///
1351/// Expired and post-match slots are verified against an internal dummy PHC hash,
1352/// a fixed Argon2id PHC string with the same cost parameters as the real
1353/// hashes. This bounds the timing observable to "one Argon2 per configured
1354/// key" regardless of which (if any) slot held the matching credential,
1355/// closing the first-match latency oracle (CWE-208) and the expired-slot
1356/// timing leak.
1357///
1358/// `subtle::ConstantTimeEq` folds each slot's match bit into the running
1359/// result without comparing the token bytes in short-circuiting fashion.
1360///
1361/// The guarantee this function provides is the Argon2 count, not full
1362/// branchlessness: selecting `verify_against` and recording `matched_index`
1363/// are both ordinary data-dependent branches. They are cheap, predictable,
1364/// and operate on locals, so they are dwarfed by the Argon2id verification
1365/// that dominates every iteration -- but the timing claim stops at
1366/// "one verification per configured key". Do not read this as a
1367/// constant-time selection routine.
1368///
1369/// # Panics
1370///
1371/// Panics if the internal dummy PHC hash cannot be parsed as an Argon2id PHC string.
1372/// This is impossible by construction: the static is generated by
1373/// [`argon2::Argon2::hash_password`] which always emits a valid PHC string.
1374#[must_use]
1375pub fn verify_bearer_token(token: &str, keys: &[ApiKeyEntry]) -> Option<AuthIdentity> {
1376    use subtle::ConstantTimeEq as _;
1377
1378    let now = chrono::Utc::now();
1379    #[allow(
1380        clippy::expect_used,
1381        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."
1382    )]
1383    let dummy_hash = PasswordHash::new(&DUMMY_PHC_HASH)
1384        .expect("DUMMY_PHC_HASH is a valid Argon2id PHC string by construction");
1385
1386    let mut matched_index: usize = usize::MAX;
1387    let mut any_match: u8 = 0;
1388
1389    for (idx, key) in keys.iter().enumerate() {
1390        let expired = key.expires_at.is_some_and(|exp| exp.as_datetime() < &now);
1391
1392        let real_hash = PasswordHash::new(&key.hash);
1393        let verify_against = match (&real_hash, expired, any_match) {
1394            (Ok(h), false, 0) => h,
1395            _ => &dummy_hash,
1396        };
1397
1398        let slot_ok = u8::from(
1399            Argon2::default()
1400                .verify_password(token.as_bytes(), verify_against)
1401                .is_ok(),
1402        );
1403
1404        let real_match = slot_ok & u8::from(!expired) & u8::from(real_hash.is_ok());
1405        let first_real_match = real_match & (1 - any_match);
1406        if first_real_match.ct_eq(&1).into() {
1407            matched_index = idx;
1408        }
1409        any_match |= real_match;
1410    }
1411
1412    if any_match == 0 {
1413        return None;
1414    }
1415    let key = keys.get(matched_index)?;
1416    // Blank stable id collides distinct principals in the session-binding
1417    // fingerprint (CWE-384). Checked here, after the constant-time match loop
1418    // has fully resolved `matched_index`, so the "one Argon2 per key" timing
1419    // guarantee above is untouched.
1420    if key.name.trim().is_empty() {
1421        tracing::warn!("bearer token rejected: matched API key has a blank name");
1422        return None;
1423    }
1424    Some(AuthIdentity {
1425        name: key.name.clone(),
1426        role: key.role.clone(),
1427        method: AuthMethod::BearerToken,
1428        raw_token: None,
1429        sub: None,
1430    })
1431}
1432
1433/// Fixed Argon2id PHC hash used as a constant-time placeholder when an
1434/// API-key slot is expired, malformed, or follows the matching slot.
1435///
1436/// Generated once on first access using the same default Argon2 cost
1437/// parameters as live verifications, so the dummy verify takes
1438/// indistinguishable wall time from a real one. The plaintext
1439/// (`"rmcp-server-kit-dummy"`) and the fixed salt are unrelated to any
1440/// real credential - randomness is unnecessary because this hash is
1441/// only ever compared against attacker-supplied input on slots that
1442/// will be discarded regardless of match result. Argon2's work factor is
1443/// set by the PHC `m`/`t`/`p` parameters, not by the salt value, so a
1444/// fixed salt costs exactly what a random one would;
1445/// `dummy_and_real_hashes_share_cost_parameters` pins that equivalence.
1446static DUMMY_PHC_HASH: LazyLock<String> = LazyLock::new(|| {
1447    #[allow(
1448        clippy::expect_used,
1449        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"
1450    )]
1451    Argon2::default()
1452        .hash_password_with_salt(b"rmcp-server-kit-dummy", &[0u8; 16])
1453        .expect("Argon2 default params hash a fixed plaintext")
1454        .to_string()
1455});
1456
1457/// Generate a new API key: 256-bit random token + Argon2id hash.
1458///
1459/// Returns `(plaintext_token, argon2id_hash_phc_string)`.
1460/// The plaintext is shown once to the user and never stored.
1461///
1462/// # Errors
1463///
1464/// Returns an error if Argon2id hashing fails (should not happen with valid
1465/// inputs, but we avoid panicking).
1466pub fn generate_api_key() -> Result<(String, String), RmcpServerKitError> {
1467    let mut token_bytes = [0u8; 32];
1468    rand::fill(&mut token_bytes);
1469    let token = URL_SAFE_NO_PAD.encode(token_bytes);
1470
1471    let mut salt_bytes = [0u8; 16];
1472    rand::fill(&mut salt_bytes);
1473    let hash = Argon2::default()
1474        .hash_password_with_salt(token.as_bytes(), &salt_bytes)
1475        .map_err(|e| RmcpServerKitError::Internal(format!("argon2id hashing failed: {e}")))?
1476        .to_string();
1477
1478    Ok((token, hash))
1479}
1480
1481fn build_www_authenticate_value(
1482    resource_metadata: Option<&str>,
1483    failure: AuthFailureClass,
1484) -> String {
1485    let (error, error_description) = failure.bearer_error();
1486    if let Some(url) = resource_metadata {
1487        return format!(
1488            "Bearer resource_metadata=\"{url}\", error=\"{error}\", error_description=\"{error_description}\""
1489        );
1490    }
1491    format!("Bearer error=\"{error}\", error_description=\"{error_description}\"")
1492}
1493
1494fn auth_method_label(method: AuthMethod) -> &'static str {
1495    match method {
1496        AuthMethod::MtlsCertificate => "mTLS",
1497        AuthMethod::BearerToken => "bearer token",
1498        AuthMethod::OAuthJwt => "OAuth JWT",
1499    }
1500}
1501
1502#[cfg_attr(
1503    not(feature = "oauth"),
1504    allow(
1505        unused_variables,
1506        reason = "`state` is only read to decide whether to advertise OAuth \
1507                  protected-resource metadata; without the `oauth` feature that \
1508                  decision is a compile-time `false`"
1509    )
1510)]
1511fn unauthorized_response(state: &AuthState, failure_class: AuthFailureClass) -> Response {
1512    #[cfg(feature = "oauth")]
1513    let advertise_resource_metadata = state.jwks_cache.is_some();
1514    #[cfg(not(feature = "oauth"))]
1515    let advertise_resource_metadata = false;
1516
1517    let resource_metadata = advertise_resource_metadata.then(|| {
1518        state
1519            .resource_metadata_url
1520            .as_deref()
1521            .unwrap_or("/.well-known/oauth-protected-resource")
1522    });
1523    let challenge = build_www_authenticate_value(resource_metadata, failure_class);
1524    (
1525        StatusCode::UNAUTHORIZED,
1526        [(header::WWW_AUTHENTICATE, challenge)],
1527        failure_class.response_body(),
1528    )
1529        .into_response()
1530}
1531
1532// cancel-safe: no shared-state mutation. The Argon2 verification is offloaded
1533// to `spawn_blocking`; dropping its `JoinHandle` on cancellation detaches the
1534// task (the hash completes off-task, harmlessly) rather than tearing partial
1535// state. The OAuth branch delegates to `validate_token_with_reason`, which is
1536// itself cancel-safe (read-only JWKS lookup + pure claim checks).
1537async fn authenticate_bearer_identity(
1538    state: &AuthState,
1539    token: &str,
1540) -> Result<AuthIdentity, AuthFailureClass> {
1541    let mut failure_class = AuthFailureClass::MissingCredential;
1542
1543    #[cfg(feature = "oauth")]
1544    if let Some(ref cache) = state.jwks_cache
1545        && crate::oauth::looks_like_jwt(token)
1546    {
1547        match cache.validate_token_with_reason(token).await {
1548            Ok(mut id) => {
1549                id.raw_token = Some(SecretString::from(token.to_owned()));
1550                return Ok(id);
1551            }
1552            Err(crate::oauth::JwtValidationFailure::Expired) => {
1553                failure_class = AuthFailureClass::ExpiredCredential;
1554            }
1555            Err(crate::oauth::JwtValidationFailure::Invalid) => {
1556                failure_class = AuthFailureClass::InvalidCredential;
1557            }
1558        }
1559    }
1560
1561    let token = token.to_owned();
1562    let keys = state.api_keys.load_full(); // Arc clone, lock-free
1563
1564    // Argon2id is CPU-bound - offload to blocking thread pool.
1565    let identity = tokio::task::spawn_blocking(move || verify_bearer_token(&token, &keys))
1566        .await
1567        .ok()
1568        .flatten();
1569
1570    if let Some(id) = identity {
1571        return Ok(id);
1572    }
1573
1574    if failure_class == AuthFailureClass::MissingCredential {
1575        failure_class = AuthFailureClass::InvalidCredential;
1576    }
1577
1578    Err(failure_class)
1579}
1580
1581/// Consult the pre-auth abuse gate for the given peer.
1582///
1583/// Returns `Some(response)` if the request should be rejected (limiter
1584/// configured AND quota exhausted for this source IP). Returns `None`
1585/// otherwise (limiter absent, peer address unknown, or quota available),
1586/// in which case the caller should proceed with credential verification.
1587///
1588/// Side effects on rejection: increments the `pre_auth_gate` failure
1589/// counter and emits a warn-level log. mTLS-authenticated requests must
1590/// be admitted by the caller *before* invoking this helper.
1591fn pre_auth_gate(state: &AuthState, client_key: Option<&RateLimitKey>) -> Option<Response> {
1592    let limiter = state.pre_auth_limiter.as_ref()?;
1593    let key = client_key?;
1594    match limiter.check_key_detailed(key) {
1595        Ok(()) => None,
1596        Err(BoundedLimiterDeny::RateLimited(wait)) => {
1597            state.counters.record_failure(AuthFailureClass::PreAuthGate);
1598            tracing::warn!(
1599                rate_limit_key = %key,
1600                "auth rate limited by pre-auth gate (request rejected before credential verification)"
1601            );
1602            Some(
1603                RmcpServerKitError::RateLimitedFor {
1604                    message: "too many unauthenticated requests from this source".into(),
1605                    retry_after: wait,
1606                }
1607                .into_response(),
1608            )
1609        }
1610        Err(BoundedLimiterDeny::CapacityFull) => {
1611            tracing::warn!(
1612                rate_limit_key = %key,
1613                "auth pre-auth gate rejected unseen key because tracked-key capacity is full"
1614            );
1615            Some(
1616                (
1617                    StatusCode::SERVICE_UNAVAILABLE,
1618                    "rate limiter capacity exhausted",
1619                )
1620                    .into_response(),
1621            )
1622        }
1623    }
1624}
1625
1626#[cfg_attr(
1627    not(feature = "metrics"),
1628    allow(
1629        unused_variables,
1630        reason = "`extensions` is read only to record the \
1631                  `rmcp_server_kit_rate_limited_total` metric; without the \
1632                  `metrics` feature there is no recording site"
1633    )
1634)]
1635fn post_failure_rate_limit_response(
1636    limiter: &KeyedLimiter,
1637    key: &RateLimitKey,
1638    extensions: &axum::http::Extensions,
1639) -> Option<Response> {
1640    match limiter.check_key_detailed(key) {
1641        Ok(()) => None,
1642        Err(BoundedLimiterDeny::RateLimited(wait)) => {
1643            #[cfg(feature = "metrics")]
1644            crate::metrics::record_rate_limit_deny(extensions, "auth_post");
1645            tracing::warn!(rate_limit_key = %key, "auth rate limited after repeated failures");
1646            Some(
1647                RmcpServerKitError::RateLimitedFor {
1648                    message: "too many failed authentication attempts".into(),
1649                    retry_after: wait,
1650                }
1651                .into_response(),
1652            )
1653        }
1654        Err(BoundedLimiterDeny::CapacityFull) => {
1655            tracing::warn!(
1656                rate_limit_key = %key,
1657                "auth post-failure limiter rejected unseen key because tracked-key capacity is full"
1658            );
1659            Some(
1660                (
1661                    StatusCode::SERVICE_UNAVAILABLE,
1662                    "rate limiter capacity exhausted",
1663                )
1664                    .into_response(),
1665            )
1666        }
1667    }
1668}
1669
1670/// Axum middleware that enforces authentication.
1671///
1672/// Tries authentication methods in priority order:
1673/// 1. mTLS client certificate identity (populated by TLS acceptor)
1674/// 2. Bearer token from `Authorization` header
1675///
1676/// Failed authentication attempts are rate-limited per source IP.
1677/// Successful authentications do not consume rate limit budget.
1678// cancel-safe: `TimeoutLayer` may drop this future, but limiter mutations are
1679// deliberate attempt accounting: pre-auth prices bearer/JWT verification,
1680// post-failure prices failed auth, and identity extensions die with the request.
1681pub(crate) async fn auth_middleware(
1682    state: Arc<AuthState>,
1683    req: Request<Body>,
1684    next: Next,
1685) -> Response {
1686    // Extract the mTLS identity from ConnectInfo (TLS / mTLS:
1687    // ConnectInfo<TlsConnInfo> carries the verified identity directly on
1688    // the connection - no shared map, no port-reuse aliasing) and the
1689    // rate-limit key (resolved client IP when trusted-forwarder mode is
1690    // active, else the direct peer; see transport::limiter_client_ip).
1691    let tls_info = req.extensions().get::<ConnectInfo<TlsConnInfo>>().cloned();
1692    // Resolved only when a limiter will actually consult it, so servers
1693    // with no rate limiting never trip the unattributed-fallback warning.
1694    let client_key = (state.pre_auth_limiter.is_some() || state.rate_limiter.is_some())
1695        .then(|| crate::transport::limiter_client_key(req.extensions()));
1696
1697    // 1. Try mTLS identity (extracted by the TLS acceptor during handshake
1698    //    and attached to the connection itself).
1699    //
1700    //    mTLS connections bypass the pre-auth abuse gate below: the TLS
1701    //    handshake already performed expensive crypto with a verified peer,
1702    //    so we trust them not to be a CPU-spray attacker.
1703    if let Some(id) = tls_info.and_then(|ci| ci.0.identity) {
1704        state.log_auth(&id, "mTLS");
1705        let mut req = req;
1706        req.extensions_mut().insert(id);
1707        return next.run(req).await;
1708    }
1709
1710    // 2. Pre-auth abuse gate: rejects CPU-spray attacks BEFORE the Argon2id
1711    //    verification path runs. Keyed by source IP. mTLS connections (above)
1712    //    are exempt; this gate only protects the bearer/JWT verification path.
1713    if let Some(blocked) = pre_auth_gate(&state, client_key.as_ref()) {
1714        #[cfg(feature = "metrics")]
1715        crate::metrics::record_rate_limit_deny(req.extensions(), "auth_pre");
1716        return blocked;
1717    }
1718
1719    let failure_class = if let Some(value) = req.headers().get(header::AUTHORIZATION) {
1720        match value.to_str().ok().and_then(extract_bearer) {
1721            Some(token) => match authenticate_bearer_identity(&state, token).await {
1722                Ok(id) => {
1723                    state.log_auth(&id, auth_method_label(id.method));
1724                    let mut req = req;
1725                    req.extensions_mut().insert(id);
1726                    return next.run(req).await;
1727                }
1728                Err(class) => class,
1729            },
1730            None => AuthFailureClass::InvalidCredential,
1731        }
1732    } else {
1733        AuthFailureClass::MissingCredential
1734    };
1735
1736    tracing::warn!(failure_class = %failure_class.as_str(), "auth failed");
1737
1738    // Rate limit check (applied after auth failure only).
1739    // Successful authentications do not consume rate limit budget.
1740    if let (Some(limiter), Some(key)) = (&state.rate_limiter, client_key.as_ref())
1741        && let Some(resp) = post_failure_rate_limit_response(limiter, key, req.extensions())
1742    {
1743        if resp.status() == StatusCode::TOO_MANY_REQUESTS {
1744            state.counters.record_failure(AuthFailureClass::RateLimited);
1745        }
1746        return resp;
1747    }
1748
1749    state.counters.record_failure(failure_class);
1750    unauthorized_response(&state, failure_class)
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755    use std::net::IpAddr;
1756
1757    use super::*;
1758    use crate::transport::RateLimitKey;
1759
1760    /// A PHC string produced by **argon2 0.5.3** through the same code path as
1761    /// [`generate_api_key`] (16 salt bytes, `Argon2::default()`).
1762    ///
1763    /// Pinned so the argon2 0.6 upgrade cannot silently invalidate credentials
1764    /// that are already deployed: if this stops verifying, every stored API key
1765    /// stops working. Captured before the upgrade and asserted after it.
1766    const ARGON2_0_5_TOKEN: &str = "golden-vector-token-0p5p3";
1767    const ARGON2_0_5_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$BwcHBwcHBwcHBwcHBwcHBw$spS8B9AhHG1LikfhGlssVMfP8mq37+8/mXnl98ps0NU";
1768
1769    #[test]
1770    fn argon2_0_5_produced_hash_still_verifies() {
1771        let parsed =
1772            PasswordHash::new(ARGON2_0_5_HASH).expect("a 0.5-era PHC string must still parse");
1773        Argon2::default()
1774            .verify_password(ARGON2_0_5_TOKEN.as_bytes(), &parsed)
1775            .expect("already-deployed API keys must keep verifying across the argon2 upgrade");
1776    }
1777
1778    /// The dummy hash burned on a miss must cost the same as a real one.
1779    ///
1780    /// Argon2 work is set by the PHC parameters, not the salt, so asserting the
1781    /// dummy and a freshly generated key share `argon2id`, `v=19` and identical
1782    /// `m`/`t`/`p` pins the constant-time property without a flaky wall-clock
1783    /// measurement. Forcing `DUMMY_PHC_HASH` here also surfaces a `LazyLock`
1784    /// panic in CI rather than at the first production auth.
1785    #[test]
1786    fn dummy_and_real_hashes_share_cost_parameters() {
1787        let (_token, real_hash) = generate_api_key().expect("key generation must succeed");
1788        let real = PasswordHash::new(&real_hash).expect("generated hash must parse");
1789        let dummy = PasswordHash::new(&DUMMY_PHC_HASH).expect("dummy hash must parse");
1790
1791        assert_eq!(dummy.algorithm, real.algorithm, "algorithm must match");
1792        assert_eq!(dummy.version, real.version, "PHC version must match");
1793        assert_eq!(
1794            dummy.params, real.params,
1795            "m/t/p must match or the dummy no longer costs what a real verification costs"
1796        );
1797    }
1798
1799    #[test]
1800    fn generate_and_verify_api_key() {
1801        let (token, hash) = generate_api_key().unwrap();
1802
1803        // Token is 43 chars (256-bit base64url, no padding)
1804        assert_eq!(token.len(), 43);
1805
1806        // Hash is a valid PHC string
1807        assert!(hash.starts_with("$argon2id$"));
1808
1809        // Verification succeeds with correct token
1810        let keys = vec![ApiKeyEntry {
1811            name: "test".into(),
1812            hash,
1813            role: "viewer".into(),
1814            expires_at: None,
1815        }];
1816        let id = verify_bearer_token(&token, &keys);
1817        assert!(id.is_some());
1818        let id = id.unwrap();
1819        assert_eq!(id.name, "test");
1820        assert_eq!(id.role, "viewer");
1821        assert_eq!(id.method, AuthMethod::BearerToken);
1822    }
1823
1824    #[test]
1825    fn wrong_token_rejected() {
1826        let (_token, hash) = generate_api_key().unwrap();
1827        let keys = vec![ApiKeyEntry {
1828            name: "test".into(),
1829            hash,
1830            role: "viewer".into(),
1831            expires_at: None,
1832        }];
1833        assert!(verify_bearer_token("wrong-token", &keys).is_none());
1834    }
1835
1836    #[test]
1837    fn expired_key_rejected() {
1838        let (token, hash) = generate_api_key().unwrap();
1839        let keys = vec![ApiKeyEntry {
1840            name: "test".into(),
1841            hash,
1842            role: "viewer".into(),
1843            expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1844        }];
1845        assert!(verify_bearer_token(&token, &keys).is_none());
1846    }
1847
1848    #[test]
1849    fn match_in_last_slot_still_authenticates() {
1850        let (token, hash) = generate_api_key().unwrap();
1851        let (_other_token, other_hash) = generate_api_key().unwrap();
1852        let keys = vec![
1853            ApiKeyEntry {
1854                name: "first".into(),
1855                hash: other_hash.clone(),
1856                role: "viewer".into(),
1857                expires_at: None,
1858            },
1859            ApiKeyEntry {
1860                name: "second".into(),
1861                hash: other_hash,
1862                role: "viewer".into(),
1863                expires_at: None,
1864            },
1865            ApiKeyEntry {
1866                name: "match".into(),
1867                hash,
1868                role: "ops".into(),
1869                expires_at: None,
1870            },
1871        ];
1872        let id = verify_bearer_token(&token, &keys).expect("last-slot match must authenticate");
1873        assert_eq!(id.name, "match");
1874        assert_eq!(id.role, "ops");
1875    }
1876
1877    #[test]
1878    fn expired_slot_before_valid_match_does_not_short_circuit() {
1879        let (token, hash) = generate_api_key().unwrap();
1880        let (_, other_hash) = generate_api_key().unwrap();
1881        let keys = vec![
1882            ApiKeyEntry {
1883                name: "expired".into(),
1884                hash: other_hash,
1885                role: "viewer".into(),
1886                expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1887            },
1888            ApiKeyEntry {
1889                name: "valid".into(),
1890                hash,
1891                role: "ops".into(),
1892                expires_at: None,
1893            },
1894        ];
1895        let id = verify_bearer_token(&token, &keys)
1896            .expect("valid slot following an expired slot must authenticate");
1897        assert_eq!(id.name, "valid");
1898    }
1899
1900    #[test]
1901    fn malformed_hash_slot_does_not_short_circuit() {
1902        let (token, hash) = generate_api_key().unwrap();
1903        let keys = vec![
1904            ApiKeyEntry {
1905                name: "broken".into(),
1906                hash: "this-is-not-a-phc-string".into(),
1907                role: "viewer".into(),
1908                expires_at: None,
1909            },
1910            ApiKeyEntry {
1911                name: "valid".into(),
1912                hash,
1913                role: "ops".into(),
1914                expires_at: None,
1915            },
1916        ];
1917        let id = verify_bearer_token(&token, &keys)
1918            .expect("valid slot following a malformed-hash slot must authenticate");
1919        assert_eq!(id.name, "valid");
1920    }
1921
1922    // Regression tests for H3 (api_key_expires_at_fail_open).
1923    //
1924    // Prior to 1.6.0 the runtime expiry check used a chained
1925    // `if let Some(_) && let Ok(exp) = parse(_) && exp < now` which
1926    // silently fell through on parse error, letting a key with
1927    // `expires_at = "not-a-date"` authenticate forever. These tests
1928    // pin the type-system fix: malformed RFC 3339 is rejected at
1929    // deserialization time (no `RfcTimestamp` can ever be malformed),
1930    // and the runtime check is a pure comparison with no parse path.
1931
1932    #[test]
1933    fn rfc_timestamp_parse_rejects_malformed() {
1934        for bad in [
1935            "not-a-date",
1936            "",
1937            "2025-13-01T00:00:00Z", // month 13
1938            "2025-01-32T00:00:00Z", // day 32
1939            "2025-01-01T00:00:00",  // missing offset
1940            "01/01/2025",           // wrong format
1941            "2025-01-01T25:00:00Z", // hour 25
1942        ] {
1943            assert!(
1944                RfcTimestamp::parse(bad).is_err(),
1945                "RfcTimestamp::parse must reject {bad:?}"
1946            );
1947        }
1948    }
1949
1950    #[test]
1951    fn rfc_timestamp_parse_accepts_valid() {
1952        for good in [
1953            "2025-01-01T00:00:00Z",
1954            "2025-01-01T00:00:00+00:00",
1955            "2025-12-31T23:59:59-08:00",
1956            "2099-01-01T00:00:00.123456789Z",
1957        ] {
1958            assert!(
1959                RfcTimestamp::parse(good).is_ok(),
1960                "RfcTimestamp::parse must accept {good:?}"
1961            );
1962        }
1963    }
1964
1965    #[test]
1966    fn api_key_entry_deserialize_rejects_malformed_expires_at() {
1967        // TOML with a malformed expires_at must fail to deserialize.
1968        // This is the load-time defense: a typo in auth.toml aborts
1969        // config load with a clear serde error, instead of producing
1970        // a key that authenticates forever (the H3 fail-open).
1971        let toml = r#"
1972            name = "bad-key"
1973            hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1974            role = "viewer"
1975            expires_at = "not-a-date"
1976        "#;
1977        let result: Result<ApiKeyEntry, _> = toml::from_str(toml);
1978        assert!(
1979            result.is_err(),
1980            "deserialization must reject malformed expires_at"
1981        );
1982    }
1983
1984    #[test]
1985    fn api_key_entry_deserialize_accepts_valid_expires_at() {
1986        let toml = r#"
1987            name = "good-key"
1988            hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1989            role = "viewer"
1990            expires_at = "2099-01-01T00:00:00Z"
1991        "#;
1992        let entry: ApiKeyEntry = toml::from_str(toml).expect("valid RFC 3339 must deserialize");
1993        assert!(entry.expires_at.is_some());
1994    }
1995
1996    #[test]
1997    fn api_key_entry_deserialize_accepts_missing_expires_at() {
1998        // Omitting expires_at must continue to mean "no expiry"; this
1999        // is the documented contract and must survive the H3 fix.
2000        let toml = r#"
2001            name = "eternal-key"
2002            hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
2003            role = "viewer"
2004        "#;
2005        let entry: ApiKeyEntry = toml::from_str(toml).expect("missing expires_at must deserialize");
2006        assert!(entry.expires_at.is_none());
2007    }
2008
2009    #[test]
2010    fn mtls_crl_deny_on_unavailable_defaults_to_fail_closed() {
2011        // Every in-crate test helper builds MtlsConfig via a struct literal,
2012        // which bypasses serde defaults entirely. Only a deserialization from
2013        // TOML that omits the key exercises the shipped default.
2014        let toml = r#"
2015            ca_cert_path = "/etc/certs/clients-ca.pem"
2016        "#;
2017        let cfg: MtlsConfig = toml::from_str(toml).expect("minimal mtls config must deserialize");
2018        assert!(
2019            cfg.crl_deny_on_unavailable,
2020            "omitting crl_deny_on_unavailable must fail closed (RFC 5280 6.3)"
2021        );
2022    }
2023
2024    #[test]
2025    fn mtls_crl_deny_on_unavailable_opt_out_is_honoured() {
2026        let toml = r#"
2027            ca_cert_path = "/etc/certs/clients-ca.pem"
2028            crl_deny_on_unavailable = false
2029        "#;
2030        let cfg: MtlsConfig = toml::from_str(toml).expect("opt-out config must deserialize");
2031        assert!(
2032            !cfg.crl_deny_on_unavailable,
2033            "an explicit false must still select fail-open"
2034        );
2035    }
2036
2037    #[test]
2038    fn try_with_expiry_rejects_malformed() {
2039        let entry = ApiKeyEntry::new("k", "hash", "viewer");
2040        assert!(entry.try_with_expiry("not-a-date").is_err());
2041    }
2042
2043    #[test]
2044    fn try_with_expiry_accepts_valid() {
2045        let entry = ApiKeyEntry::new("k", "hash", "viewer")
2046            .try_with_expiry("2099-01-01T00:00:00Z")
2047            .expect("valid RFC 3339 must be accepted");
2048        assert!(entry.expires_at.is_some());
2049    }
2050
2051    #[test]
2052    fn api_key_summary_serializes_expires_at_as_rfc3339() {
2053        // The admin endpoint wire format is `{"expires_at": "RFC 3339 str"}`.
2054        // Pinning this prevents an accidental serialization-format change
2055        // (e.g. chrono's debug form, a Unix timestamp) that would silently
2056        // break operator tooling that parses these payloads.
2057        let summary = ApiKeySummary {
2058            name: "k".into(),
2059            role: "viewer".into(),
2060            expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
2061        };
2062        let json = serde_json::to_string(&summary).unwrap();
2063        assert!(
2064            json.contains(r#""expires_at":"2030-01-01T00:00:00+00:00""#),
2065            "wire format regressed: {json}"
2066        );
2067    }
2068
2069    #[test]
2070    fn future_expiry_accepted() {
2071        let (token, hash) = generate_api_key().unwrap();
2072        let keys = vec![ApiKeyEntry {
2073            name: "test".into(),
2074            hash,
2075            role: "viewer".into(),
2076            expires_at: Some(RfcTimestamp::parse("2099-01-01T00:00:00Z").unwrap()),
2077        }];
2078        assert!(verify_bearer_token(&token, &keys).is_some());
2079    }
2080
2081    #[test]
2082    fn multiple_keys_first_match_wins() {
2083        let (token, hash) = generate_api_key().unwrap();
2084        let keys = vec![
2085            ApiKeyEntry {
2086                name: "wrong".into(),
2087                hash: "$argon2id$v=19$m=19456,t=2,p=1$invalid$invalid".into(),
2088                role: "ops".into(),
2089                expires_at: None,
2090            },
2091            ApiKeyEntry {
2092                name: "correct".into(),
2093                hash,
2094                role: "deploy".into(),
2095                expires_at: None,
2096            },
2097        ];
2098        let id = verify_bearer_token(&token, &keys).unwrap();
2099        assert_eq!(id.name, "correct");
2100        assert_eq!(id.role, "deploy");
2101    }
2102
2103    #[test]
2104    fn rate_limiter_allows_within_quota() {
2105        let config = RateLimitConfig {
2106            max_attempts_per_minute: 5,
2107            pre_auth_max_per_minute: None,
2108            max_tracked_keys: default_max_tracked_keys(),
2109            idle_eviction: default_idle_eviction(),
2110            burst: None,
2111            pre_auth_burst: None,
2112            key_eviction_policy: KeyEvictionPolicy::default(),
2113        };
2114        let limiter = build_rate_limiter(&config);
2115        let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2116
2117        // First 5 should succeed.
2118        for _ in 0..5 {
2119            assert!(limiter.check_key(&ip).is_ok());
2120        }
2121        // 6th should fail.
2122        assert!(limiter.check_key(&ip).is_err());
2123    }
2124
2125    #[test]
2126    fn rate_limiter_separate_ips() {
2127        let config = RateLimitConfig {
2128            max_attempts_per_minute: 2,
2129            pre_auth_max_per_minute: None,
2130            max_tracked_keys: default_max_tracked_keys(),
2131            idle_eviction: default_idle_eviction(),
2132            burst: None,
2133            pre_auth_burst: None,
2134            key_eviction_policy: KeyEvictionPolicy::default(),
2135        };
2136        let limiter = build_rate_limiter(&config);
2137        let ip1 = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2138        let ip2 = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2139
2140        // Exhaust ip1's quota.
2141        assert!(limiter.check_key(&ip1).is_ok());
2142        assert!(limiter.check_key(&ip1).is_ok());
2143        assert!(limiter.check_key(&ip1).is_err());
2144
2145        // ip2 should still have quota.
2146        assert!(limiter.check_key(&ip2).is_ok());
2147    }
2148
2149    #[test]
2150    fn extract_mtls_identity_from_cn() {
2151        // Generate a cert with explicit CN.
2152        let mut params = rcgen::CertificateParams::new(vec!["test-client.local".into()]).unwrap();
2153        params.distinguished_name = rcgen::DistinguishedName::new();
2154        params
2155            .distinguished_name
2156            .push(rcgen::DnType::CommonName, "test-client");
2157        let cert = params
2158            .self_signed(&rcgen::KeyPair::generate().unwrap())
2159            .unwrap();
2160        let der = cert.der();
2161
2162        let id = extract_mtls_identity(der, "ops").unwrap();
2163        assert_eq!(id.name, "test-client");
2164        assert_eq!(id.role, "ops");
2165        assert_eq!(id.method, AuthMethod::MtlsCertificate);
2166    }
2167
2168    #[test]
2169    fn extract_mtls_identity_falls_back_to_san() {
2170        // Cert with no CN but has a DNS SAN.
2171        let mut params =
2172            rcgen::CertificateParams::new(vec!["san-only.example.com".into()]).unwrap();
2173        params.distinguished_name = rcgen::DistinguishedName::new();
2174        // No CN set - should fall back to DNS SAN.
2175        let cert = params
2176            .self_signed(&rcgen::KeyPair::generate().unwrap())
2177            .unwrap();
2178        let der = cert.der();
2179
2180        let id = extract_mtls_identity(der, "viewer").unwrap();
2181        assert_eq!(id.name, "san-only.example.com");
2182        assert_eq!(id.role, "viewer");
2183    }
2184
2185    #[test]
2186    fn extract_mtls_identity_invalid_der() {
2187        assert!(extract_mtls_identity(b"not-a-cert", "viewer").is_none());
2188    }
2189
2190    #[test]
2191    fn extract_mtls_identity_blank_cn_falls_back_to_san() {
2192        // A present-but-blank CN must not shadow a usable DNS SAN. The pre-fix
2193        // `or_else` never reached the SAN here because `Some("")` short-circuited it.
2194        let mut params =
2195            rcgen::CertificateParams::new(vec!["san-fallback.example.com".into()]).unwrap();
2196        params.distinguished_name = rcgen::DistinguishedName::new();
2197        params
2198            .distinguished_name
2199            .push(rcgen::DnType::CommonName, "");
2200        let cert = params
2201            .self_signed(&rcgen::KeyPair::generate().unwrap())
2202            .unwrap();
2203
2204        let id = extract_mtls_identity(cert.der(), "viewer").unwrap();
2205        assert_eq!(id.name, "san-fallback.example.com");
2206        assert_eq!(id.role, "viewer");
2207    }
2208
2209    #[test]
2210    fn extract_mtls_identity_blank_cn_without_san_yields_none() {
2211        let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
2212        params.distinguished_name = rcgen::DistinguishedName::new();
2213        params
2214            .distinguished_name
2215            .push(rcgen::DnType::CommonName, "");
2216        let cert = params
2217            .self_signed(&rcgen::KeyPair::generate().unwrap())
2218            .unwrap();
2219
2220        assert!(extract_mtls_identity(cert.der(), "viewer").is_none());
2221    }
2222
2223    #[test]
2224    fn extract_mtls_identity_whitespace_cn_behaves_as_blank() {
2225        let mut params =
2226            rcgen::CertificateParams::new(vec!["san-fallback.example.com".into()]).unwrap();
2227        params.distinguished_name = rcgen::DistinguishedName::new();
2228        params
2229            .distinguished_name
2230            .push(rcgen::DnType::CommonName, "   ");
2231        let cert = params
2232            .self_signed(&rcgen::KeyPair::generate().unwrap())
2233            .unwrap();
2234
2235        let id = extract_mtls_identity(cert.der(), "viewer").unwrap();
2236        assert_eq!(id.name, "san-fallback.example.com");
2237    }
2238
2239    #[test]
2240    fn validate_api_key_names_rejects_blank_and_whitespace() {
2241        let blank = AuthConfig::with_keys(vec![
2242            ApiKeyEntry::new("ok", "hash", "viewer"),
2243            ApiKeyEntry::new("", "hash", "viewer"),
2244        ]);
2245        let err = blank.validate_api_key_names().unwrap_err().to_string();
2246        assert!(
2247            err.contains("api_keys[1]"),
2248            "must name offending index: {err}"
2249        );
2250
2251        let whitespace = AuthConfig::with_keys(vec![ApiKeyEntry::new("   ", "hash", "viewer")]);
2252        assert!(whitespace.validate_api_key_names().is_err());
2253
2254        let ok = AuthConfig::with_keys(vec![ApiKeyEntry::new("viewer-key", "hash", "viewer")]);
2255        assert!(ok.validate_api_key_names().is_ok());
2256    }
2257
2258    #[test]
2259    fn try_reload_keys_rejects_blank_name_and_keeps_previous() {
2260        let (token, hash) = generate_api_key().unwrap();
2261        let state = test_auth_state(vec![ApiKeyEntry::new("prev-key", hash, "ops")]);
2262
2263        let err = state
2264            .try_reload_keys(vec![ApiKeyEntry::new("  ", "unused-hash", "ops")])
2265            .unwrap_err()
2266            .to_string();
2267        assert!(
2268            err.contains("api_keys[0]"),
2269            "must name offending index: {err}"
2270        );
2271
2272        let installed = state.api_keys.load();
2273        assert!(
2274            verify_bearer_token(&token, &installed).is_some(),
2275            "the previous key must remain installed after a rejected reload"
2276        );
2277    }
2278
2279    #[test]
2280    fn verify_bearer_token_rejects_blank_named_key() {
2281        let (token, hash) = generate_api_key().unwrap();
2282        let blank = ApiKeyEntry {
2283            name: String::new(),
2284            hash: hash.clone(),
2285            role: "ops".into(),
2286            expires_at: None,
2287        };
2288        assert!(
2289            verify_bearer_token(&token, std::slice::from_ref(&blank)).is_none(),
2290            "a valid token for a blank-named key must yield no identity"
2291        );
2292
2293        let whitespace = ApiKeyEntry {
2294            name: "   ".into(),
2295            hash: hash.clone(),
2296            role: "ops".into(),
2297            expires_at: None,
2298        };
2299        assert!(
2300            verify_bearer_token(&token, std::slice::from_ref(&whitespace)).is_none(),
2301            "a whitespace-only key name must be treated as blank"
2302        );
2303
2304        let named = ApiKeyEntry::new("real-key", hash, "ops");
2305        assert!(
2306            verify_bearer_token(&token, std::slice::from_ref(&named)).is_some(),
2307            "a non-blank key name must still authenticate"
2308        );
2309    }
2310
2311    // -- auth_middleware integration tests --
2312
2313    use axum::{
2314        body::Body,
2315        http::{Request, StatusCode},
2316    };
2317    use tower::ServiceExt as _;
2318
2319    fn auth_router(state: Arc<AuthState>) -> axum::Router {
2320        axum::Router::new()
2321            .route("/mcp", axum::routing::post(|| async { "ok" }))
2322            .layer(axum::middleware::from_fn(move |req, next| {
2323                let s = Arc::clone(&state);
2324                auth_middleware(s, req, next)
2325            }))
2326    }
2327
2328    fn test_auth_state(keys: Vec<ApiKeyEntry>) -> Arc<AuthState> {
2329        Arc::new(AuthState {
2330            api_keys: ArcSwap::new(Arc::new(keys)),
2331            rate_limiter: None,
2332            pre_auth_limiter: None,
2333            #[cfg(feature = "oauth")]
2334            jwks_cache: None,
2335            seen_identities: SeenIdentitySet::new(),
2336            counters: AuthCounters::default(),
2337            resource_metadata_url: None,
2338        })
2339    }
2340
2341    #[tokio::test]
2342    async fn middleware_rejects_no_credentials() {
2343        let state = test_auth_state(vec![]);
2344        let app = auth_router(Arc::clone(&state));
2345        let req = Request::builder()
2346            .method(axum::http::Method::POST)
2347            .uri("/mcp")
2348            .body(Body::empty())
2349            .unwrap();
2350        let resp = app.oneshot(req).await.unwrap();
2351        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2352        let challenge = resp
2353            .headers()
2354            .get(header::WWW_AUTHENTICATE)
2355            .unwrap()
2356            .to_str()
2357            .unwrap();
2358        assert!(challenge.contains("error=\"invalid_request\""));
2359
2360        let counters = state.counters_snapshot();
2361        assert_eq!(counters.failure_missing_credential, 1);
2362    }
2363
2364    #[tokio::test]
2365    async fn middleware_accepts_valid_bearer() {
2366        let (token, hash) = generate_api_key().unwrap();
2367        let keys = vec![ApiKeyEntry {
2368            name: "test-key".into(),
2369            hash,
2370            role: "ops".into(),
2371            expires_at: None,
2372        }];
2373        let state = test_auth_state(keys);
2374        let app = auth_router(Arc::clone(&state));
2375        let req = Request::builder()
2376            .method(axum::http::Method::POST)
2377            .uri("/mcp")
2378            .header("authorization", format!("Bearer {token}"))
2379            .body(Body::empty())
2380            .unwrap();
2381        let resp = app.oneshot(req).await.unwrap();
2382        assert_eq!(resp.status(), StatusCode::OK);
2383
2384        let counters = state.counters_snapshot();
2385        assert_eq!(counters.success_bearer, 1);
2386    }
2387
2388    #[tokio::test]
2389    async fn middleware_rejects_wrong_bearer() {
2390        let (_token, hash) = generate_api_key().unwrap();
2391        let keys = vec![ApiKeyEntry {
2392            name: "test-key".into(),
2393            hash,
2394            role: "ops".into(),
2395            expires_at: None,
2396        }];
2397        let state = test_auth_state(keys);
2398        let app = auth_router(Arc::clone(&state));
2399        let req = Request::builder()
2400            .method(axum::http::Method::POST)
2401            .uri("/mcp")
2402            .header("authorization", "Bearer wrong-token-here")
2403            .body(Body::empty())
2404            .unwrap();
2405        let resp = app.oneshot(req).await.unwrap();
2406        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2407        let challenge = resp
2408            .headers()
2409            .get(header::WWW_AUTHENTICATE)
2410            .unwrap()
2411            .to_str()
2412            .unwrap();
2413        assert!(challenge.contains("error=\"invalid_token\""));
2414
2415        let counters = state.counters_snapshot();
2416        assert_eq!(counters.failure_invalid_credential, 1);
2417    }
2418
2419    #[tokio::test]
2420    async fn middleware_rate_limits() {
2421        let state = Arc::new(AuthState {
2422            api_keys: ArcSwap::new(Arc::new(vec![])),
2423            rate_limiter: Some(build_rate_limiter(&RateLimitConfig {
2424                max_attempts_per_minute: 1,
2425                pre_auth_max_per_minute: None,
2426                max_tracked_keys: default_max_tracked_keys(),
2427                idle_eviction: default_idle_eviction(),
2428                burst: None,
2429                pre_auth_burst: None,
2430                key_eviction_policy: KeyEvictionPolicy::default(),
2431            })),
2432            pre_auth_limiter: None,
2433            #[cfg(feature = "oauth")]
2434            jwks_cache: None,
2435            seen_identities: SeenIdentitySet::new(),
2436            counters: AuthCounters::default(),
2437            resource_metadata_url: None,
2438        });
2439        let app = auth_router(state);
2440
2441        // First request: UNAUTHORIZED (no credentials, but not rate limited)
2442        let req = Request::builder()
2443            .method(axum::http::Method::POST)
2444            .uri("/mcp")
2445            .body(Body::empty())
2446            .unwrap();
2447        let resp = app.clone().oneshot(req).await.unwrap();
2448        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2449
2450        // Second request from same "IP" (no ConnectInfo in test, so peer_addr is None
2451        // and rate limiter won't fire). That's expected -- rate limiting requires
2452        // ConnectInfo which isn't available in unit tests without a real server.
2453        // This test verifies the middleware wiring doesn't panic.
2454    }
2455
2456    /// Verify that rate limit semantics: only failed auth attempts consume budget.
2457    ///
2458    /// This is a unit test of the limiter behavior. The middleware integration
2459    /// is that on auth failure, `check_key` is called; on auth success, it is NOT.
2460    /// Full e2e tests verify the middleware routing but require `ConnectInfo`.
2461    #[test]
2462    fn rate_limit_semantics_failed_only() {
2463        let config = RateLimitConfig {
2464            max_attempts_per_minute: 3,
2465            pre_auth_max_per_minute: None,
2466            max_tracked_keys: default_max_tracked_keys(),
2467            idle_eviction: default_idle_eviction(),
2468            burst: None,
2469            pre_auth_burst: None,
2470            key_eviction_policy: KeyEvictionPolicy::default(),
2471        };
2472        let limiter = build_rate_limiter(&config);
2473        let ip = RateLimitKey::Ip("192.168.1.100".parse::<IpAddr>().unwrap());
2474
2475        // Simulate: 3 failed attempts should exhaust quota.
2476        assert!(
2477            limiter.check_key(&ip).is_ok(),
2478            "failure 1 should be allowed"
2479        );
2480        assert!(
2481            limiter.check_key(&ip).is_ok(),
2482            "failure 2 should be allowed"
2483        );
2484        assert!(
2485            limiter.check_key(&ip).is_ok(),
2486            "failure 3 should be allowed"
2487        );
2488        assert!(
2489            limiter.check_key(&ip).is_err(),
2490            "failure 4 should be blocked"
2491        );
2492
2493        // In the actual middleware flow:
2494        // - Successful auth: verify_bearer_token returns Some, we return early
2495        //   WITHOUT calling check_key, so no budget consumed.
2496        // - Failed auth: verify_bearer_token returns None, we call check_key
2497        //   THEN return 401, so budget is consumed.
2498        //
2499        // This means N successful requests followed by M failed requests
2500        // will only count M toward the rate limit, not N+M.
2501    }
2502
2503    // -- pre-auth abuse gate (H-S1) --
2504
2505    /// The pre-auth gate must default to ~10x the post-failure quota so honest
2506    /// retry storms never trip it but a Argon2-spray attacker is throttled.
2507    #[test]
2508    fn pre_auth_default_multiplier_is_10x() {
2509        let config = RateLimitConfig {
2510            max_attempts_per_minute: 5,
2511            pre_auth_max_per_minute: None,
2512            max_tracked_keys: default_max_tracked_keys(),
2513            idle_eviction: default_idle_eviction(),
2514            burst: None,
2515            pre_auth_burst: None,
2516            key_eviction_policy: KeyEvictionPolicy::default(),
2517        };
2518        let limiter = build_pre_auth_limiter(&config);
2519        let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2520
2521        // Quota should be 50 (5 * 10), not 5. We expect the first 50 to pass.
2522        for i in 0..50 {
2523            assert!(
2524                limiter.check_key(&ip).is_ok(),
2525                "pre-auth attempt {i} (of expected 50) should be allowed under default 10x multiplier"
2526            );
2527        }
2528        // The 51st attempt must be blocked: confirms quota is bounded, not infinite.
2529        assert!(
2530            limiter.check_key(&ip).is_err(),
2531            "pre-auth attempt 51 should be blocked (quota is 50, not unbounded)"
2532        );
2533    }
2534
2535    /// An explicit `pre_auth_max_per_minute` override must win over the
2536    /// 10x-multiplier default.
2537    #[test]
2538    fn pre_auth_explicit_override_wins() {
2539        let config = RateLimitConfig {
2540            max_attempts_per_minute: 100,     // would default to 1000 pre-auth quota
2541            pre_auth_max_per_minute: Some(2), // but operator caps at 2
2542            max_tracked_keys: default_max_tracked_keys(),
2543            idle_eviction: default_idle_eviction(),
2544            burst: None,
2545            pre_auth_burst: None,
2546            key_eviction_policy: KeyEvictionPolicy::default(),
2547        };
2548        let limiter = build_pre_auth_limiter(&config);
2549        let ip = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2550
2551        assert!(limiter.check_key(&ip).is_ok(), "attempt 1 allowed");
2552        assert!(limiter.check_key(&ip).is_ok(), "attempt 2 allowed");
2553        assert!(
2554            limiter.check_key(&ip).is_err(),
2555            "attempt 3 must be blocked (explicit override of 2 wins over 10x default of 1000)"
2556        );
2557    }
2558
2559    /// The pre-auth gate's 429 must carry a Retry-After header.
2560    #[test]
2561    fn pre_auth_gate_deny_sets_retry_after() {
2562        let config = RateLimitConfig::new(100).with_pre_auth_max_per_minute(1);
2563        let state = AuthState {
2564            api_keys: ArcSwap::new(Arc::new(vec![])),
2565            rate_limiter: None,
2566            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2567            #[cfg(feature = "oauth")]
2568            jwks_cache: None,
2569            seen_identities: SeenIdentitySet::new(),
2570            counters: AuthCounters::default(),
2571            resource_metadata_url: None,
2572        };
2573        let ip = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2574        assert!(
2575            pre_auth_gate(&state, Some(&ip)).is_none(),
2576            "first request within quota"
2577        );
2578        let resp = pre_auth_gate(&state, Some(&ip)).expect("second request must be gated");
2579        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
2580        let retry_after = resp
2581            .headers()
2582            .get(header::RETRY_AFTER)
2583            .expect("Retry-After present")
2584            .to_str()
2585            .unwrap()
2586            .parse::<u64>()
2587            .unwrap();
2588        assert!(retry_after >= 1, "delta-seconds must be >= 1");
2589    }
2590
2591    #[test]
2592    fn pre_auth_gate_capacity_full_returns_503_without_retry_after() {
2593        let config = RateLimitConfig::new(100)
2594            .with_pre_auth_max_per_minute(10)
2595            .with_max_tracked_keys(1)
2596            .with_key_eviction_policy(KeyEvictionPolicy::RejectNew);
2597        let state = 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 established = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2608        let unseen = RateLimitKey::Ip("10.7.7.8".parse::<IpAddr>().unwrap());
2609        assert!(pre_auth_gate(&state, Some(&established)).is_none());
2610
2611        let resp = pre_auth_gate(&state, Some(&unseen)).expect("unseen key must be rejected");
2612
2613        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
2614        assert!(resp.headers().get(header::RETRY_AFTER).is_none());
2615    }
2616
2617    /// Post-failure limiter honors an explicit burst capacity.
2618    #[test]
2619    fn post_failure_limiter_burst_allows_initial_spike() {
2620        let config = RateLimitConfig::new(1).with_burst(3);
2621        let limiter = build_rate_limiter(&config);
2622        let ip = RateLimitKey::Ip("10.6.6.6".parse::<IpAddr>().unwrap());
2623        for i in 0..3 {
2624            assert!(limiter.check_key(&ip).is_ok(), "burst attempt {i}");
2625        }
2626        assert!(
2627            limiter.check_key(&ip).is_err(),
2628            "attempt 4 must exceed the burst bucket"
2629        );
2630    }
2631
2632    /// End-to-end: the pre-auth gate must reject before the bearer-verification
2633    /// path runs. We exhaust the gate's quota (Some(1)) with one bad-bearer
2634    /// request, then the second request must be rejected with 429 + the
2635    /// `pre_auth_gate` failure counter incremented (NOT
2636    /// `failure_invalid_credential`, which would prove Argon2 ran).
2637    #[tokio::test]
2638    async fn pre_auth_gate_blocks_before_argon2_verification() {
2639        let (_token, hash) = generate_api_key().unwrap();
2640        let keys = vec![ApiKeyEntry {
2641            name: "test-key".into(),
2642            hash,
2643            role: "ops".into(),
2644            expires_at: None,
2645        }];
2646        let config = RateLimitConfig {
2647            max_attempts_per_minute: 100,
2648            pre_auth_max_per_minute: Some(1),
2649            max_tracked_keys: default_max_tracked_keys(),
2650            idle_eviction: default_idle_eviction(),
2651            burst: None,
2652            pre_auth_burst: None,
2653            key_eviction_policy: KeyEvictionPolicy::default(),
2654        };
2655        let state = Arc::new(AuthState {
2656            api_keys: ArcSwap::new(Arc::new(keys)),
2657            rate_limiter: None,
2658            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2659            #[cfg(feature = "oauth")]
2660            jwks_cache: None,
2661            seen_identities: SeenIdentitySet::new(),
2662            counters: AuthCounters::default(),
2663            resource_metadata_url: None,
2664        });
2665        let app = auth_router(Arc::clone(&state));
2666        let peer: SocketAddr = "10.0.0.10:54321".parse().unwrap();
2667
2668        // First bad-bearer request: gate has quota, bearer verification runs,
2669        // returns 401 (invalid credential).
2670        let mut req1 = Request::builder()
2671            .method(axum::http::Method::POST)
2672            .uri("/mcp")
2673            .header("authorization", "Bearer obviously-not-a-real-token")
2674            .body(Body::empty())
2675            .unwrap();
2676        req1.extensions_mut().insert(ConnectInfo(peer));
2677        let resp1 = app.clone().oneshot(req1).await.unwrap();
2678        assert_eq!(
2679            resp1.status(),
2680            StatusCode::UNAUTHORIZED,
2681            "first attempt: gate has quota, falls through to bearer auth which fails with 401"
2682        );
2683
2684        // Second bad-bearer request from same IP: gate quota exhausted, must
2685        // reject with 429 BEFORE the Argon2 verification path runs.
2686        let mut req2 = Request::builder()
2687            .method(axum::http::Method::POST)
2688            .uri("/mcp")
2689            .header("authorization", "Bearer also-not-a-real-token")
2690            .body(Body::empty())
2691            .unwrap();
2692        req2.extensions_mut().insert(ConnectInfo(peer));
2693        let resp2 = app.oneshot(req2).await.unwrap();
2694        assert_eq!(
2695            resp2.status(),
2696            StatusCode::TOO_MANY_REQUESTS,
2697            "second attempt from same IP: pre-auth gate must reject with 429"
2698        );
2699
2700        let counters = state.counters_snapshot();
2701        assert_eq!(
2702            counters.failure_pre_auth_gate, 1,
2703            "exactly one request must have been rejected by the pre-auth gate"
2704        );
2705        // Critical: Argon2 verification must NOT have run on the gated request.
2706        // The first request's 401 increments `failure_invalid_credential` to 1;
2707        // the second (gated) request must NOT increment it further.
2708        assert_eq!(
2709            counters.failure_invalid_credential, 1,
2710            "bearer verification must run exactly once (only the un-gated first request)"
2711        );
2712    }
2713
2714    /// mTLS-authenticated requests must bypass the pre-auth gate entirely.
2715    /// The TLS handshake already performed expensive crypto with a verified
2716    /// peer, so mTLS callers should never be throttled by this gate.
2717    ///
2718    /// Setup: a pre-auth gate with quota 1 (very tight). Submit two mTLS
2719    /// requests in quick succession from the same IP. Both must succeed.
2720    #[tokio::test]
2721    async fn pre_auth_gate_does_not_throttle_mtls() {
2722        let config = RateLimitConfig {
2723            max_attempts_per_minute: 100,
2724            pre_auth_max_per_minute: Some(1), // tight: would block 2nd plain request
2725            max_tracked_keys: default_max_tracked_keys(),
2726            idle_eviction: default_idle_eviction(),
2727            burst: None,
2728            pre_auth_burst: None,
2729            key_eviction_policy: KeyEvictionPolicy::default(),
2730        };
2731        let state = Arc::new(AuthState {
2732            api_keys: ArcSwap::new(Arc::new(vec![])),
2733            rate_limiter: None,
2734            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2735            #[cfg(feature = "oauth")]
2736            jwks_cache: None,
2737            seen_identities: SeenIdentitySet::new(),
2738            counters: AuthCounters::default(),
2739            resource_metadata_url: None,
2740        });
2741        let app = auth_router(Arc::clone(&state));
2742        let peer: SocketAddr = "10.0.0.20:54321".parse().unwrap();
2743        let identity = AuthIdentity {
2744            name: "cn=test-client".into(),
2745            role: "viewer".into(),
2746            method: AuthMethod::MtlsCertificate,
2747            raw_token: None,
2748            sub: None,
2749        };
2750        let tls_info = TlsConnInfo::new(peer, Some(identity));
2751
2752        for i in 0..3 {
2753            let mut req = Request::builder()
2754                .method(axum::http::Method::POST)
2755                .uri("/mcp")
2756                .body(Body::empty())
2757                .unwrap();
2758            req.extensions_mut().insert(ConnectInfo(tls_info.clone()));
2759            let resp = app.clone().oneshot(req).await.unwrap();
2760            assert_eq!(
2761                resp.status(),
2762                StatusCode::OK,
2763                "mTLS request {i} must succeed: pre-auth gate must not apply to mTLS callers"
2764            );
2765        }
2766
2767        let counters = state.counters_snapshot();
2768        assert_eq!(
2769            counters.failure_pre_auth_gate, 0,
2770            "pre-auth gate counter must remain at zero: mTLS bypasses the gate"
2771        );
2772        assert_eq!(
2773            counters.success_mtls, 3,
2774            "all three mTLS requests must have been counted as successful"
2775        );
2776    }
2777
2778    /// Pre-auth-gate denial must increment the `auth_pre` deny counter
2779    /// via the metrics handle in the request extensions.
2780    #[cfg(feature = "metrics")]
2781    #[tokio::test]
2782    async fn pre_auth_gate_deny_increments_counter() {
2783        let config = RateLimitConfig {
2784            max_attempts_per_minute: 100,
2785            pre_auth_max_per_minute: Some(1),
2786            max_tracked_keys: default_max_tracked_keys(),
2787            idle_eviction: default_idle_eviction(),
2788            burst: None,
2789            pre_auth_burst: None,
2790            key_eviction_policy: KeyEvictionPolicy::default(),
2791        };
2792        let state = Arc::new(AuthState {
2793            api_keys: ArcSwap::new(Arc::new(vec![])),
2794            rate_limiter: None,
2795            pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2796            #[cfg(feature = "oauth")]
2797            jwks_cache: None,
2798            seen_identities: SeenIdentitySet::new(),
2799            counters: AuthCounters::default(),
2800            resource_metadata_url: None,
2801        });
2802        let app = auth_router(Arc::clone(&state));
2803        let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2804        let peer: SocketAddr = "10.0.0.30:54321".parse().expect("addr parses");
2805        let mk = || {
2806            let mut req = Request::builder()
2807                .method(axum::http::Method::POST)
2808                .uri("/mcp")
2809                .header("authorization", "Bearer not-a-real-token")
2810                .body(Body::empty())
2811                .expect("request builds");
2812            req.extensions_mut().insert(ConnectInfo(peer));
2813            req.extensions_mut().insert(Arc::clone(&metrics));
2814            req
2815        };
2816        let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2817
2818        let first = app.clone().oneshot(mk()).await.expect("first request");
2819        assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2820        assert_eq!(counter("auth_pre"), 0, "un-gated request must not count");
2821
2822        let gated = app.oneshot(mk()).await.expect("second request");
2823        assert_eq!(gated.status(), StatusCode::TOO_MANY_REQUESTS);
2824        assert_eq!(counter("auth_pre"), 1, "gated request must count once");
2825        assert_eq!(counter("auth_post"), 0, "post limiter never fired");
2826    }
2827
2828    /// Post-failure limiter denial must increment the `auth_post` deny
2829    /// counter via the metrics handle in the request extensions.
2830    #[cfg(feature = "metrics")]
2831    #[tokio::test]
2832    async fn post_failure_limiter_deny_increments_counter() {
2833        let config = RateLimitConfig {
2834            max_attempts_per_minute: 1, // tight: 2nd failure trips the limiter
2835            pre_auth_max_per_minute: None,
2836            max_tracked_keys: default_max_tracked_keys(),
2837            idle_eviction: default_idle_eviction(),
2838            burst: None,
2839            pre_auth_burst: None,
2840            key_eviction_policy: KeyEvictionPolicy::default(),
2841        };
2842        let state = Arc::new(AuthState {
2843            api_keys: ArcSwap::new(Arc::new(vec![])),
2844            rate_limiter: Some(build_rate_limiter(&config)),
2845            pre_auth_limiter: None,
2846            #[cfg(feature = "oauth")]
2847            jwks_cache: None,
2848            seen_identities: SeenIdentitySet::new(),
2849            counters: AuthCounters::default(),
2850            resource_metadata_url: None,
2851        });
2852        let app = auth_router(Arc::clone(&state));
2853        let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2854        let peer: SocketAddr = "10.0.0.31:54321".parse().expect("addr parses");
2855        let mk = || {
2856            let mut req = Request::builder()
2857                .method(axum::http::Method::POST)
2858                .uri("/mcp")
2859                .header("authorization", "Bearer not-a-real-token")
2860                .body(Body::empty())
2861                .expect("request builds");
2862            req.extensions_mut().insert(ConnectInfo(peer));
2863            req.extensions_mut().insert(Arc::clone(&metrics));
2864            req
2865        };
2866        let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2867
2868        // First failure consumes the budget but is NOT itself limited.
2869        let first = app.clone().oneshot(mk()).await.expect("first request");
2870        assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2871        assert_eq!(counter("auth_post"), 0);
2872
2873        // Second failure trips the post-failure limiter.
2874        let limited = app.oneshot(mk()).await.expect("second request");
2875        assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
2876        assert_eq!(counter("auth_post"), 1, "deny must count once");
2877        assert_eq!(counter("auth_pre"), 0, "pre-auth gate disabled here");
2878    }
2879
2880    // -------------------------------------------------------------------
2881    // RFC 7235 §2.1 case-insensitive scheme parsing for `extract_bearer`.
2882    // -------------------------------------------------------------------
2883
2884    #[test]
2885    fn extract_bearer_accepts_canonical_case() {
2886        assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2887    }
2888
2889    #[test]
2890    fn extract_bearer_is_case_insensitive_per_rfc7235() {
2891        // RFC 7235 §2.1: "auth-scheme is case-insensitive".
2892        // Real-world clients (curl, browsers, custom HTTP libs) emit varied
2893        // casings; rejecting any of them is a spec violation.
2894        for header in &[
2895            "bearer abc123",
2896            "BEARER abc123",
2897            "BeArEr abc123",
2898            "bEaReR abc123",
2899        ] {
2900            assert_eq!(
2901                extract_bearer(header),
2902                Some("abc123"),
2903                "header {header:?} must parse as a Bearer token (RFC 7235 §2.1)"
2904            );
2905        }
2906    }
2907
2908    #[test]
2909    fn extract_bearer_rejects_other_schemes() {
2910        assert_eq!(extract_bearer("Basic dXNlcjpwYXNz"), None);
2911        assert_eq!(extract_bearer("Digest username=\"x\""), None);
2912        assert_eq!(extract_bearer("Token abc123"), None);
2913    }
2914
2915    #[test]
2916    fn extract_bearer_rejects_malformed() {
2917        // Empty string, no separator, scheme-only, scheme + only whitespace.
2918        assert_eq!(extract_bearer(""), None);
2919        assert_eq!(extract_bearer("Bearer"), None);
2920        assert_eq!(extract_bearer("Bearer "), None);
2921        assert_eq!(extract_bearer("Bearer    "), None);
2922    }
2923
2924    #[test]
2925    fn extract_bearer_tolerates_extra_separator_whitespace() {
2926        // Some non-conformant clients emit two spaces; we should still parse.
2927        assert_eq!(extract_bearer("Bearer  abc123"), Some("abc123"));
2928        assert_eq!(extract_bearer("Bearer   abc123"), Some("abc123"));
2929    }
2930
2931    #[test]
2932    fn extract_bearer_rejects_embedded_whitespace() {
2933        assert_eq!(extract_bearer("Bearer abc 123"), None);
2934        assert_eq!(extract_bearer("Bearer abc\t123"), None);
2935        assert_eq!(extract_bearer("Bearer abc123 "), None);
2936        assert_eq!(extract_bearer("Bearer abc123\r\n"), None);
2937    }
2938
2939    #[test]
2940    fn extract_bearer_still_accepts_opaque_non_token68_credentials() {
2941        // Compatibility guard. `ApiKeyEntry::new` accepts an arbitrary
2942        // caller-supplied hash, so consumers may have hashed opaque tokens
2943        // using punctuation outside RFC 7235 `token68`. Narrowing this to a
2944        // strict token68 charset would silently 401 them on upgrade.
2945        assert_eq!(
2946            extract_bearer("Bearer aBc!@#$%^&*()"),
2947            Some("aBc!@#$%^&*()")
2948        );
2949        assert_eq!(extract_bearer("Bearer tok{en}|v1"), Some("tok{en}|v1"));
2950    }
2951
2952    #[test]
2953    fn extract_bearer_accepts_generated_key_and_jwt_shapes() {
2954        let (token, _hash) = generate_api_key().unwrap();
2955        let header = format!("Bearer {token}");
2956        assert_eq!(extract_bearer(&header), Some(token.as_str()));
2957
2958        let jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln-_bmF0dXJl";
2959        let jwt_header = format!("Bearer {jwt}");
2960        assert_eq!(extract_bearer(&jwt_header), Some(jwt));
2961    }
2962
2963    // -------------------------------------------------------------------
2964    // Debug redaction: ensure `AuthIdentity` and `ApiKeyEntry` never leak
2965    // secret material via `format!("{:?}", …)` or `tracing::debug!(?…)`.
2966    // -------------------------------------------------------------------
2967
2968    #[test]
2969    fn auth_identity_debug_redacts_raw_token() {
2970        let id = AuthIdentity {
2971            name: "alice".into(),
2972            role: "admin".into(),
2973            method: AuthMethod::OAuthJwt,
2974            raw_token: Some(SecretString::from("super-secret-jwt-payload-xyz")),
2975            sub: Some("keycloak-uuid-2f3c8b".into()),
2976        };
2977        let dbg = format!("{id:?}");
2978
2979        // Plaintext fields must be visible (they are not secrets).
2980        assert!(dbg.contains("alice"), "name should be visible: {dbg}");
2981        assert!(dbg.contains("admin"), "role should be visible: {dbg}");
2982        assert!(dbg.contains("OAuthJwt"), "method should be visible: {dbg}");
2983
2984        // Secret fields must NOT leak.
2985        assert!(
2986            !dbg.contains("super-secret-jwt-payload-xyz"),
2987            "raw_token must be redacted in Debug output: {dbg}"
2988        );
2989        assert!(
2990            !dbg.contains("keycloak-uuid-2f3c8b"),
2991            "sub must be redacted in Debug output: {dbg}"
2992        );
2993        assert!(
2994            dbg.contains("<redacted>"),
2995            "redaction marker missing: {dbg}"
2996        );
2997    }
2998
2999    #[test]
3000    fn auth_identity_debug_marks_absent_secrets() {
3001        // For non-OAuth identities (mTLS / API key) the secret fields are
3002        // None; redacted Debug output should distinguish that from "present".
3003        let id = AuthIdentity {
3004            name: "viewer-key".into(),
3005            role: "viewer".into(),
3006            method: AuthMethod::BearerToken,
3007            raw_token: None,
3008            sub: None,
3009        };
3010        let dbg = format!("{id:?}");
3011        assert!(
3012            dbg.contains("<none>"),
3013            "absent secrets should be marked: {dbg}"
3014        );
3015        assert!(
3016            !dbg.contains("<redacted>"),
3017            "no <redacted> marker when secrets are absent: {dbg}"
3018        );
3019    }
3020
3021    #[test]
3022    fn api_key_entry_debug_redacts_hash() {
3023        let entry = ApiKeyEntry {
3024            name: "viewer-key".into(),
3025            // Realistic Argon2id PHC string (must NOT leak).
3026            hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$h4sh3dPa55w0rd".into(),
3027            role: "viewer".into(),
3028            expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
3029        };
3030        let dbg = format!("{entry:?}");
3031
3032        // Non-secret fields visible.
3033        assert!(dbg.contains("viewer-key"));
3034        assert!(dbg.contains("viewer"));
3035        assert!(dbg.contains("2030-01-01T00:00:00+00:00"));
3036
3037        // Hash material must NOT leak.
3038        assert!(
3039            !dbg.contains("$argon2id$"),
3040            "argon2 hash leaked into Debug output: {dbg}"
3041        );
3042        assert!(
3043            !dbg.contains("h4sh3dPa55w0rd"),
3044            "hash digest leaked into Debug output: {dbg}"
3045        );
3046        assert!(
3047            dbg.contains("<redacted>"),
3048            "redaction marker missing: {dbg}"
3049        );
3050    }
3051
3052    // -- AuthFailureClass exact-string contract tests --
3053    //
3054    // These tests pin the exact wire strings emitted for each failure
3055    // class. They exist to kill mutation-test mutants that replace the
3056    // match-arm string literals (e.g. with `""` or with the value from
3057    // another arm). Operators and dashboards rely on these literals
3058    // for metric labels and audit-log filters; any change is a
3059    // breaking observability change and must be reflected in
3060    // CHANGELOG.md.
3061
3062    #[test]
3063    fn auth_failure_class_as_str_exact_strings() {
3064        assert_eq!(
3065            AuthFailureClass::MissingCredential.as_str(),
3066            "missing_credential"
3067        );
3068        assert_eq!(
3069            AuthFailureClass::InvalidCredential.as_str(),
3070            "invalid_credential"
3071        );
3072        assert_eq!(
3073            AuthFailureClass::ExpiredCredential.as_str(),
3074            "expired_credential"
3075        );
3076        assert_eq!(AuthFailureClass::RateLimited.as_str(), "rate_limited");
3077        assert_eq!(AuthFailureClass::PreAuthGate.as_str(), "pre_auth_gate");
3078    }
3079
3080    #[test]
3081    fn auth_failure_class_response_body_exact_strings() {
3082        assert_eq!(
3083            AuthFailureClass::MissingCredential.response_body(),
3084            "unauthorized: missing credential"
3085        );
3086        assert_eq!(
3087            AuthFailureClass::InvalidCredential.response_body(),
3088            "unauthorized: invalid credential"
3089        );
3090        assert_eq!(
3091            AuthFailureClass::ExpiredCredential.response_body(),
3092            "unauthorized: expired credential"
3093        );
3094        assert_eq!(
3095            AuthFailureClass::RateLimited.response_body(),
3096            "rate limited"
3097        );
3098        assert_eq!(
3099            AuthFailureClass::PreAuthGate.response_body(),
3100            "rate limited (pre-auth)"
3101        );
3102    }
3103
3104    #[test]
3105    fn auth_failure_class_bearer_error_exact_strings() {
3106        assert_eq!(
3107            AuthFailureClass::MissingCredential.bearer_error(),
3108            (
3109                "invalid_request",
3110                "missing bearer token or mTLS client certificate"
3111            )
3112        );
3113        assert_eq!(
3114            AuthFailureClass::InvalidCredential.bearer_error(),
3115            ("invalid_token", "token is invalid")
3116        );
3117        assert_eq!(
3118            AuthFailureClass::ExpiredCredential.bearer_error(),
3119            ("invalid_token", "token is expired")
3120        );
3121        assert_eq!(
3122            AuthFailureClass::RateLimited.bearer_error(),
3123            ("invalid_request", "too many failed authentication attempts")
3124        );
3125        assert_eq!(
3126            AuthFailureClass::PreAuthGate.bearer_error(),
3127            (
3128                "invalid_request",
3129                "too many unauthenticated requests from this source"
3130            )
3131        );
3132    }
3133
3134    // -- AuthConfig::summary boolean-flag contract tests --
3135    //
3136    // These tests pin the boolean flags emitted by `AuthConfig::summary`
3137    // so that mutations like deleting `!` (which would invert the
3138    // semantics of `bearer`) or replacing `is_some()` with `is_none()`
3139    // are caught immediately. The summary is consumed by `/admin/*`
3140    // diagnostics so any inversion is an operator-visible regression.
3141
3142    #[test]
3143    fn auth_config_summary_bearer_true_when_keys_present() {
3144        let (_token, hash) = generate_api_key().unwrap();
3145        let cfg = AuthConfig::with_keys(vec![ApiKeyEntry::new("k", hash, "viewer")]);
3146        let s = cfg.summary();
3147        assert!(s.enabled, "summary.enabled must reflect AuthConfig.enabled");
3148        assert!(
3149            s.bearer,
3150            "summary.bearer must be true when api_keys is non-empty (kills `!` deletion at L615)"
3151        );
3152        assert!(!s.mtls, "summary.mtls must be false when mtls is None");
3153        assert!(!s.oauth, "summary.oauth must be false when oauth is None");
3154        assert_eq!(s.api_keys.len(), 1);
3155        assert_eq!(s.api_keys[0].name, "k");
3156        assert_eq!(s.api_keys[0].role, "viewer");
3157    }
3158
3159    #[test]
3160    fn auth_config_summary_bearer_false_when_no_keys() {
3161        let cfg = AuthConfig::with_keys(vec![]);
3162        let s = cfg.summary();
3163        assert!(
3164            !s.bearer,
3165            "summary.bearer must be false when api_keys is empty (kills `!` deletion at L615)"
3166        );
3167        assert!(s.api_keys.is_empty());
3168    }
3169
3170    #[test]
3171    fn seen_identity_set_first_then_repeat() {
3172        let set = SeenIdentitySet::new();
3173        assert!(set.insert_is_first("alice"), "first sighting is first");
3174        assert!(
3175            !set.insert_is_first("alice"),
3176            "second sighting is not first"
3177        );
3178        assert!(set.insert_is_first("bob"));
3179        assert_eq!(set.len(), 2);
3180    }
3181
3182    #[test]
3183    fn seen_identity_set_evicts_oldest_at_cap() {
3184        let set = SeenIdentitySet::with_cap(2);
3185        assert!(set.insert_is_first("a"));
3186        assert!(set.insert_is_first("b"));
3187        // Cap reached; inserting "c" evicts "a".
3188        assert!(set.insert_is_first("c"));
3189        assert_eq!(set.len(), 2);
3190        // "a" was evicted, so it re-fires as "first" (matches the documented
3191        // bounded trade-off: re-INFO once on reappearance). Inserting "a"
3192        // here evicts "b" (next oldest), leaving {c, a}.
3193        assert!(set.insert_is_first("a"));
3194        assert_eq!(set.len(), 2);
3195        // "b" has now been evicted in turn, so it re-fires as "first" too.
3196        assert!(set.insert_is_first("b"));
3197        // Sanity: cap is never exceeded regardless of churn pattern.
3198        for i in 0..32 {
3199            set.insert_is_first(&format!("churn-{i}"));
3200            assert!(set.len() <= 2, "cap invariant must hold");
3201        }
3202    }
3203
3204    #[test]
3205    fn seen_identity_set_cap_zero_is_raised_to_one() {
3206        let set = SeenIdentitySet::with_cap(0);
3207        assert!(set.insert_is_first("only"));
3208        assert_eq!(set.len(), 1);
3209        // Next insert evicts "only".
3210        assert!(set.insert_is_first("next"));
3211        assert_eq!(set.len(), 1);
3212    }
3213
3214    #[test]
3215    fn seen_identity_set_fifo_does_not_refresh_on_repeat_hit() {
3216        // Locks in the FIFO contract: repeat hits MUST NOT bump an entry
3217        // to the back of the eviction queue (that would be LRU).
3218        let set = SeenIdentitySet::with_cap(2);
3219        assert!(set.insert_is_first("a")); // order=[a]
3220        assert!(set.insert_is_first("b")); // order=[a,b]
3221        // Repeat hit on "a" - if this were LRU, "a" would move to the back
3222        // and "b" would be the next eviction victim. Under FIFO, "a" stays
3223        // at the front (oldest by insertion).
3224        assert!(!set.insert_is_first("a"));
3225        // Insert "c" forces eviction. Under FIFO, "a" (oldest by insertion)
3226        // is evicted; "b" survives. Under LRU, "b" would have been evicted.
3227        assert!(set.insert_is_first("c"));
3228        // Prove "a" was evicted: re-inserting fires as first again.
3229        assert!(set.insert_is_first("a"));
3230        // Prove "b" was NOT evicted: re-inserting does NOT fire as first.
3231        // (If LRU semantics had snuck in, this assertion would fail.)
3232        // After the previous step, "a" eviction pushed out "b" as the new
3233        // oldest, so we must re-add "b" via a fresh insert path. To keep
3234        // the test deterministic we rebuild a small scenario:
3235        let set = SeenIdentitySet::with_cap(2);
3236        assert!(set.insert_is_first("x")); // order=[x]
3237        assert!(set.insert_is_first("y")); // order=[x,y]
3238        assert!(!set.insert_is_first("x")); // repeat hit (under FIFO: order unchanged)
3239        assert!(set.insert_is_first("z")); // evicts "x" under FIFO
3240        assert!(
3241            !set.insert_is_first("y"),
3242            "y must still be present (FIFO did not evict it)"
3243        );
3244        assert!(
3245            set.insert_is_first("x"),
3246            "x must have been evicted by FIFO (would NOT have been evicted under LRU)"
3247        );
3248    }
3249}