1use 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#[derive(Clone)]
46#[non_exhaustive]
47pub struct AuthIdentity {
48 pub name: String,
50 pub role: String,
52 pub method: AuthMethod,
54 pub raw_token: Option<SecretString>,
60 pub sub: Option<String>,
63}
64
65impl std::fmt::Debug for AuthIdentity {
66 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum AuthMethod {
97 BearerToken,
99 MtlsCertificate,
101 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 RateLimited,
113 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
158#[non_exhaustive]
159pub struct AuthCountersSnapshot {
160 pub success_mtls: u64,
162 pub success_bearer: u64,
164 pub success_oauth_jwt: u64,
166 pub failure_missing_credential: u64,
168 pub failure_invalid_credential: u64,
170 pub failure_expired_credential: u64,
172 pub failure_rate_limited: u64,
174 pub failure_pre_auth_gate: u64,
177}
178
179#[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#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
256#[non_exhaustive]
257pub struct RfcTimestamp(chrono::DateTime<chrono::FixedOffset>);
258
259impl RfcTimestamp {
260 pub fn parse(s: &str) -> Result<Self, chrono::ParseError> {
268 chrono::DateTime::parse_from_rfc3339(s).map(Self)
269 }
270
271 #[must_use]
273 pub fn as_datetime(&self) -> &chrono::DateTime<chrono::FixedOffset> {
274 &self.0
275 }
276
277 #[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 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 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 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#[derive(Clone, Deserialize)]
336#[non_exhaustive]
337pub struct ApiKeyEntry {
338 pub name: String,
340 pub hash: String,
342 pub role: String,
344 pub expires_at: Option<RfcTimestamp>,
349}
350
351impl std::fmt::Debug for ApiKeyEntry {
352 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 #[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 #[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 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#[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 pub ca_cert_path: PathBuf,
412 #[serde(default)]
415 pub required: bool,
416 #[serde(default = "default_mtls_role")]
419 pub default_role: String,
420 #[serde(default = "default_true")]
423 pub crl_enabled: bool,
424 #[serde(default, with = "humantime_serde::option")]
427 pub crl_refresh_interval: Option<Duration>,
428 #[serde(default = "default_crl_fetch_timeout", with = "humantime_serde")]
430 pub crl_fetch_timeout: Duration,
431 #[serde(
445 default = "default_crl_stale_grace",
446 alias = "crl_retry_retention",
447 with = "humantime_serde"
448 )]
449 pub crl_stale_grace: Duration,
450 #[serde(default)]
453 pub crl_deny_on_unavailable: bool,
454 #[serde(default)]
456 pub crl_end_entity_only: bool,
457 #[serde(default = "default_true")]
466 pub crl_allow_http: bool,
467 #[serde(default = "default_true")]
469 pub crl_enforce_expiration: bool,
470 #[serde(default = "default_crl_max_concurrent_fetches")]
476 pub crl_max_concurrent_fetches: usize,
477 #[serde(default = "default_crl_max_response_bytes")]
481 pub crl_max_response_bytes: u64,
482 #[serde(default = "default_crl_discovery_rate_per_min")]
498 pub crl_discovery_rate_per_min: u32,
499 #[serde(default = "default_crl_max_host_semaphores")]
508 pub crl_max_host_semaphores: usize,
509 #[serde(default = "default_crl_max_seen_urls")]
513 pub crl_max_seen_urls: usize,
514 #[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#[derive(Debug, Clone, Deserialize)]
576#[non_exhaustive]
577pub struct RateLimitConfig {
578 #[serde(default = "default_max_attempts")]
581 pub max_attempts_per_minute: u32,
582 #[serde(default)]
590 pub pre_auth_max_per_minute: Option<u32>,
591 #[serde(default = "default_max_tracked_keys")]
596 pub max_tracked_keys: usize,
597 #[serde(default = "default_idle_eviction", with = "humantime_serde")]
600 pub idle_eviction: Duration,
601 #[serde(default)]
608 pub burst: Option<u32>,
609 #[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 #[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 #[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 #[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 #[must_use]
660 pub fn with_idle_eviction(mut self, idle: Duration) -> Self {
661 self.idle_eviction = idle;
662 self
663 }
664
665 #[must_use]
668 pub fn with_burst(mut self, burst: u32) -> Self {
669 self.burst = Some(burst);
670 self
671 }
672
673 #[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#[derive(Debug, Clone, Default, Deserialize)]
696#[non_exhaustive]
697pub struct AuthConfig {
698 #[serde(default)]
700 pub enabled: bool,
701 #[serde(default)]
703 pub api_keys: Vec<ApiKeyEntry>,
704 pub mtls: Option<MtlsConfig>,
706 pub rate_limit: Option<RateLimitConfig>,
708 #[cfg(feature = "oauth")]
710 pub oauth: Option<crate::oauth::OAuthConfig>,
711}
712
713impl AuthConfig {
714 #[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 #[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#[derive(Debug, Clone, serde::Serialize)]
739#[non_exhaustive]
740pub struct ApiKeySummary {
741 pub name: String,
743 pub role: String,
745 pub expires_at: Option<RfcTimestamp>,
748}
749
750#[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 pub enabled: bool,
760 pub bearer: bool,
762 pub mtls: bool,
764 pub oauth: bool,
766 pub api_keys: Vec<ApiKeySummary>,
768}
769
770impl AuthConfig {
771 #[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
795pub(crate) type KeyedLimiter = BoundedKeyedLimiter<IpAddr>;
798
799#[derive(Clone, Debug)]
809#[non_exhaustive]
810pub(crate) struct TlsConnInfo {
811 pub addr: SocketAddr,
813 pub identity: Option<AuthIdentity>,
816}
817
818impl TlsConnInfo {
819 #[must_use]
821 pub(crate) const fn new(addr: SocketAddr, identity: Option<AuthIdentity>) -> Self {
822 Self { addr, identity }
823 }
824}
825
826const DEFAULT_SEEN_IDENTITY_CAP: usize = 4096;
834
835pub(crate) struct SeenIdentitySet {
855 inner: Mutex<SeenInner>,
856}
857
858struct SeenInner {
859 set: HashSet<String>,
860 order: std::collections::VecDeque<String>,
865 cap: usize,
866}
867
868impl SeenIdentitySet {
869 #[must_use]
871 pub(crate) fn new() -> Self {
872 Self::with_cap(DEFAULT_SEEN_IDENTITY_CAP)
873 }
874
875 #[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 pub(crate) fn insert_is_first(&self, name: &str) -> bool {
896 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 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 #[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#[allow(
944 missing_debug_implementations,
945 reason = "contains governor RateLimiter and JwksCache without Debug impls"
946)]
947#[non_exhaustive]
948pub(crate) struct AuthState {
949 pub api_keys: ArcSwap<Vec<ApiKeyEntry>>,
951 pub rate_limiter: Option<Arc<KeyedLimiter>>,
953 pub pre_auth_limiter: Option<Arc<KeyedLimiter>>,
956 #[cfg(feature = "oauth")]
957 pub jwks_cache: Option<Arc<crate::oauth::JwksCache>>,
959 pub seen_identities: SeenIdentitySet,
964 pub counters: AuthCounters,
966}
967
968impl AuthState {
969 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 #[must_use]
982 pub(crate) fn counters_snapshot(&self) -> AuthCountersSnapshot {
983 self.counters.snapshot()
984 }
985
986 #[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 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
1018const DEFAULT_AUTH_RATE: NonZeroU32 = NonZeroU32::new(30).unwrap();
1021
1022fn 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#[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#[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
1069const PRE_AUTH_DEFAULT_MULTIPLIER: u32 = 10;
1072
1073const DEFAULT_PRE_AUTH_RATE: NonZeroU32 = NonZeroU32::new(300).unwrap();
1077
1078#[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 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 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 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
1129fn 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#[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
1240static DUMMY_PHC_HASH: LazyLock<String> = LazyLock::new(|| {
1253 #[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
1270pub 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 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
1334async 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(); 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
1383fn 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
1413pub(crate) async fn auth_middleware(
1422 state: Arc<AuthState>,
1423 req: Request<Body>,
1424 next: Next,
1425) -> Response {
1426 let tls_info = req.extensions().get::<ConnectInfo<TlsConnInfo>>().cloned();
1432 let client_ip = crate::transport::limiter_client_ip(req.extensions());
1433
1434 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 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 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 assert_eq!(token.len(), 43);
1505
1506 assert!(hash.starts_with("$argon2id$"));
1508
1509 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 #[test]
1633 fn rfc_timestamp_parse_rejects_malformed() {
1634 for bad in [
1635 "not-a-date",
1636 "",
1637 "2025-13-01T00:00:00Z", "2025-01-32T00:00:00Z", "2025-01-01T00:00:00", "01/01/2025", "2025-01-01T25:00:00Z", ] {
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 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 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 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 for _ in 0..5 {
1790 assert!(limiter.check_key(&ip).is_ok());
1791 }
1792 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 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 assert!(limiter.check_key(&ip2).is_ok());
1817 }
1818
1819 #[test]
1820 fn extract_mtls_identity_from_cn() {
1821 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 let mut params =
1842 rcgen::CertificateParams::new(vec!["san-only.example.com".into()]).unwrap();
1843 params.distinguished_name = rcgen::DistinguishedName::new();
1844 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 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 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 }
2001
2002 #[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 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 }
2047
2048 #[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 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 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 #[test]
2082 fn pre_auth_explicit_override_wins() {
2083 let config = RateLimitConfig {
2084 max_attempts_per_minute: 100, pre_auth_max_per_minute: Some(2), 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 #[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 #[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 #[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 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 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 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 #[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), 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 #[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 #[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, 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 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 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 #[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 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 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 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2436 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2437 }
2438
2439 #[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 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 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 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 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 assert!(dbg.contains("viewer-key"));
2510 assert!(dbg.contains("viewer"));
2511 assert!(dbg.contains("2030-01-01T00:00:00+00:00"));
2512
2513 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 #[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 #[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 assert!(set.insert_is_first("c"));
2665 assert_eq!(set.len(), 2);
2666 assert!(set.insert_is_first("a"));
2670 assert_eq!(set.len(), 2);
2671 assert!(set.insert_is_first("b"));
2673 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 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 let set = SeenIdentitySet::with_cap(2);
2695 assert!(set.insert_is_first("a")); assert!(set.insert_is_first("b")); assert!(!set.insert_is_first("a"));
2701 assert!(set.insert_is_first("c"));
2704 assert!(set.insert_is_first("a"));
2706 let set = SeenIdentitySet::with_cap(2);
2712 assert!(set.insert_is_first("x")); assert!(set.insert_is_first("y")); assert!(!set.insert_is_first("x")); assert!(set.insert_is_first("z")); 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}