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 #[serde(default)]
731 pub issuer: String,
732 #[serde(default)]
738 pub audience: String,
739 #[serde(default)]
744 pub jwks_uri: String,
745 #[serde(default)]
748 pub scopes: Vec<ScopeMapping>,
749 pub role_claim: Option<String>,
755 #[serde(default)]
758 pub role_mappings: Vec<RoleMapping>,
759 #[serde(default = "default_jwks_cache_ttl")]
762 pub jwks_cache_ttl: String,
763 pub proxy: Option<OAuthProxyConfig>,
767 pub token_exchange: Option<TokenExchangeConfig>,
772 #[serde(default)]
787 pub ca_cert_path: Option<PathBuf>,
788 #[serde(default)]
800 pub allow_http_oauth_urls: bool,
801 #[serde(default)]
810 pub ssrf_allowlist: Option<OAuthSsrfAllowlist>,
811 #[serde(default = "default_max_jwks_keys")]
815 pub max_jwks_keys: usize,
816 #[serde(default)]
821 pub require_subject: bool,
822 #[serde(default)]
831 #[deprecated(
832 since = "1.7.0",
833 note = "use `audience_validation_mode` instead; this field is consulted only when `audience_validation_mode` is None"
834 )]
835 pub strict_audience_validation: Option<bool>,
836 #[serde(default)]
845 pub audience_validation_mode: Option<AudienceValidationMode>,
846 #[serde(default = "default_jwks_max_bytes")]
850 pub jwks_max_response_bytes: u64,
851}
852
853fn default_jwks_cache_ttl() -> String {
854 "10m".into()
855}
856
857const fn default_max_jwks_keys() -> usize {
858 256
859}
860
861const fn default_jwks_max_bytes() -> u64 {
862 1024 * 1024
863}
864
865#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
882#[serde(rename_all = "snake_case")]
883#[non_exhaustive]
884pub enum AudienceValidationMode {
885 Permissive,
889 Warn,
892 #[default]
896 Strict,
897}
898
899impl AudienceValidationMode {
900 #[must_use]
905 pub(crate) const fn as_str(self) -> &'static str {
906 match self {
907 Self::Permissive => "permissive",
908 Self::Warn => "warn",
909 Self::Strict => "strict",
910 }
911 }
912}
913
914impl Default for OAuthConfig {
915 fn default() -> Self {
916 Self {
917 issuer: String::new(),
918 audience: String::new(),
919 jwks_uri: String::new(),
920 scopes: Vec::new(),
921 role_claim: None,
922 role_mappings: Vec::new(),
923 jwks_cache_ttl: default_jwks_cache_ttl(),
924 proxy: None,
925 token_exchange: None,
926 ca_cert_path: None,
927 allow_http_oauth_urls: false,
928 max_jwks_keys: default_max_jwks_keys(),
929 require_subject: false,
930 #[allow(
931 deprecated,
932 reason = "default-construct deprecated field for backward compat"
933 )]
934 strict_audience_validation: None,
935 audience_validation_mode: None,
936 jwks_max_response_bytes: default_jwks_max_bytes(),
937 ssrf_allowlist: None,
938 }
939 }
940}
941
942impl OAuthConfig {
943 #[must_use]
950 pub fn effective_audience_validation_mode(&self) -> AudienceValidationMode {
951 if let Some(mode) = self.audience_validation_mode {
952 return mode;
953 }
954 #[allow(deprecated, reason = "intentional: legacy flag resolution path")]
955 match self.strict_audience_validation {
956 Some(true) | None => AudienceValidationMode::Strict,
957 Some(false) => AudienceValidationMode::Warn,
958 }
959 }
960
961 pub fn builder(
967 issuer: impl Into<String>,
968 audience: impl Into<String>,
969 jwks_uri: impl Into<String>,
970 ) -> OAuthConfigBuilder {
971 OAuthConfigBuilder {
972 inner: Self {
973 issuer: issuer.into(),
974 audience: audience.into(),
975 jwks_uri: jwks_uri.into(),
976 ..Self::default()
977 },
978 }
979 }
980
981 pub fn validate(&self) -> Result<(), crate::error::McpxError> {
997 let allow_http = self.allow_http_oauth_urls;
998 let url = check_oauth_url("oauth.issuer", &self.issuer, allow_http)?;
999 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1000 return Err(crate::error::McpxError::Config(format!(
1001 "oauth.issuer forbidden ({reason})"
1002 )));
1003 }
1004 let url = check_oauth_url("oauth.jwks_uri", &self.jwks_uri, allow_http)?;
1005 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1006 return Err(crate::error::McpxError::Config(format!(
1007 "oauth.jwks_uri forbidden ({reason})"
1008 )));
1009 }
1010 if self.audience.is_empty() {
1015 return Err(crate::error::McpxError::Config(
1016 "oauth.audience must not be empty".into(),
1017 ));
1018 }
1019 if let Some(proxy) = &self.proxy {
1020 let url = check_oauth_url(
1021 "oauth.proxy.authorize_url",
1022 &proxy.authorize_url,
1023 allow_http,
1024 )?;
1025 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1026 return Err(crate::error::McpxError::Config(format!(
1027 "oauth.proxy.authorize_url forbidden ({reason})"
1028 )));
1029 }
1030 let url = check_oauth_url("oauth.proxy.token_url", &proxy.token_url, allow_http)?;
1031 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1032 return Err(crate::error::McpxError::Config(format!(
1033 "oauth.proxy.token_url forbidden ({reason})"
1034 )));
1035 }
1036 if let Some(url) = &proxy.introspection_url {
1037 let parsed = check_oauth_url("oauth.proxy.introspection_url", url, allow_http)?;
1038 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1039 return Err(crate::error::McpxError::Config(format!(
1040 "oauth.proxy.introspection_url forbidden ({reason})"
1041 )));
1042 }
1043 }
1044 if let Some(url) = &proxy.revocation_url {
1045 let parsed = check_oauth_url("oauth.proxy.revocation_url", url, allow_http)?;
1046 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1047 return Err(crate::error::McpxError::Config(format!(
1048 "oauth.proxy.revocation_url forbidden ({reason})"
1049 )));
1050 }
1051 }
1052 if proxy.expose_admin_endpoints
1059 && !proxy.require_auth_on_admin_endpoints
1060 && !proxy.allow_unauthenticated_admin_endpoints
1061 {
1062 return Err(crate::error::McpxError::Config(
1063 "oauth.proxy: expose_admin_endpoints = true requires \
1064 require_auth_on_admin_endpoints = true (recommended) \
1065 or allow_unauthenticated_admin_endpoints = true \
1066 (explicit opt-out, only safe behind an authenticated \
1067 reverse proxy)"
1068 .into(),
1069 ));
1070 }
1071 }
1072 if let Some(tx) = &self.token_exchange {
1073 let url = check_oauth_url("oauth.token_exchange.token_url", &tx.token_url, allow_http)?;
1074 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1075 return Err(crate::error::McpxError::Config(format!(
1076 "oauth.token_exchange.token_url forbidden ({reason})"
1077 )));
1078 }
1079 validate_token_exchange_client_auth(tx)?;
1082 }
1083 if let Some(raw) = &self.ssrf_allowlist {
1087 let compiled = compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1088 crate::error::McpxError::Config(format!("oauth.ssrf_allowlist: {e}"))
1089 })?;
1090 if !compiled.is_empty() {
1091 tracing::warn!(
1092 host_count = compiled.host_count(),
1093 cidr_count = compiled.cidr_count(),
1094 "oauth.ssrf_allowlist is configured: private/loopback OAuth/JWKS targets \
1095 are now reachable. Cloud-metadata addresses remain blocked. \
1096 See SECURITY.md \"Operator allowlist\"."
1097 );
1098 }
1099 }
1100 humantime::parse_duration(&self.jwks_cache_ttl).map_err(|e| {
1103 crate::error::McpxError::Config(format!(
1104 "oauth.jwks_cache_ttl {:?} is not a valid humantime duration (e.g. \"10m\", \"1h30m\"): {e}",
1105 self.jwks_cache_ttl
1106 ))
1107 })?;
1108 Ok(())
1109 }
1110}
1111
1112fn validate_token_exchange_client_auth(
1118 tx: &TokenExchangeConfig,
1119) -> Result<(), crate::error::McpxError> {
1120 match (&tx.client_cert, tx.client_secret.is_some()) {
1121 (Some(_), true) => Err(crate::error::McpxError::Config(
1122 "oauth.token_exchange: client_cert and client_secret are mutually \
1123 exclusive (RFC 8705 ยง2). Set exactly one."
1124 .into(),
1125 )),
1126 (None, false) => Err(crate::error::McpxError::Config(
1127 "oauth.token_exchange: token exchange requires client authentication. \
1128 Set either client_secret (RFC 6749 ยง2.3.1) or client_cert (RFC 8705 ยง2)."
1129 .into(),
1130 )),
1131 (Some(cc), false) => validate_client_cert_config(cc),
1132 (None, true) => Ok(()),
1133 }
1134}
1135
1136fn validate_client_cert_config(cc: &ClientCertConfig) -> Result<(), crate::error::McpxError> {
1149 #[cfg(not(feature = "oauth-mtls-client"))]
1150 {
1151 let _ = cc;
1152 Err(crate::error::McpxError::Config(
1153 "oauth.token_exchange.client_cert requires the `oauth-mtls-client` cargo feature; \
1154 rebuild rmcp-server-kit with --features oauth-mtls-client (or have your \
1155 application crate enable it via `rmcp-server-kit/oauth-mtls-client`), or remove \
1156 the field"
1157 .into(),
1158 ))
1159 }
1160 #[cfg(feature = "oauth-mtls-client")]
1161 {
1162 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1163 tracing::warn!(error = %e, path = %cc.cert_path.display(), "client cert read failed");
1164 crate::error::McpxError::Config(format!(
1165 "oauth.token_exchange.client_cert.cert_path unreadable: {}",
1166 cc.cert_path.display()
1167 ))
1168 })?;
1169 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1170 tracing::warn!(error = %e, path = %cc.key_path.display(), "client cert key read failed");
1171 crate::error::McpxError::Config(format!(
1172 "oauth.token_exchange.client_cert.key_path unreadable: {}",
1173 cc.key_path.display()
1174 ))
1175 })?;
1176 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1177 combined.extend_from_slice(&cert_bytes);
1178 if !cert_bytes.ends_with(b"\n") {
1179 combined.push(b'\n');
1180 }
1181 combined.extend_from_slice(&key_bytes);
1182 let _identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1183 tracing::warn!(
1184 error = %e,
1185 cert_path = %cc.cert_path.display(),
1186 key_path = %cc.key_path.display(),
1187 "client cert PEM parse failed"
1188 );
1189 crate::error::McpxError::Config(format!(
1190 "oauth.token_exchange.client_cert: PEM parse failed (cert={}, key={})",
1191 cc.cert_path.display(),
1192 cc.key_path.display()
1193 ))
1194 })?;
1195 Ok(())
1196 }
1197}
1198
1199#[cfg(feature = "oauth-mtls-client")]
1207fn build_mtls_clients(
1208 config: Option<&OAuthConfig>,
1209 allowlist: &Arc<crate::ssrf::CompiledSsrfAllowlist>,
1210 test_bypass: &crate::ssrf_resolver::TestLoopbackBypass,
1211) -> Result<Arc<HashMap<MtlsClientKey, reqwest::Client>>, crate::error::McpxError> {
1212 let mut map: HashMap<MtlsClientKey, reqwest::Client> = HashMap::new();
1213 let Some(cfg) = config else {
1214 return Ok(Arc::new(map));
1215 };
1216 let Some(tx) = &cfg.token_exchange else {
1217 return Ok(Arc::new(map));
1218 };
1219 let Some(cc) = &tx.client_cert else {
1220 return Ok(Arc::new(map));
1221 };
1222
1223 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1224 crate::error::McpxError::Startup(format!(
1225 "oauth http client mTLS: read cert_path {}: {e}",
1226 cc.cert_path.display()
1227 ))
1228 })?;
1229 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1230 crate::error::McpxError::Startup(format!(
1231 "oauth http client mTLS: read key_path {}: {e}",
1232 cc.key_path.display()
1233 ))
1234 })?;
1235 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1236 combined.extend_from_slice(&cert_bytes);
1237 if !cert_bytes.ends_with(b"\n") {
1238 combined.push(b'\n');
1239 }
1240 combined.extend_from_slice(&key_bytes);
1241 let identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1242 crate::error::McpxError::Startup(format!(
1243 "oauth http client mTLS: PEM parse (cert={}, key={}): {e}",
1244 cc.cert_path.display(),
1245 cc.key_path.display()
1246 ))
1247 })?;
1248
1249 let resolver: Arc<dyn reqwest::dns::Resolve> =
1250 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1251 Arc::clone(allowlist),
1252 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1257 test_bypass.clone(),
1258 ));
1259
1260 let mut builder = reqwest::Client::builder()
1261 .no_proxy()
1263 .dns_resolver(Arc::clone(&resolver))
1264 .connect_timeout(Duration::from_secs(10))
1265 .timeout(Duration::from_secs(30))
1266 .redirect(reqwest::redirect::Policy::none())
1267 .identity(identity);
1268
1269 if let Some(ref ca_path) = cfg.ca_cert_path {
1270 let pem = std::fs::read(ca_path).map_err(|e| {
1271 crate::error::McpxError::Startup(format!(
1272 "oauth http client mTLS: read ca_cert_path {}: {e}",
1273 ca_path.display()
1274 ))
1275 })?;
1276 let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
1277 crate::error::McpxError::Startup(format!(
1278 "oauth http client mTLS: parse ca_cert_path {}: {e}",
1279 ca_path.display()
1280 ))
1281 })?;
1282 builder = builder.add_root_certificate(cert);
1283 }
1284
1285 let client = builder.build().map_err(|e| {
1286 crate::error::McpxError::Startup(format!("oauth http client mTLS init: {e}"))
1287 })?;
1288 map.insert(
1289 MtlsClientKey {
1290 cert_path: cc.cert_path.clone(),
1291 key_path: cc.key_path.clone(),
1292 },
1293 client,
1294 );
1295 Ok(Arc::new(map))
1296}
1297
1298fn check_oauth_url(
1305 field: &str,
1306 raw: &str,
1307 allow_http: bool,
1308) -> Result<url::Url, crate::error::McpxError> {
1309 let parsed = url::Url::parse(raw).map_err(|e| {
1310 crate::error::McpxError::Config(format!("{field}: invalid URL {raw:?}: {e}"))
1311 })?;
1312 if !parsed.username().is_empty() || parsed.password().is_some() {
1313 return Err(crate::error::McpxError::Config(format!(
1314 "{field} rejected: URL contains userinfo (credentials in URL are forbidden)"
1315 )));
1316 }
1317 match parsed.scheme() {
1318 "https" => Ok(parsed),
1319 "http" if allow_http => Ok(parsed),
1320 "http" => Err(crate::error::McpxError::Config(format!(
1321 "{field}: must use https scheme (got http; set allow_http_oauth_urls=true \
1322 to override - strongly discouraged in production)"
1323 ))),
1324 other => Err(crate::error::McpxError::Config(format!(
1325 "{field}: must use https scheme (got {other:?})"
1326 ))),
1327 }
1328}
1329
1330#[derive(Debug, Clone)]
1336#[must_use = "builders do nothing until `.build()` is called"]
1337pub struct OAuthConfigBuilder {
1338 inner: OAuthConfig,
1339}
1340
1341impl OAuthConfigBuilder {
1342 pub fn scopes(mut self, scopes: Vec<ScopeMapping>) -> Self {
1344 self.inner.scopes = scopes;
1345 self
1346 }
1347
1348 pub fn scope(mut self, scope: impl Into<String>, role: impl Into<String>) -> Self {
1350 self.inner.scopes.push(ScopeMapping {
1351 scope: scope.into(),
1352 role: role.into(),
1353 });
1354 self
1355 }
1356
1357 pub fn role_claim(mut self, claim: impl Into<String>) -> Self {
1360 self.inner.role_claim = Some(claim.into());
1361 self
1362 }
1363
1364 pub fn role_mappings(mut self, mappings: Vec<RoleMapping>) -> Self {
1366 self.inner.role_mappings = mappings;
1367 self
1368 }
1369
1370 pub fn role_mapping(mut self, claim_value: impl Into<String>, role: impl Into<String>) -> Self {
1373 self.inner.role_mappings.push(RoleMapping {
1374 claim_value: claim_value.into(),
1375 role: role.into(),
1376 });
1377 self
1378 }
1379
1380 pub fn jwks_cache_ttl(mut self, ttl: impl Into<String>) -> Self {
1383 self.inner.jwks_cache_ttl = ttl.into();
1384 self
1385 }
1386
1387 pub fn proxy(mut self, proxy: OAuthProxyConfig) -> Self {
1390 self.inner.proxy = Some(proxy);
1391 self
1392 }
1393
1394 pub fn token_exchange(mut self, token_exchange: TokenExchangeConfig) -> Self {
1396 self.inner.token_exchange = Some(token_exchange);
1397 self
1398 }
1399
1400 pub fn ca_cert_path(mut self, path: impl Into<PathBuf>) -> Self {
1405 self.inner.ca_cert_path = Some(path.into());
1406 self
1407 }
1408
1409 pub const fn allow_http_oauth_urls(mut self, allow: bool) -> Self {
1415 self.inner.allow_http_oauth_urls = allow;
1416 self
1417 }
1418
1419 #[deprecated(since = "1.7.0", note = "use `audience_validation_mode` instead")]
1428 pub const fn strict_audience_validation(mut self, strict: bool) -> Self {
1429 #[allow(
1430 deprecated,
1431 reason = "intentional: deprecated builder forwards to deprecated field"
1432 )]
1433 {
1434 self.inner.strict_audience_validation = Some(strict);
1435 }
1436 self.inner.audience_validation_mode = None;
1437 self
1438 }
1439
1440 pub const fn audience_validation_mode(mut self, mode: AudienceValidationMode) -> Self {
1448 self.inner.audience_validation_mode = Some(mode);
1449 self
1450 }
1451
1452 pub const fn require_subject(mut self, require: bool) -> Self {
1458 self.inner.require_subject = require;
1459 self
1460 }
1461
1462 pub const fn jwks_max_response_bytes(mut self, bytes: u64) -> Self {
1464 self.inner.jwks_max_response_bytes = bytes;
1465 self
1466 }
1467
1468 pub fn ssrf_allowlist(mut self, allowlist: OAuthSsrfAllowlist) -> Self {
1476 self.inner.ssrf_allowlist = Some(allowlist);
1477 self
1478 }
1479
1480 #[must_use]
1482 pub fn build(self) -> OAuthConfig {
1483 self.inner
1484 }
1485}
1486
1487#[derive(Debug, Clone, Deserialize)]
1489#[non_exhaustive]
1490pub struct ScopeMapping {
1491 pub scope: String,
1493 pub role: String,
1495}
1496
1497#[derive(Debug, Clone, Deserialize)]
1501#[non_exhaustive]
1502pub struct RoleMapping {
1503 pub claim_value: String,
1505 pub role: String,
1507}
1508
1509#[derive(Debug, Clone, Deserialize)]
1516#[non_exhaustive]
1517pub struct TokenExchangeConfig {
1518 pub token_url: String,
1521 pub client_id: String,
1523 pub client_secret: Option<secrecy::SecretString>,
1528 pub client_cert: Option<ClientCertConfig>,
1541 pub audience: String,
1545}
1546
1547impl TokenExchangeConfig {
1548 #[must_use]
1550 pub fn new(
1551 token_url: String,
1552 client_id: String,
1553 client_secret: Option<secrecy::SecretString>,
1554 client_cert: Option<ClientCertConfig>,
1555 audience: String,
1556 ) -> Self {
1557 Self {
1558 token_url,
1559 client_id,
1560 client_secret,
1561 client_cert,
1562 audience,
1563 }
1564 }
1565}
1566
1567#[derive(Debug, Clone, Deserialize)]
1571#[non_exhaustive]
1572pub struct ClientCertConfig {
1573 pub cert_path: PathBuf,
1576 pub key_path: PathBuf,
1580}
1581
1582impl ClientCertConfig {
1583 #[must_use]
1587 pub fn new(cert_path: PathBuf, key_path: PathBuf) -> Self {
1588 Self {
1589 cert_path,
1590 key_path,
1591 }
1592 }
1593}
1594
1595#[derive(Debug, Deserialize)]
1597#[non_exhaustive]
1598pub struct ExchangedToken {
1599 pub access_token: String,
1601 pub expires_in: Option<u64>,
1603 pub issued_token_type: Option<String>,
1606}
1607
1608#[derive(Debug, Clone, Deserialize, Default)]
1615#[non_exhaustive]
1616pub struct OAuthProxyConfig {
1617 pub authorize_url: String,
1620 pub token_url: String,
1623 pub client_id: String,
1625 pub client_secret: Option<secrecy::SecretString>,
1627 #[serde(default)]
1631 pub introspection_url: Option<String>,
1632 #[serde(default)]
1636 pub revocation_url: Option<String>,
1637 #[serde(default)]
1649 pub expose_admin_endpoints: bool,
1650 #[serde(default)]
1656 pub require_auth_on_admin_endpoints: bool,
1657 #[serde(default)]
1668 pub allow_unauthenticated_admin_endpoints: bool,
1669}
1670
1671impl OAuthProxyConfig {
1672 pub fn builder(
1680 authorize_url: impl Into<String>,
1681 token_url: impl Into<String>,
1682 client_id: impl Into<String>,
1683 ) -> OAuthProxyConfigBuilder {
1684 OAuthProxyConfigBuilder {
1685 inner: Self {
1686 authorize_url: authorize_url.into(),
1687 token_url: token_url.into(),
1688 client_id: client_id.into(),
1689 ..Self::default()
1690 },
1691 }
1692 }
1693}
1694
1695#[derive(Debug, Clone)]
1701#[must_use = "builders do nothing until `.build()` is called"]
1702pub struct OAuthProxyConfigBuilder {
1703 inner: OAuthProxyConfig,
1704}
1705
1706impl OAuthProxyConfigBuilder {
1707 pub fn client_secret(mut self, secret: secrecy::SecretString) -> Self {
1709 self.inner.client_secret = Some(secret);
1710 self
1711 }
1712
1713 pub fn introspection_url(mut self, url: impl Into<String>) -> Self {
1717 self.inner.introspection_url = Some(url.into());
1718 self
1719 }
1720
1721 pub fn revocation_url(mut self, url: impl Into<String>) -> Self {
1725 self.inner.revocation_url = Some(url.into());
1726 self
1727 }
1728
1729 pub const fn expose_admin_endpoints(mut self, expose: bool) -> Self {
1737 self.inner.expose_admin_endpoints = expose;
1738 self
1739 }
1740
1741 pub const fn require_auth_on_admin_endpoints(mut self, require: bool) -> Self {
1744 self.inner.require_auth_on_admin_endpoints = require;
1745 self
1746 }
1747
1748 pub const fn allow_unauthenticated_admin_endpoints(mut self, allow: bool) -> Self {
1752 self.inner.allow_unauthenticated_admin_endpoints = allow;
1753 self
1754 }
1755
1756 #[must_use]
1758 pub fn build(self) -> OAuthProxyConfig {
1759 self.inner
1760 }
1761}
1762
1763type JwksKeyCache = (
1771 HashMap<String, (Algorithm, DecodingKey)>,
1772 Vec<(Algorithm, DecodingKey)>,
1773);
1774
1775struct CachedKeys {
1776 keys: HashMap<String, (Algorithm, DecodingKey)>,
1778 unnamed_keys: Vec<(Algorithm, DecodingKey)>,
1780 fetched_at: Instant,
1781 ttl: Duration,
1782}
1783
1784impl CachedKeys {
1785 fn is_expired(&self) -> bool {
1786 self.fetched_at.elapsed() >= self.ttl
1787 }
1788}
1789
1790#[allow(
1799 missing_debug_implementations,
1800 reason = "contains reqwest::Client and DecodingKey cache with no Debug impl"
1801)]
1802#[non_exhaustive]
1803pub struct JwksCache {
1804 jwks_uri: String,
1805 ttl: Duration,
1806 max_jwks_keys: usize,
1807 max_response_bytes: u64,
1808 allow_http: bool,
1809 inner: RwLock<Option<CachedKeys>>,
1810 http: reqwest::Client,
1811 validation_template: Validation,
1812 expected_audience: String,
1815 audience_mode: AudienceValidationMode,
1816 require_subject: bool,
1817 azp_fallback_warned: AtomicBool,
1821 scopes: Vec<ScopeMapping>,
1822 role_claim: Option<String>,
1823 role_mappings: Vec<RoleMapping>,
1824 last_refresh_attempt: RwLock<Option<Instant>>,
1827 refresh_lock: tokio::sync::Mutex<()>,
1829 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
1833 #[cfg(any(test, feature = "test-helpers"))]
1837 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
1838}
1839
1840const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(10);
1842
1843const OAUTH_PROXY_MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
1853
1854const ACCEPTED_ALGS: &[Algorithm] = &[
1856 Algorithm::RS256,
1857 Algorithm::RS384,
1858 Algorithm::RS512,
1859 Algorithm::ES256,
1860 Algorithm::ES384,
1861 Algorithm::PS256,
1862 Algorithm::PS384,
1863 Algorithm::PS512,
1864 Algorithm::EdDSA,
1865];
1866
1867#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1869#[non_exhaustive]
1870pub enum JwtValidationFailure {
1871 Expired,
1873 Invalid,
1875}
1876
1877impl JwksCache {
1878 pub fn new(config: &OAuthConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
1890 rustls::crypto::ring::default_provider()
1893 .install_default()
1894 .ok();
1895 jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
1896 .install_default()
1897 .ok();
1898
1899 let ttl = humantime::parse_duration(&config.jwks_cache_ttl).map_err(|error| {
1900 format!(
1901 "invalid jwks_cache_ttl {:?}: {error}",
1902 config.jwks_cache_ttl
1903 )
1904 })?;
1905
1906 let mut validation = Validation::new(Algorithm::RS256);
1907 validation.validate_aud = false;
1919 validation.set_issuer(&[&config.issuer]);
1920 validation.set_required_spec_claims(&["exp", "iss"]);
1921 validation.validate_exp = true;
1922 validation.validate_nbf = true;
1923
1924 let allow_http = config.allow_http_oauth_urls;
1925
1926 let allowlist = match config.ssrf_allowlist.as_ref() {
1929 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1930 Box::<dyn std::error::Error + Send + Sync>::from(format!(
1931 "oauth.ssrf_allowlist: {e}"
1932 ))
1933 })?),
1934 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
1935 };
1936 let redirect_allowlist = Arc::clone(&allowlist);
1937
1938 #[cfg(any(test, feature = "test-helpers"))]
1940 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
1941 Arc::new(AtomicBool::new(false));
1942 #[cfg(not(any(test, feature = "test-helpers")))]
1943 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
1944
1945 let resolver: Arc<dyn reqwest::dns::Resolve> =
1946 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1947 Arc::clone(&allowlist),
1948 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1949 test_bypass.clone(),
1950 ));
1951
1952 let mut http_builder = reqwest::Client::builder()
1953 .no_proxy()
1955 .dns_resolver(Arc::clone(&resolver))
1956 .timeout(Duration::from_secs(10))
1957 .connect_timeout(Duration::from_secs(3))
1958 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
1959 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
1969 Ok(()) => attempt.follow(),
1970 Err(reason) => {
1971 tracing::warn!(
1975 reason = %reason,
1976 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
1977 "oauth redirect rejected"
1978 );
1979 attempt.error(reason)
1980 }
1981 }
1982 }));
1983
1984 if let Some(ref ca_path) = config.ca_cert_path {
1985 let pem = std::fs::read(ca_path)?;
1991 let cert = reqwest::tls::Certificate::from_pem(&pem)?;
1992 http_builder = http_builder.add_root_certificate(cert);
1993 }
1994
1995 let http = http_builder.build()?;
1996
1997 Ok(Self {
1998 jwks_uri: config.jwks_uri.clone(),
1999 ttl,
2000 max_jwks_keys: config.max_jwks_keys,
2001 max_response_bytes: config.jwks_max_response_bytes,
2002 allow_http,
2003 inner: RwLock::new(None),
2004 http,
2005 validation_template: validation,
2006 expected_audience: config.audience.clone(),
2007 audience_mode: config.effective_audience_validation_mode(),
2008 require_subject: config.require_subject,
2009 azp_fallback_warned: AtomicBool::new(false),
2010 scopes: config.scopes.clone(),
2011 role_claim: config.role_claim.clone(),
2012 role_mappings: config.role_mappings.clone(),
2013 last_refresh_attempt: RwLock::new(None),
2014 refresh_lock: tokio::sync::Mutex::new(()),
2015 allowlist,
2016 #[cfg(any(test, feature = "test-helpers"))]
2017 test_allow_loopback_ssrf: test_bypass,
2018 })
2019 }
2020
2021 #[cfg(any(test, feature = "test-helpers"))]
2025 #[doc(hidden)]
2026 #[must_use]
2027 pub fn __test_allow_loopback_ssrf(self) -> Self {
2028 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
2031 self
2032 }
2033
2034 pub async fn validate_token(&self, token: &str) -> Option<AuthIdentity> {
2036 self.validate_token_with_reason(token).await.ok()
2037 }
2038
2039 pub async fn validate_token_with_reason(
2049 &self,
2050 token: &str,
2051 ) -> Result<AuthIdentity, JwtValidationFailure> {
2052 let claims = self.decode_claims(token).await?;
2053
2054 if self.require_subject && claims.sub.is_none() {
2055 core::hint::cold_path();
2056 tracing::debug!("JWT rejected: require_subject is set but the token has no `sub`");
2057 return Err(JwtValidationFailure::Invalid);
2058 }
2059 self.check_audience(&claims)?;
2060 let role = self.resolve_role(&claims)?;
2061
2062 let sub = claims.sub;
2065 let name = claims
2066 .extra
2067 .get("preferred_username")
2068 .and_then(|v| v.as_str())
2069 .map(String::from)
2070 .or_else(|| sub.clone())
2071 .or(claims.azp)
2072 .or(claims.client_id)
2073 .unwrap_or_else(|| "oauth-client".into());
2074
2075 Ok(AuthIdentity {
2076 name,
2077 role,
2078 method: AuthMethod::OAuthJwt,
2079 raw_token: None,
2080 sub,
2081 })
2082 }
2083
2084 async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2100 let (key, alg) = self.select_jwks_key(token).await?;
2101
2102 let mut validation = self.validation_template.clone();
2106 validation.algorithms = vec![alg];
2107
2108 let token_owned = token.to_owned();
2111 let join =
2112 tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2113 .await;
2114
2115 let decode_result = match join {
2116 Ok(r) => r,
2117 Err(join_err) => {
2118 core::hint::cold_path();
2119 tracing::error!(
2120 error = %join_err,
2121 "JWT decode task panicked or was cancelled"
2122 );
2123 return Err(JwtValidationFailure::Invalid);
2124 }
2125 };
2126
2127 decode_result.map(|td| td.claims).map_err(|e| {
2128 core::hint::cold_path();
2129 let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2130 JwtValidationFailure::Expired
2131 } else {
2132 JwtValidationFailure::Invalid
2133 };
2134 tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2135 failure
2136 })
2137 }
2138
2139 #[allow(
2148 clippy::cognitive_complexity,
2149 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"
2150 )]
2151 async fn select_jwks_key(
2152 &self,
2153 token: &str,
2154 ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2155 let Ok(header) = decode_header(token) else {
2156 core::hint::cold_path();
2157 tracing::debug!("JWT header decode failed");
2158 return Err(JwtValidationFailure::Invalid);
2159 };
2160 let kid = header.kid.as_deref();
2161 tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2162
2163 if !ACCEPTED_ALGS.contains(&header.alg) {
2164 core::hint::cold_path();
2165 tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2166 return Err(JwtValidationFailure::Invalid);
2167 }
2168
2169 let Some(key) = self.find_key(kid, header.alg).await else {
2170 core::hint::cold_path();
2171 tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2172 return Err(JwtValidationFailure::Invalid);
2173 };
2174
2175 Ok((key, header.alg))
2176 }
2177
2178 fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2187 if claims.aud.contains(&self.expected_audience) {
2188 return Ok(());
2189 }
2190 let azp_match = claims
2191 .azp
2192 .as_deref()
2193 .is_some_and(|azp| azp == self.expected_audience);
2194 if azp_match {
2195 match self.audience_mode {
2196 AudienceValidationMode::Permissive => return Ok(()),
2197 AudienceValidationMode::Warn => {
2198 if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2199 tracing::warn!(
2200 expected = %self.expected_audience,
2201 azp = claims.azp.as_deref().unwrap_or("-"),
2202 "JWT accepted via deprecated azp-only audience fallback. \
2203 Configure your IdP to populate aud, or set \
2204 audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2205 To silence this warning without changing acceptance, \
2206 set audience_validation_mode = \"permissive\". \
2207 This warning logs once per process."
2208 );
2209 }
2210 return Ok(());
2211 }
2212 AudienceValidationMode::Strict => {}
2213 }
2214 }
2215 core::hint::cold_path();
2216 tracing::debug!(
2217 aud = %claims.aud.log_display(),
2218 azp = claims.azp.as_deref().unwrap_or("-"),
2219 expected = %self.expected_audience,
2220 mode = self.audience_mode.as_str(),
2221 "JWT rejected: audience mismatch"
2222 );
2223 Err(JwtValidationFailure::Invalid)
2224 }
2225
2226 fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2232 if let Some(ref claim_path) = self.role_claim {
2233 let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2234 let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2235 values.extend(resolve_claim_path(&claims.extra, claim_path));
2236 return self
2237 .role_mappings
2238 .iter()
2239 .find(|m| values.contains(&m.claim_value.as_str()))
2240 .map(|m| m.role.clone())
2241 .ok_or(JwtValidationFailure::Invalid);
2242 }
2243
2244 let token_scopes: Vec<&str> = claims
2245 .scope
2246 .as_deref()
2247 .unwrap_or("")
2248 .split_whitespace()
2249 .collect();
2250
2251 self.scopes
2252 .iter()
2253 .find(|m| token_scopes.contains(&m.scope.as_str()))
2254 .map(|m| m.role.clone())
2255 .ok_or(JwtValidationFailure::Invalid)
2256 }
2257
2258 async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2264 {
2266 let guard = self.inner.read().await;
2267 if let Some(cached) = guard.as_ref()
2268 && !cached.is_expired()
2269 && let Some(key) = lookup_key(cached, kid, alg)
2270 {
2271 return Some(key);
2272 }
2273 }
2274
2275 self.refresh_with_cooldown().await;
2277
2278 let guard = self.inner.read().await;
2284 guard
2285 .as_ref()
2286 .filter(|cached| !cached.is_expired())
2287 .and_then(|cached| lookup_key(cached, kid, alg))
2288 }
2289
2290 async fn refresh_with_cooldown(&self) {
2310 let _guard = self.refresh_lock.lock().await;
2312
2313 {
2315 let last = self.last_refresh_attempt.read().await;
2316 if let Some(ts) = *last
2317 && ts.elapsed() < JWKS_REFRESH_COOLDOWN
2318 {
2319 tracing::debug!(
2320 elapsed_ms = ts.elapsed().as_millis(),
2321 cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
2322 "JWKS refresh skipped (cooldown active)"
2323 );
2324 return;
2325 }
2326 }
2327
2328 {
2331 let mut last = self.last_refresh_attempt.write().await;
2332 *last = Some(Instant::now());
2333 }
2334
2335 let _ = self.refresh_inner().await;
2337 }
2338
2339 async fn refresh_inner(&self) -> Result<(), String> {
2348 let Some(jwks) = self.fetch_jwks().await else {
2349 return Ok(());
2350 };
2351 let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
2352 Ok(cache) => cache,
2353 Err(msg) => {
2354 tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
2355 return Err(msg);
2356 }
2357 };
2358
2359 tracing::debug!(
2360 named = keys.len(),
2361 unnamed = unnamed_keys.len(),
2362 "JWKS refreshed"
2363 );
2364
2365 let mut guard = self.inner.write().await;
2366 *guard = Some(CachedKeys {
2367 keys,
2368 unnamed_keys,
2369 fetched_at: Instant::now(),
2370 ttl: self.ttl,
2371 });
2372 drop(guard);
2373 Ok(())
2374 }
2375
2376 #[allow(
2378 clippy::cognitive_complexity,
2379 reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
2380 )]
2381 async fn fetch_jwks(&self) -> Option<JwkSet> {
2382 #[cfg(any(test, feature = "test-helpers"))]
2383 let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
2384 screen_oauth_target_with_test_override(
2385 &self.jwks_uri,
2386 self.allow_http,
2387 &self.allowlist,
2388 true,
2389 )
2390 .await
2391 } else {
2392 screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
2393 };
2394 #[cfg(not(any(test, feature = "test-helpers")))]
2395 let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
2396
2397 if let Err(error) = screening {
2398 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to screen JWKS target");
2399 return None;
2400 }
2401
2402 let mut resp = match self.http.get(&self.jwks_uri).send().await {
2403 Ok(resp) => resp,
2404 Err(e) => {
2405 tracing::warn!(error = %e, uri = %self.jwks_uri, "failed to fetch JWKS");
2406 return None;
2407 }
2408 };
2409
2410 let initial_capacity =
2411 usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
2412 let mut body = Vec::with_capacity(initial_capacity);
2413 while let Some(chunk) = match resp.chunk().await {
2414 Ok(chunk) => chunk,
2415 Err(error) => {
2416 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to read JWKS response");
2417 return None;
2418 }
2419 } {
2420 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
2421 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
2422 if body_len.saturating_add(chunk_len) > self.max_response_bytes {
2423 tracing::warn!(
2424 uri = %self.jwks_uri,
2425 max_bytes = self.max_response_bytes,
2426 "JWKS response exceeded configured size cap"
2427 );
2428 return None;
2429 }
2430 body.extend_from_slice(&chunk);
2431 }
2432
2433 match serde_json::from_slice::<JwkSet>(&body) {
2434 Ok(jwks) => Some(jwks),
2435 Err(error) => {
2436 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to parse JWKS");
2437 None
2438 }
2439 }
2440 }
2441
2442 #[cfg(any(test, feature = "test-helpers"))]
2445 #[doc(hidden)]
2446 pub async fn __test_refresh_now(&self) -> Result<(), String> {
2447 let jwks = self
2448 .fetch_jwks()
2449 .await
2450 .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
2451 let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
2452 let mut guard = self.inner.write().await;
2453 *guard = Some(CachedKeys {
2454 keys,
2455 unnamed_keys,
2456 fetched_at: Instant::now(),
2457 ttl: self.ttl,
2458 });
2459 drop(guard);
2460 Ok(())
2461 }
2462
2463 #[cfg(any(test, feature = "test-helpers"))]
2466 #[doc(hidden)]
2467 pub async fn __test_has_kid(&self, kid: &str) -> bool {
2468 let guard = self.inner.read().await;
2469 guard
2470 .as_ref()
2471 .is_some_and(|cache| cache.keys.contains_key(kid))
2472 }
2473}
2474
2475fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
2477 if jwks.keys.len() > max_keys {
2478 return Err(format!(
2479 "jwks_key_count_exceeds_cap: got {} keys, max is {}",
2480 jwks.keys.len(),
2481 max_keys
2482 ));
2483 }
2484 let mut keys = HashMap::new();
2485 let mut unnamed_keys = Vec::new();
2486 for jwk in &jwks.keys {
2487 let Ok(decoding_key) = DecodingKey::from_jwk(jwk) else {
2488 continue;
2489 };
2490 let Some(alg) = jwk_algorithm(jwk) else {
2491 continue;
2492 };
2493 if let Some(ref kid) = jwk.common.key_id {
2494 keys.insert(kid.clone(), (alg, decoding_key));
2495 } else {
2496 unnamed_keys.push((alg, decoding_key));
2497 }
2498 }
2499 Ok((keys, unnamed_keys))
2500}
2501
2502fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2504 if let Some(kid) = kid {
2505 if let Some((cached_alg, key)) = cached.keys.get(kid)
2510 && *cached_alg == alg
2511 {
2512 return Some(key.clone());
2513 }
2514 return None;
2515 }
2516 cached
2518 .unnamed_keys
2519 .iter()
2520 .find(|(a, _)| *a == alg)
2521 .map(|(_, k)| k.clone())
2522}
2523
2524#[allow(
2526 clippy::wildcard_enum_match_arm,
2527 reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
2528)]
2529fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<Algorithm> {
2530 jwk.common.key_algorithm.and_then(|ka| match ka {
2531 jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
2532 jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
2533 jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
2534 jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
2535 jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
2536 jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
2537 jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
2538 jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
2539 jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
2540 _ => None,
2541 })
2542}
2543
2544fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
2565 match path {
2566 "sub" => claims.sub.iter().cloned().collect(),
2567 "azp" => claims.azp.iter().cloned().collect(),
2568 "client_id" => claims.client_id.iter().cloned().collect(),
2569 "aud" => claims.aud.0.clone(),
2570 "scope" => claims
2571 .scope
2572 .as_deref()
2573 .unwrap_or("")
2574 .split_whitespace()
2575 .map(str::to_owned)
2576 .collect(),
2577 _ => Vec::new(),
2578 }
2579}
2580
2581fn resolve_claim_path<'a>(
2591 extra: &'a HashMap<String, serde_json::Value>,
2592 path: &str,
2593) -> Vec<&'a str> {
2594 let mut segments = path.split('.');
2595 let Some(first) = segments.next() else {
2596 return Vec::new();
2597 };
2598
2599 let mut current: Option<&serde_json::Value> = extra.get(first);
2600
2601 for segment in segments {
2602 current = current.and_then(|v| v.get(segment));
2603 }
2604
2605 match current {
2606 Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
2607 Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
2608 _ => Vec::new(),
2609 }
2610}
2611
2612#[derive(Debug, Deserialize)]
2618struct Claims {
2619 sub: Option<String>,
2621 #[serde(default)]
2624 aud: OneOrMany,
2625 azp: Option<String>,
2627 client_id: Option<String>,
2629 scope: Option<String>,
2631 #[serde(flatten)]
2633 extra: HashMap<String, serde_json::Value>,
2634}
2635
2636#[derive(Debug, Default)]
2638struct OneOrMany(Vec<String>);
2639
2640impl OneOrMany {
2641 fn contains(&self, value: &str) -> bool {
2642 self.0.iter().any(|v| v == value)
2643 }
2644
2645 fn log_display(&self) -> String {
2649 if self.0.is_empty() {
2650 "-".to_owned()
2651 } else {
2652 self.0.join(", ")
2653 }
2654 }
2655}
2656
2657fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
2667 match value {
2668 Some(serde_json::Value::String(s)) => s.clone(),
2669 Some(serde_json::Value::Array(items)) => {
2670 let joined = items
2671 .iter()
2672 .filter_map(serde_json::Value::as_str)
2673 .collect::<Vec<_>>()
2674 .join(", ");
2675 if joined.is_empty() {
2676 "-".to_owned()
2677 } else {
2678 joined
2679 }
2680 }
2681 Some(
2682 serde_json::Value::Null
2683 | serde_json::Value::Bool(_)
2684 | serde_json::Value::Number(_)
2685 | serde_json::Value::Object(_),
2686 )
2687 | None => "-".to_owned(),
2688 }
2689}
2690
2691fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
2695 value.and_then(serde_json::Value::as_str).unwrap_or("-")
2696}
2697
2698impl<'de> Deserialize<'de> for OneOrMany {
2699 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2700 use serde::de;
2701
2702 struct Visitor;
2703 impl<'de> de::Visitor<'de> for Visitor {
2704 type Value = OneOrMany;
2705 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2706 f.write_str("a string or array of strings")
2707 }
2708 fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
2709 Ok(OneOrMany(vec![v.to_owned()]))
2710 }
2711 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
2712 let mut v = Vec::new();
2713 while let Some(s) = seq.next_element::<String>()? {
2714 v.push(s);
2715 }
2716 Ok(OneOrMany(v))
2717 }
2718 }
2719 deserializer.deserialize_any(Visitor)
2720 }
2721}
2722
2723#[must_use]
2730pub fn looks_like_jwt(token: &str) -> bool {
2731 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2732
2733 let mut parts = token.splitn(4, '.');
2734 let Some(header_b64) = parts.next() else {
2735 return false;
2736 };
2737 if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
2739 return false;
2740 }
2741 let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
2743 return false;
2744 };
2745 let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
2747 return false;
2748 };
2749 header.get("alg").is_some()
2750}
2751
2752#[must_use]
2762pub fn protected_resource_metadata(
2763 resource_url: &str,
2764 server_url: &str,
2765 config: &OAuthConfig,
2766) -> serde_json::Value {
2767 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2772 let auth_server = server_url;
2773 serde_json::json!({
2774 "resource": resource_url,
2775 "authorization_servers": [auth_server],
2776 "scopes_supported": scopes,
2777 "bearer_methods_supported": ["header"]
2778 })
2779}
2780
2781#[must_use]
2786pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
2787 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2788 let mut meta = serde_json::json!({
2789 "issuer": &config.issuer,
2790 "authorization_endpoint": format!("{server_url}/authorize"),
2791 "token_endpoint": format!("{server_url}/token"),
2792 "registration_endpoint": format!("{server_url}/register"),
2793 "response_types_supported": ["code"],
2794 "grant_types_supported": ["authorization_code", "refresh_token"],
2795 "code_challenge_methods_supported": ["S256"],
2796 "scopes_supported": scopes,
2797 "token_endpoint_auth_methods_supported": ["none"],
2798 });
2799 if let Some(proxy) = &config.proxy
2800 && proxy.expose_admin_endpoints
2801 && let Some(obj) = meta.as_object_mut()
2802 {
2803 if proxy.introspection_url.is_some() {
2804 obj.insert(
2805 "introspection_endpoint".into(),
2806 serde_json::Value::String(format!("{server_url}/introspect")),
2807 );
2808 }
2809 if proxy.revocation_url.is_some() {
2810 obj.insert(
2811 "revocation_endpoint".into(),
2812 serde_json::Value::String(format!("{server_url}/revoke")),
2813 );
2814 }
2815 if proxy.require_auth_on_admin_endpoints {
2816 obj.insert(
2817 "introspection_endpoint_auth_methods_supported".into(),
2818 serde_json::json!(["bearer"]),
2819 );
2820 obj.insert(
2821 "revocation_endpoint_auth_methods_supported".into(),
2822 serde_json::json!(["bearer"]),
2823 );
2824 }
2825 }
2826 meta
2827}
2828
2829#[must_use]
2842pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
2843 use axum::{
2844 http::{StatusCode, header},
2845 response::IntoResponse,
2846 };
2847
2848 let upstream_query = replace_client_id(query, &proxy.client_id);
2850 let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
2851
2852 (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
2853}
2854
2855pub async fn handle_token(
2861 http: &OauthHttpClient,
2862 proxy: &OAuthProxyConfig,
2863 body: &str,
2864) -> axum::response::Response {
2865 use axum::{
2866 http::{StatusCode, header},
2867 response::IntoResponse,
2868 };
2869
2870 let mut upstream_body = replace_client_id(body, &proxy.client_id);
2872
2873 if let Some(ref secret) = proxy.client_secret {
2875 use std::fmt::Write;
2876
2877 use secrecy::ExposeSecret;
2878 let _ = write!(
2879 upstream_body,
2880 "&client_secret={}",
2881 urlencoding::encode(secret.expose_secret())
2882 );
2883 }
2884
2885 let result = http
2886 .send_screened(
2887 &proxy.token_url,
2888 http.credential_client
2889 .post(&proxy.token_url)
2890 .header("Content-Type", "application/x-www-form-urlencoded")
2891 .body(upstream_body),
2892 )
2893 .await;
2894
2895 match result {
2896 Ok(resp) => {
2897 let status =
2898 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
2899 let Ok(body_bytes) =
2900 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
2901 else {
2902 return oauth_error_response(
2903 StatusCode::BAD_GATEWAY,
2904 "server_error",
2905 "upstream response too large or unreadable",
2906 );
2907 };
2908 (
2909 status,
2910 [(header::CONTENT_TYPE, "application/json")],
2911 body_bytes,
2912 )
2913 .into_response()
2914 }
2915 Err(e) => {
2916 tracing::error!(error = %e, "OAuth token proxy request failed");
2917 (
2918 StatusCode::BAD_GATEWAY,
2919 [(header::CONTENT_TYPE, "application/json")],
2920 "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
2921 )
2922 .into_response()
2923 }
2924 }
2925}
2926
2927#[must_use]
2934pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
2935 let mut resp = serde_json::json!({
2936 "client_id": proxy.client_id,
2937 "token_endpoint_auth_method": "none",
2938 });
2939 if let Some(uris) = body.get("redirect_uris")
2940 && let Some(obj) = resp.as_object_mut()
2941 {
2942 obj.insert("redirect_uris".into(), uris.clone());
2943 }
2944 if let Some(name) = body.get("client_name")
2945 && let Some(obj) = resp.as_object_mut()
2946 {
2947 obj.insert("client_name".into(), name.clone());
2948 }
2949 resp
2950}
2951
2952pub async fn handle_introspect(
2958 http: &OauthHttpClient,
2959 proxy: &OAuthProxyConfig,
2960 body: &str,
2961) -> axum::response::Response {
2962 let Some(ref url) = proxy.introspection_url else {
2963 return oauth_error_response(
2964 axum::http::StatusCode::NOT_FOUND,
2965 "not_supported",
2966 "introspection endpoint is not configured",
2967 );
2968 };
2969 proxy_oauth_admin_request(http, proxy, url, body).await
2970}
2971
2972pub async fn handle_revoke(
2979 http: &OauthHttpClient,
2980 proxy: &OAuthProxyConfig,
2981 body: &str,
2982) -> axum::response::Response {
2983 let Some(ref url) = proxy.revocation_url else {
2984 return oauth_error_response(
2985 axum::http::StatusCode::NOT_FOUND,
2986 "not_supported",
2987 "revocation endpoint is not configured",
2988 );
2989 };
2990 proxy_oauth_admin_request(http, proxy, url, body).await
2991}
2992
2993async fn proxy_oauth_admin_request(
2997 http: &OauthHttpClient,
2998 proxy: &OAuthProxyConfig,
2999 upstream_url: &str,
3000 body: &str,
3001) -> axum::response::Response {
3002 use axum::{
3003 http::{StatusCode, header},
3004 response::IntoResponse,
3005 };
3006
3007 let mut upstream_body = replace_client_id(body, &proxy.client_id);
3008 if let Some(ref secret) = proxy.client_secret {
3009 use std::fmt::Write;
3010
3011 use secrecy::ExposeSecret;
3012 let _ = write!(
3013 upstream_body,
3014 "&client_secret={}",
3015 urlencoding::encode(secret.expose_secret())
3016 );
3017 }
3018
3019 let result = http
3020 .send_screened(
3021 upstream_url,
3022 http.credential_client
3023 .post(upstream_url)
3024 .header("Content-Type", "application/x-www-form-urlencoded")
3025 .body(upstream_body),
3026 )
3027 .await;
3028
3029 match result {
3030 Ok(resp) => {
3031 let status =
3032 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3033 let content_type = resp
3034 .headers()
3035 .get(header::CONTENT_TYPE)
3036 .and_then(|v| v.to_str().ok())
3037 .unwrap_or("application/json")
3038 .to_owned();
3039 let Ok(body_bytes) =
3040 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
3041 else {
3042 return oauth_error_response(
3043 StatusCode::BAD_GATEWAY,
3044 "server_error",
3045 "upstream response too large or unreadable",
3046 );
3047 };
3048 (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
3049 }
3050 Err(e) => {
3051 tracing::error!(error = %e, url = %upstream_url, "OAuth admin proxy request failed");
3052 oauth_error_response(
3053 StatusCode::BAD_GATEWAY,
3054 "server_error",
3055 "upstream endpoint unreachable",
3056 )
3057 }
3058 }
3059}
3060
3061async fn read_response_capped(
3071 mut resp: reqwest::Response,
3072 max_bytes: u64,
3073 context: &str,
3074) -> Result<Vec<u8>, ()> {
3075 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3076 let mut body = Vec::with_capacity(initial_capacity);
3077 loop {
3078 match resp.chunk().await {
3079 Ok(Some(chunk)) => {
3080 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3081 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3082 if body_len.saturating_add(chunk_len) > max_bytes {
3083 tracing::warn!(
3084 context = context,
3085 max_bytes = max_bytes,
3086 "upstream OAuth response exceeded size cap; failing closed"
3087 );
3088 return Err(());
3089 }
3090 body.extend_from_slice(&chunk);
3091 }
3092 Ok(None) => return Ok(body),
3093 Err(error) => {
3094 tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
3095 return Err(());
3096 }
3097 }
3098 }
3099}
3100
3101fn oauth_error_response(
3102 status: axum::http::StatusCode,
3103 error: &str,
3104 description: &str,
3105) -> axum::response::Response {
3106 use axum::{http::header, response::IntoResponse};
3107 let body = serde_json::json!({
3108 "error": error,
3109 "error_description": description,
3110 });
3111 (
3112 status,
3113 [(header::CONTENT_TYPE, "application/json")],
3114 body.to_string(),
3115 )
3116 .into_response()
3117}
3118
3119#[derive(Debug, Deserialize)]
3125struct OAuthErrorResponse {
3126 error: String,
3127 error_description: Option<String>,
3128}
3129
3130fn sanitize_oauth_error_code(raw: &str) -> &'static str {
3137 match raw {
3138 "invalid_request" => "invalid_request",
3139 "invalid_client" => "invalid_client",
3140 "invalid_grant" => "invalid_grant",
3141 "unauthorized_client" => "unauthorized_client",
3142 "unsupported_grant_type" => "unsupported_grant_type",
3143 "invalid_scope" => "invalid_scope",
3144 "temporarily_unavailable" => "temporarily_unavailable",
3145 "invalid_target" => "invalid_target",
3147 _ => "server_error",
3150 }
3151}
3152
3153pub async fn exchange_token(
3165 http: &OauthHttpClient,
3166 config: &TokenExchangeConfig,
3167 subject_token: &str,
3168) -> Result<ExchangedToken, crate::error::McpxError> {
3169 use secrecy::ExposeSecret;
3170
3171 let client = http.client_for(config);
3172 let mut req = client
3173 .post(&config.token_url)
3174 .header("Content-Type", "application/x-www-form-urlencoded")
3175 .header("Accept", "application/json");
3176
3177 if config.client_cert.is_none()
3186 && let Some(ref secret) = config.client_secret
3187 {
3188 use base64::Engine;
3189 let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
3190 "{}:{}",
3191 urlencoding::encode(&config.client_id),
3192 urlencoding::encode(secret.expose_secret()),
3193 ));
3194 req = req.header("Authorization", format!("Basic {credentials}"));
3195 }
3196
3197 let form_body = build_exchange_form(config, subject_token);
3198
3199 let resp = http
3200 .send_screened(&config.token_url, req.body(form_body))
3201 .await
3202 .map_err(|e| {
3203 tracing::error!(error = %e, "token exchange request failed");
3204 crate::error::McpxError::Auth("server_error".into())
3206 })?;
3207
3208 let status = resp.status();
3209 let body_bytes =
3210 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
3211 .await
3212 .map_err(|()| {
3213 crate::error::McpxError::Auth("server_error".into())
3215 })?;
3216
3217 if !status.is_success() {
3218 core::hint::cold_path();
3219 let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
3222 let short_code = parsed
3223 .as_ref()
3224 .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
3225 if let Some(ref e) = parsed {
3226 tracing::warn!(
3227 status = %status,
3228 upstream_error = %e.error,
3229 upstream_error_description = e.error_description.as_deref().unwrap_or(""),
3230 client_code = %short_code,
3231 "token exchange rejected by authorization server",
3232 );
3233 } else {
3234 tracing::warn!(
3235 status = %status,
3236 client_code = %short_code,
3237 "token exchange rejected (unparseable upstream body)",
3238 );
3239 }
3240 return Err(crate::error::McpxError::Auth(short_code.into()));
3241 }
3242
3243 let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
3244 tracing::error!(error = %e, "failed to parse token exchange response");
3245 crate::error::McpxError::Auth("server_error".into())
3248 })?;
3249
3250 log_exchanged_token(&exchanged);
3251
3252 Ok(exchanged)
3253}
3254
3255fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
3258 let body = format!(
3259 "grant_type={}&subject_token={}&subject_token_type={}&requested_token_type={}&audience={}",
3260 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
3261 urlencoding::encode(subject_token),
3262 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3263 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3264 urlencoding::encode(&config.audience),
3265 );
3266 if config.client_secret.is_none() {
3267 format!(
3268 "{body}&client_id={}",
3269 urlencoding::encode(&config.client_id)
3270 )
3271 } else {
3272 body
3273 }
3274}
3275
3276fn log_exchanged_token(exchanged: &ExchangedToken) {
3279 use base64::Engine;
3280
3281 if !looks_like_jwt(&exchanged.access_token) {
3282 tracing::debug!(
3283 token_len = exchanged.access_token.len(),
3284 issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
3285 expires_in = exchanged.expires_in,
3286 "exchanged token (opaque)",
3287 );
3288 return;
3289 }
3290 let Some(payload) = exchanged.access_token.split('.').nth(1) else {
3291 return;
3292 };
3293 let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
3294 return;
3295 };
3296 let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
3297 return;
3298 };
3299 tracing::debug!(
3300 sub = fmt_json_str(claims.get("sub")),
3301 aud = %fmt_json_aud(claims.get("aud")),
3302 azp = fmt_json_str(claims.get("azp")),
3303 iss = fmt_json_str(claims.get("iss")),
3304 expires_in = exchanged.expires_in,
3305 "exchanged token claims (JWT)",
3306 );
3307}
3308
3309fn replace_client_id(params: &str, upstream_client_id: &str) -> String {
3311 let encoded_id = urlencoding::encode(upstream_client_id);
3312 let mut parts: Vec<String> = params
3313 .split('&')
3314 .filter(|p| !p.starts_with("client_id="))
3315 .map(String::from)
3316 .collect();
3317 parts.push(format!("client_id={encoded_id}"));
3318 parts.join("&")
3319}
3320
3321#[cfg(test)]
3322mod tests {
3323 use std::sync::Arc;
3324
3325 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3326
3327 use super::*;
3328
3329 #[test]
3330 fn looks_like_jwt_valid() {
3331 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
3333 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3334 let token = format!("{header}.{payload}.signature");
3335 assert!(looks_like_jwt(&token));
3336 }
3337
3338 #[test]
3339 fn looks_like_jwt_rejects_opaque_token() {
3340 assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
3341 }
3342
3343 #[test]
3344 fn looks_like_jwt_rejects_two_segments() {
3345 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
3346 let token = format!("{header}.payload");
3347 assert!(!looks_like_jwt(&token));
3348 }
3349
3350 #[test]
3351 fn looks_like_jwt_rejects_four_segments() {
3352 assert!(!looks_like_jwt("a.b.c.d"));
3353 }
3354
3355 #[test]
3356 fn looks_like_jwt_rejects_no_alg() {
3357 let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
3358 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3359 let token = format!("{header}.{payload}.sig");
3360 assert!(!looks_like_jwt(&token));
3361 }
3362
3363 #[test]
3364 fn protected_resource_metadata_shape() {
3365 let config = OAuthConfig {
3366 require_subject: false,
3367 issuer: "https://auth.example.com".into(),
3368 audience: "https://mcp.example.com/mcp".into(),
3369 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
3370 scopes: vec![
3371 ScopeMapping {
3372 scope: "mcp:read".into(),
3373 role: "viewer".into(),
3374 },
3375 ScopeMapping {
3376 scope: "mcp:admin".into(),
3377 role: "ops".into(),
3378 },
3379 ],
3380 role_claim: None,
3381 role_mappings: vec![],
3382 jwks_cache_ttl: "10m".into(),
3383 proxy: None,
3384 token_exchange: None,
3385 ca_cert_path: None,
3386 allow_http_oauth_urls: false,
3387 max_jwks_keys: default_max_jwks_keys(),
3388 #[allow(
3389 deprecated,
3390 reason = "test fixture: explicit value for the deprecated field"
3391 )]
3392 strict_audience_validation: None,
3393 audience_validation_mode: None,
3394 jwks_max_response_bytes: default_jwks_max_bytes(),
3395 ssrf_allowlist: None,
3396 };
3397 let meta = protected_resource_metadata(
3398 "https://mcp.example.com/mcp",
3399 "https://mcp.example.com",
3400 &config,
3401 );
3402 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
3403 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
3404 assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
3405 assert_eq!(meta["bearer_methods_supported"][0], "header");
3406 }
3407
3408 fn validation_https_config() -> OAuthConfig {
3413 OAuthConfig::builder(
3414 "https://auth.example.com",
3415 "mcp",
3416 "https://auth.example.com/.well-known/jwks.json",
3417 )
3418 .build()
3419 }
3420
3421 #[test]
3422 fn validate_accepts_all_https_urls() {
3423 let cfg = validation_https_config();
3424 cfg.validate().expect("all-HTTPS config must validate");
3425 }
3426
3427 #[test]
3428 fn validate_rejects_empty_audience() {
3429 let mut cfg = validation_https_config();
3430 cfg.audience = String::new();
3431 let err = cfg.validate().expect_err("empty audience must be rejected");
3432 assert!(
3433 err.to_string().contains("oauth.audience"),
3434 "error must reference oauth.audience; got {err}"
3435 );
3436 }
3437
3438 #[test]
3439 fn oauth_config_partial_table_deserializes_then_validate_rejects_empty_fields() {
3440 let toml_src = r#"
3441role_claim = "realm_access.roles"
3442
3443[[role_mappings]]
3444claim_value = "mcp-admin"
3445role = "admin"
3446"#;
3447 let cfg: OAuthConfig = toml::from_str(toml_src).expect(
3448 "partial [oauth] table without issuer/audience/jwks_uri must deserialize via serde(default)",
3449 );
3450 assert_eq!(cfg.issuer, "", "omitted issuer must default to empty");
3451 assert_eq!(cfg.audience, "", "omitted audience must default to empty");
3452 assert_eq!(cfg.jwks_uri, "", "omitted jwks_uri must default to empty");
3453 assert_eq!(cfg.role_claim.as_deref(), Some("realm_access.roles"));
3454 assert_eq!(cfg.role_mappings.len(), 1);
3455 cfg.validate().expect_err(
3456 "empty issuer/jwks_uri/audience must still fail validate() (parse-don't-validate)",
3457 );
3458 }
3459
3460 #[test]
3461 fn validate_rejects_unparseable_jwks_cache_ttl() {
3462 let mut cfg = validation_https_config();
3463 cfg.jwks_cache_ttl = "not-a-duration".into();
3464 let err = cfg
3465 .validate()
3466 .expect_err("malformed jwks_cache_ttl must be rejected");
3467 let msg = err.to_string();
3468 assert!(
3469 msg.contains("jwks_cache_ttl"),
3470 "error must reference offending field; got {msg:?}"
3471 );
3472 }
3473
3474 #[test]
3475 fn validate_rejects_http_jwks_uri() {
3476 let mut cfg = validation_https_config();
3477 cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
3478 let err = cfg.validate().expect_err("http jwks_uri must be rejected");
3479 let msg = err.to_string();
3480 assert!(
3481 msg.contains("oauth.jwks_uri") && msg.contains("https"),
3482 "error must reference offending field + scheme requirement; got {msg:?}"
3483 );
3484 }
3485
3486 #[test]
3487 fn validate_rejects_http_proxy_authorize_url() {
3488 let mut cfg = validation_https_config();
3489 cfg.proxy = Some(
3490 OAuthProxyConfig::builder(
3491 "http://idp.example.com/authorize", "https://idp.example.com/token",
3493 "client",
3494 )
3495 .build(),
3496 );
3497 let err = cfg
3498 .validate()
3499 .expect_err("http authorize_url must be rejected");
3500 assert!(
3501 err.to_string().contains("oauth.proxy.authorize_url"),
3502 "error must reference proxy.authorize_url; got {err}"
3503 );
3504 }
3505
3506 #[test]
3507 fn validate_rejects_http_proxy_token_url() {
3508 let mut cfg = validation_https_config();
3509 cfg.proxy = Some(
3510 OAuthProxyConfig::builder(
3511 "https://idp.example.com/authorize",
3512 "http://idp.example.com/token", "client",
3514 )
3515 .build(),
3516 );
3517 let err = cfg.validate().expect_err("http token_url must be rejected");
3518 assert!(
3519 err.to_string().contains("oauth.proxy.token_url"),
3520 "error must reference proxy.token_url; got {err}"
3521 );
3522 }
3523
3524 #[test]
3525 fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
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("http://idp.example.com/introspect")
3534 .build(),
3535 );
3536 let err = cfg
3537 .validate()
3538 .expect_err("http introspection_url must be rejected");
3539 assert!(err.to_string().contains("oauth.proxy.introspection_url"));
3540
3541 let mut cfg = validation_https_config();
3542 cfg.proxy = Some(
3543 OAuthProxyConfig::builder(
3544 "https://idp.example.com/authorize",
3545 "https://idp.example.com/token",
3546 "client",
3547 )
3548 .revocation_url("http://idp.example.com/revoke")
3549 .build(),
3550 );
3551 let err = cfg
3552 .validate()
3553 .expect_err("http revocation_url must be rejected");
3554 assert!(err.to_string().contains("oauth.proxy.revocation_url"));
3555 }
3556
3557 #[test]
3560 fn validate_rejects_exposed_admin_endpoints_without_auth() {
3561 let mut cfg = validation_https_config();
3562 cfg.proxy = Some(
3563 OAuthProxyConfig::builder(
3564 "https://idp.example.com/authorize",
3565 "https://idp.example.com/token",
3566 "client",
3567 )
3568 .introspection_url("https://idp.example.com/introspect")
3569 .expose_admin_endpoints(true)
3570 .build(),
3571 );
3572 let err = cfg
3573 .validate()
3574 .expect_err("expose_admin_endpoints without auth must fail");
3575 let msg = err.to_string();
3576 assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
3577 assert!(
3578 msg.contains("allow_unauthenticated_admin_endpoints"),
3579 "{msg}"
3580 );
3581 }
3582
3583 #[test]
3584 fn validate_accepts_exposed_admin_endpoints_with_auth() {
3585 let mut cfg = validation_https_config();
3586 cfg.proxy = Some(
3587 OAuthProxyConfig::builder(
3588 "https://idp.example.com/authorize",
3589 "https://idp.example.com/token",
3590 "client",
3591 )
3592 .introspection_url("https://idp.example.com/introspect")
3593 .expose_admin_endpoints(true)
3594 .require_auth_on_admin_endpoints(true)
3595 .build(),
3596 );
3597 cfg.validate()
3598 .expect("authed admin endpoints must validate");
3599 }
3600
3601 #[test]
3602 fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
3603 let mut cfg = validation_https_config();
3604 cfg.proxy = Some(
3605 OAuthProxyConfig::builder(
3606 "https://idp.example.com/authorize",
3607 "https://idp.example.com/token",
3608 "client",
3609 )
3610 .introspection_url("https://idp.example.com/introspect")
3611 .expose_admin_endpoints(true)
3612 .allow_unauthenticated_admin_endpoints(true)
3613 .build(),
3614 );
3615 cfg.validate()
3616 .expect("explicit unauth opt-out must validate");
3617 }
3618
3619 #[test]
3620 fn validate_accepts_unexposed_admin_endpoints_without_auth() {
3621 let mut cfg = validation_https_config();
3624 cfg.proxy = Some(
3625 OAuthProxyConfig::builder(
3626 "https://idp.example.com/authorize",
3627 "https://idp.example.com/token",
3628 "client",
3629 )
3630 .introspection_url("https://idp.example.com/introspect")
3631 .build(),
3632 );
3633 cfg.validate()
3634 .expect("unexposed admin endpoints must validate");
3635 }
3636
3637 #[test]
3638 fn validate_rejects_http_token_exchange_url() {
3639 let mut cfg = validation_https_config();
3640 cfg.token_exchange = Some(TokenExchangeConfig::new(
3641 "http://idp.example.com/token".into(), "client".into(),
3643 None,
3644 None,
3645 "downstream".into(),
3646 ));
3647 let err = cfg
3648 .validate()
3649 .expect_err("http token_exchange.token_url must be rejected");
3650 assert!(
3651 err.to_string().contains("oauth.token_exchange.token_url"),
3652 "error must reference token_exchange.token_url; got {err}"
3653 );
3654 }
3655
3656 #[test]
3657 fn validate_rejects_unparseable_url() {
3658 let mut cfg = validation_https_config();
3659 cfg.jwks_uri = "not a url".into();
3660 let err = cfg
3661 .validate()
3662 .expect_err("unparseable URL must be rejected");
3663 assert!(err.to_string().contains("invalid URL"));
3664 }
3665
3666 #[test]
3667 fn validate_rejects_non_http_scheme() {
3668 let mut cfg = validation_https_config();
3669 cfg.jwks_uri = "file:///etc/passwd".into();
3670 let err = cfg.validate().expect_err("file:// scheme must be rejected");
3671 let msg = err.to_string();
3672 assert!(
3673 msg.contains("must use https scheme") && msg.contains("file"),
3674 "error must reject non-http(s) schemes; got {msg:?}"
3675 );
3676 }
3677
3678 #[test]
3679 fn validate_accepts_http_with_escape_hatch() {
3680 let mut cfg = OAuthConfig::builder(
3685 "http://auth.local",
3686 "mcp",
3687 "http://auth.local/.well-known/jwks.json",
3688 )
3689 .allow_http_oauth_urls(true)
3690 .build();
3691 cfg.proxy = Some(
3692 OAuthProxyConfig::builder(
3693 "http://idp.local/authorize",
3694 "http://idp.local/token",
3695 "client",
3696 )
3697 .introspection_url("http://idp.local/introspect")
3698 .revocation_url("http://idp.local/revoke")
3699 .build(),
3700 );
3701 cfg.token_exchange = Some(TokenExchangeConfig::new(
3702 "http://idp.local/token".into(),
3703 "client".into(),
3704 Some(secrecy::SecretString::new("dev-secret".into())),
3705 None,
3706 "downstream".into(),
3707 ));
3708 cfg.validate()
3709 .expect("escape hatch must permit http on all URL fields");
3710 }
3711
3712 #[test]
3713 fn validate_with_escape_hatch_still_rejects_unparseable() {
3714 let mut cfg = validation_https_config();
3717 cfg.allow_http_oauth_urls = true;
3718 cfg.jwks_uri = "::not-a-url::".into();
3719 cfg.validate()
3720 .expect_err("escape hatch must NOT bypass URL parsing");
3721 }
3722
3723 #[tokio::test]
3724 async fn jwks_cache_rejects_redirect_downgrade_to_http() {
3725 rustls::crypto::ring::default_provider()
3740 .install_default()
3741 .ok();
3742
3743 let policy = reqwest::redirect::Policy::custom(|attempt| {
3744 if attempt.url().scheme() != "https" {
3745 attempt.error("redirect to non-HTTPS URL refused")
3746 } else if attempt.previous().len() >= 2 {
3747 attempt.error("too many redirects (max 2)")
3748 } else {
3749 attempt.follow()
3750 }
3751 });
3752 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
3759 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
3760 let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
3761 crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
3762 );
3763 let client = reqwest::Client::builder()
3764 .no_proxy()
3765 .dns_resolver(Arc::clone(&resolver))
3766 .timeout(Duration::from_secs(5))
3767 .connect_timeout(Duration::from_secs(3))
3768 .redirect(policy)
3769 .build()
3770 .expect("test client builds");
3771
3772 let mock = wiremock::MockServer::start().await;
3773 wiremock::Mock::given(wiremock::matchers::method("GET"))
3774 .and(wiremock::matchers::path("/jwks.json"))
3775 .respond_with(
3776 wiremock::ResponseTemplate::new(302)
3777 .insert_header("location", "http://example.invalid/jwks.json"),
3778 )
3779 .mount(&mock)
3780 .await;
3781
3782 let url = format!("{}/jwks.json", mock.uri());
3791 let err = client
3792 .get(&url)
3793 .send()
3794 .await
3795 .expect_err("redirect policy must reject scheme downgrade");
3796 let chain = format!("{err:#}");
3797 assert!(
3798 chain.contains("redirect to non-HTTPS URL refused")
3799 || chain.to_lowercase().contains("redirect"),
3800 "error must surface redirect-policy rejection; got {chain:?}"
3801 );
3802 }
3803
3804 use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
3809
3810 fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
3812 let mut rng = rsa::rand_core::OsRng;
3813 let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
3814 let private_pem = private_key
3815 .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
3816 .expect("PKCS8 PEM export")
3817 .to_string();
3818
3819 let public_key = private_key.to_public_key();
3820 let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
3821 let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
3822
3823 let jwks = serde_json::json!({
3824 "keys": [{
3825 "kty": "RSA",
3826 "use": "sig",
3827 "alg": "RS256",
3828 "kid": kid,
3829 "n": n,
3830 "e": e
3831 }]
3832 });
3833
3834 (private_pem, jwks)
3835 }
3836
3837 fn mint_token(
3839 private_pem: &str,
3840 kid: &str,
3841 issuer: &str,
3842 audience: &str,
3843 subject: &str,
3844 scope: &str,
3845 ) -> String {
3846 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
3847 .expect("encoding key from PEM");
3848 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
3849 header.kid = Some(kid.into());
3850
3851 let now = jsonwebtoken::get_current_timestamp();
3852 let claims = serde_json::json!({
3853 "iss": issuer,
3854 "aud": audience,
3855 "sub": subject,
3856 "scope": scope,
3857 "exp": now + 3600,
3858 "iat": now,
3859 });
3860
3861 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
3862 }
3863
3864 fn mint_token_without_sub(
3866 private_pem: &str,
3867 kid: &str,
3868 issuer: &str,
3869 audience: &str,
3870 scope: &str,
3871 ) -> String {
3872 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
3873 .expect("encoding key from PEM");
3874 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
3875 header.kid = Some(kid.into());
3876 let now = jsonwebtoken::get_current_timestamp();
3877 let claims = serde_json::json!({
3878 "iss": issuer,
3879 "aud": audience,
3880 "scope": scope,
3881 "exp": now + 3600,
3882 "iat": now,
3883 });
3884 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
3885 }
3886
3887 fn test_config(jwks_uri: &str) -> OAuthConfig {
3888 OAuthConfig {
3889 require_subject: false,
3890 issuer: "https://auth.test.local".into(),
3891 audience: "https://mcp.test.local/mcp".into(),
3892 jwks_uri: jwks_uri.into(),
3893 scopes: vec![
3894 ScopeMapping {
3895 scope: "mcp:read".into(),
3896 role: "viewer".into(),
3897 },
3898 ScopeMapping {
3899 scope: "mcp:admin".into(),
3900 role: "ops".into(),
3901 },
3902 ],
3903 role_claim: None,
3904 role_mappings: vec![],
3905 jwks_cache_ttl: "5m".into(),
3906 proxy: None,
3907 token_exchange: None,
3908 ca_cert_path: None,
3909 allow_http_oauth_urls: true,
3910 max_jwks_keys: default_max_jwks_keys(),
3911 #[allow(
3912 deprecated,
3913 reason = "test fixture: explicit value for the deprecated field"
3914 )]
3915 strict_audience_validation: None,
3916 audience_validation_mode: None,
3917 jwks_max_response_bytes: default_jwks_max_bytes(),
3918 ssrf_allowlist: None,
3919 }
3920 }
3921
3922 fn test_cache(config: &OAuthConfig) -> JwksCache {
3923 JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
3924 }
3925
3926 async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
3933 let kid = "test-h2-stale";
3934 let (pem, jwks) = generate_test_keypair(kid);
3935 let mock_server = wiremock::MockServer::start().await;
3936 wiremock::Mock::given(wiremock::matchers::method("GET"))
3937 .and(wiremock::matchers::path("/jwks.json"))
3938 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3939 .mount(&mock_server)
3940 .await;
3941 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3942 let mut config = test_config(&jwks_uri);
3943 config.jwks_cache_ttl = ttl.into();
3944 let cache = test_cache(&config);
3945 cache.__test_refresh_now().await.expect("prime JWKS cache");
3946 assert!(cache.__test_has_kid(kid).await, "kid must be primed");
3947
3948 mock_server.reset().await;
3949 wiremock::Mock::given(wiremock::matchers::method("GET"))
3950 .and(wiremock::matchers::path("/jwks.json"))
3951 .respond_with(wiremock::ResponseTemplate::new(503))
3952 .mount(&mock_server)
3953 .await;
3954
3955 let token = mint_token(
3956 &pem,
3957 kid,
3958 "https://auth.test.local",
3959 "https://mcp.test.local/mcp",
3960 "h2-client",
3961 "mcp:read",
3962 );
3963 (cache, token, mock_server)
3964 }
3965
3966 #[tokio::test]
3967 async fn expired_jwks_fails_closed_when_refresh_fails() {
3968 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
3969 tokio::time::sleep(Duration::from_millis(200)).await;
3970 let failure = cache
3971 .validate_token_with_reason(&token)
3972 .await
3973 .expect_err("an expired cache whose refresh fails must not serve the stale key");
3974 assert_eq!(failure, JwtValidationFailure::Invalid);
3975 }
3976
3977 #[tokio::test]
3978 async fn fresh_jwks_still_validates() {
3979 let kid = "test-h2-fresh";
3980 let (pem, jwks) = generate_test_keypair(kid);
3981 let mock_server = wiremock::MockServer::start().await;
3982 wiremock::Mock::given(wiremock::matchers::method("GET"))
3983 .and(wiremock::matchers::path("/jwks.json"))
3984 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3985 .mount(&mock_server)
3986 .await;
3987 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3988 let config = test_config(&jwks_uri); let cache = test_cache(&config);
3990 let token = mint_token(
3991 &pem,
3992 kid,
3993 "https://auth.test.local",
3994 "https://mcp.test.local/mcp",
3995 "h2-fresh-client",
3996 "mcp:read",
3997 );
3998 cache
3999 .validate_token_with_reason(&token)
4000 .await
4001 .expect("a reachable JWKS must still validate a matching token");
4002 }
4003
4004 #[tokio::test]
4005 async fn cooldown_active_plus_expired_fails_closed() {
4006 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
4007 tokio::time::sleep(Duration::from_millis(200)).await;
4008 assert_eq!(
4011 cache
4012 .validate_token_with_reason(&token)
4013 .await
4014 .expect_err("first attempt must fail closed"),
4015 JwtValidationFailure::Invalid,
4016 );
4017 let failure = cache
4020 .validate_token_with_reason(&token)
4021 .await
4022 .expect_err("cooldown-active + expired cache must still fail closed");
4023 assert_eq!(failure, JwtValidationFailure::Invalid);
4024 }
4025
4026 #[tokio::test]
4027 async fn valid_jwt_returns_identity() {
4028 let kid = "test-key-1";
4029 let (pem, jwks) = generate_test_keypair(kid);
4030
4031 let mock_server = wiremock::MockServer::start().await;
4032 wiremock::Mock::given(wiremock::matchers::method("GET"))
4033 .and(wiremock::matchers::path("/jwks.json"))
4034 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4035 .mount(&mock_server)
4036 .await;
4037
4038 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4039 let config = test_config(&jwks_uri);
4040 let cache = test_cache(&config);
4041
4042 let token = mint_token(
4043 &pem,
4044 kid,
4045 "https://auth.test.local",
4046 "https://mcp.test.local/mcp",
4047 "ci-bot",
4048 "mcp:read mcp:other",
4049 );
4050
4051 let identity = cache.validate_token(&token).await;
4052 assert!(identity.is_some(), "valid JWT should authenticate");
4053 let id = identity.unwrap();
4054 assert_eq!(id.name, "ci-bot");
4055 assert_eq!(id.role, "viewer"); assert_eq!(id.method, AuthMethod::OAuthJwt);
4057 }
4058
4059 #[test]
4062 fn unknown_kid_with_named_keys_rejected() {
4063 let mut keys = HashMap::new();
4064 keys.insert(
4065 "kid-1".to_owned(),
4066 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4067 );
4068 let cached = CachedKeys {
4069 keys,
4070 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4071 fetched_at: Instant::now(),
4072 ttl: Duration::from_secs(300),
4073 };
4074 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
4076 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
4080 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
4082 }
4083
4084 #[test]
4085 fn no_kid_token_matches_unnamed_key() {
4086 let mut keys = HashMap::new();
4087 keys.insert(
4088 "kid-1".to_owned(),
4089 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4090 );
4091 let cached = CachedKeys {
4092 keys,
4093 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4094 fetched_at: Instant::now(),
4095 ttl: Duration::from_secs(300),
4096 };
4097 assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
4100 }
4101
4102 #[tokio::test]
4103 async fn require_subject_rejects_subject_less() {
4104 let kid = "test-key-reqsub";
4105 let (pem, jwks) = generate_test_keypair(kid);
4106 let mock_server = wiremock::MockServer::start().await;
4107 wiremock::Mock::given(wiremock::matchers::method("GET"))
4108 .and(wiremock::matchers::path("/jwks.json"))
4109 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4110 .mount(&mock_server)
4111 .await;
4112 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4113 let mut config = test_config(&jwks_uri);
4114 config.require_subject = true;
4115 let cache = test_cache(&config);
4116
4117 let no_sub = mint_token_without_sub(
4118 &pem,
4119 kid,
4120 "https://auth.test.local",
4121 "https://mcp.test.local/mcp",
4122 "mcp:read",
4123 );
4124 assert!(
4125 cache.validate_token(&no_sub).await.is_none(),
4126 "require_subject must reject a token with no sub"
4127 );
4128
4129 let with_sub = mint_token(
4130 &pem,
4131 kid,
4132 "https://auth.test.local",
4133 "https://mcp.test.local/mcp",
4134 "svc",
4135 "mcp:read",
4136 );
4137 assert!(
4138 cache.validate_token(&with_sub).await.is_some(),
4139 "a token carrying sub must still be accepted"
4140 );
4141 }
4142
4143 #[tokio::test]
4144 async fn subject_less_token_accepted_by_default() {
4145 let kid = "test-key-nosub-default";
4146 let (pem, jwks) = generate_test_keypair(kid);
4147 let mock_server = wiremock::MockServer::start().await;
4148 wiremock::Mock::given(wiremock::matchers::method("GET"))
4149 .and(wiremock::matchers::path("/jwks.json"))
4150 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4151 .mount(&mock_server)
4152 .await;
4153 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4154 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4156 let no_sub = mint_token_without_sub(
4157 &pem,
4158 kid,
4159 "https://auth.test.local",
4160 "https://mcp.test.local/mcp",
4161 "mcp:read",
4162 );
4163 assert!(
4164 cache.validate_token(&no_sub).await.is_some(),
4165 "the default policy must accept a sub-less (client-credentials) token"
4166 );
4167 }
4168
4169 #[tokio::test]
4170 async fn credential_post_does_not_follow_redirect() {
4171 let mock = wiremock::MockServer::start().await;
4174 wiremock::Mock::given(wiremock::matchers::method("POST"))
4175 .and(wiremock::matchers::path("/followed"))
4176 .respond_with(wiremock::ResponseTemplate::new(200))
4177 .expect(0) .mount(&mock)
4179 .await;
4180 wiremock::Mock::given(wiremock::matchers::method("POST"))
4181 .and(wiremock::matchers::path("/token"))
4182 .respond_with(
4183 wiremock::ResponseTemplate::new(307)
4184 .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
4185 )
4186 .mount(&mock)
4187 .await;
4188
4189 let client = OauthHttpClient::build(None).expect("build oauth http client");
4190 let resp = client
4191 .credential_client
4192 .post(format!("{}/token", mock.uri()))
4193 .body("grant_type=client_credentials")
4194 .send()
4195 .await
4196 .expect("request sent");
4197 assert_eq!(
4198 resp.status().as_u16(),
4199 307,
4200 "credential client must surface the 307 rather than follow it"
4201 );
4202 }
4203
4204 #[tokio::test]
4205 async fn jwks_get_still_follows_screened_redirect() {
4206 let mock = wiremock::MockServer::start().await;
4212 wiremock::Mock::given(wiremock::matchers::method("GET"))
4213 .and(wiremock::matchers::path("/jwks.json"))
4214 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
4215 "location",
4216 format!("{}/jwks-final.json", mock.uri()).as_str(),
4217 ))
4218 .mount(&mock)
4219 .await;
4220 wiremock::Mock::given(wiremock::matchers::method("GET"))
4221 .and(wiremock::matchers::path("/jwks-final.json"))
4222 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
4223 .expect(1)
4224 .mount(&mock)
4225 .await;
4226
4227 let mut allowlist = OAuthSsrfAllowlist::default();
4228 allowlist.cidrs.push("127.0.0.0/8".into());
4229 allowlist.cidrs.push("::1/128".into());
4230 let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
4231 config.allow_http_oauth_urls = true;
4232 config.ssrf_allowlist = Some(allowlist);
4233
4234 let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
4235 let resp = client
4236 .inner
4237 .get(format!("{}/jwks.json", mock.uri()))
4238 .send()
4239 .await
4240 .expect("request sent");
4241 assert_eq!(
4242 resp.status().as_u16(),
4243 200,
4244 "JWKS client must follow the screened redirect to the final endpoint"
4245 );
4246 assert_eq!(resp.text().await.expect("response body"), "reached");
4247 }
4248
4249 #[tokio::test]
4250 async fn wrong_issuer_rejected() {
4251 let kid = "test-key-2";
4252 let (pem, jwks) = generate_test_keypair(kid);
4253
4254 let mock_server = wiremock::MockServer::start().await;
4255 wiremock::Mock::given(wiremock::matchers::method("GET"))
4256 .and(wiremock::matchers::path("/jwks.json"))
4257 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4258 .mount(&mock_server)
4259 .await;
4260
4261 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4262 let config = test_config(&jwks_uri);
4263 let cache = test_cache(&config);
4264
4265 let token = mint_token(
4266 &pem,
4267 kid,
4268 "https://wrong-issuer.example.com", "https://mcp.test.local/mcp",
4270 "attacker",
4271 "mcp:admin",
4272 );
4273
4274 assert!(cache.validate_token(&token).await.is_none());
4275 }
4276
4277 #[tokio::test]
4278 async fn wrong_audience_rejected() {
4279 let kid = "test-key-3";
4280 let (pem, jwks) = generate_test_keypair(kid);
4281
4282 let mock_server = wiremock::MockServer::start().await;
4283 wiremock::Mock::given(wiremock::matchers::method("GET"))
4284 .and(wiremock::matchers::path("/jwks.json"))
4285 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4286 .mount(&mock_server)
4287 .await;
4288
4289 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4290 let config = test_config(&jwks_uri);
4291 let cache = test_cache(&config);
4292
4293 let token = mint_token(
4294 &pem,
4295 kid,
4296 "https://auth.test.local",
4297 "https://wrong-audience.example.com", "attacker",
4299 "mcp:admin",
4300 );
4301
4302 assert!(cache.validate_token(&token).await.is_none());
4303 }
4304
4305 #[tokio::test]
4306 async fn expired_jwt_rejected() {
4307 let kid = "test-key-4";
4308 let (pem, jwks) = generate_test_keypair(kid);
4309
4310 let mock_server = wiremock::MockServer::start().await;
4311 wiremock::Mock::given(wiremock::matchers::method("GET"))
4312 .and(wiremock::matchers::path("/jwks.json"))
4313 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4314 .mount(&mock_server)
4315 .await;
4316
4317 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4318 let config = test_config(&jwks_uri);
4319 let cache = test_cache(&config);
4320
4321 let encoding_key =
4323 jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
4324 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4325 header.kid = Some(kid.into());
4326 let now = jsonwebtoken::get_current_timestamp();
4327 let claims = serde_json::json!({
4328 "iss": "https://auth.test.local",
4329 "aud": "https://mcp.test.local/mcp",
4330 "sub": "expired-bot",
4331 "scope": "mcp:read",
4332 "exp": now - 120,
4333 "iat": now - 3720,
4334 });
4335 let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
4336
4337 assert!(cache.validate_token(&token).await.is_none());
4338 }
4339
4340 #[tokio::test]
4341 async fn no_matching_scope_rejected() {
4342 let kid = "test-key-5";
4343 let (pem, jwks) = generate_test_keypair(kid);
4344
4345 let mock_server = wiremock::MockServer::start().await;
4346 wiremock::Mock::given(wiremock::matchers::method("GET"))
4347 .and(wiremock::matchers::path("/jwks.json"))
4348 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4349 .mount(&mock_server)
4350 .await;
4351
4352 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4353 let config = test_config(&jwks_uri);
4354 let cache = test_cache(&config);
4355
4356 let token = mint_token(
4357 &pem,
4358 kid,
4359 "https://auth.test.local",
4360 "https://mcp.test.local/mcp",
4361 "limited-bot",
4362 "some:other:scope", );
4364
4365 assert!(cache.validate_token(&token).await.is_none());
4366 }
4367
4368 #[tokio::test]
4369 async fn wrong_signing_key_rejected() {
4370 let kid = "test-key-6";
4371 let (_pem, jwks) = generate_test_keypair(kid);
4372
4373 let (attacker_pem, _) = generate_test_keypair(kid);
4375
4376 let mock_server = wiremock::MockServer::start().await;
4377 wiremock::Mock::given(wiremock::matchers::method("GET"))
4378 .and(wiremock::matchers::path("/jwks.json"))
4379 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4380 .mount(&mock_server)
4381 .await;
4382
4383 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4384 let config = test_config(&jwks_uri);
4385 let cache = test_cache(&config);
4386
4387 let token = mint_token(
4389 &attacker_pem,
4390 kid,
4391 "https://auth.test.local",
4392 "https://mcp.test.local/mcp",
4393 "attacker",
4394 "mcp:admin",
4395 );
4396
4397 assert!(cache.validate_token(&token).await.is_none());
4398 }
4399
4400 #[tokio::test]
4401 async fn admin_scope_maps_to_ops_role() {
4402 let kid = "test-key-7";
4403 let (pem, jwks) = generate_test_keypair(kid);
4404
4405 let mock_server = wiremock::MockServer::start().await;
4406 wiremock::Mock::given(wiremock::matchers::method("GET"))
4407 .and(wiremock::matchers::path("/jwks.json"))
4408 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4409 .mount(&mock_server)
4410 .await;
4411
4412 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4413 let config = test_config(&jwks_uri);
4414 let cache = test_cache(&config);
4415
4416 let token = mint_token(
4417 &pem,
4418 kid,
4419 "https://auth.test.local",
4420 "https://mcp.test.local/mcp",
4421 "admin-bot",
4422 "mcp:admin",
4423 );
4424
4425 let id = cache
4426 .validate_token(&token)
4427 .await
4428 .expect("should authenticate");
4429 assert_eq!(id.role, "ops");
4430 assert_eq!(id.name, "admin-bot");
4431 }
4432
4433 #[tokio::test]
4434 async fn jwks_server_down_returns_none() {
4435 let config = test_config("http://127.0.0.1:1/jwks.json");
4437 let cache = test_cache(&config);
4438
4439 let kid = "orphan-key";
4440 let (pem, _) = generate_test_keypair(kid);
4441 let token = mint_token(
4442 &pem,
4443 kid,
4444 "https://auth.test.local",
4445 "https://mcp.test.local/mcp",
4446 "bot",
4447 "mcp:read",
4448 );
4449
4450 assert!(cache.validate_token(&token).await.is_none());
4451 }
4452
4453 #[test]
4458 fn resolve_claim_path_flat_string() {
4459 let mut extra = HashMap::new();
4460 extra.insert(
4461 "scope".into(),
4462 serde_json::Value::String("mcp:read mcp:admin".into()),
4463 );
4464 let values = resolve_claim_path(&extra, "scope");
4465 assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
4466 }
4467
4468 #[test]
4469 fn resolve_claim_path_flat_array() {
4470 let mut extra = HashMap::new();
4471 extra.insert(
4472 "roles".into(),
4473 serde_json::json!(["mcp-admin", "mcp-viewer"]),
4474 );
4475 let values = resolve_claim_path(&extra, "roles");
4476 assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
4477 }
4478
4479 #[test]
4480 fn resolve_claim_path_nested_keycloak() {
4481 let mut extra = HashMap::new();
4482 extra.insert(
4483 "realm_access".into(),
4484 serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
4485 );
4486 let values = resolve_claim_path(&extra, "realm_access.roles");
4487 assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
4488 }
4489
4490 #[test]
4491 fn resolve_claim_path_missing_returns_empty() {
4492 let extra = HashMap::new();
4493 assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
4494 }
4495
4496 #[test]
4497 fn resolve_claim_path_numeric_leaf_returns_empty() {
4498 let mut extra = HashMap::new();
4499 extra.insert("count".into(), serde_json::json!(42));
4500 assert!(resolve_claim_path(&extra, "count").is_empty());
4501 }
4502
4503 fn make_claims(json: serde_json::Value) -> Claims {
4504 serde_json::from_value(json).expect("test claims must deserialize")
4505 }
4506
4507 #[test]
4508 fn first_class_scope_claim_splits_on_whitespace() {
4509 let claims = make_claims(serde_json::json!({
4510 "iss": "https://issuer.example.com",
4511 "exp": 9_999_999_999_u64,
4512 "scope": "read write admin",
4513 }));
4514 let values = first_class_claim_values(&claims, "scope");
4515 assert_eq!(values, vec!["read", "write", "admin"]);
4516 }
4517
4518 #[test]
4519 fn first_class_sub_claim_returns_single_value() {
4520 let claims = make_claims(serde_json::json!({
4521 "iss": "https://issuer.example.com",
4522 "exp": 9_999_999_999_u64,
4523 "sub": "service-account-orders",
4524 }));
4525 let values = first_class_claim_values(&claims, "sub");
4526 assert_eq!(values, vec!["service-account-orders"]);
4527 }
4528
4529 #[test]
4530 fn first_class_aud_claim_returns_every_audience() {
4531 let claims = make_claims(serde_json::json!({
4532 "iss": "https://issuer.example.com",
4533 "exp": 9_999_999_999_u64,
4534 "aud": ["api-a", "api-b"],
4535 }));
4536 let values = first_class_claim_values(&claims, "aud");
4537 assert_eq!(values, vec!["api-a", "api-b"]);
4538 }
4539
4540 #[test]
4541 fn first_class_unknown_path_returns_empty() {
4542 let claims = make_claims(serde_json::json!({
4543 "iss": "https://issuer.example.com",
4544 "exp": 9_999_999_999_u64,
4545 }));
4546 assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
4547 }
4548
4549 fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
4555 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4556 .expect("encoding key from PEM");
4557 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4558 header.kid = Some(kid.into());
4559 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4560 }
4561
4562 fn test_config_with_role_claim(
4563 jwks_uri: &str,
4564 role_claim: &str,
4565 role_mappings: Vec<RoleMapping>,
4566 ) -> OAuthConfig {
4567 OAuthConfig {
4568 require_subject: false,
4569 issuer: "https://auth.test.local".into(),
4570 audience: "https://mcp.test.local/mcp".into(),
4571 jwks_uri: jwks_uri.into(),
4572 scopes: vec![],
4573 role_claim: Some(role_claim.into()),
4574 role_mappings,
4575 jwks_cache_ttl: "5m".into(),
4576 proxy: None,
4577 token_exchange: None,
4578 ca_cert_path: None,
4579 allow_http_oauth_urls: true,
4580 max_jwks_keys: default_max_jwks_keys(),
4581 #[allow(
4582 deprecated,
4583 reason = "test fixture: explicit value for the deprecated field"
4584 )]
4585 strict_audience_validation: None,
4586 audience_validation_mode: None,
4587 jwks_max_response_bytes: default_jwks_max_bytes(),
4588 ssrf_allowlist: None,
4589 }
4590 }
4591
4592 #[tokio::test]
4593 async fn screen_oauth_target_rejects_literal_ip() {
4594 let err = screen_oauth_target(
4595 "https://127.0.0.1/jwks.json",
4596 false,
4597 &crate::ssrf::CompiledSsrfAllowlist::default(),
4598 )
4599 .await
4600 .expect_err("literal IPs must be rejected");
4601 let msg = err.to_string();
4602 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4603 }
4604
4605 #[tokio::test]
4606 async fn screen_oauth_target_rejects_private_dns_resolution() {
4607 let err = screen_oauth_target(
4608 "https://localhost/jwks.json",
4609 false,
4610 &crate::ssrf::CompiledSsrfAllowlist::default(),
4611 )
4612 .await
4613 .expect_err("localhost resolution must be rejected");
4614 let msg = err.to_string();
4615 assert!(
4616 msg.contains("blocked IP") && msg.contains("loopback"),
4617 "got {msg:?}"
4618 );
4619 }
4620
4621 #[tokio::test]
4622 async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
4623 let err = screen_oauth_target(
4624 "http://127.0.0.1/jwks.json",
4625 true,
4626 &crate::ssrf::CompiledSsrfAllowlist::default(),
4627 )
4628 .await
4629 .expect_err("literal IPs must still be rejected when http is allowed");
4630 let msg = err.to_string();
4631 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4632 }
4633
4634 #[tokio::test]
4635 async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
4636 let err = screen_oauth_target(
4637 "http://localhost/jwks.json",
4638 true,
4639 &crate::ssrf::CompiledSsrfAllowlist::default(),
4640 )
4641 .await
4642 .expect_err("private DNS resolution must still be rejected when http is allowed");
4643 let msg = err.to_string();
4644 assert!(
4645 msg.contains("blocked IP") && msg.contains("loopback"),
4646 "got {msg:?}"
4647 );
4648 }
4649
4650 #[tokio::test]
4651 async fn screen_oauth_target_allows_public_hostname() {
4652 screen_oauth_target(
4653 "https://example.com/.well-known/jwks.json",
4654 false,
4655 &crate::ssrf::CompiledSsrfAllowlist::default(),
4656 )
4657 .await
4658 .expect("public hostname should pass screening");
4659 }
4660
4661 fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
4667 let raw = OAuthSsrfAllowlist {
4668 hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
4669 cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
4670 };
4671 compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
4672 }
4673
4674 #[test]
4675 fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
4676 let raw = OAuthSsrfAllowlist {
4677 hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
4678 cidrs: vec![],
4679 };
4680 let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
4681 assert_eq!(compiled.host_count(), 1);
4682 assert!(compiled.host_allowed("rhbk.ops.example.com"));
4683 assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
4684 }
4685
4686 #[test]
4687 fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
4688 let raw = OAuthSsrfAllowlist {
4689 hosts: vec!["10.0.0.1".into()],
4690 cidrs: vec![],
4691 };
4692 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
4693 assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
4694 }
4695
4696 #[test]
4697 fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
4698 let raw = OAuthSsrfAllowlist {
4699 hosts: vec!["rhbk.ops.example.com:8443".into()],
4700 cidrs: vec![],
4701 };
4702 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
4703 assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
4704 }
4705
4706 #[test]
4709 fn internal_suffix_rejected_by_default() {
4710 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4711 for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
4712 assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
4713 }
4714 }
4715
4716 #[test]
4717 fn exact_allowlisted_internal_permitted() {
4718 let allow = make_allowlist(&["idp.internal"], &[]);
4719 assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
4720 assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
4721 }
4722
4723 #[test]
4724 fn subdomain_of_allowlisted_internal_still_rejected() {
4725 let allow = make_allowlist(&["idp.internal"], &[]);
4726 assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
4727 }
4728
4729 #[test]
4730 fn cidr_allowlist_does_not_bypass_suffix_denylist() {
4731 let allow = make_allowlist(&[], &["10.0.0.0/8"]);
4732 assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
4733 }
4734
4735 #[test]
4736 fn public_hostname_not_blocked_by_suffix() {
4737 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4738 assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
4739 }
4740
4741 #[test]
4742 fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
4743 let raw = OAuthSsrfAllowlist {
4744 hosts: vec![],
4745 cidrs: vec!["not-a-cidr".into()],
4746 };
4747 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
4748 assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
4749 }
4750
4751 #[test]
4752 fn validate_rejects_misconfigured_allowlist() {
4753 let mut cfg = OAuthConfig::builder(
4754 "https://auth.example.com/",
4755 "mcp",
4756 "https://auth.example.com/jwks.json",
4757 )
4758 .build();
4759 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4760 hosts: vec!["10.0.0.1".into()],
4761 cidrs: vec![],
4762 });
4763 let err = cfg
4764 .validate()
4765 .expect_err("literal IP host must be rejected");
4766 assert!(
4767 err.to_string().contains("oauth.ssrf_allowlist"),
4768 "got {err}"
4769 );
4770 }
4771
4772 #[tokio::test]
4773 async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
4774 let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
4778 let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
4779 .await
4780 .expect_err("loopback must still be blocked when not in allowlist");
4781 let msg = err.to_string();
4782 assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
4783 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4784 assert!(msg.contains("SECURITY.md"), "got {msg:?}");
4785 }
4786
4787 #[tokio::test]
4788 async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
4789 let err = screen_oauth_target(
4792 "https://localhost/jwks.json",
4793 false,
4794 &crate::ssrf::CompiledSsrfAllowlist::default(),
4795 )
4796 .await
4797 .expect_err("loopback rejection");
4798 let msg = err.to_string();
4799 assert!(msg.contains("blocked IP"), "got {msg:?}");
4800 assert!(msg.contains("loopback"), "got {msg:?}");
4801 assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4803 }
4804
4805 #[tokio::test]
4806 async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
4807 let allow = make_allowlist(&["localhost"], &[]);
4809 screen_oauth_target("https://localhost/jwks.json", false, &allow)
4810 .await
4811 .expect("allowlisted host must pass");
4812 }
4813
4814 #[tokio::test]
4815 async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
4816 let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
4819 screen_oauth_target("https://localhost/jwks.json", false, &allow)
4820 .await
4821 .expect("allowlisted CIDR must pass");
4822 }
4823
4824 #[tokio::test]
4825 async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
4826 let mut cfg = OAuthConfig::builder(
4827 "https://auth.example.com/",
4828 "mcp",
4829 "https://auth.example.com/jwks.json",
4830 )
4831 .build();
4832 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4833 hosts: vec![],
4834 cidrs: vec!["bad-cidr".into()],
4835 });
4836 let Err(err) = JwksCache::new(&cfg) else {
4837 panic!("invalid CIDR must fail JwksCache::new")
4838 };
4839 let msg = err.to_string();
4840 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4841 }
4842
4843 #[tokio::test]
4844 async fn jwks_cache_new_invalid_ttl_is_err() {
4845 let cfg = OAuthConfig::builder(
4848 "https://auth.example.com/",
4849 "mcp",
4850 "https://auth.example.com/jwks.json",
4851 )
4852 .jwks_cache_ttl("not-a-duration")
4853 .build();
4854 let Err(err) = JwksCache::new(&cfg) else {
4855 panic!("invalid jwks_cache_ttl must fail JwksCache::new")
4856 };
4857 let msg = err.to_string();
4858 assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
4859 }
4860
4861 #[tokio::test]
4862 async fn audience_default_is_strict() {
4863 let kid = "test-audience-azp-default";
4864 let (pem, jwks) = generate_test_keypair(kid);
4865
4866 let mock_server = wiremock::MockServer::start().await;
4867 wiremock::Mock::given(wiremock::matchers::method("GET"))
4868 .and(wiremock::matchers::path("/jwks.json"))
4869 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4870 .mount(&mock_server)
4871 .await;
4872
4873 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4874 let config = test_config(&jwks_uri);
4875 let cache = test_cache(&config);
4876
4877 let now = jsonwebtoken::get_current_timestamp();
4878 let token = mint_token_with_claims(
4879 &pem,
4880 kid,
4881 &serde_json::json!({
4882 "iss": "https://auth.test.local",
4883 "aud": "https://some-other-resource.example.com",
4884 "azp": "https://mcp.test.local/mcp",
4885 "sub": "compat-client",
4886 "scope": "mcp:read",
4887 "exp": now + 3600,
4888 "iat": now,
4889 }),
4890 );
4891
4892 let failure = cache
4893 .validate_token_with_reason(&token)
4894 .await
4895 .expect_err("the default policy is Strict and must reject an azp-only match");
4896 assert_eq!(failure, JwtValidationFailure::Invalid);
4897 }
4898
4899 #[tokio::test]
4900 async fn audience_warn_still_accepts_azp() {
4901 let kid = "test-audience-warn-optin";
4902 let (pem, jwks) = generate_test_keypair(kid);
4903
4904 let mock_server = wiremock::MockServer::start().await;
4905 wiremock::Mock::given(wiremock::matchers::method("GET"))
4906 .and(wiremock::matchers::path("/jwks.json"))
4907 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4908 .mount(&mock_server)
4909 .await;
4910
4911 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4912 let mut config = test_config(&jwks_uri);
4913 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
4914 let cache = test_cache(&config);
4915
4916 let now = jsonwebtoken::get_current_timestamp();
4917 let token = mint_token_with_claims(
4918 &pem,
4919 kid,
4920 &serde_json::json!({
4921 "iss": "https://auth.test.local",
4922 "aud": "https://some-other-resource.example.com",
4923 "azp": "https://mcp.test.local/mcp",
4924 "sub": "warn-optin-client",
4925 "scope": "mcp:read",
4926 "exp": now + 3600,
4927 "iat": now,
4928 }),
4929 );
4930
4931 cache.validate_token_with_reason(&token).await.expect(
4932 "the audience_validation_mode=warn opt-out must still accept an azp-only match",
4933 );
4934 }
4935
4936 #[tokio::test]
4937 async fn legacy_strict_false_maps_to_warn() {
4938 let kid = "test-audience-legacy-false";
4939 let (pem, jwks) = generate_test_keypair(kid);
4940
4941 let mock_server = wiremock::MockServer::start().await;
4942 wiremock::Mock::given(wiremock::matchers::method("GET"))
4943 .and(wiremock::matchers::path("/jwks.json"))
4944 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4945 .mount(&mock_server)
4946 .await;
4947
4948 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4949 let mut config = test_config(&jwks_uri);
4950 #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
4953 {
4954 config.strict_audience_validation = Some(false);
4955 }
4956 let cache = test_cache(&config);
4957
4958 let now = jsonwebtoken::get_current_timestamp();
4959 let token = mint_token_with_claims(
4960 &pem,
4961 kid,
4962 &serde_json::json!({
4963 "iss": "https://auth.test.local",
4964 "aud": "https://some-other-resource.example.com",
4965 "azp": "https://mcp.test.local/mcp",
4966 "sub": "legacy-false-client",
4967 "scope": "mcp:read",
4968 "exp": now + 3600,
4969 "iat": now,
4970 }),
4971 );
4972
4973 cache
4974 .validate_token_with_reason(&token)
4975 .await
4976 .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
4977 }
4978
4979 #[tokio::test]
4980 async fn aud_match_always_accepts() {
4981 let kid = "test-audience-aud-match";
4982 let (pem, jwks) = generate_test_keypair(kid);
4983
4984 let mock_server = wiremock::MockServer::start().await;
4985 wiremock::Mock::given(wiremock::matchers::method("GET"))
4986 .and(wiremock::matchers::path("/jwks.json"))
4987 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4988 .mount(&mock_server)
4989 .await;
4990
4991 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4992 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4994
4995 let now = jsonwebtoken::get_current_timestamp();
4996 let token = mint_token_with_claims(
4997 &pem,
4998 kid,
4999 &serde_json::json!({
5000 "iss": "https://auth.test.local",
5001 "aud": "https://mcp.test.local/mcp",
5002 "sub": "aud-match-client",
5003 "scope": "mcp:read",
5004 "exp": now + 3600,
5005 "iat": now,
5006 }),
5007 );
5008
5009 cache
5010 .validate_token_with_reason(&token)
5011 .await
5012 .expect("a matching aud must be accepted even under the Strict default");
5013 }
5014
5015 #[tokio::test]
5016 async fn strict_audience_validation_rejects_azp_only_match() {
5017 let kid = "test-audience-azp-strict";
5018 let (pem, jwks) = generate_test_keypair(kid);
5019
5020 let mock_server = wiremock::MockServer::start().await;
5021 wiremock::Mock::given(wiremock::matchers::method("GET"))
5022 .and(wiremock::matchers::path("/jwks.json"))
5023 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5024 .mount(&mock_server)
5025 .await;
5026
5027 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5028 let mut config = test_config(&jwks_uri);
5029 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
5030 {
5031 config.strict_audience_validation = Some(true);
5032 }
5033 let cache = test_cache(&config);
5034
5035 let now = jsonwebtoken::get_current_timestamp();
5036 let token = mint_token_with_claims(
5037 &pem,
5038 kid,
5039 &serde_json::json!({
5040 "iss": "https://auth.test.local",
5041 "aud": "https://some-other-resource.example.com",
5042 "azp": "https://mcp.test.local/mcp",
5043 "sub": "strict-client",
5044 "scope": "mcp:read",
5045 "exp": now + 3600,
5046 "iat": now,
5047 }),
5048 );
5049
5050 let failure = cache
5051 .validate_token_with_reason(&token)
5052 .await
5053 .expect_err("strict audience validation must ignore azp fallback");
5054 assert_eq!(failure, JwtValidationFailure::Invalid);
5055 }
5056
5057 #[tokio::test]
5058 async fn warn_mode_accepts_azp_only_match_and_warns_once() {
5059 let kid = "test-audience-warn-mode";
5060 let (pem, jwks) = generate_test_keypair(kid);
5061
5062 let mock_server = wiremock::MockServer::start().await;
5063 wiremock::Mock::given(wiremock::matchers::method("GET"))
5064 .and(wiremock::matchers::path("/jwks.json"))
5065 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5066 .mount(&mock_server)
5067 .await;
5068
5069 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5070 let mut config = test_config(&jwks_uri);
5071 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
5072 let cache = test_cache(&config);
5073
5074 let now = jsonwebtoken::get_current_timestamp();
5075 let claims = serde_json::json!({
5076 "iss": "https://auth.test.local",
5077 "aud": "https://some-other-resource.example.com",
5078 "azp": "https://mcp.test.local/mcp",
5079 "sub": "warn-client",
5080 "scope": "mcp:read",
5081 "exp": now + 3600,
5082 "iat": now,
5083 });
5084 let token = mint_token_with_claims(&pem, kid, &claims);
5085
5086 let identity = cache
5087 .validate_token_with_reason(&token)
5088 .await
5089 .expect("warn mode must accept azp-only match");
5090 assert_eq!(identity.role, "viewer");
5091 assert!(
5092 cache.azp_fallback_warned.load(Ordering::Relaxed),
5093 "warn-once flag should be set after first azp-only match"
5094 );
5095
5096 let token2 = mint_token_with_claims(&pem, kid, &claims);
5097 cache
5098 .validate_token_with_reason(&token2)
5099 .await
5100 .expect("warn mode must continue accepting subsequent matches");
5101 assert!(
5102 cache.azp_fallback_warned.load(Ordering::Relaxed),
5103 "warn-once flag must remain set; the assertion guards against accidental clearing"
5104 );
5105 }
5106
5107 #[tokio::test]
5108 async fn permissive_mode_accepts_azp_only_match_silently() {
5109 let kid = "test-audience-permissive-mode";
5110 let (pem, jwks) = generate_test_keypair(kid);
5111
5112 let mock_server = wiremock::MockServer::start().await;
5113 wiremock::Mock::given(wiremock::matchers::method("GET"))
5114 .and(wiremock::matchers::path("/jwks.json"))
5115 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5116 .mount(&mock_server)
5117 .await;
5118
5119 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5120 let mut config = test_config(&jwks_uri);
5121 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5122 let cache = test_cache(&config);
5123
5124 let now = jsonwebtoken::get_current_timestamp();
5125 let token = mint_token_with_claims(
5126 &pem,
5127 kid,
5128 &serde_json::json!({
5129 "iss": "https://auth.test.local",
5130 "aud": "https://some-other-resource.example.com",
5131 "azp": "https://mcp.test.local/mcp",
5132 "sub": "permissive-client",
5133 "scope": "mcp:read",
5134 "exp": now + 3600,
5135 "iat": now,
5136 }),
5137 );
5138
5139 cache
5140 .validate_token_with_reason(&token)
5141 .await
5142 .expect("permissive mode must accept azp-only match");
5143 assert!(
5144 !cache.azp_fallback_warned.load(Ordering::Relaxed),
5145 "permissive mode must not flip the warn-once flag"
5146 );
5147 }
5148
5149 #[test]
5150 fn audience_validation_mode_overrides_legacy_bool() {
5151 let mut config = OAuthConfig::default();
5152 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5153 {
5154 config.strict_audience_validation = Some(false);
5155 }
5156 config.audience_validation_mode = Some(AudienceValidationMode::Strict);
5157 assert_eq!(
5158 config.effective_audience_validation_mode(),
5159 AudienceValidationMode::Strict,
5160 "explicit mode must override legacy false"
5161 );
5162
5163 let mut config = OAuthConfig::default();
5164 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5165 {
5166 config.strict_audience_validation = Some(true);
5167 }
5168 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5169 assert_eq!(
5170 config.effective_audience_validation_mode(),
5171 AudienceValidationMode::Permissive,
5172 "explicit mode must override legacy true"
5173 );
5174 }
5175
5176 #[test]
5177 fn audience_validation_mode_default_is_strict_when_unset() {
5178 let config = OAuthConfig::default();
5179 assert_eq!(
5180 config.effective_audience_validation_mode(),
5181 AudienceValidationMode::Strict,
5182 "unset mode + unset bool must resolve to Strict (the secure default)"
5183 );
5184 }
5185
5186 #[test]
5187 fn audience_validation_legacy_bool_true_resolves_to_strict() {
5188 let mut config = OAuthConfig::default();
5189 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
5190 {
5191 config.strict_audience_validation = Some(true);
5192 }
5193 assert_eq!(
5194 config.effective_audience_validation_mode(),
5195 AudienceValidationMode::Strict,
5196 "legacy bool=true must resolve to Strict for backward compat"
5197 );
5198 }
5199
5200 #[derive(Clone, Default)]
5201 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
5202
5203 impl CapturedLogs {
5204 fn contents(&self) -> String {
5205 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
5206 String::from_utf8(bytes).unwrap_or_default()
5207 }
5208 }
5209
5210 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
5211
5212 impl std::io::Write for CapturedLogsWriter {
5213 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
5214 if let Ok(mut guard) = self.0.lock() {
5215 guard.extend_from_slice(buf);
5216 }
5217 Ok(buf.len())
5218 }
5219
5220 fn flush(&mut self) -> std::io::Result<()> {
5221 Ok(())
5222 }
5223 }
5224
5225 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
5226 type Writer = CapturedLogsWriter;
5227
5228 fn make_writer(&'a self) -> Self::Writer {
5229 CapturedLogsWriter(Arc::clone(&self.0))
5230 }
5231 }
5232
5233 #[tokio::test]
5234 async fn jwks_response_size_cap_returns_none_and_logs_warning() {
5235 let kid = "oversized-jwks";
5236 let (_pem, jwks) = generate_test_keypair(kid);
5237 let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
5238 oversized_body.push_str(&" ".repeat(4096));
5239
5240 let mock_server = wiremock::MockServer::start().await;
5241 wiremock::Mock::given(wiremock::matchers::method("GET"))
5242 .and(wiremock::matchers::path("/jwks.json"))
5243 .respond_with(
5244 wiremock::ResponseTemplate::new(200)
5245 .insert_header("content-type", "application/json")
5246 .set_body_string(oversized_body),
5247 )
5248 .mount(&mock_server)
5249 .await;
5250
5251 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5252 let mut config = test_config(&jwks_uri);
5253 config.jwks_max_response_bytes = 256;
5254 let cache = test_cache(&config);
5255
5256 let logs = CapturedLogs::default();
5257 let subscriber = tracing_subscriber::fmt()
5258 .with_writer(logs.clone())
5259 .with_ansi(false)
5260 .without_time()
5261 .finish();
5262 let _guard = tracing::subscriber::set_default(subscriber);
5263
5264 let result = cache.fetch_jwks().await;
5265 assert!(result.is_none(), "oversized JWKS must be dropped");
5266 assert!(
5267 logs.contents()
5268 .contains("JWKS response exceeded configured size cap"),
5269 "expected cap-exceeded warning in logs"
5270 );
5271 }
5272
5273 #[tokio::test]
5277 async fn redirect_rejection_log_does_not_echo_credentials() {
5278 let mock_server = wiremock::MockServer::start().await;
5279 wiremock::Mock::given(wiremock::matchers::method("GET"))
5280 .and(wiremock::matchers::path("/jwks.json"))
5281 .respond_with(
5282 wiremock::ResponseTemplate::new(302)
5283 .insert_header("location", "https://u:p@redirect-target.example/next"),
5284 )
5285 .mount(&mock_server)
5286 .await;
5287
5288 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5289 let config = test_config(&jwks_uri);
5290 let cache = test_cache(&config);
5291
5292 let logs = CapturedLogs::default();
5293 let subscriber = tracing_subscriber::fmt()
5294 .with_writer(logs.clone())
5295 .with_ansi(false)
5296 .without_time()
5297 .finish();
5298 let _guard = tracing::subscriber::set_default(subscriber);
5299
5300 let result = cache.fetch_jwks().await;
5301 assert!(result.is_none(), "rejected redirect must fail the fetch");
5302 let contents = logs.contents();
5303 assert!(
5304 contents.contains("oauth redirect rejected"),
5305 "expected redirect-rejection warning in logs: {contents}"
5306 );
5307 assert!(
5308 !contents.contains("u:p"),
5309 "rejection log must not echo userinfo credentials: {contents}"
5310 );
5311 }
5312
5313 #[tokio::test]
5314 async fn role_claim_keycloak_nested_array() {
5315 let kid = "test-role-1";
5316 let (pem, jwks) = generate_test_keypair(kid);
5317
5318 let mock_server = wiremock::MockServer::start().await;
5319 wiremock::Mock::given(wiremock::matchers::method("GET"))
5320 .and(wiremock::matchers::path("/jwks.json"))
5321 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5322 .mount(&mock_server)
5323 .await;
5324
5325 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5326 let config = test_config_with_role_claim(
5327 &jwks_uri,
5328 "realm_access.roles",
5329 vec![
5330 RoleMapping {
5331 claim_value: "mcp-admin".into(),
5332 role: "ops".into(),
5333 },
5334 RoleMapping {
5335 claim_value: "mcp-viewer".into(),
5336 role: "viewer".into(),
5337 },
5338 ],
5339 );
5340 let cache = test_cache(&config);
5341
5342 let now = jsonwebtoken::get_current_timestamp();
5343 let token = mint_token_with_claims(
5344 &pem,
5345 kid,
5346 &serde_json::json!({
5347 "iss": "https://auth.test.local",
5348 "aud": "https://mcp.test.local/mcp",
5349 "sub": "keycloak-user",
5350 "exp": now + 3600,
5351 "iat": now,
5352 "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
5353 }),
5354 );
5355
5356 let id = cache
5357 .validate_token(&token)
5358 .await
5359 .expect("should authenticate");
5360 assert_eq!(id.name, "keycloak-user");
5361 assert_eq!(id.role, "ops");
5362 }
5363
5364 #[tokio::test]
5365 async fn role_claim_flat_roles_array() {
5366 let kid = "test-role-2";
5367 let (pem, jwks) = generate_test_keypair(kid);
5368
5369 let mock_server = wiremock::MockServer::start().await;
5370 wiremock::Mock::given(wiremock::matchers::method("GET"))
5371 .and(wiremock::matchers::path("/jwks.json"))
5372 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5373 .mount(&mock_server)
5374 .await;
5375
5376 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5377 let config = test_config_with_role_claim(
5378 &jwks_uri,
5379 "roles",
5380 vec![
5381 RoleMapping {
5382 claim_value: "MCP.Admin".into(),
5383 role: "ops".into(),
5384 },
5385 RoleMapping {
5386 claim_value: "MCP.Reader".into(),
5387 role: "viewer".into(),
5388 },
5389 ],
5390 );
5391 let cache = test_cache(&config);
5392
5393 let now = jsonwebtoken::get_current_timestamp();
5394 let token = mint_token_with_claims(
5395 &pem,
5396 kid,
5397 &serde_json::json!({
5398 "iss": "https://auth.test.local",
5399 "aud": "https://mcp.test.local/mcp",
5400 "sub": "azure-ad-user",
5401 "exp": now + 3600,
5402 "iat": now,
5403 "roles": ["MCP.Reader", "OtherApp.Admin"]
5404 }),
5405 );
5406
5407 let id = cache
5408 .validate_token(&token)
5409 .await
5410 .expect("should authenticate");
5411 assert_eq!(id.name, "azure-ad-user");
5412 assert_eq!(id.role, "viewer");
5413 }
5414
5415 #[tokio::test]
5416 async fn role_claim_no_matching_value_rejected() {
5417 let kid = "test-role-3";
5418 let (pem, jwks) = generate_test_keypair(kid);
5419
5420 let mock_server = wiremock::MockServer::start().await;
5421 wiremock::Mock::given(wiremock::matchers::method("GET"))
5422 .and(wiremock::matchers::path("/jwks.json"))
5423 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5424 .mount(&mock_server)
5425 .await;
5426
5427 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5428 let config = test_config_with_role_claim(
5429 &jwks_uri,
5430 "roles",
5431 vec![RoleMapping {
5432 claim_value: "mcp-admin".into(),
5433 role: "ops".into(),
5434 }],
5435 );
5436 let cache = test_cache(&config);
5437
5438 let now = jsonwebtoken::get_current_timestamp();
5439 let token = mint_token_with_claims(
5440 &pem,
5441 kid,
5442 &serde_json::json!({
5443 "iss": "https://auth.test.local",
5444 "aud": "https://mcp.test.local/mcp",
5445 "sub": "limited-user",
5446 "exp": now + 3600,
5447 "iat": now,
5448 "roles": ["some-other-role"]
5449 }),
5450 );
5451
5452 assert!(cache.validate_token(&token).await.is_none());
5453 }
5454
5455 #[tokio::test]
5456 async fn role_claim_space_separated_string() {
5457 let kid = "test-role-4";
5458 let (pem, jwks) = generate_test_keypair(kid);
5459
5460 let mock_server = wiremock::MockServer::start().await;
5461 wiremock::Mock::given(wiremock::matchers::method("GET"))
5462 .and(wiremock::matchers::path("/jwks.json"))
5463 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5464 .mount(&mock_server)
5465 .await;
5466
5467 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5468 let config = test_config_with_role_claim(
5469 &jwks_uri,
5470 "custom_scope",
5471 vec![
5472 RoleMapping {
5473 claim_value: "write".into(),
5474 role: "ops".into(),
5475 },
5476 RoleMapping {
5477 claim_value: "read".into(),
5478 role: "viewer".into(),
5479 },
5480 ],
5481 );
5482 let cache = test_cache(&config);
5483
5484 let now = jsonwebtoken::get_current_timestamp();
5485 let token = mint_token_with_claims(
5486 &pem,
5487 kid,
5488 &serde_json::json!({
5489 "iss": "https://auth.test.local",
5490 "aud": "https://mcp.test.local/mcp",
5491 "sub": "custom-client",
5492 "exp": now + 3600,
5493 "iat": now,
5494 "custom_scope": "read audit"
5495 }),
5496 );
5497
5498 let id = cache
5499 .validate_token(&token)
5500 .await
5501 .expect("should authenticate");
5502 assert_eq!(id.name, "custom-client");
5503 assert_eq!(id.role, "viewer");
5504 }
5505
5506 #[tokio::test]
5507 async fn scope_backward_compat_without_role_claim() {
5508 let kid = "test-compat-1";
5510 let (pem, jwks) = generate_test_keypair(kid);
5511
5512 let mock_server = wiremock::MockServer::start().await;
5513 wiremock::Mock::given(wiremock::matchers::method("GET"))
5514 .and(wiremock::matchers::path("/jwks.json"))
5515 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5516 .mount(&mock_server)
5517 .await;
5518
5519 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5520 let config = test_config(&jwks_uri); let cache = test_cache(&config);
5522
5523 let token = mint_token(
5524 &pem,
5525 kid,
5526 "https://auth.test.local",
5527 "https://mcp.test.local/mcp",
5528 "legacy-bot",
5529 "mcp:admin other:scope",
5530 );
5531
5532 let id = cache
5533 .validate_token(&token)
5534 .await
5535 .expect("should authenticate");
5536 assert_eq!(id.name, "legacy-bot");
5537 assert_eq!(id.role, "ops"); }
5539
5540 #[tokio::test]
5545 async fn jwks_refresh_deduplication() {
5546 let kid = "test-dedup";
5549 let (pem, jwks) = generate_test_keypair(kid);
5550
5551 let mock_server = wiremock::MockServer::start().await;
5552 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5553 .and(wiremock::matchers::path("/jwks.json"))
5554 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5555 .expect(1) .mount(&mock_server)
5557 .await;
5558
5559 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5560 let config = test_config(&jwks_uri);
5561 let cache = Arc::new(test_cache(&config));
5562
5563 let token = mint_token(
5565 &pem,
5566 kid,
5567 "https://auth.test.local",
5568 "https://mcp.test.local/mcp",
5569 "concurrent-bot",
5570 "mcp:read",
5571 );
5572
5573 let mut handles = Vec::new();
5574 for _ in 0..5 {
5575 let c = Arc::clone(&cache);
5576 let t = token.clone();
5577 handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
5578 }
5579
5580 for h in handles {
5581 let result = h.await.unwrap();
5582 assert!(result.is_some(), "all concurrent requests should succeed");
5583 }
5584
5585 }
5587
5588 #[tokio::test]
5589 async fn jwks_refresh_cooldown_blocks_rapid_requests() {
5590 let kid = "test-cooldown";
5593 let (_pem, jwks) = generate_test_keypair(kid);
5594
5595 let mock_server = wiremock::MockServer::start().await;
5596 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5597 .and(wiremock::matchers::path("/jwks.json"))
5598 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5599 .expect(1) .mount(&mock_server)
5601 .await;
5602
5603 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5604 let config = test_config(&jwks_uri);
5605 let cache = test_cache(&config);
5606
5607 let fake_token1 =
5609 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
5610 let _ = cache.validate_token(fake_token1).await;
5611
5612 let fake_token2 =
5615 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
5616 let _ = cache.validate_token(fake_token2).await;
5617
5618 let fake_token3 =
5620 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
5621 let _ = cache.validate_token(fake_token3).await;
5622
5623 }
5625
5626 fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
5629 OAuthProxyConfig {
5630 authorize_url: "https://example.invalid/auth".into(),
5631 token_url: token_url.into(),
5632 client_id: "mcp-client".into(),
5633 client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
5634 introspection_url: None,
5635 revocation_url: None,
5636 expose_admin_endpoints: false,
5637 require_auth_on_admin_endpoints: false,
5638 allow_unauthenticated_admin_endpoints: false,
5639 }
5640 }
5641
5642 fn test_http_client() -> OauthHttpClient {
5645 rustls::crypto::ring::default_provider()
5646 .install_default()
5647 .ok();
5648 let config = OAuthConfig::builder(
5649 "https://auth.test.local",
5650 "https://mcp.test.local/mcp",
5651 "https://auth.test.local/.well-known/jwks.json",
5652 )
5653 .allow_http_oauth_urls(true)
5654 .build();
5655 OauthHttpClient::with_config(&config)
5656 .expect("build test http client")
5657 .__test_allow_loopback_ssrf()
5658 }
5659
5660 #[tokio::test]
5661 async fn introspect_proxies_and_injects_client_credentials() {
5662 use wiremock::matchers::{body_string_contains, method, path};
5663
5664 let mock_server = wiremock::MockServer::start().await;
5665 wiremock::Mock::given(method("POST"))
5666 .and(path("/introspect"))
5667 .and(body_string_contains("client_id=mcp-client"))
5668 .and(body_string_contains("client_secret=shh"))
5669 .and(body_string_contains("token=abc"))
5670 .respond_with(
5671 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5672 "active": true,
5673 "scope": "read"
5674 })),
5675 )
5676 .expect(1)
5677 .mount(&mock_server)
5678 .await;
5679
5680 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5681 proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
5682
5683 let http = test_http_client();
5684 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5685 assert_eq!(resp.status(), 200);
5686 }
5687
5688 #[tokio::test]
5689 async fn token_proxy_fails_closed_on_oversized_upstream_response() {
5690 use http_body_util::BodyExt as _;
5691 use wiremock::matchers::{method, path};
5692
5693 let oversized = "x"
5695 .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
5696 let mock_server = wiremock::MockServer::start().await;
5697 wiremock::Mock::given(method("POST"))
5698 .and(path("/token"))
5699 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
5700 .expect(1)
5701 .mount(&mock_server)
5702 .await;
5703
5704 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5705 let http = test_http_client();
5706 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5707
5708 assert_eq!(
5710 resp.status(),
5711 502,
5712 "oversized upstream response must fail closed as 502"
5713 );
5714 let body = resp
5715 .into_body()
5716 .collect()
5717 .await
5718 .expect("collect body")
5719 .to_bytes();
5720 assert!(
5721 body.len() < 1024,
5722 "must return the small generic error body, not the oversized upstream body (got {} bytes)",
5723 body.len()
5724 );
5725 assert!(
5726 !body.windows(8).any(|w| w == b"xxxxxxxx"),
5727 "the oversized upstream payload must not be forwarded to the client"
5728 );
5729 }
5730
5731 #[tokio::test]
5732 async fn token_proxy_passes_through_normal_response() {
5733 use http_body_util::BodyExt as _;
5734 use wiremock::matchers::{method, path};
5735
5736 let mock_server = wiremock::MockServer::start().await;
5737 wiremock::Mock::given(method("POST"))
5738 .and(path("/token"))
5739 .respond_with(
5740 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5741 "access_token": "at-123",
5742 "token_type": "Bearer"
5743 })),
5744 )
5745 .expect(1)
5746 .mount(&mock_server)
5747 .await;
5748
5749 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5750 let http = test_http_client();
5751 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5752
5753 assert_eq!(
5754 resp.status(),
5755 200,
5756 "a normal-sized response must pass through"
5757 );
5758 let body = resp
5759 .into_body()
5760 .collect()
5761 .await
5762 .expect("collect body")
5763 .to_bytes();
5764 let json: serde_json::Value =
5765 serde_json::from_slice(&body).expect("upstream JSON preserved");
5766 assert_eq!(json["access_token"], "at-123");
5767 }
5768
5769 #[tokio::test]
5770 async fn introspect_returns_404_when_not_configured() {
5771 let proxy = proxy_cfg("https://example.invalid/token");
5772 let http = test_http_client();
5773 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5774 assert_eq!(resp.status(), 404);
5775 }
5776
5777 #[tokio::test]
5778 async fn revoke_proxies_and_returns_upstream_status() {
5779 use wiremock::matchers::{method, path};
5780
5781 let mock_server = wiremock::MockServer::start().await;
5782 wiremock::Mock::given(method("POST"))
5783 .and(path("/revoke"))
5784 .respond_with(wiremock::ResponseTemplate::new(200))
5785 .expect(1)
5786 .mount(&mock_server)
5787 .await;
5788
5789 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5790 proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
5791
5792 let http = test_http_client();
5793 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5794 assert_eq!(resp.status(), 200);
5795 }
5796
5797 #[tokio::test]
5798 async fn revoke_returns_404_when_not_configured() {
5799 let proxy = proxy_cfg("https://example.invalid/token");
5800 let http = test_http_client();
5801 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5802 assert_eq!(resp.status(), 404);
5803 }
5804
5805 #[test]
5806 fn metadata_advertises_endpoints_only_when_configured() {
5807 let mut cfg = test_config("https://auth.test.local/jwks.json");
5808 let m = authorization_server_metadata("https://mcp.local", &cfg);
5810 assert!(m.get("introspection_endpoint").is_none());
5811 assert!(m.get("revocation_endpoint").is_none());
5812
5813 let mut proxy = proxy_cfg("https://upstream.local/token");
5816 proxy.introspection_url = Some("https://upstream.local/introspect".into());
5817 proxy.revocation_url = Some("https://upstream.local/revoke".into());
5818 cfg.proxy = Some(proxy);
5819 let m = authorization_server_metadata("https://mcp.local", &cfg);
5820 assert!(
5821 m.get("introspection_endpoint").is_none(),
5822 "introspection must not be advertised when expose_admin_endpoints=false"
5823 );
5824 assert!(
5825 m.get("revocation_endpoint").is_none(),
5826 "revocation must not be advertised when expose_admin_endpoints=false"
5827 );
5828
5829 if let Some(p) = cfg.proxy.as_mut() {
5831 p.expose_admin_endpoints = true;
5832 p.revocation_url = None;
5833 }
5834 let m = authorization_server_metadata("https://mcp.local", &cfg);
5835 assert_eq!(
5836 m["introspection_endpoint"],
5837 serde_json::Value::String("https://mcp.local/introspect".into())
5838 );
5839 assert!(m.get("revocation_endpoint").is_none());
5840
5841 if let Some(p) = cfg.proxy.as_mut() {
5843 p.revocation_url = Some("https://upstream.local/revoke".into());
5844 }
5845 let m = authorization_server_metadata("https://mcp.local", &cfg);
5846 assert_eq!(
5847 m["revocation_endpoint"],
5848 serde_json::Value::String("https://mcp.local/revoke".into())
5849 );
5850 }
5851
5852 fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
5855 let mut cfg = validation_https_config();
5856 cfg.token_exchange = Some(tx);
5857 cfg
5858 }
5859
5860 fn tx_with(
5861 client_secret: Option<&str>,
5862 client_cert: Option<ClientCertConfig>,
5863 ) -> TokenExchangeConfig {
5864 TokenExchangeConfig::new(
5865 "https://idp.example.com/token".into(),
5866 "client".into(),
5867 client_secret.map(|s| secrecy::SecretString::new(s.into())),
5868 client_cert,
5869 "downstream".into(),
5870 )
5871 }
5872
5873 #[test]
5874 fn validate_rejects_token_exchange_without_client_auth() {
5875 let cfg = https_cfg_with_tx(tx_with(None, None));
5876 let err = cfg
5877 .validate()
5878 .expect_err("token_exchange without client auth must be rejected");
5879 let msg = err.to_string();
5880 assert!(
5881 msg.contains("requires client authentication"),
5882 "error must explain missing client auth; got {msg:?}"
5883 );
5884 }
5885
5886 #[test]
5887 fn validate_rejects_token_exchange_with_both_secret_and_cert() {
5888 let cc = ClientCertConfig {
5889 cert_path: PathBuf::from("/nonexistent/cert.pem"),
5890 key_path: PathBuf::from("/nonexistent/key.pem"),
5891 };
5892 let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
5893 let err = cfg
5894 .validate()
5895 .expect_err("client_secret + client_cert must be rejected");
5896 let msg = err.to_string();
5897 assert!(
5898 msg.contains("mutually") && msg.contains("exclusive"),
5899 "error must explain mutual exclusion; got {msg:?}"
5900 );
5901 }
5902
5903 #[cfg(not(feature = "oauth-mtls-client"))]
5904 #[test]
5905 fn validate_rejects_client_cert_without_feature() {
5906 let cc = ClientCertConfig {
5907 cert_path: PathBuf::from("/nonexistent/cert.pem"),
5908 key_path: PathBuf::from("/nonexistent/key.pem"),
5909 };
5910 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5911 let err = cfg
5912 .validate()
5913 .expect_err("client_cert without feature must be rejected");
5914 assert!(
5915 err.to_string().contains("oauth-mtls-client"),
5916 "error must reference the cargo feature; got {err}"
5917 );
5918 }
5919
5920 #[cfg(feature = "oauth-mtls-client")]
5921 #[test]
5922 fn validate_rejects_missing_client_cert_files() {
5923 let cc = ClientCertConfig {
5924 cert_path: PathBuf::from("/nonexistent/cert.pem"),
5925 key_path: PathBuf::from("/nonexistent/key.pem"),
5926 };
5927 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5928 let err = cfg
5929 .validate()
5930 .expect_err("missing cert file must be rejected");
5931 assert!(
5932 err.to_string().contains("unreadable"),
5933 "error must call out unreadable file; got {err}"
5934 );
5935 }
5936
5937 #[cfg(feature = "oauth-mtls-client")]
5938 #[test]
5939 fn validate_rejects_malformed_client_cert_pem() {
5940 let dir = std::env::temp_dir();
5941 let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
5942 let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
5943 std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
5944 std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
5945 let cc = ClientCertConfig {
5946 cert_path: cert.clone(),
5947 key_path: key.clone(),
5948 };
5949 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5950 let err = cfg.validate().expect_err("malformed PEM must be rejected");
5951 let _ = std::fs::remove_file(&cert);
5952 let _ = std::fs::remove_file(&key);
5953 assert!(
5954 err.to_string().contains("PEM parse failed"),
5955 "error must call out PEM parse failure; got {err}"
5956 );
5957 }
5958
5959 #[cfg(feature = "oauth-mtls-client")]
5960 fn write_self_signed_pem() -> (PathBuf, PathBuf) {
5961 let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
5962 let dir = std::env::temp_dir();
5963 let pid = std::process::id();
5964 let nonce: u64 = rand::random();
5965 let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
5966 let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
5967 std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
5968 std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
5969 (cert_path, key_path)
5970 }
5971
5972 #[cfg(feature = "oauth-mtls-client")]
5973 fn install_test_crypto_provider() {
5974 let _ = rustls::crypto::ring::default_provider().install_default();
5975 }
5976
5977 #[cfg(feature = "oauth-mtls-client")]
5978 #[test]
5979 fn validate_accepts_well_formed_client_cert() {
5980 install_test_crypto_provider();
5981 let (cert_path, key_path) = write_self_signed_pem();
5982 let cc = ClientCertConfig {
5983 cert_path: cert_path.clone(),
5984 key_path: key_path.clone(),
5985 };
5986 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5987 let res = cfg.validate();
5988 let _ = std::fs::remove_file(&cert_path);
5989 let _ = std::fs::remove_file(&key_path);
5990 res.expect("well-formed cert+key must validate");
5991 }
5992
5993 #[cfg(feature = "oauth-mtls-client")]
5994 #[test]
5995 fn client_for_returns_cached_mtls_client() {
5996 install_test_crypto_provider();
5997 let (cert_path, key_path) = write_self_signed_pem();
5998 let cc = ClientCertConfig {
5999 cert_path: cert_path.clone(),
6000 key_path: key_path.clone(),
6001 };
6002 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6003 let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
6004 let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
6005 let cert_client = http.client_for(tx_ref);
6006 let inner_client = http.client_for(&tx_with(Some("s"), None));
6007 let _ = std::fs::remove_file(&cert_path);
6008 let _ = std::fs::remove_file(&key_path);
6009 assert!(
6010 !std::ptr::eq(cert_client, inner_client),
6011 "client_for must return distinct clients for cert vs no-cert configs"
6012 );
6013 }
6014
6015 #[cfg(feature = "oauth-mtls-client")]
6016 #[test]
6017 fn client_for_falls_back_to_inner_when_cache_miss() {
6018 install_test_crypto_provider();
6019 let cfg = validation_https_config();
6020 let http = OauthHttpClient::with_config(&cfg).expect("build client");
6021 let unrelated_cc = ClientCertConfig {
6022 cert_path: PathBuf::from("/cache/miss/cert.pem"),
6023 key_path: PathBuf::from("/cache/miss/key.pem"),
6024 };
6025 let tx_unknown = tx_with(None, Some(unrelated_cc));
6026 let fallback = http.client_for(&tx_unknown);
6027 let inner = http.client_for(&tx_with(Some("s"), None));
6028 assert!(
6029 std::ptr::eq(fallback, inner),
6030 "cache miss must fall back to inner client"
6031 );
6032 }
6033}