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
766pub(crate) fn check_api_key_names(keys: &[ApiKeyEntry]) -> Result<(), RmcpServerKitError> {
774 for (index, key) in keys.iter().enumerate() {
775 if key.name.trim().is_empty() {
776 return Err(RmcpServerKitError::Config(format!(
777 "auth.api_keys[{index}] has a blank name; each API-key name must be \
778 non-empty and not whitespace-only (it is the session-binding identity)"
779 )));
780 }
781 }
782 Ok(())
783}
784
785impl AuthConfig {
786 #[must_use]
788 pub fn with_keys(keys: Vec<ApiKeyEntry>) -> Self {
789 Self {
790 enabled: true,
791 api_keys: keys,
792 mtls: None,
793 rate_limit: None,
794 #[cfg(feature = "oauth")]
795 oauth: None,
796 #[cfg(not(feature = "oauth"))]
797 oauth: None,
798 }
799 }
800
801 #[must_use]
803 pub fn with_rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
804 self.rate_limit = Some(rate_limit);
805 self
806 }
807
808 pub fn check_oauth_feature(&self) -> Result<(), RmcpServerKitError> {
822 #[cfg(not(feature = "oauth"))]
823 {
824 (self.oauth.is_none()).ok_or_else(|| {
825 RmcpServerKitError::Config(
826 "auth.oauth is configured but this build of rmcp-server-kit was compiled \
827 without the `oauth` cargo feature; rebuild with `--features oauth` or \
828 remove the [auth.oauth] table"
829 .into(),
830 )
831 })?;
832 }
833 Ok(())
834 }
835
836 pub fn validate_api_key_names(&self) -> Result<(), RmcpServerKitError> {
849 check_api_key_names(&self.api_keys)
850 }
851}
852
853#[derive(Debug, Clone, serde::Serialize)]
857#[non_exhaustive]
858pub struct ApiKeySummary {
859 pub name: String,
861 pub role: String,
863 pub expires_at: Option<RfcTimestamp>,
866}
867
868#[derive(Debug, Clone, serde::Serialize)]
870#[allow(
871 clippy::struct_excessive_bools,
872 reason = "this is a flat summary of independent auth-method booleans"
873)]
874#[non_exhaustive]
875pub struct AuthConfigSummary {
876 pub enabled: bool,
878 pub bearer: bool,
880 pub mtls: bool,
882 pub oauth: bool,
884 pub api_keys: Vec<ApiKeySummary>,
886}
887
888impl AuthConfig {
889 #[must_use]
891 pub fn summary(&self) -> AuthConfigSummary {
892 AuthConfigSummary {
893 enabled: self.enabled,
894 bearer: !self.api_keys.is_empty(),
895 mtls: self.mtls.is_some(),
896 #[cfg(feature = "oauth")]
897 oauth: self.oauth.is_some(),
898 #[cfg(not(feature = "oauth"))]
899 oauth: false,
900 api_keys: self
901 .api_keys
902 .iter()
903 .map(|k| ApiKeySummary {
904 name: k.name.clone(),
905 role: k.role.clone(),
906 expires_at: k.expires_at,
907 })
908 .collect(),
909 }
910 }
911}
912
913pub(crate) type KeyedLimiter = BoundedKeyedLimiter<RateLimitKey>;
916
917#[derive(Clone, Debug)]
927#[non_exhaustive]
928pub(crate) struct TlsConnInfo {
929 pub addr: SocketAddr,
931 pub identity: Option<AuthIdentity>,
934}
935
936impl TlsConnInfo {
937 #[must_use]
939 pub(crate) const fn new(addr: SocketAddr, identity: Option<AuthIdentity>) -> Self {
940 Self { addr, identity }
941 }
942}
943
944const DEFAULT_SEEN_IDENTITY_CAP: usize = 4096;
952
953pub(crate) struct SeenIdentitySet {
973 inner: Mutex<SeenInner>,
974}
975
976struct SeenInner {
977 set: HashSet<String>,
978 order: std::collections::VecDeque<String>,
983 cap: usize,
984}
985
986impl SeenIdentitySet {
987 #[must_use]
989 pub(crate) fn new() -> Self {
990 Self::with_cap(DEFAULT_SEEN_IDENTITY_CAP)
991 }
992
993 #[must_use]
996 pub(crate) fn with_cap(cap: usize) -> Self {
997 let cap = cap.max(1);
998 Self {
999 inner: Mutex::new(SeenInner {
1000 set: HashSet::with_capacity(cap.min(64)),
1001 order: std::collections::VecDeque::with_capacity(cap.min(64)),
1002 cap,
1003 }),
1004 }
1005 }
1006
1007 pub(crate) fn insert_is_first(&self, name: &str) -> bool {
1014 let mut guard = self
1020 .inner
1021 .lock()
1022 .unwrap_or_else(std::sync::PoisonError::into_inner);
1023
1024 if guard.set.contains(name) {
1025 return false;
1026 }
1027 if guard.set.len() >= guard.cap
1030 && let Some(evicted) = guard.order.pop_front()
1031 {
1032 guard.set.remove(&evicted);
1033 }
1034 let owned = name.to_owned();
1035 guard.set.insert(owned.clone());
1036 guard.order.push_back(owned);
1037 true
1038 }
1039
1040 #[cfg(test)]
1042 pub(crate) fn len(&self) -> usize {
1043 self.inner
1044 .lock()
1045 .unwrap_or_else(std::sync::PoisonError::into_inner)
1046 .set
1047 .len()
1048 }
1049}
1050
1051impl Default for SeenIdentitySet {
1052 fn default() -> Self {
1053 Self::new()
1054 }
1055}
1056
1057#[allow(
1062 missing_debug_implementations,
1063 reason = "contains governor RateLimiter and JwksCache without Debug impls"
1064)]
1065#[non_exhaustive]
1066pub(crate) struct AuthState {
1067 pub api_keys: ArcSwap<Vec<ApiKeyEntry>>,
1069 pub rate_limiter: Option<Arc<KeyedLimiter>>,
1071 pub pre_auth_limiter: Option<Arc<KeyedLimiter>>,
1074 #[cfg(feature = "oauth")]
1075 pub jwks_cache: Option<Arc<crate::oauth::JwksCache>>,
1077 pub seen_identities: SeenIdentitySet,
1082 pub counters: AuthCounters,
1084 pub resource_metadata_url: Option<String>,
1092}
1093
1094impl AuthState {
1095 pub(crate) fn try_reload_keys(&self, keys: Vec<ApiKeyEntry>) -> Result<(), RmcpServerKitError> {
1106 check_api_key_names(&keys)?;
1107 self.reload_keys_unchecked(keys);
1108 Ok(())
1109 }
1110
1111 fn reload_keys_unchecked(&self, keys: Vec<ApiKeyEntry>) {
1123 let count = keys.len();
1124 self.api_keys.store(Arc::new(keys));
1125 tracing::info!(keys = count, "API keys reloaded");
1126 }
1127
1128 #[must_use]
1130 pub(crate) fn counters_snapshot(&self) -> AuthCountersSnapshot {
1131 self.counters.snapshot()
1132 }
1133
1134 #[must_use]
1136 pub(crate) fn api_key_summaries(&self) -> Vec<ApiKeySummary> {
1137 self.api_keys
1138 .load()
1139 .iter()
1140 .map(|k| ApiKeySummary {
1141 name: k.name.clone(),
1142 role: k.role.clone(),
1143 expires_at: k.expires_at,
1144 })
1145 .collect()
1146 }
1147
1148 fn log_auth(&self, id: &AuthIdentity, method: &str) {
1156 self.counters.record_success(id.method);
1157 let first = self.seen_identities.insert_is_first(&id.name);
1158 if first {
1159 tracing::info!(name = %id.name, role = %id.role, "{method} authenticated");
1160 } else {
1161 tracing::debug!(name = %id.name, role = %id.role, "{method} authenticated");
1162 }
1163 }
1164}
1165
1166const DEFAULT_AUTH_RATE: NonZeroU32 = NonZeroU32::new(30).unwrap();
1169
1170fn apply_burst(quota: governor::Quota, burst: Option<u32>) -> governor::Quota {
1174 match burst.and_then(NonZeroU32::new) {
1175 Some(b) => quota.allow_burst(b),
1176 None => quota,
1177 }
1178}
1179
1180#[must_use]
1182pub(crate) fn build_rate_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1183 let quota = governor::Quota::per_minute(
1188 NonZeroU32::new(config.max_attempts_per_minute).unwrap_or(DEFAULT_AUTH_RATE),
1189 );
1190 let quota = apply_burst(quota, config.burst);
1191 let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1194 Arc::new(BoundedKeyedLimiter::new_with_policy(
1195 quota,
1196 max_tracked_keys,
1197 config.idle_eviction,
1198 config.key_eviction_policy,
1199 ))
1200}
1201
1202#[must_use]
1209pub(crate) fn build_pre_auth_limiter(config: &RateLimitConfig) -> Arc<KeyedLimiter> {
1210 let resolved = config.pre_auth_max_per_minute.unwrap_or_else(|| {
1211 config
1212 .max_attempts_per_minute
1213 .saturating_mul(PRE_AUTH_DEFAULT_MULTIPLIER)
1214 });
1215 let quota =
1216 governor::Quota::per_minute(NonZeroU32::new(resolved).unwrap_or(DEFAULT_PRE_AUTH_RATE));
1217 let quota = apply_burst(quota, config.pre_auth_burst);
1218 let max_tracked_keys = NonZeroUsize::new(config.max_tracked_keys).unwrap_or(NonZeroUsize::MIN);
1221 Arc::new(BoundedKeyedLimiter::new_with_policy(
1222 quota,
1223 max_tracked_keys,
1224 config.idle_eviction,
1225 config.key_eviction_policy,
1226 ))
1227}
1228
1229const PRE_AUTH_DEFAULT_MULTIPLIER: u32 = 10;
1232
1233const DEFAULT_PRE_AUTH_RATE: NonZeroU32 = NonZeroU32::new(300).unwrap();
1237
1238#[must_use]
1246pub fn extract_mtls_identity(cert_der: &[u8], default_role: &str) -> Option<AuthIdentity> {
1247 let (_, cert) = X509Certificate::from_der(cert_der).ok()?;
1248
1249 let cn = cert
1253 .subject()
1254 .iter_common_name()
1255 .filter_map(|attr| attr.as_str().ok())
1256 .find(|value| !value.trim().is_empty())
1257 .map(String::from);
1258
1259 let name = cn.or_else(|| {
1260 cert.subject_alternative_name()
1261 .ok()
1262 .flatten()
1263 .and_then(|san| {
1264 #[allow(
1265 clippy::wildcard_enum_match_arm,
1266 reason = "x509-parser GeneralName is a large external enum; only DNSName is meaningful here"
1267 )]
1268 san.value.general_names.iter().find_map(|gn| match gn {
1269 GeneralName::DNSName(dns) if !dns.trim().is_empty() => Some((*dns).to_owned()),
1270 _ => None,
1271 })
1272 })
1273 });
1274
1275 let Some(name) = name else {
1276 tracing::warn!("mTLS identity rejected: no non-blank CN or DNS SAN present");
1277 return None;
1278 };
1279
1280 if !name
1282 .chars()
1283 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '.' | '_' | '@'))
1284 {
1285 tracing::warn!(cn = %name, "mTLS identity rejected: invalid characters in CN/SAN");
1286 return None;
1287 }
1288
1289 Some(AuthIdentity {
1290 name,
1291 role: default_role.to_owned(),
1292 method: AuthMethod::MtlsCertificate,
1293 raw_token: None,
1294 sub: None,
1295 })
1296}
1297
1298fn extract_bearer(value: &str) -> Option<&str> {
1327 let (scheme, rest) = value.split_once(' ')?;
1328 if !scheme.eq_ignore_ascii_case("Bearer") {
1329 return None;
1330 }
1331 let token = rest.trim_start_matches(' ');
1332 if token.is_empty() || token.bytes().any(|b| b.is_ascii_whitespace()) {
1333 return None;
1334 }
1335 Some(token)
1336}
1337
1338#[must_use]
1375pub fn verify_bearer_token(token: &str, keys: &[ApiKeyEntry]) -> Option<AuthIdentity> {
1376 use subtle::ConstantTimeEq as _;
1377
1378 let now = chrono::Utc::now();
1379 #[allow(
1380 clippy::expect_used,
1381 reason = "DUMMY_PHC_HASH is a static LazyLock built from a fixed Argon2id PHC string by construction; PasswordHash::new on it is infallible. See DUMMY_PHC_HASH definition."
1382 )]
1383 let dummy_hash = PasswordHash::new(&DUMMY_PHC_HASH)
1384 .expect("DUMMY_PHC_HASH is a valid Argon2id PHC string by construction");
1385
1386 let mut matched_index: usize = usize::MAX;
1387 let mut any_match: u8 = 0;
1388
1389 for (idx, key) in keys.iter().enumerate() {
1390 let expired = key.expires_at.is_some_and(|exp| exp.as_datetime() < &now);
1391
1392 let real_hash = PasswordHash::new(&key.hash);
1393 let verify_against = match (&real_hash, expired, any_match) {
1394 (Ok(h), false, 0) => h,
1395 _ => &dummy_hash,
1396 };
1397
1398 let slot_ok = u8::from(
1399 Argon2::default()
1400 .verify_password(token.as_bytes(), verify_against)
1401 .is_ok(),
1402 );
1403
1404 let real_match = slot_ok & u8::from(!expired) & u8::from(real_hash.is_ok());
1405 let first_real_match = real_match & (1 - any_match);
1406 if first_real_match.ct_eq(&1).into() {
1407 matched_index = idx;
1408 }
1409 any_match |= real_match;
1410 }
1411
1412 if any_match == 0 {
1413 return None;
1414 }
1415 let key = keys.get(matched_index)?;
1416 if key.name.trim().is_empty() {
1421 tracing::warn!("bearer token rejected: matched API key has a blank name");
1422 return None;
1423 }
1424 Some(AuthIdentity {
1425 name: key.name.clone(),
1426 role: key.role.clone(),
1427 method: AuthMethod::BearerToken,
1428 raw_token: None,
1429 sub: None,
1430 })
1431}
1432
1433static DUMMY_PHC_HASH: LazyLock<String> = LazyLock::new(|| {
1447 #[allow(
1448 clippy::expect_used,
1449 reason = "Argon2::default() over a fixed plaintext and a fixed 16-byte salt is infallible; it fails only on invalid params or salt length, both constants here"
1450 )]
1451 Argon2::default()
1452 .hash_password_with_salt(b"rmcp-server-kit-dummy", &[0u8; 16])
1453 .expect("Argon2 default params hash a fixed plaintext")
1454 .to_string()
1455});
1456
1457pub fn generate_api_key() -> Result<(String, String), RmcpServerKitError> {
1467 let mut token_bytes = [0u8; 32];
1468 rand::fill(&mut token_bytes);
1469 let token = URL_SAFE_NO_PAD.encode(token_bytes);
1470
1471 let mut salt_bytes = [0u8; 16];
1472 rand::fill(&mut salt_bytes);
1473 let hash = Argon2::default()
1474 .hash_password_with_salt(token.as_bytes(), &salt_bytes)
1475 .map_err(|e| RmcpServerKitError::Internal(format!("argon2id hashing failed: {e}")))?
1476 .to_string();
1477
1478 Ok((token, hash))
1479}
1480
1481fn build_www_authenticate_value(
1482 resource_metadata: Option<&str>,
1483 failure: AuthFailureClass,
1484) -> String {
1485 let (error, error_description) = failure.bearer_error();
1486 if let Some(url) = resource_metadata {
1487 return format!(
1488 "Bearer resource_metadata=\"{url}\", error=\"{error}\", error_description=\"{error_description}\""
1489 );
1490 }
1491 format!("Bearer error=\"{error}\", error_description=\"{error_description}\"")
1492}
1493
1494fn auth_method_label(method: AuthMethod) -> &'static str {
1495 match method {
1496 AuthMethod::MtlsCertificate => "mTLS",
1497 AuthMethod::BearerToken => "bearer token",
1498 AuthMethod::OAuthJwt => "OAuth JWT",
1499 }
1500}
1501
1502#[cfg_attr(
1503 not(feature = "oauth"),
1504 allow(
1505 unused_variables,
1506 reason = "`state` is only read to decide whether to advertise OAuth \
1507 protected-resource metadata; without the `oauth` feature that \
1508 decision is a compile-time `false`"
1509 )
1510)]
1511fn unauthorized_response(state: &AuthState, failure_class: AuthFailureClass) -> Response {
1512 #[cfg(feature = "oauth")]
1513 let advertise_resource_metadata = state.jwks_cache.is_some();
1514 #[cfg(not(feature = "oauth"))]
1515 let advertise_resource_metadata = false;
1516
1517 let resource_metadata = advertise_resource_metadata.then(|| {
1518 state
1519 .resource_metadata_url
1520 .as_deref()
1521 .unwrap_or("/.well-known/oauth-protected-resource")
1522 });
1523 let challenge = build_www_authenticate_value(resource_metadata, failure_class);
1524 (
1525 StatusCode::UNAUTHORIZED,
1526 [(header::WWW_AUTHENTICATE, challenge)],
1527 failure_class.response_body(),
1528 )
1529 .into_response()
1530}
1531
1532async fn authenticate_bearer_identity(
1538 state: &AuthState,
1539 token: &str,
1540) -> Result<AuthIdentity, AuthFailureClass> {
1541 let mut failure_class = AuthFailureClass::MissingCredential;
1542
1543 #[cfg(feature = "oauth")]
1544 if let Some(ref cache) = state.jwks_cache
1545 && crate::oauth::looks_like_jwt(token)
1546 {
1547 match cache.validate_token_with_reason(token).await {
1548 Ok(mut id) => {
1549 id.raw_token = Some(SecretString::from(token.to_owned()));
1550 return Ok(id);
1551 }
1552 Err(crate::oauth::JwtValidationFailure::Expired) => {
1553 failure_class = AuthFailureClass::ExpiredCredential;
1554 }
1555 Err(crate::oauth::JwtValidationFailure::Invalid) => {
1556 failure_class = AuthFailureClass::InvalidCredential;
1557 }
1558 }
1559 }
1560
1561 let token = token.to_owned();
1562 let keys = state.api_keys.load_full(); let identity = tokio::task::spawn_blocking(move || verify_bearer_token(&token, &keys))
1566 .await
1567 .ok()
1568 .flatten();
1569
1570 if let Some(id) = identity {
1571 return Ok(id);
1572 }
1573
1574 if failure_class == AuthFailureClass::MissingCredential {
1575 failure_class = AuthFailureClass::InvalidCredential;
1576 }
1577
1578 Err(failure_class)
1579}
1580
1581fn pre_auth_gate(state: &AuthState, client_key: Option<&RateLimitKey>) -> Option<Response> {
1592 let limiter = state.pre_auth_limiter.as_ref()?;
1593 let key = client_key?;
1594 match limiter.check_key_detailed(key) {
1595 Ok(()) => None,
1596 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1597 state.counters.record_failure(AuthFailureClass::PreAuthGate);
1598 tracing::warn!(
1599 rate_limit_key = %key,
1600 "auth rate limited by pre-auth gate (request rejected before credential verification)"
1601 );
1602 Some(
1603 RmcpServerKitError::RateLimitedFor {
1604 message: "too many unauthenticated requests from this source".into(),
1605 retry_after: wait,
1606 }
1607 .into_response(),
1608 )
1609 }
1610 Err(BoundedLimiterDeny::CapacityFull) => {
1611 tracing::warn!(
1612 rate_limit_key = %key,
1613 "auth pre-auth gate rejected unseen key because tracked-key capacity is full"
1614 );
1615 Some(
1616 (
1617 StatusCode::SERVICE_UNAVAILABLE,
1618 "rate limiter capacity exhausted",
1619 )
1620 .into_response(),
1621 )
1622 }
1623 }
1624}
1625
1626#[cfg_attr(
1627 not(feature = "metrics"),
1628 allow(
1629 unused_variables,
1630 reason = "`extensions` is read only to record the \
1631 `rmcp_server_kit_rate_limited_total` metric; without the \
1632 `metrics` feature there is no recording site"
1633 )
1634)]
1635fn post_failure_rate_limit_response(
1636 limiter: &KeyedLimiter,
1637 key: &RateLimitKey,
1638 extensions: &axum::http::Extensions,
1639) -> Option<Response> {
1640 match limiter.check_key_detailed(key) {
1641 Ok(()) => None,
1642 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1643 #[cfg(feature = "metrics")]
1644 crate::metrics::record_rate_limit_deny(extensions, "auth_post");
1645 tracing::warn!(rate_limit_key = %key, "auth rate limited after repeated failures");
1646 Some(
1647 RmcpServerKitError::RateLimitedFor {
1648 message: "too many failed authentication attempts".into(),
1649 retry_after: wait,
1650 }
1651 .into_response(),
1652 )
1653 }
1654 Err(BoundedLimiterDeny::CapacityFull) => {
1655 tracing::warn!(
1656 rate_limit_key = %key,
1657 "auth post-failure limiter rejected unseen key because tracked-key capacity is full"
1658 );
1659 Some(
1660 (
1661 StatusCode::SERVICE_UNAVAILABLE,
1662 "rate limiter capacity exhausted",
1663 )
1664 .into_response(),
1665 )
1666 }
1667 }
1668}
1669
1670pub(crate) async fn auth_middleware(
1682 state: Arc<AuthState>,
1683 req: Request<Body>,
1684 next: Next,
1685) -> Response {
1686 let tls_info = req.extensions().get::<ConnectInfo<TlsConnInfo>>().cloned();
1692 let client_key = (state.pre_auth_limiter.is_some() || state.rate_limiter.is_some())
1695 .then(|| crate::transport::limiter_client_key(req.extensions()));
1696
1697 if let Some(id) = tls_info.and_then(|ci| ci.0.identity) {
1704 state.log_auth(&id, "mTLS");
1705 let mut req = req;
1706 req.extensions_mut().insert(id);
1707 return next.run(req).await;
1708 }
1709
1710 if let Some(blocked) = pre_auth_gate(&state, client_key.as_ref()) {
1714 #[cfg(feature = "metrics")]
1715 crate::metrics::record_rate_limit_deny(req.extensions(), "auth_pre");
1716 return blocked;
1717 }
1718
1719 let failure_class = if let Some(value) = req.headers().get(header::AUTHORIZATION) {
1720 match value.to_str().ok().and_then(extract_bearer) {
1721 Some(token) => match authenticate_bearer_identity(&state, token).await {
1722 Ok(id) => {
1723 state.log_auth(&id, auth_method_label(id.method));
1724 let mut req = req;
1725 req.extensions_mut().insert(id);
1726 return next.run(req).await;
1727 }
1728 Err(class) => class,
1729 },
1730 None => AuthFailureClass::InvalidCredential,
1731 }
1732 } else {
1733 AuthFailureClass::MissingCredential
1734 };
1735
1736 tracing::warn!(failure_class = %failure_class.as_str(), "auth failed");
1737
1738 if let (Some(limiter), Some(key)) = (&state.rate_limiter, client_key.as_ref())
1741 && let Some(resp) = post_failure_rate_limit_response(limiter, key, req.extensions())
1742 {
1743 if resp.status() == StatusCode::TOO_MANY_REQUESTS {
1744 state.counters.record_failure(AuthFailureClass::RateLimited);
1745 }
1746 return resp;
1747 }
1748
1749 state.counters.record_failure(failure_class);
1750 unauthorized_response(&state, failure_class)
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755 use std::net::IpAddr;
1756
1757 use super::*;
1758 use crate::transport::RateLimitKey;
1759
1760 const ARGON2_0_5_TOKEN: &str = "golden-vector-token-0p5p3";
1767 const ARGON2_0_5_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$BwcHBwcHBwcHBwcHBwcHBw$spS8B9AhHG1LikfhGlssVMfP8mq37+8/mXnl98ps0NU";
1768
1769 #[test]
1770 fn argon2_0_5_produced_hash_still_verifies() {
1771 let parsed =
1772 PasswordHash::new(ARGON2_0_5_HASH).expect("a 0.5-era PHC string must still parse");
1773 Argon2::default()
1774 .verify_password(ARGON2_0_5_TOKEN.as_bytes(), &parsed)
1775 .expect("already-deployed API keys must keep verifying across the argon2 upgrade");
1776 }
1777
1778 #[test]
1786 fn dummy_and_real_hashes_share_cost_parameters() {
1787 let (_token, real_hash) = generate_api_key().expect("key generation must succeed");
1788 let real = PasswordHash::new(&real_hash).expect("generated hash must parse");
1789 let dummy = PasswordHash::new(&DUMMY_PHC_HASH).expect("dummy hash must parse");
1790
1791 assert_eq!(dummy.algorithm, real.algorithm, "algorithm must match");
1792 assert_eq!(dummy.version, real.version, "PHC version must match");
1793 assert_eq!(
1794 dummy.params, real.params,
1795 "m/t/p must match or the dummy no longer costs what a real verification costs"
1796 );
1797 }
1798
1799 #[test]
1800 fn generate_and_verify_api_key() {
1801 let (token, hash) = generate_api_key().unwrap();
1802
1803 assert_eq!(token.len(), 43);
1805
1806 assert!(hash.starts_with("$argon2id$"));
1808
1809 let keys = vec![ApiKeyEntry {
1811 name: "test".into(),
1812 hash,
1813 role: "viewer".into(),
1814 expires_at: None,
1815 }];
1816 let id = verify_bearer_token(&token, &keys);
1817 assert!(id.is_some());
1818 let id = id.unwrap();
1819 assert_eq!(id.name, "test");
1820 assert_eq!(id.role, "viewer");
1821 assert_eq!(id.method, AuthMethod::BearerToken);
1822 }
1823
1824 #[test]
1825 fn wrong_token_rejected() {
1826 let (_token, hash) = generate_api_key().unwrap();
1827 let keys = vec![ApiKeyEntry {
1828 name: "test".into(),
1829 hash,
1830 role: "viewer".into(),
1831 expires_at: None,
1832 }];
1833 assert!(verify_bearer_token("wrong-token", &keys).is_none());
1834 }
1835
1836 #[test]
1837 fn expired_key_rejected() {
1838 let (token, hash) = generate_api_key().unwrap();
1839 let keys = vec![ApiKeyEntry {
1840 name: "test".into(),
1841 hash,
1842 role: "viewer".into(),
1843 expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1844 }];
1845 assert!(verify_bearer_token(&token, &keys).is_none());
1846 }
1847
1848 #[test]
1849 fn match_in_last_slot_still_authenticates() {
1850 let (token, hash) = generate_api_key().unwrap();
1851 let (_other_token, other_hash) = generate_api_key().unwrap();
1852 let keys = vec![
1853 ApiKeyEntry {
1854 name: "first".into(),
1855 hash: other_hash.clone(),
1856 role: "viewer".into(),
1857 expires_at: None,
1858 },
1859 ApiKeyEntry {
1860 name: "second".into(),
1861 hash: other_hash,
1862 role: "viewer".into(),
1863 expires_at: None,
1864 },
1865 ApiKeyEntry {
1866 name: "match".into(),
1867 hash,
1868 role: "ops".into(),
1869 expires_at: None,
1870 },
1871 ];
1872 let id = verify_bearer_token(&token, &keys).expect("last-slot match must authenticate");
1873 assert_eq!(id.name, "match");
1874 assert_eq!(id.role, "ops");
1875 }
1876
1877 #[test]
1878 fn expired_slot_before_valid_match_does_not_short_circuit() {
1879 let (token, hash) = generate_api_key().unwrap();
1880 let (_, other_hash) = generate_api_key().unwrap();
1881 let keys = vec![
1882 ApiKeyEntry {
1883 name: "expired".into(),
1884 hash: other_hash,
1885 role: "viewer".into(),
1886 expires_at: Some(RfcTimestamp::parse("2020-01-01T00:00:00Z").unwrap()),
1887 },
1888 ApiKeyEntry {
1889 name: "valid".into(),
1890 hash,
1891 role: "ops".into(),
1892 expires_at: None,
1893 },
1894 ];
1895 let id = verify_bearer_token(&token, &keys)
1896 .expect("valid slot following an expired slot must authenticate");
1897 assert_eq!(id.name, "valid");
1898 }
1899
1900 #[test]
1901 fn malformed_hash_slot_does_not_short_circuit() {
1902 let (token, hash) = generate_api_key().unwrap();
1903 let keys = vec![
1904 ApiKeyEntry {
1905 name: "broken".into(),
1906 hash: "this-is-not-a-phc-string".into(),
1907 role: "viewer".into(),
1908 expires_at: None,
1909 },
1910 ApiKeyEntry {
1911 name: "valid".into(),
1912 hash,
1913 role: "ops".into(),
1914 expires_at: None,
1915 },
1916 ];
1917 let id = verify_bearer_token(&token, &keys)
1918 .expect("valid slot following a malformed-hash slot must authenticate");
1919 assert_eq!(id.name, "valid");
1920 }
1921
1922 #[test]
1933 fn rfc_timestamp_parse_rejects_malformed() {
1934 for bad in [
1935 "not-a-date",
1936 "",
1937 "2025-13-01T00:00:00Z", "2025-01-32T00:00:00Z", "2025-01-01T00:00:00", "01/01/2025", "2025-01-01T25:00:00Z", ] {
1943 assert!(
1944 RfcTimestamp::parse(bad).is_err(),
1945 "RfcTimestamp::parse must reject {bad:?}"
1946 );
1947 }
1948 }
1949
1950 #[test]
1951 fn rfc_timestamp_parse_accepts_valid() {
1952 for good in [
1953 "2025-01-01T00:00:00Z",
1954 "2025-01-01T00:00:00+00:00",
1955 "2025-12-31T23:59:59-08:00",
1956 "2099-01-01T00:00:00.123456789Z",
1957 ] {
1958 assert!(
1959 RfcTimestamp::parse(good).is_ok(),
1960 "RfcTimestamp::parse must accept {good:?}"
1961 );
1962 }
1963 }
1964
1965 #[test]
1966 fn api_key_entry_deserialize_rejects_malformed_expires_at() {
1967 let toml = r#"
1972 name = "bad-key"
1973 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1974 role = "viewer"
1975 expires_at = "not-a-date"
1976 "#;
1977 let result: Result<ApiKeyEntry, _> = toml::from_str(toml);
1978 assert!(
1979 result.is_err(),
1980 "deserialization must reject malformed expires_at"
1981 );
1982 }
1983
1984 #[test]
1985 fn api_key_entry_deserialize_accepts_valid_expires_at() {
1986 let toml = r#"
1987 name = "good-key"
1988 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
1989 role = "viewer"
1990 expires_at = "2099-01-01T00:00:00Z"
1991 "#;
1992 let entry: ApiKeyEntry = toml::from_str(toml).expect("valid RFC 3339 must deserialize");
1993 assert!(entry.expires_at.is_some());
1994 }
1995
1996 #[test]
1997 fn api_key_entry_deserialize_accepts_missing_expires_at() {
1998 let toml = r#"
2001 name = "eternal-key"
2002 hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$h4sh"
2003 role = "viewer"
2004 "#;
2005 let entry: ApiKeyEntry = toml::from_str(toml).expect("missing expires_at must deserialize");
2006 assert!(entry.expires_at.is_none());
2007 }
2008
2009 #[test]
2010 fn mtls_crl_deny_on_unavailable_defaults_to_fail_closed() {
2011 let toml = r#"
2015 ca_cert_path = "/etc/certs/clients-ca.pem"
2016 "#;
2017 let cfg: MtlsConfig = toml::from_str(toml).expect("minimal mtls config must deserialize");
2018 assert!(
2019 cfg.crl_deny_on_unavailable,
2020 "omitting crl_deny_on_unavailable must fail closed (RFC 5280 6.3)"
2021 );
2022 }
2023
2024 #[test]
2025 fn mtls_crl_deny_on_unavailable_opt_out_is_honoured() {
2026 let toml = r#"
2027 ca_cert_path = "/etc/certs/clients-ca.pem"
2028 crl_deny_on_unavailable = false
2029 "#;
2030 let cfg: MtlsConfig = toml::from_str(toml).expect("opt-out config must deserialize");
2031 assert!(
2032 !cfg.crl_deny_on_unavailable,
2033 "an explicit false must still select fail-open"
2034 );
2035 }
2036
2037 #[test]
2038 fn try_with_expiry_rejects_malformed() {
2039 let entry = ApiKeyEntry::new("k", "hash", "viewer");
2040 assert!(entry.try_with_expiry("not-a-date").is_err());
2041 }
2042
2043 #[test]
2044 fn try_with_expiry_accepts_valid() {
2045 let entry = ApiKeyEntry::new("k", "hash", "viewer")
2046 .try_with_expiry("2099-01-01T00:00:00Z")
2047 .expect("valid RFC 3339 must be accepted");
2048 assert!(entry.expires_at.is_some());
2049 }
2050
2051 #[test]
2052 fn api_key_summary_serializes_expires_at_as_rfc3339() {
2053 let summary = ApiKeySummary {
2058 name: "k".into(),
2059 role: "viewer".into(),
2060 expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
2061 };
2062 let json = serde_json::to_string(&summary).unwrap();
2063 assert!(
2064 json.contains(r#""expires_at":"2030-01-01T00:00:00+00:00""#),
2065 "wire format regressed: {json}"
2066 );
2067 }
2068
2069 #[test]
2070 fn future_expiry_accepted() {
2071 let (token, hash) = generate_api_key().unwrap();
2072 let keys = vec![ApiKeyEntry {
2073 name: "test".into(),
2074 hash,
2075 role: "viewer".into(),
2076 expires_at: Some(RfcTimestamp::parse("2099-01-01T00:00:00Z").unwrap()),
2077 }];
2078 assert!(verify_bearer_token(&token, &keys).is_some());
2079 }
2080
2081 #[test]
2082 fn multiple_keys_first_match_wins() {
2083 let (token, hash) = generate_api_key().unwrap();
2084 let keys = vec![
2085 ApiKeyEntry {
2086 name: "wrong".into(),
2087 hash: "$argon2id$v=19$m=19456,t=2,p=1$invalid$invalid".into(),
2088 role: "ops".into(),
2089 expires_at: None,
2090 },
2091 ApiKeyEntry {
2092 name: "correct".into(),
2093 hash,
2094 role: "deploy".into(),
2095 expires_at: None,
2096 },
2097 ];
2098 let id = verify_bearer_token(&token, &keys).unwrap();
2099 assert_eq!(id.name, "correct");
2100 assert_eq!(id.role, "deploy");
2101 }
2102
2103 #[test]
2104 fn rate_limiter_allows_within_quota() {
2105 let config = RateLimitConfig {
2106 max_attempts_per_minute: 5,
2107 pre_auth_max_per_minute: None,
2108 max_tracked_keys: default_max_tracked_keys(),
2109 idle_eviction: default_idle_eviction(),
2110 burst: None,
2111 pre_auth_burst: None,
2112 key_eviction_policy: KeyEvictionPolicy::default(),
2113 };
2114 let limiter = build_rate_limiter(&config);
2115 let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2116
2117 for _ in 0..5 {
2119 assert!(limiter.check_key(&ip).is_ok());
2120 }
2121 assert!(limiter.check_key(&ip).is_err());
2123 }
2124
2125 #[test]
2126 fn rate_limiter_separate_ips() {
2127 let config = RateLimitConfig {
2128 max_attempts_per_minute: 2,
2129 pre_auth_max_per_minute: None,
2130 max_tracked_keys: default_max_tracked_keys(),
2131 idle_eviction: default_idle_eviction(),
2132 burst: None,
2133 pre_auth_burst: None,
2134 key_eviction_policy: KeyEvictionPolicy::default(),
2135 };
2136 let limiter = build_rate_limiter(&config);
2137 let ip1 = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2138 let ip2 = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2139
2140 assert!(limiter.check_key(&ip1).is_ok());
2142 assert!(limiter.check_key(&ip1).is_ok());
2143 assert!(limiter.check_key(&ip1).is_err());
2144
2145 assert!(limiter.check_key(&ip2).is_ok());
2147 }
2148
2149 #[test]
2150 fn extract_mtls_identity_from_cn() {
2151 let mut params = rcgen::CertificateParams::new(vec!["test-client.local".into()]).unwrap();
2153 params.distinguished_name = rcgen::DistinguishedName::new();
2154 params
2155 .distinguished_name
2156 .push(rcgen::DnType::CommonName, "test-client");
2157 let cert = params
2158 .self_signed(&rcgen::KeyPair::generate().unwrap())
2159 .unwrap();
2160 let der = cert.der();
2161
2162 let id = extract_mtls_identity(der, "ops").unwrap();
2163 assert_eq!(id.name, "test-client");
2164 assert_eq!(id.role, "ops");
2165 assert_eq!(id.method, AuthMethod::MtlsCertificate);
2166 }
2167
2168 #[test]
2169 fn extract_mtls_identity_falls_back_to_san() {
2170 let mut params =
2172 rcgen::CertificateParams::new(vec!["san-only.example.com".into()]).unwrap();
2173 params.distinguished_name = rcgen::DistinguishedName::new();
2174 let cert = params
2176 .self_signed(&rcgen::KeyPair::generate().unwrap())
2177 .unwrap();
2178 let der = cert.der();
2179
2180 let id = extract_mtls_identity(der, "viewer").unwrap();
2181 assert_eq!(id.name, "san-only.example.com");
2182 assert_eq!(id.role, "viewer");
2183 }
2184
2185 #[test]
2186 fn extract_mtls_identity_invalid_der() {
2187 assert!(extract_mtls_identity(b"not-a-cert", "viewer").is_none());
2188 }
2189
2190 #[test]
2191 fn extract_mtls_identity_blank_cn_falls_back_to_san() {
2192 let mut params =
2195 rcgen::CertificateParams::new(vec!["san-fallback.example.com".into()]).unwrap();
2196 params.distinguished_name = rcgen::DistinguishedName::new();
2197 params
2198 .distinguished_name
2199 .push(rcgen::DnType::CommonName, "");
2200 let cert = params
2201 .self_signed(&rcgen::KeyPair::generate().unwrap())
2202 .unwrap();
2203
2204 let id = extract_mtls_identity(cert.der(), "viewer").unwrap();
2205 assert_eq!(id.name, "san-fallback.example.com");
2206 assert_eq!(id.role, "viewer");
2207 }
2208
2209 #[test]
2210 fn extract_mtls_identity_blank_cn_without_san_yields_none() {
2211 let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
2212 params.distinguished_name = rcgen::DistinguishedName::new();
2213 params
2214 .distinguished_name
2215 .push(rcgen::DnType::CommonName, "");
2216 let cert = params
2217 .self_signed(&rcgen::KeyPair::generate().unwrap())
2218 .unwrap();
2219
2220 assert!(extract_mtls_identity(cert.der(), "viewer").is_none());
2221 }
2222
2223 #[test]
2224 fn extract_mtls_identity_whitespace_cn_behaves_as_blank() {
2225 let mut params =
2226 rcgen::CertificateParams::new(vec!["san-fallback.example.com".into()]).unwrap();
2227 params.distinguished_name = rcgen::DistinguishedName::new();
2228 params
2229 .distinguished_name
2230 .push(rcgen::DnType::CommonName, " ");
2231 let cert = params
2232 .self_signed(&rcgen::KeyPair::generate().unwrap())
2233 .unwrap();
2234
2235 let id = extract_mtls_identity(cert.der(), "viewer").unwrap();
2236 assert_eq!(id.name, "san-fallback.example.com");
2237 }
2238
2239 #[test]
2240 fn validate_api_key_names_rejects_blank_and_whitespace() {
2241 let blank = AuthConfig::with_keys(vec![
2242 ApiKeyEntry::new("ok", "hash", "viewer"),
2243 ApiKeyEntry::new("", "hash", "viewer"),
2244 ]);
2245 let err = blank.validate_api_key_names().unwrap_err().to_string();
2246 assert!(
2247 err.contains("api_keys[1]"),
2248 "must name offending index: {err}"
2249 );
2250
2251 let whitespace = AuthConfig::with_keys(vec![ApiKeyEntry::new(" ", "hash", "viewer")]);
2252 assert!(whitespace.validate_api_key_names().is_err());
2253
2254 let ok = AuthConfig::with_keys(vec![ApiKeyEntry::new("viewer-key", "hash", "viewer")]);
2255 assert!(ok.validate_api_key_names().is_ok());
2256 }
2257
2258 #[test]
2259 fn try_reload_keys_rejects_blank_name_and_keeps_previous() {
2260 let (token, hash) = generate_api_key().unwrap();
2261 let state = test_auth_state(vec![ApiKeyEntry::new("prev-key", hash, "ops")]);
2262
2263 let err = state
2264 .try_reload_keys(vec![ApiKeyEntry::new(" ", "unused-hash", "ops")])
2265 .unwrap_err()
2266 .to_string();
2267 assert!(
2268 err.contains("api_keys[0]"),
2269 "must name offending index: {err}"
2270 );
2271
2272 let installed = state.api_keys.load();
2273 assert!(
2274 verify_bearer_token(&token, &installed).is_some(),
2275 "the previous key must remain installed after a rejected reload"
2276 );
2277 }
2278
2279 #[test]
2280 fn verify_bearer_token_rejects_blank_named_key() {
2281 let (token, hash) = generate_api_key().unwrap();
2282 let blank = ApiKeyEntry {
2283 name: String::new(),
2284 hash: hash.clone(),
2285 role: "ops".into(),
2286 expires_at: None,
2287 };
2288 assert!(
2289 verify_bearer_token(&token, std::slice::from_ref(&blank)).is_none(),
2290 "a valid token for a blank-named key must yield no identity"
2291 );
2292
2293 let whitespace = ApiKeyEntry {
2294 name: " ".into(),
2295 hash: hash.clone(),
2296 role: "ops".into(),
2297 expires_at: None,
2298 };
2299 assert!(
2300 verify_bearer_token(&token, std::slice::from_ref(&whitespace)).is_none(),
2301 "a whitespace-only key name must be treated as blank"
2302 );
2303
2304 let named = ApiKeyEntry::new("real-key", hash, "ops");
2305 assert!(
2306 verify_bearer_token(&token, std::slice::from_ref(&named)).is_some(),
2307 "a non-blank key name must still authenticate"
2308 );
2309 }
2310
2311 use axum::{
2314 body::Body,
2315 http::{Request, StatusCode},
2316 };
2317 use tower::ServiceExt as _;
2318
2319 fn auth_router(state: Arc<AuthState>) -> axum::Router {
2320 axum::Router::new()
2321 .route("/mcp", axum::routing::post(|| async { "ok" }))
2322 .layer(axum::middleware::from_fn(move |req, next| {
2323 let s = Arc::clone(&state);
2324 auth_middleware(s, req, next)
2325 }))
2326 }
2327
2328 fn test_auth_state(keys: Vec<ApiKeyEntry>) -> Arc<AuthState> {
2329 Arc::new(AuthState {
2330 api_keys: ArcSwap::new(Arc::new(keys)),
2331 rate_limiter: None,
2332 pre_auth_limiter: None,
2333 #[cfg(feature = "oauth")]
2334 jwks_cache: None,
2335 seen_identities: SeenIdentitySet::new(),
2336 counters: AuthCounters::default(),
2337 resource_metadata_url: None,
2338 })
2339 }
2340
2341 #[tokio::test]
2342 async fn middleware_rejects_no_credentials() {
2343 let state = test_auth_state(vec![]);
2344 let app = auth_router(Arc::clone(&state));
2345 let req = Request::builder()
2346 .method(axum::http::Method::POST)
2347 .uri("/mcp")
2348 .body(Body::empty())
2349 .unwrap();
2350 let resp = app.oneshot(req).await.unwrap();
2351 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2352 let challenge = resp
2353 .headers()
2354 .get(header::WWW_AUTHENTICATE)
2355 .unwrap()
2356 .to_str()
2357 .unwrap();
2358 assert!(challenge.contains("error=\"invalid_request\""));
2359
2360 let counters = state.counters_snapshot();
2361 assert_eq!(counters.failure_missing_credential, 1);
2362 }
2363
2364 #[tokio::test]
2365 async fn middleware_accepts_valid_bearer() {
2366 let (token, hash) = generate_api_key().unwrap();
2367 let keys = vec![ApiKeyEntry {
2368 name: "test-key".into(),
2369 hash,
2370 role: "ops".into(),
2371 expires_at: None,
2372 }];
2373 let state = test_auth_state(keys);
2374 let app = auth_router(Arc::clone(&state));
2375 let req = Request::builder()
2376 .method(axum::http::Method::POST)
2377 .uri("/mcp")
2378 .header("authorization", format!("Bearer {token}"))
2379 .body(Body::empty())
2380 .unwrap();
2381 let resp = app.oneshot(req).await.unwrap();
2382 assert_eq!(resp.status(), StatusCode::OK);
2383
2384 let counters = state.counters_snapshot();
2385 assert_eq!(counters.success_bearer, 1);
2386 }
2387
2388 #[tokio::test]
2389 async fn middleware_rejects_wrong_bearer() {
2390 let (_token, hash) = generate_api_key().unwrap();
2391 let keys = vec![ApiKeyEntry {
2392 name: "test-key".into(),
2393 hash,
2394 role: "ops".into(),
2395 expires_at: None,
2396 }];
2397 let state = test_auth_state(keys);
2398 let app = auth_router(Arc::clone(&state));
2399 let req = Request::builder()
2400 .method(axum::http::Method::POST)
2401 .uri("/mcp")
2402 .header("authorization", "Bearer wrong-token-here")
2403 .body(Body::empty())
2404 .unwrap();
2405 let resp = app.oneshot(req).await.unwrap();
2406 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2407 let challenge = resp
2408 .headers()
2409 .get(header::WWW_AUTHENTICATE)
2410 .unwrap()
2411 .to_str()
2412 .unwrap();
2413 assert!(challenge.contains("error=\"invalid_token\""));
2414
2415 let counters = state.counters_snapshot();
2416 assert_eq!(counters.failure_invalid_credential, 1);
2417 }
2418
2419 #[tokio::test]
2420 async fn middleware_rate_limits() {
2421 let state = Arc::new(AuthState {
2422 api_keys: ArcSwap::new(Arc::new(vec![])),
2423 rate_limiter: Some(build_rate_limiter(&RateLimitConfig {
2424 max_attempts_per_minute: 1,
2425 pre_auth_max_per_minute: None,
2426 max_tracked_keys: default_max_tracked_keys(),
2427 idle_eviction: default_idle_eviction(),
2428 burst: None,
2429 pre_auth_burst: None,
2430 key_eviction_policy: KeyEvictionPolicy::default(),
2431 })),
2432 pre_auth_limiter: None,
2433 #[cfg(feature = "oauth")]
2434 jwks_cache: None,
2435 seen_identities: SeenIdentitySet::new(),
2436 counters: AuthCounters::default(),
2437 resource_metadata_url: None,
2438 });
2439 let app = auth_router(state);
2440
2441 let req = Request::builder()
2443 .method(axum::http::Method::POST)
2444 .uri("/mcp")
2445 .body(Body::empty())
2446 .unwrap();
2447 let resp = app.clone().oneshot(req).await.unwrap();
2448 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2449
2450 }
2455
2456 #[test]
2462 fn rate_limit_semantics_failed_only() {
2463 let config = RateLimitConfig {
2464 max_attempts_per_minute: 3,
2465 pre_auth_max_per_minute: None,
2466 max_tracked_keys: default_max_tracked_keys(),
2467 idle_eviction: default_idle_eviction(),
2468 burst: None,
2469 pre_auth_burst: None,
2470 key_eviction_policy: KeyEvictionPolicy::default(),
2471 };
2472 let limiter = build_rate_limiter(&config);
2473 let ip = RateLimitKey::Ip("192.168.1.100".parse::<IpAddr>().unwrap());
2474
2475 assert!(
2477 limiter.check_key(&ip).is_ok(),
2478 "failure 1 should be allowed"
2479 );
2480 assert!(
2481 limiter.check_key(&ip).is_ok(),
2482 "failure 2 should be allowed"
2483 );
2484 assert!(
2485 limiter.check_key(&ip).is_ok(),
2486 "failure 3 should be allowed"
2487 );
2488 assert!(
2489 limiter.check_key(&ip).is_err(),
2490 "failure 4 should be blocked"
2491 );
2492
2493 }
2502
2503 #[test]
2508 fn pre_auth_default_multiplier_is_10x() {
2509 let config = RateLimitConfig {
2510 max_attempts_per_minute: 5,
2511 pre_auth_max_per_minute: None,
2512 max_tracked_keys: default_max_tracked_keys(),
2513 idle_eviction: default_idle_eviction(),
2514 burst: None,
2515 pre_auth_burst: None,
2516 key_eviction_policy: KeyEvictionPolicy::default(),
2517 };
2518 let limiter = build_pre_auth_limiter(&config);
2519 let ip = RateLimitKey::Ip("10.0.0.1".parse::<IpAddr>().unwrap());
2520
2521 for i in 0..50 {
2523 assert!(
2524 limiter.check_key(&ip).is_ok(),
2525 "pre-auth attempt {i} (of expected 50) should be allowed under default 10x multiplier"
2526 );
2527 }
2528 assert!(
2530 limiter.check_key(&ip).is_err(),
2531 "pre-auth attempt 51 should be blocked (quota is 50, not unbounded)"
2532 );
2533 }
2534
2535 #[test]
2538 fn pre_auth_explicit_override_wins() {
2539 let config = RateLimitConfig {
2540 max_attempts_per_minute: 100, pre_auth_max_per_minute: Some(2), max_tracked_keys: default_max_tracked_keys(),
2543 idle_eviction: default_idle_eviction(),
2544 burst: None,
2545 pre_auth_burst: None,
2546 key_eviction_policy: KeyEvictionPolicy::default(),
2547 };
2548 let limiter = build_pre_auth_limiter(&config);
2549 let ip = RateLimitKey::Ip("10.0.0.2".parse::<IpAddr>().unwrap());
2550
2551 assert!(limiter.check_key(&ip).is_ok(), "attempt 1 allowed");
2552 assert!(limiter.check_key(&ip).is_ok(), "attempt 2 allowed");
2553 assert!(
2554 limiter.check_key(&ip).is_err(),
2555 "attempt 3 must be blocked (explicit override of 2 wins over 10x default of 1000)"
2556 );
2557 }
2558
2559 #[test]
2561 fn pre_auth_gate_deny_sets_retry_after() {
2562 let config = RateLimitConfig::new(100).with_pre_auth_max_per_minute(1);
2563 let state = AuthState {
2564 api_keys: ArcSwap::new(Arc::new(vec![])),
2565 rate_limiter: None,
2566 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2567 #[cfg(feature = "oauth")]
2568 jwks_cache: None,
2569 seen_identities: SeenIdentitySet::new(),
2570 counters: AuthCounters::default(),
2571 resource_metadata_url: None,
2572 };
2573 let ip = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2574 assert!(
2575 pre_auth_gate(&state, Some(&ip)).is_none(),
2576 "first request within quota"
2577 );
2578 let resp = pre_auth_gate(&state, Some(&ip)).expect("second request must be gated");
2579 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
2580 let retry_after = resp
2581 .headers()
2582 .get(header::RETRY_AFTER)
2583 .expect("Retry-After present")
2584 .to_str()
2585 .unwrap()
2586 .parse::<u64>()
2587 .unwrap();
2588 assert!(retry_after >= 1, "delta-seconds must be >= 1");
2589 }
2590
2591 #[test]
2592 fn pre_auth_gate_capacity_full_returns_503_without_retry_after() {
2593 let config = RateLimitConfig::new(100)
2594 .with_pre_auth_max_per_minute(10)
2595 .with_max_tracked_keys(1)
2596 .with_key_eviction_policy(KeyEvictionPolicy::RejectNew);
2597 let state = AuthState {
2598 api_keys: ArcSwap::new(Arc::new(vec![])),
2599 rate_limiter: None,
2600 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2601 #[cfg(feature = "oauth")]
2602 jwks_cache: None,
2603 seen_identities: SeenIdentitySet::new(),
2604 counters: AuthCounters::default(),
2605 resource_metadata_url: None,
2606 };
2607 let established = RateLimitKey::Ip("10.7.7.7".parse::<IpAddr>().unwrap());
2608 let unseen = RateLimitKey::Ip("10.7.7.8".parse::<IpAddr>().unwrap());
2609 assert!(pre_auth_gate(&state, Some(&established)).is_none());
2610
2611 let resp = pre_auth_gate(&state, Some(&unseen)).expect("unseen key must be rejected");
2612
2613 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
2614 assert!(resp.headers().get(header::RETRY_AFTER).is_none());
2615 }
2616
2617 #[test]
2619 fn post_failure_limiter_burst_allows_initial_spike() {
2620 let config = RateLimitConfig::new(1).with_burst(3);
2621 let limiter = build_rate_limiter(&config);
2622 let ip = RateLimitKey::Ip("10.6.6.6".parse::<IpAddr>().unwrap());
2623 for i in 0..3 {
2624 assert!(limiter.check_key(&ip).is_ok(), "burst attempt {i}");
2625 }
2626 assert!(
2627 limiter.check_key(&ip).is_err(),
2628 "attempt 4 must exceed the burst bucket"
2629 );
2630 }
2631
2632 #[tokio::test]
2638 async fn pre_auth_gate_blocks_before_argon2_verification() {
2639 let (_token, hash) = generate_api_key().unwrap();
2640 let keys = vec![ApiKeyEntry {
2641 name: "test-key".into(),
2642 hash,
2643 role: "ops".into(),
2644 expires_at: None,
2645 }];
2646 let config = RateLimitConfig {
2647 max_attempts_per_minute: 100,
2648 pre_auth_max_per_minute: Some(1),
2649 max_tracked_keys: default_max_tracked_keys(),
2650 idle_eviction: default_idle_eviction(),
2651 burst: None,
2652 pre_auth_burst: None,
2653 key_eviction_policy: KeyEvictionPolicy::default(),
2654 };
2655 let state = Arc::new(AuthState {
2656 api_keys: ArcSwap::new(Arc::new(keys)),
2657 rate_limiter: None,
2658 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2659 #[cfg(feature = "oauth")]
2660 jwks_cache: None,
2661 seen_identities: SeenIdentitySet::new(),
2662 counters: AuthCounters::default(),
2663 resource_metadata_url: None,
2664 });
2665 let app = auth_router(Arc::clone(&state));
2666 let peer: SocketAddr = "10.0.0.10:54321".parse().unwrap();
2667
2668 let mut req1 = Request::builder()
2671 .method(axum::http::Method::POST)
2672 .uri("/mcp")
2673 .header("authorization", "Bearer obviously-not-a-real-token")
2674 .body(Body::empty())
2675 .unwrap();
2676 req1.extensions_mut().insert(ConnectInfo(peer));
2677 let resp1 = app.clone().oneshot(req1).await.unwrap();
2678 assert_eq!(
2679 resp1.status(),
2680 StatusCode::UNAUTHORIZED,
2681 "first attempt: gate has quota, falls through to bearer auth which fails with 401"
2682 );
2683
2684 let mut req2 = Request::builder()
2687 .method(axum::http::Method::POST)
2688 .uri("/mcp")
2689 .header("authorization", "Bearer also-not-a-real-token")
2690 .body(Body::empty())
2691 .unwrap();
2692 req2.extensions_mut().insert(ConnectInfo(peer));
2693 let resp2 = app.oneshot(req2).await.unwrap();
2694 assert_eq!(
2695 resp2.status(),
2696 StatusCode::TOO_MANY_REQUESTS,
2697 "second attempt from same IP: pre-auth gate must reject with 429"
2698 );
2699
2700 let counters = state.counters_snapshot();
2701 assert_eq!(
2702 counters.failure_pre_auth_gate, 1,
2703 "exactly one request must have been rejected by the pre-auth gate"
2704 );
2705 assert_eq!(
2709 counters.failure_invalid_credential, 1,
2710 "bearer verification must run exactly once (only the un-gated first request)"
2711 );
2712 }
2713
2714 #[tokio::test]
2721 async fn pre_auth_gate_does_not_throttle_mtls() {
2722 let config = RateLimitConfig {
2723 max_attempts_per_minute: 100,
2724 pre_auth_max_per_minute: Some(1), max_tracked_keys: default_max_tracked_keys(),
2726 idle_eviction: default_idle_eviction(),
2727 burst: None,
2728 pre_auth_burst: None,
2729 key_eviction_policy: KeyEvictionPolicy::default(),
2730 };
2731 let state = Arc::new(AuthState {
2732 api_keys: ArcSwap::new(Arc::new(vec![])),
2733 rate_limiter: None,
2734 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2735 #[cfg(feature = "oauth")]
2736 jwks_cache: None,
2737 seen_identities: SeenIdentitySet::new(),
2738 counters: AuthCounters::default(),
2739 resource_metadata_url: None,
2740 });
2741 let app = auth_router(Arc::clone(&state));
2742 let peer: SocketAddr = "10.0.0.20:54321".parse().unwrap();
2743 let identity = AuthIdentity {
2744 name: "cn=test-client".into(),
2745 role: "viewer".into(),
2746 method: AuthMethod::MtlsCertificate,
2747 raw_token: None,
2748 sub: None,
2749 };
2750 let tls_info = TlsConnInfo::new(peer, Some(identity));
2751
2752 for i in 0..3 {
2753 let mut req = Request::builder()
2754 .method(axum::http::Method::POST)
2755 .uri("/mcp")
2756 .body(Body::empty())
2757 .unwrap();
2758 req.extensions_mut().insert(ConnectInfo(tls_info.clone()));
2759 let resp = app.clone().oneshot(req).await.unwrap();
2760 assert_eq!(
2761 resp.status(),
2762 StatusCode::OK,
2763 "mTLS request {i} must succeed: pre-auth gate must not apply to mTLS callers"
2764 );
2765 }
2766
2767 let counters = state.counters_snapshot();
2768 assert_eq!(
2769 counters.failure_pre_auth_gate, 0,
2770 "pre-auth gate counter must remain at zero: mTLS bypasses the gate"
2771 );
2772 assert_eq!(
2773 counters.success_mtls, 3,
2774 "all three mTLS requests must have been counted as successful"
2775 );
2776 }
2777
2778 #[cfg(feature = "metrics")]
2781 #[tokio::test]
2782 async fn pre_auth_gate_deny_increments_counter() {
2783 let config = RateLimitConfig {
2784 max_attempts_per_minute: 100,
2785 pre_auth_max_per_minute: Some(1),
2786 max_tracked_keys: default_max_tracked_keys(),
2787 idle_eviction: default_idle_eviction(),
2788 burst: None,
2789 pre_auth_burst: None,
2790 key_eviction_policy: KeyEvictionPolicy::default(),
2791 };
2792 let state = Arc::new(AuthState {
2793 api_keys: ArcSwap::new(Arc::new(vec![])),
2794 rate_limiter: None,
2795 pre_auth_limiter: Some(build_pre_auth_limiter(&config)),
2796 #[cfg(feature = "oauth")]
2797 jwks_cache: None,
2798 seen_identities: SeenIdentitySet::new(),
2799 counters: AuthCounters::default(),
2800 resource_metadata_url: None,
2801 });
2802 let app = auth_router(Arc::clone(&state));
2803 let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2804 let peer: SocketAddr = "10.0.0.30:54321".parse().expect("addr parses");
2805 let mk = || {
2806 let mut req = Request::builder()
2807 .method(axum::http::Method::POST)
2808 .uri("/mcp")
2809 .header("authorization", "Bearer not-a-real-token")
2810 .body(Body::empty())
2811 .expect("request builds");
2812 req.extensions_mut().insert(ConnectInfo(peer));
2813 req.extensions_mut().insert(Arc::clone(&metrics));
2814 req
2815 };
2816 let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2817
2818 let first = app.clone().oneshot(mk()).await.expect("first request");
2819 assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2820 assert_eq!(counter("auth_pre"), 0, "un-gated request must not count");
2821
2822 let gated = app.oneshot(mk()).await.expect("second request");
2823 assert_eq!(gated.status(), StatusCode::TOO_MANY_REQUESTS);
2824 assert_eq!(counter("auth_pre"), 1, "gated request must count once");
2825 assert_eq!(counter("auth_post"), 0, "post limiter never fired");
2826 }
2827
2828 #[cfg(feature = "metrics")]
2831 #[tokio::test]
2832 async fn post_failure_limiter_deny_increments_counter() {
2833 let config = RateLimitConfig {
2834 max_attempts_per_minute: 1, pre_auth_max_per_minute: None,
2836 max_tracked_keys: default_max_tracked_keys(),
2837 idle_eviction: default_idle_eviction(),
2838 burst: None,
2839 pre_auth_burst: None,
2840 key_eviction_policy: KeyEvictionPolicy::default(),
2841 };
2842 let state = Arc::new(AuthState {
2843 api_keys: ArcSwap::new(Arc::new(vec![])),
2844 rate_limiter: Some(build_rate_limiter(&config)),
2845 pre_auth_limiter: None,
2846 #[cfg(feature = "oauth")]
2847 jwks_cache: None,
2848 seen_identities: SeenIdentitySet::new(),
2849 counters: AuthCounters::default(),
2850 resource_metadata_url: None,
2851 });
2852 let app = auth_router(Arc::clone(&state));
2853 let metrics = Arc::new(crate::metrics::McpMetrics::new().expect("metrics registry"));
2854 let peer: SocketAddr = "10.0.0.31:54321".parse().expect("addr parses");
2855 let mk = || {
2856 let mut req = Request::builder()
2857 .method(axum::http::Method::POST)
2858 .uri("/mcp")
2859 .header("authorization", "Bearer not-a-real-token")
2860 .body(Body::empty())
2861 .expect("request builds");
2862 req.extensions_mut().insert(ConnectInfo(peer));
2863 req.extensions_mut().insert(Arc::clone(&metrics));
2864 req
2865 };
2866 let counter = |label: &str| metrics.rate_limited_total.with_label_values(&[label]).get();
2867
2868 let first = app.clone().oneshot(mk()).await.expect("first request");
2870 assert_eq!(first.status(), StatusCode::UNAUTHORIZED);
2871 assert_eq!(counter("auth_post"), 0);
2872
2873 let limited = app.oneshot(mk()).await.expect("second request");
2875 assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
2876 assert_eq!(counter("auth_post"), 1, "deny must count once");
2877 assert_eq!(counter("auth_pre"), 0, "pre-auth gate disabled here");
2878 }
2879
2880 #[test]
2885 fn extract_bearer_accepts_canonical_case() {
2886 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2887 }
2888
2889 #[test]
2890 fn extract_bearer_is_case_insensitive_per_rfc7235() {
2891 for header in &[
2895 "bearer abc123",
2896 "BEARER abc123",
2897 "BeArEr abc123",
2898 "bEaReR abc123",
2899 ] {
2900 assert_eq!(
2901 extract_bearer(header),
2902 Some("abc123"),
2903 "header {header:?} must parse as a Bearer token (RFC 7235 §2.1)"
2904 );
2905 }
2906 }
2907
2908 #[test]
2909 fn extract_bearer_rejects_other_schemes() {
2910 assert_eq!(extract_bearer("Basic dXNlcjpwYXNz"), None);
2911 assert_eq!(extract_bearer("Digest username=\"x\""), None);
2912 assert_eq!(extract_bearer("Token abc123"), None);
2913 }
2914
2915 #[test]
2916 fn extract_bearer_rejects_malformed() {
2917 assert_eq!(extract_bearer(""), None);
2919 assert_eq!(extract_bearer("Bearer"), None);
2920 assert_eq!(extract_bearer("Bearer "), None);
2921 assert_eq!(extract_bearer("Bearer "), None);
2922 }
2923
2924 #[test]
2925 fn extract_bearer_tolerates_extra_separator_whitespace() {
2926 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2928 assert_eq!(extract_bearer("Bearer abc123"), Some("abc123"));
2929 }
2930
2931 #[test]
2932 fn extract_bearer_rejects_embedded_whitespace() {
2933 assert_eq!(extract_bearer("Bearer abc 123"), None);
2934 assert_eq!(extract_bearer("Bearer abc\t123"), None);
2935 assert_eq!(extract_bearer("Bearer abc123 "), None);
2936 assert_eq!(extract_bearer("Bearer abc123\r\n"), None);
2937 }
2938
2939 #[test]
2940 fn extract_bearer_still_accepts_opaque_non_token68_credentials() {
2941 assert_eq!(
2946 extract_bearer("Bearer aBc!@#$%^&*()"),
2947 Some("aBc!@#$%^&*()")
2948 );
2949 assert_eq!(extract_bearer("Bearer tok{en}|v1"), Some("tok{en}|v1"));
2950 }
2951
2952 #[test]
2953 fn extract_bearer_accepts_generated_key_and_jwt_shapes() {
2954 let (token, _hash) = generate_api_key().unwrap();
2955 let header = format!("Bearer {token}");
2956 assert_eq!(extract_bearer(&header), Some(token.as_str()));
2957
2958 let jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln-_bmF0dXJl";
2959 let jwt_header = format!("Bearer {jwt}");
2960 assert_eq!(extract_bearer(&jwt_header), Some(jwt));
2961 }
2962
2963 #[test]
2969 fn auth_identity_debug_redacts_raw_token() {
2970 let id = AuthIdentity {
2971 name: "alice".into(),
2972 role: "admin".into(),
2973 method: AuthMethod::OAuthJwt,
2974 raw_token: Some(SecretString::from("super-secret-jwt-payload-xyz")),
2975 sub: Some("keycloak-uuid-2f3c8b".into()),
2976 };
2977 let dbg = format!("{id:?}");
2978
2979 assert!(dbg.contains("alice"), "name should be visible: {dbg}");
2981 assert!(dbg.contains("admin"), "role should be visible: {dbg}");
2982 assert!(dbg.contains("OAuthJwt"), "method should be visible: {dbg}");
2983
2984 assert!(
2986 !dbg.contains("super-secret-jwt-payload-xyz"),
2987 "raw_token must be redacted in Debug output: {dbg}"
2988 );
2989 assert!(
2990 !dbg.contains("keycloak-uuid-2f3c8b"),
2991 "sub must be redacted in Debug output: {dbg}"
2992 );
2993 assert!(
2994 dbg.contains("<redacted>"),
2995 "redaction marker missing: {dbg}"
2996 );
2997 }
2998
2999 #[test]
3000 fn auth_identity_debug_marks_absent_secrets() {
3001 let id = AuthIdentity {
3004 name: "viewer-key".into(),
3005 role: "viewer".into(),
3006 method: AuthMethod::BearerToken,
3007 raw_token: None,
3008 sub: None,
3009 };
3010 let dbg = format!("{id:?}");
3011 assert!(
3012 dbg.contains("<none>"),
3013 "absent secrets should be marked: {dbg}"
3014 );
3015 assert!(
3016 !dbg.contains("<redacted>"),
3017 "no <redacted> marker when secrets are absent: {dbg}"
3018 );
3019 }
3020
3021 #[test]
3022 fn api_key_entry_debug_redacts_hash() {
3023 let entry = ApiKeyEntry {
3024 name: "viewer-key".into(),
3025 hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$h4sh3dPa55w0rd".into(),
3027 role: "viewer".into(),
3028 expires_at: Some(RfcTimestamp::parse("2030-01-01T00:00:00Z").unwrap()),
3029 };
3030 let dbg = format!("{entry:?}");
3031
3032 assert!(dbg.contains("viewer-key"));
3034 assert!(dbg.contains("viewer"));
3035 assert!(dbg.contains("2030-01-01T00:00:00+00:00"));
3036
3037 assert!(
3039 !dbg.contains("$argon2id$"),
3040 "argon2 hash leaked into Debug output: {dbg}"
3041 );
3042 assert!(
3043 !dbg.contains("h4sh3dPa55w0rd"),
3044 "hash digest leaked into Debug output: {dbg}"
3045 );
3046 assert!(
3047 dbg.contains("<redacted>"),
3048 "redaction marker missing: {dbg}"
3049 );
3050 }
3051
3052 #[test]
3063 fn auth_failure_class_as_str_exact_strings() {
3064 assert_eq!(
3065 AuthFailureClass::MissingCredential.as_str(),
3066 "missing_credential"
3067 );
3068 assert_eq!(
3069 AuthFailureClass::InvalidCredential.as_str(),
3070 "invalid_credential"
3071 );
3072 assert_eq!(
3073 AuthFailureClass::ExpiredCredential.as_str(),
3074 "expired_credential"
3075 );
3076 assert_eq!(AuthFailureClass::RateLimited.as_str(), "rate_limited");
3077 assert_eq!(AuthFailureClass::PreAuthGate.as_str(), "pre_auth_gate");
3078 }
3079
3080 #[test]
3081 fn auth_failure_class_response_body_exact_strings() {
3082 assert_eq!(
3083 AuthFailureClass::MissingCredential.response_body(),
3084 "unauthorized: missing credential"
3085 );
3086 assert_eq!(
3087 AuthFailureClass::InvalidCredential.response_body(),
3088 "unauthorized: invalid credential"
3089 );
3090 assert_eq!(
3091 AuthFailureClass::ExpiredCredential.response_body(),
3092 "unauthorized: expired credential"
3093 );
3094 assert_eq!(
3095 AuthFailureClass::RateLimited.response_body(),
3096 "rate limited"
3097 );
3098 assert_eq!(
3099 AuthFailureClass::PreAuthGate.response_body(),
3100 "rate limited (pre-auth)"
3101 );
3102 }
3103
3104 #[test]
3105 fn auth_failure_class_bearer_error_exact_strings() {
3106 assert_eq!(
3107 AuthFailureClass::MissingCredential.bearer_error(),
3108 (
3109 "invalid_request",
3110 "missing bearer token or mTLS client certificate"
3111 )
3112 );
3113 assert_eq!(
3114 AuthFailureClass::InvalidCredential.bearer_error(),
3115 ("invalid_token", "token is invalid")
3116 );
3117 assert_eq!(
3118 AuthFailureClass::ExpiredCredential.bearer_error(),
3119 ("invalid_token", "token is expired")
3120 );
3121 assert_eq!(
3122 AuthFailureClass::RateLimited.bearer_error(),
3123 ("invalid_request", "too many failed authentication attempts")
3124 );
3125 assert_eq!(
3126 AuthFailureClass::PreAuthGate.bearer_error(),
3127 (
3128 "invalid_request",
3129 "too many unauthenticated requests from this source"
3130 )
3131 );
3132 }
3133
3134 #[test]
3143 fn auth_config_summary_bearer_true_when_keys_present() {
3144 let (_token, hash) = generate_api_key().unwrap();
3145 let cfg = AuthConfig::with_keys(vec![ApiKeyEntry::new("k", hash, "viewer")]);
3146 let s = cfg.summary();
3147 assert!(s.enabled, "summary.enabled must reflect AuthConfig.enabled");
3148 assert!(
3149 s.bearer,
3150 "summary.bearer must be true when api_keys is non-empty (kills `!` deletion at L615)"
3151 );
3152 assert!(!s.mtls, "summary.mtls must be false when mtls is None");
3153 assert!(!s.oauth, "summary.oauth must be false when oauth is None");
3154 assert_eq!(s.api_keys.len(), 1);
3155 assert_eq!(s.api_keys[0].name, "k");
3156 assert_eq!(s.api_keys[0].role, "viewer");
3157 }
3158
3159 #[test]
3160 fn auth_config_summary_bearer_false_when_no_keys() {
3161 let cfg = AuthConfig::with_keys(vec![]);
3162 let s = cfg.summary();
3163 assert!(
3164 !s.bearer,
3165 "summary.bearer must be false when api_keys is empty (kills `!` deletion at L615)"
3166 );
3167 assert!(s.api_keys.is_empty());
3168 }
3169
3170 #[test]
3171 fn seen_identity_set_first_then_repeat() {
3172 let set = SeenIdentitySet::new();
3173 assert!(set.insert_is_first("alice"), "first sighting is first");
3174 assert!(
3175 !set.insert_is_first("alice"),
3176 "second sighting is not first"
3177 );
3178 assert!(set.insert_is_first("bob"));
3179 assert_eq!(set.len(), 2);
3180 }
3181
3182 #[test]
3183 fn seen_identity_set_evicts_oldest_at_cap() {
3184 let set = SeenIdentitySet::with_cap(2);
3185 assert!(set.insert_is_first("a"));
3186 assert!(set.insert_is_first("b"));
3187 assert!(set.insert_is_first("c"));
3189 assert_eq!(set.len(), 2);
3190 assert!(set.insert_is_first("a"));
3194 assert_eq!(set.len(), 2);
3195 assert!(set.insert_is_first("b"));
3197 for i in 0..32 {
3199 set.insert_is_first(&format!("churn-{i}"));
3200 assert!(set.len() <= 2, "cap invariant must hold");
3201 }
3202 }
3203
3204 #[test]
3205 fn seen_identity_set_cap_zero_is_raised_to_one() {
3206 let set = SeenIdentitySet::with_cap(0);
3207 assert!(set.insert_is_first("only"));
3208 assert_eq!(set.len(), 1);
3209 assert!(set.insert_is_first("next"));
3211 assert_eq!(set.len(), 1);
3212 }
3213
3214 #[test]
3215 fn seen_identity_set_fifo_does_not_refresh_on_repeat_hit() {
3216 let set = SeenIdentitySet::with_cap(2);
3219 assert!(set.insert_is_first("a")); assert!(set.insert_is_first("b")); assert!(!set.insert_is_first("a"));
3225 assert!(set.insert_is_first("c"));
3228 assert!(set.insert_is_first("a"));
3230 let set = SeenIdentitySet::with_cap(2);
3236 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!(
3241 !set.insert_is_first("y"),
3242 "y must still be present (FIFO did not evict it)"
3243 );
3244 assert!(
3245 set.insert_is_first("x"),
3246 "x must have been evicted by FIFO (would NOT have been evicted under LRU)"
3247 );
3248 }
3249}