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