1use std::{
10 collections::HashSet,
11 net::SocketAddr,
12 num::{NonZeroU32, NonZeroUsize},
13 path::PathBuf,
14 sync::{
15 Arc, LazyLock, Mutex,
16 atomic::{AtomicU64, Ordering},
17 },
18 time::Duration,
19};
20
21use arc_swap::ArcSwap;
22use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
23use axum::{
24 body::Body,
25 extract::ConnectInfo,
26 http::{Request, StatusCode, header},
27 middleware::Next,
28 response::{IntoResponse, Response},
29};
30use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
31use secrecy::SecretString;
32use serde::Deserialize;
33use x509_parser::prelude::*;
34
35use crate::{
36 bounded_limiter::{BoundedKeyedLimiter, BoundedLimiterDeny, KeyEvictionPolicy},
37 error::RmcpServerKitError,
38 transport::RateLimitKey,
39};
40
41#[derive(Clone)]
50#[non_exhaustive]
51pub struct AuthIdentity {
52 pub name: String,
54 pub role: String,
56 pub method: AuthMethod,
58 pub raw_token: Option<SecretString>,
64 pub sub: Option<String>,
67}
68
69impl std::fmt::Debug for AuthIdentity {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("AuthIdentity")
74 .field("name", &self.name)
75 .field("role", &self.role)
76 .field("method", &self.method)
77 .field(
78 "raw_token",
79 &if self.raw_token.is_some() {
80 "<redacted>"
81 } else {
82 "<none>"
83 },
84 )
85 .field(
86 "sub",
87 &if self.sub.is_some() {
88 "<redacted>"
89 } else {
90 "<none>"
91 },
92 )
93 .finish()
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum AuthMethod {
101 BearerToken,
103 MtlsCertificate,
105 OAuthJwt,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110enum AuthFailureClass {
111 MissingCredential,
112 InvalidCredential,
113 #[cfg_attr(
114 not(feature = "oauth"),
115 allow(
116 dead_code,
117 reason = "only OAuth JWT validation can report an expired credential; \
118 the variant is unconstructed in builds without that feature"
119 )
120 )]
121 ExpiredCredential,
122 RateLimited,
124 PreAuthGate,
127}
128
129impl AuthFailureClass {
130 fn as_str(self) -> &'static str {
131 match self {
132 Self::MissingCredential => "missing_credential",
133 Self::InvalidCredential => "invalid_credential",
134 Self::ExpiredCredential => "expired_credential",
135 Self::RateLimited => "rate_limited",
136 Self::PreAuthGate => "pre_auth_gate",
137 }
138 }
139
140 fn bearer_error(self) -> (&'static str, &'static str) {
141 match self {
142 Self::MissingCredential => (
143 "invalid_request",
144 "missing bearer token or mTLS client certificate",
145 ),
146 Self::InvalidCredential => ("invalid_token", "token is invalid"),
147 Self::ExpiredCredential => ("invalid_token", "token is expired"),
148 Self::RateLimited => ("invalid_request", "too many failed authentication attempts"),
149 Self::PreAuthGate => (
150 "invalid_request",
151 "too many unauthenticated requests from this source",
152 ),
153 }
154 }
155
156 fn response_body(self) -> &'static str {
157 match self {
158 Self::MissingCredential => "unauthorized: missing credential",
159 Self::InvalidCredential => "unauthorized: invalid credential",
160 Self::ExpiredCredential => "unauthorized: expired credential",
161 Self::RateLimited => "rate limited",
162 Self::PreAuthGate => "rate limited (pre-auth)",
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
169#[non_exhaustive]
170pub struct AuthCountersSnapshot {
171 pub success_mtls: u64,
173 pub success_bearer: u64,
175 pub success_oauth_jwt: u64,
177 pub failure_missing_credential: u64,
179 pub failure_invalid_credential: u64,
181 pub failure_expired_credential: u64,
183 pub failure_rate_limited: u64,
185 pub failure_pre_auth_gate: u64,
188}
189
190#[derive(Debug, Default)]
192pub(crate) struct AuthCounters {
193 success_mtls: AtomicU64,
194 success_bearer: AtomicU64,
195 success_oauth_jwt: AtomicU64,
196 failure_missing_credential: AtomicU64,
197 failure_invalid_credential: AtomicU64,
198 failure_expired_credential: AtomicU64,
199 failure_rate_limited: AtomicU64,
200 failure_pre_auth_gate: AtomicU64,
201}
202
203impl AuthCounters {
204 fn record_success(&self, method: AuthMethod) {
205 match method {
206 AuthMethod::MtlsCertificate => {
207 self.success_mtls.fetch_add(1, Ordering::Relaxed);
208 }
209 AuthMethod::BearerToken => {
210 self.success_bearer.fetch_add(1, Ordering::Relaxed);
211 }
212 AuthMethod::OAuthJwt => {
213 self.success_oauth_jwt.fetch_add(1, Ordering::Relaxed);
214 }
215 }
216 }
217
218 fn record_failure(&self, class: AuthFailureClass) {
219 match class {
220 AuthFailureClass::MissingCredential => {
221 self.failure_missing_credential
222 .fetch_add(1, Ordering::Relaxed);
223 }
224 AuthFailureClass::InvalidCredential => {
225 self.failure_invalid_credential
226 .fetch_add(1, Ordering::Relaxed);
227 }
228 AuthFailureClass::ExpiredCredential => {
229 self.failure_expired_credential
230 .fetch_add(1, Ordering::Relaxed);
231 }
232 AuthFailureClass::RateLimited => {
233 self.failure_rate_limited.fetch_add(1, Ordering::Relaxed);
234 }
235 AuthFailureClass::PreAuthGate => {
236 self.failure_pre_auth_gate.fetch_add(1, Ordering::Relaxed);
237 }
238 }
239 }
240
241 fn snapshot(&self) -> AuthCountersSnapshot {
242 AuthCountersSnapshot {
243 success_mtls: self.success_mtls.load(Ordering::Relaxed),
244 success_bearer: self.success_bearer.load(Ordering::Relaxed),
245 success_oauth_jwt: self.success_oauth_jwt.load(Ordering::Relaxed),
246 failure_missing_credential: self.failure_missing_credential.load(Ordering::Relaxed),
247 failure_invalid_credential: self.failure_invalid_credential.load(Ordering::Relaxed),
248 failure_expired_credential: self.failure_expired_credential.load(Ordering::Relaxed),
249 failure_rate_limited: self.failure_rate_limited.load(Ordering::Relaxed),
250 failure_pre_auth_gate: self.failure_pre_auth_gate.load(Ordering::Relaxed),
251 }
252 }
253}
254
255#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
267#[non_exhaustive]
268pub struct RfcTimestamp(chrono::DateTime<chrono::FixedOffset>);
269
270impl RfcTimestamp {
271 pub fn parse(s: &str) -> Result<Self, chrono::ParseError> {
279 chrono::DateTime::parse_from_rfc3339(s).map(Self)
280 }
281
282 #[must_use]
284 pub fn as_datetime(&self) -> &chrono::DateTime<chrono::FixedOffset> {
285 &self.0
286 }
287
288 #[must_use]
290 pub fn into_inner(self) -> chrono::DateTime<chrono::FixedOffset> {
291 self.0
292 }
293}
294
295impl std::fmt::Display for RfcTimestamp {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 write!(f, "{}", self.0.to_rfc3339())
299 }
300}
301
302impl std::fmt::Debug for RfcTimestamp {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 write!(f, "{}", self.0.to_rfc3339())
309 }
310}
311
312impl<'de> Deserialize<'de> for RfcTimestamp {
313 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
314 where
315 D: serde::Deserializer<'de>,
316 {
317 let s = String::deserialize(deserializer)?;
321 Self::parse(&s).map_err(serde::de::Error::custom)
322 }
323}
324
325impl serde::Serialize for RfcTimestamp {
326 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
327 where
328 S: serde::Serializer,
329 {
330 serializer.serialize_str(&self.0.to_rfc3339())
331 }
332}
333
334impl From<chrono::DateTime<chrono::FixedOffset>> for RfcTimestamp {
335 fn from(value: chrono::DateTime<chrono::FixedOffset>) -> Self {
336 Self(value)
337 }
338}
339
340#[derive(Clone, Deserialize)]
347#[serde(deny_unknown_fields)]
348#[non_exhaustive]
349pub struct ApiKeyEntry {
350 pub name: String,
352 pub hash: String,
354 pub role: String,
356 pub expires_at: Option<RfcTimestamp>,
361}
362
363impl std::fmt::Debug for ApiKeyEntry {
364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 f.debug_struct("ApiKeyEntry")
368 .field("name", &self.name)
369 .field("hash", &"<redacted>")
370 .field("role", &self.role)
371 .field("expires_at", &self.expires_at)
372 .finish()
373 }
374}
375
376impl ApiKeyEntry {
377 #[must_use]
379 pub fn new(name: impl Into<String>, hash: impl Into<String>, role: impl Into<String>) -> Self {
380 Self {
381 name: name.into(),
382 hash: hash.into(),
383 role: role.into(),
384 expires_at: None,
385 }
386 }
387
388 #[must_use]
393 pub fn with_expiry(mut self, expires_at: RfcTimestamp) -> Self {
394 self.expires_at = Some(expires_at);
395 self
396 }
397
398 pub fn try_with_expiry(
406 mut self,
407 expires_at: impl AsRef<str>,
408 ) -> Result<Self, chrono::ParseError> {
409 self.expires_at = Some(RfcTimestamp::parse(expires_at.as_ref())?);
410 Ok(self)
411 }
412}
413
414#[derive(Debug, Clone, Deserialize)]
416#[serde(deny_unknown_fields)]
417#[allow(
418 clippy::struct_excessive_bools,
419 reason = "mTLS CRL behavior is intentionally configured as independent booleans"
420)]
421#[non_exhaustive]
422pub struct MtlsConfig {
423 pub ca_cert_path: PathBuf,
425 #[serde(default)]
428 pub required: bool,
429 #[serde(default = "default_mtls_role")]
432 pub default_role: String,
433 #[serde(default = "default_true")]
436 pub crl_enabled: bool,
437 #[serde(default, with = "humantime_serde::option")]
440 pub crl_refresh_interval: Option<Duration>,
441 #[serde(default = "default_crl_fetch_timeout", with = "humantime_serde")]
443 pub crl_fetch_timeout: Duration,
444 #[serde(
458 default = "default_crl_stale_grace",
459 alias = "crl_retry_retention",
460 with = "humantime_serde"
461 )]
462 pub crl_stale_grace: Duration,
463 #[serde(default = "default_true")]
479 pub crl_deny_on_unavailable: bool,
480 #[serde(default)]
482 pub crl_end_entity_only: bool,
483 #[serde(default = "default_true")]
492 pub crl_allow_http: bool,
493 #[serde(default = "default_true")]
495 pub crl_enforce_expiration: bool,
496 #[serde(default = "default_crl_max_concurrent_fetches")]
502 pub crl_max_concurrent_fetches: usize,
503 #[serde(default = "default_crl_max_response_bytes")]
507 pub crl_max_response_bytes: u64,
508 #[serde(default = "default_crl_discovery_rate_per_min")]
524 pub crl_discovery_rate_per_min: u32,
525 #[serde(default = "default_crl_max_host_semaphores")]
534 pub crl_max_host_semaphores: usize,
535 #[serde(default = "default_crl_max_seen_urls")]
539 pub crl_max_seen_urls: usize,
540 #[serde(default = "default_crl_max_cache_entries")]
544 pub crl_max_cache_entries: usize,
545}
546
547fn default_mtls_role() -> String {
548 "viewer".into()
549}
550
551const fn default_true() -> bool {
552 true
553}
554
555const fn default_crl_fetch_timeout() -> Duration {
556 Duration::from_secs(30)
557}
558
559const fn default_crl_stale_grace() -> Duration {
560 Duration::from_hours(24)
561}
562
563const fn default_crl_max_concurrent_fetches() -> usize {
564 4
565}
566
567const fn default_crl_max_response_bytes() -> u64 {
568 5 * 1024 * 1024
569}
570
571const fn default_crl_discovery_rate_per_min() -> u32 {
572 60
573}
574
575const fn default_crl_max_host_semaphores() -> usize {
576 1024
577}
578
579const fn default_crl_max_seen_urls() -> usize {
580 4096
581}
582
583const fn default_crl_max_cache_entries() -> usize {
584 1024
585}
586
587#[derive(Debug, Clone, Deserialize)]
602#[serde(deny_unknown_fields)]
603#[non_exhaustive]
604pub struct RateLimitConfig {
605 #[serde(default = "default_max_attempts")]
608 pub max_attempts_per_minute: u32,
609 #[serde(default)]
617 pub pre_auth_max_per_minute: Option<u32>,
618 #[serde(default = "default_max_tracked_keys")]
623 pub max_tracked_keys: usize,
624 #[serde(default = "default_idle_eviction", with = "humantime_serde")]
627 pub idle_eviction: Duration,
628 #[serde(default)]
635 pub burst: Option<u32>,
636 #[serde(default)]
642 pub pre_auth_burst: Option<u32>,
643 #[serde(default)]
646 pub key_eviction_policy: KeyEvictionPolicy,
647}
648
649impl Default for RateLimitConfig {
650 fn default() -> Self {
651 Self {
652 max_attempts_per_minute: default_max_attempts(),
653 pre_auth_max_per_minute: None,
654 max_tracked_keys: default_max_tracked_keys(),
655 idle_eviction: default_idle_eviction(),
656 burst: None,
657 pre_auth_burst: None,
658 key_eviction_policy: KeyEvictionPolicy::default(),
659 }
660 }
661}
662
663impl RateLimitConfig {
664 #[must_use]
668 pub fn new(max_attempts_per_minute: u32) -> Self {
669 Self {
670 max_attempts_per_minute,
671 ..Self::default()
672 }
673 }
674
675 #[must_use]
678 pub fn with_pre_auth_max_per_minute(mut self, quota: u32) -> Self {
679 self.pre_auth_max_per_minute = Some(quota);
680 self
681 }
682
683 #[must_use]
685 pub fn with_max_tracked_keys(mut self, max: usize) -> Self {
686 self.max_tracked_keys = max;
687 self
688 }
689
690 #[must_use]
692 pub fn with_idle_eviction(mut self, idle: Duration) -> Self {
693 self.idle_eviction = idle;
694 self
695 }
696
697 #[must_use]
700 pub fn with_burst(mut self, burst: u32) -> Self {
701 self.burst = Some(burst);
702 self
703 }
704
705 #[must_use]
708 pub fn with_pre_auth_burst(mut self, burst: u32) -> Self {
709 self.pre_auth_burst = Some(burst);
710 self
711 }
712
713 #[must_use]
715 pub const fn with_key_eviction_policy(mut self, policy: KeyEvictionPolicy) -> Self {
716 self.key_eviction_policy = policy;
717 self
718 }
719}
720
721fn default_max_attempts() -> u32 {
722 30
723}
724
725fn default_max_tracked_keys() -> usize {
726 10_000
727}
728
729fn default_idle_eviction() -> Duration {
730 Duration::from_mins(15)
731}
732
733#[derive(Debug, Clone, Default, Deserialize)]
735#[serde(deny_unknown_fields)]
736#[non_exhaustive]
737pub struct AuthConfig {
738 #[serde(default)]
740 pub enabled: bool,
741 #[serde(default)]
743 pub api_keys: Vec<ApiKeyEntry>,
744 pub mtls: Option<MtlsConfig>,
746 pub rate_limit: Option<RateLimitConfig>,
748 #[cfg(feature = "oauth")]
750 pub oauth: Option<crate::oauth::OAuthConfig>,
751 #[cfg(not(feature = "oauth"))]
762 #[serde(default)]
763 pub(crate) oauth: Option<serde::de::IgnoredAny>,
764}
765
766impl AuthConfig {
767 #[must_use]
769 pub fn with_keys(keys: Vec<ApiKeyEntry>) -> Self {
770 Self {
771 enabled: true,
772 api_keys: keys,
773 mtls: None,
774 rate_limit: None,
775 #[cfg(feature = "oauth")]
776 oauth: None,
777 #[cfg(not(feature = "oauth"))]
778 oauth: None,
779 }
780 }
781
782 #[must_use]
784 pub fn with_rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
785 self.rate_limit = Some(rate_limit);
786 self
787 }
788
789 pub fn check_oauth_feature(&self) -> Result<(), RmcpServerKitError> {
803 #[cfg(not(feature = "oauth"))]
804 {
805 (self.oauth.is_none()).ok_or_else(|| {
806 RmcpServerKitError::Config(
807 "auth.oauth is configured but this build of rmcp-server-kit was compiled \
808 without the `oauth` cargo feature; rebuild with `--features oauth` or \
809 remove the [auth.oauth] table"
810 .into(),
811 )
812 })?;
813 }
814 Ok(())
815 }
816}
817
818#[derive(Debug, Clone, serde::Serialize)]
822#[non_exhaustive]
823pub struct ApiKeySummary {
824 pub name: String,
826 pub role: String,
828 pub expires_at: Option<RfcTimestamp>,
831}
832
833#[derive(Debug, Clone, serde::Serialize)]
835#[allow(
836 clippy::struct_excessive_bools,
837 reason = "this is a flat summary of independent auth-method booleans"
838)]
839#[non_exhaustive]
840pub struct AuthConfigSummary {
841 pub enabled: bool,
843 pub bearer: bool,
845 pub mtls: bool,
847 pub oauth: bool,
849 pub api_keys: Vec<ApiKeySummary>,
851}
852
853impl AuthConfig {
854 #[must_use]
856 pub fn summary(&self) -> AuthConfigSummary {
857 AuthConfigSummary {
858 enabled: self.enabled,
859 bearer: !self.api_keys.is_empty(),
860 mtls: self.mtls.is_some(),
861 #[cfg(feature = "oauth")]
862 oauth: self.oauth.is_some(),
863 #[cfg(not(feature = "oauth"))]
864 oauth: false,
865 api_keys: self
866 .api_keys
867 .iter()
868 .map(|k| ApiKeySummary {
869 name: k.name.clone(),
870 role: k.role.clone(),
871 expires_at: k.expires_at,
872 })
873 .collect(),
874 }
875 }
876}
877
878pub(crate) type KeyedLimiter = BoundedKeyedLimiter<RateLimitKey>;
881
882#[derive(Clone, Debug)]
892#[non_exhaustive]
893pub(crate) struct TlsConnInfo {
894 pub addr: SocketAddr,
896 pub identity: Option<AuthIdentity>,
899}
900
901impl TlsConnInfo {
902 #[must_use]
904 pub(crate) const fn new(addr: SocketAddr, identity: Option<AuthIdentity>) -> Self {
905 Self { addr, identity }
906 }
907}
908
909const DEFAULT_SEEN_IDENTITY_CAP: usize = 4096;
917
918pub(crate) struct SeenIdentitySet {
938 inner: Mutex<SeenInner>,
939}
940
941struct SeenInner {
942 set: HashSet<String>,
943 order: std::collections::VecDeque<String>,
948 cap: usize,
949}
950
951impl SeenIdentitySet {
952 #[must_use]
954 pub(crate) fn new() -> Self {
955 Self::with_cap(DEFAULT_SEEN_IDENTITY_CAP)
956 }
957
958 #[must_use]
961 pub(crate) fn with_cap(cap: usize) -> Self {
962 let cap = cap.max(1);
963 Self {
964 inner: Mutex::new(SeenInner {
965 set: HashSet::with_capacity(cap.min(64)),
966 order: std::collections::VecDeque::with_capacity(cap.min(64)),
967 cap,
968 }),
969 }
970 }
971
972 pub(crate) fn insert_is_first(&self, name: &str) -> bool {
979 let mut guard = self
985 .inner
986 .lock()
987 .unwrap_or_else(std::sync::PoisonError::into_inner);
988
989 if guard.set.contains(name) {
990 return false;
991 }
992 if guard.set.len() >= guard.cap
995 && let Some(evicted) = guard.order.pop_front()
996 {
997 guard.set.remove(&evicted);
998 }
999 let owned = name.to_owned();
1000 guard.set.insert(owned.clone());
1001 guard.order.push_back(owned);
1002 true
1003 }
1004
1005 #[cfg(test)]
1007 pub(crate) fn len(&self) -> usize {
1008 self.inner
1009 .lock()
1010 .unwrap_or_else(std::sync::PoisonError::into_inner)
1011 .set
1012 .len()
1013 }
1014}
1015
1016impl Default for SeenIdentitySet {
1017 fn default() -> Self {
1018 Self::new()
1019 }
1020}
1021
1022#[allow(
1027 missing_debug_implementations,
1028 reason = "contains governor RateLimiter and JwksCache without Debug impls"
1029)]
1030#[non_exhaustive]
1031pub(crate) struct AuthState {
1032 pub api_keys: ArcSwap<Vec<ApiKeyEntry>>,
1034 pub rate_limiter: Option<Arc<KeyedLimiter>>,
1036 pub pre_auth_limiter: Option<Arc<KeyedLimiter>>,
1039 #[cfg(feature = "oauth")]
1040 pub jwks_cache: Option<Arc<crate::oauth::JwksCache>>,
1042 pub seen_identities: SeenIdentitySet,
1047 pub counters: AuthCounters,
1049 pub resource_metadata_url: Option<String>,
1057}
1058
1059impl AuthState {
1060 pub(crate) fn reload_keys(&self, keys: Vec<ApiKeyEntry>) {
1066 let count = keys.len();
1067 self.api_keys.store(Arc::new(keys));
1068 tracing::info!(keys = count, "API keys reloaded");
1069 }
1070
1071 #[must_use]
1073 pub(crate) fn counters_snapshot(&self) -> AuthCountersSnapshot {
1074 self.counters.snapshot()
1075 }
1076
1077 #[must_use]
1079 pub(crate) fn api_key_summaries(&self) -> Vec<ApiKeySummary> {
1080 self.api_keys
1081 .load()
1082 .iter()
1083 .map(|k| ApiKeySummary {
1084 name: k.name.clone(),
1085 role: k.role.clone(),
1086 expires_at: k.expires_at,
1087 })
1088 .collect()
1089 }
1090
1091 fn log_auth(&self, id: &AuthIdentity, method: &str) {
1099 self.counters.record_success(id.method);
1100 let first = self.seen_identities.insert_is_first(&id.name);
1101 if first {
1102 tracing::info!(name = %id.name, role = %id.role, "{method} authenticated");
1103 } else {
1104 tracing::debug!(name = %id.name, role = %id.role, "{method} authenticated");
1105 }
1106 }
1107}
1108
1109const DEFAULT_AUTH_RATE: NonZeroU32 = NonZeroU32::new(30).unwrap();
1112
1113fn apply_burst(quota: governor::Quota, burst: Option<u32>) -> governor::Quota {
1117 match burst.and_then(NonZeroU32::new) {
1118 Some(b) => quota.allow_burst(b),
1119 None => quota,
1120 }
1121}
1122
1123#[must_use]
1125pub(crate) fn build_rate_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1126 let quota = governor::Quota::per_minute(
1131 NonZeroU32::new(config.max_attempts_per_minute).unwrap_or(DEFAULT_AUTH_RATE),
1132 );
1133 let quota = apply_burst(quota, config.burst);
1134 let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1137 Arc::new(BoundedKeyedLimiter::new_with_policy(
1138 quota,
1139 max_tracked_keys,
1140 config.idle_eviction,
1141 config.key_eviction_policy,
1142 ))
1143}
1144
1145#[must_use]
1152pub(crate) fn build_pre_auth_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1153 let resolved = config.pre_auth_max_per_minute.unwrap_or_else(|| {
1154 config
1155 .max_attempts_per_minute
1156 .saturating_mul(PRE_AUTH_DEFAULT_MULTIPLIER)
1157 });
1158 let quota =
1159 governor::Quota::per_minute(NonZeroU32::new(resolved).unwrap_or(DEFAULT_PRE_AUTH_RATE));
1160 let quota = apply_burst(quota, config.pre_auth_burst);
1161 let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1164 Arc::new(BoundedKeyedLimiter::new_with_policy(
1165 quota,
1166 max_tracked_keys,
1167 config.idle_eviction,
1168 config.key_eviction_policy,
1169 ))
1170}
1171
1172const PRE_AUTH_DEFAULT_MULTIPLIER: u32 = 10;
1175
1176const DEFAULT_PRE_AUTH_RATE: NonZeroU32 = NonZeroU32::new(300).unwrap();
1180
1181#[must_use]
1186pub fn extract_mtls_identity(cert_der: &[u8], default_role: &str) -> Option<AuthIdentity> {
1187 let (_, cert) = X509Certificate::from_der(cert_der).ok()?;
1188
1189 let cn = cert
1191 .subject()
1192 .iter_common_name()
1193 .next()
1194 .and_then(|attr| attr.as_str().ok())
1195 .map(String::from);
1196
1197 let name = cn.or_else(|| {
1199 cert.subject_alternative_name()
1200 .ok()
1201 .flatten()
1202 .and_then(|san| {
1203 #[allow(
1204 clippy::wildcard_enum_match_arm,
1205 reason = "x509-parser GeneralName is a large external enum; only DNSName is meaningful here"
1206 )]
1207 san.value.general_names.iter().find_map(|gn| match gn {
1208 GeneralName::DNSName(dns) => Some((*dns).to_owned()),
1209 _ => None,
1210 })
1211 })
1212 })?;
1213
1214 if !name
1216 .chars()
1217 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '.' | '_' | '@'))
1218 {
1219 tracing::warn!(cn = %name, "mTLS identity rejected: invalid characters in CN/SAN");
1220 return None;
1221 }
1222
1223 Some(AuthIdentity {
1224 name,
1225 role: default_role.to_owned(),
1226 method: AuthMethod::MtlsCertificate,
1227 raw_token: None,
1228 sub: None,
1229 })
1230}
1231
1232fn extract_bearer(value: &str) -> Option<&str> {
1261 let (scheme, rest) = value.split_once(' ')?;
1262 if !scheme.eq_ignore_ascii_case("Bearer") {
1263 return None;
1264 }
1265 let token = rest.trim_start_matches(' ');
1266 if token.is_empty() || token.bytes().any(|b| b.is_ascii_whitespace()) {
1267 return None;
1268 }
1269 Some(token)
1270}
1271
1272#[must_use]
1309pub fn verify_bearer_token(token: &str, keys: &[ApiKeyEntry]) -> Option<AuthIdentity> {
1310 use subtle::ConstantTimeEq as _;
1311
1312 let now = chrono::Utc::now();
1313 #[allow(
1314 clippy::expect_used,
1315 reason = "DUMMY_PHC_HASH is a static LazyLock built from a fixed Argon2id PHC string by construction; PasswordHash::new on it is infallible. See DUMMY_PHC_HASH definition."
1316 )]
1317 let dummy_hash = PasswordHash::new(&DUMMY_PHC_HASH)
1318 .expect("DUMMY_PHC_HASH is a valid Argon2id PHC string by construction");
1319
1320 let mut matched_index: usize = usize::MAX;
1321 let mut any_match: u8 = 0;
1322
1323 for (idx, key) in keys.iter().enumerate() {
1324 let expired = key.expires_at.is_some_and(|exp| exp.as_datetime() < &now);
1325
1326 let real_hash = PasswordHash::new(&key.hash);
1327 let verify_against = match (&real_hash, expired, any_match) {
1328 (Ok(h), false, 0) => h,
1329 _ => &dummy_hash,
1330 };
1331
1332 let slot_ok = u8::from(
1333 Argon2::default()
1334 .verify_password(token.as_bytes(), verify_against)
1335 .is_ok(),
1336 );
1337
1338 let real_match = slot_ok & u8::from(!expired) & u8::from(real_hash.is_ok());
1339 let first_real_match = real_match & (1 - any_match);
1340 if first_real_match.ct_eq(&1).into() {
1341 matched_index = idx;
1342 }
1343 any_match |= real_match;
1344 }
1345
1346 if any_match == 0 {
1347 return None;
1348 }
1349 let key = keys.get(matched_index)?;
1350 Some(AuthIdentity {
1351 name: key.name.clone(),
1352 role: key.role.clone(),
1353 method: AuthMethod::BearerToken,
1354 raw_token: None,
1355 sub: None,
1356 })
1357}
1358
1359static DUMMY_PHC_HASH: LazyLock<String> = LazyLock::new(|| {
1373 #[allow(
1374 clippy::expect_used,
1375 reason = "Argon2::default() over a fixed plaintext and a fixed 16-byte salt is infallible; it fails only on invalid params or salt length, both constants here"
1376 )]
1377 Argon2::default()
1378 .hash_password_with_salt(b"rmcp-server-kit-dummy", &[0u8; 16])
1379 .expect("Argon2 default params hash a fixed plaintext")
1380 .to_string()
1381});
1382
1383pub fn generate_api_key() -> Result<(String, String), RmcpServerKitError> {
1393 let mut token_bytes = [0u8; 32];
1394 rand::fill(&mut token_bytes);
1395 let token = URL_SAFE_NO_PAD.encode(token_bytes);
1396
1397 let mut salt_bytes = [0u8; 16];
1398 rand::fill(&mut salt_bytes);
1399 let hash = Argon2::default()
1400 .hash_password_with_salt(token.as_bytes(), &salt_bytes)
1401 .map_err(|e| RmcpServerKitError::Internal(format!("argon2id hashing failed: {e}")))?
1402 .to_string();
1403
1404 Ok((token, hash))
1405}
1406
1407fn build_www_authenticate_value(
1408 resource_metadata: Option<&str>,
1409 failure: AuthFailureClass,
1410) -> String {
1411 let (error, error_description) = failure.bearer_error();
1412 if let Some(url) = resource_metadata {
1413 return format!(
1414 "Bearer resource_metadata=\"{url}\", error=\"{error}\", error_description=\"{error_description}\""
1415 );
1416 }
1417 format!("Bearer error=\"{error}\", error_description=\"{error_description}\"")
1418}
1419
1420fn auth_method_label(method: AuthMethod) -> &'static str {
1421 match method {
1422 AuthMethod::MtlsCertificate => "mTLS",
1423 AuthMethod::BearerToken => "bearer token",
1424 AuthMethod::OAuthJwt => "OAuth JWT",
1425 }
1426}
1427
1428#[cfg_attr(
1429 not(feature = "oauth"),
1430 allow(
1431 unused_variables,
1432 reason = "`state` is only read to decide whether to advertise OAuth \
1433 protected-resource metadata; without the `oauth` feature that \
1434 decision is a compile-time `false`"
1435 )
1436)]
1437fn unauthorized_response(state: &AuthState, failure_class: AuthFailureClass) -> Response {
1438 #[cfg(feature = "oauth")]
1439 let advertise_resource_metadata = state.jwks_cache.is_some();
1440 #[cfg(not(feature = "oauth"))]
1441 let advertise_resource_metadata = false;
1442
1443 let resource_metadata = advertise_resource_metadata.then(|| {
1444 state
1445 .resource_metadata_url
1446 .as_deref()
1447 .unwrap_or("/.well-known/oauth-protected-resource")
1448 });
1449 let challenge = build_www_authenticate_value(resource_metadata, failure_class);
1450 (
1451 StatusCode::UNAUTHORIZED,
1452 [(header::WWW_AUTHENTICATE, challenge)],
1453 failure_class.response_body(),
1454 )
1455 .into_response()
1456}
1457
1458async fn authenticate_bearer_identity(
1464 state: &AuthState,
1465 token: &str,
1466) -> Result<AuthIdentity, AuthFailureClass> {
1467 let mut failure_class = AuthFailureClass::MissingCredential;
1468
1469 #[cfg(feature = "oauth")]
1470 if let Some(ref cache) = state.jwks_cache
1471 && crate::oauth::looks_like_jwt(token)
1472 {
1473 match cache.validate_token_with_reason(token).await {
1474 Ok(mut id) => {
1475 id.raw_token = Some(SecretString::from(token.to_owned()));
1476 return Ok(id);
1477 }
1478 Err(crate::oauth::JwtValidationFailure::Expired) => {
1479 failure_class = AuthFailureClass::ExpiredCredential;
1480 }
1481 Err(crate::oauth::JwtValidationFailure::Invalid) => {
1482 failure_class = AuthFailureClass::InvalidCredential;
1483 }
1484 }
1485 }
1486
1487 let token = token.to_owned();
1488 let keys = state.api_keys.load_full(); let identity = tokio::task::spawn_blocking(move || verify_bearer_token(&token, &keys))
1492 .await
1493 .ok()
1494 .flatten();
1495
1496 if let Some(id) = identity {
1497 return Ok(id);
1498 }
1499
1500 if failure_class == AuthFailureClass::MissingCredential {
1501 failure_class = AuthFailureClass::InvalidCredential;
1502 }
1503
1504 Err(failure_class)
1505}
1506
1507fn pre_auth_gate(state: &AuthState, client_key: Option<&RateLimitKey>) -> Option<Response> {
1518 let limiter = state.pre_auth_limiter.as_ref()?;
1519 let key = client_key?;
1520 match limiter.check_key_detailed(key) {
1521 Ok(()) => None,
1522 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1523 state.counters.record_failure(AuthFailureClass::PreAuthGate);
1524 tracing::warn!(
1525 rate_limit_key = %key,
1526 "auth rate limited by pre-auth gate (request rejected before credential verification)"
1527 );
1528 Some(
1529 RmcpServerKitError::RateLimitedFor {
1530 message: "too many unauthenticated requests from this source".into(),
1531 retry_after: wait,
1532 }
1533 .into_response(),
1534 )
1535 }
1536 Err(BoundedLimiterDeny::CapacityFull) => {
1537 tracing::warn!(
1538 rate_limit_key = %key,
1539 "auth pre-auth gate rejected unseen key because tracked-key capacity is full"
1540 );
1541 Some(
1542 (
1543 StatusCode::SERVICE_UNAVAILABLE,
1544 "rate limiter capacity exhausted",
1545 )
1546 .into_response(),
1547 )
1548 }
1549 }
1550}
1551
1552#[cfg_attr(
1553 not(feature = "metrics"),
1554 allow(
1555 unused_variables,
1556 reason = "`extensions` is read only to record the \
1557 `rmcp_server_kit_rate_limited_total` metric; without the \
1558 `metrics` feature there is no recording site"
1559 )
1560)]
1561fn post_failure_rate_limit_response(
1562 limiter: &KeyedLimiter,
1563 key: &RateLimitKey,
1564 extensions: &axum::http::Extensions,
1565) -> Option<Response> {
1566 match limiter.check_key_detailed(key) {
1567 Ok(()) => None,
1568 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1569 #[cfg(feature = "metrics")]
1570 crate::metrics::record_rate_limit_deny(extensions, "auth_post");
1571 tracing::warn!(rate_limit_key = %key, "auth rate limited after repeated failures");
1572 Some(
1573 RmcpServerKitError::RateLimitedFor {
1574 message: "too many failed authentication attempts".into(),
1575 retry_after: wait,
1576 }
1577 .into_response(),
1578 )
1579 }
1580 Err(BoundedLimiterDeny::CapacityFull) => {
1581 tracing::warn!(
1582 rate_limit_key = %key,
1583 "auth post-failure limiter rejected unseen key because tracked-key capacity is full"
1584 );
1585 Some(
1586 (
1587 StatusCode::SERVICE_UNAVAILABLE,
1588 "rate limiter capacity exhausted",
1589 )
1590 .into_response(),
1591 )
1592 }
1593 }
1594}
1595
1596pub(crate) async fn auth_middleware(
1608 state: Arc<AuthState>,
1609 req: Request<Body>,
1610 next: Next,
1611) -> Response {
1612 let tls_info = req.extensions().get::<ConnectInfo<TlsConnInfo>>().cloned();
1618 let client_key = (state.pre_auth_limiter.is_some() || state.rate_limiter.is_some())
1621 .then(|| crate::transport::limiter_client_key(req.extensions()));
1622
1623 if let Some(id) = tls_info.and_then(|ci| ci.0.identity) {
1630 state.log_auth(&id, "mTLS");
1631 let mut req = req;
1632 req.extensions_mut().insert(id);
1633 return next.run(req).await;
1634 }
1635
1636 if let Some(blocked) = pre_auth_gate(&state, client_key.as_ref()) {
1640 #[cfg(feature = "metrics")]
1641 crate::metrics::record_rate_limit_deny(req.extensions(), "auth_pre");
1642 return blocked;
1643 }
1644
1645 let failure_class = if let Some(value) = req.headers().get(header::AUTHORIZATION) {
1646 match value.to_str().ok().and_then(extract_bearer) {
1647 Some(token) => match authenticate_bearer_identity(&state, token).await {
1648 Ok(id) => {
1649 state.log_auth(&id, auth_method_label(id.method));
1650 let mut req = req;
1651 req.extensions_mut().insert(id);
1652 return next.run(req).await;
1653 }
1654 Err(class) => class,
1655 },
1656 None => AuthFailureClass::InvalidCredential,
1657 }
1658 } else {
1659 AuthFailureClass::MissingCredential
1660 };
1661
1662 tracing::warn!(failure_class = %failure_class.as_str(), "auth failed");
1663
1664 if let (Some(limiter), Some(key)) = (&state.rate_limiter, client_key.as_ref())
1667 && let Some(resp) = post_failure_rate_limit_response(limiter, key, req.extensions())
1668 {
1669 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
1670 state.counters.record_failure(AuthFailureClass::RateLimited);
1671 }
1672 return resp;
1673 }
1674
1675 state.counters.record_failure(failure_class);
1676 unauthorized_response(&state, failure_class)
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681 use std::net::IpAddr;
1682
1683 use super::*;
1684 use crate::transport::RateLimitKey;
1685
1686 const ARGON2_0_5_TOKEN: &str = "golden-vector-token-0p5p3";
1693 const ARGON2_0_5_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$BwcHBwcHBwcHBwcHBwcHBw$spS8B9AhHG1LikfhGlssVMfP8mq37+8/mXnl98ps0NU";
1694
1695 #[test]
1696 fn argon2_0_5_produced_hash_still_verifies() {
1697 let parsed =
1698 PasswordHash::new(ARGON2_0_5_HASH).expect("a 0.5-era PHC string must still parse");
1699 Argon2::default()
1700 .verify_password(ARGON2_0_5_TOKEN.as_bytes(), &parsed)
1701 .expect("already-deployed API keys must keep verifying across the argon2 upgrade");
1702 }
1703
1704 #[test]
1712 fn dummy_and_real_hashes_share_cost_parameters() {
1713 let (_token, real_hash) = generate_api_key().expect("key generation must succeed");
1714 let real = PasswordHash::new(&real_hash).expect("generated hash must parse");
1715 let dummy = PasswordHash::new(&DUMMY_PHC_HASH).expect("dummy hash must parse");
1716
1717 assert_eq!(dummy.algorithm, real.algorithm, "algorithm must match");
1718 assert_eq!(dummy.version, real.version, "PHC version must match");
1719 assert_eq!(
1720 dummy.params, real.params,
1721 "m/t/p must match or the dummy no longer costs what a real verification costs"
1722 );
1723 }
1724
1725 #[test]
1726 fn generate_and_verify_api_key() {
1727 let (token, hash) = generate_api_key().unwrap();
1728
1729 assert_eq!(token.len(), 43);
1731
1732 assert!(hash.starts_with("$argon2id$"));
1734
1735 let keys = vec![ApiKeyEntry {
1737 name: "test".into(),
1738 hash,
1739 role: "viewer".into(),
1740 expires_at: None,
1741 }];
1742 let id = verify_bearer_token(&token, &keys);
1743 assert!(id.is_some());
1744 let id = id.unwrap();
1745 assert_eq!(id.name, "test");
1746 assert_eq!(id.role, "viewer");
1747 assert_eq!(id.method, AuthMethod::BearerToken);
1748 }
1749
1750 #[test]
1751 fn wrong_token_rejected() {
1752 let (_token, hash) = generate_api_key().unwrap();
1753 let keys = vec![ApiKeyEntry {
1754 name: "test".into(),
1755 hash,
1756 role: "viewer".into(),
1757 expires_at: None,
1758 }];
1759 assert!(verify_bearer_token("wrong-token", &keys).is_none());
1760 }
1761
1762 #[test]
1763 fn expired_key_rejected() {
1764 let (token, hash) = generate_api_key().unwrap();
1765 let keys = vec![ApiKeyEntry {
1766 name: "test".into(),
1767 hash,
1768 role: "viewer".into(),
1769 expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1770 }];
1771 assert!(verify_bearer_token(&token, &keys).is_none());
1772 }
1773
1774 #[test]
1775 fn match_in_last_slot_still_authenticates() {
1776 let (token, hash) = generate_api_key().unwrap();
1777 let (_other_token, other_hash) = generate_api_key().unwrap();
1778 let keys = vec![
1779 ApiKeyEntry {
1780 name: "first".into(),
1781 hash: other_hash.clone(),
1782 role: "viewer".into(),
1783 expires_at: None,
1784 },
1785 ApiKeyEntry {
1786 name: "second".into(),
1787 hash: other_hash,
1788 role: "viewer".into(),
1789 expires_at: None,
1790 },
1791 ApiKeyEntry {
1792 name: "match".into(),
1793 hash,
1794 role: "ops".into(),
1795 expires_at: None,
1796 },
1797 ];
1798 let id = verify_bearer_token(&token, &keys).expect("last-slot match must authenticate");
1799 assert_eq!(id.name, "match");
1800 assert_eq!(id.role, "ops");
1801 }
1802
1803 #[test]
1804 fn expired_slot_before_valid_match_does_not_short_circuit() {
1805 let (token, hash) = generate_api_key().unwrap();
1806 let (_, other_hash) = generate_api_key().unwrap();
1807 let keys = vec![
1808 ApiKeyEntry {
1809 name: "expired".into(),
1810 hash: other_hash,
1811 role: "viewer".into(),
1812 expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1813 },
1814 ApiKeyEntry {
1815 name: "valid".into(),
1816 hash,
1817 role: "ops".into(),
1818 expires_at: None,
1819 },
1820 ];
1821 let id = verify_bearer_token(&token, &keys)
1822 .expect("valid slot following an expired slot must authenticate");
1823 assert_eq!(id.name, "valid");
1824 }
1825
1826 #[test]
1827 fn malformed_hash_slot_does_not_short_circuit() {
1828 let (token, hash) = generate_api_key().unwrap();
1829 let keys = vec![
1830 ApiKeyEntry {
1831 name: "broken".into(),
1832 hash: "this-is-not-a-phc-string".into(),
1833 role: "viewer".into(),
1834 expires_at: None,
1835 },
1836 ApiKeyEntry {
1837 name: "valid".into(),
1838 hash,
1839 role: "ops".into(),
1840 expires_at: None,
1841 },
1842 ];
1843 let id = verify_bearer_token(&token, &keys)
1844 .expect("valid slot following a malformed-hash slot must authenticate");
1845 assert_eq!(id.name, "valid");
1846 }
1847
1848 #[test]
1859 fn rfc_timestamp_parse_rejects_malformed() {
1860 for bad in [
1861 "not-a-date",
1862 "",
1863 "2025-13-01T00:00:00Z", "2025-01-32T00:00:00Z", "2025-01-01T00:00:00", "01/01/2025", "2025-01-01T25:00:00Z", ] {
1869 assert!(
1870 RfcTimestamp::parse(bad).is_err(),
1871 "RfcTimestamp::parse must reject {bad:?}"
1872 );
1873 }
1874 }
1875
1876 #[test]
1877 fn rfc_timestamp_parse_accepts_valid() {
1878 for good in [
1879 "2025-01-01T00:00:00Z",
1880 "2025-01-01T00:00:00+00:00",
1881 "2025-12-31T23:59:59-08:00",
1882 "2099-01-01T00:00:00.123456789Z",
1883 ] {
1884 assert!(
1885 RfcTimestamp::parse(good).is_ok(),
1886 "RfcTimestamp::parse must accept {good:?}"
1887 );
1888 }
1889 }
1890
1891 #[test]
1892 fn api_key_entry_deserialize_rejects_malformed_expires_at() {
1893 let toml = r#"
1898 name = "bad-key"
1899 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1900 role = "viewer"
1901 expires_at = "not-a-date"
1902 "#;
1903 let result: Result<ApiKeyEntry, _> = toml::from_str(toml);
1904 assert!(
1905 result.is_err(),
1906 "deserialization must reject malformed expires_at"
1907 );
1908 }
1909
1910 #[test]
1911 fn api_key_entry_deserialize_accepts_valid_expires_at() {
1912 let toml = r#"
1913 name = "good-key"
1914 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1915 role = "viewer"
1916 expires_at = "2099-01-01T00:00:00Z"
1917 "#;
1918 let entry: ApiKeyEntry = toml::from_str(toml).expect("valid RFC 3339 must deserialize");
1919 assert!(entry.expires_at.is_some());
1920 }
1921
1922 #[test]
1923 fn api_key_entry_deserialize_accepts_missing_expires_at() {
1924 let toml = r#"
1927 name = "eternal-key"
1928 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1929 role = "viewer"
1930 "#;
1931 let entry: ApiKeyEntry = toml::from_str(toml).expect("missing expires_at must deserialize");
1932 assert!(entry.expires_at.is_none());
1933 }
1934
1935 #[test]
1936 fn mtls_crl_deny_on_unavailable_defaults_to_fail_closed() {
1937 let toml = r#"
1941 ca_cert_path = "/etc/certs/clients-ca.pem"
1942 "#;
1943 let cfg: MtlsConfig = toml::from_str(toml).expect("minimal mtls config must deserialize");
1944 assert!(
1945 cfg.crl_deny_on_unavailable,
1946 "omitting crl_deny_on_unavailable must fail closed (RFC 5280 6.3)"
1947 );
1948 }
1949
1950 #[test]
1951 fn mtls_crl_deny_on_unavailable_opt_out_is_honoured() {
1952 let toml = r#"
1953 ca_cert_path = "/etc/certs/clients-ca.pem"
1954 crl_deny_on_unavailable = false
1955 "#;
1956 let cfg: MtlsConfig = toml::from_str(toml).expect("opt-out config must deserialize");
1957 assert!(
1958 !cfg.crl_deny_on_unavailable,
1959 "an explicit false must still select fail-open"
1960 );
1961 }
1962
1963 #[test]
1964 fn try_with_expiry_rejects_malformed() {
1965 let entry = ApiKeyEntry::new("k", "hash", "viewer");
1966 assert!(entry.try_with_expiry("not-a-date").is_err());
1967 }
1968
1969 #[test]
1970 fn try_with_expiry_accepts_valid() {
1971 let entry = ApiKeyEntry::new("k", "hash", "viewer")
1972 .try_with_expiry("2099-01-01T00:00:00Z")
1973 .expect("valid RFC 3339 must be accepted");
1974 assert!(entry.expires_at.is_some());
1975 }
1976
1977 #[test]
1978 fn api_key_summary_serializes_expires_at_as_rfc3339() {
1979 let summary = ApiKeySummary {
1984 name: "k".into(),
1985 role: "viewer".into(),
1986 expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
1987 };
1988 let json = serde_json::to_string(&summary).unwrap();
1989 assert!(
1990 json.contains(r#""expires_at":"2030-01-01T00:00:00+00:00""#),
1991 "wire format regressed: {json}"
1992 );
1993 }
1994
1995 #[test]
1996 fn future_expiry_accepted() {
1997 let (token, hash) = generate_api_key().unwrap();
1998 let keys = vec![ApiKeyEntry {
1999 name: "test".into(),
2000 hash,
2001 role: "viewer".into(),
2002 expires_at: Some(RfcTimestamp::parse("2099-01-01T00:00:00Z").unwrap()),
2003 }];
2004 assert!(verify_bearer_token(&token, &keys).is_some());
2005 }
2006
2007 #[test]
2008 fn multiple_keys_first_match_wins() {
2009 let (token, hash) = generate_api_key().unwrap();
2010 let keys = vec![
2011 ApiKeyEntry {
2012 name: "wrong".into(),
2013 hash: "$argon2id$v=19$m=19456,t=2,p=1$invalid$invalid".into(),
2014 role: "ops".into(),
2015 expires_at: None,
2016 },
2017 ApiKeyEntry {
2018 name: "correct".into(),
2019 hash,
2020 role: "deploy".into(),
2021 expires_at: None,
2022 },
2023 ];
2024 let id = verify_bearer_token(&token, &keys).unwrap();
2025 assert_eq!(id.name, "correct");
2026 assert_eq!(id.role, "deploy");
2027 }
2028
2029 #[test]
2030 fn rate_limiter_allows_within_quota() {
2031 let config = RateLimitConfig {
2032 max_attempts_per_minute: 5,
2033 pre_auth_max_per_minute: None,
2034 max_tracked_keys: default_max_tracked_keys(),
2035 idle_eviction: default_idle_eviction(),
2036 burst: None,
2037 pre_auth_burst: None,
2038 key_eviction_policy: KeyEvictionPolicy::default(),
2039 };
2040 let limiter = build_rate_limiter(&config);
2041 let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2042
2043 for _ in 0..5 {
2045 assert!(limiter.check_key(&ip).is_ok());
2046 }
2047 assert!(limiter.check_key(&ip).is_err());
2049 }
2050
2051 #[test]
2052 fn rate_limiter_separate_ips() {
2053 let config = RateLimitConfig {
2054 max_attempts_per_minute: 2,
2055 pre_auth_max_per_minute: None,
2056 max_tracked_keys: default_max_tracked_keys(),
2057 idle_eviction: default_idle_eviction(),
2058 burst: None,
2059 pre_auth_burst: None,
2060 key_eviction_policy: KeyEvictionPolicy::default(),
2061 };
2062 let limiter = build_rate_limiter(&config);
2063 let ip1 = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2064 let ip2 = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2065
2066 assert!(limiter.check_key(&ip1).is_ok());
2068 assert!(limiter.check_key(&ip1).is_ok());
2069 assert!(limiter.check_key(&ip1).is_err());
2070
2071 assert!(limiter.check_key(&ip2).is_ok());
2073 }
2074
2075 #[test]
2076 fn extract_mtls_identity_from_cn() {
2077 let mut params = rcgen::CertificateParams::new(vec!["test-client.local".into()]).unwrap();
2079 params.distinguished_name = rcgen::DistinguishedName::new();
2080 params
2081 .distinguished_name
2082 .push(rcgen::DnType::CommonName, "test-client");
2083 let cert = params
2084 .self_signed(&rcgen::KeyPair::generate().unwrap())
2085 .unwrap();
2086 let der = cert.der();
2087
2088 let id = extract_mtls_identity(der, "ops").unwrap();
2089 assert_eq!(id.name, "test-client");
2090 assert_eq!(id.role, "ops");
2091 assert_eq!(id.method, AuthMethod::MtlsCertificate);
2092 }
2093
2094 #[test]
2095 fn extract_mtls_identity_falls_back_to_san() {
2096 let mut params =
2098 rcgen::CertificateParams::new(vec!["san-only.example.com".into()]).unwrap();
2099 params.distinguished_name = rcgen::DistinguishedName::new();
2100 let cert = params
2102 .self_signed(&rcgen::KeyPair::generate().unwrap())
2103 .unwrap();
2104 let der = cert.der();
2105
2106 let id = extract_mtls_identity(der, "viewer").unwrap();
2107 assert_eq!(id.name, "san-only.example.com");
2108 assert_eq!(id.role, "viewer");
2109 }
2110
2111 #[test]
2112 fn extract_mtls_identity_invalid_der() {
2113 assert!(extract_mtls_identity(b"not-a-cert", "viewer").is_none());
2114 }
2115
2116 use axum::{
2119 body::Body,
2120 http::{Request, StatusCode},
2121 };
2122 use tower::ServiceExt as _;
2123
2124 fn auth_router(state: Arc<AuthState>) -> axum::Router {
2125 axum::Router::new()
2126 .route("/mcp", axum::routing::post(|| async { "ok" }))
2127 .layer(axum::middleware::from_fn(move |req, next| {
2128 let s = Arc::clone(&state);
2129 auth_middleware(s, req, next)
2130 }))
2131 }
2132
2133 fn test_auth_state(keys: Vec<ApiKeyEntry>) -> Arc<AuthState> {
2134 Arc::new(AuthState {
2135 api_keys: ArcSwap::new(Arc::new(keys)),
2136 rate_limiter: None,
2137 pre_auth_limiter: None,
2138 #[cfg(feature = "oauth")]
2139 jwks_cache: None,
2140 seen_identities: SeenIdentitySet::new(),
2141 counters: AuthCounters::default(),
2142 resource_metadata_url: None,
2143 })
2144 }
2145
2146 #[tokio::test]
2147 async fn middleware_rejects_no_credentials() {
2148 let state = test_auth_state(vec![]);
2149 let app = auth_router(Arc::clone(&state));
2150 let req = Request::builder()
2151 .method(axum::http::Method::POST)
2152 .uri("/mcp")
2153 .body(Body::empty())
2154 .unwrap();
2155 let resp = app.oneshot(req).await.unwrap();
2156 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2157 let challenge = resp
2158 .headers()
2159 .get(header::WWW_AUTHENTICATE)
2160 .unwrap()
2161 .to_str()
2162 .unwrap();
2163 assert!(challenge.contains("error=\"invalid_request\""));
2164
2165 let counters = state.counters_snapshot();
2166 assert_eq!(counters.failure_missing_credential, 1);
2167 }
2168
2169 #[tokio::test]
2170 async fn middleware_accepts_valid_bearer() {
2171 let (token, hash) = generate_api_key().unwrap();
2172 let keys = vec![ApiKeyEntry {
2173 name: "test-key".into(),
2174 hash,
2175 role: "ops".into(),
2176 expires_at: None,
2177 }];
2178 let state = test_auth_state(keys);
2179 let app = auth_router(Arc::clone(&state));
2180 let req = Request::builder()
2181 .method(axum::http::Method::POST)
2182 .uri("/mcp")
2183 .header("authorization", format!("Bearer {token}"))
2184 .body(Body::empty())
2185 .unwrap();
2186 let resp = app.oneshot(req).await.unwrap();
2187 assert_eq!(resp.status(), StatusCode::OK);
2188
2189 let counters = state.counters_snapshot();
2190 assert_eq!(counters.success_bearer, 1);
2191 }
2192
2193 #[tokio::test]
2194 async fn middleware_rejects_wrong_bearer() {
2195 let (_token, hash) = generate_api_key().unwrap();
2196 let keys = vec![ApiKeyEntry {
2197 name: "test-key".into(),
2198 hash,
2199 role: "ops".into(),
2200 expires_at: None,
2201 }];
2202 let state = test_auth_state(keys);
2203 let app = auth_router(Arc::clone(&state));
2204 let req = Request::builder()
2205 .method(axum::http::Method::POST)
2206 .uri("/mcp")
2207 .header("authorization", "Bearer wrong-token-here")
2208 .body(Body::empty())
2209 .unwrap();
2210 let resp = app.oneshot(req).await.unwrap();
2211 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2212 let challenge = resp
2213 .headers()
2214 .get(header::WWW_AUTHENTICATE)
2215 .unwrap()
2216 .to_str()
2217 .unwrap();
2218 assert!(challenge.contains("error=\"invalid_token\""));
2219
2220 let counters = state.counters_snapshot();
2221 assert_eq!(counters.failure_invalid_credential, 1);
2222 }
2223
2224 #[tokio::test]
2225 async fn middleware_rate_limits() {
2226 let state = Arc::new(AuthState {
2227 api_keys: ArcSwap::new(Arc::new(vec![])),
2228 rate_limiter: Some(build_rate_limiter(&RateLimitConfig {
2229 max_attempts_per_minute: 1,
2230 pre_auth_max_per_minute: None,
2231 max_tracked_keys: default_max_tracked_keys(),
2232 idle_eviction: default_idle_eviction(),
2233 burst: None,
2234 pre_auth_burst: None,
2235 key_eviction_policy: KeyEvictionPolicy::default(),
2236 })),
2237 pre_auth_limiter: None,
2238 #[cfg(feature = "oauth")]
2239 jwks_cache: None,
2240 seen_identities: SeenIdentitySet::new(),
2241 counters: AuthCounters::default(),
2242 resource_metadata_url: None,
2243 });
2244 let app = auth_router(state);
2245
2246 let req = Request::builder()
2248 .method(axum::http::Method::POST)
2249 .uri("/mcp")
2250 .body(Body::empty())
2251 .unwrap();
2252 let resp = app.clone().oneshot(req).await.unwrap();
2253 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2254
2255 }
2260
2261 #[test]
2267 fn rate_limit_semantics_failed_only() {
2268 let config = RateLimitConfig {
2269 max_attempts_per_minute: 3,
2270 pre_auth_max_per_minute: None,
2271 max_tracked_keys: default_max_tracked_keys(),
2272 idle_eviction: default_idle_eviction(),
2273 burst: None,
2274 pre_auth_burst: None,
2275 key_eviction_policy: KeyEvictionPolicy::default(),
2276 };
2277 let limiter = build_rate_limiter(&config);
2278 let ip = RateLimitKey::Ip("192.168.1.100".parse::<IpAddr>().unwrap());
2279
2280 assert!(
2282 limiter.check_key(&ip).is_ok(),
2283 "failure 1 should be allowed"
2284 );
2285 assert!(
2286 limiter.check_key(&ip).is_ok(),
2287 "failure 2 should be allowed"
2288 );
2289 assert!(
2290 limiter.check_key(&ip).is_ok(),
2291 "failure 3 should be allowed"
2292 );
2293 assert!(
2294 limiter.check_key(&ip).is_err(),
2295 "failure 4 should be blocked"
2296 );
2297
2298 }
2307
2308 #[test]
2313 fn pre_auth_default_multiplier_is_10x() {
2314 let config = RateLimitConfig {
2315 max_attempts_per_minute: 5,
2316 pre_auth_max_per_minute: None,
2317 max_tracked_keys: default_max_tracked_keys(),
2318 idle_eviction: default_idle_eviction(),
2319 burst: None,
2320 pre_auth_burst: None,
2321 key_eviction_policy: KeyEvictionPolicy::default(),
2322 };
2323 let limiter = build_pre_auth_limiter(&config);
2324 let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2325
2326 for i in 0..50 {
2328 assert!(
2329 limiter.check_key(&ip).is_ok(),
2330 "pre-auth attempt {i} (of expected 50) should be allowed under default 10x multiplier"
2331 );
2332 }
2333 assert!(
2335 limiter.check_key(&ip).is_err(),
2336 "pre-auth attempt 51 should be blocked (quota is 50, not unbounded)"
2337 );
2338 }
2339
2340 #[test]
2343 fn pre_auth_explicit_override_wins() {
2344 let config = RateLimitConfig {
2345 max_attempts_per_minute: 100, pre_auth_max_per_minute: Some(2), max_tracked_keys: default_max_tracked_keys(),
2348 idle_eviction: default_idle_eviction(),
2349 burst: None,
2350 pre_auth_burst: None,
2351 key_eviction_policy: KeyEvictionPolicy::default(),
2352 };
2353 let limiter = build_pre_auth_limiter(&config);
2354 let ip = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2355
2356 assert!(limiter.check_key(&ip).is_ok(), "attempt 1 allowed");
2357 assert!(limiter.check_key(&ip).is_ok(), "attempt 2 allowed");
2358 assert!(
2359 limiter.check_key(&ip).is_err(),
2360 "attempt 3 must be blocked (explicit override of 2 wins over 10x default of 1000)"
2361 );
2362 }
2363
2364 #[test]
2366 fn pre_auth_gate_deny_sets_retry_after() {
2367 let config = RateLimitConfig::new(100).with_pre_auth_max_per_minute(1);
2368 let state = AuthState {
2369 api_keys: ArcSwap::new(Arc::new(vec![])),
2370 rate_limiter: None,
2371 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2372 #[cfg(feature = "oauth")]
2373 jwks_cache: None,
2374 seen_identities: SeenIdentitySet::new(),
2375 counters: AuthCounters::default(),
2376 resource_metadata_url: None,
2377 };
2378 let ip = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2379 assert!(
2380 pre_auth_gate(&state, Some(&ip)).is_none(),
2381 "first request within quota"
2382 );
2383 let resp = pre_auth_gate(&state, Some(&ip)).expect("second request must be gated");
2384 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
2385 let retry_after = resp
2386 .headers()
2387 .get(header::RETRY_AFTER)
2388 .expect("Retry-After present")
2389 .to_str()
2390 .unwrap()
2391 .parse::<u64>()
2392 .unwrap();
2393 assert!(retry_after >= 1, "delta-seconds must be >= 1");
2394 }
2395
2396 #[test]
2397 fn pre_auth_gate_capacity_full_returns_503_without_retry_after() {
2398 let config = RateLimitConfig::new(100)
2399 .with_pre_auth_max_per_minute(10)
2400 .with_max_tracked_keys(1)
2401 .with_key_eviction_policy(KeyEvictionPolicy::RejectNew);
2402 let state = AuthState {
2403 api_keys: ArcSwap::new(Arc::new(vec![])),
2404 rate_limiter: None,
2405 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2406 #[cfg(feature = "oauth")]
2407 jwks_cache: None,
2408 seen_identities: SeenIdentitySet::new(),
2409 counters: AuthCounters::default(),
2410 resource_metadata_url: None,
2411 };
2412 let established = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2413 let unseen = RateLimitKey::Ip("10.7.7.8".parse::<IpAddr>().unwrap());
2414 assert!(pre_auth_gate(&state, Some(&established)).is_none());
2415
2416 let resp = pre_auth_gate(&state, Some(&unseen)).expect("unseen key must be rejected");
2417
2418 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
2419 assert!(resp.headers().get(header::RETRY_AFTER).is_none());
2420 }
2421
2422 #[test]
2424 fn post_failure_limiter_burst_allows_initial_spike() {
2425 let config = RateLimitConfig::new(1).with_burst(3);
2426 let limiter = build_rate_limiter(&config);
2427 let ip = RateLimitKey::Ip("10.6.6.6".parse::<IpAddr>().unwrap());
2428 for i in 0..3 {
2429 assert!(limiter.check_key(&ip).is_ok(), "burst attempt {i}");
2430 }
2431 assert!(
2432 limiter.check_key(&ip).is_err(),
2433 "attempt 4 must exceed the burst bucket"
2434 );
2435 }
2436
2437 #[tokio::test]
2443 async fn pre_auth_gate_blocks_before_argon2_verification() {
2444 let (_token, hash) = generate_api_key().unwrap();
2445 let keys = vec![ApiKeyEntry {
2446 name: "test-key".into(),
2447 hash,
2448 role: "ops".into(),
2449 expires_at: None,
2450 }];
2451 let config = RateLimitConfig {
2452 max_attempts_per_minute: 100,
2453 pre_auth_max_per_minute: Some(1),
2454 max_tracked_keys: default_max_tracked_keys(),
2455 idle_eviction: default_idle_eviction(),
2456 burst: None,
2457 pre_auth_burst: None,
2458 key_eviction_policy: KeyEvictionPolicy::default(),
2459 };
2460 let state = Arc::new(AuthState {
2461 api_keys: ArcSwap::new(Arc::new(keys)),
2462 rate_limiter: None,
2463 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2464 #[cfg(feature = "oauth")]
2465 jwks_cache: None,
2466 seen_identities: SeenIdentitySet::new(),
2467 counters: AuthCounters::default(),
2468 resource_metadata_url: None,
2469 });
2470 let app = auth_router(Arc::clone(&state));
2471 let peer: SocketAddr = "10.0.0.10:54321".parse().unwrap();
2472
2473 let mut req1 = Request::builder()
2476 .method(axum::http::Method::POST)
2477 .uri("/mcp")
2478 .header("authorization", "Bearer obviously-not-a-real-token")
2479 .body(Body::empty())
2480 .unwrap();
2481 req1.extensions_mut().insert(ConnectInfo(peer));
2482 let resp1 = app.clone().oneshot(req1).await.unwrap();
2483 assert_eq!(
2484 resp1.status(),
2485 StatusCode::UNAUTHORIZED,
2486 "first attempt: gate has quota, falls through to bearer auth which fails with 401"
2487 );
2488
2489 let mut req2 = Request::builder()
2492 .method(axum::http::Method::POST)
2493 .uri("/mcp")
2494 .header("authorization", "Bearer also-not-a-real-token")
2495 .body(Body::empty())
2496 .unwrap();
2497 req2.extensions_mut().insert(ConnectInfo(peer));
2498 let resp2 = app.oneshot(req2).await.unwrap();
2499 assert_eq!(
2500 resp2.status(),
2501 StatusCode::TOO_MANY_REQUESTS,
2502 "second attempt from same IP: pre-auth gate must reject with 429"
2503 );
2504
2505 let counters = state.counters_snapshot();
2506 assert_eq!(
2507 counters.failure_pre_auth_gate, 1,
2508 "exactly one request must have been rejected by the pre-auth gate"
2509 );
2510 assert_eq!(
2514 counters.failure_invalid_credential, 1,
2515 "bearer verification must run exactly once (only the un-gated first request)"
2516 );
2517 }
2518
2519 #[tokio::test]
2526 async fn pre_auth_gate_does_not_throttle_mtls() {
2527 let config = RateLimitConfig {
2528 max_attempts_per_minute: 100,
2529 pre_auth_max_per_minute: Some(1), max_tracked_keys: default_max_tracked_keys(),
2531 idle_eviction: default_idle_eviction(),
2532 burst: None,
2533 pre_auth_burst: None,
2534 key_eviction_policy: KeyEvictionPolicy::default(),
2535 };
2536 let state = Arc::new(AuthState {
2537 api_keys: ArcSwap::new(Arc::new(vec![])),
2538 rate_limiter: None,
2539 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2540 #[cfg(feature = "oauth")]
2541 jwks_cache: None,
2542 seen_identities: SeenIdentitySet::new(),
2543 counters: AuthCounters::default(),
2544 resource_metadata_url: None,
2545 });
2546 let app = auth_router(Arc::clone(&state));
2547 let peer: SocketAddr = "10.0.0.20:54321".parse().unwrap();
2548 let identity = AuthIdentity {
2549 name: "cn=test-client".into(),
2550 role: "viewer".into(),
2551 method: AuthMethod::MtlsCertificate,
2552 raw_token: None,
2553 sub: None,
2554 };
2555 let tls_info = TlsConnInfo::new(peer, Some(identity));
2556
2557 for i in 0..3 {
2558 let mut req = Request::builder()
2559 .method(axum::http::Method::POST)
2560 .uri("/mcp")
2561 .body(Body::empty())
2562 .unwrap();
2563 req.extensions_mut().insert(ConnectInfo(tls_info.clone()));
2564 let resp = app.clone().oneshot(req).await.unwrap();
2565 assert_eq!(
2566 resp.status(),
2567 StatusCode::OK,
2568 "mTLS request {i} must succeed: pre-auth gate must not apply to mTLS callers"
2569 );
2570 }
2571
2572 let counters = state.counters_snapshot();
2573 assert_eq!(
2574 counters.failure_pre_auth_gate, 0,
2575 "pre-auth gate counter must remain at zero: mTLS bypasses the gate"
2576 );
2577 assert_eq!(
2578 counters.success_mtls, 3,
2579 "all three mTLS requests must have been counted as successful"
2580 );
2581 }
2582
2583 #[cfg(feature = "metrics")]
2586 #[tokio::test]
2587 async fn pre_auth_gate_deny_increments_counter() {
2588 let config = RateLimitConfig {
2589 max_attempts_per_minute: 100,
2590 pre_auth_max_per_minute: Some(1),
2591 max_tracked_keys: default_max_tracked_keys(),
2592 idle_eviction: default_idle_eviction(),
2593 burst: None,
2594 pre_auth_burst: None,
2595 key_eviction_policy: KeyEvictionPolicy::default(),
2596 };
2597 let state = Arc::new(AuthState {
2598 api_keys: ArcSwap::new(Arc::new(vec![])),
2599 rate_limiter: None,
2600 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2601 #[cfg(feature = "oauth")]
2602 jwks_cache: None,
2603 seen_identities: SeenIdentitySet::new(),
2604 counters: AuthCounters::default(),
2605 resource_metadata_url: None,
2606 });
2607 let app = auth_router(Arc::clone(&state));
2608 let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2609 let peer: SocketAddr = "10.0.0.30:54321".parse().expect("addr parses");
2610 let mk = || {
2611 let mut req = Request::builder()
2612 .method(axum::http::Method::POST)
2613 .uri("/mcp")
2614 .header("authorization", "Bearer not-a-real-token")
2615 .body(Body::empty())
2616 .expect("request builds");
2617 req.extensions_mut().insert(ConnectInfo(peer));
2618 req.extensions_mut().insert(Arc::clone(&metrics));
2619 req
2620 };
2621 let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2622
2623 let first = app.clone().oneshot(mk()).await.expect("first request");
2624 assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2625 assert_eq!(counter("auth_pre"), 0, "un-gated request must not count");
2626
2627 let gated = app.oneshot(mk()).await.expect("second request");
2628 assert_eq!(gated.status(), StatusCode::TOO_MANY_REQUESTS);
2629 assert_eq!(counter("auth_pre"), 1, "gated request must count once");
2630 assert_eq!(counter("auth_post"), 0, "post limiter never fired");
2631 }
2632
2633 #[cfg(feature = "metrics")]
2636 #[tokio::test]
2637 async fn post_failure_limiter_deny_increments_counter() {
2638 let config = RateLimitConfig {
2639 max_attempts_per_minute: 1, pre_auth_max_per_minute: None,
2641 max_tracked_keys: default_max_tracked_keys(),
2642 idle_eviction: default_idle_eviction(),
2643 burst: None,
2644 pre_auth_burst: None,
2645 key_eviction_policy: KeyEvictionPolicy::default(),
2646 };
2647 let state = Arc::new(AuthState {
2648 api_keys: ArcSwap::new(Arc::new(vec![])),
2649 rate_limiter: Some(build_rate_limiter(&config)),
2650 pre_auth_limiter: None,
2651 #[cfg(feature = "oauth")]
2652 jwks_cache: None,
2653 seen_identities: SeenIdentitySet::new(),
2654 counters: AuthCounters::default(),
2655 resource_metadata_url: None,
2656 });
2657 let app = auth_router(Arc::clone(&state));
2658 let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2659 let peer: SocketAddr = "10.0.0.31:54321".parse().expect("addr parses");
2660 let mk = || {
2661 let mut req = Request::builder()
2662 .method(axum::http::Method::POST)
2663 .uri("/mcp")
2664 .header("authorization", "Bearer not-a-real-token")
2665 .body(Body::empty())
2666 .expect("request builds");
2667 req.extensions_mut().insert(ConnectInfo(peer));
2668 req.extensions_mut().insert(Arc::clone(&metrics));
2669 req
2670 };
2671 let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2672
2673 let first = app.clone().oneshot(mk()).await.expect("first request");
2675 assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2676 assert_eq!(counter("auth_post"), 0);
2677
2678 let limited = app.oneshot(mk()).await.expect("second request");
2680 assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
2681 assert_eq!(counter("auth_post"), 1, "deny must count once");
2682 assert_eq!(counter("auth_pre"), 0, "pre-auth gate disabled here");
2683 }
2684
2685 #[test]
2690 fn extract_bearer_accepts_canonical_case() {
2691 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2692 }
2693
2694 #[test]
2695 fn extract_bearer_is_case_insensitive_per_rfc7235() {
2696 for header in &[
2700 "bearer abc123",
2701 "BEARER abc123",
2702 "BeArEr abc123",
2703 "bEaReR abc123",
2704 ] {
2705 assert_eq!(
2706 extract_bearer(header),
2707 Some("abc123"),
2708 "header {header:?} must parse as a Bearer token (RFC 7235 §2.1)"
2709 );
2710 }
2711 }
2712
2713 #[test]
2714 fn extract_bearer_rejects_other_schemes() {
2715 assert_eq!(extract_bearer("Basic dXNlcjpwYXNz"), None);
2716 assert_eq!(extract_bearer("Digest username=\"x\""), None);
2717 assert_eq!(extract_bearer("Token abc123"), None);
2718 }
2719
2720 #[test]
2721 fn extract_bearer_rejects_malformed() {
2722 assert_eq!(extract_bearer(""), None);
2724 assert_eq!(extract_bearer("Bearer"), None);
2725 assert_eq!(extract_bearer("Bearer "), None);
2726 assert_eq!(extract_bearer("Bearer "), None);
2727 }
2728
2729 #[test]
2730 fn extract_bearer_tolerates_extra_separator_whitespace() {
2731 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2733 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2734 }
2735
2736 #[test]
2737 fn extract_bearer_rejects_embedded_whitespace() {
2738 assert_eq!(extract_bearer("Bearer abc 123"), None);
2739 assert_eq!(extract_bearer("Bearer abc\t123"), None);
2740 assert_eq!(extract_bearer("Bearer abc123 "), None);
2741 assert_eq!(extract_bearer("Bearer abc123\r\n"), None);
2742 }
2743
2744 #[test]
2745 fn extract_bearer_still_accepts_opaque_non_token68_credentials() {
2746 assert_eq!(
2751 extract_bearer("Bearer aBc!@#$%^&*()"),
2752 Some("aBc!@#$%^&*()")
2753 );
2754 assert_eq!(extract_bearer("Bearer tok{en}|v1"), Some("tok{en}|v1"));
2755 }
2756
2757 #[test]
2758 fn extract_bearer_accepts_generated_key_and_jwt_shapes() {
2759 let (token, _hash) = generate_api_key().unwrap();
2760 let header = format!("Bearer {token}");
2761 assert_eq!(extract_bearer(&header), Some(token.as_str()));
2762
2763 let jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln-_bmF0dXJl";
2764 let jwt_header = format!("Bearer {jwt}");
2765 assert_eq!(extract_bearer(&jwt_header), Some(jwt));
2766 }
2767
2768 #[test]
2774 fn auth_identity_debug_redacts_raw_token() {
2775 let id = AuthIdentity {
2776 name: "alice".into(),
2777 role: "admin".into(),
2778 method: AuthMethod::OAuthJwt,
2779 raw_token: Some(SecretString::from("super-secret-jwt-payload-xyz")),
2780 sub: Some("keycloak-uuid-2f3c8b".into()),
2781 };
2782 let dbg = format!("{id:?}");
2783
2784 assert!(dbg.contains("alice"), "name should be visible: {dbg}");
2786 assert!(dbg.contains("admin"), "role should be visible: {dbg}");
2787 assert!(dbg.contains("OAuthJwt"), "method should be visible: {dbg}");
2788
2789 assert!(
2791 !dbg.contains("super-secret-jwt-payload-xyz"),
2792 "raw_token must be redacted in Debug output: {dbg}"
2793 );
2794 assert!(
2795 !dbg.contains("keycloak-uuid-2f3c8b"),
2796 "sub must be redacted in Debug output: {dbg}"
2797 );
2798 assert!(
2799 dbg.contains("<redacted>"),
2800 "redaction marker missing: {dbg}"
2801 );
2802 }
2803
2804 #[test]
2805 fn auth_identity_debug_marks_absent_secrets() {
2806 let id = AuthIdentity {
2809 name: "viewer-key".into(),
2810 role: "viewer".into(),
2811 method: AuthMethod::BearerToken,
2812 raw_token: None,
2813 sub: None,
2814 };
2815 let dbg = format!("{id:?}");
2816 assert!(
2817 dbg.contains("<none>"),
2818 "absent secrets should be marked: {dbg}"
2819 );
2820 assert!(
2821 !dbg.contains("<redacted>"),
2822 "no <redacted> marker when secrets are absent: {dbg}"
2823 );
2824 }
2825
2826 #[test]
2827 fn api_key_entry_debug_redacts_hash() {
2828 let entry = ApiKeyEntry {
2829 name: "viewer-key".into(),
2830 hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$h4sh3dPa55w0rd".into(),
2832 role: "viewer".into(),
2833 expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
2834 };
2835 let dbg = format!("{entry:?}");
2836
2837 assert!(dbg.contains("viewer-key"));
2839 assert!(dbg.contains("viewer"));
2840 assert!(dbg.contains("2030-01-01T00:00:00+00:00"));
2841
2842 assert!(
2844 !dbg.contains("$argon2id$"),
2845 "argon2 hash leaked into Debug output: {dbg}"
2846 );
2847 assert!(
2848 !dbg.contains("h4sh3dPa55w0rd"),
2849 "hash digest leaked into Debug output: {dbg}"
2850 );
2851 assert!(
2852 dbg.contains("<redacted>"),
2853 "redaction marker missing: {dbg}"
2854 );
2855 }
2856
2857 #[test]
2868 fn auth_failure_class_as_str_exact_strings() {
2869 assert_eq!(
2870 AuthFailureClass::MissingCredential.as_str(),
2871 "missing_credential"
2872 );
2873 assert_eq!(
2874 AuthFailureClass::InvalidCredential.as_str(),
2875 "invalid_credential"
2876 );
2877 assert_eq!(
2878 AuthFailureClass::ExpiredCredential.as_str(),
2879 "expired_credential"
2880 );
2881 assert_eq!(AuthFailureClass::RateLimited.as_str(), "rate_limited");
2882 assert_eq!(AuthFailureClass::PreAuthGate.as_str(), "pre_auth_gate");
2883 }
2884
2885 #[test]
2886 fn auth_failure_class_response_body_exact_strings() {
2887 assert_eq!(
2888 AuthFailureClass::MissingCredential.response_body(),
2889 "unauthorized: missing credential"
2890 );
2891 assert_eq!(
2892 AuthFailureClass::InvalidCredential.response_body(),
2893 "unauthorized: invalid credential"
2894 );
2895 assert_eq!(
2896 AuthFailureClass::ExpiredCredential.response_body(),
2897 "unauthorized: expired credential"
2898 );
2899 assert_eq!(
2900 AuthFailureClass::RateLimited.response_body(),
2901 "rate limited"
2902 );
2903 assert_eq!(
2904 AuthFailureClass::PreAuthGate.response_body(),
2905 "rate limited (pre-auth)"
2906 );
2907 }
2908
2909 #[test]
2910 fn auth_failure_class_bearer_error_exact_strings() {
2911 assert_eq!(
2912 AuthFailureClass::MissingCredential.bearer_error(),
2913 (
2914 "invalid_request",
2915 "missing bearer token or mTLS client certificate"
2916 )
2917 );
2918 assert_eq!(
2919 AuthFailureClass::InvalidCredential.bearer_error(),
2920 ("invalid_token", "token is invalid")
2921 );
2922 assert_eq!(
2923 AuthFailureClass::ExpiredCredential.bearer_error(),
2924 ("invalid_token", "token is expired")
2925 );
2926 assert_eq!(
2927 AuthFailureClass::RateLimited.bearer_error(),
2928 ("invalid_request", "too many failed authentication attempts")
2929 );
2930 assert_eq!(
2931 AuthFailureClass::PreAuthGate.bearer_error(),
2932 (
2933 "invalid_request",
2934 "too many unauthenticated requests from this source"
2935 )
2936 );
2937 }
2938
2939 #[test]
2948 fn auth_config_summary_bearer_true_when_keys_present() {
2949 let (_token, hash) = generate_api_key().unwrap();
2950 let cfg = AuthConfig::with_keys(vec![ApiKeyEntry::new("k", hash, "viewer")]);
2951 let s = cfg.summary();
2952 assert!(s.enabled, "summary.enabled must reflect AuthConfig.enabled");
2953 assert!(
2954 s.bearer,
2955 "summary.bearer must be true when api_keys is non-empty (kills `!` deletion at L615)"
2956 );
2957 assert!(!s.mtls, "summary.mtls must be false when mtls is None");
2958 assert!(!s.oauth, "summary.oauth must be false when oauth is None");
2959 assert_eq!(s.api_keys.len(), 1);
2960 assert_eq!(s.api_keys[0].name, "k");
2961 assert_eq!(s.api_keys[0].role, "viewer");
2962 }
2963
2964 #[test]
2965 fn auth_config_summary_bearer_false_when_no_keys() {
2966 let cfg = AuthConfig::with_keys(vec![]);
2967 let s = cfg.summary();
2968 assert!(
2969 !s.bearer,
2970 "summary.bearer must be false when api_keys is empty (kills `!` deletion at L615)"
2971 );
2972 assert!(s.api_keys.is_empty());
2973 }
2974
2975 #[test]
2976 fn seen_identity_set_first_then_repeat() {
2977 let set = SeenIdentitySet::new();
2978 assert!(set.insert_is_first("alice"), "first sighting is first");
2979 assert!(
2980 !set.insert_is_first("alice"),
2981 "second sighting is not first"
2982 );
2983 assert!(set.insert_is_first("bob"));
2984 assert_eq!(set.len(), 2);
2985 }
2986
2987 #[test]
2988 fn seen_identity_set_evicts_oldest_at_cap() {
2989 let set = SeenIdentitySet::with_cap(2);
2990 assert!(set.insert_is_first("a"));
2991 assert!(set.insert_is_first("b"));
2992 assert!(set.insert_is_first("c"));
2994 assert_eq!(set.len(), 2);
2995 assert!(set.insert_is_first("a"));
2999 assert_eq!(set.len(), 2);
3000 assert!(set.insert_is_first("b"));
3002 for i in 0..32 {
3004 set.insert_is_first(&format!("churn-{i}"));
3005 assert!(set.len() <= 2, "cap invariant must hold");
3006 }
3007 }
3008
3009 #[test]
3010 fn seen_identity_set_cap_zero_is_raised_to_one() {
3011 let set = SeenIdentitySet::with_cap(0);
3012 assert!(set.insert_is_first("only"));
3013 assert_eq!(set.len(), 1);
3014 assert!(set.insert_is_first("next"));
3016 assert_eq!(set.len(), 1);
3017 }
3018
3019 #[test]
3020 fn seen_identity_set_fifo_does_not_refresh_on_repeat_hit() {
3021 let set = SeenIdentitySet::with_cap(2);
3024 assert!(set.insert_is_first("a")); assert!(set.insert_is_first("b")); assert!(!set.insert_is_first("a"));
3030 assert!(set.insert_is_first("c"));
3033 assert!(set.insert_is_first("a"));
3035 let set = SeenIdentitySet::with_cap(2);
3041 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!(
3046 !set.insert_is_first("y"),
3047 "y must still be present (FIFO did not evict it)"
3048 );
3049 assert!(
3050 set.insert_is_first("x"),
3051 "x must have been evicted by FIFO (would NOT have been evicted under LRU)"
3052 );
3053 }
3054}