1use std::{
17 collections::HashMap,
18 path::PathBuf,
19 sync::{
20 Arc,
21 atomic::{AtomicBool, Ordering},
22 },
23 time::{Duration, Instant},
24};
25
26use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
27use serde::Deserialize;
28use tokio::{net::lookup_host, sync::RwLock};
29
30use crate::auth::{AuthIdentity, AuthMethod};
31
32fn evaluate_oauth_redirect(
58 attempt: &reqwest::redirect::Attempt<'_>,
59 allow_http: bool,
60 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
61) -> Result<(), String> {
62 let prev_https = attempt
63 .previous()
64 .last()
65 .is_some_and(|prev| prev.scheme() == "https");
66 let target_url = attempt.url();
67 let dest_scheme = target_url.scheme();
68 if dest_scheme != "https" {
69 if prev_https {
70 return Err("redirect downgrades https -> http".to_owned());
71 }
72 if !allow_http || dest_scheme != "http" {
73 return Err("redirect to non-HTTP(S) URL refused".to_owned());
74 }
75 }
76 if let Some(reason) = crate::ssrf::redirect_target_reason_with_allowlist(target_url, allowlist)
77 {
78 return Err(format!("redirect target forbidden: {reason}"));
79 }
80 if attempt.previous().len() >= 2 {
81 return Err("too many redirects (max 2)".to_owned());
82 }
83 Ok(())
84}
85
86#[allow(
97 clippy::case_sensitive_file_extension_comparisons,
98 reason = "these are DNS-name suffixes on an already-lowercased host, not file extensions"
99)]
100fn oauth_internal_suffix_blocked(
101 host: &str,
102 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
103) -> bool {
104 let host_canon = host.strip_suffix('.').unwrap_or(host);
105 let host_lower = host_canon.to_ascii_lowercase();
106 let is_internal = host_lower.ends_with(".localhost")
107 || host_lower.ends_with(".local")
108 || host_lower.ends_with(".internal");
109 is_internal && (allowlist.is_empty() || !allowlist.host_allowed(host_canon))
111}
112
113async fn screen_oauth_target_core(
133 url: &str,
134 allow_http: bool,
135 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
136 test_allow_loopback_ssrf: bool,
137) -> Result<(), crate::error::McpxError> {
138 let parsed = check_oauth_url("oauth target", url, allow_http)?;
139 if test_allow_loopback_ssrf {
140 return Ok(());
141 }
142 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
143 return Err(crate::error::McpxError::Config(format!(
144 "OAuth target forbidden ({reason}): {url}"
145 )));
146 }
147
148 let host = parsed.host_str().ok_or_else(|| {
149 crate::error::McpxError::Config(format!("OAuth target URL has no host: {url}"))
150 })?;
151 if oauth_internal_suffix_blocked(host, allowlist) {
152 return Err(crate::error::McpxError::Config(format!(
153 "OAuth target forbidden (internal hostname suffix): {url}"
154 )));
155 }
156 let port = parsed.port_or_known_default().ok_or_else(|| {
157 crate::error::McpxError::Config(format!("OAuth target URL has no known port: {url}"))
158 })?;
159
160 let addrs = lookup_host((host, port)).await.map_err(|error| {
161 crate::error::McpxError::Config(format!("OAuth target DNS resolution {url}: {error}"))
162 })?;
163
164 let host_allowed = !allowlist.is_empty() && allowlist.host_allowed(host);
165 let mut any_addr = false;
166 for addr in addrs {
167 any_addr = true;
168 let ip = addr.ip();
169 if let Some(reason) = crate::ssrf::ip_block_reason(ip) {
170 if reason == "cloud_metadata" {
173 return Err(crate::error::McpxError::Config(format!(
174 "OAuth target resolved to blocked IP ({reason}): {url}"
175 )));
176 }
177 if allowlist.is_empty() {
181 return Err(crate::error::McpxError::Config(format!(
182 "OAuth target resolved to blocked IP ({reason}): {url}"
183 )));
184 }
185 if host_allowed || allowlist.ip_allowed(ip) {
187 continue;
188 }
189 return Err(crate::error::McpxError::Config(format!(
190 "OAuth target blocked: hostname {host} resolved to {ip} ({reason}). \
191 To allow, add the hostname to oauth.ssrf_allowlist.hosts or the CIDR \
192 to oauth.ssrf_allowlist.cidrs (operators only -- see SECURITY.md). \
193 URL: {url}"
194 )));
195 }
196 }
197 if !any_addr {
198 return Err(crate::error::McpxError::Config(format!(
199 "OAuth target DNS resolution returned no addresses: {url}"
200 )));
201 }
202
203 Ok(())
204}
205
206async fn screen_oauth_target(
209 url: &str,
210 allow_http: bool,
211 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
212) -> Result<(), crate::error::McpxError> {
213 screen_oauth_target_core(url, allow_http, allowlist, false).await
214}
215
216#[cfg(any(test, feature = "test-helpers"))]
220async fn screen_oauth_target_with_test_override(
221 url: &str,
222 allow_http: bool,
223 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
224 test_allow_loopback_ssrf: bool,
225) -> Result<(), crate::error::McpxError> {
226 screen_oauth_target_core(url, allow_http, allowlist, test_allow_loopback_ssrf).await
227}
228
229#[derive(Clone)]
270pub struct OauthHttpClient {
271 #[cfg(any(test, feature = "test-helpers"))]
279 inner: reqwest::Client,
280 credential_client: reqwest::Client,
287 allow_http: bool,
288 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
293 #[cfg(feature = "oauth-mtls-client")]
298 mtls_clients: Arc<HashMap<MtlsClientKey, reqwest::Client>>,
299 #[cfg(any(test, feature = "test-helpers"))]
305 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
306}
307
308#[cfg(feature = "oauth-mtls-client")]
312#[derive(Debug, Clone, Hash, Eq, PartialEq)]
313struct MtlsClientKey {
314 cert_path: PathBuf,
315 key_path: PathBuf,
316}
317
318impl OauthHttpClient {
319 pub fn with_config(config: &OAuthConfig) -> Result<Self, crate::error::McpxError> {
337 Self::build(Some(config))
338 }
339
340 #[deprecated(
363 since = "1.2.1",
364 note = "use OauthHttpClient::with_config(&OAuthConfig) so token/introspect/revoke/exchange traffic inherits ca_cert_path and the allow_http_oauth_urls toggle"
365 )]
366 pub fn new() -> Result<Self, crate::error::McpxError> {
367 Self::build(None)
368 }
369
370 fn build(config: Option<&OAuthConfig>) -> Result<Self, crate::error::McpxError> {
373 rustls::crypto::ring::default_provider()
380 .install_default()
381 .ok();
382
383 let allow_http = config.is_some_and(|c| c.allow_http_oauth_urls);
384
385 let allowlist = match config.and_then(|c| c.ssrf_allowlist.as_ref()) {
390 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
391 crate::error::McpxError::Startup(format!("oauth http client: {e}"))
392 })?),
393 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
394 };
395
396 #[cfg(any(test, feature = "test-helpers"))]
401 let redirect_allowlist = Arc::clone(&allowlist);
402
403 #[cfg(any(test, feature = "test-helpers"))]
407 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
408 Arc::new(AtomicBool::new(false));
409 #[cfg(not(any(test, feature = "test-helpers")))]
410 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
411
412 #[allow(
417 clippy::clone_on_ref_ptr,
418 clippy::clone_on_copy,
419 clippy::unit_arg,
420 reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
421 )]
422 let resolver: Arc<dyn reqwest::dns::Resolve> =
423 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
424 Arc::clone(&allowlist),
425 test_bypass.clone(),
426 ));
427
428 let ca_pem: Option<Vec<u8>> = if let Some(cfg) = config
432 && let Some(ref ca_path) = cfg.ca_cert_path
433 {
434 Some(std::fs::read(ca_path).map_err(|e| {
435 crate::error::McpxError::Startup(format!(
436 "oauth http client: read ca_cert_path {}: {e}",
437 ca_path.display()
438 ))
439 })?)
440 } else {
441 None
442 };
443
444 let make_base = || -> Result<reqwest::ClientBuilder, crate::error::McpxError> {
448 let mut b = reqwest::Client::builder()
449 .no_proxy()
450 .dns_resolver(Arc::clone(&resolver))
451 .connect_timeout(Duration::from_secs(10))
452 .timeout(Duration::from_secs(30));
453 if let Some(ref pem) = ca_pem {
454 let cert = reqwest::tls::Certificate::from_pem(pem).map_err(|e| {
455 crate::error::McpxError::Startup(format!(
456 "oauth http client: parse ca_cert_path: {e}"
457 ))
458 })?;
459 b = b.add_root_certificate(cert);
460 }
461 Ok(b)
462 };
463
464 #[cfg(any(test, feature = "test-helpers"))]
471 let inner =
472 make_base()?
473 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
474 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
475 Ok(()) => attempt.follow(),
476 Err(reason) => {
477 tracing::warn!(
478 reason = %reason,
479 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
480 "oauth redirect rejected"
481 );
482 attempt.error(reason)
483 }
484 }
485 }))
486 .build()
487 .map_err(|e| {
488 crate::error::McpxError::Startup(format!("oauth http client init: {e}"))
489 })?;
490
491 let credential_client = make_base()?
504 .redirect(reqwest::redirect::Policy::none())
505 .build()
506 .map_err(|e| {
507 crate::error::McpxError::Startup(format!("oauth http client init: {e}"))
508 })?;
509
510 #[cfg(feature = "oauth-mtls-client")]
511 let mtls_clients = build_mtls_clients(config, &allowlist, &test_bypass)?;
512
513 Ok(Self {
514 #[cfg(any(test, feature = "test-helpers"))]
515 inner,
516 credential_client,
517 allow_http,
518 allowlist,
519 #[cfg(feature = "oauth-mtls-client")]
520 mtls_clients,
521 #[cfg(any(test, feature = "test-helpers"))]
522 test_allow_loopback_ssrf: test_bypass,
523 })
524 }
525
526 async fn send_screened(
527 &self,
528 url: &str,
529 request: reqwest::RequestBuilder,
530 ) -> Result<reqwest::Response, crate::error::McpxError> {
531 #[cfg(any(test, feature = "test-helpers"))]
532 if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
533 screen_oauth_target_with_test_override(url, self.allow_http, &self.allowlist, true)
534 .await?;
535 } else {
536 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
537 }
538 #[cfg(not(any(test, feature = "test-helpers")))]
539 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
540 request.send().await.map_err(|error| {
541 crate::error::McpxError::Config(format!("oauth request {url}: {error}"))
542 })
543 }
544
545 #[cfg(any(test, feature = "test-helpers"))]
550 #[doc(hidden)]
551 #[must_use]
552 pub fn __test_allow_loopback_ssrf(self) -> Self {
553 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
556 self
557 }
558
559 #[cfg(any(test, feature = "test-helpers"))]
565 #[doc(hidden)]
566 pub async fn __test_get(&self, url: &str) -> reqwest::Result<reqwest::Response> {
567 self.inner.get(url).send().await
568 }
569
570 #[cfg(any(test, feature = "test-helpers"))]
576 #[doc(hidden)]
577 #[must_use]
578 pub fn __test_inner_client(&self) -> &reqwest::Client {
579 &self.inner
580 }
581
582 #[cfg(feature = "oauth-mtls-client")]
589 fn client_for(&self, cfg: &TokenExchangeConfig) -> &reqwest::Client {
590 if let Some(cc) = &cfg.client_cert {
591 let key = MtlsClientKey {
592 cert_path: cc.cert_path.clone(),
593 key_path: cc.key_path.clone(),
594 };
595 if let Some(client) = self.mtls_clients.get(&key) {
596 return client;
597 }
598 }
599 &self.credential_client
600 }
601
602 #[cfg(not(feature = "oauth-mtls-client"))]
603 fn client_for(&self, _cfg: &TokenExchangeConfig) -> &reqwest::Client {
604 &self.credential_client
605 }
606}
607
608impl std::fmt::Debug for OauthHttpClient {
609 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610 f.debug_struct("OauthHttpClient").finish_non_exhaustive()
611 }
612}
613
614#[derive(Debug, Clone, Default, Deserialize)]
677#[non_exhaustive]
678pub struct OAuthSsrfAllowlist {
679 #[serde(default)]
684 pub hosts: Vec<String>,
685 #[serde(default)]
691 pub cidrs: Vec<String>,
692}
693
694fn compile_oauth_ssrf_allowlist(
701 raw: &OAuthSsrfAllowlist,
702) -> Result<crate::ssrf::CompiledSsrfAllowlist, String> {
703 let mut hosts: Vec<String> = Vec::with_capacity(raw.hosts.len());
704 for (idx, entry) in raw.hosts.iter().enumerate() {
705 let trimmed = entry.trim();
706 if trimmed.is_empty() {
707 return Err(format!("oauth.ssrf_allowlist.hosts[{idx}]: empty entry"));
708 }
709 if trimmed.contains([':', '/', '@', '?', '#']) {
713 return Err(format!(
714 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: must be a bare DNS hostname \
715 (no scheme, port, path, userinfo, query, or fragment)"
716 ));
717 }
718 match url::Host::parse(trimmed) {
719 Ok(url::Host::Domain(_)) => {}
720 Ok(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
721 return Err(format!(
722 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: literal IPs are forbidden \
723 here -- list them via oauth.ssrf_allowlist.cidrs instead"
724 ));
725 }
726 Err(e) => {
727 return Err(format!(
728 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: invalid hostname: {e}"
729 ));
730 }
731 }
732 hosts.push(trimmed.to_ascii_lowercase());
733 }
734 hosts.sort();
735 hosts.dedup();
736
737 let mut cidrs = Vec::with_capacity(raw.cidrs.len());
738 for (idx, entry) in raw.cidrs.iter().enumerate() {
739 let parsed = crate::ssrf::CidrEntry::parse(entry)
740 .map_err(|e| format!("oauth.ssrf_allowlist.cidrs[{idx}]: {e}"))?;
741 cidrs.push(parsed);
742 }
743
744 Ok(crate::ssrf::CompiledSsrfAllowlist::new(hosts, cidrs))
745}
746
747#[derive(Debug, Clone, Deserialize)]
749#[non_exhaustive]
750pub struct OAuthConfig {
751 #[serde(default)]
760 pub issuer: String,
761 #[serde(default)]
767 pub audience: String,
768 #[serde(default)]
773 pub jwks_uri: String,
774 #[serde(default)]
777 pub scopes: Vec<ScopeMapping>,
778 pub role_claim: Option<String>,
784 #[serde(default)]
787 pub role_mappings: Vec<RoleMapping>,
788 #[serde(default = "default_jwks_cache_ttl")]
791 pub jwks_cache_ttl: String,
792 pub proxy: Option<OAuthProxyConfig>,
796 pub token_exchange: Option<TokenExchangeConfig>,
801 #[serde(default)]
816 pub ca_cert_path: Option<PathBuf>,
817 #[serde(default)]
829 pub allow_http_oauth_urls: bool,
830 #[serde(default)]
839 pub ssrf_allowlist: Option<OAuthSsrfAllowlist>,
840 #[serde(default = "default_max_jwks_keys")]
844 pub max_jwks_keys: usize,
845 #[serde(default)]
850 pub require_subject: bool,
851 #[serde(default)]
860 #[deprecated(
861 since = "1.7.0",
862 note = "use `audience_validation_mode` instead; this field is consulted only when `audience_validation_mode` is None"
863 )]
864 pub strict_audience_validation: Option<bool>,
865 #[serde(default)]
874 pub audience_validation_mode: Option<AudienceValidationMode>,
875 #[serde(default = "default_jwks_max_bytes")]
879 pub jwks_max_response_bytes: u64,
880}
881
882fn default_jwks_cache_ttl() -> String {
883 "10m".into()
884}
885
886const fn default_max_jwks_keys() -> usize {
887 256
888}
889
890const fn default_jwks_max_bytes() -> u64 {
891 1024 * 1024
892}
893
894#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
911#[serde(rename_all = "snake_case")]
912#[non_exhaustive]
913pub enum AudienceValidationMode {
914 Permissive,
918 Warn,
921 #[default]
925 Strict,
926}
927
928impl AudienceValidationMode {
929 #[must_use]
934 pub(crate) const fn as_str(self) -> &'static str {
935 match self {
936 Self::Permissive => "permissive",
937 Self::Warn => "warn",
938 Self::Strict => "strict",
939 }
940 }
941}
942
943impl Default for OAuthConfig {
944 fn default() -> Self {
945 Self {
946 issuer: String::new(),
947 audience: String::new(),
948 jwks_uri: String::new(),
949 scopes: Vec::new(),
950 role_claim: None,
951 role_mappings: Vec::new(),
952 jwks_cache_ttl: default_jwks_cache_ttl(),
953 proxy: None,
954 token_exchange: None,
955 ca_cert_path: None,
956 allow_http_oauth_urls: false,
957 max_jwks_keys: default_max_jwks_keys(),
958 require_subject: false,
959 #[allow(
960 deprecated,
961 reason = "default-construct deprecated field for backward compat"
962 )]
963 strict_audience_validation: None,
964 audience_validation_mode: None,
965 jwks_max_response_bytes: default_jwks_max_bytes(),
966 ssrf_allowlist: None,
967 }
968 }
969}
970
971impl OAuthConfig {
972 #[must_use]
979 pub fn effective_audience_validation_mode(&self) -> AudienceValidationMode {
980 if let Some(mode) = self.audience_validation_mode {
981 return mode;
982 }
983 #[allow(deprecated, reason = "intentional: legacy flag resolution path")]
984 match self.strict_audience_validation {
985 Some(true) | None => AudienceValidationMode::Strict,
986 Some(false) => AudienceValidationMode::Warn,
987 }
988 }
989
990 pub fn builder(
996 issuer: impl Into<String>,
997 audience: impl Into<String>,
998 jwks_uri: impl Into<String>,
999 ) -> OAuthConfigBuilder {
1000 OAuthConfigBuilder {
1001 inner: Self {
1002 issuer: issuer.into(),
1003 audience: audience.into(),
1004 jwks_uri: jwks_uri.into(),
1005 ..Self::default()
1006 },
1007 }
1008 }
1009
1010 pub fn validate(&self) -> Result<(), crate::error::McpxError> {
1026 let allow_http = self.allow_http_oauth_urls;
1027 let url = check_oauth_url("oauth.issuer", &self.issuer, allow_http)?;
1028 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1029 return Err(crate::error::McpxError::Config(format!(
1030 "oauth.issuer forbidden ({reason})"
1031 )));
1032 }
1033 let url = check_oauth_url("oauth.jwks_uri", &self.jwks_uri, allow_http)?;
1034 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1035 return Err(crate::error::McpxError::Config(format!(
1036 "oauth.jwks_uri forbidden ({reason})"
1037 )));
1038 }
1039 if self.audience.is_empty() {
1044 return Err(crate::error::McpxError::Config(
1045 "oauth.audience must not be empty".into(),
1046 ));
1047 }
1048 if let Some(proxy) = &self.proxy {
1049 let url = check_oauth_url(
1050 "oauth.proxy.authorize_url",
1051 &proxy.authorize_url,
1052 allow_http,
1053 )?;
1054 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1055 return Err(crate::error::McpxError::Config(format!(
1056 "oauth.proxy.authorize_url forbidden ({reason})"
1057 )));
1058 }
1059 let url = check_oauth_url("oauth.proxy.token_url", &proxy.token_url, allow_http)?;
1060 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1061 return Err(crate::error::McpxError::Config(format!(
1062 "oauth.proxy.token_url forbidden ({reason})"
1063 )));
1064 }
1065 if let Some(url) = &proxy.introspection_url {
1066 let parsed = check_oauth_url("oauth.proxy.introspection_url", url, allow_http)?;
1067 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1068 return Err(crate::error::McpxError::Config(format!(
1069 "oauth.proxy.introspection_url forbidden ({reason})"
1070 )));
1071 }
1072 }
1073 if let Some(url) = &proxy.revocation_url {
1074 let parsed = check_oauth_url("oauth.proxy.revocation_url", url, allow_http)?;
1075 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1076 return Err(crate::error::McpxError::Config(format!(
1077 "oauth.proxy.revocation_url forbidden ({reason})"
1078 )));
1079 }
1080 }
1081 if proxy.expose_admin_endpoints
1088 && !proxy.require_auth_on_admin_endpoints
1089 && !proxy.allow_unauthenticated_admin_endpoints
1090 {
1091 return Err(crate::error::McpxError::Config(
1092 "oauth.proxy: expose_admin_endpoints = true requires \
1093 require_auth_on_admin_endpoints = true (recommended) \
1094 or allow_unauthenticated_admin_endpoints = true \
1095 (explicit opt-out, only safe behind an authenticated \
1096 reverse proxy)"
1097 .into(),
1098 ));
1099 }
1100 }
1101 if let Some(tx) = &self.token_exchange {
1102 let url = check_oauth_url("oauth.token_exchange.token_url", &tx.token_url, allow_http)?;
1103 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1104 return Err(crate::error::McpxError::Config(format!(
1105 "oauth.token_exchange.token_url forbidden ({reason})"
1106 )));
1107 }
1108 validate_token_exchange_client_auth(tx)?;
1111 }
1112 if let Some(raw) = &self.ssrf_allowlist {
1116 let compiled = compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1117 crate::error::McpxError::Config(format!("oauth.ssrf_allowlist: {e}"))
1118 })?;
1119 if !compiled.is_empty() {
1120 tracing::warn!(
1121 host_count = compiled.host_count(),
1122 cidr_count = compiled.cidr_count(),
1123 "oauth.ssrf_allowlist is configured: private/loopback OAuth/JWKS targets \
1124 are now reachable. Cloud-metadata addresses remain blocked. \
1125 See SECURITY.md \"Operator allowlist\"."
1126 );
1127 }
1128 }
1129 humantime::parse_duration(&self.jwks_cache_ttl).map_err(|e| {
1132 crate::error::McpxError::Config(format!(
1133 "oauth.jwks_cache_ttl {:?} is not a valid humantime duration (e.g. \"10m\", \"1h30m\"): {e}",
1134 self.jwks_cache_ttl
1135 ))
1136 })?;
1137 Ok(())
1138 }
1139}
1140
1141fn validate_token_exchange_client_auth(
1147 tx: &TokenExchangeConfig,
1148) -> Result<(), crate::error::McpxError> {
1149 match (&tx.client_cert, tx.client_secret.is_some()) {
1150 (Some(_), true) => Err(crate::error::McpxError::Config(
1151 "oauth.token_exchange: client_cert and client_secret are mutually \
1152 exclusive (RFC 8705 ยง2). Set exactly one."
1153 .into(),
1154 )),
1155 (None, false) => Err(crate::error::McpxError::Config(
1156 "oauth.token_exchange: token exchange requires client authentication. \
1157 Set either client_secret (RFC 6749 ยง2.3.1) or client_cert (RFC 8705 ยง2)."
1158 .into(),
1159 )),
1160 (Some(cc), false) => validate_client_cert_config(cc),
1161 (None, true) => Ok(()),
1162 }
1163}
1164
1165fn validate_client_cert_config(cc: &ClientCertConfig) -> Result<(), crate::error::McpxError> {
1178 #[cfg(not(feature = "oauth-mtls-client"))]
1179 {
1180 let _ = cc;
1181 Err(crate::error::McpxError::Config(
1182 "oauth.token_exchange.client_cert requires the `oauth-mtls-client` cargo feature; \
1183 rebuild rmcp-server-kit with --features oauth-mtls-client (or have your \
1184 application crate enable it via `rmcp-server-kit/oauth-mtls-client`), or remove \
1185 the field"
1186 .into(),
1187 ))
1188 }
1189 #[cfg(feature = "oauth-mtls-client")]
1190 {
1191 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1192 tracing::warn!(error = %e, path = %cc.cert_path.display(), "client cert read failed");
1193 crate::error::McpxError::Config(format!(
1194 "oauth.token_exchange.client_cert.cert_path unreadable: {}",
1195 cc.cert_path.display()
1196 ))
1197 })?;
1198 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1199 tracing::warn!(error = %e, path = %cc.key_path.display(), "client cert key read failed");
1200 crate::error::McpxError::Config(format!(
1201 "oauth.token_exchange.client_cert.key_path unreadable: {}",
1202 cc.key_path.display()
1203 ))
1204 })?;
1205 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1206 combined.extend_from_slice(&cert_bytes);
1207 if !cert_bytes.ends_with(b"\n") {
1208 combined.push(b'\n');
1209 }
1210 combined.extend_from_slice(&key_bytes);
1211 let _identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1212 tracing::warn!(
1213 error = %e,
1214 cert_path = %cc.cert_path.display(),
1215 key_path = %cc.key_path.display(),
1216 "client cert PEM parse failed"
1217 );
1218 crate::error::McpxError::Config(format!(
1219 "oauth.token_exchange.client_cert: PEM parse failed (cert={}, key={})",
1220 cc.cert_path.display(),
1221 cc.key_path.display()
1222 ))
1223 })?;
1224 Ok(())
1225 }
1226}
1227
1228#[cfg(feature = "oauth-mtls-client")]
1236fn build_mtls_clients(
1237 config: Option<&OAuthConfig>,
1238 allowlist: &Arc<crate::ssrf::CompiledSsrfAllowlist>,
1239 test_bypass: &crate::ssrf_resolver::TestLoopbackBypass,
1240) -> Result<Arc<HashMap<MtlsClientKey, reqwest::Client>>, crate::error::McpxError> {
1241 let mut map: HashMap<MtlsClientKey, reqwest::Client> = HashMap::new();
1242 let Some(cfg) = config else {
1243 return Ok(Arc::new(map));
1244 };
1245 let Some(tx) = &cfg.token_exchange else {
1246 return Ok(Arc::new(map));
1247 };
1248 let Some(cc) = &tx.client_cert else {
1249 return Ok(Arc::new(map));
1250 };
1251
1252 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1253 crate::error::McpxError::Startup(format!(
1254 "oauth http client mTLS: read cert_path {}: {e}",
1255 cc.cert_path.display()
1256 ))
1257 })?;
1258 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1259 crate::error::McpxError::Startup(format!(
1260 "oauth http client mTLS: read key_path {}: {e}",
1261 cc.key_path.display()
1262 ))
1263 })?;
1264 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1265 combined.extend_from_slice(&cert_bytes);
1266 if !cert_bytes.ends_with(b"\n") {
1267 combined.push(b'\n');
1268 }
1269 combined.extend_from_slice(&key_bytes);
1270 let identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1271 crate::error::McpxError::Startup(format!(
1272 "oauth http client mTLS: PEM parse (cert={}, key={}): {e}",
1273 cc.cert_path.display(),
1274 cc.key_path.display()
1275 ))
1276 })?;
1277
1278 let resolver: Arc<dyn reqwest::dns::Resolve> =
1279 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1280 Arc::clone(allowlist),
1281 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1286 test_bypass.clone(),
1287 ));
1288
1289 let mut builder = reqwest::Client::builder()
1290 .no_proxy()
1292 .dns_resolver(Arc::clone(&resolver))
1293 .connect_timeout(Duration::from_secs(10))
1294 .timeout(Duration::from_secs(30))
1295 .redirect(reqwest::redirect::Policy::none())
1296 .identity(identity);
1297
1298 if let Some(ref ca_path) = cfg.ca_cert_path {
1299 let pem = std::fs::read(ca_path).map_err(|e| {
1300 crate::error::McpxError::Startup(format!(
1301 "oauth http client mTLS: read ca_cert_path {}: {e}",
1302 ca_path.display()
1303 ))
1304 })?;
1305 let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
1306 crate::error::McpxError::Startup(format!(
1307 "oauth http client mTLS: parse ca_cert_path {}: {e}",
1308 ca_path.display()
1309 ))
1310 })?;
1311 builder = builder.add_root_certificate(cert);
1312 }
1313
1314 let client = builder.build().map_err(|e| {
1315 crate::error::McpxError::Startup(format!("oauth http client mTLS init: {e}"))
1316 })?;
1317 map.insert(
1318 MtlsClientKey {
1319 cert_path: cc.cert_path.clone(),
1320 key_path: cc.key_path.clone(),
1321 },
1322 client,
1323 );
1324 Ok(Arc::new(map))
1325}
1326
1327fn check_oauth_url(
1334 field: &str,
1335 raw: &str,
1336 allow_http: bool,
1337) -> Result<url::Url, crate::error::McpxError> {
1338 let parsed = url::Url::parse(raw).map_err(|e| {
1339 crate::error::McpxError::Config(format!("{field}: invalid URL {raw:?}: {e}"))
1340 })?;
1341 if !parsed.username().is_empty() || parsed.password().is_some() {
1342 return Err(crate::error::McpxError::Config(format!(
1343 "{field} rejected: URL contains userinfo (credentials in URL are forbidden)"
1344 )));
1345 }
1346 match parsed.scheme() {
1347 "https" => Ok(parsed),
1348 "http" if allow_http => Ok(parsed),
1349 "http" => Err(crate::error::McpxError::Config(format!(
1350 "{field}: must use https scheme (got http; set allow_http_oauth_urls=true \
1351 to override - strongly discouraged in production)"
1352 ))),
1353 other => Err(crate::error::McpxError::Config(format!(
1354 "{field}: must use https scheme (got {other:?})"
1355 ))),
1356 }
1357}
1358
1359#[derive(Debug, Clone)]
1365#[must_use = "builders do nothing until `.build()` is called"]
1366pub struct OAuthConfigBuilder {
1367 inner: OAuthConfig,
1368}
1369
1370impl OAuthConfigBuilder {
1371 pub fn scopes(mut self, scopes: Vec<ScopeMapping>) -> Self {
1373 self.inner.scopes = scopes;
1374 self
1375 }
1376
1377 pub fn scope(mut self, scope: impl Into<String>, role: impl Into<String>) -> Self {
1379 self.inner.scopes.push(ScopeMapping {
1380 scope: scope.into(),
1381 role: role.into(),
1382 });
1383 self
1384 }
1385
1386 pub fn role_claim(mut self, claim: impl Into<String>) -> Self {
1389 self.inner.role_claim = Some(claim.into());
1390 self
1391 }
1392
1393 pub fn role_mappings(mut self, mappings: Vec<RoleMapping>) -> Self {
1395 self.inner.role_mappings = mappings;
1396 self
1397 }
1398
1399 pub fn role_mapping(mut self, claim_value: impl Into<String>, role: impl Into<String>) -> Self {
1402 self.inner.role_mappings.push(RoleMapping {
1403 claim_value: claim_value.into(),
1404 role: role.into(),
1405 });
1406 self
1407 }
1408
1409 pub fn jwks_cache_ttl(mut self, ttl: impl Into<String>) -> Self {
1412 self.inner.jwks_cache_ttl = ttl.into();
1413 self
1414 }
1415
1416 pub fn proxy(mut self, proxy: OAuthProxyConfig) -> Self {
1419 self.inner.proxy = Some(proxy);
1420 self
1421 }
1422
1423 pub fn token_exchange(mut self, token_exchange: TokenExchangeConfig) -> Self {
1425 self.inner.token_exchange = Some(token_exchange);
1426 self
1427 }
1428
1429 pub fn ca_cert_path(mut self, path: impl Into<PathBuf>) -> Self {
1434 self.inner.ca_cert_path = Some(path.into());
1435 self
1436 }
1437
1438 pub const fn allow_http_oauth_urls(mut self, allow: bool) -> Self {
1444 self.inner.allow_http_oauth_urls = allow;
1445 self
1446 }
1447
1448 #[deprecated(since = "1.7.0", note = "use `audience_validation_mode` instead")]
1457 pub const fn strict_audience_validation(mut self, strict: bool) -> Self {
1458 #[allow(
1459 deprecated,
1460 reason = "intentional: deprecated builder forwards to deprecated field"
1461 )]
1462 {
1463 self.inner.strict_audience_validation = Some(strict);
1464 }
1465 self.inner.audience_validation_mode = None;
1466 self
1467 }
1468
1469 pub const fn audience_validation_mode(mut self, mode: AudienceValidationMode) -> Self {
1477 self.inner.audience_validation_mode = Some(mode);
1478 self
1479 }
1480
1481 pub const fn require_subject(mut self, require: bool) -> Self {
1487 self.inner.require_subject = require;
1488 self
1489 }
1490
1491 pub const fn jwks_max_response_bytes(mut self, bytes: u64) -> Self {
1493 self.inner.jwks_max_response_bytes = bytes;
1494 self
1495 }
1496
1497 pub fn ssrf_allowlist(mut self, allowlist: OAuthSsrfAllowlist) -> Self {
1505 self.inner.ssrf_allowlist = Some(allowlist);
1506 self
1507 }
1508
1509 #[must_use]
1511 pub fn build(self) -> OAuthConfig {
1512 self.inner
1513 }
1514}
1515
1516#[derive(Debug, Clone, Deserialize)]
1518#[non_exhaustive]
1519pub struct ScopeMapping {
1520 pub scope: String,
1522 pub role: String,
1524}
1525
1526#[derive(Debug, Clone, Deserialize)]
1530#[non_exhaustive]
1531pub struct RoleMapping {
1532 pub claim_value: String,
1534 pub role: String,
1536}
1537
1538#[derive(Debug, Clone, Deserialize)]
1545#[non_exhaustive]
1546pub struct TokenExchangeConfig {
1547 pub token_url: String,
1550 pub client_id: String,
1552 pub client_secret: Option<secrecy::SecretString>,
1557 pub client_cert: Option<ClientCertConfig>,
1570 pub audience: String,
1574}
1575
1576impl TokenExchangeConfig {
1577 #[must_use]
1579 pub fn new(
1580 token_url: String,
1581 client_id: String,
1582 client_secret: Option<secrecy::SecretString>,
1583 client_cert: Option<ClientCertConfig>,
1584 audience: String,
1585 ) -> Self {
1586 Self {
1587 token_url,
1588 client_id,
1589 client_secret,
1590 client_cert,
1591 audience,
1592 }
1593 }
1594}
1595
1596#[derive(Debug, Clone, Deserialize)]
1600#[non_exhaustive]
1601pub struct ClientCertConfig {
1602 pub cert_path: PathBuf,
1605 pub key_path: PathBuf,
1609}
1610
1611impl ClientCertConfig {
1612 #[must_use]
1616 pub fn new(cert_path: PathBuf, key_path: PathBuf) -> Self {
1617 Self {
1618 cert_path,
1619 key_path,
1620 }
1621 }
1622}
1623
1624#[derive(Debug, Deserialize)]
1626#[non_exhaustive]
1627pub struct ExchangedToken {
1628 pub access_token: String,
1630 pub expires_in: Option<u64>,
1632 pub issued_token_type: Option<String>,
1635}
1636
1637#[derive(Debug, Clone, Deserialize, Default)]
1644#[non_exhaustive]
1645pub struct OAuthProxyConfig {
1646 pub authorize_url: String,
1649 pub token_url: String,
1652 pub client_id: String,
1654 pub client_secret: Option<secrecy::SecretString>,
1656 #[serde(default)]
1660 pub introspection_url: Option<String>,
1661 #[serde(default)]
1665 pub revocation_url: Option<String>,
1666 #[serde(default)]
1678 pub expose_admin_endpoints: bool,
1679 #[serde(default)]
1685 pub require_auth_on_admin_endpoints: bool,
1686 #[serde(default)]
1697 pub allow_unauthenticated_admin_endpoints: bool,
1698}
1699
1700impl OAuthProxyConfig {
1701 pub fn builder(
1709 authorize_url: impl Into<String>,
1710 token_url: impl Into<String>,
1711 client_id: impl Into<String>,
1712 ) -> OAuthProxyConfigBuilder {
1713 OAuthProxyConfigBuilder {
1714 inner: Self {
1715 authorize_url: authorize_url.into(),
1716 token_url: token_url.into(),
1717 client_id: client_id.into(),
1718 ..Self::default()
1719 },
1720 }
1721 }
1722}
1723
1724#[derive(Debug, Clone)]
1730#[must_use = "builders do nothing until `.build()` is called"]
1731pub struct OAuthProxyConfigBuilder {
1732 inner: OAuthProxyConfig,
1733}
1734
1735impl OAuthProxyConfigBuilder {
1736 pub fn client_secret(mut self, secret: secrecy::SecretString) -> Self {
1738 self.inner.client_secret = Some(secret);
1739 self
1740 }
1741
1742 pub fn introspection_url(mut self, url: impl Into<String>) -> Self {
1746 self.inner.introspection_url = Some(url.into());
1747 self
1748 }
1749
1750 pub fn revocation_url(mut self, url: impl Into<String>) -> Self {
1754 self.inner.revocation_url = Some(url.into());
1755 self
1756 }
1757
1758 pub const fn expose_admin_endpoints(mut self, expose: bool) -> Self {
1766 self.inner.expose_admin_endpoints = expose;
1767 self
1768 }
1769
1770 pub const fn require_auth_on_admin_endpoints(mut self, require: bool) -> Self {
1773 self.inner.require_auth_on_admin_endpoints = require;
1774 self
1775 }
1776
1777 pub const fn allow_unauthenticated_admin_endpoints(mut self, allow: bool) -> Self {
1781 self.inner.allow_unauthenticated_admin_endpoints = allow;
1782 self
1783 }
1784
1785 #[must_use]
1787 pub fn build(self) -> OAuthProxyConfig {
1788 self.inner
1789 }
1790}
1791
1792type JwksKeyCache = (
1800 HashMap<String, (Algorithm, DecodingKey)>,
1801 Vec<(Algorithm, DecodingKey)>,
1802);
1803
1804struct CachedKeys {
1805 keys: HashMap<String, (Algorithm, DecodingKey)>,
1807 unnamed_keys: Vec<(Algorithm, DecodingKey)>,
1809 fetched_at: Instant,
1810 ttl: Duration,
1811}
1812
1813impl CachedKeys {
1814 fn is_expired(&self) -> bool {
1815 self.fetched_at.elapsed() >= self.ttl
1816 }
1817}
1818
1819#[allow(
1828 missing_debug_implementations,
1829 reason = "contains reqwest::Client and DecodingKey cache with no Debug impl"
1830)]
1831#[non_exhaustive]
1832pub struct JwksCache {
1833 jwks_uri: String,
1834 ttl: Duration,
1835 max_jwks_keys: usize,
1836 max_response_bytes: u64,
1837 allow_http: bool,
1838 inner: RwLock<Option<CachedKeys>>,
1839 http: reqwest::Client,
1840 validation_template: Validation,
1841 expected_audience: String,
1844 audience_mode: AudienceValidationMode,
1845 require_subject: bool,
1846 azp_fallback_warned: AtomicBool,
1850 scopes: Vec<ScopeMapping>,
1851 role_claim: Option<String>,
1852 role_mappings: Vec<RoleMapping>,
1853 last_refresh_attempt: RwLock<Option<Instant>>,
1856 refresh_lock: tokio::sync::Mutex<()>,
1858 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
1862 #[cfg(any(test, feature = "test-helpers"))]
1866 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
1867}
1868
1869const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(10);
1871
1872const OAUTH_PROXY_MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
1882
1883const ACCEPTED_ALGS: &[Algorithm] = &[
1885 Algorithm::RS256,
1886 Algorithm::RS384,
1887 Algorithm::RS512,
1888 Algorithm::ES256,
1889 Algorithm::ES384,
1890 Algorithm::PS256,
1891 Algorithm::PS384,
1892 Algorithm::PS512,
1893 Algorithm::EdDSA,
1894];
1895
1896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1898#[non_exhaustive]
1899pub enum JwtValidationFailure {
1900 Expired,
1902 Invalid,
1904}
1905
1906impl JwksCache {
1907 pub fn new(config: &OAuthConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
1919 rustls::crypto::ring::default_provider()
1922 .install_default()
1923 .ok();
1924 jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
1925 .install_default()
1926 .ok();
1927
1928 let ttl = humantime::parse_duration(&config.jwks_cache_ttl).map_err(|error| {
1929 format!(
1930 "invalid jwks_cache_ttl {:?}: {error}",
1931 config.jwks_cache_ttl
1932 )
1933 })?;
1934
1935 let mut validation = Validation::new(Algorithm::RS256);
1936 validation.validate_aud = false;
1948 validation.set_issuer(&[&config.issuer]);
1949 validation.set_required_spec_claims(&["exp", "iss"]);
1950 validation.validate_exp = true;
1951 validation.validate_nbf = true;
1952
1953 let allow_http = config.allow_http_oauth_urls;
1954
1955 let allowlist = match config.ssrf_allowlist.as_ref() {
1958 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1959 Box::<dyn std::error::Error + Send + Sync>::from(format!(
1960 "oauth.ssrf_allowlist: {e}"
1961 ))
1962 })?),
1963 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
1964 };
1965 let redirect_allowlist = Arc::clone(&allowlist);
1966
1967 #[cfg(any(test, feature = "test-helpers"))]
1969 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
1970 Arc::new(AtomicBool::new(false));
1971 #[cfg(not(any(test, feature = "test-helpers")))]
1972 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
1973
1974 #[allow(
1975 clippy::clone_on_ref_ptr,
1976 clippy::clone_on_copy,
1977 clippy::unit_arg,
1978 reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
1979 )]
1980 let resolver: Arc<dyn reqwest::dns::Resolve> =
1981 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1982 Arc::clone(&allowlist),
1983 test_bypass.clone(),
1984 ));
1985
1986 let mut http_builder = reqwest::Client::builder()
1987 .no_proxy()
1989 .dns_resolver(Arc::clone(&resolver))
1990 .timeout(Duration::from_secs(10))
1991 .connect_timeout(Duration::from_secs(3))
1992 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
1993 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
2003 Ok(()) => attempt.follow(),
2004 Err(reason) => {
2005 tracing::warn!(
2009 reason = %reason,
2010 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
2011 "oauth redirect rejected"
2012 );
2013 attempt.error(reason)
2014 }
2015 }
2016 }));
2017
2018 if let Some(ref ca_path) = config.ca_cert_path {
2019 let pem = std::fs::read(ca_path)?;
2025 let cert = reqwest::tls::Certificate::from_pem(&pem)?;
2026 http_builder = http_builder.add_root_certificate(cert);
2027 }
2028
2029 let http = http_builder.build()?;
2030
2031 Ok(Self {
2032 jwks_uri: config.jwks_uri.clone(),
2033 ttl,
2034 max_jwks_keys: config.max_jwks_keys,
2035 max_response_bytes: config.jwks_max_response_bytes,
2036 allow_http,
2037 inner: RwLock::new(None),
2038 http,
2039 validation_template: validation,
2040 expected_audience: config.audience.clone(),
2041 audience_mode: config.effective_audience_validation_mode(),
2042 require_subject: config.require_subject,
2043 azp_fallback_warned: AtomicBool::new(false),
2044 scopes: config.scopes.clone(),
2045 role_claim: config.role_claim.clone(),
2046 role_mappings: config.role_mappings.clone(),
2047 last_refresh_attempt: RwLock::new(None),
2048 refresh_lock: tokio::sync::Mutex::new(()),
2049 allowlist,
2050 #[cfg(any(test, feature = "test-helpers"))]
2051 test_allow_loopback_ssrf: test_bypass,
2052 })
2053 }
2054
2055 #[cfg(any(test, feature = "test-helpers"))]
2059 #[doc(hidden)]
2060 #[must_use]
2061 pub fn __test_allow_loopback_ssrf(self) -> Self {
2062 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
2065 self
2066 }
2067
2068 pub async fn validate_token(&self, token: &str) -> Option<AuthIdentity> {
2070 self.validate_token_with_reason(token).await.ok()
2071 }
2072
2073 pub async fn validate_token_with_reason(
2083 &self,
2084 token: &str,
2085 ) -> Result<AuthIdentity, JwtValidationFailure> {
2086 let claims = self.decode_claims(token).await?;
2087
2088 if self.require_subject && claims.sub.is_none() {
2089 core::hint::cold_path();
2090 tracing::debug!("JWT rejected: require_subject is set but the token has no `sub`");
2091 return Err(JwtValidationFailure::Invalid);
2092 }
2093 self.check_audience(&claims)?;
2094 let role = self.resolve_role(&claims)?;
2095
2096 let sub = claims.sub;
2099 let name = claims
2100 .extra
2101 .get("preferred_username")
2102 .and_then(|v| v.as_str())
2103 .map(String::from)
2104 .or_else(|| sub.clone())
2105 .or(claims.azp)
2106 .or(claims.client_id)
2107 .unwrap_or_else(|| "oauth-client".into());
2108
2109 Ok(AuthIdentity {
2110 name,
2111 role,
2112 method: AuthMethod::OAuthJwt,
2113 raw_token: None,
2114 sub,
2115 })
2116 }
2117
2118 async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2134 let (key, alg) = self.select_jwks_key(token).await?;
2135
2136 let mut validation = self.validation_template.clone();
2140 validation.algorithms = vec![alg];
2141
2142 let token_owned = token.to_owned();
2145 let join =
2146 tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2147 .await;
2148
2149 let decode_result = match join {
2150 Ok(r) => r,
2151 Err(join_err) => {
2152 core::hint::cold_path();
2153 tracing::error!(
2154 error = %join_err,
2155 "JWT decode task panicked or was cancelled"
2156 );
2157 return Err(JwtValidationFailure::Invalid);
2158 }
2159 };
2160
2161 decode_result.map(|td| td.claims).map_err(|e| {
2162 core::hint::cold_path();
2163 let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2164 JwtValidationFailure::Expired
2165 } else {
2166 JwtValidationFailure::Invalid
2167 };
2168 tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2169 failure
2170 })
2171 }
2172
2173 #[allow(
2182 clippy::cognitive_complexity,
2183 reason = "each failure arm pairs `cold_path()` with a distinct `tracing::debug!` site for observability; collapsing into combinators would lose structured-field log sites without reducing real complexity"
2184 )]
2185 async fn select_jwks_key(
2186 &self,
2187 token: &str,
2188 ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2189 let Ok(header) = decode_header(token) else {
2190 core::hint::cold_path();
2191 tracing::debug!("JWT header decode failed");
2192 return Err(JwtValidationFailure::Invalid);
2193 };
2194 let kid = header.kid.as_deref();
2195 tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2196
2197 if !ACCEPTED_ALGS.contains(&header.alg) {
2198 core::hint::cold_path();
2199 tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2200 return Err(JwtValidationFailure::Invalid);
2201 }
2202
2203 let Some(key) = self.find_key(kid, header.alg).await else {
2204 core::hint::cold_path();
2205 tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2206 return Err(JwtValidationFailure::Invalid);
2207 };
2208
2209 Ok((key, header.alg))
2210 }
2211
2212 fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2221 if claims.aud.contains(&self.expected_audience) {
2222 return Ok(());
2223 }
2224 let azp_match = claims
2225 .azp
2226 .as_deref()
2227 .is_some_and(|azp| azp == self.expected_audience);
2228 if azp_match {
2229 match self.audience_mode {
2230 AudienceValidationMode::Permissive => return Ok(()),
2231 AudienceValidationMode::Warn => {
2232 if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2233 tracing::warn!(
2234 expected = %self.expected_audience,
2235 azp = claims.azp.as_deref().unwrap_or("-"),
2236 "JWT accepted via deprecated azp-only audience fallback. \
2237 Configure your IdP to populate aud, or set \
2238 audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2239 To silence this warning without changing acceptance, \
2240 set audience_validation_mode = \"permissive\". \
2241 This warning logs once per process."
2242 );
2243 }
2244 return Ok(());
2245 }
2246 AudienceValidationMode::Strict => {}
2247 }
2248 }
2249 core::hint::cold_path();
2250 tracing::debug!(
2251 aud = %claims.aud.log_display(),
2252 azp = claims.azp.as_deref().unwrap_or("-"),
2253 expected = %self.expected_audience,
2254 mode = self.audience_mode.as_str(),
2255 "JWT rejected: audience mismatch"
2256 );
2257 Err(JwtValidationFailure::Invalid)
2258 }
2259
2260 fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2266 if let Some(ref claim_path) = self.role_claim {
2267 let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2268 let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2269 values.extend(resolve_claim_path(&claims.extra, claim_path));
2270 return self
2271 .role_mappings
2272 .iter()
2273 .find(|m| values.contains(&m.claim_value.as_str()))
2274 .map(|m| m.role.clone())
2275 .ok_or(JwtValidationFailure::Invalid);
2276 }
2277
2278 let token_scopes: Vec<&str> = claims
2279 .scope
2280 .as_deref()
2281 .unwrap_or("")
2282 .split_whitespace()
2283 .collect();
2284
2285 self.scopes
2286 .iter()
2287 .find(|m| token_scopes.contains(&m.scope.as_str()))
2288 .map(|m| m.role.clone())
2289 .ok_or(JwtValidationFailure::Invalid)
2290 }
2291
2292 async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2298 {
2300 let guard = self.inner.read().await;
2301 if let Some(cached) = guard.as_ref()
2302 && !cached.is_expired()
2303 && let Some(key) = lookup_key(cached, kid, alg)
2304 {
2305 return Some(key);
2306 }
2307 }
2308
2309 self.refresh_with_cooldown().await;
2311
2312 let guard = self.inner.read().await;
2318 guard
2319 .as_ref()
2320 .filter(|cached| !cached.is_expired())
2321 .and_then(|cached| lookup_key(cached, kid, alg))
2322 }
2323
2324 async fn refresh_with_cooldown(&self) {
2344 let _guard = self.refresh_lock.lock().await;
2346
2347 {
2349 let last = self.last_refresh_attempt.read().await;
2350 if let Some(ts) = *last
2351 && ts.elapsed() < JWKS_REFRESH_COOLDOWN
2352 {
2353 tracing::debug!(
2354 elapsed_ms = ts.elapsed().as_millis(),
2355 cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
2356 "JWKS refresh skipped (cooldown active)"
2357 );
2358 return;
2359 }
2360 }
2361
2362 {
2365 let mut last = self.last_refresh_attempt.write().await;
2366 *last = Some(Instant::now());
2367 }
2368
2369 let _ = self.refresh_inner().await;
2371 }
2372
2373 async fn refresh_inner(&self) -> Result<(), String> {
2382 let Some(jwks) = self.fetch_jwks().await else {
2383 return Ok(());
2384 };
2385 let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
2386 Ok(cache) => cache,
2387 Err(msg) => {
2388 tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
2389 return Err(msg);
2390 }
2391 };
2392
2393 tracing::debug!(
2394 named = keys.len(),
2395 unnamed = unnamed_keys.len(),
2396 "JWKS refreshed"
2397 );
2398
2399 let mut guard = self.inner.write().await;
2400 *guard = Some(CachedKeys {
2401 keys,
2402 unnamed_keys,
2403 fetched_at: Instant::now(),
2404 ttl: self.ttl,
2405 });
2406 drop(guard);
2407 Ok(())
2408 }
2409
2410 #[allow(
2412 clippy::cognitive_complexity,
2413 reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
2414 )]
2415 async fn fetch_jwks(&self) -> Option<JwkSet> {
2416 #[cfg(any(test, feature = "test-helpers"))]
2417 let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
2418 screen_oauth_target_with_test_override(
2419 &self.jwks_uri,
2420 self.allow_http,
2421 &self.allowlist,
2422 true,
2423 )
2424 .await
2425 } else {
2426 screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
2427 };
2428 #[cfg(not(any(test, feature = "test-helpers")))]
2429 let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
2430
2431 if let Err(error) = screening {
2432 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to screen JWKS target");
2433 return None;
2434 }
2435
2436 let mut resp = match self.http.get(&self.jwks_uri).send().await {
2437 Ok(resp) => resp,
2438 Err(e) => {
2439 tracing::warn!(error = %e, uri = %self.jwks_uri, "failed to fetch JWKS");
2440 return None;
2441 }
2442 };
2443
2444 let initial_capacity =
2445 usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
2446 let mut body = Vec::with_capacity(initial_capacity);
2447 while let Some(chunk) = match resp.chunk().await {
2448 Ok(chunk) => chunk,
2449 Err(error) => {
2450 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to read JWKS response");
2451 return None;
2452 }
2453 } {
2454 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
2455 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
2456 if body_len.saturating_add(chunk_len) > self.max_response_bytes {
2457 tracing::warn!(
2458 uri = %self.jwks_uri,
2459 max_bytes = self.max_response_bytes,
2460 "JWKS response exceeded configured size cap"
2461 );
2462 return None;
2463 }
2464 body.extend_from_slice(&chunk);
2465 }
2466
2467 match serde_json::from_slice::<JwkSet>(&body) {
2468 Ok(jwks) => Some(jwks),
2469 Err(error) => {
2470 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to parse JWKS");
2471 None
2472 }
2473 }
2474 }
2475
2476 #[cfg(any(test, feature = "test-helpers"))]
2479 #[doc(hidden)]
2480 pub async fn __test_refresh_now(&self) -> Result<(), String> {
2481 let jwks = self
2482 .fetch_jwks()
2483 .await
2484 .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
2485 let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
2486 let mut guard = self.inner.write().await;
2487 *guard = Some(CachedKeys {
2488 keys,
2489 unnamed_keys,
2490 fetched_at: Instant::now(),
2491 ttl: self.ttl,
2492 });
2493 drop(guard);
2494 Ok(())
2495 }
2496
2497 #[cfg(any(test, feature = "test-helpers"))]
2500 #[doc(hidden)]
2501 pub async fn __test_has_kid(&self, kid: &str) -> bool {
2502 let guard = self.inner.read().await;
2503 guard
2504 .as_ref()
2505 .is_some_and(|cache| cache.keys.contains_key(kid))
2506 }
2507}
2508
2509fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
2511 if jwks.keys.len() > max_keys {
2512 return Err(format!(
2513 "jwks_key_count_exceeds_cap: got {} keys, max is {}",
2514 jwks.keys.len(),
2515 max_keys
2516 ));
2517 }
2518 let mut keys = HashMap::new();
2519 let mut unnamed_keys = Vec::new();
2520 for jwk in &jwks.keys {
2521 let Ok(decoding_key) = DecodingKey::from_jwk(jwk) else {
2522 continue;
2523 };
2524 let Some(alg) = jwk_algorithm(jwk) else {
2525 continue;
2526 };
2527 if let Some(ref kid) = jwk.common.key_id {
2528 keys.insert(kid.clone(), (alg, decoding_key));
2529 } else {
2530 unnamed_keys.push((alg, decoding_key));
2531 }
2532 }
2533 Ok((keys, unnamed_keys))
2534}
2535
2536fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2538 if let Some(kid) = kid {
2539 if let Some((cached_alg, key)) = cached.keys.get(kid)
2544 && *cached_alg == alg
2545 {
2546 return Some(key.clone());
2547 }
2548 return None;
2549 }
2550 cached
2552 .unnamed_keys
2553 .iter()
2554 .find(|(a, _)| *a == alg)
2555 .map(|(_, k)| k.clone())
2556}
2557
2558#[allow(
2560 clippy::wildcard_enum_match_arm,
2561 reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
2562)]
2563fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<Algorithm> {
2564 jwk.common.key_algorithm.and_then(|ka| match ka {
2565 jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
2566 jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
2567 jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
2568 jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
2569 jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
2570 jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
2571 jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
2572 jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
2573 jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
2574 _ => None,
2575 })
2576}
2577
2578fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
2599 match path {
2600 "sub" => claims.sub.iter().cloned().collect(),
2601 "azp" => claims.azp.iter().cloned().collect(),
2602 "client_id" => claims.client_id.iter().cloned().collect(),
2603 "aud" => claims.aud.0.clone(),
2604 "scope" => claims
2605 .scope
2606 .as_deref()
2607 .unwrap_or("")
2608 .split_whitespace()
2609 .map(str::to_owned)
2610 .collect(),
2611 _ => Vec::new(),
2612 }
2613}
2614
2615fn resolve_claim_path<'a>(
2625 extra: &'a HashMap<String, serde_json::Value>,
2626 path: &str,
2627) -> Vec<&'a str> {
2628 let mut segments = path.split('.');
2629 let Some(first) = segments.next() else {
2630 return Vec::new();
2631 };
2632
2633 let mut current: Option<&serde_json::Value> = extra.get(first);
2634
2635 for segment in segments {
2636 current = current.and_then(|v| v.get(segment));
2637 }
2638
2639 match current {
2640 Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
2641 Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
2642 _ => Vec::new(),
2643 }
2644}
2645
2646#[derive(Debug, Deserialize)]
2652struct Claims {
2653 sub: Option<String>,
2655 #[serde(default)]
2658 aud: OneOrMany,
2659 azp: Option<String>,
2661 client_id: Option<String>,
2663 scope: Option<String>,
2665 #[serde(flatten)]
2667 extra: HashMap<String, serde_json::Value>,
2668}
2669
2670#[derive(Debug, Default)]
2672struct OneOrMany(Vec<String>);
2673
2674impl OneOrMany {
2675 fn contains(&self, value: &str) -> bool {
2676 self.0.iter().any(|v| v == value)
2677 }
2678
2679 fn log_display(&self) -> String {
2683 if self.0.is_empty() {
2684 "-".to_owned()
2685 } else {
2686 self.0.join(", ")
2687 }
2688 }
2689}
2690
2691fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
2701 match value {
2702 Some(serde_json::Value::String(s)) => s.clone(),
2703 Some(serde_json::Value::Array(items)) => {
2704 let joined = items
2705 .iter()
2706 .filter_map(serde_json::Value::as_str)
2707 .collect::<Vec<_>>()
2708 .join(", ");
2709 if joined.is_empty() {
2710 "-".to_owned()
2711 } else {
2712 joined
2713 }
2714 }
2715 Some(
2716 serde_json::Value::Null
2717 | serde_json::Value::Bool(_)
2718 | serde_json::Value::Number(_)
2719 | serde_json::Value::Object(_),
2720 )
2721 | None => "-".to_owned(),
2722 }
2723}
2724
2725fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
2729 value.and_then(serde_json::Value::as_str).unwrap_or("-")
2730}
2731
2732impl<'de> Deserialize<'de> for OneOrMany {
2733 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2734 use serde::de;
2735
2736 struct Visitor;
2737 impl<'de> de::Visitor<'de> for Visitor {
2738 type Value = OneOrMany;
2739 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2740 f.write_str("a string or array of strings")
2741 }
2742 fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
2743 Ok(OneOrMany(vec![v.to_owned()]))
2744 }
2745 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
2746 let mut v = Vec::new();
2747 while let Some(s) = seq.next_element::<String>()? {
2748 v.push(s);
2749 }
2750 Ok(OneOrMany(v))
2751 }
2752 }
2753 deserializer.deserialize_any(Visitor)
2754 }
2755}
2756
2757#[must_use]
2764pub fn looks_like_jwt(token: &str) -> bool {
2765 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2766
2767 let mut parts = token.splitn(4, '.');
2768 let Some(header_b64) = parts.next() else {
2769 return false;
2770 };
2771 if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
2773 return false;
2774 }
2775 let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
2777 return false;
2778 };
2779 let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
2781 return false;
2782 };
2783 header.get("alg").is_some()
2784}
2785
2786#[must_use]
2796pub fn protected_resource_metadata(
2797 resource_url: &str,
2798 server_url: &str,
2799 config: &OAuthConfig,
2800) -> serde_json::Value {
2801 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2806 let auth_server = server_url;
2807 serde_json::json!({
2808 "resource": resource_url,
2809 "authorization_servers": [auth_server],
2810 "scopes_supported": scopes,
2811 "bearer_methods_supported": ["header"]
2812 })
2813}
2814
2815#[must_use]
2820pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
2821 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2822 let mut meta = serde_json::json!({
2823 "issuer": &config.issuer,
2824 "authorization_endpoint": format!("{server_url}/authorize"),
2825 "token_endpoint": format!("{server_url}/token"),
2826 "registration_endpoint": format!("{server_url}/register"),
2827 "response_types_supported": ["code"],
2828 "grant_types_supported": ["authorization_code", "refresh_token"],
2829 "code_challenge_methods_supported": ["S256"],
2830 "scopes_supported": scopes,
2831 "token_endpoint_auth_methods_supported": ["none"],
2832 });
2833 if let Some(proxy) = &config.proxy
2834 && proxy.expose_admin_endpoints
2835 && let Some(obj) = meta.as_object_mut()
2836 {
2837 if proxy.introspection_url.is_some() {
2838 obj.insert(
2839 "introspection_endpoint".into(),
2840 serde_json::Value::String(format!("{server_url}/introspect")),
2841 );
2842 }
2843 if proxy.revocation_url.is_some() {
2844 obj.insert(
2845 "revocation_endpoint".into(),
2846 serde_json::Value::String(format!("{server_url}/revoke")),
2847 );
2848 }
2849 if proxy.require_auth_on_admin_endpoints {
2850 obj.insert(
2851 "introspection_endpoint_auth_methods_supported".into(),
2852 serde_json::json!(["bearer"]),
2853 );
2854 obj.insert(
2855 "revocation_endpoint_auth_methods_supported".into(),
2856 serde_json::json!(["bearer"]),
2857 );
2858 }
2859 }
2860 meta
2861}
2862
2863#[must_use]
2876pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
2877 use axum::{
2878 http::{StatusCode, header},
2879 response::IntoResponse,
2880 };
2881
2882 let upstream_query = rewrite_client_auth_params(query, &proxy.client_id);
2884 let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
2885
2886 (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
2887}
2888
2889pub async fn handle_token(
2895 http: &OauthHttpClient,
2896 proxy: &OAuthProxyConfig,
2897 body: &str,
2898) -> axum::response::Response {
2899 use axum::{
2900 http::{StatusCode, header},
2901 response::IntoResponse,
2902 };
2903
2904 let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id);
2906
2907 if let Some(ref secret) = proxy.client_secret {
2909 use std::fmt::Write;
2910
2911 use secrecy::ExposeSecret;
2912 let _ = write!(
2913 upstream_body,
2914 "&client_secret={}",
2915 urlencoding::encode(secret.expose_secret())
2916 );
2917 }
2918
2919 let result = http
2920 .send_screened(
2921 &proxy.token_url,
2922 http.credential_client
2923 .post(&proxy.token_url)
2924 .header("Content-Type", "application/x-www-form-urlencoded")
2925 .body(upstream_body),
2926 )
2927 .await;
2928
2929 match result {
2930 Ok(resp) => {
2931 let status =
2932 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
2933 let Ok(body_bytes) =
2934 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
2935 else {
2936 return oauth_error_response(
2937 StatusCode::BAD_GATEWAY,
2938 "server_error",
2939 "upstream response too large or unreadable",
2940 );
2941 };
2942 (
2943 status,
2944 [(header::CONTENT_TYPE, "application/json")],
2945 body_bytes,
2946 )
2947 .into_response()
2948 }
2949 Err(e) => {
2950 tracing::error!(error = %e, "OAuth token proxy request failed");
2951 (
2952 StatusCode::BAD_GATEWAY,
2953 [(header::CONTENT_TYPE, "application/json")],
2954 "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
2955 )
2956 .into_response()
2957 }
2958 }
2959}
2960
2961#[must_use]
2968pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
2969 let mut resp = serde_json::json!({
2970 "client_id": proxy.client_id,
2971 "token_endpoint_auth_method": "none",
2972 });
2973 if let Some(uris) = body.get("redirect_uris")
2974 && let Some(obj) = resp.as_object_mut()
2975 {
2976 obj.insert("redirect_uris".into(), uris.clone());
2977 }
2978 if let Some(name) = body.get("client_name")
2979 && let Some(obj) = resp.as_object_mut()
2980 {
2981 obj.insert("client_name".into(), name.clone());
2982 }
2983 resp
2984}
2985
2986pub async fn handle_introspect(
2992 http: &OauthHttpClient,
2993 proxy: &OAuthProxyConfig,
2994 body: &str,
2995) -> axum::response::Response {
2996 let Some(ref url) = proxy.introspection_url else {
2997 return oauth_error_response(
2998 axum::http::StatusCode::NOT_FOUND,
2999 "not_supported",
3000 "introspection endpoint is not configured",
3001 );
3002 };
3003 proxy_oauth_admin_request(http, proxy, url, body).await
3004}
3005
3006pub async fn handle_revoke(
3013 http: &OauthHttpClient,
3014 proxy: &OAuthProxyConfig,
3015 body: &str,
3016) -> axum::response::Response {
3017 let Some(ref url) = proxy.revocation_url else {
3018 return oauth_error_response(
3019 axum::http::StatusCode::NOT_FOUND,
3020 "not_supported",
3021 "revocation endpoint is not configured",
3022 );
3023 };
3024 proxy_oauth_admin_request(http, proxy, url, body).await
3025}
3026
3027async fn proxy_oauth_admin_request(
3031 http: &OauthHttpClient,
3032 proxy: &OAuthProxyConfig,
3033 upstream_url: &str,
3034 body: &str,
3035) -> axum::response::Response {
3036 use axum::{
3037 http::{StatusCode, header},
3038 response::IntoResponse,
3039 };
3040
3041 let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id);
3042 if let Some(ref secret) = proxy.client_secret {
3043 use std::fmt::Write;
3044
3045 use secrecy::ExposeSecret;
3046 let _ = write!(
3047 upstream_body,
3048 "&client_secret={}",
3049 urlencoding::encode(secret.expose_secret())
3050 );
3051 }
3052
3053 let result = http
3054 .send_screened(
3055 upstream_url,
3056 http.credential_client
3057 .post(upstream_url)
3058 .header("Content-Type", "application/x-www-form-urlencoded")
3059 .body(upstream_body),
3060 )
3061 .await;
3062
3063 match result {
3064 Ok(resp) => {
3065 let status =
3066 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3067 let content_type = resp
3068 .headers()
3069 .get(header::CONTENT_TYPE)
3070 .and_then(|v| v.to_str().ok())
3071 .unwrap_or("application/json")
3072 .to_owned();
3073 let Ok(body_bytes) =
3074 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
3075 else {
3076 return oauth_error_response(
3077 StatusCode::BAD_GATEWAY,
3078 "server_error",
3079 "upstream response too large or unreadable",
3080 );
3081 };
3082 (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
3083 }
3084 Err(e) => {
3085 tracing::error!(error = %e, url = %upstream_url, "OAuth admin proxy request failed");
3086 oauth_error_response(
3087 StatusCode::BAD_GATEWAY,
3088 "server_error",
3089 "upstream endpoint unreachable",
3090 )
3091 }
3092 }
3093}
3094
3095async fn read_response_capped(
3105 mut resp: reqwest::Response,
3106 max_bytes: u64,
3107 context: &str,
3108) -> Result<Vec<u8>, ()> {
3109 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3110 let mut body = Vec::with_capacity(initial_capacity);
3111 loop {
3112 match resp.chunk().await {
3113 Ok(Some(chunk)) => {
3114 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3115 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3116 if body_len.saturating_add(chunk_len) > max_bytes {
3117 tracing::warn!(
3118 context = context,
3119 max_bytes = max_bytes,
3120 "upstream OAuth response exceeded size cap; failing closed"
3121 );
3122 return Err(());
3123 }
3124 body.extend_from_slice(&chunk);
3125 }
3126 Ok(None) => return Ok(body),
3127 Err(error) => {
3128 tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
3129 return Err(());
3130 }
3131 }
3132 }
3133}
3134
3135fn oauth_error_response(
3136 status: axum::http::StatusCode,
3137 error: &str,
3138 description: &str,
3139) -> axum::response::Response {
3140 use axum::{http::header, response::IntoResponse};
3141 let body = serde_json::json!({
3142 "error": error,
3143 "error_description": description,
3144 });
3145 (
3146 status,
3147 [(header::CONTENT_TYPE, "application/json")],
3148 body.to_string(),
3149 )
3150 .into_response()
3151}
3152
3153#[derive(Debug, Deserialize)]
3159struct OAuthErrorResponse {
3160 error: String,
3161 error_description: Option<String>,
3162}
3163
3164fn sanitize_oauth_error_code(raw: &str) -> &'static str {
3171 match raw {
3172 "invalid_request" => "invalid_request",
3173 "invalid_client" => "invalid_client",
3174 "invalid_grant" => "invalid_grant",
3175 "unauthorized_client" => "unauthorized_client",
3176 "unsupported_grant_type" => "unsupported_grant_type",
3177 "invalid_scope" => "invalid_scope",
3178 "temporarily_unavailable" => "temporarily_unavailable",
3179 "invalid_target" => "invalid_target",
3181 _ => "server_error",
3184 }
3185}
3186
3187pub async fn exchange_token(
3199 http: &OauthHttpClient,
3200 config: &TokenExchangeConfig,
3201 subject_token: &str,
3202) -> Result<ExchangedToken, crate::error::McpxError> {
3203 use secrecy::ExposeSecret;
3204
3205 let client = http.client_for(config);
3206 let mut req = client
3207 .post(&config.token_url)
3208 .header("Content-Type", "application/x-www-form-urlencoded")
3209 .header("Accept", "application/json");
3210
3211 if config.client_cert.is_none()
3220 && let Some(ref secret) = config.client_secret
3221 {
3222 use base64::Engine;
3223 let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
3224 "{}:{}",
3225 urlencoding::encode(&config.client_id),
3226 urlencoding::encode(secret.expose_secret()),
3227 ));
3228 req = req.header("Authorization", format!("Basic {credentials}"));
3229 }
3230
3231 let form_body = build_exchange_form(config, subject_token);
3232
3233 let resp = http
3234 .send_screened(&config.token_url, req.body(form_body))
3235 .await
3236 .map_err(|e| {
3237 tracing::error!(error = %e, "token exchange request failed");
3238 crate::error::McpxError::Auth("server_error".into())
3240 })?;
3241
3242 let status = resp.status();
3243 let body_bytes =
3244 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
3245 .await
3246 .map_err(|()| {
3247 crate::error::McpxError::Auth("server_error".into())
3249 })?;
3250
3251 if !status.is_success() {
3252 core::hint::cold_path();
3253 let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
3256 let short_code = parsed
3257 .as_ref()
3258 .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
3259 if let Some(ref e) = parsed {
3260 tracing::warn!(
3261 status = %status,
3262 upstream_error = %e.error,
3263 upstream_error_description = e.error_description.as_deref().unwrap_or(""),
3264 client_code = %short_code,
3265 "token exchange rejected by authorization server",
3266 );
3267 } else {
3268 tracing::warn!(
3269 status = %status,
3270 client_code = %short_code,
3271 "token exchange rejected (unparseable upstream body)",
3272 );
3273 }
3274 return Err(crate::error::McpxError::Auth(short_code.into()));
3275 }
3276
3277 let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
3278 tracing::error!(error = %e, "failed to parse token exchange response");
3279 crate::error::McpxError::Auth("server_error".into())
3282 })?;
3283
3284 log_exchanged_token(&exchanged);
3285
3286 Ok(exchanged)
3287}
3288
3289fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
3292 let body = format!(
3293 "grant_type={}&subject_token={}&subject_token_type={}&requested_token_type={}&audience={}",
3294 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
3295 urlencoding::encode(subject_token),
3296 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3297 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3298 urlencoding::encode(&config.audience),
3299 );
3300 if config.client_secret.is_none() {
3301 format!(
3302 "{body}&client_id={}",
3303 urlencoding::encode(&config.client_id)
3304 )
3305 } else {
3306 body
3307 }
3308}
3309
3310fn log_exchanged_token(exchanged: &ExchangedToken) {
3313 use base64::Engine;
3314
3315 if !looks_like_jwt(&exchanged.access_token) {
3316 tracing::debug!(
3317 token_len = exchanged.access_token.len(),
3318 issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
3319 expires_in = exchanged.expires_in,
3320 "exchanged token (opaque)",
3321 );
3322 return;
3323 }
3324 let Some(payload) = exchanged.access_token.split('.').nth(1) else {
3325 return;
3326 };
3327 let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
3328 return;
3329 };
3330 let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
3331 return;
3332 };
3333 tracing::debug!(
3334 sub = fmt_json_str(claims.get("sub")),
3335 aud = %fmt_json_aud(claims.get("aud")),
3336 azp = fmt_json_str(claims.get("azp")),
3337 iss = fmt_json_str(claims.get("iss")),
3338 expires_in = exchanged.expires_in,
3339 "exchanged token claims (JWT)",
3340 );
3341}
3342
3343const CLIENT_AUTH_PARAMS: [&str; 4] = [
3349 "client_id",
3350 "client_secret",
3351 "client_assertion",
3352 "client_assertion_type",
3353];
3354
3355fn rewrite_client_auth_params(params: &str, upstream_client_id: &str) -> String {
3377 let mut out = url::form_urlencoded::Serializer::new(String::new());
3378 for (key, value) in url::form_urlencoded::parse(params.as_bytes()) {
3379 if CLIENT_AUTH_PARAMS.contains(&key.as_ref()) {
3380 continue;
3381 }
3382 out.append_pair(&key, &value);
3383 }
3384 out.append_pair("client_id", upstream_client_id);
3385 out.finish()
3386}
3387
3388#[cfg(test)]
3389mod tests {
3390 use std::sync::Arc;
3391
3392 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3393
3394 use super::*;
3395
3396 fn decoded_pairs(form: &str) -> Vec<(String, String)> {
3410 url::form_urlencoded::parse(form.as_bytes())
3411 .map(|(k, v)| (k.into_owned(), v.into_owned()))
3412 .collect()
3413 }
3414
3415 #[test]
3416 fn rewrite_drops_percent_encoded_client_id_key() {
3417 let out = rewrite_client_auth_params("%63lient_id=attacker&scope=read", "proxy-id");
3418 let pairs = decoded_pairs(&out);
3419 let client_ids: Vec<&String> = pairs
3420 .iter()
3421 .filter(|(k, _)| k == "client_id")
3422 .map(|(_, v)| v)
3423 .collect();
3424 assert_eq!(client_ids, vec!["proxy-id"], "smuggled client_id survived");
3425 }
3426
3427 #[test]
3428 fn rewrite_drops_underscore_encoded_client_id_key() {
3429 let out = rewrite_client_auth_params("client%5Fid=attacker&scope=read", "proxy-id");
3430 let pairs = decoded_pairs(&out);
3431 assert!(
3432 !pairs.iter().any(|(_, v)| v == "attacker"),
3433 "smuggled client_id survived: {pairs:?}"
3434 );
3435 }
3436
3437 #[test]
3438 fn rewrite_drops_caller_supplied_client_secret() {
3439 let out =
3440 rewrite_client_auth_params("client_secret=attacker-secret&scope=read", "proxy-id");
3441 let pairs = decoded_pairs(&out);
3442 assert!(
3443 !pairs.iter().any(|(k, _)| k == "client_secret"),
3444 "caller client_secret survived: {pairs:?}"
3445 );
3446 }
3447
3448 #[test]
3449 fn rewrite_drops_caller_supplied_client_assertion() {
3450 let out = rewrite_client_auth_params(
3451 "client_assertion=ey.evil&client_assertion_type=urn:evil&scope=read",
3452 "proxy-id",
3453 );
3454 let pairs = decoded_pairs(&out);
3455 assert!(
3456 !pairs
3457 .iter()
3458 .any(|(k, _)| k == "client_assertion" || k == "client_assertion_type"),
3459 "caller client assertion survived: {pairs:?}"
3460 );
3461 }
3462
3463 #[test]
3464 fn rewrite_collapses_duplicate_client_id_to_proxy_value() {
3465 let out = rewrite_client_auth_params("client_id=a&client_id=b&scope=read", "proxy-id");
3466 let pairs = decoded_pairs(&out);
3467 let client_ids: Vec<&String> = pairs
3468 .iter()
3469 .filter(|(k, _)| k == "client_id")
3470 .map(|(_, v)| v)
3471 .collect();
3472 assert_eq!(client_ids, vec!["proxy-id"]);
3473 }
3474
3475 #[test]
3476 fn rewrite_preserves_non_client_params_in_order_with_duplicates() {
3477 let out = rewrite_client_auth_params(
3478 "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
3479 "proxy-id",
3480 );
3481 let pairs = decoded_pairs(&out);
3482 let non_client: Vec<(String, String)> = pairs
3483 .into_iter()
3484 .filter(|(k, _)| k != "client_id")
3485 .collect();
3486 assert_eq!(
3487 non_client,
3488 vec![
3489 ("scope".to_owned(), "read".to_owned()),
3490 ("resource".to_owned(), "a".to_owned()),
3491 ("state".to_owned(), "xyz".to_owned()),
3492 ("resource".to_owned(), "b".to_owned()),
3493 ("code_verifier".to_owned(), "v".to_owned()),
3494 ]
3495 );
3496 }
3497
3498 #[test]
3499 fn rewrite_roundtrips_values_with_special_characters() {
3500 let input = url::form_urlencoded::Serializer::new(String::new())
3501 .append_pair("state", "a&b=c+d")
3502 .append_pair("scope", "rรฉad โ")
3503 .finish();
3504 let out = rewrite_client_auth_params(&input, "proxy-id");
3505 let pairs = decoded_pairs(&out);
3506 assert!(pairs.contains(&("state".to_owned(), "a&b=c+d".to_owned())));
3507 assert!(pairs.contains(&("scope".to_owned(), "rรฉad โ".to_owned())));
3508 }
3509
3510 #[test]
3511 fn rewrite_injects_client_id_when_absent() {
3512 let out = rewrite_client_auth_params("scope=read", "proxy-id");
3513 assert!(decoded_pairs(&out).contains(&("client_id".to_owned(), "proxy-id".to_owned())));
3514 }
3515
3516 #[test]
3517 fn looks_like_jwt_valid() {
3518 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
3520 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3521 let token = format!("{header}.{payload}.signature");
3522 assert!(looks_like_jwt(&token));
3523 }
3524
3525 #[test]
3526 fn looks_like_jwt_rejects_opaque_token() {
3527 assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
3528 }
3529
3530 #[test]
3531 fn looks_like_jwt_rejects_two_segments() {
3532 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
3533 let token = format!("{header}.payload");
3534 assert!(!looks_like_jwt(&token));
3535 }
3536
3537 #[test]
3538 fn looks_like_jwt_rejects_four_segments() {
3539 assert!(!looks_like_jwt("a.b.c.d"));
3540 }
3541
3542 #[test]
3543 fn looks_like_jwt_rejects_no_alg() {
3544 let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
3545 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3546 let token = format!("{header}.{payload}.sig");
3547 assert!(!looks_like_jwt(&token));
3548 }
3549
3550 #[test]
3551 fn protected_resource_metadata_shape() {
3552 let config = OAuthConfig {
3553 require_subject: false,
3554 issuer: "https://auth.example.com".into(),
3555 audience: "https://mcp.example.com/mcp".into(),
3556 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
3557 scopes: vec![
3558 ScopeMapping {
3559 scope: "mcp:read".into(),
3560 role: "viewer".into(),
3561 },
3562 ScopeMapping {
3563 scope: "mcp:admin".into(),
3564 role: "ops".into(),
3565 },
3566 ],
3567 role_claim: None,
3568 role_mappings: vec![],
3569 jwks_cache_ttl: "10m".into(),
3570 proxy: None,
3571 token_exchange: None,
3572 ca_cert_path: None,
3573 allow_http_oauth_urls: false,
3574 max_jwks_keys: default_max_jwks_keys(),
3575 #[allow(
3576 deprecated,
3577 reason = "test fixture: explicit value for the deprecated field"
3578 )]
3579 strict_audience_validation: None,
3580 audience_validation_mode: None,
3581 jwks_max_response_bytes: default_jwks_max_bytes(),
3582 ssrf_allowlist: None,
3583 };
3584 let meta = protected_resource_metadata(
3585 "https://mcp.example.com/mcp",
3586 "https://mcp.example.com",
3587 &config,
3588 );
3589 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
3590 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
3591 assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
3592 assert_eq!(meta["bearer_methods_supported"][0], "header");
3593 }
3594
3595 fn validation_https_config() -> OAuthConfig {
3600 OAuthConfig::builder(
3601 "https://auth.example.com",
3602 "mcp",
3603 "https://auth.example.com/.well-known/jwks.json",
3604 )
3605 .build()
3606 }
3607
3608 #[test]
3609 fn validate_accepts_all_https_urls() {
3610 let cfg = validation_https_config();
3611 cfg.validate().expect("all-HTTPS config must validate");
3612 }
3613
3614 #[test]
3615 fn validate_rejects_empty_audience() {
3616 let mut cfg = validation_https_config();
3617 cfg.audience = String::new();
3618 let err = cfg.validate().expect_err("empty audience must be rejected");
3619 assert!(
3620 err.to_string().contains("oauth.audience"),
3621 "error must reference oauth.audience; got {err}"
3622 );
3623 }
3624
3625 #[test]
3626 fn oauth_config_partial_table_deserializes_then_validate_rejects_empty_fields() {
3627 let toml_src = r#"
3628role_claim = "realm_access.roles"
3629
3630[[role_mappings]]
3631claim_value = "mcp-admin"
3632role = "admin"
3633"#;
3634 let cfg: OAuthConfig = toml::from_str(toml_src).expect(
3635 "partial [oauth] table without issuer/audience/jwks_uri must deserialize via serde(default)",
3636 );
3637 assert_eq!(cfg.issuer, "", "omitted issuer must default to empty");
3638 assert_eq!(cfg.audience, "", "omitted audience must default to empty");
3639 assert_eq!(cfg.jwks_uri, "", "omitted jwks_uri must default to empty");
3640 assert_eq!(cfg.role_claim.as_deref(), Some("realm_access.roles"));
3641 assert_eq!(cfg.role_mappings.len(), 1);
3642 cfg.validate().expect_err(
3643 "empty issuer/jwks_uri/audience must still fail validate() (parse-don't-validate)",
3644 );
3645 }
3646
3647 #[test]
3648 fn validate_rejects_unparseable_jwks_cache_ttl() {
3649 let mut cfg = validation_https_config();
3650 cfg.jwks_cache_ttl = "not-a-duration".into();
3651 let err = cfg
3652 .validate()
3653 .expect_err("malformed jwks_cache_ttl must be rejected");
3654 let msg = err.to_string();
3655 assert!(
3656 msg.contains("jwks_cache_ttl"),
3657 "error must reference offending field; got {msg:?}"
3658 );
3659 }
3660
3661 #[test]
3662 fn validate_rejects_http_jwks_uri() {
3663 let mut cfg = validation_https_config();
3664 cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
3665 let err = cfg.validate().expect_err("http jwks_uri must be rejected");
3666 let msg = err.to_string();
3667 assert!(
3668 msg.contains("oauth.jwks_uri") && msg.contains("https"),
3669 "error must reference offending field + scheme requirement; got {msg:?}"
3670 );
3671 }
3672
3673 #[test]
3674 fn validate_rejects_http_proxy_authorize_url() {
3675 let mut cfg = validation_https_config();
3676 cfg.proxy = Some(
3677 OAuthProxyConfig::builder(
3678 "http://idp.example.com/authorize", "https://idp.example.com/token",
3680 "client",
3681 )
3682 .build(),
3683 );
3684 let err = cfg
3685 .validate()
3686 .expect_err("http authorize_url must be rejected");
3687 assert!(
3688 err.to_string().contains("oauth.proxy.authorize_url"),
3689 "error must reference proxy.authorize_url; got {err}"
3690 );
3691 }
3692
3693 #[test]
3694 fn validate_rejects_http_proxy_token_url() {
3695 let mut cfg = validation_https_config();
3696 cfg.proxy = Some(
3697 OAuthProxyConfig::builder(
3698 "https://idp.example.com/authorize",
3699 "http://idp.example.com/token", "client",
3701 )
3702 .build(),
3703 );
3704 let err = cfg.validate().expect_err("http token_url must be rejected");
3705 assert!(
3706 err.to_string().contains("oauth.proxy.token_url"),
3707 "error must reference proxy.token_url; got {err}"
3708 );
3709 }
3710
3711 #[test]
3712 fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
3713 let mut cfg = validation_https_config();
3714 cfg.proxy = Some(
3715 OAuthProxyConfig::builder(
3716 "https://idp.example.com/authorize",
3717 "https://idp.example.com/token",
3718 "client",
3719 )
3720 .introspection_url("http://idp.example.com/introspect")
3721 .build(),
3722 );
3723 let err = cfg
3724 .validate()
3725 .expect_err("http introspection_url must be rejected");
3726 assert!(err.to_string().contains("oauth.proxy.introspection_url"));
3727
3728 let mut cfg = validation_https_config();
3729 cfg.proxy = Some(
3730 OAuthProxyConfig::builder(
3731 "https://idp.example.com/authorize",
3732 "https://idp.example.com/token",
3733 "client",
3734 )
3735 .revocation_url("http://idp.example.com/revoke")
3736 .build(),
3737 );
3738 let err = cfg
3739 .validate()
3740 .expect_err("http revocation_url must be rejected");
3741 assert!(err.to_string().contains("oauth.proxy.revocation_url"));
3742 }
3743
3744 #[test]
3747 fn validate_rejects_exposed_admin_endpoints_without_auth() {
3748 let mut cfg = validation_https_config();
3749 cfg.proxy = Some(
3750 OAuthProxyConfig::builder(
3751 "https://idp.example.com/authorize",
3752 "https://idp.example.com/token",
3753 "client",
3754 )
3755 .introspection_url("https://idp.example.com/introspect")
3756 .expose_admin_endpoints(true)
3757 .build(),
3758 );
3759 let err = cfg
3760 .validate()
3761 .expect_err("expose_admin_endpoints without auth must fail");
3762 let msg = err.to_string();
3763 assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
3764 assert!(
3765 msg.contains("allow_unauthenticated_admin_endpoints"),
3766 "{msg}"
3767 );
3768 }
3769
3770 #[test]
3771 fn validate_accepts_exposed_admin_endpoints_with_auth() {
3772 let mut cfg = validation_https_config();
3773 cfg.proxy = Some(
3774 OAuthProxyConfig::builder(
3775 "https://idp.example.com/authorize",
3776 "https://idp.example.com/token",
3777 "client",
3778 )
3779 .introspection_url("https://idp.example.com/introspect")
3780 .expose_admin_endpoints(true)
3781 .require_auth_on_admin_endpoints(true)
3782 .build(),
3783 );
3784 cfg.validate()
3785 .expect("authed admin endpoints must validate");
3786 }
3787
3788 #[test]
3789 fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
3790 let mut cfg = validation_https_config();
3791 cfg.proxy = Some(
3792 OAuthProxyConfig::builder(
3793 "https://idp.example.com/authorize",
3794 "https://idp.example.com/token",
3795 "client",
3796 )
3797 .introspection_url("https://idp.example.com/introspect")
3798 .expose_admin_endpoints(true)
3799 .allow_unauthenticated_admin_endpoints(true)
3800 .build(),
3801 );
3802 cfg.validate()
3803 .expect("explicit unauth opt-out must validate");
3804 }
3805
3806 #[test]
3807 fn validate_accepts_unexposed_admin_endpoints_without_auth() {
3808 let mut cfg = validation_https_config();
3811 cfg.proxy = Some(
3812 OAuthProxyConfig::builder(
3813 "https://idp.example.com/authorize",
3814 "https://idp.example.com/token",
3815 "client",
3816 )
3817 .introspection_url("https://idp.example.com/introspect")
3818 .build(),
3819 );
3820 cfg.validate()
3821 .expect("unexposed admin endpoints must validate");
3822 }
3823
3824 #[test]
3825 fn validate_rejects_http_token_exchange_url() {
3826 let mut cfg = validation_https_config();
3827 cfg.token_exchange = Some(TokenExchangeConfig::new(
3828 "http://idp.example.com/token".into(), "client".into(),
3830 None,
3831 None,
3832 "downstream".into(),
3833 ));
3834 let err = cfg
3835 .validate()
3836 .expect_err("http token_exchange.token_url must be rejected");
3837 assert!(
3838 err.to_string().contains("oauth.token_exchange.token_url"),
3839 "error must reference token_exchange.token_url; got {err}"
3840 );
3841 }
3842
3843 #[test]
3844 fn validate_rejects_unparseable_url() {
3845 let mut cfg = validation_https_config();
3846 cfg.jwks_uri = "not a url".into();
3847 let err = cfg
3848 .validate()
3849 .expect_err("unparseable URL must be rejected");
3850 assert!(err.to_string().contains("invalid URL"));
3851 }
3852
3853 #[test]
3854 fn validate_rejects_non_http_scheme() {
3855 let mut cfg = validation_https_config();
3856 cfg.jwks_uri = "file:///etc/passwd".into();
3857 let err = cfg.validate().expect_err("file:// scheme must be rejected");
3858 let msg = err.to_string();
3859 assert!(
3860 msg.contains("must use https scheme") && msg.contains("file"),
3861 "error must reject non-http(s) schemes; got {msg:?}"
3862 );
3863 }
3864
3865 #[test]
3866 fn validate_accepts_http_with_escape_hatch() {
3867 let mut cfg = OAuthConfig::builder(
3872 "http://auth.local",
3873 "mcp",
3874 "http://auth.local/.well-known/jwks.json",
3875 )
3876 .allow_http_oauth_urls(true)
3877 .build();
3878 cfg.proxy = Some(
3879 OAuthProxyConfig::builder(
3880 "http://idp.local/authorize",
3881 "http://idp.local/token",
3882 "client",
3883 )
3884 .introspection_url("http://idp.local/introspect")
3885 .revocation_url("http://idp.local/revoke")
3886 .build(),
3887 );
3888 cfg.token_exchange = Some(TokenExchangeConfig::new(
3889 "http://idp.local/token".into(),
3890 "client".into(),
3891 Some(secrecy::SecretString::new("dev-secret".into())),
3892 None,
3893 "downstream".into(),
3894 ));
3895 cfg.validate()
3896 .expect("escape hatch must permit http on all URL fields");
3897 }
3898
3899 #[test]
3900 fn validate_with_escape_hatch_still_rejects_unparseable() {
3901 let mut cfg = validation_https_config();
3904 cfg.allow_http_oauth_urls = true;
3905 cfg.jwks_uri = "::not-a-url::".into();
3906 cfg.validate()
3907 .expect_err("escape hatch must NOT bypass URL parsing");
3908 }
3909
3910 #[tokio::test]
3911 async fn jwks_cache_rejects_redirect_downgrade_to_http() {
3912 rustls::crypto::ring::default_provider()
3927 .install_default()
3928 .ok();
3929
3930 let policy = reqwest::redirect::Policy::custom(|attempt| {
3931 if attempt.url().scheme() != "https" {
3932 attempt.error("redirect to non-HTTPS URL refused")
3933 } else if attempt.previous().len() >= 2 {
3934 attempt.error("too many redirects (max 2)")
3935 } else {
3936 attempt.follow()
3937 }
3938 });
3939 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
3946 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
3947 let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
3948 crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
3949 );
3950 let client = reqwest::Client::builder()
3951 .no_proxy()
3952 .dns_resolver(Arc::clone(&resolver))
3953 .timeout(Duration::from_secs(5))
3954 .connect_timeout(Duration::from_secs(3))
3955 .redirect(policy)
3956 .build()
3957 .expect("test client builds");
3958
3959 let mock = wiremock::MockServer::start().await;
3960 wiremock::Mock::given(wiremock::matchers::method("GET"))
3961 .and(wiremock::matchers::path("/jwks.json"))
3962 .respond_with(
3963 wiremock::ResponseTemplate::new(302)
3964 .insert_header("location", "http://example.invalid/jwks.json"),
3965 )
3966 .mount(&mock)
3967 .await;
3968
3969 let url = format!("{}/jwks.json", mock.uri());
3978 let err = client
3979 .get(&url)
3980 .send()
3981 .await
3982 .expect_err("redirect policy must reject scheme downgrade");
3983 let chain = format!("{err:#}");
3984 assert!(
3985 chain.contains("redirect to non-HTTPS URL refused")
3986 || chain.to_lowercase().contains("redirect"),
3987 "error must surface redirect-policy rejection; got {chain:?}"
3988 );
3989 }
3990
3991 use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
3996
3997 fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
3999 let mut rng = rsa::rand_core::OsRng;
4000 let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
4001 let private_pem = private_key
4002 .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
4003 .expect("PKCS8 PEM export")
4004 .to_string();
4005
4006 let public_key = private_key.to_public_key();
4007 let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
4008 let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
4009
4010 let jwks = serde_json::json!({
4011 "keys": [{
4012 "kty": "RSA",
4013 "use": "sig",
4014 "alg": "RS256",
4015 "kid": kid,
4016 "n": n,
4017 "e": e
4018 }]
4019 });
4020
4021 (private_pem, jwks)
4022 }
4023
4024 fn mint_token(
4026 private_pem: &str,
4027 kid: &str,
4028 issuer: &str,
4029 audience: &str,
4030 subject: &str,
4031 scope: &str,
4032 ) -> String {
4033 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4034 .expect("encoding key from PEM");
4035 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4036 header.kid = Some(kid.into());
4037
4038 let now = jsonwebtoken::get_current_timestamp();
4039 let claims = serde_json::json!({
4040 "iss": issuer,
4041 "aud": audience,
4042 "sub": subject,
4043 "scope": scope,
4044 "exp": now + 3600,
4045 "iat": now,
4046 });
4047
4048 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4049 }
4050
4051 fn mint_token_without_sub(
4053 private_pem: &str,
4054 kid: &str,
4055 issuer: &str,
4056 audience: &str,
4057 scope: &str,
4058 ) -> String {
4059 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4060 .expect("encoding key from PEM");
4061 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4062 header.kid = Some(kid.into());
4063 let now = jsonwebtoken::get_current_timestamp();
4064 let claims = serde_json::json!({
4065 "iss": issuer,
4066 "aud": audience,
4067 "scope": scope,
4068 "exp": now + 3600,
4069 "iat": now,
4070 });
4071 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4072 }
4073
4074 fn test_config(jwks_uri: &str) -> OAuthConfig {
4075 OAuthConfig {
4076 require_subject: false,
4077 issuer: "https://auth.test.local".into(),
4078 audience: "https://mcp.test.local/mcp".into(),
4079 jwks_uri: jwks_uri.into(),
4080 scopes: vec![
4081 ScopeMapping {
4082 scope: "mcp:read".into(),
4083 role: "viewer".into(),
4084 },
4085 ScopeMapping {
4086 scope: "mcp:admin".into(),
4087 role: "ops".into(),
4088 },
4089 ],
4090 role_claim: None,
4091 role_mappings: vec![],
4092 jwks_cache_ttl: "5m".into(),
4093 proxy: None,
4094 token_exchange: None,
4095 ca_cert_path: None,
4096 allow_http_oauth_urls: true,
4097 max_jwks_keys: default_max_jwks_keys(),
4098 #[allow(
4099 deprecated,
4100 reason = "test fixture: explicit value for the deprecated field"
4101 )]
4102 strict_audience_validation: None,
4103 audience_validation_mode: None,
4104 jwks_max_response_bytes: default_jwks_max_bytes(),
4105 ssrf_allowlist: None,
4106 }
4107 }
4108
4109 fn test_cache(config: &OAuthConfig) -> JwksCache {
4110 JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
4111 }
4112
4113 async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
4120 let kid = "test-h2-stale";
4121 let (pem, jwks) = generate_test_keypair(kid);
4122 let mock_server = wiremock::MockServer::start().await;
4123 wiremock::Mock::given(wiremock::matchers::method("GET"))
4124 .and(wiremock::matchers::path("/jwks.json"))
4125 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4126 .mount(&mock_server)
4127 .await;
4128 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4129 let mut config = test_config(&jwks_uri);
4130 config.jwks_cache_ttl = ttl.into();
4131 let cache = test_cache(&config);
4132 cache.__test_refresh_now().await.expect("prime JWKS cache");
4133 assert!(cache.__test_has_kid(kid).await, "kid must be primed");
4134
4135 mock_server.reset().await;
4136 wiremock::Mock::given(wiremock::matchers::method("GET"))
4137 .and(wiremock::matchers::path("/jwks.json"))
4138 .respond_with(wiremock::ResponseTemplate::new(503))
4139 .mount(&mock_server)
4140 .await;
4141
4142 let token = mint_token(
4143 &pem,
4144 kid,
4145 "https://auth.test.local",
4146 "https://mcp.test.local/mcp",
4147 "h2-client",
4148 "mcp:read",
4149 );
4150 (cache, token, mock_server)
4151 }
4152
4153 #[tokio::test]
4154 async fn expired_jwks_fails_closed_when_refresh_fails() {
4155 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
4156 tokio::time::sleep(Duration::from_millis(200)).await;
4157 let failure = cache
4158 .validate_token_with_reason(&token)
4159 .await
4160 .expect_err("an expired cache whose refresh fails must not serve the stale key");
4161 assert_eq!(failure, JwtValidationFailure::Invalid);
4162 }
4163
4164 #[tokio::test]
4165 async fn fresh_jwks_still_validates() {
4166 let kid = "test-h2-fresh";
4167 let (pem, jwks) = generate_test_keypair(kid);
4168 let mock_server = wiremock::MockServer::start().await;
4169 wiremock::Mock::given(wiremock::matchers::method("GET"))
4170 .and(wiremock::matchers::path("/jwks.json"))
4171 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4172 .mount(&mock_server)
4173 .await;
4174 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4175 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4177 let token = mint_token(
4178 &pem,
4179 kid,
4180 "https://auth.test.local",
4181 "https://mcp.test.local/mcp",
4182 "h2-fresh-client",
4183 "mcp:read",
4184 );
4185 cache
4186 .validate_token_with_reason(&token)
4187 .await
4188 .expect("a reachable JWKS must still validate a matching token");
4189 }
4190
4191 #[tokio::test]
4192 async fn cooldown_active_plus_expired_fails_closed() {
4193 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
4194 tokio::time::sleep(Duration::from_millis(200)).await;
4195 assert_eq!(
4198 cache
4199 .validate_token_with_reason(&token)
4200 .await
4201 .expect_err("first attempt must fail closed"),
4202 JwtValidationFailure::Invalid,
4203 );
4204 let failure = cache
4207 .validate_token_with_reason(&token)
4208 .await
4209 .expect_err("cooldown-active + expired cache must still fail closed");
4210 assert_eq!(failure, JwtValidationFailure::Invalid);
4211 }
4212
4213 #[tokio::test]
4214 async fn valid_jwt_returns_identity() {
4215 let kid = "test-key-1";
4216 let (pem, jwks) = generate_test_keypair(kid);
4217
4218 let mock_server = wiremock::MockServer::start().await;
4219 wiremock::Mock::given(wiremock::matchers::method("GET"))
4220 .and(wiremock::matchers::path("/jwks.json"))
4221 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4222 .mount(&mock_server)
4223 .await;
4224
4225 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4226 let config = test_config(&jwks_uri);
4227 let cache = test_cache(&config);
4228
4229 let token = mint_token(
4230 &pem,
4231 kid,
4232 "https://auth.test.local",
4233 "https://mcp.test.local/mcp",
4234 "ci-bot",
4235 "mcp:read mcp:other",
4236 );
4237
4238 let identity = cache.validate_token(&token).await;
4239 assert!(identity.is_some(), "valid JWT should authenticate");
4240 let id = identity.unwrap();
4241 assert_eq!(id.name, "ci-bot");
4242 assert_eq!(id.role, "viewer"); assert_eq!(id.method, AuthMethod::OAuthJwt);
4244 }
4245
4246 #[test]
4249 fn unknown_kid_with_named_keys_rejected() {
4250 let mut keys = HashMap::new();
4251 keys.insert(
4252 "kid-1".to_owned(),
4253 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4254 );
4255 let cached = CachedKeys {
4256 keys,
4257 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4258 fetched_at: Instant::now(),
4259 ttl: Duration::from_secs(300),
4260 };
4261 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
4263 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
4267 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
4269 }
4270
4271 #[test]
4272 fn no_kid_token_matches_unnamed_key() {
4273 let mut keys = HashMap::new();
4274 keys.insert(
4275 "kid-1".to_owned(),
4276 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4277 );
4278 let cached = CachedKeys {
4279 keys,
4280 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4281 fetched_at: Instant::now(),
4282 ttl: Duration::from_secs(300),
4283 };
4284 assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
4287 }
4288
4289 #[tokio::test]
4290 async fn require_subject_rejects_subject_less() {
4291 let kid = "test-key-reqsub";
4292 let (pem, jwks) = generate_test_keypair(kid);
4293 let mock_server = wiremock::MockServer::start().await;
4294 wiremock::Mock::given(wiremock::matchers::method("GET"))
4295 .and(wiremock::matchers::path("/jwks.json"))
4296 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4297 .mount(&mock_server)
4298 .await;
4299 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4300 let mut config = test_config(&jwks_uri);
4301 config.require_subject = true;
4302 let cache = test_cache(&config);
4303
4304 let no_sub = mint_token_without_sub(
4305 &pem,
4306 kid,
4307 "https://auth.test.local",
4308 "https://mcp.test.local/mcp",
4309 "mcp:read",
4310 );
4311 assert!(
4312 cache.validate_token(&no_sub).await.is_none(),
4313 "require_subject must reject a token with no sub"
4314 );
4315
4316 let with_sub = mint_token(
4317 &pem,
4318 kid,
4319 "https://auth.test.local",
4320 "https://mcp.test.local/mcp",
4321 "svc",
4322 "mcp:read",
4323 );
4324 assert!(
4325 cache.validate_token(&with_sub).await.is_some(),
4326 "a token carrying sub must still be accepted"
4327 );
4328 }
4329
4330 #[tokio::test]
4331 async fn subject_less_token_accepted_by_default() {
4332 let kid = "test-key-nosub-default";
4333 let (pem, jwks) = generate_test_keypair(kid);
4334 let mock_server = wiremock::MockServer::start().await;
4335 wiremock::Mock::given(wiremock::matchers::method("GET"))
4336 .and(wiremock::matchers::path("/jwks.json"))
4337 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4338 .mount(&mock_server)
4339 .await;
4340 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4341 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4343 let no_sub = mint_token_without_sub(
4344 &pem,
4345 kid,
4346 "https://auth.test.local",
4347 "https://mcp.test.local/mcp",
4348 "mcp:read",
4349 );
4350 assert!(
4351 cache.validate_token(&no_sub).await.is_some(),
4352 "the default policy must accept a sub-less (client-credentials) token"
4353 );
4354 }
4355
4356 #[tokio::test]
4357 async fn credential_post_does_not_follow_redirect() {
4358 let mock = wiremock::MockServer::start().await;
4361 wiremock::Mock::given(wiremock::matchers::method("POST"))
4362 .and(wiremock::matchers::path("/followed"))
4363 .respond_with(wiremock::ResponseTemplate::new(200))
4364 .expect(0) .mount(&mock)
4366 .await;
4367 wiremock::Mock::given(wiremock::matchers::method("POST"))
4368 .and(wiremock::matchers::path("/token"))
4369 .respond_with(
4370 wiremock::ResponseTemplate::new(307)
4371 .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
4372 )
4373 .mount(&mock)
4374 .await;
4375
4376 let client = OauthHttpClient::build(None).expect("build oauth http client");
4377 let resp = client
4378 .credential_client
4379 .post(format!("{}/token", mock.uri()))
4380 .body("grant_type=client_credentials")
4381 .send()
4382 .await
4383 .expect("request sent");
4384 assert_eq!(
4385 resp.status().as_u16(),
4386 307,
4387 "credential client must surface the 307 rather than follow it"
4388 );
4389 }
4390
4391 #[tokio::test]
4392 async fn jwks_get_still_follows_screened_redirect() {
4393 let mock = wiremock::MockServer::start().await;
4399 wiremock::Mock::given(wiremock::matchers::method("GET"))
4400 .and(wiremock::matchers::path("/jwks.json"))
4401 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
4402 "location",
4403 format!("{}/jwks-final.json", mock.uri()).as_str(),
4404 ))
4405 .mount(&mock)
4406 .await;
4407 wiremock::Mock::given(wiremock::matchers::method("GET"))
4408 .and(wiremock::matchers::path("/jwks-final.json"))
4409 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
4410 .expect(1)
4411 .mount(&mock)
4412 .await;
4413
4414 let mut allowlist = OAuthSsrfAllowlist::default();
4415 allowlist.cidrs.push("127.0.0.0/8".into());
4416 allowlist.cidrs.push("::1/128".into());
4417 let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
4418 config.allow_http_oauth_urls = true;
4419 config.ssrf_allowlist = Some(allowlist);
4420
4421 let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
4422 let resp = client
4423 .inner
4424 .get(format!("{}/jwks.json", mock.uri()))
4425 .send()
4426 .await
4427 .expect("request sent");
4428 assert_eq!(
4429 resp.status().as_u16(),
4430 200,
4431 "JWKS client must follow the screened redirect to the final endpoint"
4432 );
4433 assert_eq!(resp.text().await.expect("response body"), "reached");
4434 }
4435
4436 #[tokio::test]
4437 async fn wrong_issuer_rejected() {
4438 let kid = "test-key-2";
4439 let (pem, jwks) = generate_test_keypair(kid);
4440
4441 let mock_server = wiremock::MockServer::start().await;
4442 wiremock::Mock::given(wiremock::matchers::method("GET"))
4443 .and(wiremock::matchers::path("/jwks.json"))
4444 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4445 .mount(&mock_server)
4446 .await;
4447
4448 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4449 let config = test_config(&jwks_uri);
4450 let cache = test_cache(&config);
4451
4452 let token = mint_token(
4453 &pem,
4454 kid,
4455 "https://wrong-issuer.example.com", "https://mcp.test.local/mcp",
4457 "attacker",
4458 "mcp:admin",
4459 );
4460
4461 assert!(cache.validate_token(&token).await.is_none());
4462 }
4463
4464 #[tokio::test]
4465 async fn wrong_audience_rejected() {
4466 let kid = "test-key-3";
4467 let (pem, jwks) = generate_test_keypair(kid);
4468
4469 let mock_server = wiremock::MockServer::start().await;
4470 wiremock::Mock::given(wiremock::matchers::method("GET"))
4471 .and(wiremock::matchers::path("/jwks.json"))
4472 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4473 .mount(&mock_server)
4474 .await;
4475
4476 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4477 let config = test_config(&jwks_uri);
4478 let cache = test_cache(&config);
4479
4480 let token = mint_token(
4481 &pem,
4482 kid,
4483 "https://auth.test.local",
4484 "https://wrong-audience.example.com", "attacker",
4486 "mcp:admin",
4487 );
4488
4489 assert!(cache.validate_token(&token).await.is_none());
4490 }
4491
4492 #[tokio::test]
4493 async fn expired_jwt_rejected() {
4494 let kid = "test-key-4";
4495 let (pem, jwks) = generate_test_keypair(kid);
4496
4497 let mock_server = wiremock::MockServer::start().await;
4498 wiremock::Mock::given(wiremock::matchers::method("GET"))
4499 .and(wiremock::matchers::path("/jwks.json"))
4500 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4501 .mount(&mock_server)
4502 .await;
4503
4504 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4505 let config = test_config(&jwks_uri);
4506 let cache = test_cache(&config);
4507
4508 let encoding_key =
4510 jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
4511 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4512 header.kid = Some(kid.into());
4513 let now = jsonwebtoken::get_current_timestamp();
4514 let claims = serde_json::json!({
4515 "iss": "https://auth.test.local",
4516 "aud": "https://mcp.test.local/mcp",
4517 "sub": "expired-bot",
4518 "scope": "mcp:read",
4519 "exp": now - 120,
4520 "iat": now - 3720,
4521 });
4522 let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
4523
4524 assert!(cache.validate_token(&token).await.is_none());
4525 }
4526
4527 #[tokio::test]
4528 async fn no_matching_scope_rejected() {
4529 let kid = "test-key-5";
4530 let (pem, jwks) = generate_test_keypair(kid);
4531
4532 let mock_server = wiremock::MockServer::start().await;
4533 wiremock::Mock::given(wiremock::matchers::method("GET"))
4534 .and(wiremock::matchers::path("/jwks.json"))
4535 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4536 .mount(&mock_server)
4537 .await;
4538
4539 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4540 let config = test_config(&jwks_uri);
4541 let cache = test_cache(&config);
4542
4543 let token = mint_token(
4544 &pem,
4545 kid,
4546 "https://auth.test.local",
4547 "https://mcp.test.local/mcp",
4548 "limited-bot",
4549 "some:other:scope", );
4551
4552 assert!(cache.validate_token(&token).await.is_none());
4553 }
4554
4555 #[tokio::test]
4556 async fn wrong_signing_key_rejected() {
4557 let kid = "test-key-6";
4558 let (_pem, jwks) = generate_test_keypair(kid);
4559
4560 let (attacker_pem, _) = generate_test_keypair(kid);
4562
4563 let mock_server = wiremock::MockServer::start().await;
4564 wiremock::Mock::given(wiremock::matchers::method("GET"))
4565 .and(wiremock::matchers::path("/jwks.json"))
4566 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4567 .mount(&mock_server)
4568 .await;
4569
4570 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4571 let config = test_config(&jwks_uri);
4572 let cache = test_cache(&config);
4573
4574 let token = mint_token(
4576 &attacker_pem,
4577 kid,
4578 "https://auth.test.local",
4579 "https://mcp.test.local/mcp",
4580 "attacker",
4581 "mcp:admin",
4582 );
4583
4584 assert!(cache.validate_token(&token).await.is_none());
4585 }
4586
4587 #[tokio::test]
4588 async fn admin_scope_maps_to_ops_role() {
4589 let kid = "test-key-7";
4590 let (pem, jwks) = generate_test_keypair(kid);
4591
4592 let mock_server = wiremock::MockServer::start().await;
4593 wiremock::Mock::given(wiremock::matchers::method("GET"))
4594 .and(wiremock::matchers::path("/jwks.json"))
4595 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4596 .mount(&mock_server)
4597 .await;
4598
4599 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4600 let config = test_config(&jwks_uri);
4601 let cache = test_cache(&config);
4602
4603 let token = mint_token(
4604 &pem,
4605 kid,
4606 "https://auth.test.local",
4607 "https://mcp.test.local/mcp",
4608 "admin-bot",
4609 "mcp:admin",
4610 );
4611
4612 let id = cache
4613 .validate_token(&token)
4614 .await
4615 .expect("should authenticate");
4616 assert_eq!(id.role, "ops");
4617 assert_eq!(id.name, "admin-bot");
4618 }
4619
4620 #[tokio::test]
4621 async fn jwks_server_down_returns_none() {
4622 let config = test_config("http://127.0.0.1:1/jwks.json");
4624 let cache = test_cache(&config);
4625
4626 let kid = "orphan-key";
4627 let (pem, _) = generate_test_keypair(kid);
4628 let token = mint_token(
4629 &pem,
4630 kid,
4631 "https://auth.test.local",
4632 "https://mcp.test.local/mcp",
4633 "bot",
4634 "mcp:read",
4635 );
4636
4637 assert!(cache.validate_token(&token).await.is_none());
4638 }
4639
4640 #[test]
4645 fn resolve_claim_path_flat_string() {
4646 let mut extra = HashMap::new();
4647 extra.insert(
4648 "scope".into(),
4649 serde_json::Value::String("mcp:read mcp:admin".into()),
4650 );
4651 let values = resolve_claim_path(&extra, "scope");
4652 assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
4653 }
4654
4655 #[test]
4656 fn resolve_claim_path_flat_array() {
4657 let mut extra = HashMap::new();
4658 extra.insert(
4659 "roles".into(),
4660 serde_json::json!(["mcp-admin", "mcp-viewer"]),
4661 );
4662 let values = resolve_claim_path(&extra, "roles");
4663 assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
4664 }
4665
4666 #[test]
4667 fn resolve_claim_path_nested_keycloak() {
4668 let mut extra = HashMap::new();
4669 extra.insert(
4670 "realm_access".into(),
4671 serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
4672 );
4673 let values = resolve_claim_path(&extra, "realm_access.roles");
4674 assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
4675 }
4676
4677 #[test]
4678 fn resolve_claim_path_missing_returns_empty() {
4679 let extra = HashMap::new();
4680 assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
4681 }
4682
4683 #[test]
4684 fn resolve_claim_path_numeric_leaf_returns_empty() {
4685 let mut extra = HashMap::new();
4686 extra.insert("count".into(), serde_json::json!(42));
4687 assert!(resolve_claim_path(&extra, "count").is_empty());
4688 }
4689
4690 fn make_claims(json: serde_json::Value) -> Claims {
4691 serde_json::from_value(json).expect("test claims must deserialize")
4692 }
4693
4694 #[test]
4695 fn first_class_scope_claim_splits_on_whitespace() {
4696 let claims = make_claims(serde_json::json!({
4697 "iss": "https://issuer.example.com",
4698 "exp": 9_999_999_999_u64,
4699 "scope": "read write admin",
4700 }));
4701 let values = first_class_claim_values(&claims, "scope");
4702 assert_eq!(values, vec!["read", "write", "admin"]);
4703 }
4704
4705 #[test]
4706 fn first_class_sub_claim_returns_single_value() {
4707 let claims = make_claims(serde_json::json!({
4708 "iss": "https://issuer.example.com",
4709 "exp": 9_999_999_999_u64,
4710 "sub": "service-account-orders",
4711 }));
4712 let values = first_class_claim_values(&claims, "sub");
4713 assert_eq!(values, vec!["service-account-orders"]);
4714 }
4715
4716 #[test]
4717 fn first_class_aud_claim_returns_every_audience() {
4718 let claims = make_claims(serde_json::json!({
4719 "iss": "https://issuer.example.com",
4720 "exp": 9_999_999_999_u64,
4721 "aud": ["api-a", "api-b"],
4722 }));
4723 let values = first_class_claim_values(&claims, "aud");
4724 assert_eq!(values, vec!["api-a", "api-b"]);
4725 }
4726
4727 #[test]
4728 fn first_class_unknown_path_returns_empty() {
4729 let claims = make_claims(serde_json::json!({
4730 "iss": "https://issuer.example.com",
4731 "exp": 9_999_999_999_u64,
4732 }));
4733 assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
4734 }
4735
4736 fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
4742 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4743 .expect("encoding key from PEM");
4744 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4745 header.kid = Some(kid.into());
4746 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4747 }
4748
4749 fn test_config_with_role_claim(
4750 jwks_uri: &str,
4751 role_claim: &str,
4752 role_mappings: Vec<RoleMapping>,
4753 ) -> OAuthConfig {
4754 OAuthConfig {
4755 require_subject: false,
4756 issuer: "https://auth.test.local".into(),
4757 audience: "https://mcp.test.local/mcp".into(),
4758 jwks_uri: jwks_uri.into(),
4759 scopes: vec![],
4760 role_claim: Some(role_claim.into()),
4761 role_mappings,
4762 jwks_cache_ttl: "5m".into(),
4763 proxy: None,
4764 token_exchange: None,
4765 ca_cert_path: None,
4766 allow_http_oauth_urls: true,
4767 max_jwks_keys: default_max_jwks_keys(),
4768 #[allow(
4769 deprecated,
4770 reason = "test fixture: explicit value for the deprecated field"
4771 )]
4772 strict_audience_validation: None,
4773 audience_validation_mode: None,
4774 jwks_max_response_bytes: default_jwks_max_bytes(),
4775 ssrf_allowlist: None,
4776 }
4777 }
4778
4779 #[tokio::test]
4780 async fn screen_oauth_target_rejects_literal_ip() {
4781 let err = screen_oauth_target(
4782 "https://127.0.0.1/jwks.json",
4783 false,
4784 &crate::ssrf::CompiledSsrfAllowlist::default(),
4785 )
4786 .await
4787 .expect_err("literal IPs must be rejected");
4788 let msg = err.to_string();
4789 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4790 }
4791
4792 #[tokio::test]
4793 async fn screen_oauth_target_rejects_private_dns_resolution() {
4794 let err = screen_oauth_target(
4795 "https://localhost/jwks.json",
4796 false,
4797 &crate::ssrf::CompiledSsrfAllowlist::default(),
4798 )
4799 .await
4800 .expect_err("localhost resolution must be rejected");
4801 let msg = err.to_string();
4802 assert!(
4803 msg.contains("blocked IP") && msg.contains("loopback"),
4804 "got {msg:?}"
4805 );
4806 }
4807
4808 #[tokio::test]
4809 async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
4810 let err = screen_oauth_target(
4811 "http://127.0.0.1/jwks.json",
4812 true,
4813 &crate::ssrf::CompiledSsrfAllowlist::default(),
4814 )
4815 .await
4816 .expect_err("literal IPs must still be rejected when http is allowed");
4817 let msg = err.to_string();
4818 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4819 }
4820
4821 #[tokio::test]
4822 async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
4823 let err = screen_oauth_target(
4824 "http://localhost/jwks.json",
4825 true,
4826 &crate::ssrf::CompiledSsrfAllowlist::default(),
4827 )
4828 .await
4829 .expect_err("private DNS resolution must still be rejected when http is allowed");
4830 let msg = err.to_string();
4831 assert!(
4832 msg.contains("blocked IP") && msg.contains("loopback"),
4833 "got {msg:?}"
4834 );
4835 }
4836
4837 #[tokio::test]
4838 async fn screen_oauth_target_allows_public_hostname() {
4839 screen_oauth_target(
4840 "https://example.com/.well-known/jwks.json",
4841 false,
4842 &crate::ssrf::CompiledSsrfAllowlist::default(),
4843 )
4844 .await
4845 .expect("public hostname should pass screening");
4846 }
4847
4848 fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
4854 let raw = OAuthSsrfAllowlist {
4855 hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
4856 cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
4857 };
4858 compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
4859 }
4860
4861 #[test]
4862 fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
4863 let raw = OAuthSsrfAllowlist {
4864 hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
4865 cidrs: vec![],
4866 };
4867 let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
4868 assert_eq!(compiled.host_count(), 1);
4869 assert!(compiled.host_allowed("rhbk.ops.example.com"));
4870 assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
4871 }
4872
4873 #[test]
4874 fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
4875 let raw = OAuthSsrfAllowlist {
4876 hosts: vec!["10.0.0.1".into()],
4877 cidrs: vec![],
4878 };
4879 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
4880 assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
4881 }
4882
4883 #[test]
4884 fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
4885 let raw = OAuthSsrfAllowlist {
4886 hosts: vec!["rhbk.ops.example.com:8443".into()],
4887 cidrs: vec![],
4888 };
4889 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
4890 assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
4891 }
4892
4893 #[test]
4896 fn internal_suffix_rejected_by_default() {
4897 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4898 for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
4899 assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
4900 }
4901 }
4902
4903 #[test]
4904 fn exact_allowlisted_internal_permitted() {
4905 let allow = make_allowlist(&["idp.internal"], &[]);
4906 assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
4907 assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
4908 }
4909
4910 #[test]
4911 fn subdomain_of_allowlisted_internal_still_rejected() {
4912 let allow = make_allowlist(&["idp.internal"], &[]);
4913 assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
4914 }
4915
4916 #[test]
4917 fn cidr_allowlist_does_not_bypass_suffix_denylist() {
4918 let allow = make_allowlist(&[], &["10.0.0.0/8"]);
4919 assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
4920 }
4921
4922 #[test]
4923 fn public_hostname_not_blocked_by_suffix() {
4924 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4925 assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
4926 }
4927
4928 #[test]
4929 fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
4930 let raw = OAuthSsrfAllowlist {
4931 hosts: vec![],
4932 cidrs: vec!["not-a-cidr".into()],
4933 };
4934 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
4935 assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
4936 }
4937
4938 #[test]
4939 fn validate_rejects_misconfigured_allowlist() {
4940 let mut cfg = OAuthConfig::builder(
4941 "https://auth.example.com/",
4942 "mcp",
4943 "https://auth.example.com/jwks.json",
4944 )
4945 .build();
4946 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4947 hosts: vec!["10.0.0.1".into()],
4948 cidrs: vec![],
4949 });
4950 let err = cfg
4951 .validate()
4952 .expect_err("literal IP host must be rejected");
4953 assert!(
4954 err.to_string().contains("oauth.ssrf_allowlist"),
4955 "got {err}"
4956 );
4957 }
4958
4959 #[tokio::test]
4960 async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
4961 let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
4965 let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
4966 .await
4967 .expect_err("loopback must still be blocked when not in allowlist");
4968 let msg = err.to_string();
4969 assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
4970 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4971 assert!(msg.contains("SECURITY.md"), "got {msg:?}");
4972 }
4973
4974 #[tokio::test]
4975 async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
4976 let err = screen_oauth_target(
4979 "https://localhost/jwks.json",
4980 false,
4981 &crate::ssrf::CompiledSsrfAllowlist::default(),
4982 )
4983 .await
4984 .expect_err("loopback rejection");
4985 let msg = err.to_string();
4986 assert!(msg.contains("blocked IP"), "got {msg:?}");
4987 assert!(msg.contains("loopback"), "got {msg:?}");
4988 assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4990 }
4991
4992 #[tokio::test]
4993 async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
4994 let allow = make_allowlist(&["localhost"], &[]);
4996 screen_oauth_target("https://localhost/jwks.json", false, &allow)
4997 .await
4998 .expect("allowlisted host must pass");
4999 }
5000
5001 #[tokio::test]
5002 async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
5003 let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
5006 screen_oauth_target("https://localhost/jwks.json", false, &allow)
5007 .await
5008 .expect("allowlisted CIDR must pass");
5009 }
5010
5011 #[tokio::test]
5012 async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
5013 let mut cfg = OAuthConfig::builder(
5014 "https://auth.example.com/",
5015 "mcp",
5016 "https://auth.example.com/jwks.json",
5017 )
5018 .build();
5019 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
5020 hosts: vec![],
5021 cidrs: vec!["bad-cidr".into()],
5022 });
5023 let Err(err) = JwksCache::new(&cfg) else {
5024 panic!("invalid CIDR must fail JwksCache::new")
5025 };
5026 let msg = err.to_string();
5027 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
5028 }
5029
5030 #[tokio::test]
5031 async fn jwks_cache_new_invalid_ttl_is_err() {
5032 let cfg = OAuthConfig::builder(
5035 "https://auth.example.com/",
5036 "mcp",
5037 "https://auth.example.com/jwks.json",
5038 )
5039 .jwks_cache_ttl("not-a-duration")
5040 .build();
5041 let Err(err) = JwksCache::new(&cfg) else {
5042 panic!("invalid jwks_cache_ttl must fail JwksCache::new")
5043 };
5044 let msg = err.to_string();
5045 assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
5046 }
5047
5048 #[tokio::test]
5049 async fn audience_default_is_strict() {
5050 let kid = "test-audience-azp-default";
5051 let (pem, jwks) = generate_test_keypair(kid);
5052
5053 let mock_server = wiremock::MockServer::start().await;
5054 wiremock::Mock::given(wiremock::matchers::method("GET"))
5055 .and(wiremock::matchers::path("/jwks.json"))
5056 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5057 .mount(&mock_server)
5058 .await;
5059
5060 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5061 let config = test_config(&jwks_uri);
5062 let cache = test_cache(&config);
5063
5064 let now = jsonwebtoken::get_current_timestamp();
5065 let token = mint_token_with_claims(
5066 &pem,
5067 kid,
5068 &serde_json::json!({
5069 "iss": "https://auth.test.local",
5070 "aud": "https://some-other-resource.example.com",
5071 "azp": "https://mcp.test.local/mcp",
5072 "sub": "compat-client",
5073 "scope": "mcp:read",
5074 "exp": now + 3600,
5075 "iat": now,
5076 }),
5077 );
5078
5079 let failure = cache
5080 .validate_token_with_reason(&token)
5081 .await
5082 .expect_err("the default policy is Strict and must reject an azp-only match");
5083 assert_eq!(failure, JwtValidationFailure::Invalid);
5084 }
5085
5086 #[tokio::test]
5087 async fn audience_warn_still_accepts_azp() {
5088 let kid = "test-audience-warn-optin";
5089 let (pem, jwks) = generate_test_keypair(kid);
5090
5091 let mock_server = wiremock::MockServer::start().await;
5092 wiremock::Mock::given(wiremock::matchers::method("GET"))
5093 .and(wiremock::matchers::path("/jwks.json"))
5094 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5095 .mount(&mock_server)
5096 .await;
5097
5098 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5099 let mut config = test_config(&jwks_uri);
5100 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
5101 let cache = test_cache(&config);
5102
5103 let now = jsonwebtoken::get_current_timestamp();
5104 let token = mint_token_with_claims(
5105 &pem,
5106 kid,
5107 &serde_json::json!({
5108 "iss": "https://auth.test.local",
5109 "aud": "https://some-other-resource.example.com",
5110 "azp": "https://mcp.test.local/mcp",
5111 "sub": "warn-optin-client",
5112 "scope": "mcp:read",
5113 "exp": now + 3600,
5114 "iat": now,
5115 }),
5116 );
5117
5118 cache.validate_token_with_reason(&token).await.expect(
5119 "the audience_validation_mode=warn opt-out must still accept an azp-only match",
5120 );
5121 }
5122
5123 #[tokio::test]
5124 async fn legacy_strict_false_maps_to_warn() {
5125 let kid = "test-audience-legacy-false";
5126 let (pem, jwks) = generate_test_keypair(kid);
5127
5128 let mock_server = wiremock::MockServer::start().await;
5129 wiremock::Mock::given(wiremock::matchers::method("GET"))
5130 .and(wiremock::matchers::path("/jwks.json"))
5131 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5132 .mount(&mock_server)
5133 .await;
5134
5135 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5136 let mut config = test_config(&jwks_uri);
5137 #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
5140 {
5141 config.strict_audience_validation = Some(false);
5142 }
5143 let cache = test_cache(&config);
5144
5145 let now = jsonwebtoken::get_current_timestamp();
5146 let token = mint_token_with_claims(
5147 &pem,
5148 kid,
5149 &serde_json::json!({
5150 "iss": "https://auth.test.local",
5151 "aud": "https://some-other-resource.example.com",
5152 "azp": "https://mcp.test.local/mcp",
5153 "sub": "legacy-false-client",
5154 "scope": "mcp:read",
5155 "exp": now + 3600,
5156 "iat": now,
5157 }),
5158 );
5159
5160 cache
5161 .validate_token_with_reason(&token)
5162 .await
5163 .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
5164 }
5165
5166 #[tokio::test]
5167 async fn aud_match_always_accepts() {
5168 let kid = "test-audience-aud-match";
5169 let (pem, jwks) = generate_test_keypair(kid);
5170
5171 let mock_server = wiremock::MockServer::start().await;
5172 wiremock::Mock::given(wiremock::matchers::method("GET"))
5173 .and(wiremock::matchers::path("/jwks.json"))
5174 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5175 .mount(&mock_server)
5176 .await;
5177
5178 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5179 let config = test_config(&jwks_uri); let cache = test_cache(&config);
5181
5182 let now = jsonwebtoken::get_current_timestamp();
5183 let token = mint_token_with_claims(
5184 &pem,
5185 kid,
5186 &serde_json::json!({
5187 "iss": "https://auth.test.local",
5188 "aud": "https://mcp.test.local/mcp",
5189 "sub": "aud-match-client",
5190 "scope": "mcp:read",
5191 "exp": now + 3600,
5192 "iat": now,
5193 }),
5194 );
5195
5196 cache
5197 .validate_token_with_reason(&token)
5198 .await
5199 .expect("a matching aud must be accepted even under the Strict default");
5200 }
5201
5202 #[tokio::test]
5203 async fn strict_audience_validation_rejects_azp_only_match() {
5204 let kid = "test-audience-azp-strict";
5205 let (pem, jwks) = generate_test_keypair(kid);
5206
5207 let mock_server = wiremock::MockServer::start().await;
5208 wiremock::Mock::given(wiremock::matchers::method("GET"))
5209 .and(wiremock::matchers::path("/jwks.json"))
5210 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5211 .mount(&mock_server)
5212 .await;
5213
5214 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5215 let mut config = test_config(&jwks_uri);
5216 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
5217 {
5218 config.strict_audience_validation = Some(true);
5219 }
5220 let cache = test_cache(&config);
5221
5222 let now = jsonwebtoken::get_current_timestamp();
5223 let token = mint_token_with_claims(
5224 &pem,
5225 kid,
5226 &serde_json::json!({
5227 "iss": "https://auth.test.local",
5228 "aud": "https://some-other-resource.example.com",
5229 "azp": "https://mcp.test.local/mcp",
5230 "sub": "strict-client",
5231 "scope": "mcp:read",
5232 "exp": now + 3600,
5233 "iat": now,
5234 }),
5235 );
5236
5237 let failure = cache
5238 .validate_token_with_reason(&token)
5239 .await
5240 .expect_err("strict audience validation must ignore azp fallback");
5241 assert_eq!(failure, JwtValidationFailure::Invalid);
5242 }
5243
5244 #[tokio::test]
5245 async fn warn_mode_accepts_azp_only_match_and_warns_once() {
5246 let kid = "test-audience-warn-mode";
5247 let (pem, jwks) = generate_test_keypair(kid);
5248
5249 let mock_server = wiremock::MockServer::start().await;
5250 wiremock::Mock::given(wiremock::matchers::method("GET"))
5251 .and(wiremock::matchers::path("/jwks.json"))
5252 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5253 .mount(&mock_server)
5254 .await;
5255
5256 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5257 let mut config = test_config(&jwks_uri);
5258 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
5259 let cache = test_cache(&config);
5260
5261 let now = jsonwebtoken::get_current_timestamp();
5262 let claims = serde_json::json!({
5263 "iss": "https://auth.test.local",
5264 "aud": "https://some-other-resource.example.com",
5265 "azp": "https://mcp.test.local/mcp",
5266 "sub": "warn-client",
5267 "scope": "mcp:read",
5268 "exp": now + 3600,
5269 "iat": now,
5270 });
5271 let token = mint_token_with_claims(&pem, kid, &claims);
5272
5273 let identity = cache
5274 .validate_token_with_reason(&token)
5275 .await
5276 .expect("warn mode must accept azp-only match");
5277 assert_eq!(identity.role, "viewer");
5278 assert!(
5279 cache.azp_fallback_warned.load(Ordering::Relaxed),
5280 "warn-once flag should be set after first azp-only match"
5281 );
5282
5283 let token2 = mint_token_with_claims(&pem, kid, &claims);
5284 cache
5285 .validate_token_with_reason(&token2)
5286 .await
5287 .expect("warn mode must continue accepting subsequent matches");
5288 assert!(
5289 cache.azp_fallback_warned.load(Ordering::Relaxed),
5290 "warn-once flag must remain set; the assertion guards against accidental clearing"
5291 );
5292 }
5293
5294 #[tokio::test]
5295 async fn permissive_mode_accepts_azp_only_match_silently() {
5296 let kid = "test-audience-permissive-mode";
5297 let (pem, jwks) = generate_test_keypair(kid);
5298
5299 let mock_server = wiremock::MockServer::start().await;
5300 wiremock::Mock::given(wiremock::matchers::method("GET"))
5301 .and(wiremock::matchers::path("/jwks.json"))
5302 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5303 .mount(&mock_server)
5304 .await;
5305
5306 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5307 let mut config = test_config(&jwks_uri);
5308 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5309 let cache = test_cache(&config);
5310
5311 let now = jsonwebtoken::get_current_timestamp();
5312 let token = mint_token_with_claims(
5313 &pem,
5314 kid,
5315 &serde_json::json!({
5316 "iss": "https://auth.test.local",
5317 "aud": "https://some-other-resource.example.com",
5318 "azp": "https://mcp.test.local/mcp",
5319 "sub": "permissive-client",
5320 "scope": "mcp:read",
5321 "exp": now + 3600,
5322 "iat": now,
5323 }),
5324 );
5325
5326 cache
5327 .validate_token_with_reason(&token)
5328 .await
5329 .expect("permissive mode must accept azp-only match");
5330 assert!(
5331 !cache.azp_fallback_warned.load(Ordering::Relaxed),
5332 "permissive mode must not flip the warn-once flag"
5333 );
5334 }
5335
5336 #[test]
5337 fn audience_validation_mode_overrides_legacy_bool() {
5338 let mut config = OAuthConfig::default();
5339 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5340 {
5341 config.strict_audience_validation = Some(false);
5342 }
5343 config.audience_validation_mode = Some(AudienceValidationMode::Strict);
5344 assert_eq!(
5345 config.effective_audience_validation_mode(),
5346 AudienceValidationMode::Strict,
5347 "explicit mode must override legacy false"
5348 );
5349
5350 let mut config = OAuthConfig::default();
5351 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5352 {
5353 config.strict_audience_validation = Some(true);
5354 }
5355 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5356 assert_eq!(
5357 config.effective_audience_validation_mode(),
5358 AudienceValidationMode::Permissive,
5359 "explicit mode must override legacy true"
5360 );
5361 }
5362
5363 #[test]
5364 fn audience_validation_mode_default_is_strict_when_unset() {
5365 let config = OAuthConfig::default();
5366 assert_eq!(
5367 config.effective_audience_validation_mode(),
5368 AudienceValidationMode::Strict,
5369 "unset mode + unset bool must resolve to Strict (the secure default)"
5370 );
5371 }
5372
5373 #[test]
5374 fn audience_validation_legacy_bool_true_resolves_to_strict() {
5375 let mut config = OAuthConfig::default();
5376 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
5377 {
5378 config.strict_audience_validation = Some(true);
5379 }
5380 assert_eq!(
5381 config.effective_audience_validation_mode(),
5382 AudienceValidationMode::Strict,
5383 "legacy bool=true must resolve to Strict for backward compat"
5384 );
5385 }
5386
5387 #[derive(Clone, Default)]
5388 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
5389
5390 impl CapturedLogs {
5391 fn contents(&self) -> String {
5392 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
5393 String::from_utf8(bytes).unwrap_or_default()
5394 }
5395 }
5396
5397 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
5398
5399 impl std::io::Write for CapturedLogsWriter {
5400 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
5401 if let Ok(mut guard) = self.0.lock() {
5402 guard.extend_from_slice(buf);
5403 }
5404 Ok(buf.len())
5405 }
5406
5407 fn flush(&mut self) -> std::io::Result<()> {
5408 Ok(())
5409 }
5410 }
5411
5412 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
5413 type Writer = CapturedLogsWriter;
5414
5415 fn make_writer(&'a self) -> Self::Writer {
5416 CapturedLogsWriter(Arc::clone(&self.0))
5417 }
5418 }
5419
5420 #[tokio::test]
5421 async fn jwks_response_size_cap_returns_none_and_logs_warning() {
5422 let kid = "oversized-jwks";
5423 let (_pem, jwks) = generate_test_keypair(kid);
5424 let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
5425 oversized_body.push_str(&" ".repeat(4096));
5426
5427 let mock_server = wiremock::MockServer::start().await;
5428 wiremock::Mock::given(wiremock::matchers::method("GET"))
5429 .and(wiremock::matchers::path("/jwks.json"))
5430 .respond_with(
5431 wiremock::ResponseTemplate::new(200)
5432 .insert_header("content-type", "application/json")
5433 .set_body_string(oversized_body),
5434 )
5435 .mount(&mock_server)
5436 .await;
5437
5438 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5439 let mut config = test_config(&jwks_uri);
5440 config.jwks_max_response_bytes = 256;
5441 let cache = test_cache(&config);
5442
5443 let logs = CapturedLogs::default();
5444 let subscriber = tracing_subscriber::fmt()
5445 .with_writer(logs.clone())
5446 .with_ansi(false)
5447 .without_time()
5448 .finish();
5449 let _guard = tracing::subscriber::set_default(subscriber);
5450
5451 let result = cache.fetch_jwks().await;
5452 assert!(result.is_none(), "oversized JWKS must be dropped");
5453 assert!(
5454 logs.contents()
5455 .contains("JWKS response exceeded configured size cap"),
5456 "expected cap-exceeded warning in logs"
5457 );
5458 }
5459
5460 #[tokio::test]
5464 async fn redirect_rejection_log_does_not_echo_credentials() {
5465 let mock_server = wiremock::MockServer::start().await;
5466 wiremock::Mock::given(wiremock::matchers::method("GET"))
5467 .and(wiremock::matchers::path("/jwks.json"))
5468 .respond_with(
5469 wiremock::ResponseTemplate::new(302)
5470 .insert_header("location", "https://u:p@redirect-target.example/next"),
5471 )
5472 .mount(&mock_server)
5473 .await;
5474
5475 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5476 let config = test_config(&jwks_uri);
5477 let cache = test_cache(&config);
5478
5479 let logs = CapturedLogs::default();
5480 let subscriber = tracing_subscriber::fmt()
5481 .with_writer(logs.clone())
5482 .with_ansi(false)
5483 .without_time()
5484 .finish();
5485 let _guard = tracing::subscriber::set_default(subscriber);
5486
5487 let result = cache.fetch_jwks().await;
5488 assert!(result.is_none(), "rejected redirect must fail the fetch");
5489 let contents = logs.contents();
5490 assert!(
5491 contents.contains("oauth redirect rejected"),
5492 "expected redirect-rejection warning in logs: {contents}"
5493 );
5494 assert!(
5495 !contents.contains("u:p"),
5496 "rejection log must not echo userinfo credentials: {contents}"
5497 );
5498 }
5499
5500 #[tokio::test]
5501 async fn role_claim_keycloak_nested_array() {
5502 let kid = "test-role-1";
5503 let (pem, jwks) = generate_test_keypair(kid);
5504
5505 let mock_server = wiremock::MockServer::start().await;
5506 wiremock::Mock::given(wiremock::matchers::method("GET"))
5507 .and(wiremock::matchers::path("/jwks.json"))
5508 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5509 .mount(&mock_server)
5510 .await;
5511
5512 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5513 let config = test_config_with_role_claim(
5514 &jwks_uri,
5515 "realm_access.roles",
5516 vec![
5517 RoleMapping {
5518 claim_value: "mcp-admin".into(),
5519 role: "ops".into(),
5520 },
5521 RoleMapping {
5522 claim_value: "mcp-viewer".into(),
5523 role: "viewer".into(),
5524 },
5525 ],
5526 );
5527 let cache = test_cache(&config);
5528
5529 let now = jsonwebtoken::get_current_timestamp();
5530 let token = mint_token_with_claims(
5531 &pem,
5532 kid,
5533 &serde_json::json!({
5534 "iss": "https://auth.test.local",
5535 "aud": "https://mcp.test.local/mcp",
5536 "sub": "keycloak-user",
5537 "exp": now + 3600,
5538 "iat": now,
5539 "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
5540 }),
5541 );
5542
5543 let id = cache
5544 .validate_token(&token)
5545 .await
5546 .expect("should authenticate");
5547 assert_eq!(id.name, "keycloak-user");
5548 assert_eq!(id.role, "ops");
5549 }
5550
5551 #[tokio::test]
5552 async fn role_claim_flat_roles_array() {
5553 let kid = "test-role-2";
5554 let (pem, jwks) = generate_test_keypair(kid);
5555
5556 let mock_server = wiremock::MockServer::start().await;
5557 wiremock::Mock::given(wiremock::matchers::method("GET"))
5558 .and(wiremock::matchers::path("/jwks.json"))
5559 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5560 .mount(&mock_server)
5561 .await;
5562
5563 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5564 let config = test_config_with_role_claim(
5565 &jwks_uri,
5566 "roles",
5567 vec![
5568 RoleMapping {
5569 claim_value: "MCP.Admin".into(),
5570 role: "ops".into(),
5571 },
5572 RoleMapping {
5573 claim_value: "MCP.Reader".into(),
5574 role: "viewer".into(),
5575 },
5576 ],
5577 );
5578 let cache = test_cache(&config);
5579
5580 let now = jsonwebtoken::get_current_timestamp();
5581 let token = mint_token_with_claims(
5582 &pem,
5583 kid,
5584 &serde_json::json!({
5585 "iss": "https://auth.test.local",
5586 "aud": "https://mcp.test.local/mcp",
5587 "sub": "azure-ad-user",
5588 "exp": now + 3600,
5589 "iat": now,
5590 "roles": ["MCP.Reader", "OtherApp.Admin"]
5591 }),
5592 );
5593
5594 let id = cache
5595 .validate_token(&token)
5596 .await
5597 .expect("should authenticate");
5598 assert_eq!(id.name, "azure-ad-user");
5599 assert_eq!(id.role, "viewer");
5600 }
5601
5602 #[tokio::test]
5603 async fn role_claim_no_matching_value_rejected() {
5604 let kid = "test-role-3";
5605 let (pem, jwks) = generate_test_keypair(kid);
5606
5607 let mock_server = wiremock::MockServer::start().await;
5608 wiremock::Mock::given(wiremock::matchers::method("GET"))
5609 .and(wiremock::matchers::path("/jwks.json"))
5610 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5611 .mount(&mock_server)
5612 .await;
5613
5614 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5615 let config = test_config_with_role_claim(
5616 &jwks_uri,
5617 "roles",
5618 vec![RoleMapping {
5619 claim_value: "mcp-admin".into(),
5620 role: "ops".into(),
5621 }],
5622 );
5623 let cache = test_cache(&config);
5624
5625 let now = jsonwebtoken::get_current_timestamp();
5626 let token = mint_token_with_claims(
5627 &pem,
5628 kid,
5629 &serde_json::json!({
5630 "iss": "https://auth.test.local",
5631 "aud": "https://mcp.test.local/mcp",
5632 "sub": "limited-user",
5633 "exp": now + 3600,
5634 "iat": now,
5635 "roles": ["some-other-role"]
5636 }),
5637 );
5638
5639 assert!(cache.validate_token(&token).await.is_none());
5640 }
5641
5642 #[tokio::test]
5643 async fn role_claim_space_separated_string() {
5644 let kid = "test-role-4";
5645 let (pem, jwks) = generate_test_keypair(kid);
5646
5647 let mock_server = wiremock::MockServer::start().await;
5648 wiremock::Mock::given(wiremock::matchers::method("GET"))
5649 .and(wiremock::matchers::path("/jwks.json"))
5650 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5651 .mount(&mock_server)
5652 .await;
5653
5654 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5655 let config = test_config_with_role_claim(
5656 &jwks_uri,
5657 "custom_scope",
5658 vec![
5659 RoleMapping {
5660 claim_value: "write".into(),
5661 role: "ops".into(),
5662 },
5663 RoleMapping {
5664 claim_value: "read".into(),
5665 role: "viewer".into(),
5666 },
5667 ],
5668 );
5669 let cache = test_cache(&config);
5670
5671 let now = jsonwebtoken::get_current_timestamp();
5672 let token = mint_token_with_claims(
5673 &pem,
5674 kid,
5675 &serde_json::json!({
5676 "iss": "https://auth.test.local",
5677 "aud": "https://mcp.test.local/mcp",
5678 "sub": "custom-client",
5679 "exp": now + 3600,
5680 "iat": now,
5681 "custom_scope": "read audit"
5682 }),
5683 );
5684
5685 let id = cache
5686 .validate_token(&token)
5687 .await
5688 .expect("should authenticate");
5689 assert_eq!(id.name, "custom-client");
5690 assert_eq!(id.role, "viewer");
5691 }
5692
5693 #[tokio::test]
5694 async fn scope_backward_compat_without_role_claim() {
5695 let kid = "test-compat-1";
5697 let (pem, jwks) = generate_test_keypair(kid);
5698
5699 let mock_server = wiremock::MockServer::start().await;
5700 wiremock::Mock::given(wiremock::matchers::method("GET"))
5701 .and(wiremock::matchers::path("/jwks.json"))
5702 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5703 .mount(&mock_server)
5704 .await;
5705
5706 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5707 let config = test_config(&jwks_uri); let cache = test_cache(&config);
5709
5710 let token = mint_token(
5711 &pem,
5712 kid,
5713 "https://auth.test.local",
5714 "https://mcp.test.local/mcp",
5715 "legacy-bot",
5716 "mcp:admin other:scope",
5717 );
5718
5719 let id = cache
5720 .validate_token(&token)
5721 .await
5722 .expect("should authenticate");
5723 assert_eq!(id.name, "legacy-bot");
5724 assert_eq!(id.role, "ops"); }
5726
5727 #[tokio::test]
5732 async fn jwks_refresh_deduplication() {
5733 let kid = "test-dedup";
5736 let (pem, jwks) = generate_test_keypair(kid);
5737
5738 let mock_server = wiremock::MockServer::start().await;
5739 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5740 .and(wiremock::matchers::path("/jwks.json"))
5741 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5742 .expect(1) .mount(&mock_server)
5744 .await;
5745
5746 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5747 let config = test_config(&jwks_uri);
5748 let cache = Arc::new(test_cache(&config));
5749
5750 let token = mint_token(
5752 &pem,
5753 kid,
5754 "https://auth.test.local",
5755 "https://mcp.test.local/mcp",
5756 "concurrent-bot",
5757 "mcp:read",
5758 );
5759
5760 let mut handles = Vec::new();
5761 for _ in 0..5 {
5762 let c = Arc::clone(&cache);
5763 let t = token.clone();
5764 handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
5765 }
5766
5767 for h in handles {
5768 let result = h.await.unwrap();
5769 assert!(result.is_some(), "all concurrent requests should succeed");
5770 }
5771
5772 }
5774
5775 #[tokio::test]
5776 async fn jwks_refresh_cooldown_blocks_rapid_requests() {
5777 let kid = "test-cooldown";
5780 let (_pem, jwks) = generate_test_keypair(kid);
5781
5782 let mock_server = wiremock::MockServer::start().await;
5783 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5784 .and(wiremock::matchers::path("/jwks.json"))
5785 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5786 .expect(1) .mount(&mock_server)
5788 .await;
5789
5790 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5791 let config = test_config(&jwks_uri);
5792 let cache = test_cache(&config);
5793
5794 let fake_token1 =
5796 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
5797 let _ = cache.validate_token(fake_token1).await;
5798
5799 let fake_token2 =
5802 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
5803 let _ = cache.validate_token(fake_token2).await;
5804
5805 let fake_token3 =
5807 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
5808 let _ = cache.validate_token(fake_token3).await;
5809
5810 }
5812
5813 fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
5816 OAuthProxyConfig {
5817 authorize_url: "https://example.invalid/auth".into(),
5818 token_url: token_url.into(),
5819 client_id: "mcp-client".into(),
5820 client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
5821 introspection_url: None,
5822 revocation_url: None,
5823 expose_admin_endpoints: false,
5824 require_auth_on_admin_endpoints: false,
5825 allow_unauthenticated_admin_endpoints: false,
5826 }
5827 }
5828
5829 fn test_http_client() -> OauthHttpClient {
5832 rustls::crypto::ring::default_provider()
5833 .install_default()
5834 .ok();
5835 let config = OAuthConfig::builder(
5836 "https://auth.test.local",
5837 "https://mcp.test.local/mcp",
5838 "https://auth.test.local/.well-known/jwks.json",
5839 )
5840 .allow_http_oauth_urls(true)
5841 .build();
5842 OauthHttpClient::with_config(&config)
5843 .expect("build test http client")
5844 .__test_allow_loopback_ssrf()
5845 }
5846
5847 #[tokio::test]
5848 async fn introspect_proxies_and_injects_client_credentials() {
5849 use wiremock::matchers::{body_string_contains, method, path};
5850
5851 let mock_server = wiremock::MockServer::start().await;
5852 wiremock::Mock::given(method("POST"))
5853 .and(path("/introspect"))
5854 .and(body_string_contains("client_id=mcp-client"))
5855 .and(body_string_contains("client_secret=shh"))
5856 .and(body_string_contains("token=abc"))
5857 .respond_with(
5858 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5859 "active": true,
5860 "scope": "read"
5861 })),
5862 )
5863 .expect(1)
5864 .mount(&mock_server)
5865 .await;
5866
5867 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5868 proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
5869
5870 let http = test_http_client();
5871 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5872 assert_eq!(resp.status(), 200);
5873 }
5874
5875 #[tokio::test]
5876 async fn token_proxy_fails_closed_on_oversized_upstream_response() {
5877 use http_body_util::BodyExt as _;
5878 use wiremock::matchers::{method, path};
5879
5880 let oversized = "x"
5882 .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
5883 let mock_server = wiremock::MockServer::start().await;
5884 wiremock::Mock::given(method("POST"))
5885 .and(path("/token"))
5886 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
5887 .expect(1)
5888 .mount(&mock_server)
5889 .await;
5890
5891 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5892 let http = test_http_client();
5893 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5894
5895 assert_eq!(
5897 resp.status(),
5898 502,
5899 "oversized upstream response must fail closed as 502"
5900 );
5901 let body = resp
5902 .into_body()
5903 .collect()
5904 .await
5905 .expect("collect body")
5906 .to_bytes();
5907 assert!(
5908 body.len() < 1024,
5909 "must return the small generic error body, not the oversized upstream body (got {} bytes)",
5910 body.len()
5911 );
5912 assert!(
5913 !body.windows(8).any(|w| w == b"xxxxxxxx"),
5914 "the oversized upstream payload must not be forwarded to the client"
5915 );
5916 }
5917
5918 #[tokio::test]
5919 async fn token_proxy_passes_through_normal_response() {
5920 use http_body_util::BodyExt as _;
5921 use wiremock::matchers::{method, path};
5922
5923 let mock_server = wiremock::MockServer::start().await;
5924 wiremock::Mock::given(method("POST"))
5925 .and(path("/token"))
5926 .respond_with(
5927 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5928 "access_token": "at-123",
5929 "token_type": "Bearer"
5930 })),
5931 )
5932 .expect(1)
5933 .mount(&mock_server)
5934 .await;
5935
5936 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5937 let http = test_http_client();
5938 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5939
5940 assert_eq!(
5941 resp.status(),
5942 200,
5943 "a normal-sized response must pass through"
5944 );
5945 let body = resp
5946 .into_body()
5947 .collect()
5948 .await
5949 .expect("collect body")
5950 .to_bytes();
5951 let json: serde_json::Value =
5952 serde_json::from_slice(&body).expect("upstream JSON preserved");
5953 assert_eq!(json["access_token"], "at-123");
5954 }
5955
5956 #[tokio::test]
5957 async fn introspect_returns_404_when_not_configured() {
5958 let proxy = proxy_cfg("https://example.invalid/token");
5959 let http = test_http_client();
5960 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5961 assert_eq!(resp.status(), 404);
5962 }
5963
5964 #[tokio::test]
5965 async fn revoke_proxies_and_returns_upstream_status() {
5966 use wiremock::matchers::{method, path};
5967
5968 let mock_server = wiremock::MockServer::start().await;
5969 wiremock::Mock::given(method("POST"))
5970 .and(path("/revoke"))
5971 .respond_with(wiremock::ResponseTemplate::new(200))
5972 .expect(1)
5973 .mount(&mock_server)
5974 .await;
5975
5976 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5977 proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
5978
5979 let http = test_http_client();
5980 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5981 assert_eq!(resp.status(), 200);
5982 }
5983
5984 #[tokio::test]
5985 async fn revoke_returns_404_when_not_configured() {
5986 let proxy = proxy_cfg("https://example.invalid/token");
5987 let http = test_http_client();
5988 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5989 assert_eq!(resp.status(), 404);
5990 }
5991
5992 #[test]
5993 fn metadata_advertises_endpoints_only_when_configured() {
5994 let mut cfg = test_config("https://auth.test.local/jwks.json");
5995 let m = authorization_server_metadata("https://mcp.local", &cfg);
5997 assert!(m.get("introspection_endpoint").is_none());
5998 assert!(m.get("revocation_endpoint").is_none());
5999
6000 let mut proxy = proxy_cfg("https://upstream.local/token");
6003 proxy.introspection_url = Some("https://upstream.local/introspect".into());
6004 proxy.revocation_url = Some("https://upstream.local/revoke".into());
6005 cfg.proxy = Some(proxy);
6006 let m = authorization_server_metadata("https://mcp.local", &cfg);
6007 assert!(
6008 m.get("introspection_endpoint").is_none(),
6009 "introspection must not be advertised when expose_admin_endpoints=false"
6010 );
6011 assert!(
6012 m.get("revocation_endpoint").is_none(),
6013 "revocation must not be advertised when expose_admin_endpoints=false"
6014 );
6015
6016 if let Some(p) = cfg.proxy.as_mut() {
6018 p.expose_admin_endpoints = true;
6019 p.revocation_url = None;
6020 }
6021 let m = authorization_server_metadata("https://mcp.local", &cfg);
6022 assert_eq!(
6023 m["introspection_endpoint"],
6024 serde_json::Value::String("https://mcp.local/introspect".into())
6025 );
6026 assert!(m.get("revocation_endpoint").is_none());
6027
6028 if let Some(p) = cfg.proxy.as_mut() {
6030 p.revocation_url = Some("https://upstream.local/revoke".into());
6031 }
6032 let m = authorization_server_metadata("https://mcp.local", &cfg);
6033 assert_eq!(
6034 m["revocation_endpoint"],
6035 serde_json::Value::String("https://mcp.local/revoke".into())
6036 );
6037 }
6038
6039 fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
6042 let mut cfg = validation_https_config();
6043 cfg.token_exchange = Some(tx);
6044 cfg
6045 }
6046
6047 fn tx_with(
6048 client_secret: Option<&str>,
6049 client_cert: Option<ClientCertConfig>,
6050 ) -> TokenExchangeConfig {
6051 TokenExchangeConfig::new(
6052 "https://idp.example.com/token".into(),
6053 "client".into(),
6054 client_secret.map(|s| secrecy::SecretString::new(s.into())),
6055 client_cert,
6056 "downstream".into(),
6057 )
6058 }
6059
6060 #[test]
6061 fn validate_rejects_token_exchange_without_client_auth() {
6062 let cfg = https_cfg_with_tx(tx_with(None, None));
6063 let err = cfg
6064 .validate()
6065 .expect_err("token_exchange without client auth must be rejected");
6066 let msg = err.to_string();
6067 assert!(
6068 msg.contains("requires client authentication"),
6069 "error must explain missing client auth; got {msg:?}"
6070 );
6071 }
6072
6073 #[test]
6074 fn validate_rejects_token_exchange_with_both_secret_and_cert() {
6075 let cc = ClientCertConfig {
6076 cert_path: PathBuf::from("/nonexistent/cert.pem"),
6077 key_path: PathBuf::from("/nonexistent/key.pem"),
6078 };
6079 let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
6080 let err = cfg
6081 .validate()
6082 .expect_err("client_secret + client_cert must be rejected");
6083 let msg = err.to_string();
6084 assert!(
6085 msg.contains("mutually") && msg.contains("exclusive"),
6086 "error must explain mutual exclusion; got {msg:?}"
6087 );
6088 }
6089
6090 #[cfg(not(feature = "oauth-mtls-client"))]
6091 #[test]
6092 fn validate_rejects_client_cert_without_feature() {
6093 let cc = ClientCertConfig {
6094 cert_path: PathBuf::from("/nonexistent/cert.pem"),
6095 key_path: PathBuf::from("/nonexistent/key.pem"),
6096 };
6097 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6098 let err = cfg
6099 .validate()
6100 .expect_err("client_cert without feature must be rejected");
6101 assert!(
6102 err.to_string().contains("oauth-mtls-client"),
6103 "error must reference the cargo feature; got {err}"
6104 );
6105 }
6106
6107 #[cfg(feature = "oauth-mtls-client")]
6108 #[test]
6109 fn validate_rejects_missing_client_cert_files() {
6110 let cc = ClientCertConfig {
6111 cert_path: PathBuf::from("/nonexistent/cert.pem"),
6112 key_path: PathBuf::from("/nonexistent/key.pem"),
6113 };
6114 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6115 let err = cfg
6116 .validate()
6117 .expect_err("missing cert file must be rejected");
6118 assert!(
6119 err.to_string().contains("unreadable"),
6120 "error must call out unreadable file; got {err}"
6121 );
6122 }
6123
6124 #[cfg(feature = "oauth-mtls-client")]
6125 #[test]
6126 fn validate_rejects_malformed_client_cert_pem() {
6127 let dir = std::env::temp_dir();
6128 let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
6129 let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
6130 std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
6131 std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
6132 let cc = ClientCertConfig {
6133 cert_path: cert.clone(),
6134 key_path: key.clone(),
6135 };
6136 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6137 let err = cfg.validate().expect_err("malformed PEM must be rejected");
6138 let _ = std::fs::remove_file(&cert);
6139 let _ = std::fs::remove_file(&key);
6140 assert!(
6141 err.to_string().contains("PEM parse failed"),
6142 "error must call out PEM parse failure; got {err}"
6143 );
6144 }
6145
6146 #[cfg(feature = "oauth-mtls-client")]
6147 fn write_self_signed_pem() -> (PathBuf, PathBuf) {
6148 let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
6149 let dir = std::env::temp_dir();
6150 let pid = std::process::id();
6151 let nonce: u64 = rand::random();
6152 let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
6153 let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
6154 std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
6155 std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
6156 (cert_path, key_path)
6157 }
6158
6159 #[cfg(feature = "oauth-mtls-client")]
6160 fn install_test_crypto_provider() {
6161 let _ = rustls::crypto::ring::default_provider().install_default();
6162 }
6163
6164 #[cfg(feature = "oauth-mtls-client")]
6165 #[test]
6166 fn validate_accepts_well_formed_client_cert() {
6167 install_test_crypto_provider();
6168 let (cert_path, key_path) = write_self_signed_pem();
6169 let cc = ClientCertConfig {
6170 cert_path: cert_path.clone(),
6171 key_path: key_path.clone(),
6172 };
6173 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6174 let res = cfg.validate();
6175 let _ = std::fs::remove_file(&cert_path);
6176 let _ = std::fs::remove_file(&key_path);
6177 res.expect("well-formed cert+key must validate");
6178 }
6179
6180 #[cfg(feature = "oauth-mtls-client")]
6181 #[test]
6182 fn client_for_returns_cached_mtls_client() {
6183 install_test_crypto_provider();
6184 let (cert_path, key_path) = write_self_signed_pem();
6185 let cc = ClientCertConfig {
6186 cert_path: cert_path.clone(),
6187 key_path: key_path.clone(),
6188 };
6189 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6190 let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
6191 let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
6192 let cert_client = http.client_for(tx_ref);
6193 let inner_client = http.client_for(&tx_with(Some("s"), None));
6194 let _ = std::fs::remove_file(&cert_path);
6195 let _ = std::fs::remove_file(&key_path);
6196 assert!(
6197 !std::ptr::eq(cert_client, inner_client),
6198 "client_for must return distinct clients for cert vs no-cert configs"
6199 );
6200 }
6201
6202 #[cfg(feature = "oauth-mtls-client")]
6203 #[test]
6204 fn client_for_falls_back_to_inner_when_cache_miss() {
6205 install_test_crypto_provider();
6206 let cfg = validation_https_config();
6207 let http = OauthHttpClient::with_config(&cfg).expect("build client");
6208 let unrelated_cc = ClientCertConfig {
6209 cert_path: PathBuf::from("/cache/miss/cert.pem"),
6210 key_path: PathBuf::from("/cache/miss/key.pem"),
6211 };
6212 let tx_unknown = tx_with(None, Some(unrelated_cc));
6213 let fallback = http.client_for(&tx_unknown);
6214 let inner = http.client_for(&tx_with(Some("s"), None));
6215 assert!(
6216 std::ptr::eq(fallback, inner),
6217 "cache miss must fall back to inner client"
6218 );
6219 }
6220}