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 #[allow(
272 dead_code,
273 reason = "screened-redirect JWKS/discovery client (every hop SSRF-screened). Post-M7, production credential traffic uses `credential_client` and JWKS fetching uses `JwksCache`, so in a minimal `oauth` build (no `test-helpers`) this field is consumed only by the redirect-policy regression tests (`__test_get`, `__test_inner_client`, `jwks_get_still_follows_screened_redirect`); retained to preserve the screened-redirect contract and its coverage."
274 )]
275 inner: reqwest::Client,
276 credential_client: reqwest::Client,
283 allow_http: bool,
284 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
289 #[cfg(feature = "oauth-mtls-client")]
294 mtls_clients: Arc<HashMap<MtlsClientKey, reqwest::Client>>,
295 #[cfg(any(test, feature = "test-helpers"))]
301 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
302}
303
304#[cfg(feature = "oauth-mtls-client")]
308#[derive(Debug, Clone, Hash, Eq, PartialEq)]
309struct MtlsClientKey {
310 cert_path: PathBuf,
311 key_path: PathBuf,
312}
313
314impl OauthHttpClient {
315 pub fn with_config(config: &OAuthConfig) -> Result<Self, crate::error::McpxError> {
333 Self::build(Some(config))
334 }
335
336 #[deprecated(
359 since = "1.2.1",
360 note = "use OauthHttpClient::with_config(&OAuthConfig) so token/introspect/revoke/exchange traffic inherits ca_cert_path and the allow_http_oauth_urls toggle"
361 )]
362 pub fn new() -> Result<Self, crate::error::McpxError> {
363 Self::build(None)
364 }
365
366 fn build(config: Option<&OAuthConfig>) -> Result<Self, crate::error::McpxError> {
369 rustls::crypto::ring::default_provider()
376 .install_default()
377 .ok();
378
379 let allow_http = config.is_some_and(|c| c.allow_http_oauth_urls);
380
381 let allowlist = match config.and_then(|c| c.ssrf_allowlist.as_ref()) {
386 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
387 crate::error::McpxError::Startup(format!("oauth http client: {e}"))
388 })?),
389 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
390 };
391
392 let redirect_allowlist = Arc::clone(&allowlist);
395
396 #[cfg(any(test, feature = "test-helpers"))]
400 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
401 Arc::new(AtomicBool::new(false));
402 #[cfg(not(any(test, feature = "test-helpers")))]
403 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
404
405 let resolver: Arc<dyn reqwest::dns::Resolve> =
406 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
407 Arc::clone(&allowlist),
408 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
412 test_bypass.clone(),
413 ));
414
415 let ca_pem: Option<Vec<u8>> = if let Some(cfg) = config
419 && let Some(ref ca_path) = cfg.ca_cert_path
420 {
421 Some(std::fs::read(ca_path).map_err(|e| {
422 crate::error::McpxError::Startup(format!(
423 "oauth http client: read ca_cert_path {}: {e}",
424 ca_path.display()
425 ))
426 })?)
427 } else {
428 None
429 };
430
431 let make_base = || -> Result<reqwest::ClientBuilder, crate::error::McpxError> {
435 let mut b = reqwest::Client::builder()
436 .no_proxy()
437 .dns_resolver(Arc::clone(&resolver))
438 .connect_timeout(Duration::from_secs(10))
439 .timeout(Duration::from_secs(30));
440 if let Some(ref pem) = ca_pem {
441 let cert = reqwest::tls::Certificate::from_pem(pem).map_err(|e| {
442 crate::error::McpxError::Startup(format!(
443 "oauth http client: parse ca_cert_path: {e}"
444 ))
445 })?;
446 b = b.add_root_certificate(cert);
447 }
448 Ok(b)
449 };
450
451 let inner =
455 make_base()?
456 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
457 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
458 Ok(()) => attempt.follow(),
459 Err(reason) => {
460 tracing::warn!(
461 reason = %reason,
462 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
463 "oauth redirect rejected"
464 );
465 attempt.error(reason)
466 }
467 }
468 }))
469 .build()
470 .map_err(|e| {
471 crate::error::McpxError::Startup(format!("oauth http client init: {e}"))
472 })?;
473
474 let credential_client = make_base()?
479 .redirect(reqwest::redirect::Policy::none())
480 .build()
481 .map_err(|e| {
482 crate::error::McpxError::Startup(format!("oauth credential client init: {e}"))
483 })?;
484
485 #[cfg(feature = "oauth-mtls-client")]
486 let mtls_clients = build_mtls_clients(config, &allowlist, &test_bypass)?;
487
488 Ok(Self {
489 inner,
490 credential_client,
491 allow_http,
492 allowlist,
493 #[cfg(feature = "oauth-mtls-client")]
494 mtls_clients,
495 #[cfg(any(test, feature = "test-helpers"))]
496 test_allow_loopback_ssrf: test_bypass,
497 })
498 }
499
500 async fn send_screened(
501 &self,
502 url: &str,
503 request: reqwest::RequestBuilder,
504 ) -> Result<reqwest::Response, crate::error::McpxError> {
505 #[cfg(any(test, feature = "test-helpers"))]
506 if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
507 screen_oauth_target_with_test_override(url, self.allow_http, &self.allowlist, true)
508 .await?;
509 } else {
510 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
511 }
512 #[cfg(not(any(test, feature = "test-helpers")))]
513 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
514 request.send().await.map_err(|error| {
515 crate::error::McpxError::Config(format!("oauth request {url}: {error}"))
516 })
517 }
518
519 #[cfg(any(test, feature = "test-helpers"))]
524 #[doc(hidden)]
525 #[must_use]
526 pub fn __test_allow_loopback_ssrf(self) -> Self {
527 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
530 self
531 }
532
533 #[cfg(any(test, feature = "test-helpers"))]
539 #[doc(hidden)]
540 pub async fn __test_get(&self, url: &str) -> reqwest::Result<reqwest::Response> {
541 self.inner.get(url).send().await
542 }
543
544 #[cfg(any(test, feature = "test-helpers"))]
550 #[doc(hidden)]
551 #[must_use]
552 pub fn __test_inner_client(&self) -> &reqwest::Client {
553 &self.inner
554 }
555
556 #[cfg(feature = "oauth-mtls-client")]
563 fn client_for(&self, cfg: &TokenExchangeConfig) -> &reqwest::Client {
564 if let Some(cc) = &cfg.client_cert {
565 let key = MtlsClientKey {
566 cert_path: cc.cert_path.clone(),
567 key_path: cc.key_path.clone(),
568 };
569 if let Some(client) = self.mtls_clients.get(&key) {
570 return client;
571 }
572 }
573 &self.credential_client
574 }
575
576 #[cfg(not(feature = "oauth-mtls-client"))]
577 fn client_for(&self, _cfg: &TokenExchangeConfig) -> &reqwest::Client {
578 &self.credential_client
579 }
580}
581
582impl std::fmt::Debug for OauthHttpClient {
583 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584 f.debug_struct("OauthHttpClient").finish_non_exhaustive()
585 }
586}
587
588#[derive(Debug, Clone, Default, Deserialize)]
648#[non_exhaustive]
649pub struct OAuthSsrfAllowlist {
650 #[serde(default)]
655 pub hosts: Vec<String>,
656 #[serde(default)]
662 pub cidrs: Vec<String>,
663}
664
665fn compile_oauth_ssrf_allowlist(
672 raw: &OAuthSsrfAllowlist,
673) -> Result<crate::ssrf::CompiledSsrfAllowlist, String> {
674 let mut hosts: Vec<String> = Vec::with_capacity(raw.hosts.len());
675 for (idx, entry) in raw.hosts.iter().enumerate() {
676 let trimmed = entry.trim();
677 if trimmed.is_empty() {
678 return Err(format!("oauth.ssrf_allowlist.hosts[{idx}]: empty entry"));
679 }
680 if trimmed.contains([':', '/', '@', '?', '#']) {
684 return Err(format!(
685 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: must be a bare DNS hostname \
686 (no scheme, port, path, userinfo, query, or fragment)"
687 ));
688 }
689 match url::Host::parse(trimmed) {
690 Ok(url::Host::Domain(_)) => {}
691 Ok(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
692 return Err(format!(
693 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: literal IPs are forbidden \
694 here -- list them via oauth.ssrf_allowlist.cidrs instead"
695 ));
696 }
697 Err(e) => {
698 return Err(format!(
699 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: invalid hostname: {e}"
700 ));
701 }
702 }
703 hosts.push(trimmed.to_ascii_lowercase());
704 }
705 hosts.sort();
706 hosts.dedup();
707
708 let mut cidrs = Vec::with_capacity(raw.cidrs.len());
709 for (idx, entry) in raw.cidrs.iter().enumerate() {
710 let parsed = crate::ssrf::CidrEntry::parse(entry)
711 .map_err(|e| format!("oauth.ssrf_allowlist.cidrs[{idx}]: {e}"))?;
712 cidrs.push(parsed);
713 }
714
715 Ok(crate::ssrf::CompiledSsrfAllowlist::new(hosts, cidrs))
716}
717
718#[derive(Debug, Clone, Deserialize)]
720#[non_exhaustive]
721pub struct OAuthConfig {
722 pub issuer: String,
724 pub audience: String,
726 pub jwks_uri: String,
728 #[serde(default)]
731 pub scopes: Vec<ScopeMapping>,
732 pub role_claim: Option<String>,
738 #[serde(default)]
741 pub role_mappings: Vec<RoleMapping>,
742 #[serde(default = "default_jwks_cache_ttl")]
745 pub jwks_cache_ttl: String,
746 pub proxy: Option<OAuthProxyConfig>,
750 pub token_exchange: Option<TokenExchangeConfig>,
755 #[serde(default)]
770 pub ca_cert_path: Option<PathBuf>,
771 #[serde(default)]
783 pub allow_http_oauth_urls: bool,
784 #[serde(default)]
793 pub ssrf_allowlist: Option<OAuthSsrfAllowlist>,
794 #[serde(default = "default_max_jwks_keys")]
798 pub max_jwks_keys: usize,
799 #[serde(default)]
804 pub require_subject: bool,
805 #[serde(default)]
814 #[deprecated(
815 since = "1.7.0",
816 note = "use `audience_validation_mode` instead; this field is consulted only when `audience_validation_mode` is None"
817 )]
818 pub strict_audience_validation: Option<bool>,
819 #[serde(default)]
828 pub audience_validation_mode: Option<AudienceValidationMode>,
829 #[serde(default = "default_jwks_max_bytes")]
833 pub jwks_max_response_bytes: u64,
834}
835
836fn default_jwks_cache_ttl() -> String {
837 "10m".into()
838}
839
840const fn default_max_jwks_keys() -> usize {
841 256
842}
843
844const fn default_jwks_max_bytes() -> u64 {
845 1024 * 1024
846}
847
848#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
865#[serde(rename_all = "snake_case")]
866#[non_exhaustive]
867pub enum AudienceValidationMode {
868 Permissive,
872 Warn,
875 #[default]
879 Strict,
880}
881
882impl AudienceValidationMode {
883 #[must_use]
888 pub(crate) const fn as_str(self) -> &'static str {
889 match self {
890 Self::Permissive => "permissive",
891 Self::Warn => "warn",
892 Self::Strict => "strict",
893 }
894 }
895}
896
897impl Default for OAuthConfig {
898 fn default() -> Self {
899 Self {
900 issuer: String::new(),
901 audience: String::new(),
902 jwks_uri: String::new(),
903 scopes: Vec::new(),
904 role_claim: None,
905 role_mappings: Vec::new(),
906 jwks_cache_ttl: default_jwks_cache_ttl(),
907 proxy: None,
908 token_exchange: None,
909 ca_cert_path: None,
910 allow_http_oauth_urls: false,
911 max_jwks_keys: default_max_jwks_keys(),
912 require_subject: false,
913 #[allow(
914 deprecated,
915 reason = "default-construct deprecated field for backward compat"
916 )]
917 strict_audience_validation: None,
918 audience_validation_mode: None,
919 jwks_max_response_bytes: default_jwks_max_bytes(),
920 ssrf_allowlist: None,
921 }
922 }
923}
924
925impl OAuthConfig {
926 #[must_use]
933 pub fn effective_audience_validation_mode(&self) -> AudienceValidationMode {
934 if let Some(mode) = self.audience_validation_mode {
935 return mode;
936 }
937 #[allow(deprecated, reason = "intentional: legacy flag resolution path")]
938 match self.strict_audience_validation {
939 Some(true) | None => AudienceValidationMode::Strict,
940 Some(false) => AudienceValidationMode::Warn,
941 }
942 }
943
944 pub fn builder(
950 issuer: impl Into<String>,
951 audience: impl Into<String>,
952 jwks_uri: impl Into<String>,
953 ) -> OAuthConfigBuilder {
954 OAuthConfigBuilder {
955 inner: Self {
956 issuer: issuer.into(),
957 audience: audience.into(),
958 jwks_uri: jwks_uri.into(),
959 ..Self::default()
960 },
961 }
962 }
963
964 pub fn validate(&self) -> Result<(), crate::error::McpxError> {
980 let allow_http = self.allow_http_oauth_urls;
981 let url = check_oauth_url("oauth.issuer", &self.issuer, allow_http)?;
982 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
983 return Err(crate::error::McpxError::Config(format!(
984 "oauth.issuer forbidden ({reason})"
985 )));
986 }
987 let url = check_oauth_url("oauth.jwks_uri", &self.jwks_uri, allow_http)?;
988 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
989 return Err(crate::error::McpxError::Config(format!(
990 "oauth.jwks_uri forbidden ({reason})"
991 )));
992 }
993 if let Some(proxy) = &self.proxy {
994 let url = check_oauth_url(
995 "oauth.proxy.authorize_url",
996 &proxy.authorize_url,
997 allow_http,
998 )?;
999 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1000 return Err(crate::error::McpxError::Config(format!(
1001 "oauth.proxy.authorize_url forbidden ({reason})"
1002 )));
1003 }
1004 let url = check_oauth_url("oauth.proxy.token_url", &proxy.token_url, allow_http)?;
1005 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1006 return Err(crate::error::McpxError::Config(format!(
1007 "oauth.proxy.token_url forbidden ({reason})"
1008 )));
1009 }
1010 if let Some(url) = &proxy.introspection_url {
1011 let parsed = check_oauth_url("oauth.proxy.introspection_url", url, allow_http)?;
1012 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1013 return Err(crate::error::McpxError::Config(format!(
1014 "oauth.proxy.introspection_url forbidden ({reason})"
1015 )));
1016 }
1017 }
1018 if let Some(url) = &proxy.revocation_url {
1019 let parsed = check_oauth_url("oauth.proxy.revocation_url", url, allow_http)?;
1020 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1021 return Err(crate::error::McpxError::Config(format!(
1022 "oauth.proxy.revocation_url forbidden ({reason})"
1023 )));
1024 }
1025 }
1026 if proxy.expose_admin_endpoints
1033 && !proxy.require_auth_on_admin_endpoints
1034 && !proxy.allow_unauthenticated_admin_endpoints
1035 {
1036 return Err(crate::error::McpxError::Config(
1037 "oauth.proxy: expose_admin_endpoints = true requires \
1038 require_auth_on_admin_endpoints = true (recommended) \
1039 or allow_unauthenticated_admin_endpoints = true \
1040 (explicit opt-out, only safe behind an authenticated \
1041 reverse proxy)"
1042 .into(),
1043 ));
1044 }
1045 }
1046 if let Some(tx) = &self.token_exchange {
1047 let url = check_oauth_url("oauth.token_exchange.token_url", &tx.token_url, allow_http)?;
1048 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1049 return Err(crate::error::McpxError::Config(format!(
1050 "oauth.token_exchange.token_url forbidden ({reason})"
1051 )));
1052 }
1053 validate_token_exchange_client_auth(tx)?;
1056 }
1057 if let Some(raw) = &self.ssrf_allowlist {
1061 let compiled = compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1062 crate::error::McpxError::Config(format!("oauth.ssrf_allowlist: {e}"))
1063 })?;
1064 if !compiled.is_empty() {
1065 tracing::warn!(
1066 host_count = compiled.host_count(),
1067 cidr_count = compiled.cidr_count(),
1068 "oauth.ssrf_allowlist is configured: private/loopback OAuth/JWKS targets \
1069 are now reachable. Cloud-metadata addresses remain blocked. \
1070 See SECURITY.md \"Operator allowlist\"."
1071 );
1072 }
1073 }
1074 humantime::parse_duration(&self.jwks_cache_ttl).map_err(|e| {
1077 crate::error::McpxError::Config(format!(
1078 "oauth.jwks_cache_ttl {:?} is not a valid humantime duration (e.g. \"10m\", \"1h30m\"): {e}",
1079 self.jwks_cache_ttl
1080 ))
1081 })?;
1082 Ok(())
1083 }
1084}
1085
1086fn validate_token_exchange_client_auth(
1092 tx: &TokenExchangeConfig,
1093) -> Result<(), crate::error::McpxError> {
1094 match (&tx.client_cert, tx.client_secret.is_some()) {
1095 (Some(_), true) => Err(crate::error::McpxError::Config(
1096 "oauth.token_exchange: client_cert and client_secret are mutually \
1097 exclusive (RFC 8705 ยง2). Set exactly one."
1098 .into(),
1099 )),
1100 (None, false) => Err(crate::error::McpxError::Config(
1101 "oauth.token_exchange: token exchange requires client authentication. \
1102 Set either client_secret (RFC 6749 ยง2.3.1) or client_cert (RFC 8705 ยง2)."
1103 .into(),
1104 )),
1105 (Some(cc), false) => validate_client_cert_config(cc),
1106 (None, true) => Ok(()),
1107 }
1108}
1109
1110fn validate_client_cert_config(cc: &ClientCertConfig) -> Result<(), crate::error::McpxError> {
1123 #[cfg(not(feature = "oauth-mtls-client"))]
1124 {
1125 let _ = cc;
1126 Err(crate::error::McpxError::Config(
1127 "oauth.token_exchange.client_cert requires the `oauth-mtls-client` cargo feature; \
1128 rebuild rmcp-server-kit with --features oauth-mtls-client (or have your \
1129 application crate enable it via `rmcp-server-kit/oauth-mtls-client`), or remove \
1130 the field"
1131 .into(),
1132 ))
1133 }
1134 #[cfg(feature = "oauth-mtls-client")]
1135 {
1136 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1137 tracing::warn!(error = %e, path = %cc.cert_path.display(), "client cert read failed");
1138 crate::error::McpxError::Config(format!(
1139 "oauth.token_exchange.client_cert.cert_path unreadable: {}",
1140 cc.cert_path.display()
1141 ))
1142 })?;
1143 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1144 tracing::warn!(error = %e, path = %cc.key_path.display(), "client cert key read failed");
1145 crate::error::McpxError::Config(format!(
1146 "oauth.token_exchange.client_cert.key_path unreadable: {}",
1147 cc.key_path.display()
1148 ))
1149 })?;
1150 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1151 combined.extend_from_slice(&cert_bytes);
1152 if !cert_bytes.ends_with(b"\n") {
1153 combined.push(b'\n');
1154 }
1155 combined.extend_from_slice(&key_bytes);
1156 let _identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1157 tracing::warn!(
1158 error = %e,
1159 cert_path = %cc.cert_path.display(),
1160 key_path = %cc.key_path.display(),
1161 "client cert PEM parse failed"
1162 );
1163 crate::error::McpxError::Config(format!(
1164 "oauth.token_exchange.client_cert: PEM parse failed (cert={}, key={})",
1165 cc.cert_path.display(),
1166 cc.key_path.display()
1167 ))
1168 })?;
1169 Ok(())
1170 }
1171}
1172
1173#[cfg(feature = "oauth-mtls-client")]
1181fn build_mtls_clients(
1182 config: Option<&OAuthConfig>,
1183 allowlist: &Arc<crate::ssrf::CompiledSsrfAllowlist>,
1184 test_bypass: &crate::ssrf_resolver::TestLoopbackBypass,
1185) -> Result<Arc<HashMap<MtlsClientKey, reqwest::Client>>, crate::error::McpxError> {
1186 let mut map: HashMap<MtlsClientKey, reqwest::Client> = HashMap::new();
1187 let Some(cfg) = config else {
1188 return Ok(Arc::new(map));
1189 };
1190 let Some(tx) = &cfg.token_exchange else {
1191 return Ok(Arc::new(map));
1192 };
1193 let Some(cc) = &tx.client_cert else {
1194 return Ok(Arc::new(map));
1195 };
1196
1197 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1198 crate::error::McpxError::Startup(format!(
1199 "oauth http client mTLS: read cert_path {}: {e}",
1200 cc.cert_path.display()
1201 ))
1202 })?;
1203 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1204 crate::error::McpxError::Startup(format!(
1205 "oauth http client mTLS: read key_path {}: {e}",
1206 cc.key_path.display()
1207 ))
1208 })?;
1209 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1210 combined.extend_from_slice(&cert_bytes);
1211 if !cert_bytes.ends_with(b"\n") {
1212 combined.push(b'\n');
1213 }
1214 combined.extend_from_slice(&key_bytes);
1215 let identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1216 crate::error::McpxError::Startup(format!(
1217 "oauth http client mTLS: PEM parse (cert={}, key={}): {e}",
1218 cc.cert_path.display(),
1219 cc.key_path.display()
1220 ))
1221 })?;
1222
1223 let resolver: Arc<dyn reqwest::dns::Resolve> =
1224 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1225 Arc::clone(allowlist),
1226 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1231 test_bypass.clone(),
1232 ));
1233
1234 let mut builder = reqwest::Client::builder()
1235 .no_proxy()
1237 .dns_resolver(Arc::clone(&resolver))
1238 .connect_timeout(Duration::from_secs(10))
1239 .timeout(Duration::from_secs(30))
1240 .redirect(reqwest::redirect::Policy::none())
1241 .identity(identity);
1242
1243 if let Some(ref ca_path) = cfg.ca_cert_path {
1244 let pem = std::fs::read(ca_path).map_err(|e| {
1245 crate::error::McpxError::Startup(format!(
1246 "oauth http client mTLS: read ca_cert_path {}: {e}",
1247 ca_path.display()
1248 ))
1249 })?;
1250 let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
1251 crate::error::McpxError::Startup(format!(
1252 "oauth http client mTLS: parse ca_cert_path {}: {e}",
1253 ca_path.display()
1254 ))
1255 })?;
1256 builder = builder.add_root_certificate(cert);
1257 }
1258
1259 let client = builder.build().map_err(|e| {
1260 crate::error::McpxError::Startup(format!("oauth http client mTLS init: {e}"))
1261 })?;
1262 map.insert(
1263 MtlsClientKey {
1264 cert_path: cc.cert_path.clone(),
1265 key_path: cc.key_path.clone(),
1266 },
1267 client,
1268 );
1269 Ok(Arc::new(map))
1270}
1271
1272fn check_oauth_url(
1279 field: &str,
1280 raw: &str,
1281 allow_http: bool,
1282) -> Result<url::Url, crate::error::McpxError> {
1283 let parsed = url::Url::parse(raw).map_err(|e| {
1284 crate::error::McpxError::Config(format!("{field}: invalid URL {raw:?}: {e}"))
1285 })?;
1286 if !parsed.username().is_empty() || parsed.password().is_some() {
1287 return Err(crate::error::McpxError::Config(format!(
1288 "{field} rejected: URL contains userinfo (credentials in URL are forbidden)"
1289 )));
1290 }
1291 match parsed.scheme() {
1292 "https" => Ok(parsed),
1293 "http" if allow_http => Ok(parsed),
1294 "http" => Err(crate::error::McpxError::Config(format!(
1295 "{field}: must use https scheme (got http; set allow_http_oauth_urls=true \
1296 to override - strongly discouraged in production)"
1297 ))),
1298 other => Err(crate::error::McpxError::Config(format!(
1299 "{field}: must use https scheme (got {other:?})"
1300 ))),
1301 }
1302}
1303
1304#[derive(Debug, Clone)]
1310#[must_use = "builders do nothing until `.build()` is called"]
1311pub struct OAuthConfigBuilder {
1312 inner: OAuthConfig,
1313}
1314
1315impl OAuthConfigBuilder {
1316 pub fn scopes(mut self, scopes: Vec<ScopeMapping>) -> Self {
1318 self.inner.scopes = scopes;
1319 self
1320 }
1321
1322 pub fn scope(mut self, scope: impl Into<String>, role: impl Into<String>) -> Self {
1324 self.inner.scopes.push(ScopeMapping {
1325 scope: scope.into(),
1326 role: role.into(),
1327 });
1328 self
1329 }
1330
1331 pub fn role_claim(mut self, claim: impl Into<String>) -> Self {
1334 self.inner.role_claim = Some(claim.into());
1335 self
1336 }
1337
1338 pub fn role_mappings(mut self, mappings: Vec<RoleMapping>) -> Self {
1340 self.inner.role_mappings = mappings;
1341 self
1342 }
1343
1344 pub fn role_mapping(mut self, claim_value: impl Into<String>, role: impl Into<String>) -> Self {
1347 self.inner.role_mappings.push(RoleMapping {
1348 claim_value: claim_value.into(),
1349 role: role.into(),
1350 });
1351 self
1352 }
1353
1354 pub fn jwks_cache_ttl(mut self, ttl: impl Into<String>) -> Self {
1357 self.inner.jwks_cache_ttl = ttl.into();
1358 self
1359 }
1360
1361 pub fn proxy(mut self, proxy: OAuthProxyConfig) -> Self {
1364 self.inner.proxy = Some(proxy);
1365 self
1366 }
1367
1368 pub fn token_exchange(mut self, token_exchange: TokenExchangeConfig) -> Self {
1370 self.inner.token_exchange = Some(token_exchange);
1371 self
1372 }
1373
1374 pub fn ca_cert_path(mut self, path: impl Into<PathBuf>) -> Self {
1379 self.inner.ca_cert_path = Some(path.into());
1380 self
1381 }
1382
1383 pub const fn allow_http_oauth_urls(mut self, allow: bool) -> Self {
1389 self.inner.allow_http_oauth_urls = allow;
1390 self
1391 }
1392
1393 #[deprecated(since = "1.7.0", note = "use `audience_validation_mode` instead")]
1402 pub const fn strict_audience_validation(mut self, strict: bool) -> Self {
1403 #[allow(
1404 deprecated,
1405 reason = "intentional: deprecated builder forwards to deprecated field"
1406 )]
1407 {
1408 self.inner.strict_audience_validation = Some(strict);
1409 }
1410 self.inner.audience_validation_mode = None;
1411 self
1412 }
1413
1414 pub const fn audience_validation_mode(mut self, mode: AudienceValidationMode) -> Self {
1422 self.inner.audience_validation_mode = Some(mode);
1423 self
1424 }
1425
1426 pub const fn require_subject(mut self, require: bool) -> Self {
1432 self.inner.require_subject = require;
1433 self
1434 }
1435
1436 pub const fn jwks_max_response_bytes(mut self, bytes: u64) -> Self {
1438 self.inner.jwks_max_response_bytes = bytes;
1439 self
1440 }
1441
1442 pub fn ssrf_allowlist(mut self, allowlist: OAuthSsrfAllowlist) -> Self {
1450 self.inner.ssrf_allowlist = Some(allowlist);
1451 self
1452 }
1453
1454 #[must_use]
1456 pub fn build(self) -> OAuthConfig {
1457 self.inner
1458 }
1459}
1460
1461#[derive(Debug, Clone, Deserialize)]
1463#[non_exhaustive]
1464pub struct ScopeMapping {
1465 pub scope: String,
1467 pub role: String,
1469}
1470
1471#[derive(Debug, Clone, Deserialize)]
1475#[non_exhaustive]
1476pub struct RoleMapping {
1477 pub claim_value: String,
1479 pub role: String,
1481}
1482
1483#[derive(Debug, Clone, Deserialize)]
1490#[non_exhaustive]
1491pub struct TokenExchangeConfig {
1492 pub token_url: String,
1495 pub client_id: String,
1497 pub client_secret: Option<secrecy::SecretString>,
1502 pub client_cert: Option<ClientCertConfig>,
1515 pub audience: String,
1519}
1520
1521impl TokenExchangeConfig {
1522 #[must_use]
1524 pub fn new(
1525 token_url: String,
1526 client_id: String,
1527 client_secret: Option<secrecy::SecretString>,
1528 client_cert: Option<ClientCertConfig>,
1529 audience: String,
1530 ) -> Self {
1531 Self {
1532 token_url,
1533 client_id,
1534 client_secret,
1535 client_cert,
1536 audience,
1537 }
1538 }
1539}
1540
1541#[derive(Debug, Clone, Deserialize)]
1545#[non_exhaustive]
1546pub struct ClientCertConfig {
1547 pub cert_path: PathBuf,
1550 pub key_path: PathBuf,
1554}
1555
1556impl ClientCertConfig {
1557 #[must_use]
1561 pub fn new(cert_path: PathBuf, key_path: PathBuf) -> Self {
1562 Self {
1563 cert_path,
1564 key_path,
1565 }
1566 }
1567}
1568
1569#[derive(Debug, Deserialize)]
1571#[non_exhaustive]
1572pub struct ExchangedToken {
1573 pub access_token: String,
1575 pub expires_in: Option<u64>,
1577 pub issued_token_type: Option<String>,
1580}
1581
1582#[derive(Debug, Clone, Deserialize, Default)]
1589#[non_exhaustive]
1590pub struct OAuthProxyConfig {
1591 pub authorize_url: String,
1594 pub token_url: String,
1597 pub client_id: String,
1599 pub client_secret: Option<secrecy::SecretString>,
1601 #[serde(default)]
1605 pub introspection_url: Option<String>,
1606 #[serde(default)]
1610 pub revocation_url: Option<String>,
1611 #[serde(default)]
1623 pub expose_admin_endpoints: bool,
1624 #[serde(default)]
1630 pub require_auth_on_admin_endpoints: bool,
1631 #[serde(default)]
1642 pub allow_unauthenticated_admin_endpoints: bool,
1643}
1644
1645impl OAuthProxyConfig {
1646 pub fn builder(
1654 authorize_url: impl Into<String>,
1655 token_url: impl Into<String>,
1656 client_id: impl Into<String>,
1657 ) -> OAuthProxyConfigBuilder {
1658 OAuthProxyConfigBuilder {
1659 inner: Self {
1660 authorize_url: authorize_url.into(),
1661 token_url: token_url.into(),
1662 client_id: client_id.into(),
1663 ..Self::default()
1664 },
1665 }
1666 }
1667}
1668
1669#[derive(Debug, Clone)]
1675#[must_use = "builders do nothing until `.build()` is called"]
1676pub struct OAuthProxyConfigBuilder {
1677 inner: OAuthProxyConfig,
1678}
1679
1680impl OAuthProxyConfigBuilder {
1681 pub fn client_secret(mut self, secret: secrecy::SecretString) -> Self {
1683 self.inner.client_secret = Some(secret);
1684 self
1685 }
1686
1687 pub fn introspection_url(mut self, url: impl Into<String>) -> Self {
1691 self.inner.introspection_url = Some(url.into());
1692 self
1693 }
1694
1695 pub fn revocation_url(mut self, url: impl Into<String>) -> Self {
1699 self.inner.revocation_url = Some(url.into());
1700 self
1701 }
1702
1703 pub const fn expose_admin_endpoints(mut self, expose: bool) -> Self {
1711 self.inner.expose_admin_endpoints = expose;
1712 self
1713 }
1714
1715 pub const fn require_auth_on_admin_endpoints(mut self, require: bool) -> Self {
1718 self.inner.require_auth_on_admin_endpoints = require;
1719 self
1720 }
1721
1722 pub const fn allow_unauthenticated_admin_endpoints(mut self, allow: bool) -> Self {
1726 self.inner.allow_unauthenticated_admin_endpoints = allow;
1727 self
1728 }
1729
1730 #[must_use]
1732 pub fn build(self) -> OAuthProxyConfig {
1733 self.inner
1734 }
1735}
1736
1737type JwksKeyCache = (
1745 HashMap<String, (Algorithm, DecodingKey)>,
1746 Vec<(Algorithm, DecodingKey)>,
1747);
1748
1749struct CachedKeys {
1750 keys: HashMap<String, (Algorithm, DecodingKey)>,
1752 unnamed_keys: Vec<(Algorithm, DecodingKey)>,
1754 fetched_at: Instant,
1755 ttl: Duration,
1756}
1757
1758impl CachedKeys {
1759 fn is_expired(&self) -> bool {
1760 self.fetched_at.elapsed() >= self.ttl
1761 }
1762}
1763
1764#[allow(
1773 missing_debug_implementations,
1774 reason = "contains reqwest::Client and DecodingKey cache with no Debug impl"
1775)]
1776#[non_exhaustive]
1777pub struct JwksCache {
1778 jwks_uri: String,
1779 ttl: Duration,
1780 max_jwks_keys: usize,
1781 max_response_bytes: u64,
1782 allow_http: bool,
1783 inner: RwLock<Option<CachedKeys>>,
1784 http: reqwest::Client,
1785 validation_template: Validation,
1786 expected_audience: String,
1789 audience_mode: AudienceValidationMode,
1790 require_subject: bool,
1791 azp_fallback_warned: AtomicBool,
1795 scopes: Vec<ScopeMapping>,
1796 role_claim: Option<String>,
1797 role_mappings: Vec<RoleMapping>,
1798 last_refresh_attempt: RwLock<Option<Instant>>,
1801 refresh_lock: tokio::sync::Mutex<()>,
1803 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
1807 #[cfg(any(test, feature = "test-helpers"))]
1811 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
1812}
1813
1814const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(10);
1816
1817const OAUTH_PROXY_MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
1827
1828const ACCEPTED_ALGS: &[Algorithm] = &[
1830 Algorithm::RS256,
1831 Algorithm::RS384,
1832 Algorithm::RS512,
1833 Algorithm::ES256,
1834 Algorithm::ES384,
1835 Algorithm::PS256,
1836 Algorithm::PS384,
1837 Algorithm::PS512,
1838 Algorithm::EdDSA,
1839];
1840
1841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1843#[non_exhaustive]
1844pub enum JwtValidationFailure {
1845 Expired,
1847 Invalid,
1849}
1850
1851impl JwksCache {
1852 pub fn new(config: &OAuthConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
1864 rustls::crypto::ring::default_provider()
1867 .install_default()
1868 .ok();
1869 jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
1870 .install_default()
1871 .ok();
1872
1873 let ttl = humantime::parse_duration(&config.jwks_cache_ttl).map_err(|error| {
1874 format!(
1875 "invalid jwks_cache_ttl {:?}: {error}",
1876 config.jwks_cache_ttl
1877 )
1878 })?;
1879
1880 let mut validation = Validation::new(Algorithm::RS256);
1881 validation.validate_aud = false;
1893 validation.set_issuer(&[&config.issuer]);
1894 validation.set_required_spec_claims(&["exp", "iss"]);
1895 validation.validate_exp = true;
1896 validation.validate_nbf = true;
1897
1898 let allow_http = config.allow_http_oauth_urls;
1899
1900 let allowlist = match config.ssrf_allowlist.as_ref() {
1903 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1904 Box::<dyn std::error::Error + Send + Sync>::from(format!(
1905 "oauth.ssrf_allowlist: {e}"
1906 ))
1907 })?),
1908 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
1909 };
1910 let redirect_allowlist = Arc::clone(&allowlist);
1911
1912 #[cfg(any(test, feature = "test-helpers"))]
1914 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
1915 Arc::new(AtomicBool::new(false));
1916 #[cfg(not(any(test, feature = "test-helpers")))]
1917 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
1918
1919 let resolver: Arc<dyn reqwest::dns::Resolve> =
1920 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1921 Arc::clone(&allowlist),
1922 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1923 test_bypass.clone(),
1924 ));
1925
1926 let mut http_builder = reqwest::Client::builder()
1927 .no_proxy()
1929 .dns_resolver(Arc::clone(&resolver))
1930 .timeout(Duration::from_secs(10))
1931 .connect_timeout(Duration::from_secs(3))
1932 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
1933 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
1943 Ok(()) => attempt.follow(),
1944 Err(reason) => {
1945 tracing::warn!(
1949 reason = %reason,
1950 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
1951 "oauth redirect rejected"
1952 );
1953 attempt.error(reason)
1954 }
1955 }
1956 }));
1957
1958 if let Some(ref ca_path) = config.ca_cert_path {
1959 let pem = std::fs::read(ca_path)?;
1965 let cert = reqwest::tls::Certificate::from_pem(&pem)?;
1966 http_builder = http_builder.add_root_certificate(cert);
1967 }
1968
1969 let http = http_builder.build()?;
1970
1971 Ok(Self {
1972 jwks_uri: config.jwks_uri.clone(),
1973 ttl,
1974 max_jwks_keys: config.max_jwks_keys,
1975 max_response_bytes: config.jwks_max_response_bytes,
1976 allow_http,
1977 inner: RwLock::new(None),
1978 http,
1979 validation_template: validation,
1980 expected_audience: config.audience.clone(),
1981 audience_mode: config.effective_audience_validation_mode(),
1982 require_subject: config.require_subject,
1983 azp_fallback_warned: AtomicBool::new(false),
1984 scopes: config.scopes.clone(),
1985 role_claim: config.role_claim.clone(),
1986 role_mappings: config.role_mappings.clone(),
1987 last_refresh_attempt: RwLock::new(None),
1988 refresh_lock: tokio::sync::Mutex::new(()),
1989 allowlist,
1990 #[cfg(any(test, feature = "test-helpers"))]
1991 test_allow_loopback_ssrf: test_bypass,
1992 })
1993 }
1994
1995 #[cfg(any(test, feature = "test-helpers"))]
1999 #[doc(hidden)]
2000 #[must_use]
2001 pub fn __test_allow_loopback_ssrf(self) -> Self {
2002 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
2005 self
2006 }
2007
2008 pub async fn validate_token(&self, token: &str) -> Option<AuthIdentity> {
2010 self.validate_token_with_reason(token).await.ok()
2011 }
2012
2013 pub async fn validate_token_with_reason(
2023 &self,
2024 token: &str,
2025 ) -> Result<AuthIdentity, JwtValidationFailure> {
2026 let claims = self.decode_claims(token).await?;
2027
2028 if self.require_subject && claims.sub.is_none() {
2029 core::hint::cold_path();
2030 tracing::debug!("JWT rejected: require_subject is set but the token has no `sub`");
2031 return Err(JwtValidationFailure::Invalid);
2032 }
2033 self.check_audience(&claims)?;
2034 let role = self.resolve_role(&claims)?;
2035
2036 let sub = claims.sub;
2039 let name = claims
2040 .extra
2041 .get("preferred_username")
2042 .and_then(|v| v.as_str())
2043 .map(String::from)
2044 .or_else(|| sub.clone())
2045 .or(claims.azp)
2046 .or(claims.client_id)
2047 .unwrap_or_else(|| "oauth-client".into());
2048
2049 Ok(AuthIdentity {
2050 name,
2051 role,
2052 method: AuthMethod::OAuthJwt,
2053 raw_token: None,
2054 sub,
2055 })
2056 }
2057
2058 async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2074 let (key, alg) = self.select_jwks_key(token).await?;
2075
2076 let mut validation = self.validation_template.clone();
2080 validation.algorithms = vec![alg];
2081
2082 let token_owned = token.to_owned();
2085 let join =
2086 tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2087 .await;
2088
2089 let decode_result = match join {
2090 Ok(r) => r,
2091 Err(join_err) => {
2092 core::hint::cold_path();
2093 tracing::error!(
2094 error = %join_err,
2095 "JWT decode task panicked or was cancelled"
2096 );
2097 return Err(JwtValidationFailure::Invalid);
2098 }
2099 };
2100
2101 decode_result.map(|td| td.claims).map_err(|e| {
2102 core::hint::cold_path();
2103 let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2104 JwtValidationFailure::Expired
2105 } else {
2106 JwtValidationFailure::Invalid
2107 };
2108 tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2109 failure
2110 })
2111 }
2112
2113 #[allow(
2122 clippy::cognitive_complexity,
2123 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"
2124 )]
2125 async fn select_jwks_key(
2126 &self,
2127 token: &str,
2128 ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2129 let Ok(header) = decode_header(token) else {
2130 core::hint::cold_path();
2131 tracing::debug!("JWT header decode failed");
2132 return Err(JwtValidationFailure::Invalid);
2133 };
2134 let kid = header.kid.as_deref();
2135 tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2136
2137 if !ACCEPTED_ALGS.contains(&header.alg) {
2138 core::hint::cold_path();
2139 tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2140 return Err(JwtValidationFailure::Invalid);
2141 }
2142
2143 let Some(key) = self.find_key(kid, header.alg).await else {
2144 core::hint::cold_path();
2145 tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2146 return Err(JwtValidationFailure::Invalid);
2147 };
2148
2149 Ok((key, header.alg))
2150 }
2151
2152 fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2161 if claims.aud.contains(&self.expected_audience) {
2162 return Ok(());
2163 }
2164 let azp_match = claims
2165 .azp
2166 .as_deref()
2167 .is_some_and(|azp| azp == self.expected_audience);
2168 if azp_match {
2169 match self.audience_mode {
2170 AudienceValidationMode::Permissive => return Ok(()),
2171 AudienceValidationMode::Warn => {
2172 if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2173 tracing::warn!(
2174 expected = %self.expected_audience,
2175 azp = claims.azp.as_deref().unwrap_or("-"),
2176 "JWT accepted via deprecated azp-only audience fallback. \
2177 Configure your IdP to populate aud, or set \
2178 audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2179 To silence this warning without changing acceptance, \
2180 set audience_validation_mode = \"permissive\". \
2181 This warning logs once per process."
2182 );
2183 }
2184 return Ok(());
2185 }
2186 AudienceValidationMode::Strict => {}
2187 }
2188 }
2189 core::hint::cold_path();
2190 tracing::debug!(
2191 aud = %claims.aud.log_display(),
2192 azp = claims.azp.as_deref().unwrap_or("-"),
2193 expected = %self.expected_audience,
2194 mode = self.audience_mode.as_str(),
2195 "JWT rejected: audience mismatch"
2196 );
2197 Err(JwtValidationFailure::Invalid)
2198 }
2199
2200 fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2206 if let Some(ref claim_path) = self.role_claim {
2207 let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2208 let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2209 values.extend(resolve_claim_path(&claims.extra, claim_path));
2210 return self
2211 .role_mappings
2212 .iter()
2213 .find(|m| values.contains(&m.claim_value.as_str()))
2214 .map(|m| m.role.clone())
2215 .ok_or(JwtValidationFailure::Invalid);
2216 }
2217
2218 let token_scopes: Vec<&str> = claims
2219 .scope
2220 .as_deref()
2221 .unwrap_or("")
2222 .split_whitespace()
2223 .collect();
2224
2225 self.scopes
2226 .iter()
2227 .find(|m| token_scopes.contains(&m.scope.as_str()))
2228 .map(|m| m.role.clone())
2229 .ok_or(JwtValidationFailure::Invalid)
2230 }
2231
2232 async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2238 {
2240 let guard = self.inner.read().await;
2241 if let Some(cached) = guard.as_ref()
2242 && !cached.is_expired()
2243 && let Some(key) = lookup_key(cached, kid, alg)
2244 {
2245 return Some(key);
2246 }
2247 }
2248
2249 self.refresh_with_cooldown().await;
2251
2252 let guard = self.inner.read().await;
2258 guard
2259 .as_ref()
2260 .filter(|cached| !cached.is_expired())
2261 .and_then(|cached| lookup_key(cached, kid, alg))
2262 }
2263
2264 async fn refresh_with_cooldown(&self) {
2284 let _guard = self.refresh_lock.lock().await;
2286
2287 {
2289 let last = self.last_refresh_attempt.read().await;
2290 if let Some(ts) = *last
2291 && ts.elapsed() < JWKS_REFRESH_COOLDOWN
2292 {
2293 tracing::debug!(
2294 elapsed_ms = ts.elapsed().as_millis(),
2295 cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
2296 "JWKS refresh skipped (cooldown active)"
2297 );
2298 return;
2299 }
2300 }
2301
2302 {
2305 let mut last = self.last_refresh_attempt.write().await;
2306 *last = Some(Instant::now());
2307 }
2308
2309 let _ = self.refresh_inner().await;
2311 }
2312
2313 async fn refresh_inner(&self) -> Result<(), String> {
2322 let Some(jwks) = self.fetch_jwks().await else {
2323 return Ok(());
2324 };
2325 let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
2326 Ok(cache) => cache,
2327 Err(msg) => {
2328 tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
2329 return Err(msg);
2330 }
2331 };
2332
2333 tracing::debug!(
2334 named = keys.len(),
2335 unnamed = unnamed_keys.len(),
2336 "JWKS refreshed"
2337 );
2338
2339 let mut guard = self.inner.write().await;
2340 *guard = Some(CachedKeys {
2341 keys,
2342 unnamed_keys,
2343 fetched_at: Instant::now(),
2344 ttl: self.ttl,
2345 });
2346 drop(guard);
2347 Ok(())
2348 }
2349
2350 #[allow(
2352 clippy::cognitive_complexity,
2353 reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
2354 )]
2355 async fn fetch_jwks(&self) -> Option<JwkSet> {
2356 #[cfg(any(test, feature = "test-helpers"))]
2357 let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
2358 screen_oauth_target_with_test_override(
2359 &self.jwks_uri,
2360 self.allow_http,
2361 &self.allowlist,
2362 true,
2363 )
2364 .await
2365 } else {
2366 screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
2367 };
2368 #[cfg(not(any(test, feature = "test-helpers")))]
2369 let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
2370
2371 if let Err(error) = screening {
2372 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to screen JWKS target");
2373 return None;
2374 }
2375
2376 let mut resp = match self.http.get(&self.jwks_uri).send().await {
2377 Ok(resp) => resp,
2378 Err(e) => {
2379 tracing::warn!(error = %e, uri = %self.jwks_uri, "failed to fetch JWKS");
2380 return None;
2381 }
2382 };
2383
2384 let initial_capacity =
2385 usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
2386 let mut body = Vec::with_capacity(initial_capacity);
2387 while let Some(chunk) = match resp.chunk().await {
2388 Ok(chunk) => chunk,
2389 Err(error) => {
2390 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to read JWKS response");
2391 return None;
2392 }
2393 } {
2394 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
2395 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
2396 if body_len.saturating_add(chunk_len) > self.max_response_bytes {
2397 tracing::warn!(
2398 uri = %self.jwks_uri,
2399 max_bytes = self.max_response_bytes,
2400 "JWKS response exceeded configured size cap"
2401 );
2402 return None;
2403 }
2404 body.extend_from_slice(&chunk);
2405 }
2406
2407 match serde_json::from_slice::<JwkSet>(&body) {
2408 Ok(jwks) => Some(jwks),
2409 Err(error) => {
2410 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to parse JWKS");
2411 None
2412 }
2413 }
2414 }
2415
2416 #[cfg(any(test, feature = "test-helpers"))]
2419 #[doc(hidden)]
2420 pub async fn __test_refresh_now(&self) -> Result<(), String> {
2421 let jwks = self
2422 .fetch_jwks()
2423 .await
2424 .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
2425 let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
2426 let mut guard = self.inner.write().await;
2427 *guard = Some(CachedKeys {
2428 keys,
2429 unnamed_keys,
2430 fetched_at: Instant::now(),
2431 ttl: self.ttl,
2432 });
2433 drop(guard);
2434 Ok(())
2435 }
2436
2437 #[cfg(any(test, feature = "test-helpers"))]
2440 #[doc(hidden)]
2441 pub async fn __test_has_kid(&self, kid: &str) -> bool {
2442 let guard = self.inner.read().await;
2443 guard
2444 .as_ref()
2445 .is_some_and(|cache| cache.keys.contains_key(kid))
2446 }
2447}
2448
2449fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
2451 if jwks.keys.len() > max_keys {
2452 return Err(format!(
2453 "jwks_key_count_exceeds_cap: got {} keys, max is {}",
2454 jwks.keys.len(),
2455 max_keys
2456 ));
2457 }
2458 let mut keys = HashMap::new();
2459 let mut unnamed_keys = Vec::new();
2460 for jwk in &jwks.keys {
2461 let Ok(decoding_key) = DecodingKey::from_jwk(jwk) else {
2462 continue;
2463 };
2464 let Some(alg) = jwk_algorithm(jwk) else {
2465 continue;
2466 };
2467 if let Some(ref kid) = jwk.common.key_id {
2468 keys.insert(kid.clone(), (alg, decoding_key));
2469 } else {
2470 unnamed_keys.push((alg, decoding_key));
2471 }
2472 }
2473 Ok((keys, unnamed_keys))
2474}
2475
2476fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2478 if let Some(kid) = kid {
2479 if let Some((cached_alg, key)) = cached.keys.get(kid)
2484 && *cached_alg == alg
2485 {
2486 return Some(key.clone());
2487 }
2488 return None;
2489 }
2490 cached
2492 .unnamed_keys
2493 .iter()
2494 .find(|(a, _)| *a == alg)
2495 .map(|(_, k)| k.clone())
2496}
2497
2498#[allow(
2500 clippy::wildcard_enum_match_arm,
2501 reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
2502)]
2503fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<Algorithm> {
2504 jwk.common.key_algorithm.and_then(|ka| match ka {
2505 jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
2506 jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
2507 jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
2508 jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
2509 jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
2510 jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
2511 jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
2512 jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
2513 jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
2514 _ => None,
2515 })
2516}
2517
2518fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
2539 match path {
2540 "sub" => claims.sub.iter().cloned().collect(),
2541 "azp" => claims.azp.iter().cloned().collect(),
2542 "client_id" => claims.client_id.iter().cloned().collect(),
2543 "aud" => claims.aud.0.clone(),
2544 "scope" => claims
2545 .scope
2546 .as_deref()
2547 .unwrap_or("")
2548 .split_whitespace()
2549 .map(str::to_owned)
2550 .collect(),
2551 _ => Vec::new(),
2552 }
2553}
2554
2555fn resolve_claim_path<'a>(
2565 extra: &'a HashMap<String, serde_json::Value>,
2566 path: &str,
2567) -> Vec<&'a str> {
2568 let mut segments = path.split('.');
2569 let Some(first) = segments.next() else {
2570 return Vec::new();
2571 };
2572
2573 let mut current: Option<&serde_json::Value> = extra.get(first);
2574
2575 for segment in segments {
2576 current = current.and_then(|v| v.get(segment));
2577 }
2578
2579 match current {
2580 Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
2581 Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
2582 _ => Vec::new(),
2583 }
2584}
2585
2586#[derive(Debug, Deserialize)]
2592struct Claims {
2593 sub: Option<String>,
2595 #[serde(default)]
2598 aud: OneOrMany,
2599 azp: Option<String>,
2601 client_id: Option<String>,
2603 scope: Option<String>,
2605 #[serde(flatten)]
2607 extra: HashMap<String, serde_json::Value>,
2608}
2609
2610#[derive(Debug, Default)]
2612struct OneOrMany(Vec<String>);
2613
2614impl OneOrMany {
2615 fn contains(&self, value: &str) -> bool {
2616 self.0.iter().any(|v| v == value)
2617 }
2618
2619 fn log_display(&self) -> String {
2623 if self.0.is_empty() {
2624 "-".to_owned()
2625 } else {
2626 self.0.join(", ")
2627 }
2628 }
2629}
2630
2631fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
2641 match value {
2642 Some(serde_json::Value::String(s)) => s.clone(),
2643 Some(serde_json::Value::Array(items)) => {
2644 let joined = items
2645 .iter()
2646 .filter_map(serde_json::Value::as_str)
2647 .collect::<Vec<_>>()
2648 .join(", ");
2649 if joined.is_empty() {
2650 "-".to_owned()
2651 } else {
2652 joined
2653 }
2654 }
2655 Some(
2656 serde_json::Value::Null
2657 | serde_json::Value::Bool(_)
2658 | serde_json::Value::Number(_)
2659 | serde_json::Value::Object(_),
2660 )
2661 | None => "-".to_owned(),
2662 }
2663}
2664
2665fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
2669 value.and_then(serde_json::Value::as_str).unwrap_or("-")
2670}
2671
2672impl<'de> Deserialize<'de> for OneOrMany {
2673 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2674 use serde::de;
2675
2676 struct Visitor;
2677 impl<'de> de::Visitor<'de> for Visitor {
2678 type Value = OneOrMany;
2679 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2680 f.write_str("a string or array of strings")
2681 }
2682 fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
2683 Ok(OneOrMany(vec![v.to_owned()]))
2684 }
2685 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
2686 let mut v = Vec::new();
2687 while let Some(s) = seq.next_element::<String>()? {
2688 v.push(s);
2689 }
2690 Ok(OneOrMany(v))
2691 }
2692 }
2693 deserializer.deserialize_any(Visitor)
2694 }
2695}
2696
2697#[must_use]
2704pub fn looks_like_jwt(token: &str) -> bool {
2705 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2706
2707 let mut parts = token.splitn(4, '.');
2708 let Some(header_b64) = parts.next() else {
2709 return false;
2710 };
2711 if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
2713 return false;
2714 }
2715 let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
2717 return false;
2718 };
2719 let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
2721 return false;
2722 };
2723 header.get("alg").is_some()
2724}
2725
2726#[must_use]
2736pub fn protected_resource_metadata(
2737 resource_url: &str,
2738 server_url: &str,
2739 config: &OAuthConfig,
2740) -> serde_json::Value {
2741 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2746 let auth_server = server_url;
2747 serde_json::json!({
2748 "resource": resource_url,
2749 "authorization_servers": [auth_server],
2750 "scopes_supported": scopes,
2751 "bearer_methods_supported": ["header"]
2752 })
2753}
2754
2755#[must_use]
2760pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
2761 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2762 let mut meta = serde_json::json!({
2763 "issuer": &config.issuer,
2764 "authorization_endpoint": format!("{server_url}/authorize"),
2765 "token_endpoint": format!("{server_url}/token"),
2766 "registration_endpoint": format!("{server_url}/register"),
2767 "response_types_supported": ["code"],
2768 "grant_types_supported": ["authorization_code", "refresh_token"],
2769 "code_challenge_methods_supported": ["S256"],
2770 "scopes_supported": scopes,
2771 "token_endpoint_auth_methods_supported": ["none"],
2772 });
2773 if let Some(proxy) = &config.proxy
2774 && proxy.expose_admin_endpoints
2775 && let Some(obj) = meta.as_object_mut()
2776 {
2777 if proxy.introspection_url.is_some() {
2778 obj.insert(
2779 "introspection_endpoint".into(),
2780 serde_json::Value::String(format!("{server_url}/introspect")),
2781 );
2782 }
2783 if proxy.revocation_url.is_some() {
2784 obj.insert(
2785 "revocation_endpoint".into(),
2786 serde_json::Value::String(format!("{server_url}/revoke")),
2787 );
2788 }
2789 if proxy.require_auth_on_admin_endpoints {
2790 obj.insert(
2791 "introspection_endpoint_auth_methods_supported".into(),
2792 serde_json::json!(["bearer"]),
2793 );
2794 obj.insert(
2795 "revocation_endpoint_auth_methods_supported".into(),
2796 serde_json::json!(["bearer"]),
2797 );
2798 }
2799 }
2800 meta
2801}
2802
2803#[must_use]
2816pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
2817 use axum::{
2818 http::{StatusCode, header},
2819 response::IntoResponse,
2820 };
2821
2822 let upstream_query = replace_client_id(query, &proxy.client_id);
2824 let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
2825
2826 (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
2827}
2828
2829pub async fn handle_token(
2835 http: &OauthHttpClient,
2836 proxy: &OAuthProxyConfig,
2837 body: &str,
2838) -> axum::response::Response {
2839 use axum::{
2840 http::{StatusCode, header},
2841 response::IntoResponse,
2842 };
2843
2844 let mut upstream_body = replace_client_id(body, &proxy.client_id);
2846
2847 if let Some(ref secret) = proxy.client_secret {
2849 use std::fmt::Write;
2850
2851 use secrecy::ExposeSecret;
2852 let _ = write!(
2853 upstream_body,
2854 "&client_secret={}",
2855 urlencoding::encode(secret.expose_secret())
2856 );
2857 }
2858
2859 let result = http
2860 .send_screened(
2861 &proxy.token_url,
2862 http.credential_client
2863 .post(&proxy.token_url)
2864 .header("Content-Type", "application/x-www-form-urlencoded")
2865 .body(upstream_body),
2866 )
2867 .await;
2868
2869 match result {
2870 Ok(resp) => {
2871 let status =
2872 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
2873 let Ok(body_bytes) =
2874 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
2875 else {
2876 return oauth_error_response(
2877 StatusCode::BAD_GATEWAY,
2878 "server_error",
2879 "upstream response too large or unreadable",
2880 );
2881 };
2882 (
2883 status,
2884 [(header::CONTENT_TYPE, "application/json")],
2885 body_bytes,
2886 )
2887 .into_response()
2888 }
2889 Err(e) => {
2890 tracing::error!(error = %e, "OAuth token proxy request failed");
2891 (
2892 StatusCode::BAD_GATEWAY,
2893 [(header::CONTENT_TYPE, "application/json")],
2894 "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
2895 )
2896 .into_response()
2897 }
2898 }
2899}
2900
2901#[must_use]
2908pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
2909 let mut resp = serde_json::json!({
2910 "client_id": proxy.client_id,
2911 "token_endpoint_auth_method": "none",
2912 });
2913 if let Some(uris) = body.get("redirect_uris")
2914 && let Some(obj) = resp.as_object_mut()
2915 {
2916 obj.insert("redirect_uris".into(), uris.clone());
2917 }
2918 if let Some(name) = body.get("client_name")
2919 && let Some(obj) = resp.as_object_mut()
2920 {
2921 obj.insert("client_name".into(), name.clone());
2922 }
2923 resp
2924}
2925
2926pub async fn handle_introspect(
2932 http: &OauthHttpClient,
2933 proxy: &OAuthProxyConfig,
2934 body: &str,
2935) -> axum::response::Response {
2936 let Some(ref url) = proxy.introspection_url else {
2937 return oauth_error_response(
2938 axum::http::StatusCode::NOT_FOUND,
2939 "not_supported",
2940 "introspection endpoint is not configured",
2941 );
2942 };
2943 proxy_oauth_admin_request(http, proxy, url, body).await
2944}
2945
2946pub async fn handle_revoke(
2953 http: &OauthHttpClient,
2954 proxy: &OAuthProxyConfig,
2955 body: &str,
2956) -> axum::response::Response {
2957 let Some(ref url) = proxy.revocation_url else {
2958 return oauth_error_response(
2959 axum::http::StatusCode::NOT_FOUND,
2960 "not_supported",
2961 "revocation endpoint is not configured",
2962 );
2963 };
2964 proxy_oauth_admin_request(http, proxy, url, body).await
2965}
2966
2967async fn proxy_oauth_admin_request(
2971 http: &OauthHttpClient,
2972 proxy: &OAuthProxyConfig,
2973 upstream_url: &str,
2974 body: &str,
2975) -> axum::response::Response {
2976 use axum::{
2977 http::{StatusCode, header},
2978 response::IntoResponse,
2979 };
2980
2981 let mut upstream_body = replace_client_id(body, &proxy.client_id);
2982 if let Some(ref secret) = proxy.client_secret {
2983 use std::fmt::Write;
2984
2985 use secrecy::ExposeSecret;
2986 let _ = write!(
2987 upstream_body,
2988 "&client_secret={}",
2989 urlencoding::encode(secret.expose_secret())
2990 );
2991 }
2992
2993 let result = http
2994 .send_screened(
2995 upstream_url,
2996 http.credential_client
2997 .post(upstream_url)
2998 .header("Content-Type", "application/x-www-form-urlencoded")
2999 .body(upstream_body),
3000 )
3001 .await;
3002
3003 match result {
3004 Ok(resp) => {
3005 let status =
3006 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3007 let content_type = resp
3008 .headers()
3009 .get(header::CONTENT_TYPE)
3010 .and_then(|v| v.to_str().ok())
3011 .unwrap_or("application/json")
3012 .to_owned();
3013 let Ok(body_bytes) =
3014 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
3015 else {
3016 return oauth_error_response(
3017 StatusCode::BAD_GATEWAY,
3018 "server_error",
3019 "upstream response too large or unreadable",
3020 );
3021 };
3022 (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
3023 }
3024 Err(e) => {
3025 tracing::error!(error = %e, url = %upstream_url, "OAuth admin proxy request failed");
3026 oauth_error_response(
3027 StatusCode::BAD_GATEWAY,
3028 "server_error",
3029 "upstream endpoint unreachable",
3030 )
3031 }
3032 }
3033}
3034
3035async fn read_response_capped(
3045 mut resp: reqwest::Response,
3046 max_bytes: u64,
3047 context: &str,
3048) -> Result<Vec<u8>, ()> {
3049 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3050 let mut body = Vec::with_capacity(initial_capacity);
3051 loop {
3052 match resp.chunk().await {
3053 Ok(Some(chunk)) => {
3054 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3055 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3056 if body_len.saturating_add(chunk_len) > max_bytes {
3057 tracing::warn!(
3058 context = context,
3059 max_bytes = max_bytes,
3060 "upstream OAuth response exceeded size cap; failing closed"
3061 );
3062 return Err(());
3063 }
3064 body.extend_from_slice(&chunk);
3065 }
3066 Ok(None) => return Ok(body),
3067 Err(error) => {
3068 tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
3069 return Err(());
3070 }
3071 }
3072 }
3073}
3074
3075fn oauth_error_response(
3076 status: axum::http::StatusCode,
3077 error: &str,
3078 description: &str,
3079) -> axum::response::Response {
3080 use axum::{http::header, response::IntoResponse};
3081 let body = serde_json::json!({
3082 "error": error,
3083 "error_description": description,
3084 });
3085 (
3086 status,
3087 [(header::CONTENT_TYPE, "application/json")],
3088 body.to_string(),
3089 )
3090 .into_response()
3091}
3092
3093#[derive(Debug, Deserialize)]
3099struct OAuthErrorResponse {
3100 error: String,
3101 error_description: Option<String>,
3102}
3103
3104fn sanitize_oauth_error_code(raw: &str) -> &'static str {
3111 match raw {
3112 "invalid_request" => "invalid_request",
3113 "invalid_client" => "invalid_client",
3114 "invalid_grant" => "invalid_grant",
3115 "unauthorized_client" => "unauthorized_client",
3116 "unsupported_grant_type" => "unsupported_grant_type",
3117 "invalid_scope" => "invalid_scope",
3118 "temporarily_unavailable" => "temporarily_unavailable",
3119 "invalid_target" => "invalid_target",
3121 _ => "server_error",
3124 }
3125}
3126
3127pub async fn exchange_token(
3139 http: &OauthHttpClient,
3140 config: &TokenExchangeConfig,
3141 subject_token: &str,
3142) -> Result<ExchangedToken, crate::error::McpxError> {
3143 use secrecy::ExposeSecret;
3144
3145 let client = http.client_for(config);
3146 let mut req = client
3147 .post(&config.token_url)
3148 .header("Content-Type", "application/x-www-form-urlencoded")
3149 .header("Accept", "application/json");
3150
3151 if config.client_cert.is_none()
3160 && let Some(ref secret) = config.client_secret
3161 {
3162 use base64::Engine;
3163 let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
3164 "{}:{}",
3165 urlencoding::encode(&config.client_id),
3166 urlencoding::encode(secret.expose_secret()),
3167 ));
3168 req = req.header("Authorization", format!("Basic {credentials}"));
3169 }
3170
3171 let form_body = build_exchange_form(config, subject_token);
3172
3173 let resp = http
3174 .send_screened(&config.token_url, req.body(form_body))
3175 .await
3176 .map_err(|e| {
3177 tracing::error!(error = %e, "token exchange request failed");
3178 crate::error::McpxError::Auth("server_error".into())
3180 })?;
3181
3182 let status = resp.status();
3183 let body_bytes =
3184 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
3185 .await
3186 .map_err(|()| {
3187 crate::error::McpxError::Auth("server_error".into())
3189 })?;
3190
3191 if !status.is_success() {
3192 core::hint::cold_path();
3193 let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
3196 let short_code = parsed
3197 .as_ref()
3198 .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
3199 if let Some(ref e) = parsed {
3200 tracing::warn!(
3201 status = %status,
3202 upstream_error = %e.error,
3203 upstream_error_description = e.error_description.as_deref().unwrap_or(""),
3204 client_code = %short_code,
3205 "token exchange rejected by authorization server",
3206 );
3207 } else {
3208 tracing::warn!(
3209 status = %status,
3210 client_code = %short_code,
3211 "token exchange rejected (unparseable upstream body)",
3212 );
3213 }
3214 return Err(crate::error::McpxError::Auth(short_code.into()));
3215 }
3216
3217 let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
3218 tracing::error!(error = %e, "failed to parse token exchange response");
3219 crate::error::McpxError::Auth("server_error".into())
3222 })?;
3223
3224 log_exchanged_token(&exchanged);
3225
3226 Ok(exchanged)
3227}
3228
3229fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
3232 let body = format!(
3233 "grant_type={}&subject_token={}&subject_token_type={}&requested_token_type={}&audience={}",
3234 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
3235 urlencoding::encode(subject_token),
3236 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3237 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3238 urlencoding::encode(&config.audience),
3239 );
3240 if config.client_secret.is_none() {
3241 format!(
3242 "{body}&client_id={}",
3243 urlencoding::encode(&config.client_id)
3244 )
3245 } else {
3246 body
3247 }
3248}
3249
3250fn log_exchanged_token(exchanged: &ExchangedToken) {
3253 use base64::Engine;
3254
3255 if !looks_like_jwt(&exchanged.access_token) {
3256 tracing::debug!(
3257 token_len = exchanged.access_token.len(),
3258 issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
3259 expires_in = exchanged.expires_in,
3260 "exchanged token (opaque)",
3261 );
3262 return;
3263 }
3264 let Some(payload) = exchanged.access_token.split('.').nth(1) else {
3265 return;
3266 };
3267 let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
3268 return;
3269 };
3270 let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
3271 return;
3272 };
3273 tracing::debug!(
3274 sub = fmt_json_str(claims.get("sub")),
3275 aud = %fmt_json_aud(claims.get("aud")),
3276 azp = fmt_json_str(claims.get("azp")),
3277 iss = fmt_json_str(claims.get("iss")),
3278 expires_in = exchanged.expires_in,
3279 "exchanged token claims (JWT)",
3280 );
3281}
3282
3283fn replace_client_id(params: &str, upstream_client_id: &str) -> String {
3285 let encoded_id = urlencoding::encode(upstream_client_id);
3286 let mut parts: Vec<String> = params
3287 .split('&')
3288 .filter(|p| !p.starts_with("client_id="))
3289 .map(String::from)
3290 .collect();
3291 parts.push(format!("client_id={encoded_id}"));
3292 parts.join("&")
3293}
3294
3295#[cfg(test)]
3296mod tests {
3297 use std::sync::Arc;
3298
3299 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3300
3301 use super::*;
3302
3303 #[test]
3304 fn looks_like_jwt_valid() {
3305 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
3307 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3308 let token = format!("{header}.{payload}.signature");
3309 assert!(looks_like_jwt(&token));
3310 }
3311
3312 #[test]
3313 fn looks_like_jwt_rejects_opaque_token() {
3314 assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
3315 }
3316
3317 #[test]
3318 fn looks_like_jwt_rejects_two_segments() {
3319 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
3320 let token = format!("{header}.payload");
3321 assert!(!looks_like_jwt(&token));
3322 }
3323
3324 #[test]
3325 fn looks_like_jwt_rejects_four_segments() {
3326 assert!(!looks_like_jwt("a.b.c.d"));
3327 }
3328
3329 #[test]
3330 fn looks_like_jwt_rejects_no_alg() {
3331 let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
3332 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3333 let token = format!("{header}.{payload}.sig");
3334 assert!(!looks_like_jwt(&token));
3335 }
3336
3337 #[test]
3338 fn protected_resource_metadata_shape() {
3339 let config = OAuthConfig {
3340 require_subject: false,
3341 issuer: "https://auth.example.com".into(),
3342 audience: "https://mcp.example.com/mcp".into(),
3343 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
3344 scopes: vec![
3345 ScopeMapping {
3346 scope: "mcp:read".into(),
3347 role: "viewer".into(),
3348 },
3349 ScopeMapping {
3350 scope: "mcp:admin".into(),
3351 role: "ops".into(),
3352 },
3353 ],
3354 role_claim: None,
3355 role_mappings: vec![],
3356 jwks_cache_ttl: "10m".into(),
3357 proxy: None,
3358 token_exchange: None,
3359 ca_cert_path: None,
3360 allow_http_oauth_urls: false,
3361 max_jwks_keys: default_max_jwks_keys(),
3362 #[allow(
3363 deprecated,
3364 reason = "test fixture: explicit value for the deprecated field"
3365 )]
3366 strict_audience_validation: None,
3367 audience_validation_mode: None,
3368 jwks_max_response_bytes: default_jwks_max_bytes(),
3369 ssrf_allowlist: None,
3370 };
3371 let meta = protected_resource_metadata(
3372 "https://mcp.example.com/mcp",
3373 "https://mcp.example.com",
3374 &config,
3375 );
3376 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
3377 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
3378 assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
3379 assert_eq!(meta["bearer_methods_supported"][0], "header");
3380 }
3381
3382 fn validation_https_config() -> OAuthConfig {
3387 OAuthConfig::builder(
3388 "https://auth.example.com",
3389 "mcp",
3390 "https://auth.example.com/.well-known/jwks.json",
3391 )
3392 .build()
3393 }
3394
3395 #[test]
3396 fn validate_accepts_all_https_urls() {
3397 let cfg = validation_https_config();
3398 cfg.validate().expect("all-HTTPS config must validate");
3399 }
3400
3401 #[test]
3402 fn validate_rejects_unparseable_jwks_cache_ttl() {
3403 let mut cfg = validation_https_config();
3404 cfg.jwks_cache_ttl = "not-a-duration".into();
3405 let err = cfg
3406 .validate()
3407 .expect_err("malformed jwks_cache_ttl must be rejected");
3408 let msg = err.to_string();
3409 assert!(
3410 msg.contains("jwks_cache_ttl"),
3411 "error must reference offending field; got {msg:?}"
3412 );
3413 }
3414
3415 #[test]
3416 fn validate_rejects_http_jwks_uri() {
3417 let mut cfg = validation_https_config();
3418 cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
3419 let err = cfg.validate().expect_err("http jwks_uri must be rejected");
3420 let msg = err.to_string();
3421 assert!(
3422 msg.contains("oauth.jwks_uri") && msg.contains("https"),
3423 "error must reference offending field + scheme requirement; got {msg:?}"
3424 );
3425 }
3426
3427 #[test]
3428 fn validate_rejects_http_proxy_authorize_url() {
3429 let mut cfg = validation_https_config();
3430 cfg.proxy = Some(
3431 OAuthProxyConfig::builder(
3432 "http://idp.example.com/authorize", "https://idp.example.com/token",
3434 "client",
3435 )
3436 .build(),
3437 );
3438 let err = cfg
3439 .validate()
3440 .expect_err("http authorize_url must be rejected");
3441 assert!(
3442 err.to_string().contains("oauth.proxy.authorize_url"),
3443 "error must reference proxy.authorize_url; got {err}"
3444 );
3445 }
3446
3447 #[test]
3448 fn validate_rejects_http_proxy_token_url() {
3449 let mut cfg = validation_https_config();
3450 cfg.proxy = Some(
3451 OAuthProxyConfig::builder(
3452 "https://idp.example.com/authorize",
3453 "http://idp.example.com/token", "client",
3455 )
3456 .build(),
3457 );
3458 let err = cfg.validate().expect_err("http token_url must be rejected");
3459 assert!(
3460 err.to_string().contains("oauth.proxy.token_url"),
3461 "error must reference proxy.token_url; got {err}"
3462 );
3463 }
3464
3465 #[test]
3466 fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
3467 let mut cfg = validation_https_config();
3468 cfg.proxy = Some(
3469 OAuthProxyConfig::builder(
3470 "https://idp.example.com/authorize",
3471 "https://idp.example.com/token",
3472 "client",
3473 )
3474 .introspection_url("http://idp.example.com/introspect")
3475 .build(),
3476 );
3477 let err = cfg
3478 .validate()
3479 .expect_err("http introspection_url must be rejected");
3480 assert!(err.to_string().contains("oauth.proxy.introspection_url"));
3481
3482 let mut cfg = validation_https_config();
3483 cfg.proxy = Some(
3484 OAuthProxyConfig::builder(
3485 "https://idp.example.com/authorize",
3486 "https://idp.example.com/token",
3487 "client",
3488 )
3489 .revocation_url("http://idp.example.com/revoke")
3490 .build(),
3491 );
3492 let err = cfg
3493 .validate()
3494 .expect_err("http revocation_url must be rejected");
3495 assert!(err.to_string().contains("oauth.proxy.revocation_url"));
3496 }
3497
3498 #[test]
3501 fn validate_rejects_exposed_admin_endpoints_without_auth() {
3502 let mut cfg = validation_https_config();
3503 cfg.proxy = Some(
3504 OAuthProxyConfig::builder(
3505 "https://idp.example.com/authorize",
3506 "https://idp.example.com/token",
3507 "client",
3508 )
3509 .introspection_url("https://idp.example.com/introspect")
3510 .expose_admin_endpoints(true)
3511 .build(),
3512 );
3513 let err = cfg
3514 .validate()
3515 .expect_err("expose_admin_endpoints without auth must fail");
3516 let msg = err.to_string();
3517 assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
3518 assert!(
3519 msg.contains("allow_unauthenticated_admin_endpoints"),
3520 "{msg}"
3521 );
3522 }
3523
3524 #[test]
3525 fn validate_accepts_exposed_admin_endpoints_with_auth() {
3526 let mut cfg = validation_https_config();
3527 cfg.proxy = Some(
3528 OAuthProxyConfig::builder(
3529 "https://idp.example.com/authorize",
3530 "https://idp.example.com/token",
3531 "client",
3532 )
3533 .introspection_url("https://idp.example.com/introspect")
3534 .expose_admin_endpoints(true)
3535 .require_auth_on_admin_endpoints(true)
3536 .build(),
3537 );
3538 cfg.validate()
3539 .expect("authed admin endpoints must validate");
3540 }
3541
3542 #[test]
3543 fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
3544 let mut cfg = validation_https_config();
3545 cfg.proxy = Some(
3546 OAuthProxyConfig::builder(
3547 "https://idp.example.com/authorize",
3548 "https://idp.example.com/token",
3549 "client",
3550 )
3551 .introspection_url("https://idp.example.com/introspect")
3552 .expose_admin_endpoints(true)
3553 .allow_unauthenticated_admin_endpoints(true)
3554 .build(),
3555 );
3556 cfg.validate()
3557 .expect("explicit unauth opt-out must validate");
3558 }
3559
3560 #[test]
3561 fn validate_accepts_unexposed_admin_endpoints_without_auth() {
3562 let mut cfg = validation_https_config();
3565 cfg.proxy = Some(
3566 OAuthProxyConfig::builder(
3567 "https://idp.example.com/authorize",
3568 "https://idp.example.com/token",
3569 "client",
3570 )
3571 .introspection_url("https://idp.example.com/introspect")
3572 .build(),
3573 );
3574 cfg.validate()
3575 .expect("unexposed admin endpoints must validate");
3576 }
3577
3578 #[test]
3579 fn validate_rejects_http_token_exchange_url() {
3580 let mut cfg = validation_https_config();
3581 cfg.token_exchange = Some(TokenExchangeConfig::new(
3582 "http://idp.example.com/token".into(), "client".into(),
3584 None,
3585 None,
3586 "downstream".into(),
3587 ));
3588 let err = cfg
3589 .validate()
3590 .expect_err("http token_exchange.token_url must be rejected");
3591 assert!(
3592 err.to_string().contains("oauth.token_exchange.token_url"),
3593 "error must reference token_exchange.token_url; got {err}"
3594 );
3595 }
3596
3597 #[test]
3598 fn validate_rejects_unparseable_url() {
3599 let mut cfg = validation_https_config();
3600 cfg.jwks_uri = "not a url".into();
3601 let err = cfg
3602 .validate()
3603 .expect_err("unparseable URL must be rejected");
3604 assert!(err.to_string().contains("invalid URL"));
3605 }
3606
3607 #[test]
3608 fn validate_rejects_non_http_scheme() {
3609 let mut cfg = validation_https_config();
3610 cfg.jwks_uri = "file:///etc/passwd".into();
3611 let err = cfg.validate().expect_err("file:// scheme must be rejected");
3612 let msg = err.to_string();
3613 assert!(
3614 msg.contains("must use https scheme") && msg.contains("file"),
3615 "error must reject non-http(s) schemes; got {msg:?}"
3616 );
3617 }
3618
3619 #[test]
3620 fn validate_accepts_http_with_escape_hatch() {
3621 let mut cfg = OAuthConfig::builder(
3626 "http://auth.local",
3627 "mcp",
3628 "http://auth.local/.well-known/jwks.json",
3629 )
3630 .allow_http_oauth_urls(true)
3631 .build();
3632 cfg.proxy = Some(
3633 OAuthProxyConfig::builder(
3634 "http://idp.local/authorize",
3635 "http://idp.local/token",
3636 "client",
3637 )
3638 .introspection_url("http://idp.local/introspect")
3639 .revocation_url("http://idp.local/revoke")
3640 .build(),
3641 );
3642 cfg.token_exchange = Some(TokenExchangeConfig::new(
3643 "http://idp.local/token".into(),
3644 "client".into(),
3645 Some(secrecy::SecretString::new("dev-secret".into())),
3646 None,
3647 "downstream".into(),
3648 ));
3649 cfg.validate()
3650 .expect("escape hatch must permit http on all URL fields");
3651 }
3652
3653 #[test]
3654 fn validate_with_escape_hatch_still_rejects_unparseable() {
3655 let mut cfg = validation_https_config();
3658 cfg.allow_http_oauth_urls = true;
3659 cfg.jwks_uri = "::not-a-url::".into();
3660 cfg.validate()
3661 .expect_err("escape hatch must NOT bypass URL parsing");
3662 }
3663
3664 #[tokio::test]
3665 async fn jwks_cache_rejects_redirect_downgrade_to_http() {
3666 rustls::crypto::ring::default_provider()
3681 .install_default()
3682 .ok();
3683
3684 let policy = reqwest::redirect::Policy::custom(|attempt| {
3685 if attempt.url().scheme() != "https" {
3686 attempt.error("redirect to non-HTTPS URL refused")
3687 } else if attempt.previous().len() >= 2 {
3688 attempt.error("too many redirects (max 2)")
3689 } else {
3690 attempt.follow()
3691 }
3692 });
3693 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
3700 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
3701 let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
3702 crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
3703 );
3704 let client = reqwest::Client::builder()
3705 .no_proxy()
3706 .dns_resolver(Arc::clone(&resolver))
3707 .timeout(Duration::from_secs(5))
3708 .connect_timeout(Duration::from_secs(3))
3709 .redirect(policy)
3710 .build()
3711 .expect("test client builds");
3712
3713 let mock = wiremock::MockServer::start().await;
3714 wiremock::Mock::given(wiremock::matchers::method("GET"))
3715 .and(wiremock::matchers::path("/jwks.json"))
3716 .respond_with(
3717 wiremock::ResponseTemplate::new(302)
3718 .insert_header("location", "http://example.invalid/jwks.json"),
3719 )
3720 .mount(&mock)
3721 .await;
3722
3723 let url = format!("{}/jwks.json", mock.uri());
3732 let err = client
3733 .get(&url)
3734 .send()
3735 .await
3736 .expect_err("redirect policy must reject scheme downgrade");
3737 let chain = format!("{err:#}");
3738 assert!(
3739 chain.contains("redirect to non-HTTPS URL refused")
3740 || chain.to_lowercase().contains("redirect"),
3741 "error must surface redirect-policy rejection; got {chain:?}"
3742 );
3743 }
3744
3745 use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
3750
3751 fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
3753 let mut rng = rsa::rand_core::OsRng;
3754 let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
3755 let private_pem = private_key
3756 .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
3757 .expect("PKCS8 PEM export")
3758 .to_string();
3759
3760 let public_key = private_key.to_public_key();
3761 let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
3762 let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
3763
3764 let jwks = serde_json::json!({
3765 "keys": [{
3766 "kty": "RSA",
3767 "use": "sig",
3768 "alg": "RS256",
3769 "kid": kid,
3770 "n": n,
3771 "e": e
3772 }]
3773 });
3774
3775 (private_pem, jwks)
3776 }
3777
3778 fn mint_token(
3780 private_pem: &str,
3781 kid: &str,
3782 issuer: &str,
3783 audience: &str,
3784 subject: &str,
3785 scope: &str,
3786 ) -> String {
3787 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
3788 .expect("encoding key from PEM");
3789 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
3790 header.kid = Some(kid.into());
3791
3792 let now = jsonwebtoken::get_current_timestamp();
3793 let claims = serde_json::json!({
3794 "iss": issuer,
3795 "aud": audience,
3796 "sub": subject,
3797 "scope": scope,
3798 "exp": now + 3600,
3799 "iat": now,
3800 });
3801
3802 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
3803 }
3804
3805 fn mint_token_without_sub(
3807 private_pem: &str,
3808 kid: &str,
3809 issuer: &str,
3810 audience: &str,
3811 scope: &str,
3812 ) -> String {
3813 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
3814 .expect("encoding key from PEM");
3815 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
3816 header.kid = Some(kid.into());
3817 let now = jsonwebtoken::get_current_timestamp();
3818 let claims = serde_json::json!({
3819 "iss": issuer,
3820 "aud": audience,
3821 "scope": scope,
3822 "exp": now + 3600,
3823 "iat": now,
3824 });
3825 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
3826 }
3827
3828 fn test_config(jwks_uri: &str) -> OAuthConfig {
3829 OAuthConfig {
3830 require_subject: false,
3831 issuer: "https://auth.test.local".into(),
3832 audience: "https://mcp.test.local/mcp".into(),
3833 jwks_uri: jwks_uri.into(),
3834 scopes: vec![
3835 ScopeMapping {
3836 scope: "mcp:read".into(),
3837 role: "viewer".into(),
3838 },
3839 ScopeMapping {
3840 scope: "mcp:admin".into(),
3841 role: "ops".into(),
3842 },
3843 ],
3844 role_claim: None,
3845 role_mappings: vec![],
3846 jwks_cache_ttl: "5m".into(),
3847 proxy: None,
3848 token_exchange: None,
3849 ca_cert_path: None,
3850 allow_http_oauth_urls: true,
3851 max_jwks_keys: default_max_jwks_keys(),
3852 #[allow(
3853 deprecated,
3854 reason = "test fixture: explicit value for the deprecated field"
3855 )]
3856 strict_audience_validation: None,
3857 audience_validation_mode: None,
3858 jwks_max_response_bytes: default_jwks_max_bytes(),
3859 ssrf_allowlist: None,
3860 }
3861 }
3862
3863 fn test_cache(config: &OAuthConfig) -> JwksCache {
3864 JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
3865 }
3866
3867 async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
3874 let kid = "test-h2-stale";
3875 let (pem, jwks) = generate_test_keypair(kid);
3876 let mock_server = wiremock::MockServer::start().await;
3877 wiremock::Mock::given(wiremock::matchers::method("GET"))
3878 .and(wiremock::matchers::path("/jwks.json"))
3879 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3880 .mount(&mock_server)
3881 .await;
3882 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3883 let mut config = test_config(&jwks_uri);
3884 config.jwks_cache_ttl = ttl.into();
3885 let cache = test_cache(&config);
3886 cache.__test_refresh_now().await.expect("prime JWKS cache");
3887 assert!(cache.__test_has_kid(kid).await, "kid must be primed");
3888
3889 mock_server.reset().await;
3890 wiremock::Mock::given(wiremock::matchers::method("GET"))
3891 .and(wiremock::matchers::path("/jwks.json"))
3892 .respond_with(wiremock::ResponseTemplate::new(503))
3893 .mount(&mock_server)
3894 .await;
3895
3896 let token = mint_token(
3897 &pem,
3898 kid,
3899 "https://auth.test.local",
3900 "https://mcp.test.local/mcp",
3901 "h2-client",
3902 "mcp:read",
3903 );
3904 (cache, token, mock_server)
3905 }
3906
3907 #[tokio::test]
3908 async fn expired_jwks_fails_closed_when_refresh_fails() {
3909 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
3910 tokio::time::sleep(Duration::from_millis(200)).await;
3911 let failure = cache
3912 .validate_token_with_reason(&token)
3913 .await
3914 .expect_err("an expired cache whose refresh fails must not serve the stale key");
3915 assert_eq!(failure, JwtValidationFailure::Invalid);
3916 }
3917
3918 #[tokio::test]
3919 async fn fresh_jwks_still_validates() {
3920 let kid = "test-h2-fresh";
3921 let (pem, jwks) = generate_test_keypair(kid);
3922 let mock_server = wiremock::MockServer::start().await;
3923 wiremock::Mock::given(wiremock::matchers::method("GET"))
3924 .and(wiremock::matchers::path("/jwks.json"))
3925 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3926 .mount(&mock_server)
3927 .await;
3928 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3929 let config = test_config(&jwks_uri); let cache = test_cache(&config);
3931 let token = mint_token(
3932 &pem,
3933 kid,
3934 "https://auth.test.local",
3935 "https://mcp.test.local/mcp",
3936 "h2-fresh-client",
3937 "mcp:read",
3938 );
3939 cache
3940 .validate_token_with_reason(&token)
3941 .await
3942 .expect("a reachable JWKS must still validate a matching token");
3943 }
3944
3945 #[tokio::test]
3946 async fn cooldown_active_plus_expired_fails_closed() {
3947 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
3948 tokio::time::sleep(Duration::from_millis(200)).await;
3949 assert_eq!(
3952 cache
3953 .validate_token_with_reason(&token)
3954 .await
3955 .expect_err("first attempt must fail closed"),
3956 JwtValidationFailure::Invalid,
3957 );
3958 let failure = cache
3961 .validate_token_with_reason(&token)
3962 .await
3963 .expect_err("cooldown-active + expired cache must still fail closed");
3964 assert_eq!(failure, JwtValidationFailure::Invalid);
3965 }
3966
3967 #[tokio::test]
3968 async fn valid_jwt_returns_identity() {
3969 let kid = "test-key-1";
3970 let (pem, jwks) = generate_test_keypair(kid);
3971
3972 let mock_server = wiremock::MockServer::start().await;
3973 wiremock::Mock::given(wiremock::matchers::method("GET"))
3974 .and(wiremock::matchers::path("/jwks.json"))
3975 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3976 .mount(&mock_server)
3977 .await;
3978
3979 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3980 let config = test_config(&jwks_uri);
3981 let cache = test_cache(&config);
3982
3983 let token = mint_token(
3984 &pem,
3985 kid,
3986 "https://auth.test.local",
3987 "https://mcp.test.local/mcp",
3988 "ci-bot",
3989 "mcp:read mcp:other",
3990 );
3991
3992 let identity = cache.validate_token(&token).await;
3993 assert!(identity.is_some(), "valid JWT should authenticate");
3994 let id = identity.unwrap();
3995 assert_eq!(id.name, "ci-bot");
3996 assert_eq!(id.role, "viewer"); assert_eq!(id.method, AuthMethod::OAuthJwt);
3998 }
3999
4000 #[test]
4003 fn unknown_kid_with_named_keys_rejected() {
4004 let mut keys = HashMap::new();
4005 keys.insert(
4006 "kid-1".to_owned(),
4007 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4008 );
4009 let cached = CachedKeys {
4010 keys,
4011 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4012 fetched_at: Instant::now(),
4013 ttl: Duration::from_secs(300),
4014 };
4015 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
4017 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
4021 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
4023 }
4024
4025 #[test]
4026 fn no_kid_token_matches_unnamed_key() {
4027 let mut keys = HashMap::new();
4028 keys.insert(
4029 "kid-1".to_owned(),
4030 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4031 );
4032 let cached = CachedKeys {
4033 keys,
4034 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4035 fetched_at: Instant::now(),
4036 ttl: Duration::from_secs(300),
4037 };
4038 assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
4041 }
4042
4043 #[tokio::test]
4044 async fn require_subject_rejects_subject_less() {
4045 let kid = "test-key-reqsub";
4046 let (pem, jwks) = generate_test_keypair(kid);
4047 let mock_server = wiremock::MockServer::start().await;
4048 wiremock::Mock::given(wiremock::matchers::method("GET"))
4049 .and(wiremock::matchers::path("/jwks.json"))
4050 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4051 .mount(&mock_server)
4052 .await;
4053 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4054 let mut config = test_config(&jwks_uri);
4055 config.require_subject = true;
4056 let cache = test_cache(&config);
4057
4058 let no_sub = mint_token_without_sub(
4059 &pem,
4060 kid,
4061 "https://auth.test.local",
4062 "https://mcp.test.local/mcp",
4063 "mcp:read",
4064 );
4065 assert!(
4066 cache.validate_token(&no_sub).await.is_none(),
4067 "require_subject must reject a token with no sub"
4068 );
4069
4070 let with_sub = mint_token(
4071 &pem,
4072 kid,
4073 "https://auth.test.local",
4074 "https://mcp.test.local/mcp",
4075 "svc",
4076 "mcp:read",
4077 );
4078 assert!(
4079 cache.validate_token(&with_sub).await.is_some(),
4080 "a token carrying sub must still be accepted"
4081 );
4082 }
4083
4084 #[tokio::test]
4085 async fn subject_less_token_accepted_by_default() {
4086 let kid = "test-key-nosub-default";
4087 let (pem, jwks) = generate_test_keypair(kid);
4088 let mock_server = wiremock::MockServer::start().await;
4089 wiremock::Mock::given(wiremock::matchers::method("GET"))
4090 .and(wiremock::matchers::path("/jwks.json"))
4091 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4092 .mount(&mock_server)
4093 .await;
4094 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4095 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4097 let no_sub = mint_token_without_sub(
4098 &pem,
4099 kid,
4100 "https://auth.test.local",
4101 "https://mcp.test.local/mcp",
4102 "mcp:read",
4103 );
4104 assert!(
4105 cache.validate_token(&no_sub).await.is_some(),
4106 "the default policy must accept a sub-less (client-credentials) token"
4107 );
4108 }
4109
4110 #[tokio::test]
4111 async fn credential_post_does_not_follow_redirect() {
4112 let mock = wiremock::MockServer::start().await;
4115 wiremock::Mock::given(wiremock::matchers::method("POST"))
4116 .and(wiremock::matchers::path("/followed"))
4117 .respond_with(wiremock::ResponseTemplate::new(200))
4118 .expect(0) .mount(&mock)
4120 .await;
4121 wiremock::Mock::given(wiremock::matchers::method("POST"))
4122 .and(wiremock::matchers::path("/token"))
4123 .respond_with(
4124 wiremock::ResponseTemplate::new(307)
4125 .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
4126 )
4127 .mount(&mock)
4128 .await;
4129
4130 let client = OauthHttpClient::build(None).expect("build oauth http client");
4131 let resp = client
4132 .credential_client
4133 .post(format!("{}/token", mock.uri()))
4134 .body("grant_type=client_credentials")
4135 .send()
4136 .await
4137 .expect("request sent");
4138 assert_eq!(
4139 resp.status().as_u16(),
4140 307,
4141 "credential client must surface the 307 rather than follow it"
4142 );
4143 }
4144
4145 #[tokio::test]
4146 async fn jwks_get_still_follows_screened_redirect() {
4147 let mock = wiremock::MockServer::start().await;
4153 wiremock::Mock::given(wiremock::matchers::method("GET"))
4154 .and(wiremock::matchers::path("/jwks.json"))
4155 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
4156 "location",
4157 format!("{}/jwks-final.json", mock.uri()).as_str(),
4158 ))
4159 .mount(&mock)
4160 .await;
4161 wiremock::Mock::given(wiremock::matchers::method("GET"))
4162 .and(wiremock::matchers::path("/jwks-final.json"))
4163 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
4164 .expect(1)
4165 .mount(&mock)
4166 .await;
4167
4168 let mut allowlist = OAuthSsrfAllowlist::default();
4169 allowlist.cidrs.push("127.0.0.0/8".into());
4170 allowlist.cidrs.push("::1/128".into());
4171 let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
4172 config.allow_http_oauth_urls = true;
4173 config.ssrf_allowlist = Some(allowlist);
4174
4175 let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
4176 let resp = client
4177 .inner
4178 .get(format!("{}/jwks.json", mock.uri()))
4179 .send()
4180 .await
4181 .expect("request sent");
4182 assert_eq!(
4183 resp.status().as_u16(),
4184 200,
4185 "JWKS client must follow the screened redirect to the final endpoint"
4186 );
4187 assert_eq!(resp.text().await.expect("response body"), "reached");
4188 }
4189
4190 #[tokio::test]
4191 async fn wrong_issuer_rejected() {
4192 let kid = "test-key-2";
4193 let (pem, jwks) = generate_test_keypair(kid);
4194
4195 let mock_server = wiremock::MockServer::start().await;
4196 wiremock::Mock::given(wiremock::matchers::method("GET"))
4197 .and(wiremock::matchers::path("/jwks.json"))
4198 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4199 .mount(&mock_server)
4200 .await;
4201
4202 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4203 let config = test_config(&jwks_uri);
4204 let cache = test_cache(&config);
4205
4206 let token = mint_token(
4207 &pem,
4208 kid,
4209 "https://wrong-issuer.example.com", "https://mcp.test.local/mcp",
4211 "attacker",
4212 "mcp:admin",
4213 );
4214
4215 assert!(cache.validate_token(&token).await.is_none());
4216 }
4217
4218 #[tokio::test]
4219 async fn wrong_audience_rejected() {
4220 let kid = "test-key-3";
4221 let (pem, jwks) = generate_test_keypair(kid);
4222
4223 let mock_server = wiremock::MockServer::start().await;
4224 wiremock::Mock::given(wiremock::matchers::method("GET"))
4225 .and(wiremock::matchers::path("/jwks.json"))
4226 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4227 .mount(&mock_server)
4228 .await;
4229
4230 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4231 let config = test_config(&jwks_uri);
4232 let cache = test_cache(&config);
4233
4234 let token = mint_token(
4235 &pem,
4236 kid,
4237 "https://auth.test.local",
4238 "https://wrong-audience.example.com", "attacker",
4240 "mcp:admin",
4241 );
4242
4243 assert!(cache.validate_token(&token).await.is_none());
4244 }
4245
4246 #[tokio::test]
4247 async fn expired_jwt_rejected() {
4248 let kid = "test-key-4";
4249 let (pem, jwks) = generate_test_keypair(kid);
4250
4251 let mock_server = wiremock::MockServer::start().await;
4252 wiremock::Mock::given(wiremock::matchers::method("GET"))
4253 .and(wiremock::matchers::path("/jwks.json"))
4254 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4255 .mount(&mock_server)
4256 .await;
4257
4258 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4259 let config = test_config(&jwks_uri);
4260 let cache = test_cache(&config);
4261
4262 let encoding_key =
4264 jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
4265 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4266 header.kid = Some(kid.into());
4267 let now = jsonwebtoken::get_current_timestamp();
4268 let claims = serde_json::json!({
4269 "iss": "https://auth.test.local",
4270 "aud": "https://mcp.test.local/mcp",
4271 "sub": "expired-bot",
4272 "scope": "mcp:read",
4273 "exp": now - 120,
4274 "iat": now - 3720,
4275 });
4276 let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
4277
4278 assert!(cache.validate_token(&token).await.is_none());
4279 }
4280
4281 #[tokio::test]
4282 async fn no_matching_scope_rejected() {
4283 let kid = "test-key-5";
4284 let (pem, jwks) = generate_test_keypair(kid);
4285
4286 let mock_server = wiremock::MockServer::start().await;
4287 wiremock::Mock::given(wiremock::matchers::method("GET"))
4288 .and(wiremock::matchers::path("/jwks.json"))
4289 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4290 .mount(&mock_server)
4291 .await;
4292
4293 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4294 let config = test_config(&jwks_uri);
4295 let cache = test_cache(&config);
4296
4297 let token = mint_token(
4298 &pem,
4299 kid,
4300 "https://auth.test.local",
4301 "https://mcp.test.local/mcp",
4302 "limited-bot",
4303 "some:other:scope", );
4305
4306 assert!(cache.validate_token(&token).await.is_none());
4307 }
4308
4309 #[tokio::test]
4310 async fn wrong_signing_key_rejected() {
4311 let kid = "test-key-6";
4312 let (_pem, jwks) = generate_test_keypair(kid);
4313
4314 let (attacker_pem, _) = generate_test_keypair(kid);
4316
4317 let mock_server = wiremock::MockServer::start().await;
4318 wiremock::Mock::given(wiremock::matchers::method("GET"))
4319 .and(wiremock::matchers::path("/jwks.json"))
4320 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4321 .mount(&mock_server)
4322 .await;
4323
4324 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4325 let config = test_config(&jwks_uri);
4326 let cache = test_cache(&config);
4327
4328 let token = mint_token(
4330 &attacker_pem,
4331 kid,
4332 "https://auth.test.local",
4333 "https://mcp.test.local/mcp",
4334 "attacker",
4335 "mcp:admin",
4336 );
4337
4338 assert!(cache.validate_token(&token).await.is_none());
4339 }
4340
4341 #[tokio::test]
4342 async fn admin_scope_maps_to_ops_role() {
4343 let kid = "test-key-7";
4344 let (pem, jwks) = generate_test_keypair(kid);
4345
4346 let mock_server = wiremock::MockServer::start().await;
4347 wiremock::Mock::given(wiremock::matchers::method("GET"))
4348 .and(wiremock::matchers::path("/jwks.json"))
4349 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4350 .mount(&mock_server)
4351 .await;
4352
4353 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4354 let config = test_config(&jwks_uri);
4355 let cache = test_cache(&config);
4356
4357 let token = mint_token(
4358 &pem,
4359 kid,
4360 "https://auth.test.local",
4361 "https://mcp.test.local/mcp",
4362 "admin-bot",
4363 "mcp:admin",
4364 );
4365
4366 let id = cache
4367 .validate_token(&token)
4368 .await
4369 .expect("should authenticate");
4370 assert_eq!(id.role, "ops");
4371 assert_eq!(id.name, "admin-bot");
4372 }
4373
4374 #[tokio::test]
4375 async fn jwks_server_down_returns_none() {
4376 let config = test_config("http://127.0.0.1:1/jwks.json");
4378 let cache = test_cache(&config);
4379
4380 let kid = "orphan-key";
4381 let (pem, _) = generate_test_keypair(kid);
4382 let token = mint_token(
4383 &pem,
4384 kid,
4385 "https://auth.test.local",
4386 "https://mcp.test.local/mcp",
4387 "bot",
4388 "mcp:read",
4389 );
4390
4391 assert!(cache.validate_token(&token).await.is_none());
4392 }
4393
4394 #[test]
4399 fn resolve_claim_path_flat_string() {
4400 let mut extra = HashMap::new();
4401 extra.insert(
4402 "scope".into(),
4403 serde_json::Value::String("mcp:read mcp:admin".into()),
4404 );
4405 let values = resolve_claim_path(&extra, "scope");
4406 assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
4407 }
4408
4409 #[test]
4410 fn resolve_claim_path_flat_array() {
4411 let mut extra = HashMap::new();
4412 extra.insert(
4413 "roles".into(),
4414 serde_json::json!(["mcp-admin", "mcp-viewer"]),
4415 );
4416 let values = resolve_claim_path(&extra, "roles");
4417 assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
4418 }
4419
4420 #[test]
4421 fn resolve_claim_path_nested_keycloak() {
4422 let mut extra = HashMap::new();
4423 extra.insert(
4424 "realm_access".into(),
4425 serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
4426 );
4427 let values = resolve_claim_path(&extra, "realm_access.roles");
4428 assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
4429 }
4430
4431 #[test]
4432 fn resolve_claim_path_missing_returns_empty() {
4433 let extra = HashMap::new();
4434 assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
4435 }
4436
4437 #[test]
4438 fn resolve_claim_path_numeric_leaf_returns_empty() {
4439 let mut extra = HashMap::new();
4440 extra.insert("count".into(), serde_json::json!(42));
4441 assert!(resolve_claim_path(&extra, "count").is_empty());
4442 }
4443
4444 fn make_claims(json: serde_json::Value) -> Claims {
4445 serde_json::from_value(json).expect("test claims must deserialize")
4446 }
4447
4448 #[test]
4449 fn first_class_scope_claim_splits_on_whitespace() {
4450 let claims = make_claims(serde_json::json!({
4451 "iss": "https://issuer.example.com",
4452 "exp": 9_999_999_999_u64,
4453 "scope": "read write admin",
4454 }));
4455 let values = first_class_claim_values(&claims, "scope");
4456 assert_eq!(values, vec!["read", "write", "admin"]);
4457 }
4458
4459 #[test]
4460 fn first_class_sub_claim_returns_single_value() {
4461 let claims = make_claims(serde_json::json!({
4462 "iss": "https://issuer.example.com",
4463 "exp": 9_999_999_999_u64,
4464 "sub": "service-account-orders",
4465 }));
4466 let values = first_class_claim_values(&claims, "sub");
4467 assert_eq!(values, vec!["service-account-orders"]);
4468 }
4469
4470 #[test]
4471 fn first_class_aud_claim_returns_every_audience() {
4472 let claims = make_claims(serde_json::json!({
4473 "iss": "https://issuer.example.com",
4474 "exp": 9_999_999_999_u64,
4475 "aud": ["api-a", "api-b"],
4476 }));
4477 let values = first_class_claim_values(&claims, "aud");
4478 assert_eq!(values, vec!["api-a", "api-b"]);
4479 }
4480
4481 #[test]
4482 fn first_class_unknown_path_returns_empty() {
4483 let claims = make_claims(serde_json::json!({
4484 "iss": "https://issuer.example.com",
4485 "exp": 9_999_999_999_u64,
4486 }));
4487 assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
4488 }
4489
4490 fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
4496 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4497 .expect("encoding key from PEM");
4498 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4499 header.kid = Some(kid.into());
4500 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4501 }
4502
4503 fn test_config_with_role_claim(
4504 jwks_uri: &str,
4505 role_claim: &str,
4506 role_mappings: Vec<RoleMapping>,
4507 ) -> OAuthConfig {
4508 OAuthConfig {
4509 require_subject: false,
4510 issuer: "https://auth.test.local".into(),
4511 audience: "https://mcp.test.local/mcp".into(),
4512 jwks_uri: jwks_uri.into(),
4513 scopes: vec![],
4514 role_claim: Some(role_claim.into()),
4515 role_mappings,
4516 jwks_cache_ttl: "5m".into(),
4517 proxy: None,
4518 token_exchange: None,
4519 ca_cert_path: None,
4520 allow_http_oauth_urls: true,
4521 max_jwks_keys: default_max_jwks_keys(),
4522 #[allow(
4523 deprecated,
4524 reason = "test fixture: explicit value for the deprecated field"
4525 )]
4526 strict_audience_validation: None,
4527 audience_validation_mode: None,
4528 jwks_max_response_bytes: default_jwks_max_bytes(),
4529 ssrf_allowlist: None,
4530 }
4531 }
4532
4533 #[tokio::test]
4534 async fn screen_oauth_target_rejects_literal_ip() {
4535 let err = screen_oauth_target(
4536 "https://127.0.0.1/jwks.json",
4537 false,
4538 &crate::ssrf::CompiledSsrfAllowlist::default(),
4539 )
4540 .await
4541 .expect_err("literal IPs must be rejected");
4542 let msg = err.to_string();
4543 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4544 }
4545
4546 #[tokio::test]
4547 async fn screen_oauth_target_rejects_private_dns_resolution() {
4548 let err = screen_oauth_target(
4549 "https://localhost/jwks.json",
4550 false,
4551 &crate::ssrf::CompiledSsrfAllowlist::default(),
4552 )
4553 .await
4554 .expect_err("localhost resolution must be rejected");
4555 let msg = err.to_string();
4556 assert!(
4557 msg.contains("blocked IP") && msg.contains("loopback"),
4558 "got {msg:?}"
4559 );
4560 }
4561
4562 #[tokio::test]
4563 async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
4564 let err = screen_oauth_target(
4565 "http://127.0.0.1/jwks.json",
4566 true,
4567 &crate::ssrf::CompiledSsrfAllowlist::default(),
4568 )
4569 .await
4570 .expect_err("literal IPs must still be rejected when http is allowed");
4571 let msg = err.to_string();
4572 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4573 }
4574
4575 #[tokio::test]
4576 async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
4577 let err = screen_oauth_target(
4578 "http://localhost/jwks.json",
4579 true,
4580 &crate::ssrf::CompiledSsrfAllowlist::default(),
4581 )
4582 .await
4583 .expect_err("private DNS resolution must still be rejected when http is allowed");
4584 let msg = err.to_string();
4585 assert!(
4586 msg.contains("blocked IP") && msg.contains("loopback"),
4587 "got {msg:?}"
4588 );
4589 }
4590
4591 #[tokio::test]
4592 async fn screen_oauth_target_allows_public_hostname() {
4593 screen_oauth_target(
4594 "https://example.com/.well-known/jwks.json",
4595 false,
4596 &crate::ssrf::CompiledSsrfAllowlist::default(),
4597 )
4598 .await
4599 .expect("public hostname should pass screening");
4600 }
4601
4602 fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
4608 let raw = OAuthSsrfAllowlist {
4609 hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
4610 cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
4611 };
4612 compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
4613 }
4614
4615 #[test]
4616 fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
4617 let raw = OAuthSsrfAllowlist {
4618 hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
4619 cidrs: vec![],
4620 };
4621 let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
4622 assert_eq!(compiled.host_count(), 1);
4623 assert!(compiled.host_allowed("rhbk.ops.example.com"));
4624 assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
4625 }
4626
4627 #[test]
4628 fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
4629 let raw = OAuthSsrfAllowlist {
4630 hosts: vec!["10.0.0.1".into()],
4631 cidrs: vec![],
4632 };
4633 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
4634 assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
4635 }
4636
4637 #[test]
4638 fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
4639 let raw = OAuthSsrfAllowlist {
4640 hosts: vec!["rhbk.ops.example.com:8443".into()],
4641 cidrs: vec![],
4642 };
4643 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
4644 assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
4645 }
4646
4647 #[test]
4650 fn internal_suffix_rejected_by_default() {
4651 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4652 for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
4653 assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
4654 }
4655 }
4656
4657 #[test]
4658 fn exact_allowlisted_internal_permitted() {
4659 let allow = make_allowlist(&["idp.internal"], &[]);
4660 assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
4661 assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
4662 }
4663
4664 #[test]
4665 fn subdomain_of_allowlisted_internal_still_rejected() {
4666 let allow = make_allowlist(&["idp.internal"], &[]);
4667 assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
4668 }
4669
4670 #[test]
4671 fn cidr_allowlist_does_not_bypass_suffix_denylist() {
4672 let allow = make_allowlist(&[], &["10.0.0.0/8"]);
4673 assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
4674 }
4675
4676 #[test]
4677 fn public_hostname_not_blocked_by_suffix() {
4678 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4679 assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
4680 }
4681
4682 #[test]
4683 fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
4684 let raw = OAuthSsrfAllowlist {
4685 hosts: vec![],
4686 cidrs: vec!["not-a-cidr".into()],
4687 };
4688 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
4689 assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
4690 }
4691
4692 #[test]
4693 fn validate_rejects_misconfigured_allowlist() {
4694 let mut cfg = OAuthConfig::builder(
4695 "https://auth.example.com/",
4696 "mcp",
4697 "https://auth.example.com/jwks.json",
4698 )
4699 .build();
4700 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4701 hosts: vec!["10.0.0.1".into()],
4702 cidrs: vec![],
4703 });
4704 let err = cfg
4705 .validate()
4706 .expect_err("literal IP host must be rejected");
4707 assert!(
4708 err.to_string().contains("oauth.ssrf_allowlist"),
4709 "got {err}"
4710 );
4711 }
4712
4713 #[tokio::test]
4714 async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
4715 let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
4719 let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
4720 .await
4721 .expect_err("loopback must still be blocked when not in allowlist");
4722 let msg = err.to_string();
4723 assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
4724 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4725 assert!(msg.contains("SECURITY.md"), "got {msg:?}");
4726 }
4727
4728 #[tokio::test]
4729 async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
4730 let err = screen_oauth_target(
4733 "https://localhost/jwks.json",
4734 false,
4735 &crate::ssrf::CompiledSsrfAllowlist::default(),
4736 )
4737 .await
4738 .expect_err("loopback rejection");
4739 let msg = err.to_string();
4740 assert!(msg.contains("blocked IP"), "got {msg:?}");
4741 assert!(msg.contains("loopback"), "got {msg:?}");
4742 assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4744 }
4745
4746 #[tokio::test]
4747 async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
4748 let allow = make_allowlist(&["localhost"], &[]);
4750 screen_oauth_target("https://localhost/jwks.json", false, &allow)
4751 .await
4752 .expect("allowlisted host must pass");
4753 }
4754
4755 #[tokio::test]
4756 async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
4757 let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
4760 screen_oauth_target("https://localhost/jwks.json", false, &allow)
4761 .await
4762 .expect("allowlisted CIDR must pass");
4763 }
4764
4765 #[tokio::test]
4766 async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
4767 let mut cfg = OAuthConfig::builder(
4768 "https://auth.example.com/",
4769 "mcp",
4770 "https://auth.example.com/jwks.json",
4771 )
4772 .build();
4773 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4774 hosts: vec![],
4775 cidrs: vec!["bad-cidr".into()],
4776 });
4777 let Err(err) = JwksCache::new(&cfg) else {
4778 panic!("invalid CIDR must fail JwksCache::new")
4779 };
4780 let msg = err.to_string();
4781 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4782 }
4783
4784 #[tokio::test]
4785 async fn jwks_cache_new_invalid_ttl_is_err() {
4786 let cfg = OAuthConfig::builder(
4789 "https://auth.example.com/",
4790 "mcp",
4791 "https://auth.example.com/jwks.json",
4792 )
4793 .jwks_cache_ttl("not-a-duration")
4794 .build();
4795 let Err(err) = JwksCache::new(&cfg) else {
4796 panic!("invalid jwks_cache_ttl must fail JwksCache::new")
4797 };
4798 let msg = err.to_string();
4799 assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
4800 }
4801
4802 #[tokio::test]
4803 async fn audience_default_is_strict() {
4804 let kid = "test-audience-azp-default";
4805 let (pem, jwks) = generate_test_keypair(kid);
4806
4807 let mock_server = wiremock::MockServer::start().await;
4808 wiremock::Mock::given(wiremock::matchers::method("GET"))
4809 .and(wiremock::matchers::path("/jwks.json"))
4810 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4811 .mount(&mock_server)
4812 .await;
4813
4814 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4815 let config = test_config(&jwks_uri);
4816 let cache = test_cache(&config);
4817
4818 let now = jsonwebtoken::get_current_timestamp();
4819 let token = mint_token_with_claims(
4820 &pem,
4821 kid,
4822 &serde_json::json!({
4823 "iss": "https://auth.test.local",
4824 "aud": "https://some-other-resource.example.com",
4825 "azp": "https://mcp.test.local/mcp",
4826 "sub": "compat-client",
4827 "scope": "mcp:read",
4828 "exp": now + 3600,
4829 "iat": now,
4830 }),
4831 );
4832
4833 let failure = cache
4834 .validate_token_with_reason(&token)
4835 .await
4836 .expect_err("the default policy is Strict and must reject an azp-only match");
4837 assert_eq!(failure, JwtValidationFailure::Invalid);
4838 }
4839
4840 #[tokio::test]
4841 async fn audience_warn_still_accepts_azp() {
4842 let kid = "test-audience-warn-optin";
4843 let (pem, jwks) = generate_test_keypair(kid);
4844
4845 let mock_server = wiremock::MockServer::start().await;
4846 wiremock::Mock::given(wiremock::matchers::method("GET"))
4847 .and(wiremock::matchers::path("/jwks.json"))
4848 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4849 .mount(&mock_server)
4850 .await;
4851
4852 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4853 let mut config = test_config(&jwks_uri);
4854 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
4855 let cache = test_cache(&config);
4856
4857 let now = jsonwebtoken::get_current_timestamp();
4858 let token = mint_token_with_claims(
4859 &pem,
4860 kid,
4861 &serde_json::json!({
4862 "iss": "https://auth.test.local",
4863 "aud": "https://some-other-resource.example.com",
4864 "azp": "https://mcp.test.local/mcp",
4865 "sub": "warn-optin-client",
4866 "scope": "mcp:read",
4867 "exp": now + 3600,
4868 "iat": now,
4869 }),
4870 );
4871
4872 cache.validate_token_with_reason(&token).await.expect(
4873 "the audience_validation_mode=warn opt-out must still accept an azp-only match",
4874 );
4875 }
4876
4877 #[tokio::test]
4878 async fn legacy_strict_false_maps_to_warn() {
4879 let kid = "test-audience-legacy-false";
4880 let (pem, jwks) = generate_test_keypair(kid);
4881
4882 let mock_server = wiremock::MockServer::start().await;
4883 wiremock::Mock::given(wiremock::matchers::method("GET"))
4884 .and(wiremock::matchers::path("/jwks.json"))
4885 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4886 .mount(&mock_server)
4887 .await;
4888
4889 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4890 let mut config = test_config(&jwks_uri);
4891 #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
4894 {
4895 config.strict_audience_validation = Some(false);
4896 }
4897 let cache = test_cache(&config);
4898
4899 let now = jsonwebtoken::get_current_timestamp();
4900 let token = mint_token_with_claims(
4901 &pem,
4902 kid,
4903 &serde_json::json!({
4904 "iss": "https://auth.test.local",
4905 "aud": "https://some-other-resource.example.com",
4906 "azp": "https://mcp.test.local/mcp",
4907 "sub": "legacy-false-client",
4908 "scope": "mcp:read",
4909 "exp": now + 3600,
4910 "iat": now,
4911 }),
4912 );
4913
4914 cache
4915 .validate_token_with_reason(&token)
4916 .await
4917 .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
4918 }
4919
4920 #[tokio::test]
4921 async fn aud_match_always_accepts() {
4922 let kid = "test-audience-aud-match";
4923 let (pem, jwks) = generate_test_keypair(kid);
4924
4925 let mock_server = wiremock::MockServer::start().await;
4926 wiremock::Mock::given(wiremock::matchers::method("GET"))
4927 .and(wiremock::matchers::path("/jwks.json"))
4928 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4929 .mount(&mock_server)
4930 .await;
4931
4932 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4933 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4935
4936 let now = jsonwebtoken::get_current_timestamp();
4937 let token = mint_token_with_claims(
4938 &pem,
4939 kid,
4940 &serde_json::json!({
4941 "iss": "https://auth.test.local",
4942 "aud": "https://mcp.test.local/mcp",
4943 "sub": "aud-match-client",
4944 "scope": "mcp:read",
4945 "exp": now + 3600,
4946 "iat": now,
4947 }),
4948 );
4949
4950 cache
4951 .validate_token_with_reason(&token)
4952 .await
4953 .expect("a matching aud must be accepted even under the Strict default");
4954 }
4955
4956 #[tokio::test]
4957 async fn strict_audience_validation_rejects_azp_only_match() {
4958 let kid = "test-audience-azp-strict";
4959 let (pem, jwks) = generate_test_keypair(kid);
4960
4961 let mock_server = wiremock::MockServer::start().await;
4962 wiremock::Mock::given(wiremock::matchers::method("GET"))
4963 .and(wiremock::matchers::path("/jwks.json"))
4964 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4965 .mount(&mock_server)
4966 .await;
4967
4968 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4969 let mut config = test_config(&jwks_uri);
4970 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
4971 {
4972 config.strict_audience_validation = Some(true);
4973 }
4974 let cache = test_cache(&config);
4975
4976 let now = jsonwebtoken::get_current_timestamp();
4977 let token = mint_token_with_claims(
4978 &pem,
4979 kid,
4980 &serde_json::json!({
4981 "iss": "https://auth.test.local",
4982 "aud": "https://some-other-resource.example.com",
4983 "azp": "https://mcp.test.local/mcp",
4984 "sub": "strict-client",
4985 "scope": "mcp:read",
4986 "exp": now + 3600,
4987 "iat": now,
4988 }),
4989 );
4990
4991 let failure = cache
4992 .validate_token_with_reason(&token)
4993 .await
4994 .expect_err("strict audience validation must ignore azp fallback");
4995 assert_eq!(failure, JwtValidationFailure::Invalid);
4996 }
4997
4998 #[tokio::test]
4999 async fn warn_mode_accepts_azp_only_match_and_warns_once() {
5000 let kid = "test-audience-warn-mode";
5001 let (pem, jwks) = generate_test_keypair(kid);
5002
5003 let mock_server = wiremock::MockServer::start().await;
5004 wiremock::Mock::given(wiremock::matchers::method("GET"))
5005 .and(wiremock::matchers::path("/jwks.json"))
5006 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5007 .mount(&mock_server)
5008 .await;
5009
5010 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5011 let mut config = test_config(&jwks_uri);
5012 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
5013 let cache = test_cache(&config);
5014
5015 let now = jsonwebtoken::get_current_timestamp();
5016 let claims = serde_json::json!({
5017 "iss": "https://auth.test.local",
5018 "aud": "https://some-other-resource.example.com",
5019 "azp": "https://mcp.test.local/mcp",
5020 "sub": "warn-client",
5021 "scope": "mcp:read",
5022 "exp": now + 3600,
5023 "iat": now,
5024 });
5025 let token = mint_token_with_claims(&pem, kid, &claims);
5026
5027 let identity = cache
5028 .validate_token_with_reason(&token)
5029 .await
5030 .expect("warn mode must accept azp-only match");
5031 assert_eq!(identity.role, "viewer");
5032 assert!(
5033 cache.azp_fallback_warned.load(Ordering::Relaxed),
5034 "warn-once flag should be set after first azp-only match"
5035 );
5036
5037 let token2 = mint_token_with_claims(&pem, kid, &claims);
5038 cache
5039 .validate_token_with_reason(&token2)
5040 .await
5041 .expect("warn mode must continue accepting subsequent matches");
5042 assert!(
5043 cache.azp_fallback_warned.load(Ordering::Relaxed),
5044 "warn-once flag must remain set; the assertion guards against accidental clearing"
5045 );
5046 }
5047
5048 #[tokio::test]
5049 async fn permissive_mode_accepts_azp_only_match_silently() {
5050 let kid = "test-audience-permissive-mode";
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 mut config = test_config(&jwks_uri);
5062 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5063 let cache = test_cache(&config);
5064
5065 let now = jsonwebtoken::get_current_timestamp();
5066 let token = mint_token_with_claims(
5067 &pem,
5068 kid,
5069 &serde_json::json!({
5070 "iss": "https://auth.test.local",
5071 "aud": "https://some-other-resource.example.com",
5072 "azp": "https://mcp.test.local/mcp",
5073 "sub": "permissive-client",
5074 "scope": "mcp:read",
5075 "exp": now + 3600,
5076 "iat": now,
5077 }),
5078 );
5079
5080 cache
5081 .validate_token_with_reason(&token)
5082 .await
5083 .expect("permissive mode must accept azp-only match");
5084 assert!(
5085 !cache.azp_fallback_warned.load(Ordering::Relaxed),
5086 "permissive mode must not flip the warn-once flag"
5087 );
5088 }
5089
5090 #[test]
5091 fn audience_validation_mode_overrides_legacy_bool() {
5092 let mut config = OAuthConfig::default();
5093 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5094 {
5095 config.strict_audience_validation = Some(false);
5096 }
5097 config.audience_validation_mode = Some(AudienceValidationMode::Strict);
5098 assert_eq!(
5099 config.effective_audience_validation_mode(),
5100 AudienceValidationMode::Strict,
5101 "explicit mode must override legacy false"
5102 );
5103
5104 let mut config = OAuthConfig::default();
5105 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5106 {
5107 config.strict_audience_validation = Some(true);
5108 }
5109 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5110 assert_eq!(
5111 config.effective_audience_validation_mode(),
5112 AudienceValidationMode::Permissive,
5113 "explicit mode must override legacy true"
5114 );
5115 }
5116
5117 #[test]
5118 fn audience_validation_mode_default_is_strict_when_unset() {
5119 let config = OAuthConfig::default();
5120 assert_eq!(
5121 config.effective_audience_validation_mode(),
5122 AudienceValidationMode::Strict,
5123 "unset mode + unset bool must resolve to Strict (the secure default)"
5124 );
5125 }
5126
5127 #[test]
5128 fn audience_validation_legacy_bool_true_resolves_to_strict() {
5129 let mut config = OAuthConfig::default();
5130 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
5131 {
5132 config.strict_audience_validation = Some(true);
5133 }
5134 assert_eq!(
5135 config.effective_audience_validation_mode(),
5136 AudienceValidationMode::Strict,
5137 "legacy bool=true must resolve to Strict for backward compat"
5138 );
5139 }
5140
5141 #[derive(Clone, Default)]
5142 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
5143
5144 impl CapturedLogs {
5145 fn contents(&self) -> String {
5146 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
5147 String::from_utf8(bytes).unwrap_or_default()
5148 }
5149 }
5150
5151 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
5152
5153 impl std::io::Write for CapturedLogsWriter {
5154 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
5155 if let Ok(mut guard) = self.0.lock() {
5156 guard.extend_from_slice(buf);
5157 }
5158 Ok(buf.len())
5159 }
5160
5161 fn flush(&mut self) -> std::io::Result<()> {
5162 Ok(())
5163 }
5164 }
5165
5166 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
5167 type Writer = CapturedLogsWriter;
5168
5169 fn make_writer(&'a self) -> Self::Writer {
5170 CapturedLogsWriter(Arc::clone(&self.0))
5171 }
5172 }
5173
5174 #[tokio::test]
5175 async fn jwks_response_size_cap_returns_none_and_logs_warning() {
5176 let kid = "oversized-jwks";
5177 let (_pem, jwks) = generate_test_keypair(kid);
5178 let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
5179 oversized_body.push_str(&" ".repeat(4096));
5180
5181 let mock_server = wiremock::MockServer::start().await;
5182 wiremock::Mock::given(wiremock::matchers::method("GET"))
5183 .and(wiremock::matchers::path("/jwks.json"))
5184 .respond_with(
5185 wiremock::ResponseTemplate::new(200)
5186 .insert_header("content-type", "application/json")
5187 .set_body_string(oversized_body),
5188 )
5189 .mount(&mock_server)
5190 .await;
5191
5192 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5193 let mut config = test_config(&jwks_uri);
5194 config.jwks_max_response_bytes = 256;
5195 let cache = test_cache(&config);
5196
5197 let logs = CapturedLogs::default();
5198 let subscriber = tracing_subscriber::fmt()
5199 .with_writer(logs.clone())
5200 .with_ansi(false)
5201 .without_time()
5202 .finish();
5203 let _guard = tracing::subscriber::set_default(subscriber);
5204
5205 let result = cache.fetch_jwks().await;
5206 assert!(result.is_none(), "oversized JWKS must be dropped");
5207 assert!(
5208 logs.contents()
5209 .contains("JWKS response exceeded configured size cap"),
5210 "expected cap-exceeded warning in logs"
5211 );
5212 }
5213
5214 #[tokio::test]
5218 async fn redirect_rejection_log_does_not_echo_credentials() {
5219 let mock_server = wiremock::MockServer::start().await;
5220 wiremock::Mock::given(wiremock::matchers::method("GET"))
5221 .and(wiremock::matchers::path("/jwks.json"))
5222 .respond_with(
5223 wiremock::ResponseTemplate::new(302)
5224 .insert_header("location", "https://u:p@redirect-target.example/next"),
5225 )
5226 .mount(&mock_server)
5227 .await;
5228
5229 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5230 let config = test_config(&jwks_uri);
5231 let cache = test_cache(&config);
5232
5233 let logs = CapturedLogs::default();
5234 let subscriber = tracing_subscriber::fmt()
5235 .with_writer(logs.clone())
5236 .with_ansi(false)
5237 .without_time()
5238 .finish();
5239 let _guard = tracing::subscriber::set_default(subscriber);
5240
5241 let result = cache.fetch_jwks().await;
5242 assert!(result.is_none(), "rejected redirect must fail the fetch");
5243 let contents = logs.contents();
5244 assert!(
5245 contents.contains("oauth redirect rejected"),
5246 "expected redirect-rejection warning in logs: {contents}"
5247 );
5248 assert!(
5249 !contents.contains("u:p"),
5250 "rejection log must not echo userinfo credentials: {contents}"
5251 );
5252 }
5253
5254 #[tokio::test]
5255 async fn role_claim_keycloak_nested_array() {
5256 let kid = "test-role-1";
5257 let (pem, jwks) = generate_test_keypair(kid);
5258
5259 let mock_server = wiremock::MockServer::start().await;
5260 wiremock::Mock::given(wiremock::matchers::method("GET"))
5261 .and(wiremock::matchers::path("/jwks.json"))
5262 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5263 .mount(&mock_server)
5264 .await;
5265
5266 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5267 let config = test_config_with_role_claim(
5268 &jwks_uri,
5269 "realm_access.roles",
5270 vec![
5271 RoleMapping {
5272 claim_value: "mcp-admin".into(),
5273 role: "ops".into(),
5274 },
5275 RoleMapping {
5276 claim_value: "mcp-viewer".into(),
5277 role: "viewer".into(),
5278 },
5279 ],
5280 );
5281 let cache = test_cache(&config);
5282
5283 let now = jsonwebtoken::get_current_timestamp();
5284 let token = mint_token_with_claims(
5285 &pem,
5286 kid,
5287 &serde_json::json!({
5288 "iss": "https://auth.test.local",
5289 "aud": "https://mcp.test.local/mcp",
5290 "sub": "keycloak-user",
5291 "exp": now + 3600,
5292 "iat": now,
5293 "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
5294 }),
5295 );
5296
5297 let id = cache
5298 .validate_token(&token)
5299 .await
5300 .expect("should authenticate");
5301 assert_eq!(id.name, "keycloak-user");
5302 assert_eq!(id.role, "ops");
5303 }
5304
5305 #[tokio::test]
5306 async fn role_claim_flat_roles_array() {
5307 let kid = "test-role-2";
5308 let (pem, jwks) = generate_test_keypair(kid);
5309
5310 let mock_server = wiremock::MockServer::start().await;
5311 wiremock::Mock::given(wiremock::matchers::method("GET"))
5312 .and(wiremock::matchers::path("/jwks.json"))
5313 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5314 .mount(&mock_server)
5315 .await;
5316
5317 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5318 let config = test_config_with_role_claim(
5319 &jwks_uri,
5320 "roles",
5321 vec![
5322 RoleMapping {
5323 claim_value: "MCP.Admin".into(),
5324 role: "ops".into(),
5325 },
5326 RoleMapping {
5327 claim_value: "MCP.Reader".into(),
5328 role: "viewer".into(),
5329 },
5330 ],
5331 );
5332 let cache = test_cache(&config);
5333
5334 let now = jsonwebtoken::get_current_timestamp();
5335 let token = mint_token_with_claims(
5336 &pem,
5337 kid,
5338 &serde_json::json!({
5339 "iss": "https://auth.test.local",
5340 "aud": "https://mcp.test.local/mcp",
5341 "sub": "azure-ad-user",
5342 "exp": now + 3600,
5343 "iat": now,
5344 "roles": ["MCP.Reader", "OtherApp.Admin"]
5345 }),
5346 );
5347
5348 let id = cache
5349 .validate_token(&token)
5350 .await
5351 .expect("should authenticate");
5352 assert_eq!(id.name, "azure-ad-user");
5353 assert_eq!(id.role, "viewer");
5354 }
5355
5356 #[tokio::test]
5357 async fn role_claim_no_matching_value_rejected() {
5358 let kid = "test-role-3";
5359 let (pem, jwks) = generate_test_keypair(kid);
5360
5361 let mock_server = wiremock::MockServer::start().await;
5362 wiremock::Mock::given(wiremock::matchers::method("GET"))
5363 .and(wiremock::matchers::path("/jwks.json"))
5364 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5365 .mount(&mock_server)
5366 .await;
5367
5368 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5369 let config = test_config_with_role_claim(
5370 &jwks_uri,
5371 "roles",
5372 vec![RoleMapping {
5373 claim_value: "mcp-admin".into(),
5374 role: "ops".into(),
5375 }],
5376 );
5377 let cache = test_cache(&config);
5378
5379 let now = jsonwebtoken::get_current_timestamp();
5380 let token = mint_token_with_claims(
5381 &pem,
5382 kid,
5383 &serde_json::json!({
5384 "iss": "https://auth.test.local",
5385 "aud": "https://mcp.test.local/mcp",
5386 "sub": "limited-user",
5387 "exp": now + 3600,
5388 "iat": now,
5389 "roles": ["some-other-role"]
5390 }),
5391 );
5392
5393 assert!(cache.validate_token(&token).await.is_none());
5394 }
5395
5396 #[tokio::test]
5397 async fn role_claim_space_separated_string() {
5398 let kid = "test-role-4";
5399 let (pem, jwks) = generate_test_keypair(kid);
5400
5401 let mock_server = wiremock::MockServer::start().await;
5402 wiremock::Mock::given(wiremock::matchers::method("GET"))
5403 .and(wiremock::matchers::path("/jwks.json"))
5404 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5405 .mount(&mock_server)
5406 .await;
5407
5408 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5409 let config = test_config_with_role_claim(
5410 &jwks_uri,
5411 "custom_scope",
5412 vec![
5413 RoleMapping {
5414 claim_value: "write".into(),
5415 role: "ops".into(),
5416 },
5417 RoleMapping {
5418 claim_value: "read".into(),
5419 role: "viewer".into(),
5420 },
5421 ],
5422 );
5423 let cache = test_cache(&config);
5424
5425 let now = jsonwebtoken::get_current_timestamp();
5426 let token = mint_token_with_claims(
5427 &pem,
5428 kid,
5429 &serde_json::json!({
5430 "iss": "https://auth.test.local",
5431 "aud": "https://mcp.test.local/mcp",
5432 "sub": "custom-client",
5433 "exp": now + 3600,
5434 "iat": now,
5435 "custom_scope": "read audit"
5436 }),
5437 );
5438
5439 let id = cache
5440 .validate_token(&token)
5441 .await
5442 .expect("should authenticate");
5443 assert_eq!(id.name, "custom-client");
5444 assert_eq!(id.role, "viewer");
5445 }
5446
5447 #[tokio::test]
5448 async fn scope_backward_compat_without_role_claim() {
5449 let kid = "test-compat-1";
5451 let (pem, jwks) = generate_test_keypair(kid);
5452
5453 let mock_server = wiremock::MockServer::start().await;
5454 wiremock::Mock::given(wiremock::matchers::method("GET"))
5455 .and(wiremock::matchers::path("/jwks.json"))
5456 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5457 .mount(&mock_server)
5458 .await;
5459
5460 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5461 let config = test_config(&jwks_uri); let cache = test_cache(&config);
5463
5464 let token = mint_token(
5465 &pem,
5466 kid,
5467 "https://auth.test.local",
5468 "https://mcp.test.local/mcp",
5469 "legacy-bot",
5470 "mcp:admin other:scope",
5471 );
5472
5473 let id = cache
5474 .validate_token(&token)
5475 .await
5476 .expect("should authenticate");
5477 assert_eq!(id.name, "legacy-bot");
5478 assert_eq!(id.role, "ops"); }
5480
5481 #[tokio::test]
5486 async fn jwks_refresh_deduplication() {
5487 let kid = "test-dedup";
5490 let (pem, jwks) = generate_test_keypair(kid);
5491
5492 let mock_server = wiremock::MockServer::start().await;
5493 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5494 .and(wiremock::matchers::path("/jwks.json"))
5495 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5496 .expect(1) .mount(&mock_server)
5498 .await;
5499
5500 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5501 let config = test_config(&jwks_uri);
5502 let cache = Arc::new(test_cache(&config));
5503
5504 let token = mint_token(
5506 &pem,
5507 kid,
5508 "https://auth.test.local",
5509 "https://mcp.test.local/mcp",
5510 "concurrent-bot",
5511 "mcp:read",
5512 );
5513
5514 let mut handles = Vec::new();
5515 for _ in 0..5 {
5516 let c = Arc::clone(&cache);
5517 let t = token.clone();
5518 handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
5519 }
5520
5521 for h in handles {
5522 let result = h.await.unwrap();
5523 assert!(result.is_some(), "all concurrent requests should succeed");
5524 }
5525
5526 }
5528
5529 #[tokio::test]
5530 async fn jwks_refresh_cooldown_blocks_rapid_requests() {
5531 let kid = "test-cooldown";
5534 let (_pem, jwks) = generate_test_keypair(kid);
5535
5536 let mock_server = wiremock::MockServer::start().await;
5537 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5538 .and(wiremock::matchers::path("/jwks.json"))
5539 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5540 .expect(1) .mount(&mock_server)
5542 .await;
5543
5544 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5545 let config = test_config(&jwks_uri);
5546 let cache = test_cache(&config);
5547
5548 let fake_token1 =
5550 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
5551 let _ = cache.validate_token(fake_token1).await;
5552
5553 let fake_token2 =
5556 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
5557 let _ = cache.validate_token(fake_token2).await;
5558
5559 let fake_token3 =
5561 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
5562 let _ = cache.validate_token(fake_token3).await;
5563
5564 }
5566
5567 fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
5570 OAuthProxyConfig {
5571 authorize_url: "https://example.invalid/auth".into(),
5572 token_url: token_url.into(),
5573 client_id: "mcp-client".into(),
5574 client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
5575 introspection_url: None,
5576 revocation_url: None,
5577 expose_admin_endpoints: false,
5578 require_auth_on_admin_endpoints: false,
5579 allow_unauthenticated_admin_endpoints: false,
5580 }
5581 }
5582
5583 fn test_http_client() -> OauthHttpClient {
5586 rustls::crypto::ring::default_provider()
5587 .install_default()
5588 .ok();
5589 let config = OAuthConfig::builder(
5590 "https://auth.test.local",
5591 "https://mcp.test.local/mcp",
5592 "https://auth.test.local/.well-known/jwks.json",
5593 )
5594 .allow_http_oauth_urls(true)
5595 .build();
5596 OauthHttpClient::with_config(&config)
5597 .expect("build test http client")
5598 .__test_allow_loopback_ssrf()
5599 }
5600
5601 #[tokio::test]
5602 async fn introspect_proxies_and_injects_client_credentials() {
5603 use wiremock::matchers::{body_string_contains, method, path};
5604
5605 let mock_server = wiremock::MockServer::start().await;
5606 wiremock::Mock::given(method("POST"))
5607 .and(path("/introspect"))
5608 .and(body_string_contains("client_id=mcp-client"))
5609 .and(body_string_contains("client_secret=shh"))
5610 .and(body_string_contains("token=abc"))
5611 .respond_with(
5612 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5613 "active": true,
5614 "scope": "read"
5615 })),
5616 )
5617 .expect(1)
5618 .mount(&mock_server)
5619 .await;
5620
5621 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5622 proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
5623
5624 let http = test_http_client();
5625 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5626 assert_eq!(resp.status(), 200);
5627 }
5628
5629 #[tokio::test]
5630 async fn token_proxy_fails_closed_on_oversized_upstream_response() {
5631 use http_body_util::BodyExt as _;
5632 use wiremock::matchers::{method, path};
5633
5634 let oversized = "x"
5636 .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
5637 let mock_server = wiremock::MockServer::start().await;
5638 wiremock::Mock::given(method("POST"))
5639 .and(path("/token"))
5640 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
5641 .expect(1)
5642 .mount(&mock_server)
5643 .await;
5644
5645 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5646 let http = test_http_client();
5647 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5648
5649 assert_eq!(
5651 resp.status(),
5652 502,
5653 "oversized upstream response must fail closed as 502"
5654 );
5655 let body = resp
5656 .into_body()
5657 .collect()
5658 .await
5659 .expect("collect body")
5660 .to_bytes();
5661 assert!(
5662 body.len() < 1024,
5663 "must return the small generic error body, not the oversized upstream body (got {} bytes)",
5664 body.len()
5665 );
5666 assert!(
5667 !body.windows(8).any(|w| w == b"xxxxxxxx"),
5668 "the oversized upstream payload must not be forwarded to the client"
5669 );
5670 }
5671
5672 #[tokio::test]
5673 async fn token_proxy_passes_through_normal_response() {
5674 use http_body_util::BodyExt as _;
5675 use wiremock::matchers::{method, path};
5676
5677 let mock_server = wiremock::MockServer::start().await;
5678 wiremock::Mock::given(method("POST"))
5679 .and(path("/token"))
5680 .respond_with(
5681 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5682 "access_token": "at-123",
5683 "token_type": "Bearer"
5684 })),
5685 )
5686 .expect(1)
5687 .mount(&mock_server)
5688 .await;
5689
5690 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5691 let http = test_http_client();
5692 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5693
5694 assert_eq!(
5695 resp.status(),
5696 200,
5697 "a normal-sized response must pass through"
5698 );
5699 let body = resp
5700 .into_body()
5701 .collect()
5702 .await
5703 .expect("collect body")
5704 .to_bytes();
5705 let json: serde_json::Value =
5706 serde_json::from_slice(&body).expect("upstream JSON preserved");
5707 assert_eq!(json["access_token"], "at-123");
5708 }
5709
5710 #[tokio::test]
5711 async fn introspect_returns_404_when_not_configured() {
5712 let proxy = proxy_cfg("https://example.invalid/token");
5713 let http = test_http_client();
5714 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5715 assert_eq!(resp.status(), 404);
5716 }
5717
5718 #[tokio::test]
5719 async fn revoke_proxies_and_returns_upstream_status() {
5720 use wiremock::matchers::{method, path};
5721
5722 let mock_server = wiremock::MockServer::start().await;
5723 wiremock::Mock::given(method("POST"))
5724 .and(path("/revoke"))
5725 .respond_with(wiremock::ResponseTemplate::new(200))
5726 .expect(1)
5727 .mount(&mock_server)
5728 .await;
5729
5730 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5731 proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
5732
5733 let http = test_http_client();
5734 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5735 assert_eq!(resp.status(), 200);
5736 }
5737
5738 #[tokio::test]
5739 async fn revoke_returns_404_when_not_configured() {
5740 let proxy = proxy_cfg("https://example.invalid/token");
5741 let http = test_http_client();
5742 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5743 assert_eq!(resp.status(), 404);
5744 }
5745
5746 #[test]
5747 fn metadata_advertises_endpoints_only_when_configured() {
5748 let mut cfg = test_config("https://auth.test.local/jwks.json");
5749 let m = authorization_server_metadata("https://mcp.local", &cfg);
5751 assert!(m.get("introspection_endpoint").is_none());
5752 assert!(m.get("revocation_endpoint").is_none());
5753
5754 let mut proxy = proxy_cfg("https://upstream.local/token");
5757 proxy.introspection_url = Some("https://upstream.local/introspect".into());
5758 proxy.revocation_url = Some("https://upstream.local/revoke".into());
5759 cfg.proxy = Some(proxy);
5760 let m = authorization_server_metadata("https://mcp.local", &cfg);
5761 assert!(
5762 m.get("introspection_endpoint").is_none(),
5763 "introspection must not be advertised when expose_admin_endpoints=false"
5764 );
5765 assert!(
5766 m.get("revocation_endpoint").is_none(),
5767 "revocation must not be advertised when expose_admin_endpoints=false"
5768 );
5769
5770 if let Some(p) = cfg.proxy.as_mut() {
5772 p.expose_admin_endpoints = true;
5773 p.revocation_url = None;
5774 }
5775 let m = authorization_server_metadata("https://mcp.local", &cfg);
5776 assert_eq!(
5777 m["introspection_endpoint"],
5778 serde_json::Value::String("https://mcp.local/introspect".into())
5779 );
5780 assert!(m.get("revocation_endpoint").is_none());
5781
5782 if let Some(p) = cfg.proxy.as_mut() {
5784 p.revocation_url = Some("https://upstream.local/revoke".into());
5785 }
5786 let m = authorization_server_metadata("https://mcp.local", &cfg);
5787 assert_eq!(
5788 m["revocation_endpoint"],
5789 serde_json::Value::String("https://mcp.local/revoke".into())
5790 );
5791 }
5792
5793 fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
5796 let mut cfg = validation_https_config();
5797 cfg.token_exchange = Some(tx);
5798 cfg
5799 }
5800
5801 fn tx_with(
5802 client_secret: Option<&str>,
5803 client_cert: Option<ClientCertConfig>,
5804 ) -> TokenExchangeConfig {
5805 TokenExchangeConfig::new(
5806 "https://idp.example.com/token".into(),
5807 "client".into(),
5808 client_secret.map(|s| secrecy::SecretString::new(s.into())),
5809 client_cert,
5810 "downstream".into(),
5811 )
5812 }
5813
5814 #[test]
5815 fn validate_rejects_token_exchange_without_client_auth() {
5816 let cfg = https_cfg_with_tx(tx_with(None, None));
5817 let err = cfg
5818 .validate()
5819 .expect_err("token_exchange without client auth must be rejected");
5820 let msg = err.to_string();
5821 assert!(
5822 msg.contains("requires client authentication"),
5823 "error must explain missing client auth; got {msg:?}"
5824 );
5825 }
5826
5827 #[test]
5828 fn validate_rejects_token_exchange_with_both_secret_and_cert() {
5829 let cc = ClientCertConfig {
5830 cert_path: PathBuf::from("/nonexistent/cert.pem"),
5831 key_path: PathBuf::from("/nonexistent/key.pem"),
5832 };
5833 let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
5834 let err = cfg
5835 .validate()
5836 .expect_err("client_secret + client_cert must be rejected");
5837 let msg = err.to_string();
5838 assert!(
5839 msg.contains("mutually") && msg.contains("exclusive"),
5840 "error must explain mutual exclusion; got {msg:?}"
5841 );
5842 }
5843
5844 #[cfg(not(feature = "oauth-mtls-client"))]
5845 #[test]
5846 fn validate_rejects_client_cert_without_feature() {
5847 let cc = ClientCertConfig {
5848 cert_path: PathBuf::from("/nonexistent/cert.pem"),
5849 key_path: PathBuf::from("/nonexistent/key.pem"),
5850 };
5851 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5852 let err = cfg
5853 .validate()
5854 .expect_err("client_cert without feature must be rejected");
5855 assert!(
5856 err.to_string().contains("oauth-mtls-client"),
5857 "error must reference the cargo feature; got {err}"
5858 );
5859 }
5860
5861 #[cfg(feature = "oauth-mtls-client")]
5862 #[test]
5863 fn validate_rejects_missing_client_cert_files() {
5864 let cc = ClientCertConfig {
5865 cert_path: PathBuf::from("/nonexistent/cert.pem"),
5866 key_path: PathBuf::from("/nonexistent/key.pem"),
5867 };
5868 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5869 let err = cfg
5870 .validate()
5871 .expect_err("missing cert file must be rejected");
5872 assert!(
5873 err.to_string().contains("unreadable"),
5874 "error must call out unreadable file; got {err}"
5875 );
5876 }
5877
5878 #[cfg(feature = "oauth-mtls-client")]
5879 #[test]
5880 fn validate_rejects_malformed_client_cert_pem() {
5881 let dir = std::env::temp_dir();
5882 let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
5883 let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
5884 std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
5885 std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
5886 let cc = ClientCertConfig {
5887 cert_path: cert.clone(),
5888 key_path: key.clone(),
5889 };
5890 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5891 let err = cfg.validate().expect_err("malformed PEM must be rejected");
5892 let _ = std::fs::remove_file(&cert);
5893 let _ = std::fs::remove_file(&key);
5894 assert!(
5895 err.to_string().contains("PEM parse failed"),
5896 "error must call out PEM parse failure; got {err}"
5897 );
5898 }
5899
5900 #[cfg(feature = "oauth-mtls-client")]
5901 fn write_self_signed_pem() -> (PathBuf, PathBuf) {
5902 let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
5903 let dir = std::env::temp_dir();
5904 let pid = std::process::id();
5905 let nonce: u64 = rand::random();
5906 let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
5907 let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
5908 std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
5909 std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
5910 (cert_path, key_path)
5911 }
5912
5913 #[cfg(feature = "oauth-mtls-client")]
5914 fn install_test_crypto_provider() {
5915 let _ = rustls::crypto::ring::default_provider().install_default();
5916 }
5917
5918 #[cfg(feature = "oauth-mtls-client")]
5919 #[test]
5920 fn validate_accepts_well_formed_client_cert() {
5921 install_test_crypto_provider();
5922 let (cert_path, key_path) = write_self_signed_pem();
5923 let cc = ClientCertConfig {
5924 cert_path: cert_path.clone(),
5925 key_path: key_path.clone(),
5926 };
5927 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5928 let res = cfg.validate();
5929 let _ = std::fs::remove_file(&cert_path);
5930 let _ = std::fs::remove_file(&key_path);
5931 res.expect("well-formed cert+key must validate");
5932 }
5933
5934 #[cfg(feature = "oauth-mtls-client")]
5935 #[test]
5936 fn client_for_returns_cached_mtls_client() {
5937 install_test_crypto_provider();
5938 let (cert_path, key_path) = write_self_signed_pem();
5939 let cc = ClientCertConfig {
5940 cert_path: cert_path.clone(),
5941 key_path: key_path.clone(),
5942 };
5943 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5944 let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
5945 let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
5946 let cert_client = http.client_for(tx_ref);
5947 let inner_client = http.client_for(&tx_with(Some("s"), None));
5948 let _ = std::fs::remove_file(&cert_path);
5949 let _ = std::fs::remove_file(&key_path);
5950 assert!(
5951 !std::ptr::eq(cert_client, inner_client),
5952 "client_for must return distinct clients for cert vs no-cert configs"
5953 );
5954 }
5955
5956 #[cfg(feature = "oauth-mtls-client")]
5957 #[test]
5958 fn client_for_falls_back_to_inner_when_cache_miss() {
5959 install_test_crypto_provider();
5960 let cfg = validation_https_config();
5961 let http = OauthHttpClient::with_config(&cfg).expect("build client");
5962 let unrelated_cc = ClientCertConfig {
5963 cert_path: PathBuf::from("/cache/miss/cert.pem"),
5964 key_path: PathBuf::from("/cache/miss/key.pem"),
5965 };
5966 let tx_unknown = tx_with(None, Some(unrelated_cc));
5967 let fallback = http.client_for(&tx_unknown);
5968 let inner = http.client_for(&tx_with(Some("s"), None));
5969 assert!(
5970 std::ptr::eq(fallback, inner),
5971 "cache miss must fall back to inner client"
5972 );
5973 }
5974}