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]
1182pub fn verify_bearer_token(token: &str, keys: &[ApiKeyEntry]) -> Option<AuthIdentity> {
1183 use subtle::ConstantTimeEq as _;
1184
1185 let now = chrono::Utc::now();
1186 #[allow(
1187 clippy::expect_used,
1188 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."
1189 )]
1190 let dummy_hash = PasswordHash::new(&DUMMY_PHC_HASH)
1191 .expect("DUMMY_PHC_HASH is a valid Argon2id PHC string by construction");
1192
1193 let mut matched_index: usize = usize::MAX;
1194 let mut any_match: u8 = 0;
1195
1196 for (idx, key) in keys.iter().enumerate() {
1197 let expired = key.expires_at.is_some_and(|exp| exp.as_datetime() < &now);
1198
1199 let real_hash = PasswordHash::new(&key.hash);
1200 let verify_against = match (&real_hash, expired, any_match) {
1201 (Ok(h), false, 0) => h,
1202 _ => &dummy_hash,
1203 };
1204
1205 let slot_ok = u8::from(
1206 Argon2::default()
1207 .verify_password(token.as_bytes(), verify_against)
1208 .is_ok(),
1209 );
1210
1211 let real_match = slot_ok & u8::from(!expired) & u8::from(real_hash.is_ok());
1212 let first_real_match = real_match & (1 - any_match);
1213 if first_real_match.ct_eq(&1).into() {
1214 matched_index = idx;
1215 }
1216 any_match |= real_match;
1217 }
1218
1219 if any_match == 0 {
1220 return None;
1221 }
1222 let key = keys.get(matched_index)?;
1223 Some(AuthIdentity {
1224 name: key.name.clone(),
1225 role: key.role.clone(),
1226 method: AuthMethod::BearerToken,
1227 raw_token: None,
1228 sub: None,
1229 })
1230}
1231
1232static DUMMY_PHC_HASH: LazyLock<String> = LazyLock::new(|| {
1245 #[allow(
1247 clippy::expect_used,
1248 reason = "fixed 22-char base64 ('AAAA...') decodes to a valid 16-byte salt; SaltString::from_b64 is infallible on this literal"
1249 )]
1250 let salt = SaltString::from_b64("AAAAAAAAAAAAAAAAAAAAAA")
1251 .expect("fixed 16-byte base64 salt is well-formed");
1252 #[allow(
1253 clippy::expect_used,
1254 reason = "Argon2::default() with a fixed plaintext and a well-formed salt is infallible; only fails on bad params/salt"
1255 )]
1256 Argon2::default()
1257 .hash_password(b"rmcp-server-kit-dummy", &salt)
1258 .expect("Argon2 default params hash a fixed plaintext")
1259 .to_string()
1260});
1261
1262pub fn generate_api_key() -> Result<(String, String), McpxError> {
1272 let mut token_bytes = [0u8; 32];
1273 rand::fill(&mut token_bytes);
1274 let token = URL_SAFE_NO_PAD.encode(token_bytes);
1275
1276 let mut salt_bytes = [0u8; 16];
1278 rand::fill(&mut salt_bytes);
1279 let salt = SaltString::encode_b64(&salt_bytes)
1280 .map_err(|e| McpxError::Auth(format!("salt encoding failed: {e}")))?;
1281 let hash = Argon2::default()
1282 .hash_password(token.as_bytes(), &salt)
1283 .map_err(|e| McpxError::Auth(format!("argon2id hashing failed: {e}")))?
1284 .to_string();
1285
1286 Ok((token, hash))
1287}
1288
1289fn build_www_authenticate_value(
1290 advertise_resource_metadata: bool,
1291 failure: AuthFailureClass,
1292) -> String {
1293 let (error, error_description) = failure.bearer_error();
1294 if advertise_resource_metadata {
1295 return format!(
1296 "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\", error=\"{error}\", error_description=\"{error_description}\""
1297 );
1298 }
1299 format!("Bearer error=\"{error}\", error_description=\"{error_description}\"")
1300}
1301
1302fn auth_method_label(method: AuthMethod) -> &'static str {
1303 match method {
1304 AuthMethod::MtlsCertificate => "mTLS",
1305 AuthMethod::BearerToken => "bearer token",
1306 AuthMethod::OAuthJwt => "OAuth JWT",
1307 }
1308}
1309
1310#[cfg_attr(not(feature = "oauth"), allow(unused_variables))]
1311fn unauthorized_response(state: &AuthState, failure_class: AuthFailureClass) -> Response {
1312 #[cfg(feature = "oauth")]
1313 let advertise_resource_metadata = state.jwks_cache.is_some();
1314 #[cfg(not(feature = "oauth"))]
1315 let advertise_resource_metadata = false;
1316
1317 let challenge = build_www_authenticate_value(advertise_resource_metadata, failure_class);
1318 (
1319 axum::http::StatusCode::UNAUTHORIZED,
1320 [(header::WWW_AUTHENTICATE, challenge)],
1321 failure_class.response_body(),
1322 )
1323 .into_response()
1324}
1325
1326async fn authenticate_bearer_identity(
1332 state: &AuthState,
1333 token: &str,
1334) -> Result<AuthIdentity, AuthFailureClass> {
1335 let mut failure_class = AuthFailureClass::MissingCredential;
1336
1337 #[cfg(feature = "oauth")]
1338 if let Some(ref cache) = state.jwks_cache
1339 && crate::oauth::looks_like_jwt(token)
1340 {
1341 match cache.validate_token_with_reason(token).await {
1342 Ok(mut id) => {
1343 id.raw_token = Some(SecretString::from(token.to_owned()));
1344 return Ok(id);
1345 }
1346 Err(crate::oauth::JwtValidationFailure::Expired) => {
1347 failure_class = AuthFailureClass::ExpiredCredential;
1348 }
1349 Err(crate::oauth::JwtValidationFailure::Invalid) => {
1350 failure_class = AuthFailureClass::InvalidCredential;
1351 }
1352 }
1353 }
1354
1355 let token = token.to_owned();
1356 let keys = state.api_keys.load_full(); let identity = tokio::task::spawn_blocking(move || verify_bearer_token(&token, &keys))
1360 .await
1361 .ok()
1362 .flatten();
1363
1364 if let Some(id) = identity {
1365 return Ok(id);
1366 }
1367
1368 if failure_class == AuthFailureClass::MissingCredential {
1369 failure_class = AuthFailureClass::InvalidCredential;
1370 }
1371
1372 Err(failure_class)
1373}
1374
1375fn pre_auth_gate(state: &AuthState, client_ip: Option<IpAddr>) -> Option<Response> {
1386 let limiter = state.pre_auth_limiter.as_ref()?;
1387 let ip = client_ip?;
1388 let Err(wait) = limiter.check_key_wait(&ip) else {
1389 return None;
1390 };
1391 state.counters.record_failure(AuthFailureClass::PreAuthGate);
1392 tracing::warn!(
1393 %ip,
1394 "auth rate limited by pre-auth gate (request rejected before credential verification)"
1395 );
1396 Some(
1397 McpxError::RateLimitedFor {
1398 message: "too many unauthenticated requests from this source".into(),
1399 retry_after: wait,
1400 }
1401 .into_response(),
1402 )
1403}
1404
1405pub(crate) async fn auth_middleware(
1414 state: Arc<AuthState>,
1415 req: Request<Body>,
1416 next: Next,
1417) -> Response {
1418 let tls_info = req.extensions().get::<ConnectInfo<TlsConnInfo>>().cloned();
1424 let client_ip = crate::transport::limiter_client_ip(req.extensions());
1425
1426 if let Some(id) = tls_info.and_then(|ci| ci.0.identity) {
1433 state.log_auth(&id, "mTLS");
1434 let mut req = req;
1435 req.extensions_mut().insert(id);
1436 return next.run(req).await;
1437 }
1438
1439 if let Some(blocked) = pre_auth_gate(&state, client_ip) {
1443 #[cfg(feature = "metrics")]
1444 crate::metrics::record_rate_limit_deny(req.extensions(), "auth_pre");
1445 return blocked;
1446 }
1447
1448 let failure_class = if let Some(value) = req.headers().get(header::AUTHORIZATION) {
1449 match value.to_str().ok().and_then(extract_bearer) {
1450 Some(token) => match authenticate_bearer_identity(&state, token).await {
1451 Ok(id) => {
1452 state.log_auth(&id, auth_method_label(id.method));
1453 let mut req = req;
1454 req.extensions_mut().insert(id);
1455 return next.run(req).await;
1456 }
1457 Err(class) => class,
1458 },
1459 None => AuthFailureClass::InvalidCredential,
1460 }
1461 } else {
1462 AuthFailureClass::MissingCredential
1463 };
1464
1465 tracing::warn!(failure_class = %failure_class.as_str(), "auth failed");
1466
1467 if let (Some(limiter), Some(ip)) = (&state.rate_limiter, client_ip)
1470 && let Err(wait) = limiter.check_key_wait(&ip)
1471 {
1472 state.counters.record_failure(AuthFailureClass::RateLimited);
1473 #[cfg(feature = "metrics")]
1474 crate::metrics::record_rate_limit_deny(req.extensions(), "auth_post");
1475 tracing::warn!(%ip, "auth rate limited after repeated failures");
1476 return McpxError::RateLimitedFor {
1477 message: "too many failed authentication attempts".into(),
1478 retry_after: wait,
1479 }
1480 .into_response();
1481 }
1482
1483 state.counters.record_failure(failure_class);
1484 unauthorized_response(&state, failure_class)
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489 use super::*;
1490
1491 #[test]
1492 fn generate_and_verify_api_key() {
1493 let (token, hash) = generate_api_key().unwrap();
1494
1495 assert_eq!(token.len(), 43);
1497
1498 assert!(hash.starts_with("$argon2id$"));
1500
1501 let keys = vec![ApiKeyEntry {
1503 name: "test".into(),
1504 hash,
1505 role: "viewer".into(),
1506 expires_at: None,
1507 }];
1508 let id = verify_bearer_token(&token, &keys);
1509 assert!(id.is_some());
1510 let id = id.unwrap();
1511 assert_eq!(id.name, "test");
1512 assert_eq!(id.role, "viewer");
1513 assert_eq!(id.method, AuthMethod::BearerToken);
1514 }
1515
1516 #[test]
1517 fn wrong_token_rejected() {
1518 let (_token, hash) = generate_api_key().unwrap();
1519 let keys = vec![ApiKeyEntry {
1520 name: "test".into(),
1521 hash,
1522 role: "viewer".into(),
1523 expires_at: None,
1524 }];
1525 assert!(verify_bearer_token("wrong-token", &keys).is_none());
1526 }
1527
1528 #[test]
1529 fn expired_key_rejected() {
1530 let (token, hash) = generate_api_key().unwrap();
1531 let keys = vec![ApiKeyEntry {
1532 name: "test".into(),
1533 hash,
1534 role: "viewer".into(),
1535 expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1536 }];
1537 assert!(verify_bearer_token(&token, &keys).is_none());
1538 }
1539
1540 #[test]
1541 fn match_in_last_slot_still_authenticates() {
1542 let (token, hash) = generate_api_key().unwrap();
1543 let (_other_token, other_hash) = generate_api_key().unwrap();
1544 let keys = vec![
1545 ApiKeyEntry {
1546 name: "first".into(),
1547 hash: other_hash.clone(),
1548 role: "viewer".into(),
1549 expires_at: None,
1550 },
1551 ApiKeyEntry {
1552 name: "second".into(),
1553 hash: other_hash,
1554 role: "viewer".into(),
1555 expires_at: None,
1556 },
1557 ApiKeyEntry {
1558 name: "match".into(),
1559 hash,
1560 role: "ops".into(),
1561 expires_at: None,
1562 },
1563 ];
1564 let id = verify_bearer_token(&token, &keys).expect("last-slot match must authenticate");
1565 assert_eq!(id.name, "match");
1566 assert_eq!(id.role, "ops");
1567 }
1568
1569 #[test]
1570 fn expired_slot_before_valid_match_does_not_short_circuit() {
1571 let (token, hash) = generate_api_key().unwrap();
1572 let (_, other_hash) = generate_api_key().unwrap();
1573 let keys = vec![
1574 ApiKeyEntry {
1575 name: "expired".into(),
1576 hash: other_hash,
1577 role: "viewer".into(),
1578 expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1579 },
1580 ApiKeyEntry {
1581 name: "valid".into(),
1582 hash,
1583 role: "ops".into(),
1584 expires_at: None,
1585 },
1586 ];
1587 let id = verify_bearer_token(&token, &keys)
1588 .expect("valid slot following an expired slot must authenticate");
1589 assert_eq!(id.name, "valid");
1590 }
1591
1592 #[test]
1593 fn malformed_hash_slot_does_not_short_circuit() {
1594 let (token, hash) = generate_api_key().unwrap();
1595 let keys = vec![
1596 ApiKeyEntry {
1597 name: "broken".into(),
1598 hash: "this-is-not-a-phc-string".into(),
1599 role: "viewer".into(),
1600 expires_at: None,
1601 },
1602 ApiKeyEntry {
1603 name: "valid".into(),
1604 hash,
1605 role: "ops".into(),
1606 expires_at: None,
1607 },
1608 ];
1609 let id = verify_bearer_token(&token, &keys)
1610 .expect("valid slot following a malformed-hash slot must authenticate");
1611 assert_eq!(id.name, "valid");
1612 }
1613
1614 #[test]
1625 fn rfc_timestamp_parse_rejects_malformed() {
1626 for bad in [
1627 "not-a-date",
1628 "",
1629 "2025-13-01T00:00:00Z", "2025-01-32T00:00:00Z", "2025-01-01T00:00:00", "01/01/2025", "2025-01-01T25:00:00Z", ] {
1635 assert!(
1636 RfcTimestamp::parse(bad).is_err(),
1637 "RfcTimestamp::parse must reject {bad:?}"
1638 );
1639 }
1640 }
1641
1642 #[test]
1643 fn rfc_timestamp_parse_accepts_valid() {
1644 for good in [
1645 "2025-01-01T00:00:00Z",
1646 "2025-01-01T00:00:00+00:00",
1647 "2025-12-31T23:59:59-08:00",
1648 "2099-01-01T00:00:00.123456789Z",
1649 ] {
1650 assert!(
1651 RfcTimestamp::parse(good).is_ok(),
1652 "RfcTimestamp::parse must accept {good:?}"
1653 );
1654 }
1655 }
1656
1657 #[test]
1658 fn api_key_entry_deserialize_rejects_malformed_expires_at() {
1659 let toml = r#"
1664 name = "bad-key"
1665 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1666 role = "viewer"
1667 expires_at = "not-a-date"
1668 "#;
1669 let result: Result<ApiKeyEntry, _> = toml::from_str(toml);
1670 assert!(
1671 result.is_err(),
1672 "deserialization must reject malformed expires_at"
1673 );
1674 }
1675
1676 #[test]
1677 fn api_key_entry_deserialize_accepts_valid_expires_at() {
1678 let toml = r#"
1679 name = "good-key"
1680 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1681 role = "viewer"
1682 expires_at = "2099-01-01T00:00:00Z"
1683 "#;
1684 let entry: ApiKeyEntry = toml::from_str(toml).expect("valid RFC 3339 must deserialize");
1685 assert!(entry.expires_at.is_some());
1686 }
1687
1688 #[test]
1689 fn api_key_entry_deserialize_accepts_missing_expires_at() {
1690 let toml = r#"
1693 name = "eternal-key"
1694 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1695 role = "viewer"
1696 "#;
1697 let entry: ApiKeyEntry = toml::from_str(toml).expect("missing expires_at must deserialize");
1698 assert!(entry.expires_at.is_none());
1699 }
1700
1701 #[test]
1702 fn try_with_expiry_rejects_malformed() {
1703 let entry = ApiKeyEntry::new("k", "hash", "viewer");
1704 assert!(entry.try_with_expiry("not-a-date").is_err());
1705 }
1706
1707 #[test]
1708 fn try_with_expiry_accepts_valid() {
1709 let entry = ApiKeyEntry::new("k", "hash", "viewer")
1710 .try_with_expiry("2099-01-01T00:00:00Z")
1711 .expect("valid RFC 3339 must be accepted");
1712 assert!(entry.expires_at.is_some());
1713 }
1714
1715 #[test]
1716 fn api_key_summary_serializes_expires_at_as_rfc3339() {
1717 let summary = ApiKeySummary {
1722 name: "k".into(),
1723 role: "viewer".into(),
1724 expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
1725 };
1726 let json = serde_json::to_string(&summary).unwrap();
1727 assert!(
1728 json.contains(r#""expires_at":"2030-01-01T00:00:00+00:00""#),
1729 "wire format regressed: {json}"
1730 );
1731 }
1732
1733 #[test]
1734 fn future_expiry_accepted() {
1735 let (token, hash) = generate_api_key().unwrap();
1736 let keys = vec![ApiKeyEntry {
1737 name: "test".into(),
1738 hash,
1739 role: "viewer".into(),
1740 expires_at: Some(RfcTimestamp::parse("2099-01-01T00:00:00Z").unwrap()),
1741 }];
1742 assert!(verify_bearer_token(&token, &keys).is_some());
1743 }
1744
1745 #[test]
1746 fn multiple_keys_first_match_wins() {
1747 let (token, hash) = generate_api_key().unwrap();
1748 let keys = vec![
1749 ApiKeyEntry {
1750 name: "wrong".into(),
1751 hash: "$argon2id$v=19$m=19456,t=2,p=1$invalid$invalid".into(),
1752 role: "ops".into(),
1753 expires_at: None,
1754 },
1755 ApiKeyEntry {
1756 name: "correct".into(),
1757 hash,
1758 role: "deploy".into(),
1759 expires_at: None,
1760 },
1761 ];
1762 let id = verify_bearer_token(&token, &keys).unwrap();
1763 assert_eq!(id.name, "correct");
1764 assert_eq!(id.role, "deploy");
1765 }
1766
1767 #[test]
1768 fn rate_limiter_allows_within_quota() {
1769 let config = RateLimitConfig {
1770 max_attempts_per_minute: 5,
1771 pre_auth_max_per_minute: None,
1772 max_tracked_keys: default_max_tracked_keys(),
1773 idle_eviction: default_idle_eviction(),
1774 burst: None,
1775 pre_auth_burst: None,
1776 };
1777 let limiter = build_rate_limiter(&config);
1778 let ip: IpAddr = "10.0.0.1".parse().unwrap();
1779
1780 for _ in 0..5 {
1782 assert!(limiter.check_key(&ip).is_ok());
1783 }
1784 assert!(limiter.check_key(&ip).is_err());
1786 }
1787
1788 #[test]
1789 fn rate_limiter_separate_ips() {
1790 let config = RateLimitConfig {
1791 max_attempts_per_minute: 2,
1792 pre_auth_max_per_minute: None,
1793 max_tracked_keys: default_max_tracked_keys(),
1794 idle_eviction: default_idle_eviction(),
1795 burst: None,
1796 pre_auth_burst: None,
1797 };
1798 let limiter = build_rate_limiter(&config);
1799 let ip1: IpAddr = "10.0.0.1".parse().unwrap();
1800 let ip2: IpAddr = "10.0.0.2".parse().unwrap();
1801
1802 assert!(limiter.check_key(&ip1).is_ok());
1804 assert!(limiter.check_key(&ip1).is_ok());
1805 assert!(limiter.check_key(&ip1).is_err());
1806
1807 assert!(limiter.check_key(&ip2).is_ok());
1809 }
1810
1811 #[test]
1812 fn extract_mtls_identity_from_cn() {
1813 let mut params = rcgen::CertificateParams::new(vec!["test-client.local".into()]).unwrap();
1815 params.distinguished_name = rcgen::DistinguishedName::new();
1816 params
1817 .distinguished_name
1818 .push(rcgen::DnType::CommonName, "test-client");
1819 let cert = params
1820 .self_signed(&rcgen::KeyPair::generate().unwrap())
1821 .unwrap();
1822 let der = cert.der();
1823
1824 let id = extract_mtls_identity(der, "ops").unwrap();
1825 assert_eq!(id.name, "test-client");
1826 assert_eq!(id.role, "ops");
1827 assert_eq!(id.method, AuthMethod::MtlsCertificate);
1828 }
1829
1830 #[test]
1831 fn extract_mtls_identity_falls_back_to_san() {
1832 let mut params =
1834 rcgen::CertificateParams::new(vec!["san-only.example.com".into()]).unwrap();
1835 params.distinguished_name = rcgen::DistinguishedName::new();
1836 let cert = params
1838 .self_signed(&rcgen::KeyPair::generate().unwrap())
1839 .unwrap();
1840 let der = cert.der();
1841
1842 let id = extract_mtls_identity(der, "viewer").unwrap();
1843 assert_eq!(id.name, "san-only.example.com");
1844 assert_eq!(id.role, "viewer");
1845 }
1846
1847 #[test]
1848 fn extract_mtls_identity_invalid_der() {
1849 assert!(extract_mtls_identity(b"not-a-cert", "viewer").is_none());
1850 }
1851
1852 use axum::{
1855 body::Body,
1856 http::{Request, StatusCode},
1857 };
1858 use tower::ServiceExt as _;
1859
1860 fn auth_router(state: Arc<AuthState>) -> axum::Router {
1861 axum::Router::new()
1862 .route("/mcp", axum::routing::post(|| async { "ok" }))
1863 .layer(axum::middleware::from_fn(move |req, next| {
1864 let s = Arc::clone(&state);
1865 auth_middleware(s, req, next)
1866 }))
1867 }
1868
1869 fn test_auth_state(keys: Vec<ApiKeyEntry>) -> Arc<AuthState> {
1870 Arc::new(AuthState {
1871 api_keys: ArcSwap::new(Arc::new(keys)),
1872 rate_limiter: None,
1873 pre_auth_limiter: None,
1874 #[cfg(feature = "oauth")]
1875 jwks_cache: None,
1876 seen_identities: SeenIdentitySet::new(),
1877 counters: AuthCounters::default(),
1878 })
1879 }
1880
1881 #[tokio::test]
1882 async fn middleware_rejects_no_credentials() {
1883 let state = test_auth_state(vec![]);
1884 let app = auth_router(Arc::clone(&state));
1885 let req = Request::builder()
1886 .method(axum::http::Method::POST)
1887 .uri("/mcp")
1888 .body(Body::empty())
1889 .unwrap();
1890 let resp = app.oneshot(req).await.unwrap();
1891 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1892 let challenge = resp
1893 .headers()
1894 .get(header::WWW_AUTHENTICATE)
1895 .unwrap()
1896 .to_str()
1897 .unwrap();
1898 assert!(challenge.contains("error=\"invalid_request\""));
1899
1900 let counters = state.counters_snapshot();
1901 assert_eq!(counters.failure_missing_credential, 1);
1902 }
1903
1904 #[tokio::test]
1905 async fn middleware_accepts_valid_bearer() {
1906 let (token, hash) = generate_api_key().unwrap();
1907 let keys = vec![ApiKeyEntry {
1908 name: "test-key".into(),
1909 hash,
1910 role: "ops".into(),
1911 expires_at: None,
1912 }];
1913 let state = test_auth_state(keys);
1914 let app = auth_router(Arc::clone(&state));
1915 let req = Request::builder()
1916 .method(axum::http::Method::POST)
1917 .uri("/mcp")
1918 .header("authorization", format!("Bearer {token}"))
1919 .body(Body::empty())
1920 .unwrap();
1921 let resp = app.oneshot(req).await.unwrap();
1922 assert_eq!(resp.status(), StatusCode::OK);
1923
1924 let counters = state.counters_snapshot();
1925 assert_eq!(counters.success_bearer, 1);
1926 }
1927
1928 #[tokio::test]
1929 async fn middleware_rejects_wrong_bearer() {
1930 let (_token, hash) = generate_api_key().unwrap();
1931 let keys = vec![ApiKeyEntry {
1932 name: "test-key".into(),
1933 hash,
1934 role: "ops".into(),
1935 expires_at: None,
1936 }];
1937 let state = test_auth_state(keys);
1938 let app = auth_router(Arc::clone(&state));
1939 let req = Request::builder()
1940 .method(axum::http::Method::POST)
1941 .uri("/mcp")
1942 .header("authorization", "Bearer wrong-token-here")
1943 .body(Body::empty())
1944 .unwrap();
1945 let resp = app.oneshot(req).await.unwrap();
1946 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1947 let challenge = resp
1948 .headers()
1949 .get(header::WWW_AUTHENTICATE)
1950 .unwrap()
1951 .to_str()
1952 .unwrap();
1953 assert!(challenge.contains("error=\"invalid_token\""));
1954
1955 let counters = state.counters_snapshot();
1956 assert_eq!(counters.failure_invalid_credential, 1);
1957 }
1958
1959 #[tokio::test]
1960 async fn middleware_rate_limits() {
1961 let state = Arc::new(AuthState {
1962 api_keys: ArcSwap::new(Arc::new(vec![])),
1963 rate_limiter: Some(build_rate_limiter(&RateLimitConfig {
1964 max_attempts_per_minute: 1,
1965 pre_auth_max_per_minute: None,
1966 max_tracked_keys: default_max_tracked_keys(),
1967 idle_eviction: default_idle_eviction(),
1968 burst: None,
1969 pre_auth_burst: None,
1970 })),
1971 pre_auth_limiter: None,
1972 #[cfg(feature = "oauth")]
1973 jwks_cache: None,
1974 seen_identities: SeenIdentitySet::new(),
1975 counters: AuthCounters::default(),
1976 });
1977 let app = auth_router(state);
1978
1979 let req = Request::builder()
1981 .method(axum::http::Method::POST)
1982 .uri("/mcp")
1983 .body(Body::empty())
1984 .unwrap();
1985 let resp = app.clone().oneshot(req).await.unwrap();
1986 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1987
1988 }
1993
1994 #[test]
2000 fn rate_limit_semantics_failed_only() {
2001 let config = RateLimitConfig {
2002 max_attempts_per_minute: 3,
2003 pre_auth_max_per_minute: None,
2004 max_tracked_keys: default_max_tracked_keys(),
2005 idle_eviction: default_idle_eviction(),
2006 burst: None,
2007 pre_auth_burst: None,
2008 };
2009 let limiter = build_rate_limiter(&config);
2010 let ip: IpAddr = "192.168.1.100".parse().unwrap();
2011
2012 assert!(
2014 limiter.check_key(&ip).is_ok(),
2015 "failure 1 should be allowed"
2016 );
2017 assert!(
2018 limiter.check_key(&ip).is_ok(),
2019 "failure 2 should be allowed"
2020 );
2021 assert!(
2022 limiter.check_key(&ip).is_ok(),
2023 "failure 3 should be allowed"
2024 );
2025 assert!(
2026 limiter.check_key(&ip).is_err(),
2027 "failure 4 should be blocked"
2028 );
2029
2030 }
2039
2040 #[test]
2045 fn pre_auth_default_multiplier_is_10x() {
2046 let config = RateLimitConfig {
2047 max_attempts_per_minute: 5,
2048 pre_auth_max_per_minute: None,
2049 max_tracked_keys: default_max_tracked_keys(),
2050 idle_eviction: default_idle_eviction(),
2051 burst: None,
2052 pre_auth_burst: None,
2053 };
2054 let limiter = build_pre_auth_limiter(&config);
2055 let ip: IpAddr = "10.0.0.1".parse().unwrap();
2056
2057 for i in 0..50 {
2059 assert!(
2060 limiter.check_key(&ip).is_ok(),
2061 "pre-auth attempt {i} (of expected 50) should be allowed under default 10x multiplier"
2062 );
2063 }
2064 assert!(
2066 limiter.check_key(&ip).is_err(),
2067 "pre-auth attempt 51 should be blocked (quota is 50, not unbounded)"
2068 );
2069 }
2070
2071 #[test]
2074 fn pre_auth_explicit_override_wins() {
2075 let config = RateLimitConfig {
2076 max_attempts_per_minute: 100, pre_auth_max_per_minute: Some(2), max_tracked_keys: default_max_tracked_keys(),
2079 idle_eviction: default_idle_eviction(),
2080 burst: None,
2081 pre_auth_burst: None,
2082 };
2083 let limiter = build_pre_auth_limiter(&config);
2084 let ip: IpAddr = "10.0.0.2".parse().unwrap();
2085
2086 assert!(limiter.check_key(&ip).is_ok(), "attempt 1 allowed");
2087 assert!(limiter.check_key(&ip).is_ok(), "attempt 2 allowed");
2088 assert!(
2089 limiter.check_key(&ip).is_err(),
2090 "attempt 3 must be blocked (explicit override of 2 wins over 10x default of 1000)"
2091 );
2092 }
2093
2094 #[test]
2096 fn pre_auth_gate_deny_sets_retry_after() {
2097 let config = RateLimitConfig::new(100).with_pre_auth_max_per_minute(1);
2098 let state = AuthState {
2099 api_keys: ArcSwap::new(Arc::new(vec![])),
2100 rate_limiter: None,
2101 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2102 #[cfg(feature = "oauth")]
2103 jwks_cache: None,
2104 seen_identities: SeenIdentitySet::new(),
2105 counters: AuthCounters::default(),
2106 };
2107 let ip: IpAddr = "10.7.7.7".parse().unwrap();
2108 assert!(
2109 pre_auth_gate(&state, Some(ip)).is_none(),
2110 "first request within quota"
2111 );
2112 let resp = pre_auth_gate(&state, Some(ip)).expect("second request must be gated");
2113 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
2114 let retry_after = resp
2115 .headers()
2116 .get(header::RETRY_AFTER)
2117 .expect("Retry-After present")
2118 .to_str()
2119 .unwrap()
2120 .parse::<u64>()
2121 .unwrap();
2122 assert!(retry_after >= 1, "delta-seconds must be >= 1");
2123 }
2124
2125 #[test]
2127 fn post_failure_limiter_burst_allows_initial_spike() {
2128 let config = RateLimitConfig::new(1).with_burst(3);
2129 let limiter = build_rate_limiter(&config);
2130 let ip: IpAddr = "10.6.6.6".parse().unwrap();
2131 for i in 0..3 {
2132 assert!(limiter.check_key(&ip).is_ok(), "burst attempt {i}");
2133 }
2134 assert!(
2135 limiter.check_key(&ip).is_err(),
2136 "attempt 4 must exceed the burst bucket"
2137 );
2138 }
2139
2140 #[tokio::test]
2146 async fn pre_auth_gate_blocks_before_argon2_verification() {
2147 let (_token, hash) = generate_api_key().unwrap();
2148 let keys = vec![ApiKeyEntry {
2149 name: "test-key".into(),
2150 hash,
2151 role: "ops".into(),
2152 expires_at: None,
2153 }];
2154 let config = RateLimitConfig {
2155 max_attempts_per_minute: 100,
2156 pre_auth_max_per_minute: Some(1),
2157 max_tracked_keys: default_max_tracked_keys(),
2158 idle_eviction: default_idle_eviction(),
2159 burst: None,
2160 pre_auth_burst: None,
2161 };
2162 let state = Arc::new(AuthState {
2163 api_keys: ArcSwap::new(Arc::new(keys)),
2164 rate_limiter: None,
2165 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2166 #[cfg(feature = "oauth")]
2167 jwks_cache: None,
2168 seen_identities: SeenIdentitySet::new(),
2169 counters: AuthCounters::default(),
2170 });
2171 let app = auth_router(Arc::clone(&state));
2172 let peer: SocketAddr = "10.0.0.10:54321".parse().unwrap();
2173
2174 let mut req1 = Request::builder()
2177 .method(axum::http::Method::POST)
2178 .uri("/mcp")
2179 .header("authorization", "Bearer obviously-not-a-real-token")
2180 .body(Body::empty())
2181 .unwrap();
2182 req1.extensions_mut().insert(ConnectInfo(peer));
2183 let resp1 = app.clone().oneshot(req1).await.unwrap();
2184 assert_eq!(
2185 resp1.status(),
2186 StatusCode::UNAUTHORIZED,
2187 "first attempt: gate has quota, falls through to bearer auth which fails with 401"
2188 );
2189
2190 let mut req2 = Request::builder()
2193 .method(axum::http::Method::POST)
2194 .uri("/mcp")
2195 .header("authorization", "Bearer also-not-a-real-token")
2196 .body(Body::empty())
2197 .unwrap();
2198 req2.extensions_mut().insert(ConnectInfo(peer));
2199 let resp2 = app.oneshot(req2).await.unwrap();
2200 assert_eq!(
2201 resp2.status(),
2202 StatusCode::TOO_MANY_REQUESTS,
2203 "second attempt from same IP: pre-auth gate must reject with 429"
2204 );
2205
2206 let counters = state.counters_snapshot();
2207 assert_eq!(
2208 counters.failure_pre_auth_gate, 1,
2209 "exactly one request must have been rejected by the pre-auth gate"
2210 );
2211 assert_eq!(
2215 counters.failure_invalid_credential, 1,
2216 "bearer verification must run exactly once (only the un-gated first request)"
2217 );
2218 }
2219
2220 #[tokio::test]
2227 async fn pre_auth_gate_does_not_throttle_mtls() {
2228 let config = RateLimitConfig {
2229 max_attempts_per_minute: 100,
2230 pre_auth_max_per_minute: Some(1), max_tracked_keys: default_max_tracked_keys(),
2232 idle_eviction: default_idle_eviction(),
2233 burst: None,
2234 pre_auth_burst: None,
2235 };
2236 let state = Arc::new(AuthState {
2237 api_keys: ArcSwap::new(Arc::new(vec![])),
2238 rate_limiter: None,
2239 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2240 #[cfg(feature = "oauth")]
2241 jwks_cache: None,
2242 seen_identities: SeenIdentitySet::new(),
2243 counters: AuthCounters::default(),
2244 });
2245 let app = auth_router(Arc::clone(&state));
2246 let peer: SocketAddr = "10.0.0.20:54321".parse().unwrap();
2247 let identity = AuthIdentity {
2248 name: "cn=test-client".into(),
2249 role: "viewer".into(),
2250 method: AuthMethod::MtlsCertificate,
2251 raw_token: None,
2252 sub: None,
2253 };
2254 let tls_info = TlsConnInfo::new(peer, Some(identity));
2255
2256 for i in 0..3 {
2257 let mut req = Request::builder()
2258 .method(axum::http::Method::POST)
2259 .uri("/mcp")
2260 .body(Body::empty())
2261 .unwrap();
2262 req.extensions_mut().insert(ConnectInfo(tls_info.clone()));
2263 let resp = app.clone().oneshot(req).await.unwrap();
2264 assert_eq!(
2265 resp.status(),
2266 StatusCode::OK,
2267 "mTLS request {i} must succeed: pre-auth gate must not apply to mTLS callers"
2268 );
2269 }
2270
2271 let counters = state.counters_snapshot();
2272 assert_eq!(
2273 counters.failure_pre_auth_gate, 0,
2274 "pre-auth gate counter must remain at zero: mTLS bypasses the gate"
2275 );
2276 assert_eq!(
2277 counters.success_mtls, 3,
2278 "all three mTLS requests must have been counted as successful"
2279 );
2280 }
2281
2282 #[cfg(feature = "metrics")]
2285 #[tokio::test]
2286 async fn pre_auth_gate_deny_increments_counter() {
2287 let config = RateLimitConfig {
2288 max_attempts_per_minute: 100,
2289 pre_auth_max_per_minute: Some(1),
2290 max_tracked_keys: default_max_tracked_keys(),
2291 idle_eviction: default_idle_eviction(),
2292 burst: None,
2293 pre_auth_burst: None,
2294 };
2295 let state = Arc::new(AuthState {
2296 api_keys: ArcSwap::new(Arc::new(vec![])),
2297 rate_limiter: None,
2298 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2299 #[cfg(feature = "oauth")]
2300 jwks_cache: None,
2301 seen_identities: SeenIdentitySet::new(),
2302 counters: AuthCounters::default(),
2303 });
2304 let app = auth_router(Arc::clone(&state));
2305 let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2306 let peer: SocketAddr = "10.0.0.30:54321".parse().expect("addr parses");
2307 let mk = || {
2308 let mut req = Request::builder()
2309 .method(axum::http::Method::POST)
2310 .uri("/mcp")
2311 .header("authorization", "Bearer not-a-real-token")
2312 .body(Body::empty())
2313 .expect("request builds");
2314 req.extensions_mut().insert(ConnectInfo(peer));
2315 req.extensions_mut().insert(Arc::clone(&metrics));
2316 req
2317 };
2318 let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2319
2320 let first = app.clone().oneshot(mk()).await.expect("first request");
2321 assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2322 assert_eq!(counter("auth_pre"), 0, "un-gated request must not count");
2323
2324 let gated = app.oneshot(mk()).await.expect("second request");
2325 assert_eq!(gated.status(), StatusCode::TOO_MANY_REQUESTS);
2326 assert_eq!(counter("auth_pre"), 1, "gated request must count once");
2327 assert_eq!(counter("auth_post"), 0, "post limiter never fired");
2328 }
2329
2330 #[cfg(feature = "metrics")]
2333 #[tokio::test]
2334 async fn post_failure_limiter_deny_increments_counter() {
2335 let config = RateLimitConfig {
2336 max_attempts_per_minute: 1, pre_auth_max_per_minute: None,
2338 max_tracked_keys: default_max_tracked_keys(),
2339 idle_eviction: default_idle_eviction(),
2340 burst: None,
2341 pre_auth_burst: None,
2342 };
2343 let state = Arc::new(AuthState {
2344 api_keys: ArcSwap::new(Arc::new(vec![])),
2345 rate_limiter: Some(build_rate_limiter(&config)),
2346 pre_auth_limiter: None,
2347 #[cfg(feature = "oauth")]
2348 jwks_cache: None,
2349 seen_identities: SeenIdentitySet::new(),
2350 counters: AuthCounters::default(),
2351 });
2352 let app = auth_router(Arc::clone(&state));
2353 let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2354 let peer: SocketAddr = "10.0.0.31:54321".parse().expect("addr parses");
2355 let mk = || {
2356 let mut req = Request::builder()
2357 .method(axum::http::Method::POST)
2358 .uri("/mcp")
2359 .header("authorization", "Bearer not-a-real-token")
2360 .body(Body::empty())
2361 .expect("request builds");
2362 req.extensions_mut().insert(ConnectInfo(peer));
2363 req.extensions_mut().insert(Arc::clone(&metrics));
2364 req
2365 };
2366 let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2367
2368 let first = app.clone().oneshot(mk()).await.expect("first request");
2370 assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2371 assert_eq!(counter("auth_post"), 0);
2372
2373 let limited = app.oneshot(mk()).await.expect("second request");
2375 assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
2376 assert_eq!(counter("auth_post"), 1, "deny must count once");
2377 assert_eq!(counter("auth_pre"), 0, "pre-auth gate disabled here");
2378 }
2379
2380 #[test]
2385 fn extract_bearer_accepts_canonical_case() {
2386 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2387 }
2388
2389 #[test]
2390 fn extract_bearer_is_case_insensitive_per_rfc7235() {
2391 for header in &[
2395 "bearer abc123",
2396 "BEARER abc123",
2397 "BeArEr abc123",
2398 "bEaReR abc123",
2399 ] {
2400 assert_eq!(
2401 extract_bearer(header),
2402 Some("abc123"),
2403 "header {header:?} must parse as a Bearer token (RFC 7235 §2.1)"
2404 );
2405 }
2406 }
2407
2408 #[test]
2409 fn extract_bearer_rejects_other_schemes() {
2410 assert_eq!(extract_bearer("Basic dXNlcjpwYXNz"), None);
2411 assert_eq!(extract_bearer("Digest username=\"x\""), None);
2412 assert_eq!(extract_bearer("Token abc123"), None);
2413 }
2414
2415 #[test]
2416 fn extract_bearer_rejects_malformed() {
2417 assert_eq!(extract_bearer(""), None);
2419 assert_eq!(extract_bearer("Bearer"), None);
2420 assert_eq!(extract_bearer("Bearer "), None);
2421 assert_eq!(extract_bearer("Bearer "), None);
2422 }
2423
2424 #[test]
2425 fn extract_bearer_tolerates_extra_separator_whitespace() {
2426 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2428 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2429 }
2430
2431 #[test]
2437 fn auth_identity_debug_redacts_raw_token() {
2438 let id = AuthIdentity {
2439 name: "alice".into(),
2440 role: "admin".into(),
2441 method: AuthMethod::OAuthJwt,
2442 raw_token: Some(SecretString::from("super-secret-jwt-payload-xyz")),
2443 sub: Some("keycloak-uuid-2f3c8b".into()),
2444 };
2445 let dbg = format!("{id:?}");
2446
2447 assert!(dbg.contains("alice"), "name should be visible: {dbg}");
2449 assert!(dbg.contains("admin"), "role should be visible: {dbg}");
2450 assert!(dbg.contains("OAuthJwt"), "method should be visible: {dbg}");
2451
2452 assert!(
2454 !dbg.contains("super-secret-jwt-payload-xyz"),
2455 "raw_token must be redacted in Debug output: {dbg}"
2456 );
2457 assert!(
2458 !dbg.contains("keycloak-uuid-2f3c8b"),
2459 "sub must be redacted in Debug output: {dbg}"
2460 );
2461 assert!(
2462 dbg.contains("<redacted>"),
2463 "redaction marker missing: {dbg}"
2464 );
2465 }
2466
2467 #[test]
2468 fn auth_identity_debug_marks_absent_secrets() {
2469 let id = AuthIdentity {
2472 name: "viewer-key".into(),
2473 role: "viewer".into(),
2474 method: AuthMethod::BearerToken,
2475 raw_token: None,
2476 sub: None,
2477 };
2478 let dbg = format!("{id:?}");
2479 assert!(
2480 dbg.contains("<none>"),
2481 "absent secrets should be marked: {dbg}"
2482 );
2483 assert!(
2484 !dbg.contains("<redacted>"),
2485 "no <redacted> marker when secrets are absent: {dbg}"
2486 );
2487 }
2488
2489 #[test]
2490 fn api_key_entry_debug_redacts_hash() {
2491 let entry = ApiKeyEntry {
2492 name: "viewer-key".into(),
2493 hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$h4sh3dPa55w0rd".into(),
2495 role: "viewer".into(),
2496 expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
2497 };
2498 let dbg = format!("{entry:?}");
2499
2500 assert!(dbg.contains("viewer-key"));
2502 assert!(dbg.contains("viewer"));
2503 assert!(dbg.contains("2030-01-01T00:00:00+00:00"));
2504
2505 assert!(
2507 !dbg.contains("$argon2id$"),
2508 "argon2 hash leaked into Debug output: {dbg}"
2509 );
2510 assert!(
2511 !dbg.contains("h4sh3dPa55w0rd"),
2512 "hash digest leaked into Debug output: {dbg}"
2513 );
2514 assert!(
2515 dbg.contains("<redacted>"),
2516 "redaction marker missing: {dbg}"
2517 );
2518 }
2519
2520 #[test]
2531 fn auth_failure_class_as_str_exact_strings() {
2532 assert_eq!(
2533 AuthFailureClass::MissingCredential.as_str(),
2534 "missing_credential"
2535 );
2536 assert_eq!(
2537 AuthFailureClass::InvalidCredential.as_str(),
2538 "invalid_credential"
2539 );
2540 assert_eq!(
2541 AuthFailureClass::ExpiredCredential.as_str(),
2542 "expired_credential"
2543 );
2544 assert_eq!(AuthFailureClass::RateLimited.as_str(), "rate_limited");
2545 assert_eq!(AuthFailureClass::PreAuthGate.as_str(), "pre_auth_gate");
2546 }
2547
2548 #[test]
2549 fn auth_failure_class_response_body_exact_strings() {
2550 assert_eq!(
2551 AuthFailureClass::MissingCredential.response_body(),
2552 "unauthorized: missing credential"
2553 );
2554 assert_eq!(
2555 AuthFailureClass::InvalidCredential.response_body(),
2556 "unauthorized: invalid credential"
2557 );
2558 assert_eq!(
2559 AuthFailureClass::ExpiredCredential.response_body(),
2560 "unauthorized: expired credential"
2561 );
2562 assert_eq!(
2563 AuthFailureClass::RateLimited.response_body(),
2564 "rate limited"
2565 );
2566 assert_eq!(
2567 AuthFailureClass::PreAuthGate.response_body(),
2568 "rate limited (pre-auth)"
2569 );
2570 }
2571
2572 #[test]
2573 fn auth_failure_class_bearer_error_exact_strings() {
2574 assert_eq!(
2575 AuthFailureClass::MissingCredential.bearer_error(),
2576 (
2577 "invalid_request",
2578 "missing bearer token or mTLS client certificate"
2579 )
2580 );
2581 assert_eq!(
2582 AuthFailureClass::InvalidCredential.bearer_error(),
2583 ("invalid_token", "token is invalid")
2584 );
2585 assert_eq!(
2586 AuthFailureClass::ExpiredCredential.bearer_error(),
2587 ("invalid_token", "token is expired")
2588 );
2589 assert_eq!(
2590 AuthFailureClass::RateLimited.bearer_error(),
2591 ("invalid_request", "too many failed authentication attempts")
2592 );
2593 assert_eq!(
2594 AuthFailureClass::PreAuthGate.bearer_error(),
2595 (
2596 "invalid_request",
2597 "too many unauthenticated requests from this source"
2598 )
2599 );
2600 }
2601
2602 #[test]
2611 fn auth_config_summary_bearer_true_when_keys_present() {
2612 let (_token, hash) = generate_api_key().unwrap();
2613 let cfg = AuthConfig::with_keys(vec![ApiKeyEntry::new("k", hash, "viewer")]);
2614 let s = cfg.summary();
2615 assert!(s.enabled, "summary.enabled must reflect AuthConfig.enabled");
2616 assert!(
2617 s.bearer,
2618 "summary.bearer must be true when api_keys is non-empty (kills `!` deletion at L615)"
2619 );
2620 assert!(!s.mtls, "summary.mtls must be false when mtls is None");
2621 assert!(!s.oauth, "summary.oauth must be false when oauth is None");
2622 assert_eq!(s.api_keys.len(), 1);
2623 assert_eq!(s.api_keys[0].name, "k");
2624 assert_eq!(s.api_keys[0].role, "viewer");
2625 }
2626
2627 #[test]
2628 fn auth_config_summary_bearer_false_when_no_keys() {
2629 let cfg = AuthConfig::with_keys(vec![]);
2630 let s = cfg.summary();
2631 assert!(
2632 !s.bearer,
2633 "summary.bearer must be false when api_keys is empty (kills `!` deletion at L615)"
2634 );
2635 assert!(s.api_keys.is_empty());
2636 }
2637
2638 #[test]
2639 fn seen_identity_set_first_then_repeat() {
2640 let set = SeenIdentitySet::new();
2641 assert!(set.insert_is_first("alice"), "first sighting is first");
2642 assert!(
2643 !set.insert_is_first("alice"),
2644 "second sighting is not first"
2645 );
2646 assert!(set.insert_is_first("bob"));
2647 assert_eq!(set.len(), 2);
2648 }
2649
2650 #[test]
2651 fn seen_identity_set_evicts_oldest_at_cap() {
2652 let set = SeenIdentitySet::with_cap(2);
2653 assert!(set.insert_is_first("a"));
2654 assert!(set.insert_is_first("b"));
2655 assert!(set.insert_is_first("c"));
2657 assert_eq!(set.len(), 2);
2658 assert!(set.insert_is_first("a"));
2662 assert_eq!(set.len(), 2);
2663 assert!(set.insert_is_first("b"));
2665 for i in 0..32 {
2667 set.insert_is_first(&format!("churn-{i}"));
2668 assert!(set.len() <= 2, "cap invariant must hold");
2669 }
2670 }
2671
2672 #[test]
2673 fn seen_identity_set_cap_zero_is_raised_to_one() {
2674 let set = SeenIdentitySet::with_cap(0);
2675 assert!(set.insert_is_first("only"));
2676 assert_eq!(set.len(), 1);
2677 assert!(set.insert_is_first("next"));
2679 assert_eq!(set.len(), 1);
2680 }
2681
2682 #[test]
2683 fn seen_identity_set_fifo_does_not_refresh_on_repeat_hit() {
2684 let set = SeenIdentitySet::with_cap(2);
2687 assert!(set.insert_is_first("a")); assert!(set.insert_is_first("b")); assert!(!set.insert_is_first("a"));
2693 assert!(set.insert_is_first("c"));
2696 assert!(set.insert_is_first("a"));
2698 let set = SeenIdentitySet::with_cap(2);
2704 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!(
2709 !set.insert_is_first("y"),
2710 "y must still be present (FIFO did not evict it)"
2711 );
2712 assert!(
2713 set.insert_is_first("x"),
2714 "x must have been evicted by FIFO (would NOT have been evicted under LRU)"
2715 );
2716 }
2717}