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::RmcpServerKitError> {
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::RmcpServerKitError::Config(format!(
144 "OAuth target forbidden ({reason}): {url}"
145 )));
146 }
147
148 let host = parsed.host_str().ok_or_else(|| {
149 crate::error::RmcpServerKitError::Config(format!("OAuth target URL has no host: {url}"))
150 })?;
151 if oauth_internal_suffix_blocked(host, allowlist) {
152 return Err(crate::error::RmcpServerKitError::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::RmcpServerKitError::Config(format!(
158 "OAuth target URL has no known port: {url}"
159 ))
160 })?;
161
162 let addrs = lookup_host((host, port)).await.map_err(|error| {
163 crate::error::RmcpServerKitError::Config(format!(
164 "OAuth target DNS resolution {url}: {error}"
165 ))
166 })?;
167
168 let host_allowed = !allowlist.is_empty() && allowlist.host_allowed(host);
169 let mut any_addr = false;
170 for addr in addrs {
171 any_addr = true;
172 let ip = addr.ip();
173 if let Some(reason) = crate::ssrf::ip_block_reason(ip) {
174 if reason == "cloud_metadata" {
177 return Err(crate::error::RmcpServerKitError::Config(format!(
178 "OAuth target resolved to blocked IP ({reason}): {url}"
179 )));
180 }
181 if allowlist.is_empty() {
185 return Err(crate::error::RmcpServerKitError::Config(format!(
186 "OAuth target resolved to blocked IP ({reason}): {url}"
187 )));
188 }
189 if host_allowed || allowlist.ip_allowed(ip) {
191 continue;
192 }
193 return Err(crate::error::RmcpServerKitError::Config(format!(
194 "OAuth target blocked: hostname {host} resolved to {ip} ({reason}). \
195 To allow, add the hostname to oauth.ssrf_allowlist.hosts or the CIDR \
196 to oauth.ssrf_allowlist.cidrs (operators only -- see SECURITY.md). \
197 URL: {url}"
198 )));
199 }
200 }
201 if !any_addr {
202 return Err(crate::error::RmcpServerKitError::Config(format!(
203 "OAuth target DNS resolution returned no addresses: {url}"
204 )));
205 }
206
207 Ok(())
208}
209
210async fn screen_oauth_target(
213 url: &str,
214 allow_http: bool,
215 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
216) -> Result<(), crate::error::RmcpServerKitError> {
217 screen_oauth_target_core(url, allow_http, allowlist, false).await
218}
219
220#[cfg(any(test, feature = "test-helpers"))]
224async fn screen_oauth_target_with_test_override(
225 url: &str,
226 allow_http: bool,
227 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
228 test_allow_loopback_ssrf: bool,
229) -> Result<(), crate::error::RmcpServerKitError> {
230 screen_oauth_target_core(url, allow_http, allowlist, test_allow_loopback_ssrf).await
231}
232
233#[derive(Clone)]
274pub struct OauthHttpClient {
275 #[cfg(any(test, feature = "test-helpers"))]
283 inner: reqwest::Client,
284 credential_client: reqwest::Client,
291 allow_http: bool,
292 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
297 #[cfg(feature = "oauth-mtls-client")]
302 mtls_clients: Arc<HashMap<MtlsClientKey, reqwest::Client>>,
303 #[cfg(any(test, feature = "test-helpers"))]
309 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
310}
311
312#[cfg(feature = "oauth-mtls-client")]
316#[derive(Debug, Clone, Hash, Eq, PartialEq)]
317struct MtlsClientKey {
318 cert_path: PathBuf,
319 key_path: PathBuf,
320}
321
322impl OauthHttpClient {
323 pub fn with_config(config: &OAuthConfig) -> Result<Self, crate::error::RmcpServerKitError> {
341 Self::build(Some(config))
342 }
343
344 #[deprecated(
367 since = "1.2.1",
368 note = "use OauthHttpClient::with_config(&OAuthConfig) so token/introspect/revoke/exchange traffic inherits ca_cert_path and the allow_http_oauth_urls toggle"
369 )]
370 pub fn new() -> Result<Self, crate::error::RmcpServerKitError> {
371 Self::build(None)
372 }
373
374 fn build(config: Option<&OAuthConfig>) -> Result<Self, crate::error::RmcpServerKitError> {
377 rustls::crypto::ring::default_provider()
384 .install_default()
385 .ok();
386
387 let allow_http = config.is_some_and(|c| c.allow_http_oauth_urls);
388
389 let allowlist = match config.and_then(|c| c.ssrf_allowlist.as_ref()) {
394 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
395 crate::error::RmcpServerKitError::Startup(format!("oauth http client: {e}"))
396 })?),
397 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
398 };
399
400 #[cfg(any(test, feature = "test-helpers"))]
405 let redirect_allowlist = Arc::clone(&allowlist);
406
407 #[cfg(any(test, feature = "test-helpers"))]
411 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
412 Arc::new(AtomicBool::new(false));
413 #[cfg(not(any(test, feature = "test-helpers")))]
414 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
415
416 #[allow(
421 clippy::clone_on_ref_ptr,
422 clippy::clone_on_copy,
423 clippy::unit_arg,
424 reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
425 )]
426 let resolver: Arc<dyn reqwest::dns::Resolve> =
427 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
428 Arc::clone(&allowlist),
429 test_bypass.clone(),
430 ));
431
432 let ca_pem: Option<Vec<u8>> = if let Some(cfg) = config
436 && let Some(ref ca_path) = cfg.ca_cert_path
437 {
438 Some(std::fs::read(ca_path).map_err(|e| {
439 crate::error::RmcpServerKitError::Startup(format!(
440 "oauth http client: read ca_cert_path {}: {e}",
441 ca_path.display()
442 ))
443 })?)
444 } else {
445 None
446 };
447
448 let make_base = || -> Result<reqwest::ClientBuilder, crate::error::RmcpServerKitError> {
452 let mut b = reqwest::Client::builder()
453 .no_proxy()
454 .dns_resolver(Arc::clone(&resolver))
455 .connect_timeout(Duration::from_secs(10))
456 .timeout(Duration::from_secs(30));
457 if let Some(ref pem) = ca_pem {
458 let cert = reqwest::tls::Certificate::from_pem(pem).map_err(|e| {
459 crate::error::RmcpServerKitError::Startup(format!(
460 "oauth http client: parse ca_cert_path: {e}"
461 ))
462 })?;
463 b = b.add_root_certificate(cert);
464 }
465 Ok(b)
466 };
467
468 #[cfg(any(test, feature = "test-helpers"))]
475 let inner =
476 make_base()?
477 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
478 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
479 Ok(()) => attempt.follow(),
480 Err(reason) => {
481 tracing::warn!(
482 reason = %reason,
483 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
484 "oauth redirect rejected"
485 );
486 attempt.error(reason)
487 }
488 }
489 }))
490 .build()
491 .map_err(|e| {
492 crate::error::RmcpServerKitError::Startup(format!(
493 "oauth http client init: {e}"
494 ))
495 })?;
496
497 let credential_client = make_base()?
510 .redirect(reqwest::redirect::Policy::none())
511 .build()
512 .map_err(|e| {
513 crate::error::RmcpServerKitError::Startup(format!("oauth http client init: {e}"))
514 })?;
515
516 #[cfg(feature = "oauth-mtls-client")]
517 let mtls_clients = build_mtls_clients(config, &allowlist, &test_bypass)?;
518
519 Ok(Self {
520 #[cfg(any(test, feature = "test-helpers"))]
521 inner,
522 credential_client,
523 allow_http,
524 allowlist,
525 #[cfg(feature = "oauth-mtls-client")]
526 mtls_clients,
527 #[cfg(any(test, feature = "test-helpers"))]
528 test_allow_loopback_ssrf: test_bypass,
529 })
530 }
531
532 async fn send_screened(
533 &self,
534 url: &str,
535 request: reqwest::RequestBuilder,
536 ) -> Result<reqwest::Response, crate::error::RmcpServerKitError> {
537 #[cfg(any(test, feature = "test-helpers"))]
538 if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
539 screen_oauth_target_with_test_override(url, self.allow_http, &self.allowlist, true)
540 .await?;
541 } else {
542 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
543 }
544 #[cfg(not(any(test, feature = "test-helpers")))]
545 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
546 request.send().await.map_err(|error| {
547 crate::error::RmcpServerKitError::Config(format!("oauth request {url}: {error}"))
548 })
549 }
550
551 #[cfg(any(test, feature = "test-helpers"))]
556 #[doc(hidden)]
557 #[must_use]
558 pub fn __test_allow_loopback_ssrf(self) -> Self {
559 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
562 self
563 }
564
565 #[cfg(any(test, feature = "test-helpers"))]
571 #[doc(hidden)]
572 pub async fn __test_get(&self, url: &str) -> reqwest::Result<reqwest::Response> {
573 self.inner.get(url).send().await
574 }
575
576 #[cfg(any(test, feature = "test-helpers"))]
582 #[doc(hidden)]
583 #[must_use]
584 pub fn __test_inner_client(&self) -> &reqwest::Client {
585 &self.inner
586 }
587
588 #[cfg(feature = "oauth-mtls-client")]
595 fn client_for(&self, cfg: &TokenExchangeConfig) -> &reqwest::Client {
596 if let Some(cc) = &cfg.client_cert {
597 let key = MtlsClientKey {
598 cert_path: cc.cert_path.clone(),
599 key_path: cc.key_path.clone(),
600 };
601 if let Some(client) = self.mtls_clients.get(&key) {
602 return client;
603 }
604 }
605 &self.credential_client
606 }
607
608 #[cfg(not(feature = "oauth-mtls-client"))]
609 fn client_for(&self, _cfg: &TokenExchangeConfig) -> &reqwest::Client {
610 &self.credential_client
611 }
612}
613
614impl std::fmt::Debug for OauthHttpClient {
615 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
616 f.debug_struct("OauthHttpClient").finish_non_exhaustive()
617 }
618}
619
620#[derive(Debug, Clone, Default, Deserialize)]
683#[non_exhaustive]
684pub struct OAuthSsrfAllowlist {
685 #[serde(default)]
690 pub hosts: Vec<String>,
691 #[serde(default)]
697 pub cidrs: Vec<String>,
698}
699
700fn compile_oauth_ssrf_allowlist(
707 raw: &OAuthSsrfAllowlist,
708) -> Result<crate::ssrf::CompiledSsrfAllowlist, String> {
709 let mut hosts: Vec<String> = Vec::with_capacity(raw.hosts.len());
710 for (idx, entry) in raw.hosts.iter().enumerate() {
711 let trimmed = entry.trim();
712 if trimmed.is_empty() {
713 return Err(format!("oauth.ssrf_allowlist.hosts[{idx}]: empty entry"));
714 }
715 if trimmed.contains([':', '/', '@', '?', '#']) {
719 return Err(format!(
720 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: must be a bare DNS hostname \
721 (no scheme, port, path, userinfo, query, or fragment)"
722 ));
723 }
724 match url::Host::parse(trimmed) {
725 Ok(url::Host::Domain(_)) => {}
726 Ok(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
727 return Err(format!(
728 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: literal IPs are forbidden \
729 here -- list them via oauth.ssrf_allowlist.cidrs instead"
730 ));
731 }
732 Err(e) => {
733 return Err(format!(
734 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: invalid hostname: {e}"
735 ));
736 }
737 }
738 hosts.push(trimmed.to_ascii_lowercase());
739 }
740 hosts.sort();
741 hosts.dedup();
742
743 let mut cidrs = Vec::with_capacity(raw.cidrs.len());
744 for (idx, entry) in raw.cidrs.iter().enumerate() {
745 let parsed = crate::ssrf::CidrEntry::parse(entry)
746 .map_err(|e| format!("oauth.ssrf_allowlist.cidrs[{idx}]: {e}"))?;
747 cidrs.push(parsed);
748 }
749
750 Ok(crate::ssrf::CompiledSsrfAllowlist::new(hosts, cidrs))
751}
752
753#[derive(Debug, Clone, Deserialize)]
755#[non_exhaustive]
756pub struct OAuthConfig {
757 #[serde(default)]
766 pub issuer: String,
767 #[serde(default)]
773 pub audience: String,
774 #[serde(default)]
779 pub jwks_uri: String,
780 #[serde(default)]
783 pub scopes: Vec<ScopeMapping>,
784 pub role_claim: Option<String>,
790 #[serde(default)]
793 pub role_mappings: Vec<RoleMapping>,
794 #[serde(default = "default_jwks_cache_ttl")]
797 pub jwks_cache_ttl: String,
798 pub proxy: Option<OAuthProxyConfig>,
802 pub token_exchange: Option<TokenExchangeConfig>,
807 #[serde(default)]
822 pub ca_cert_path: Option<PathBuf>,
823 #[serde(default)]
835 pub allow_http_oauth_urls: bool,
836 #[serde(default)]
845 pub ssrf_allowlist: Option<OAuthSsrfAllowlist>,
846 #[serde(default = "default_max_jwks_keys")]
850 pub max_jwks_keys: usize,
851 #[serde(default)]
856 pub require_subject: bool,
857 #[serde(default)]
866 #[deprecated(
867 since = "1.7.0",
868 note = "use `audience_validation_mode` instead; this field is consulted only when `audience_validation_mode` is None"
869 )]
870 pub strict_audience_validation: Option<bool>,
871 #[serde(default)]
880 pub audience_validation_mode: Option<AudienceValidationMode>,
881 #[serde(default = "default_jwks_max_bytes")]
885 pub jwks_max_response_bytes: u64,
886}
887
888fn default_jwks_cache_ttl() -> String {
889 "10m".into()
890}
891
892const fn default_max_jwks_keys() -> usize {
893 256
894}
895
896const fn default_jwks_max_bytes() -> u64 {
897 1024 * 1024
898}
899
900#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
917#[serde(rename_all = "snake_case")]
918#[non_exhaustive]
919pub enum AudienceValidationMode {
920 Permissive,
924 Warn,
927 #[default]
931 Strict,
932}
933
934impl AudienceValidationMode {
935 #[must_use]
940 pub(crate) const fn as_str(self) -> &'static str {
941 match self {
942 Self::Permissive => "permissive",
943 Self::Warn => "warn",
944 Self::Strict => "strict",
945 }
946 }
947}
948
949impl Default for OAuthConfig {
950 fn default() -> Self {
951 Self {
952 issuer: String::new(),
953 audience: String::new(),
954 jwks_uri: String::new(),
955 scopes: Vec::new(),
956 role_claim: None,
957 role_mappings: Vec::new(),
958 jwks_cache_ttl: default_jwks_cache_ttl(),
959 proxy: None,
960 token_exchange: None,
961 ca_cert_path: None,
962 allow_http_oauth_urls: false,
963 max_jwks_keys: default_max_jwks_keys(),
964 require_subject: false,
965 #[allow(
966 deprecated,
967 reason = "default-construct deprecated field for backward compat"
968 )]
969 strict_audience_validation: None,
970 audience_validation_mode: None,
971 jwks_max_response_bytes: default_jwks_max_bytes(),
972 ssrf_allowlist: None,
973 }
974 }
975}
976
977impl OAuthConfig {
978 #[must_use]
985 pub fn effective_audience_validation_mode(&self) -> AudienceValidationMode {
986 if let Some(mode) = self.audience_validation_mode {
987 return mode;
988 }
989 #[allow(deprecated, reason = "intentional: legacy flag resolution path")]
990 match self.strict_audience_validation {
991 Some(true) | None => AudienceValidationMode::Strict,
992 Some(false) => AudienceValidationMode::Warn,
993 }
994 }
995
996 pub fn builder(
1002 issuer: impl Into<String>,
1003 audience: impl Into<String>,
1004 jwks_uri: impl Into<String>,
1005 ) -> OAuthConfigBuilder {
1006 OAuthConfigBuilder {
1007 inner: Self {
1008 issuer: issuer.into(),
1009 audience: audience.into(),
1010 jwks_uri: jwks_uri.into(),
1011 ..Self::default()
1012 },
1013 }
1014 }
1015
1016 pub fn validate(&self) -> Result<(), crate::error::RmcpServerKitError> {
1032 let allow_http = self.allow_http_oauth_urls;
1033 let url = check_oauth_url("oauth.issuer", &self.issuer, allow_http)?;
1034 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1035 return Err(crate::error::RmcpServerKitError::Config(format!(
1036 "oauth.issuer forbidden ({reason})"
1037 )));
1038 }
1039 let url = check_oauth_url("oauth.jwks_uri", &self.jwks_uri, allow_http)?;
1040 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1041 return Err(crate::error::RmcpServerKitError::Config(format!(
1042 "oauth.jwks_uri forbidden ({reason})"
1043 )));
1044 }
1045 if self.audience.is_empty() {
1050 return Err(crate::error::RmcpServerKitError::Config(
1051 "oauth.audience must not be empty".into(),
1052 ));
1053 }
1054 if let Some(proxy) = &self.proxy {
1055 let url = check_oauth_url(
1056 "oauth.proxy.authorize_url",
1057 &proxy.authorize_url,
1058 allow_http,
1059 )?;
1060 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1061 return Err(crate::error::RmcpServerKitError::Config(format!(
1062 "oauth.proxy.authorize_url forbidden ({reason})"
1063 )));
1064 }
1065 let url = check_oauth_url("oauth.proxy.token_url", &proxy.token_url, allow_http)?;
1066 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1067 return Err(crate::error::RmcpServerKitError::Config(format!(
1068 "oauth.proxy.token_url forbidden ({reason})"
1069 )));
1070 }
1071 if let Some(url) = &proxy.introspection_url {
1072 let parsed = check_oauth_url("oauth.proxy.introspection_url", url, allow_http)?;
1073 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1074 return Err(crate::error::RmcpServerKitError::Config(format!(
1075 "oauth.proxy.introspection_url forbidden ({reason})"
1076 )));
1077 }
1078 }
1079 if let Some(url) = &proxy.revocation_url {
1080 let parsed = check_oauth_url("oauth.proxy.revocation_url", url, allow_http)?;
1081 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1082 return Err(crate::error::RmcpServerKitError::Config(format!(
1083 "oauth.proxy.revocation_url forbidden ({reason})"
1084 )));
1085 }
1086 }
1087 if proxy.expose_admin_endpoints
1094 && !proxy.require_auth_on_admin_endpoints
1095 && !proxy.allow_unauthenticated_admin_endpoints
1096 {
1097 return Err(crate::error::RmcpServerKitError::Config(
1098 "oauth.proxy: expose_admin_endpoints = true requires \
1099 require_auth_on_admin_endpoints = true (recommended) \
1100 or allow_unauthenticated_admin_endpoints = true \
1101 (explicit opt-out, only safe behind an authenticated \
1102 reverse proxy)"
1103 .into(),
1104 ));
1105 }
1106 }
1107 if let Some(tx) = &self.token_exchange {
1108 let url = check_oauth_url("oauth.token_exchange.token_url", &tx.token_url, allow_http)?;
1109 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1110 return Err(crate::error::RmcpServerKitError::Config(format!(
1111 "oauth.token_exchange.token_url forbidden ({reason})"
1112 )));
1113 }
1114 validate_token_exchange_client_auth(tx)?;
1117 }
1118 if let Some(raw) = &self.ssrf_allowlist {
1122 let compiled = compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1123 crate::error::RmcpServerKitError::Config(format!("oauth.ssrf_allowlist: {e}"))
1124 })?;
1125 if !compiled.is_empty() {
1126 tracing::warn!(
1127 host_count = compiled.host_count(),
1128 cidr_count = compiled.cidr_count(),
1129 "oauth.ssrf_allowlist is configured: private/loopback OAuth/JWKS targets \
1130 are now reachable. Cloud-metadata addresses remain blocked. \
1131 See SECURITY.md \"Operator allowlist\"."
1132 );
1133 }
1134 }
1135 humantime::parse_duration(&self.jwks_cache_ttl).map_err(|e| {
1138 crate::error::RmcpServerKitError::Config(format!(
1139 "oauth.jwks_cache_ttl {:?} is not a valid humantime duration (e.g. \"10m\", \"1h30m\"): {e}",
1140 self.jwks_cache_ttl
1141 ))
1142 })?;
1143 Ok(())
1144 }
1145}
1146
1147fn validate_token_exchange_client_auth(
1153 tx: &TokenExchangeConfig,
1154) -> Result<(), crate::error::RmcpServerKitError> {
1155 match (&tx.client_cert, tx.client_secret.is_some()) {
1156 (Some(_), true) => Err(crate::error::RmcpServerKitError::Config(
1157 "oauth.token_exchange: client_cert and client_secret are mutually \
1158 exclusive (RFC 8705 ยง2). Set exactly one."
1159 .into(),
1160 )),
1161 (None, false) => Err(crate::error::RmcpServerKitError::Config(
1162 "oauth.token_exchange: token exchange requires client authentication. \
1163 Set either client_secret (RFC 6749 ยง2.3.1) or client_cert (RFC 8705 ยง2)."
1164 .into(),
1165 )),
1166 (Some(cc), false) => validate_client_cert_config(cc),
1167 (None, true) => Ok(()),
1168 }
1169}
1170
1171fn validate_client_cert_config(
1184 cc: &ClientCertConfig,
1185) -> Result<(), crate::error::RmcpServerKitError> {
1186 #[cfg(not(feature = "oauth-mtls-client"))]
1187 {
1188 let _ = cc;
1189 Err(crate::error::RmcpServerKitError::Config(
1190 "oauth.token_exchange.client_cert requires the `oauth-mtls-client` cargo feature; \
1191 rebuild rmcp-server-kit with --features oauth-mtls-client (or have your \
1192 application crate enable it via `rmcp-server-kit/oauth-mtls-client`), or remove \
1193 the field"
1194 .into(),
1195 ))
1196 }
1197 #[cfg(feature = "oauth-mtls-client")]
1198 {
1199 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1200 tracing::warn!(error = %e, path = %cc.cert_path.display(), "client cert read failed");
1201 crate::error::RmcpServerKitError::Config(format!(
1202 "oauth.token_exchange.client_cert.cert_path unreadable: {}",
1203 cc.cert_path.display()
1204 ))
1205 })?;
1206 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1207 tracing::warn!(error = %e, path = %cc.key_path.display(), "client cert key read failed");
1208 crate::error::RmcpServerKitError::Config(format!(
1209 "oauth.token_exchange.client_cert.key_path unreadable: {}",
1210 cc.key_path.display()
1211 ))
1212 })?;
1213 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1214 combined.extend_from_slice(&cert_bytes);
1215 if !cert_bytes.ends_with(b"\n") {
1216 combined.push(b'\n');
1217 }
1218 combined.extend_from_slice(&key_bytes);
1219 let _identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1220 tracing::warn!(
1221 error = %e,
1222 cert_path = %cc.cert_path.display(),
1223 key_path = %cc.key_path.display(),
1224 "client cert PEM parse failed"
1225 );
1226 crate::error::RmcpServerKitError::Config(format!(
1227 "oauth.token_exchange.client_cert: PEM parse failed (cert={}, key={})",
1228 cc.cert_path.display(),
1229 cc.key_path.display()
1230 ))
1231 })?;
1232 Ok(())
1233 }
1234}
1235
1236#[cfg(feature = "oauth-mtls-client")]
1244fn build_mtls_clients(
1245 config: Option<&OAuthConfig>,
1246 allowlist: &Arc<crate::ssrf::CompiledSsrfAllowlist>,
1247 test_bypass: &crate::ssrf_resolver::TestLoopbackBypass,
1248) -> Result<Arc<HashMap<MtlsClientKey, reqwest::Client>>, crate::error::RmcpServerKitError> {
1249 let mut map: HashMap<MtlsClientKey, reqwest::Client> = HashMap::new();
1250 let Some(cfg) = config else {
1251 return Ok(Arc::new(map));
1252 };
1253 let Some(tx) = &cfg.token_exchange else {
1254 return Ok(Arc::new(map));
1255 };
1256 let Some(cc) = &tx.client_cert else {
1257 return Ok(Arc::new(map));
1258 };
1259
1260 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1261 crate::error::RmcpServerKitError::Startup(format!(
1262 "oauth http client mTLS: read cert_path {}: {e}",
1263 cc.cert_path.display()
1264 ))
1265 })?;
1266 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1267 crate::error::RmcpServerKitError::Startup(format!(
1268 "oauth http client mTLS: read key_path {}: {e}",
1269 cc.key_path.display()
1270 ))
1271 })?;
1272 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1273 combined.extend_from_slice(&cert_bytes);
1274 if !cert_bytes.ends_with(b"\n") {
1275 combined.push(b'\n');
1276 }
1277 combined.extend_from_slice(&key_bytes);
1278 let identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1279 crate::error::RmcpServerKitError::Startup(format!(
1280 "oauth http client mTLS: PEM parse (cert={}, key={}): {e}",
1281 cc.cert_path.display(),
1282 cc.key_path.display()
1283 ))
1284 })?;
1285
1286 let resolver: Arc<dyn reqwest::dns::Resolve> =
1287 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1288 Arc::clone(allowlist),
1289 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1294 test_bypass.clone(),
1295 ));
1296
1297 let mut builder = reqwest::Client::builder()
1298 .no_proxy()
1300 .dns_resolver(Arc::clone(&resolver))
1301 .connect_timeout(Duration::from_secs(10))
1302 .timeout(Duration::from_secs(30))
1303 .redirect(reqwest::redirect::Policy::none())
1304 .identity(identity);
1305
1306 if let Some(ref ca_path) = cfg.ca_cert_path {
1307 let pem = std::fs::read(ca_path).map_err(|e| {
1308 crate::error::RmcpServerKitError::Startup(format!(
1309 "oauth http client mTLS: read ca_cert_path {}: {e}",
1310 ca_path.display()
1311 ))
1312 })?;
1313 let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
1314 crate::error::RmcpServerKitError::Startup(format!(
1315 "oauth http client mTLS: parse ca_cert_path {}: {e}",
1316 ca_path.display()
1317 ))
1318 })?;
1319 builder = builder.add_root_certificate(cert);
1320 }
1321
1322 let client = builder.build().map_err(|e| {
1323 crate::error::RmcpServerKitError::Startup(format!("oauth http client mTLS init: {e}"))
1324 })?;
1325 map.insert(
1326 MtlsClientKey {
1327 cert_path: cc.cert_path.clone(),
1328 key_path: cc.key_path.clone(),
1329 },
1330 client,
1331 );
1332 Ok(Arc::new(map))
1333}
1334
1335fn check_oauth_url(
1342 field: &str,
1343 raw: &str,
1344 allow_http: bool,
1345) -> Result<url::Url, crate::error::RmcpServerKitError> {
1346 let parsed = url::Url::parse(raw).map_err(|e| {
1347 crate::error::RmcpServerKitError::Config(format!("{field}: invalid URL {raw:?}: {e}"))
1348 })?;
1349 if !parsed.username().is_empty() || parsed.password().is_some() {
1350 return Err(crate::error::RmcpServerKitError::Config(format!(
1351 "{field} rejected: URL contains userinfo (credentials in URL are forbidden)"
1352 )));
1353 }
1354 match parsed.scheme() {
1355 "https" => Ok(parsed),
1356 "http" if allow_http => Ok(parsed),
1357 "http" => Err(crate::error::RmcpServerKitError::Config(format!(
1358 "{field}: must use https scheme (got http; set allow_http_oauth_urls=true \
1359 to override - strongly discouraged in production)"
1360 ))),
1361 other => Err(crate::error::RmcpServerKitError::Config(format!(
1362 "{field}: must use https scheme (got {other:?})"
1363 ))),
1364 }
1365}
1366
1367#[derive(Debug, Clone)]
1373#[must_use = "builders do nothing until `.build()` is called"]
1374pub struct OAuthConfigBuilder {
1375 inner: OAuthConfig,
1376}
1377
1378impl OAuthConfigBuilder {
1379 pub fn scopes(mut self, scopes: Vec<ScopeMapping>) -> Self {
1381 self.inner.scopes = scopes;
1382 self
1383 }
1384
1385 pub fn scope(mut self, scope: impl Into<String>, role: impl Into<String>) -> Self {
1387 self.inner.scopes.push(ScopeMapping {
1388 scope: scope.into(),
1389 role: role.into(),
1390 });
1391 self
1392 }
1393
1394 pub fn role_claim(mut self, claim: impl Into<String>) -> Self {
1397 self.inner.role_claim = Some(claim.into());
1398 self
1399 }
1400
1401 pub fn role_mappings(mut self, mappings: Vec<RoleMapping>) -> Self {
1403 self.inner.role_mappings = mappings;
1404 self
1405 }
1406
1407 pub fn role_mapping(mut self, claim_value: impl Into<String>, role: impl Into<String>) -> Self {
1410 self.inner.role_mappings.push(RoleMapping {
1411 claim_value: claim_value.into(),
1412 role: role.into(),
1413 });
1414 self
1415 }
1416
1417 pub fn jwks_cache_ttl(mut self, ttl: impl Into<String>) -> Self {
1420 self.inner.jwks_cache_ttl = ttl.into();
1421 self
1422 }
1423
1424 pub fn proxy(mut self, proxy: OAuthProxyConfig) -> Self {
1427 self.inner.proxy = Some(proxy);
1428 self
1429 }
1430
1431 pub fn token_exchange(mut self, token_exchange: TokenExchangeConfig) -> Self {
1433 self.inner.token_exchange = Some(token_exchange);
1434 self
1435 }
1436
1437 pub fn ca_cert_path(mut self, path: impl Into<PathBuf>) -> Self {
1442 self.inner.ca_cert_path = Some(path.into());
1443 self
1444 }
1445
1446 pub const fn allow_http_oauth_urls(mut self, allow: bool) -> Self {
1452 self.inner.allow_http_oauth_urls = allow;
1453 self
1454 }
1455
1456 #[deprecated(since = "1.7.0", note = "use `audience_validation_mode` instead")]
1465 pub const fn strict_audience_validation(mut self, strict: bool) -> Self {
1466 #[allow(
1467 deprecated,
1468 reason = "intentional: deprecated builder forwards to deprecated field"
1469 )]
1470 {
1471 self.inner.strict_audience_validation = Some(strict);
1472 }
1473 self.inner.audience_validation_mode = None;
1474 self
1475 }
1476
1477 pub const fn audience_validation_mode(mut self, mode: AudienceValidationMode) -> Self {
1485 self.inner.audience_validation_mode = Some(mode);
1486 self
1487 }
1488
1489 pub const fn require_subject(mut self, require: bool) -> Self {
1495 self.inner.require_subject = require;
1496 self
1497 }
1498
1499 pub const fn jwks_max_response_bytes(mut self, bytes: u64) -> Self {
1501 self.inner.jwks_max_response_bytes = bytes;
1502 self
1503 }
1504
1505 pub fn ssrf_allowlist(mut self, allowlist: OAuthSsrfAllowlist) -> Self {
1513 self.inner.ssrf_allowlist = Some(allowlist);
1514 self
1515 }
1516
1517 #[must_use]
1519 pub fn build(self) -> OAuthConfig {
1520 self.inner
1521 }
1522}
1523
1524#[derive(Debug, Clone, Deserialize)]
1526#[non_exhaustive]
1527pub struct ScopeMapping {
1528 pub scope: String,
1530 pub role: String,
1532}
1533
1534#[derive(Debug, Clone, Deserialize)]
1538#[non_exhaustive]
1539pub struct RoleMapping {
1540 pub claim_value: String,
1542 pub role: String,
1544}
1545
1546#[derive(Debug, Clone, Deserialize)]
1553#[non_exhaustive]
1554pub struct TokenExchangeConfig {
1555 pub token_url: String,
1558 pub client_id: String,
1560 pub client_secret: Option<secrecy::SecretString>,
1565 pub client_cert: Option<ClientCertConfig>,
1578 pub audience: String,
1582}
1583
1584impl TokenExchangeConfig {
1585 #[must_use]
1587 pub fn new(
1588 token_url: String,
1589 client_id: String,
1590 client_secret: Option<secrecy::SecretString>,
1591 client_cert: Option<ClientCertConfig>,
1592 audience: String,
1593 ) -> Self {
1594 Self {
1595 token_url,
1596 client_id,
1597 client_secret,
1598 client_cert,
1599 audience,
1600 }
1601 }
1602}
1603
1604#[derive(Debug, Clone, Deserialize)]
1608#[non_exhaustive]
1609pub struct ClientCertConfig {
1610 pub cert_path: PathBuf,
1613 pub key_path: PathBuf,
1617}
1618
1619impl ClientCertConfig {
1620 #[must_use]
1624 pub fn new(cert_path: PathBuf, key_path: PathBuf) -> Self {
1625 Self {
1626 cert_path,
1627 key_path,
1628 }
1629 }
1630}
1631
1632#[derive(Debug, Deserialize)]
1634#[non_exhaustive]
1635pub struct ExchangedToken {
1636 pub access_token: String,
1638 pub expires_in: Option<u64>,
1640 pub issued_token_type: Option<String>,
1643}
1644
1645#[derive(Debug, Clone, Deserialize, Default)]
1652#[non_exhaustive]
1653pub struct OAuthProxyConfig {
1654 pub authorize_url: String,
1657 pub token_url: String,
1660 pub client_id: String,
1662 pub client_secret: Option<secrecy::SecretString>,
1664 #[serde(default)]
1668 pub introspection_url: Option<String>,
1669 #[serde(default)]
1673 pub revocation_url: Option<String>,
1674 #[serde(default)]
1686 pub expose_admin_endpoints: bool,
1687 #[serde(default)]
1693 pub require_auth_on_admin_endpoints: bool,
1694 #[serde(default)]
1705 pub allow_unauthenticated_admin_endpoints: bool,
1706}
1707
1708impl OAuthProxyConfig {
1709 pub fn builder(
1717 authorize_url: impl Into<String>,
1718 token_url: impl Into<String>,
1719 client_id: impl Into<String>,
1720 ) -> OAuthProxyConfigBuilder {
1721 OAuthProxyConfigBuilder {
1722 inner: Self {
1723 authorize_url: authorize_url.into(),
1724 token_url: token_url.into(),
1725 client_id: client_id.into(),
1726 ..Self::default()
1727 },
1728 }
1729 }
1730}
1731
1732#[derive(Debug, Clone)]
1738#[must_use = "builders do nothing until `.build()` is called"]
1739pub struct OAuthProxyConfigBuilder {
1740 inner: OAuthProxyConfig,
1741}
1742
1743impl OAuthProxyConfigBuilder {
1744 pub fn client_secret(mut self, secret: secrecy::SecretString) -> Self {
1746 self.inner.client_secret = Some(secret);
1747 self
1748 }
1749
1750 pub fn introspection_url(mut self, url: impl Into<String>) -> Self {
1754 self.inner.introspection_url = Some(url.into());
1755 self
1756 }
1757
1758 pub fn revocation_url(mut self, url: impl Into<String>) -> Self {
1762 self.inner.revocation_url = Some(url.into());
1763 self
1764 }
1765
1766 pub const fn expose_admin_endpoints(mut self, expose: bool) -> Self {
1774 self.inner.expose_admin_endpoints = expose;
1775 self
1776 }
1777
1778 pub const fn require_auth_on_admin_endpoints(mut self, require: bool) -> Self {
1781 self.inner.require_auth_on_admin_endpoints = require;
1782 self
1783 }
1784
1785 pub const fn allow_unauthenticated_admin_endpoints(mut self, allow: bool) -> Self {
1789 self.inner.allow_unauthenticated_admin_endpoints = allow;
1790 self
1791 }
1792
1793 #[must_use]
1795 pub fn build(self) -> OAuthProxyConfig {
1796 self.inner
1797 }
1798}
1799
1800type JwksKeyCache = (
1808 HashMap<String, (Algorithm, DecodingKey)>,
1809 Vec<(Algorithm, DecodingKey)>,
1810);
1811
1812struct CachedKeys {
1813 keys: HashMap<String, (Algorithm, DecodingKey)>,
1815 unnamed_keys: Vec<(Algorithm, DecodingKey)>,
1817 fetched_at: Instant,
1818 ttl: Duration,
1819}
1820
1821impl CachedKeys {
1822 fn is_expired(&self) -> bool {
1823 self.fetched_at.elapsed() >= self.ttl
1824 }
1825}
1826
1827#[allow(
1836 missing_debug_implementations,
1837 reason = "contains reqwest::Client and DecodingKey cache with no Debug impl"
1838)]
1839#[non_exhaustive]
1840pub struct JwksCache {
1841 jwks_uri: String,
1842 ttl: Duration,
1843 max_jwks_keys: usize,
1844 max_response_bytes: u64,
1845 allow_http: bool,
1846 inner: RwLock<Option<CachedKeys>>,
1847 http: reqwest::Client,
1848 validation_template: Validation,
1849 expected_audience: String,
1852 audience_mode: AudienceValidationMode,
1853 require_subject: bool,
1854 azp_fallback_warned: AtomicBool,
1858 scopes: Vec<ScopeMapping>,
1859 role_claim: Option<String>,
1860 role_mappings: Vec<RoleMapping>,
1861 last_refresh_attempt: RwLock<Option<Instant>>,
1864 refresh_lock: tokio::sync::Mutex<()>,
1866 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
1870 #[cfg(any(test, feature = "test-helpers"))]
1874 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
1875}
1876
1877const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(10);
1879
1880const OAUTH_PROXY_MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
1890
1891const ACCEPTED_ALGS: &[Algorithm] = &[
1893 Algorithm::RS256,
1894 Algorithm::RS384,
1895 Algorithm::RS512,
1896 Algorithm::ES256,
1897 Algorithm::ES384,
1898 Algorithm::PS256,
1899 Algorithm::PS384,
1900 Algorithm::PS512,
1901 Algorithm::EdDSA,
1902];
1903
1904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1906#[non_exhaustive]
1907pub enum JwtValidationFailure {
1908 Expired,
1910 Invalid,
1912}
1913
1914impl JwksCache {
1915 pub fn new(config: &OAuthConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
1927 rustls::crypto::ring::default_provider()
1930 .install_default()
1931 .ok();
1932 jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
1933 .install_default()
1934 .ok();
1935
1936 let ttl = humantime::parse_duration(&config.jwks_cache_ttl).map_err(|error| {
1937 format!(
1938 "invalid jwks_cache_ttl {:?}: {error}",
1939 config.jwks_cache_ttl
1940 )
1941 })?;
1942
1943 let mut validation = Validation::new(Algorithm::RS256);
1944 validation.validate_aud = false;
1956 validation.set_issuer(&[&config.issuer]);
1957 validation.set_required_spec_claims(&["exp", "iss"]);
1958 validation.validate_exp = true;
1959 validation.validate_nbf = true;
1960
1961 let allow_http = config.allow_http_oauth_urls;
1962
1963 let allowlist = match config.ssrf_allowlist.as_ref() {
1966 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1967 Box::<dyn std::error::Error + Send + Sync>::from(format!(
1968 "oauth.ssrf_allowlist: {e}"
1969 ))
1970 })?),
1971 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
1972 };
1973 let redirect_allowlist = Arc::clone(&allowlist);
1974
1975 #[cfg(any(test, feature = "test-helpers"))]
1977 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
1978 Arc::new(AtomicBool::new(false));
1979 #[cfg(not(any(test, feature = "test-helpers")))]
1980 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
1981
1982 #[allow(
1983 clippy::clone_on_ref_ptr,
1984 clippy::clone_on_copy,
1985 clippy::unit_arg,
1986 reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
1987 )]
1988 let resolver: Arc<dyn reqwest::dns::Resolve> =
1989 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1990 Arc::clone(&allowlist),
1991 test_bypass.clone(),
1992 ));
1993
1994 let mut http_builder = reqwest::Client::builder()
1995 .no_proxy()
1997 .dns_resolver(Arc::clone(&resolver))
1998 .timeout(Duration::from_secs(10))
1999 .connect_timeout(Duration::from_secs(3))
2000 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
2001 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
2011 Ok(()) => attempt.follow(),
2012 Err(reason) => {
2013 tracing::warn!(
2017 reason = %reason,
2018 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
2019 "oauth redirect rejected"
2020 );
2021 attempt.error(reason)
2022 }
2023 }
2024 }));
2025
2026 if let Some(ref ca_path) = config.ca_cert_path {
2027 let pem = std::fs::read(ca_path)?;
2033 let cert = reqwest::tls::Certificate::from_pem(&pem)?;
2034 http_builder = http_builder.add_root_certificate(cert);
2035 }
2036
2037 let http = http_builder.build()?;
2038
2039 Ok(Self {
2040 jwks_uri: config.jwks_uri.clone(),
2041 ttl,
2042 max_jwks_keys: config.max_jwks_keys,
2043 max_response_bytes: config.jwks_max_response_bytes,
2044 allow_http,
2045 inner: RwLock::new(None),
2046 http,
2047 validation_template: validation,
2048 expected_audience: config.audience.clone(),
2049 audience_mode: config.effective_audience_validation_mode(),
2050 require_subject: config.require_subject,
2051 azp_fallback_warned: AtomicBool::new(false),
2052 scopes: config.scopes.clone(),
2053 role_claim: config.role_claim.clone(),
2054 role_mappings: config.role_mappings.clone(),
2055 last_refresh_attempt: RwLock::new(None),
2056 refresh_lock: tokio::sync::Mutex::new(()),
2057 allowlist,
2058 #[cfg(any(test, feature = "test-helpers"))]
2059 test_allow_loopback_ssrf: test_bypass,
2060 })
2061 }
2062
2063 #[cfg(any(test, feature = "test-helpers"))]
2067 #[doc(hidden)]
2068 #[must_use]
2069 pub fn __test_allow_loopback_ssrf(self) -> Self {
2070 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
2073 self
2074 }
2075
2076 pub async fn validate_token(&self, token: &str) -> Option<AuthIdentity> {
2078 self.validate_token_with_reason(token).await.ok()
2079 }
2080
2081 pub async fn validate_token_with_reason(
2091 &self,
2092 token: &str,
2093 ) -> Result<AuthIdentity, JwtValidationFailure> {
2094 let claims = self.decode_claims(token).await?;
2095
2096 if self.require_subject && claims.sub.is_none() {
2097 core::hint::cold_path();
2098 tracing::debug!("JWT rejected: require_subject is set but the token has no `sub`");
2099 return Err(JwtValidationFailure::Invalid);
2100 }
2101 self.check_audience(&claims)?;
2102 let role = self.resolve_role(&claims)?;
2103
2104 let sub = claims.sub;
2107 let name = claims
2108 .extra
2109 .get("preferred_username")
2110 .and_then(|v| v.as_str())
2111 .map(String::from)
2112 .or_else(|| sub.clone())
2113 .or(claims.azp)
2114 .or(claims.client_id)
2115 .unwrap_or_else(|| "oauth-client".into());
2116
2117 Ok(AuthIdentity {
2118 name,
2119 role,
2120 method: AuthMethod::OAuthJwt,
2121 raw_token: None,
2122 sub,
2123 })
2124 }
2125
2126 async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2142 let (key, alg) = self.select_jwks_key(token).await?;
2143
2144 let mut validation = self.validation_template.clone();
2148 validation.algorithms = vec![alg];
2149
2150 let token_owned = token.to_owned();
2153 let join =
2154 tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2155 .await;
2156
2157 let decode_result = match join {
2158 Ok(r) => r,
2159 Err(join_err) => {
2160 core::hint::cold_path();
2161 tracing::error!(
2162 error = %join_err,
2163 "JWT decode task panicked or was cancelled"
2164 );
2165 return Err(JwtValidationFailure::Invalid);
2166 }
2167 };
2168
2169 decode_result.map(|td| td.claims).map_err(|e| {
2170 core::hint::cold_path();
2171 let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2172 JwtValidationFailure::Expired
2173 } else {
2174 JwtValidationFailure::Invalid
2175 };
2176 tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2177 failure
2178 })
2179 }
2180
2181 #[allow(
2190 clippy::cognitive_complexity,
2191 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"
2192 )]
2193 async fn select_jwks_key(
2194 &self,
2195 token: &str,
2196 ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2197 let Ok(header) = decode_header(token) else {
2198 core::hint::cold_path();
2199 tracing::debug!("JWT header decode failed");
2200 return Err(JwtValidationFailure::Invalid);
2201 };
2202 let kid = header.kid.as_deref();
2203 tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2204
2205 if !ACCEPTED_ALGS.contains(&header.alg) {
2206 core::hint::cold_path();
2207 tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2208 return Err(JwtValidationFailure::Invalid);
2209 }
2210
2211 let Some(key) = self.find_key(kid, header.alg).await else {
2212 core::hint::cold_path();
2213 tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2214 return Err(JwtValidationFailure::Invalid);
2215 };
2216
2217 Ok((key, header.alg))
2218 }
2219
2220 fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2229 if claims.aud.contains(&self.expected_audience) {
2230 return Ok(());
2231 }
2232 let azp_match = claims
2233 .azp
2234 .as_deref()
2235 .is_some_and(|azp| azp == self.expected_audience);
2236 if azp_match {
2237 match self.audience_mode {
2238 AudienceValidationMode::Permissive => return Ok(()),
2239 AudienceValidationMode::Warn => {
2240 if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2241 tracing::warn!(
2242 expected = %self.expected_audience,
2243 azp = claims.azp.as_deref().unwrap_or("-"),
2244 "JWT accepted via deprecated azp-only audience fallback. \
2245 Configure your IdP to populate aud, or set \
2246 audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2247 To silence this warning without changing acceptance, \
2248 set audience_validation_mode = \"permissive\". \
2249 This warning logs once per process."
2250 );
2251 }
2252 return Ok(());
2253 }
2254 AudienceValidationMode::Strict => {}
2255 }
2256 }
2257 core::hint::cold_path();
2258 tracing::debug!(
2259 aud = %claims.aud.log_display(),
2260 azp = claims.azp.as_deref().unwrap_or("-"),
2261 expected = %self.expected_audience,
2262 mode = self.audience_mode.as_str(),
2263 "JWT rejected: audience mismatch"
2264 );
2265 Err(JwtValidationFailure::Invalid)
2266 }
2267
2268 fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2274 if let Some(ref claim_path) = self.role_claim {
2275 let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2276 let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2277 values.extend(resolve_claim_path(&claims.extra, claim_path));
2278 return self
2279 .role_mappings
2280 .iter()
2281 .find(|m| values.contains(&m.claim_value.as_str()))
2282 .map(|m| m.role.clone())
2283 .ok_or(JwtValidationFailure::Invalid);
2284 }
2285
2286 let token_scopes: Vec<&str> = claims
2287 .scope
2288 .as_deref()
2289 .unwrap_or("")
2290 .split_whitespace()
2291 .collect();
2292
2293 self.scopes
2294 .iter()
2295 .find(|m| token_scopes.contains(&m.scope.as_str()))
2296 .map(|m| m.role.clone())
2297 .ok_or(JwtValidationFailure::Invalid)
2298 }
2299
2300 async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2306 {
2308 let guard = self.inner.read().await;
2309 if let Some(cached) = guard.as_ref()
2310 && !cached.is_expired()
2311 && let Some(key) = lookup_key(cached, kid, alg)
2312 {
2313 return Some(key);
2314 }
2315 }
2316
2317 self.refresh_with_cooldown().await;
2319
2320 let guard = self.inner.read().await;
2326 guard
2327 .as_ref()
2328 .filter(|cached| !cached.is_expired())
2329 .and_then(|cached| lookup_key(cached, kid, alg))
2330 }
2331
2332 async fn refresh_with_cooldown(&self) {
2352 let _guard = self.refresh_lock.lock().await;
2354
2355 {
2357 let last = self.last_refresh_attempt.read().await;
2358 if let Some(ts) = *last
2359 && ts.elapsed() < JWKS_REFRESH_COOLDOWN
2360 {
2361 tracing::debug!(
2362 elapsed_ms = ts.elapsed().as_millis(),
2363 cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
2364 "JWKS refresh skipped (cooldown active)"
2365 );
2366 return;
2367 }
2368 }
2369
2370 {
2373 let mut last = self.last_refresh_attempt.write().await;
2374 *last = Some(Instant::now());
2375 }
2376
2377 let _ = self.refresh_inner().await;
2379 }
2380
2381 async fn refresh_inner(&self) -> Result<(), String> {
2390 let Some(jwks) = self.fetch_jwks().await else {
2391 return Ok(());
2392 };
2393 let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
2394 Ok(cache) => cache,
2395 Err(msg) => {
2396 tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
2397 return Err(msg);
2398 }
2399 };
2400
2401 tracing::debug!(
2402 named = keys.len(),
2403 unnamed = unnamed_keys.len(),
2404 "JWKS refreshed"
2405 );
2406
2407 let mut guard = self.inner.write().await;
2408 *guard = Some(CachedKeys {
2409 keys,
2410 unnamed_keys,
2411 fetched_at: Instant::now(),
2412 ttl: self.ttl,
2413 });
2414 drop(guard);
2415 Ok(())
2416 }
2417
2418 #[allow(
2420 clippy::cognitive_complexity,
2421 reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
2422 )]
2423 async fn fetch_jwks(&self) -> Option<JwkSet> {
2424 #[cfg(any(test, feature = "test-helpers"))]
2425 let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
2426 screen_oauth_target_with_test_override(
2427 &self.jwks_uri,
2428 self.allow_http,
2429 &self.allowlist,
2430 true,
2431 )
2432 .await
2433 } else {
2434 screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
2435 };
2436 #[cfg(not(any(test, feature = "test-helpers")))]
2437 let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
2438
2439 if let Err(error) = screening {
2440 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to screen JWKS target");
2441 return None;
2442 }
2443
2444 let mut resp = match self.http.get(&self.jwks_uri).send().await {
2445 Ok(resp) => resp,
2446 Err(e) => {
2447 tracing::warn!(error = %e, uri = %self.jwks_uri, "failed to fetch JWKS");
2448 return None;
2449 }
2450 };
2451
2452 let initial_capacity =
2453 usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
2454 let mut body = Vec::with_capacity(initial_capacity);
2455 while let Some(chunk) = match resp.chunk().await {
2456 Ok(chunk) => chunk,
2457 Err(error) => {
2458 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to read JWKS response");
2459 return None;
2460 }
2461 } {
2462 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
2463 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
2464 if body_len.saturating_add(chunk_len) > self.max_response_bytes {
2465 tracing::warn!(
2466 uri = %self.jwks_uri,
2467 max_bytes = self.max_response_bytes,
2468 "JWKS response exceeded configured size cap"
2469 );
2470 return None;
2471 }
2472 body.extend_from_slice(&chunk);
2473 }
2474
2475 match serde_json::from_slice::<JwkSet>(&body) {
2476 Ok(jwks) => Some(jwks),
2477 Err(error) => {
2478 tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to parse JWKS");
2479 None
2480 }
2481 }
2482 }
2483
2484 #[cfg(any(test, feature = "test-helpers"))]
2487 #[doc(hidden)]
2488 pub async fn __test_refresh_now(&self) -> Result<(), String> {
2489 let jwks = self
2490 .fetch_jwks()
2491 .await
2492 .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
2493 let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
2494 let mut guard = self.inner.write().await;
2495 *guard = Some(CachedKeys {
2496 keys,
2497 unnamed_keys,
2498 fetched_at: Instant::now(),
2499 ttl: self.ttl,
2500 });
2501 drop(guard);
2502 Ok(())
2503 }
2504
2505 #[cfg(any(test, feature = "test-helpers"))]
2508 #[doc(hidden)]
2509 pub async fn __test_has_kid(&self, kid: &str) -> bool {
2510 let guard = self.inner.read().await;
2511 guard
2512 .as_ref()
2513 .is_some_and(|cache| cache.keys.contains_key(kid))
2514 }
2515}
2516
2517fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
2519 if jwks.keys.len() > max_keys {
2520 return Err(format!(
2521 "jwks_key_count_exceeds_cap: got {} keys, max is {}",
2522 jwks.keys.len(),
2523 max_keys
2524 ));
2525 }
2526 let mut keys = HashMap::new();
2527 let mut unnamed_keys = Vec::new();
2528 for jwk in &jwks.keys {
2529 let Ok(decoding_key) = DecodingKey::from_jwk(jwk) else {
2530 continue;
2531 };
2532 let Some(alg) = jwk_algorithm(jwk) else {
2533 continue;
2534 };
2535 if let Some(ref kid) = jwk.common.key_id {
2536 keys.insert(kid.clone(), (alg, decoding_key));
2537 } else {
2538 unnamed_keys.push((alg, decoding_key));
2539 }
2540 }
2541 Ok((keys, unnamed_keys))
2542}
2543
2544fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2546 if let Some(kid) = kid {
2547 if let Some((cached_alg, key)) = cached.keys.get(kid)
2552 && *cached_alg == alg
2553 {
2554 return Some(key.clone());
2555 }
2556 return None;
2557 }
2558 cached
2560 .unnamed_keys
2561 .iter()
2562 .find(|(a, _)| *a == alg)
2563 .map(|(_, k)| k.clone())
2564}
2565
2566#[allow(
2568 clippy::wildcard_enum_match_arm,
2569 reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
2570)]
2571fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<Algorithm> {
2572 jwk.common.key_algorithm.and_then(|ka| match ka {
2573 jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
2574 jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
2575 jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
2576 jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
2577 jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
2578 jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
2579 jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
2580 jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
2581 jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
2582 _ => None,
2583 })
2584}
2585
2586fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
2607 match path {
2608 "sub" => claims.sub.iter().cloned().collect(),
2609 "azp" => claims.azp.iter().cloned().collect(),
2610 "client_id" => claims.client_id.iter().cloned().collect(),
2611 "aud" => claims.aud.0.clone(),
2612 "scope" => claims
2613 .scope
2614 .as_deref()
2615 .unwrap_or("")
2616 .split_whitespace()
2617 .map(str::to_owned)
2618 .collect(),
2619 _ => Vec::new(),
2620 }
2621}
2622
2623fn resolve_claim_path<'a>(
2633 extra: &'a HashMap<String, serde_json::Value>,
2634 path: &str,
2635) -> Vec<&'a str> {
2636 let mut segments = path.split('.');
2637 let Some(first) = segments.next() else {
2638 return Vec::new();
2639 };
2640
2641 let mut current: Option<&serde_json::Value> = extra.get(first);
2642
2643 for segment in segments {
2644 current = current.and_then(|v| v.get(segment));
2645 }
2646
2647 match current {
2648 Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
2649 Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
2650 _ => Vec::new(),
2651 }
2652}
2653
2654#[derive(Debug, Deserialize)]
2660struct Claims {
2661 sub: Option<String>,
2663 #[serde(default)]
2666 aud: OneOrMany,
2667 azp: Option<String>,
2669 client_id: Option<String>,
2671 scope: Option<String>,
2673 #[serde(flatten)]
2675 extra: HashMap<String, serde_json::Value>,
2676}
2677
2678#[derive(Debug, Default)]
2680struct OneOrMany(Vec<String>);
2681
2682impl OneOrMany {
2683 fn contains(&self, value: &str) -> bool {
2684 self.0.iter().any(|v| v == value)
2685 }
2686
2687 fn log_display(&self) -> String {
2691 if self.0.is_empty() {
2692 "-".to_owned()
2693 } else {
2694 self.0.join(", ")
2695 }
2696 }
2697}
2698
2699fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
2709 match value {
2710 Some(serde_json::Value::String(s)) => s.clone(),
2711 Some(serde_json::Value::Array(items)) => {
2712 let joined = items
2713 .iter()
2714 .filter_map(serde_json::Value::as_str)
2715 .collect::<Vec<_>>()
2716 .join(", ");
2717 if joined.is_empty() {
2718 "-".to_owned()
2719 } else {
2720 joined
2721 }
2722 }
2723 Some(
2724 serde_json::Value::Null
2725 | serde_json::Value::Bool(_)
2726 | serde_json::Value::Number(_)
2727 | serde_json::Value::Object(_),
2728 )
2729 | None => "-".to_owned(),
2730 }
2731}
2732
2733fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
2737 value.and_then(serde_json::Value::as_str).unwrap_or("-")
2738}
2739
2740impl<'de> Deserialize<'de> for OneOrMany {
2741 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2742 use serde::de;
2743
2744 struct Visitor;
2745 impl<'de> de::Visitor<'de> for Visitor {
2746 type Value = OneOrMany;
2747 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2748 f.write_str("a string or array of strings")
2749 }
2750 fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
2751 Ok(OneOrMany(vec![v.to_owned()]))
2752 }
2753 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
2754 let mut v = Vec::new();
2755 while let Some(s) = seq.next_element::<String>()? {
2756 v.push(s);
2757 }
2758 Ok(OneOrMany(v))
2759 }
2760 }
2761 deserializer.deserialize_any(Visitor)
2762 }
2763}
2764
2765#[must_use]
2772pub fn looks_like_jwt(token: &str) -> bool {
2773 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2774
2775 let mut parts = token.splitn(4, '.');
2776 let Some(header_b64) = parts.next() else {
2777 return false;
2778 };
2779 if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
2781 return false;
2782 }
2783 let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
2785 return false;
2786 };
2787 let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
2789 return false;
2790 };
2791 header.get("alg").is_some()
2792}
2793
2794#[must_use]
2804pub fn protected_resource_metadata(
2805 resource_url: &str,
2806 server_url: &str,
2807 config: &OAuthConfig,
2808) -> serde_json::Value {
2809 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2814 let auth_server = server_url;
2815 serde_json::json!({
2816 "resource": resource_url,
2817 "authorization_servers": [auth_server],
2818 "scopes_supported": scopes,
2819 "bearer_methods_supported": ["header"]
2820 })
2821}
2822
2823#[must_use]
2828pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
2829 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2830 let mut meta = serde_json::json!({
2831 "issuer": &config.issuer,
2832 "authorization_endpoint": format!("{server_url}/authorize"),
2833 "token_endpoint": format!("{server_url}/token"),
2834 "registration_endpoint": format!("{server_url}/register"),
2835 "response_types_supported": ["code"],
2836 "grant_types_supported": ["authorization_code", "refresh_token"],
2837 "code_challenge_methods_supported": ["S256"],
2838 "scopes_supported": scopes,
2839 "token_endpoint_auth_methods_supported": ["none"],
2840 });
2841 if let Some(proxy) = &config.proxy
2842 && proxy.expose_admin_endpoints
2843 && let Some(obj) = meta.as_object_mut()
2844 {
2845 if proxy.introspection_url.is_some() {
2846 obj.insert(
2847 "introspection_endpoint".into(),
2848 serde_json::Value::String(format!("{server_url}/introspect")),
2849 );
2850 }
2851 if proxy.revocation_url.is_some() {
2852 obj.insert(
2853 "revocation_endpoint".into(),
2854 serde_json::Value::String(format!("{server_url}/revoke")),
2855 );
2856 }
2857 if proxy.require_auth_on_admin_endpoints {
2858 obj.insert(
2859 "introspection_endpoint_auth_methods_supported".into(),
2860 serde_json::json!(["bearer"]),
2861 );
2862 obj.insert(
2863 "revocation_endpoint_auth_methods_supported".into(),
2864 serde_json::json!(["bearer"]),
2865 );
2866 }
2867 }
2868 meta
2869}
2870
2871#[must_use]
2884pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
2885 use axum::{
2886 http::{StatusCode, header},
2887 response::IntoResponse,
2888 };
2889
2890 let upstream_query = rewrite_client_auth_params(query, &proxy.client_id);
2892 let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
2893
2894 (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
2895}
2896
2897pub async fn handle_token(
2903 http: &OauthHttpClient,
2904 proxy: &OAuthProxyConfig,
2905 body: &str,
2906) -> axum::response::Response {
2907 use axum::{
2908 http::{StatusCode, header},
2909 response::IntoResponse,
2910 };
2911
2912 let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id);
2914
2915 if let Some(ref secret) = proxy.client_secret {
2917 use std::fmt::Write;
2918
2919 use secrecy::ExposeSecret;
2920 let _ = write!(
2921 upstream_body,
2922 "&client_secret={}",
2923 urlencoding::encode(secret.expose_secret())
2924 );
2925 }
2926
2927 let result = http
2928 .send_screened(
2929 &proxy.token_url,
2930 http.credential_client
2931 .post(&proxy.token_url)
2932 .header("Content-Type", "application/x-www-form-urlencoded")
2933 .body(upstream_body),
2934 )
2935 .await;
2936
2937 match result {
2938 Ok(resp) => {
2939 let status =
2940 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
2941 let Ok(body_bytes) =
2942 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
2943 else {
2944 return oauth_error_response(
2945 StatusCode::BAD_GATEWAY,
2946 "server_error",
2947 "upstream response too large or unreadable",
2948 );
2949 };
2950 (
2951 status,
2952 [(header::CONTENT_TYPE, "application/json")],
2953 body_bytes,
2954 )
2955 .into_response()
2956 }
2957 Err(e) => {
2958 tracing::error!(error = %e, "OAuth token proxy request failed");
2959 (
2960 StatusCode::BAD_GATEWAY,
2961 [(header::CONTENT_TYPE, "application/json")],
2962 "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
2963 )
2964 .into_response()
2965 }
2966 }
2967}
2968
2969#[must_use]
2976pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
2977 let mut resp = serde_json::json!({
2978 "client_id": proxy.client_id,
2979 "token_endpoint_auth_method": "none",
2980 });
2981 if let Some(uris) = body.get("redirect_uris")
2982 && let Some(obj) = resp.as_object_mut()
2983 {
2984 obj.insert("redirect_uris".into(), uris.clone());
2985 }
2986 if let Some(name) = body.get("client_name")
2987 && let Some(obj) = resp.as_object_mut()
2988 {
2989 obj.insert("client_name".into(), name.clone());
2990 }
2991 resp
2992}
2993
2994pub async fn handle_introspect(
3000 http: &OauthHttpClient,
3001 proxy: &OAuthProxyConfig,
3002 body: &str,
3003) -> axum::response::Response {
3004 let Some(ref url) = proxy.introspection_url else {
3005 return oauth_error_response(
3006 axum::http::StatusCode::NOT_FOUND,
3007 "not_supported",
3008 "introspection endpoint is not configured",
3009 );
3010 };
3011 proxy_oauth_admin_request(http, proxy, url, body).await
3012}
3013
3014pub async fn handle_revoke(
3021 http: &OauthHttpClient,
3022 proxy: &OAuthProxyConfig,
3023 body: &str,
3024) -> axum::response::Response {
3025 let Some(ref url) = proxy.revocation_url else {
3026 return oauth_error_response(
3027 axum::http::StatusCode::NOT_FOUND,
3028 "not_supported",
3029 "revocation endpoint is not configured",
3030 );
3031 };
3032 proxy_oauth_admin_request(http, proxy, url, body).await
3033}
3034
3035async fn proxy_oauth_admin_request(
3039 http: &OauthHttpClient,
3040 proxy: &OAuthProxyConfig,
3041 upstream_url: &str,
3042 body: &str,
3043) -> axum::response::Response {
3044 use axum::{
3045 http::{StatusCode, header},
3046 response::IntoResponse,
3047 };
3048
3049 let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id);
3050 if let Some(ref secret) = proxy.client_secret {
3051 use std::fmt::Write;
3052
3053 use secrecy::ExposeSecret;
3054 let _ = write!(
3055 upstream_body,
3056 "&client_secret={}",
3057 urlencoding::encode(secret.expose_secret())
3058 );
3059 }
3060
3061 let result = http
3062 .send_screened(
3063 upstream_url,
3064 http.credential_client
3065 .post(upstream_url)
3066 .header("Content-Type", "application/x-www-form-urlencoded")
3067 .body(upstream_body),
3068 )
3069 .await;
3070
3071 match result {
3072 Ok(resp) => {
3073 let status =
3074 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3075 let content_type = resp
3076 .headers()
3077 .get(header::CONTENT_TYPE)
3078 .and_then(|v| v.to_str().ok())
3079 .unwrap_or("application/json")
3080 .to_owned();
3081 let Ok(body_bytes) =
3082 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
3083 else {
3084 return oauth_error_response(
3085 StatusCode::BAD_GATEWAY,
3086 "server_error",
3087 "upstream response too large or unreadable",
3088 );
3089 };
3090 (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
3091 }
3092 Err(e) => {
3093 tracing::error!(error = %e, url = %upstream_url, "OAuth admin proxy request failed");
3094 oauth_error_response(
3095 StatusCode::BAD_GATEWAY,
3096 "server_error",
3097 "upstream endpoint unreachable",
3098 )
3099 }
3100 }
3101}
3102
3103async fn read_response_capped(
3113 mut resp: reqwest::Response,
3114 max_bytes: u64,
3115 context: &str,
3116) -> Result<Vec<u8>, ()> {
3117 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3118 let mut body = Vec::with_capacity(initial_capacity);
3119 loop {
3120 match resp.chunk().await {
3121 Ok(Some(chunk)) => {
3122 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3123 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3124 if body_len.saturating_add(chunk_len) > max_bytes {
3125 tracing::warn!(
3126 context = context,
3127 max_bytes = max_bytes,
3128 "upstream OAuth response exceeded size cap; failing closed"
3129 );
3130 return Err(());
3131 }
3132 body.extend_from_slice(&chunk);
3133 }
3134 Ok(None) => return Ok(body),
3135 Err(error) => {
3136 tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
3137 return Err(());
3138 }
3139 }
3140 }
3141}
3142
3143fn oauth_error_response(
3144 status: axum::http::StatusCode,
3145 error: &str,
3146 description: &str,
3147) -> axum::response::Response {
3148 use axum::{http::header, response::IntoResponse};
3149 let body = serde_json::json!({
3150 "error": error,
3151 "error_description": description,
3152 });
3153 (
3154 status,
3155 [(header::CONTENT_TYPE, "application/json")],
3156 body.to_string(),
3157 )
3158 .into_response()
3159}
3160
3161#[derive(Debug, Deserialize)]
3167struct OAuthErrorResponse {
3168 error: String,
3169 error_description: Option<String>,
3170}
3171
3172fn sanitize_oauth_error_code(raw: &str) -> &'static str {
3179 match raw {
3180 "invalid_request" => "invalid_request",
3181 "invalid_client" => "invalid_client",
3182 "invalid_grant" => "invalid_grant",
3183 "unauthorized_client" => "unauthorized_client",
3184 "unsupported_grant_type" => "unsupported_grant_type",
3185 "invalid_scope" => "invalid_scope",
3186 "temporarily_unavailable" => "temporarily_unavailable",
3187 "invalid_target" => "invalid_target",
3189 _ => "server_error",
3192 }
3193}
3194
3195pub async fn exchange_token(
3207 http: &OauthHttpClient,
3208 config: &TokenExchangeConfig,
3209 subject_token: &str,
3210) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
3211 use secrecy::ExposeSecret;
3212
3213 let client = http.client_for(config);
3214 let mut req = client
3215 .post(&config.token_url)
3216 .header("Content-Type", "application/x-www-form-urlencoded")
3217 .header("Accept", "application/json");
3218
3219 if config.client_cert.is_none()
3228 && let Some(ref secret) = config.client_secret
3229 {
3230 use base64::Engine;
3231 let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
3232 "{}:{}",
3233 urlencoding::encode(&config.client_id),
3234 urlencoding::encode(secret.expose_secret()),
3235 ));
3236 req = req.header("Authorization", format!("Basic {credentials}"));
3237 }
3238
3239 let form_body = build_exchange_form(config, subject_token);
3240
3241 let resp = http
3242 .send_screened(&config.token_url, req.body(form_body))
3243 .await
3244 .map_err(|e| {
3245 tracing::error!(error = %e, "token exchange request failed");
3246 crate::error::RmcpServerKitError::Auth("server_error".into())
3248 })?;
3249
3250 let status = resp.status();
3251 let body_bytes =
3252 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
3253 .await
3254 .map_err(|()| {
3255 crate::error::RmcpServerKitError::Auth("server_error".into())
3257 })?;
3258
3259 if !status.is_success() {
3260 core::hint::cold_path();
3261 let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
3264 let short_code = parsed
3265 .as_ref()
3266 .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
3267 if let Some(ref e) = parsed {
3268 tracing::warn!(
3269 status = %status,
3270 upstream_error = %e.error,
3271 upstream_error_description = e.error_description.as_deref().unwrap_or(""),
3272 client_code = %short_code,
3273 "token exchange rejected by authorization server",
3274 );
3275 } else {
3276 tracing::warn!(
3277 status = %status,
3278 client_code = %short_code,
3279 "token exchange rejected (unparseable upstream body)",
3280 );
3281 }
3282 return Err(crate::error::RmcpServerKitError::Auth(short_code.into()));
3283 }
3284
3285 let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
3286 tracing::error!(error = %e, "failed to parse token exchange response");
3287 crate::error::RmcpServerKitError::Auth("server_error".into())
3290 })?;
3291
3292 log_exchanged_token(&exchanged);
3293
3294 Ok(exchanged)
3295}
3296
3297fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
3300 let body = format!(
3301 "grant_type={}&subject_token={}&subject_token_type={}&requested_token_type={}&audience={}",
3302 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
3303 urlencoding::encode(subject_token),
3304 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3305 urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3306 urlencoding::encode(&config.audience),
3307 );
3308 if config.client_secret.is_none() {
3309 format!(
3310 "{body}&client_id={}",
3311 urlencoding::encode(&config.client_id)
3312 )
3313 } else {
3314 body
3315 }
3316}
3317
3318fn log_exchanged_token(exchanged: &ExchangedToken) {
3321 use base64::Engine;
3322
3323 if !looks_like_jwt(&exchanged.access_token) {
3324 tracing::debug!(
3325 token_len = exchanged.access_token.len(),
3326 issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
3327 expires_in = exchanged.expires_in,
3328 "exchanged token (opaque)",
3329 );
3330 return;
3331 }
3332 let Some(payload) = exchanged.access_token.split('.').nth(1) else {
3333 return;
3334 };
3335 let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
3336 return;
3337 };
3338 let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
3339 return;
3340 };
3341 tracing::debug!(
3342 sub = fmt_json_str(claims.get("sub")),
3343 aud = %fmt_json_aud(claims.get("aud")),
3344 azp = fmt_json_str(claims.get("azp")),
3345 iss = fmt_json_str(claims.get("iss")),
3346 expires_in = exchanged.expires_in,
3347 "exchanged token claims (JWT)",
3348 );
3349}
3350
3351const CLIENT_AUTH_PARAMS: [&str; 4] = [
3357 "client_id",
3358 "client_secret",
3359 "client_assertion",
3360 "client_assertion_type",
3361];
3362
3363fn rewrite_client_auth_params(params: &str, upstream_client_id: &str) -> String {
3385 let mut out = url::form_urlencoded::Serializer::new(String::new());
3386 for (key, value) in url::form_urlencoded::parse(params.as_bytes()) {
3387 if CLIENT_AUTH_PARAMS.contains(&key.as_ref()) {
3388 continue;
3389 }
3390 out.append_pair(&key, &value);
3391 }
3392 out.append_pair("client_id", upstream_client_id);
3393 out.finish()
3394}
3395
3396#[cfg(test)]
3397mod tests {
3398 use std::sync::Arc;
3399
3400 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3401
3402 use super::*;
3403
3404 fn decoded_pairs(form: &str) -> Vec<(String, String)> {
3418 url::form_urlencoded::parse(form.as_bytes())
3419 .map(|(k, v)| (k.into_owned(), v.into_owned()))
3420 .collect()
3421 }
3422
3423 #[test]
3424 fn rewrite_drops_percent_encoded_client_id_key() {
3425 let out = rewrite_client_auth_params("%63lient_id=attacker&scope=read", "proxy-id");
3426 let pairs = decoded_pairs(&out);
3427 let client_ids: Vec<&String> = pairs
3428 .iter()
3429 .filter(|(k, _)| k == "client_id")
3430 .map(|(_, v)| v)
3431 .collect();
3432 assert_eq!(client_ids, vec!["proxy-id"], "smuggled client_id survived");
3433 }
3434
3435 #[test]
3436 fn rewrite_drops_underscore_encoded_client_id_key() {
3437 let out = rewrite_client_auth_params("client%5Fid=attacker&scope=read", "proxy-id");
3438 let pairs = decoded_pairs(&out);
3439 assert!(
3440 !pairs.iter().any(|(_, v)| v == "attacker"),
3441 "smuggled client_id survived: {pairs:?}"
3442 );
3443 }
3444
3445 #[test]
3446 fn rewrite_drops_caller_supplied_client_secret() {
3447 let out =
3448 rewrite_client_auth_params("client_secret=attacker-secret&scope=read", "proxy-id");
3449 let pairs = decoded_pairs(&out);
3450 assert!(
3451 !pairs.iter().any(|(k, _)| k == "client_secret"),
3452 "caller client_secret survived: {pairs:?}"
3453 );
3454 }
3455
3456 #[test]
3457 fn rewrite_drops_caller_supplied_client_assertion() {
3458 let out = rewrite_client_auth_params(
3459 "client_assertion=ey.evil&client_assertion_type=urn:evil&scope=read",
3460 "proxy-id",
3461 );
3462 let pairs = decoded_pairs(&out);
3463 assert!(
3464 !pairs
3465 .iter()
3466 .any(|(k, _)| k == "client_assertion" || k == "client_assertion_type"),
3467 "caller client assertion survived: {pairs:?}"
3468 );
3469 }
3470
3471 #[test]
3472 fn rewrite_collapses_duplicate_client_id_to_proxy_value() {
3473 let out = rewrite_client_auth_params("client_id=a&client_id=b&scope=read", "proxy-id");
3474 let pairs = decoded_pairs(&out);
3475 let client_ids: Vec<&String> = pairs
3476 .iter()
3477 .filter(|(k, _)| k == "client_id")
3478 .map(|(_, v)| v)
3479 .collect();
3480 assert_eq!(client_ids, vec!["proxy-id"]);
3481 }
3482
3483 #[test]
3484 fn rewrite_preserves_non_client_params_in_order_with_duplicates() {
3485 let out = rewrite_client_auth_params(
3486 "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
3487 "proxy-id",
3488 );
3489 let pairs = decoded_pairs(&out);
3490 let non_client: Vec<(String, String)> = pairs
3491 .into_iter()
3492 .filter(|(k, _)| k != "client_id")
3493 .collect();
3494 assert_eq!(
3495 non_client,
3496 vec![
3497 ("scope".to_owned(), "read".to_owned()),
3498 ("resource".to_owned(), "a".to_owned()),
3499 ("state".to_owned(), "xyz".to_owned()),
3500 ("resource".to_owned(), "b".to_owned()),
3501 ("code_verifier".to_owned(), "v".to_owned()),
3502 ]
3503 );
3504 }
3505
3506 #[test]
3507 fn rewrite_roundtrips_values_with_special_characters() {
3508 let input = url::form_urlencoded::Serializer::new(String::new())
3509 .append_pair("state", "a&b=c+d")
3510 .append_pair("scope", "rรฉad โ")
3511 .finish();
3512 let out = rewrite_client_auth_params(&input, "proxy-id");
3513 let pairs = decoded_pairs(&out);
3514 assert!(pairs.contains(&("state".to_owned(), "a&b=c+d".to_owned())));
3515 assert!(pairs.contains(&("scope".to_owned(), "rรฉad โ".to_owned())));
3516 }
3517
3518 #[test]
3519 fn rewrite_injects_client_id_when_absent() {
3520 let out = rewrite_client_auth_params("scope=read", "proxy-id");
3521 assert!(decoded_pairs(&out).contains(&("client_id".to_owned(), "proxy-id".to_owned())));
3522 }
3523
3524 #[test]
3525 fn looks_like_jwt_valid() {
3526 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
3528 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3529 let token = format!("{header}.{payload}.signature");
3530 assert!(looks_like_jwt(&token));
3531 }
3532
3533 #[test]
3534 fn looks_like_jwt_rejects_opaque_token() {
3535 assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
3536 }
3537
3538 #[test]
3539 fn looks_like_jwt_rejects_two_segments() {
3540 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
3541 let token = format!("{header}.payload");
3542 assert!(!looks_like_jwt(&token));
3543 }
3544
3545 #[test]
3546 fn looks_like_jwt_rejects_four_segments() {
3547 assert!(!looks_like_jwt("a.b.c.d"));
3548 }
3549
3550 #[test]
3551 fn looks_like_jwt_rejects_no_alg() {
3552 let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
3553 let payload = URL_SAFE_NO_PAD.encode(b"{}");
3554 let token = format!("{header}.{payload}.sig");
3555 assert!(!looks_like_jwt(&token));
3556 }
3557
3558 #[test]
3559 fn protected_resource_metadata_shape() {
3560 let config = OAuthConfig {
3561 require_subject: false,
3562 issuer: "https://auth.example.com".into(),
3563 audience: "https://mcp.example.com/mcp".into(),
3564 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
3565 scopes: vec![
3566 ScopeMapping {
3567 scope: "mcp:read".into(),
3568 role: "viewer".into(),
3569 },
3570 ScopeMapping {
3571 scope: "mcp:admin".into(),
3572 role: "ops".into(),
3573 },
3574 ],
3575 role_claim: None,
3576 role_mappings: vec![],
3577 jwks_cache_ttl: "10m".into(),
3578 proxy: None,
3579 token_exchange: None,
3580 ca_cert_path: None,
3581 allow_http_oauth_urls: false,
3582 max_jwks_keys: default_max_jwks_keys(),
3583 #[allow(
3584 deprecated,
3585 reason = "test fixture: explicit value for the deprecated field"
3586 )]
3587 strict_audience_validation: None,
3588 audience_validation_mode: None,
3589 jwks_max_response_bytes: default_jwks_max_bytes(),
3590 ssrf_allowlist: None,
3591 };
3592 let meta = protected_resource_metadata(
3593 "https://mcp.example.com/mcp",
3594 "https://mcp.example.com",
3595 &config,
3596 );
3597 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
3598 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
3599 assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
3600 assert_eq!(meta["bearer_methods_supported"][0], "header");
3601 }
3602
3603 fn validation_https_config() -> OAuthConfig {
3608 OAuthConfig::builder(
3609 "https://auth.example.com",
3610 "mcp",
3611 "https://auth.example.com/.well-known/jwks.json",
3612 )
3613 .build()
3614 }
3615
3616 #[test]
3617 fn validate_accepts_all_https_urls() {
3618 let cfg = validation_https_config();
3619 cfg.validate().expect("all-HTTPS config must validate");
3620 }
3621
3622 #[test]
3623 fn validate_rejects_empty_audience() {
3624 let mut cfg = validation_https_config();
3625 cfg.audience = String::new();
3626 let err = cfg.validate().expect_err("empty audience must be rejected");
3627 assert!(
3628 err.to_string().contains("oauth.audience"),
3629 "error must reference oauth.audience; got {err}"
3630 );
3631 }
3632
3633 #[test]
3634 fn oauth_config_partial_table_deserializes_then_validate_rejects_empty_fields() {
3635 let toml_src = r#"
3636role_claim = "realm_access.roles"
3637
3638[[role_mappings]]
3639claim_value = "mcp-admin"
3640role = "admin"
3641"#;
3642 let cfg: OAuthConfig = toml::from_str(toml_src).expect(
3643 "partial [oauth] table without issuer/audience/jwks_uri must deserialize via serde(default)",
3644 );
3645 assert_eq!(cfg.issuer, "", "omitted issuer must default to empty");
3646 assert_eq!(cfg.audience, "", "omitted audience must default to empty");
3647 assert_eq!(cfg.jwks_uri, "", "omitted jwks_uri must default to empty");
3648 assert_eq!(cfg.role_claim.as_deref(), Some("realm_access.roles"));
3649 assert_eq!(cfg.role_mappings.len(), 1);
3650 cfg.validate().expect_err(
3651 "empty issuer/jwks_uri/audience must still fail validate() (parse-don't-validate)",
3652 );
3653 }
3654
3655 #[test]
3656 fn validate_rejects_unparseable_jwks_cache_ttl() {
3657 let mut cfg = validation_https_config();
3658 cfg.jwks_cache_ttl = "not-a-duration".into();
3659 let err = cfg
3660 .validate()
3661 .expect_err("malformed jwks_cache_ttl must be rejected");
3662 let msg = err.to_string();
3663 assert!(
3664 msg.contains("jwks_cache_ttl"),
3665 "error must reference offending field; got {msg:?}"
3666 );
3667 }
3668
3669 #[test]
3670 fn validate_rejects_http_jwks_uri() {
3671 let mut cfg = validation_https_config();
3672 cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
3673 let err = cfg.validate().expect_err("http jwks_uri must be rejected");
3674 let msg = err.to_string();
3675 assert!(
3676 msg.contains("oauth.jwks_uri") && msg.contains("https"),
3677 "error must reference offending field + scheme requirement; got {msg:?}"
3678 );
3679 }
3680
3681 #[test]
3682 fn validate_rejects_http_proxy_authorize_url() {
3683 let mut cfg = validation_https_config();
3684 cfg.proxy = Some(
3685 OAuthProxyConfig::builder(
3686 "http://idp.example.com/authorize", "https://idp.example.com/token",
3688 "client",
3689 )
3690 .build(),
3691 );
3692 let err = cfg
3693 .validate()
3694 .expect_err("http authorize_url must be rejected");
3695 assert!(
3696 err.to_string().contains("oauth.proxy.authorize_url"),
3697 "error must reference proxy.authorize_url; got {err}"
3698 );
3699 }
3700
3701 #[test]
3702 fn validate_rejects_http_proxy_token_url() {
3703 let mut cfg = validation_https_config();
3704 cfg.proxy = Some(
3705 OAuthProxyConfig::builder(
3706 "https://idp.example.com/authorize",
3707 "http://idp.example.com/token", "client",
3709 )
3710 .build(),
3711 );
3712 let err = cfg.validate().expect_err("http token_url must be rejected");
3713 assert!(
3714 err.to_string().contains("oauth.proxy.token_url"),
3715 "error must reference proxy.token_url; got {err}"
3716 );
3717 }
3718
3719 #[test]
3720 fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
3721 let mut cfg = validation_https_config();
3722 cfg.proxy = Some(
3723 OAuthProxyConfig::builder(
3724 "https://idp.example.com/authorize",
3725 "https://idp.example.com/token",
3726 "client",
3727 )
3728 .introspection_url("http://idp.example.com/introspect")
3729 .build(),
3730 );
3731 let err = cfg
3732 .validate()
3733 .expect_err("http introspection_url must be rejected");
3734 assert!(err.to_string().contains("oauth.proxy.introspection_url"));
3735
3736 let mut cfg = validation_https_config();
3737 cfg.proxy = Some(
3738 OAuthProxyConfig::builder(
3739 "https://idp.example.com/authorize",
3740 "https://idp.example.com/token",
3741 "client",
3742 )
3743 .revocation_url("http://idp.example.com/revoke")
3744 .build(),
3745 );
3746 let err = cfg
3747 .validate()
3748 .expect_err("http revocation_url must be rejected");
3749 assert!(err.to_string().contains("oauth.proxy.revocation_url"));
3750 }
3751
3752 #[test]
3755 fn validate_rejects_exposed_admin_endpoints_without_auth() {
3756 let mut cfg = validation_https_config();
3757 cfg.proxy = Some(
3758 OAuthProxyConfig::builder(
3759 "https://idp.example.com/authorize",
3760 "https://idp.example.com/token",
3761 "client",
3762 )
3763 .introspection_url("https://idp.example.com/introspect")
3764 .expose_admin_endpoints(true)
3765 .build(),
3766 );
3767 let err = cfg
3768 .validate()
3769 .expect_err("expose_admin_endpoints without auth must fail");
3770 let msg = err.to_string();
3771 assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
3772 assert!(
3773 msg.contains("allow_unauthenticated_admin_endpoints"),
3774 "{msg}"
3775 );
3776 }
3777
3778 #[test]
3779 fn validate_accepts_exposed_admin_endpoints_with_auth() {
3780 let mut cfg = validation_https_config();
3781 cfg.proxy = Some(
3782 OAuthProxyConfig::builder(
3783 "https://idp.example.com/authorize",
3784 "https://idp.example.com/token",
3785 "client",
3786 )
3787 .introspection_url("https://idp.example.com/introspect")
3788 .expose_admin_endpoints(true)
3789 .require_auth_on_admin_endpoints(true)
3790 .build(),
3791 );
3792 cfg.validate()
3793 .expect("authed admin endpoints must validate");
3794 }
3795
3796 #[test]
3797 fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
3798 let mut cfg = validation_https_config();
3799 cfg.proxy = Some(
3800 OAuthProxyConfig::builder(
3801 "https://idp.example.com/authorize",
3802 "https://idp.example.com/token",
3803 "client",
3804 )
3805 .introspection_url("https://idp.example.com/introspect")
3806 .expose_admin_endpoints(true)
3807 .allow_unauthenticated_admin_endpoints(true)
3808 .build(),
3809 );
3810 cfg.validate()
3811 .expect("explicit unauth opt-out must validate");
3812 }
3813
3814 #[test]
3815 fn validate_accepts_unexposed_admin_endpoints_without_auth() {
3816 let mut cfg = validation_https_config();
3819 cfg.proxy = Some(
3820 OAuthProxyConfig::builder(
3821 "https://idp.example.com/authorize",
3822 "https://idp.example.com/token",
3823 "client",
3824 )
3825 .introspection_url("https://idp.example.com/introspect")
3826 .build(),
3827 );
3828 cfg.validate()
3829 .expect("unexposed admin endpoints must validate");
3830 }
3831
3832 #[test]
3833 fn validate_rejects_http_token_exchange_url() {
3834 let mut cfg = validation_https_config();
3835 cfg.token_exchange = Some(TokenExchangeConfig::new(
3836 "http://idp.example.com/token".into(), "client".into(),
3838 None,
3839 None,
3840 "downstream".into(),
3841 ));
3842 let err = cfg
3843 .validate()
3844 .expect_err("http token_exchange.token_url must be rejected");
3845 assert!(
3846 err.to_string().contains("oauth.token_exchange.token_url"),
3847 "error must reference token_exchange.token_url; got {err}"
3848 );
3849 }
3850
3851 #[test]
3852 fn validate_rejects_unparseable_url() {
3853 let mut cfg = validation_https_config();
3854 cfg.jwks_uri = "not a url".into();
3855 let err = cfg
3856 .validate()
3857 .expect_err("unparseable URL must be rejected");
3858 assert!(err.to_string().contains("invalid URL"));
3859 }
3860
3861 #[test]
3862 fn validate_rejects_non_http_scheme() {
3863 let mut cfg = validation_https_config();
3864 cfg.jwks_uri = "file:///etc/passwd".into();
3865 let err = cfg.validate().expect_err("file:// scheme must be rejected");
3866 let msg = err.to_string();
3867 assert!(
3868 msg.contains("must use https scheme") && msg.contains("file"),
3869 "error must reject non-http(s) schemes; got {msg:?}"
3870 );
3871 }
3872
3873 #[test]
3874 fn validate_accepts_http_with_escape_hatch() {
3875 let mut cfg = OAuthConfig::builder(
3880 "http://auth.local",
3881 "mcp",
3882 "http://auth.local/.well-known/jwks.json",
3883 )
3884 .allow_http_oauth_urls(true)
3885 .build();
3886 cfg.proxy = Some(
3887 OAuthProxyConfig::builder(
3888 "http://idp.local/authorize",
3889 "http://idp.local/token",
3890 "client",
3891 )
3892 .introspection_url("http://idp.local/introspect")
3893 .revocation_url("http://idp.local/revoke")
3894 .build(),
3895 );
3896 cfg.token_exchange = Some(TokenExchangeConfig::new(
3897 "http://idp.local/token".into(),
3898 "client".into(),
3899 Some(secrecy::SecretString::new("dev-secret".into())),
3900 None,
3901 "downstream".into(),
3902 ));
3903 cfg.validate()
3904 .expect("escape hatch must permit http on all URL fields");
3905 }
3906
3907 #[test]
3908 fn validate_with_escape_hatch_still_rejects_unparseable() {
3909 let mut cfg = validation_https_config();
3912 cfg.allow_http_oauth_urls = true;
3913 cfg.jwks_uri = "::not-a-url::".into();
3914 cfg.validate()
3915 .expect_err("escape hatch must NOT bypass URL parsing");
3916 }
3917
3918 #[tokio::test]
3919 async fn jwks_cache_rejects_redirect_downgrade_to_http() {
3920 rustls::crypto::ring::default_provider()
3935 .install_default()
3936 .ok();
3937
3938 let policy = reqwest::redirect::Policy::custom(|attempt| {
3939 if attempt.url().scheme() != "https" {
3940 attempt.error("redirect to non-HTTPS URL refused")
3941 } else if attempt.previous().len() >= 2 {
3942 attempt.error("too many redirects (max 2)")
3943 } else {
3944 attempt.follow()
3945 }
3946 });
3947 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
3954 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
3955 let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
3956 crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
3957 );
3958 let client = reqwest::Client::builder()
3959 .no_proxy()
3960 .dns_resolver(Arc::clone(&resolver))
3961 .timeout(Duration::from_secs(5))
3962 .connect_timeout(Duration::from_secs(3))
3963 .redirect(policy)
3964 .build()
3965 .expect("test client builds");
3966
3967 let mock = wiremock::MockServer::start().await;
3968 wiremock::Mock::given(wiremock::matchers::method("GET"))
3969 .and(wiremock::matchers::path("/jwks.json"))
3970 .respond_with(
3971 wiremock::ResponseTemplate::new(302)
3972 .insert_header("location", "http://example.invalid/jwks.json"),
3973 )
3974 .mount(&mock)
3975 .await;
3976
3977 let url = format!("{}/jwks.json", mock.uri());
3986 let err = client
3987 .get(&url)
3988 .send()
3989 .await
3990 .expect_err("redirect policy must reject scheme downgrade");
3991 let chain = format!("{err:#}");
3992 assert!(
3993 chain.contains("redirect to non-HTTPS URL refused")
3994 || chain.to_lowercase().contains("redirect"),
3995 "error must surface redirect-policy rejection; got {chain:?}"
3996 );
3997 }
3998
3999 use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
4004
4005 fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
4007 let mut rng = rsa::rand_core::OsRng;
4008 let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
4009 let private_pem = private_key
4010 .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
4011 .expect("PKCS8 PEM export")
4012 .to_string();
4013
4014 let public_key = private_key.to_public_key();
4015 let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
4016 let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
4017
4018 let jwks = serde_json::json!({
4019 "keys": [{
4020 "kty": "RSA",
4021 "use": "sig",
4022 "alg": "RS256",
4023 "kid": kid,
4024 "n": n,
4025 "e": e
4026 }]
4027 });
4028
4029 (private_pem, jwks)
4030 }
4031
4032 fn mint_token(
4034 private_pem: &str,
4035 kid: &str,
4036 issuer: &str,
4037 audience: &str,
4038 subject: &str,
4039 scope: &str,
4040 ) -> String {
4041 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4042 .expect("encoding key from PEM");
4043 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4044 header.kid = Some(kid.into());
4045
4046 let now = jsonwebtoken::get_current_timestamp();
4047 let claims = serde_json::json!({
4048 "iss": issuer,
4049 "aud": audience,
4050 "sub": subject,
4051 "scope": scope,
4052 "exp": now + 3600,
4053 "iat": now,
4054 });
4055
4056 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4057 }
4058
4059 fn mint_token_without_sub(
4061 private_pem: &str,
4062 kid: &str,
4063 issuer: &str,
4064 audience: &str,
4065 scope: &str,
4066 ) -> String {
4067 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4068 .expect("encoding key from PEM");
4069 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4070 header.kid = Some(kid.into());
4071 let now = jsonwebtoken::get_current_timestamp();
4072 let claims = serde_json::json!({
4073 "iss": issuer,
4074 "aud": audience,
4075 "scope": scope,
4076 "exp": now + 3600,
4077 "iat": now,
4078 });
4079 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4080 }
4081
4082 fn test_config(jwks_uri: &str) -> OAuthConfig {
4083 OAuthConfig {
4084 require_subject: false,
4085 issuer: "https://auth.test.local".into(),
4086 audience: "https://mcp.test.local/mcp".into(),
4087 jwks_uri: jwks_uri.into(),
4088 scopes: vec![
4089 ScopeMapping {
4090 scope: "mcp:read".into(),
4091 role: "viewer".into(),
4092 },
4093 ScopeMapping {
4094 scope: "mcp:admin".into(),
4095 role: "ops".into(),
4096 },
4097 ],
4098 role_claim: None,
4099 role_mappings: vec![],
4100 jwks_cache_ttl: "5m".into(),
4101 proxy: None,
4102 token_exchange: None,
4103 ca_cert_path: None,
4104 allow_http_oauth_urls: true,
4105 max_jwks_keys: default_max_jwks_keys(),
4106 #[allow(
4107 deprecated,
4108 reason = "test fixture: explicit value for the deprecated field"
4109 )]
4110 strict_audience_validation: None,
4111 audience_validation_mode: None,
4112 jwks_max_response_bytes: default_jwks_max_bytes(),
4113 ssrf_allowlist: None,
4114 }
4115 }
4116
4117 fn test_cache(config: &OAuthConfig) -> JwksCache {
4118 JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
4119 }
4120
4121 async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
4128 let kid = "test-h2-stale";
4129 let (pem, jwks) = generate_test_keypair(kid);
4130 let mock_server = wiremock::MockServer::start().await;
4131 wiremock::Mock::given(wiremock::matchers::method("GET"))
4132 .and(wiremock::matchers::path("/jwks.json"))
4133 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4134 .mount(&mock_server)
4135 .await;
4136 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4137 let mut config = test_config(&jwks_uri);
4138 config.jwks_cache_ttl = ttl.into();
4139 let cache = test_cache(&config);
4140 cache.__test_refresh_now().await.expect("prime JWKS cache");
4141 assert!(cache.__test_has_kid(kid).await, "kid must be primed");
4142
4143 mock_server.reset().await;
4144 wiremock::Mock::given(wiremock::matchers::method("GET"))
4145 .and(wiremock::matchers::path("/jwks.json"))
4146 .respond_with(wiremock::ResponseTemplate::new(503))
4147 .mount(&mock_server)
4148 .await;
4149
4150 let token = mint_token(
4151 &pem,
4152 kid,
4153 "https://auth.test.local",
4154 "https://mcp.test.local/mcp",
4155 "h2-client",
4156 "mcp:read",
4157 );
4158 (cache, token, mock_server)
4159 }
4160
4161 #[tokio::test]
4162 async fn expired_jwks_fails_closed_when_refresh_fails() {
4163 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
4164 tokio::time::sleep(Duration::from_millis(200)).await;
4165 let failure = cache
4166 .validate_token_with_reason(&token)
4167 .await
4168 .expect_err("an expired cache whose refresh fails must not serve the stale key");
4169 assert_eq!(failure, JwtValidationFailure::Invalid);
4170 }
4171
4172 #[tokio::test]
4173 async fn fresh_jwks_still_validates() {
4174 let kid = "test-h2-fresh";
4175 let (pem, jwks) = generate_test_keypair(kid);
4176 let mock_server = wiremock::MockServer::start().await;
4177 wiremock::Mock::given(wiremock::matchers::method("GET"))
4178 .and(wiremock::matchers::path("/jwks.json"))
4179 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4180 .mount(&mock_server)
4181 .await;
4182 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4183 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4185 let token = mint_token(
4186 &pem,
4187 kid,
4188 "https://auth.test.local",
4189 "https://mcp.test.local/mcp",
4190 "h2-fresh-client",
4191 "mcp:read",
4192 );
4193 cache
4194 .validate_token_with_reason(&token)
4195 .await
4196 .expect("a reachable JWKS must still validate a matching token");
4197 }
4198
4199 #[tokio::test]
4200 async fn cooldown_active_plus_expired_fails_closed() {
4201 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
4202 tokio::time::sleep(Duration::from_millis(200)).await;
4203 assert_eq!(
4206 cache
4207 .validate_token_with_reason(&token)
4208 .await
4209 .expect_err("first attempt must fail closed"),
4210 JwtValidationFailure::Invalid,
4211 );
4212 let failure = cache
4215 .validate_token_with_reason(&token)
4216 .await
4217 .expect_err("cooldown-active + expired cache must still fail closed");
4218 assert_eq!(failure, JwtValidationFailure::Invalid);
4219 }
4220
4221 #[tokio::test]
4222 async fn valid_jwt_returns_identity() {
4223 let kid = "test-key-1";
4224 let (pem, jwks) = generate_test_keypair(kid);
4225
4226 let mock_server = wiremock::MockServer::start().await;
4227 wiremock::Mock::given(wiremock::matchers::method("GET"))
4228 .and(wiremock::matchers::path("/jwks.json"))
4229 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4230 .mount(&mock_server)
4231 .await;
4232
4233 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4234 let config = test_config(&jwks_uri);
4235 let cache = test_cache(&config);
4236
4237 let token = mint_token(
4238 &pem,
4239 kid,
4240 "https://auth.test.local",
4241 "https://mcp.test.local/mcp",
4242 "ci-bot",
4243 "mcp:read mcp:other",
4244 );
4245
4246 let identity = cache.validate_token(&token).await;
4247 assert!(identity.is_some(), "valid JWT should authenticate");
4248 let id = identity.unwrap();
4249 assert_eq!(id.name, "ci-bot");
4250 assert_eq!(id.role, "viewer"); assert_eq!(id.method, AuthMethod::OAuthJwt);
4252 }
4253
4254 #[test]
4257 fn unknown_kid_with_named_keys_rejected() {
4258 let mut keys = HashMap::new();
4259 keys.insert(
4260 "kid-1".to_owned(),
4261 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4262 );
4263 let cached = CachedKeys {
4264 keys,
4265 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4266 fetched_at: Instant::now(),
4267 ttl: Duration::from_secs(300),
4268 };
4269 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
4271 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
4275 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
4277 }
4278
4279 #[test]
4280 fn no_kid_token_matches_unnamed_key() {
4281 let mut keys = HashMap::new();
4282 keys.insert(
4283 "kid-1".to_owned(),
4284 (Algorithm::RS256, DecodingKey::from_secret(b"named")),
4285 );
4286 let cached = CachedKeys {
4287 keys,
4288 unnamed_keys: vec![(Algorithm::RS256, DecodingKey::from_secret(b"unnamed"))],
4289 fetched_at: Instant::now(),
4290 ttl: Duration::from_secs(300),
4291 };
4292 assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
4295 }
4296
4297 #[tokio::test]
4298 async fn require_subject_rejects_subject_less() {
4299 let kid = "test-key-reqsub";
4300 let (pem, jwks) = generate_test_keypair(kid);
4301 let mock_server = wiremock::MockServer::start().await;
4302 wiremock::Mock::given(wiremock::matchers::method("GET"))
4303 .and(wiremock::matchers::path("/jwks.json"))
4304 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4305 .mount(&mock_server)
4306 .await;
4307 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4308 let mut config = test_config(&jwks_uri);
4309 config.require_subject = true;
4310 let cache = test_cache(&config);
4311
4312 let no_sub = mint_token_without_sub(
4313 &pem,
4314 kid,
4315 "https://auth.test.local",
4316 "https://mcp.test.local/mcp",
4317 "mcp:read",
4318 );
4319 assert!(
4320 cache.validate_token(&no_sub).await.is_none(),
4321 "require_subject must reject a token with no sub"
4322 );
4323
4324 let with_sub = mint_token(
4325 &pem,
4326 kid,
4327 "https://auth.test.local",
4328 "https://mcp.test.local/mcp",
4329 "svc",
4330 "mcp:read",
4331 );
4332 assert!(
4333 cache.validate_token(&with_sub).await.is_some(),
4334 "a token carrying sub must still be accepted"
4335 );
4336 }
4337
4338 #[tokio::test]
4339 async fn subject_less_token_accepted_by_default() {
4340 let kid = "test-key-nosub-default";
4341 let (pem, jwks) = generate_test_keypair(kid);
4342 let mock_server = wiremock::MockServer::start().await;
4343 wiremock::Mock::given(wiremock::matchers::method("GET"))
4344 .and(wiremock::matchers::path("/jwks.json"))
4345 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4346 .mount(&mock_server)
4347 .await;
4348 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4349 let config = test_config(&jwks_uri); let cache = test_cache(&config);
4351 let no_sub = mint_token_without_sub(
4352 &pem,
4353 kid,
4354 "https://auth.test.local",
4355 "https://mcp.test.local/mcp",
4356 "mcp:read",
4357 );
4358 assert!(
4359 cache.validate_token(&no_sub).await.is_some(),
4360 "the default policy must accept a sub-less (client-credentials) token"
4361 );
4362 }
4363
4364 #[tokio::test]
4365 async fn credential_post_does_not_follow_redirect() {
4366 let mock = wiremock::MockServer::start().await;
4369 wiremock::Mock::given(wiremock::matchers::method("POST"))
4370 .and(wiremock::matchers::path("/followed"))
4371 .respond_with(wiremock::ResponseTemplate::new(200))
4372 .expect(0) .mount(&mock)
4374 .await;
4375 wiremock::Mock::given(wiremock::matchers::method("POST"))
4376 .and(wiremock::matchers::path("/token"))
4377 .respond_with(
4378 wiremock::ResponseTemplate::new(307)
4379 .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
4380 )
4381 .mount(&mock)
4382 .await;
4383
4384 let client = OauthHttpClient::build(None).expect("build oauth http client");
4385 let resp = client
4386 .credential_client
4387 .post(format!("{}/token", mock.uri()))
4388 .body("grant_type=client_credentials")
4389 .send()
4390 .await
4391 .expect("request sent");
4392 assert_eq!(
4393 resp.status().as_u16(),
4394 307,
4395 "credential client must surface the 307 rather than follow it"
4396 );
4397 }
4398
4399 #[tokio::test]
4400 async fn jwks_get_still_follows_screened_redirect() {
4401 let mock = wiremock::MockServer::start().await;
4407 wiremock::Mock::given(wiremock::matchers::method("GET"))
4408 .and(wiremock::matchers::path("/jwks.json"))
4409 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
4410 "location",
4411 format!("{}/jwks-final.json", mock.uri()).as_str(),
4412 ))
4413 .mount(&mock)
4414 .await;
4415 wiremock::Mock::given(wiremock::matchers::method("GET"))
4416 .and(wiremock::matchers::path("/jwks-final.json"))
4417 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
4418 .expect(1)
4419 .mount(&mock)
4420 .await;
4421
4422 let mut allowlist = OAuthSsrfAllowlist::default();
4423 allowlist.cidrs.push("127.0.0.0/8".into());
4424 allowlist.cidrs.push("::1/128".into());
4425 let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
4426 config.allow_http_oauth_urls = true;
4427 config.ssrf_allowlist = Some(allowlist);
4428
4429 let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
4430 let resp = client
4431 .inner
4432 .get(format!("{}/jwks.json", mock.uri()))
4433 .send()
4434 .await
4435 .expect("request sent");
4436 assert_eq!(
4437 resp.status().as_u16(),
4438 200,
4439 "JWKS client must follow the screened redirect to the final endpoint"
4440 );
4441 assert_eq!(resp.text().await.expect("response body"), "reached");
4442 }
4443
4444 #[tokio::test]
4445 async fn wrong_issuer_rejected() {
4446 let kid = "test-key-2";
4447 let (pem, jwks) = generate_test_keypair(kid);
4448
4449 let mock_server = wiremock::MockServer::start().await;
4450 wiremock::Mock::given(wiremock::matchers::method("GET"))
4451 .and(wiremock::matchers::path("/jwks.json"))
4452 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4453 .mount(&mock_server)
4454 .await;
4455
4456 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4457 let config = test_config(&jwks_uri);
4458 let cache = test_cache(&config);
4459
4460 let token = mint_token(
4461 &pem,
4462 kid,
4463 "https://wrong-issuer.example.com", "https://mcp.test.local/mcp",
4465 "attacker",
4466 "mcp:admin",
4467 );
4468
4469 assert!(cache.validate_token(&token).await.is_none());
4470 }
4471
4472 #[tokio::test]
4473 async fn wrong_audience_rejected() {
4474 let kid = "test-key-3";
4475 let (pem, jwks) = generate_test_keypair(kid);
4476
4477 let mock_server = wiremock::MockServer::start().await;
4478 wiremock::Mock::given(wiremock::matchers::method("GET"))
4479 .and(wiremock::matchers::path("/jwks.json"))
4480 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4481 .mount(&mock_server)
4482 .await;
4483
4484 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4485 let config = test_config(&jwks_uri);
4486 let cache = test_cache(&config);
4487
4488 let token = mint_token(
4489 &pem,
4490 kid,
4491 "https://auth.test.local",
4492 "https://wrong-audience.example.com", "attacker",
4494 "mcp:admin",
4495 );
4496
4497 assert!(cache.validate_token(&token).await.is_none());
4498 }
4499
4500 #[tokio::test]
4501 async fn expired_jwt_rejected() {
4502 let kid = "test-key-4";
4503 let (pem, jwks) = generate_test_keypair(kid);
4504
4505 let mock_server = wiremock::MockServer::start().await;
4506 wiremock::Mock::given(wiremock::matchers::method("GET"))
4507 .and(wiremock::matchers::path("/jwks.json"))
4508 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4509 .mount(&mock_server)
4510 .await;
4511
4512 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4513 let config = test_config(&jwks_uri);
4514 let cache = test_cache(&config);
4515
4516 let encoding_key =
4518 jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
4519 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4520 header.kid = Some(kid.into());
4521 let now = jsonwebtoken::get_current_timestamp();
4522 let claims = serde_json::json!({
4523 "iss": "https://auth.test.local",
4524 "aud": "https://mcp.test.local/mcp",
4525 "sub": "expired-bot",
4526 "scope": "mcp:read",
4527 "exp": now - 120,
4528 "iat": now - 3720,
4529 });
4530 let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
4531
4532 assert!(cache.validate_token(&token).await.is_none());
4533 }
4534
4535 #[tokio::test]
4536 async fn no_matching_scope_rejected() {
4537 let kid = "test-key-5";
4538 let (pem, jwks) = generate_test_keypair(kid);
4539
4540 let mock_server = wiremock::MockServer::start().await;
4541 wiremock::Mock::given(wiremock::matchers::method("GET"))
4542 .and(wiremock::matchers::path("/jwks.json"))
4543 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4544 .mount(&mock_server)
4545 .await;
4546
4547 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4548 let config = test_config(&jwks_uri);
4549 let cache = test_cache(&config);
4550
4551 let token = mint_token(
4552 &pem,
4553 kid,
4554 "https://auth.test.local",
4555 "https://mcp.test.local/mcp",
4556 "limited-bot",
4557 "some:other:scope", );
4559
4560 assert!(cache.validate_token(&token).await.is_none());
4561 }
4562
4563 #[tokio::test]
4564 async fn wrong_signing_key_rejected() {
4565 let kid = "test-key-6";
4566 let (_pem, jwks) = generate_test_keypair(kid);
4567
4568 let (attacker_pem, _) = generate_test_keypair(kid);
4570
4571 let mock_server = wiremock::MockServer::start().await;
4572 wiremock::Mock::given(wiremock::matchers::method("GET"))
4573 .and(wiremock::matchers::path("/jwks.json"))
4574 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4575 .mount(&mock_server)
4576 .await;
4577
4578 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4579 let config = test_config(&jwks_uri);
4580 let cache = test_cache(&config);
4581
4582 let token = mint_token(
4584 &attacker_pem,
4585 kid,
4586 "https://auth.test.local",
4587 "https://mcp.test.local/mcp",
4588 "attacker",
4589 "mcp:admin",
4590 );
4591
4592 assert!(cache.validate_token(&token).await.is_none());
4593 }
4594
4595 #[tokio::test]
4596 async fn admin_scope_maps_to_ops_role() {
4597 let kid = "test-key-7";
4598 let (pem, jwks) = generate_test_keypair(kid);
4599
4600 let mock_server = wiremock::MockServer::start().await;
4601 wiremock::Mock::given(wiremock::matchers::method("GET"))
4602 .and(wiremock::matchers::path("/jwks.json"))
4603 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4604 .mount(&mock_server)
4605 .await;
4606
4607 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4608 let config = test_config(&jwks_uri);
4609 let cache = test_cache(&config);
4610
4611 let token = mint_token(
4612 &pem,
4613 kid,
4614 "https://auth.test.local",
4615 "https://mcp.test.local/mcp",
4616 "admin-bot",
4617 "mcp:admin",
4618 );
4619
4620 let id = cache
4621 .validate_token(&token)
4622 .await
4623 .expect("should authenticate");
4624 assert_eq!(id.role, "ops");
4625 assert_eq!(id.name, "admin-bot");
4626 }
4627
4628 #[tokio::test]
4629 async fn jwks_server_down_returns_none() {
4630 let config = test_config("http://127.0.0.1:1/jwks.json");
4632 let cache = test_cache(&config);
4633
4634 let kid = "orphan-key";
4635 let (pem, _) = generate_test_keypair(kid);
4636 let token = mint_token(
4637 &pem,
4638 kid,
4639 "https://auth.test.local",
4640 "https://mcp.test.local/mcp",
4641 "bot",
4642 "mcp:read",
4643 );
4644
4645 assert!(cache.validate_token(&token).await.is_none());
4646 }
4647
4648 #[test]
4653 fn resolve_claim_path_flat_string() {
4654 let mut extra = HashMap::new();
4655 extra.insert(
4656 "scope".into(),
4657 serde_json::Value::String("mcp:read mcp:admin".into()),
4658 );
4659 let values = resolve_claim_path(&extra, "scope");
4660 assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
4661 }
4662
4663 #[test]
4664 fn resolve_claim_path_flat_array() {
4665 let mut extra = HashMap::new();
4666 extra.insert(
4667 "roles".into(),
4668 serde_json::json!(["mcp-admin", "mcp-viewer"]),
4669 );
4670 let values = resolve_claim_path(&extra, "roles");
4671 assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
4672 }
4673
4674 #[test]
4675 fn resolve_claim_path_nested_keycloak() {
4676 let mut extra = HashMap::new();
4677 extra.insert(
4678 "realm_access".into(),
4679 serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
4680 );
4681 let values = resolve_claim_path(&extra, "realm_access.roles");
4682 assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
4683 }
4684
4685 #[test]
4686 fn resolve_claim_path_missing_returns_empty() {
4687 let extra = HashMap::new();
4688 assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
4689 }
4690
4691 #[test]
4692 fn resolve_claim_path_numeric_leaf_returns_empty() {
4693 let mut extra = HashMap::new();
4694 extra.insert("count".into(), serde_json::json!(42));
4695 assert!(resolve_claim_path(&extra, "count").is_empty());
4696 }
4697
4698 fn make_claims(json: serde_json::Value) -> Claims {
4699 serde_json::from_value(json).expect("test claims must deserialize")
4700 }
4701
4702 #[test]
4703 fn first_class_scope_claim_splits_on_whitespace() {
4704 let claims = make_claims(serde_json::json!({
4705 "iss": "https://issuer.example.com",
4706 "exp": 9_999_999_999_u64,
4707 "scope": "read write admin",
4708 }));
4709 let values = first_class_claim_values(&claims, "scope");
4710 assert_eq!(values, vec!["read", "write", "admin"]);
4711 }
4712
4713 #[test]
4714 fn first_class_sub_claim_returns_single_value() {
4715 let claims = make_claims(serde_json::json!({
4716 "iss": "https://issuer.example.com",
4717 "exp": 9_999_999_999_u64,
4718 "sub": "service-account-orders",
4719 }));
4720 let values = first_class_claim_values(&claims, "sub");
4721 assert_eq!(values, vec!["service-account-orders"]);
4722 }
4723
4724 #[test]
4725 fn first_class_aud_claim_returns_every_audience() {
4726 let claims = make_claims(serde_json::json!({
4727 "iss": "https://issuer.example.com",
4728 "exp": 9_999_999_999_u64,
4729 "aud": ["api-a", "api-b"],
4730 }));
4731 let values = first_class_claim_values(&claims, "aud");
4732 assert_eq!(values, vec!["api-a", "api-b"]);
4733 }
4734
4735 #[test]
4736 fn first_class_unknown_path_returns_empty() {
4737 let claims = make_claims(serde_json::json!({
4738 "iss": "https://issuer.example.com",
4739 "exp": 9_999_999_999_u64,
4740 }));
4741 assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
4742 }
4743
4744 fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
4750 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4751 .expect("encoding key from PEM");
4752 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4753 header.kid = Some(kid.into());
4754 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4755 }
4756
4757 fn test_config_with_role_claim(
4758 jwks_uri: &str,
4759 role_claim: &str,
4760 role_mappings: Vec<RoleMapping>,
4761 ) -> OAuthConfig {
4762 OAuthConfig {
4763 require_subject: false,
4764 issuer: "https://auth.test.local".into(),
4765 audience: "https://mcp.test.local/mcp".into(),
4766 jwks_uri: jwks_uri.into(),
4767 scopes: vec![],
4768 role_claim: Some(role_claim.into()),
4769 role_mappings,
4770 jwks_cache_ttl: "5m".into(),
4771 proxy: None,
4772 token_exchange: None,
4773 ca_cert_path: None,
4774 allow_http_oauth_urls: true,
4775 max_jwks_keys: default_max_jwks_keys(),
4776 #[allow(
4777 deprecated,
4778 reason = "test fixture: explicit value for the deprecated field"
4779 )]
4780 strict_audience_validation: None,
4781 audience_validation_mode: None,
4782 jwks_max_response_bytes: default_jwks_max_bytes(),
4783 ssrf_allowlist: None,
4784 }
4785 }
4786
4787 #[tokio::test]
4788 async fn screen_oauth_target_rejects_literal_ip() {
4789 let err = screen_oauth_target(
4790 "https://127.0.0.1/jwks.json",
4791 false,
4792 &crate::ssrf::CompiledSsrfAllowlist::default(),
4793 )
4794 .await
4795 .expect_err("literal IPs must be rejected");
4796 let msg = err.to_string();
4797 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4798 }
4799
4800 #[tokio::test]
4801 async fn screen_oauth_target_rejects_private_dns_resolution() {
4802 let err = screen_oauth_target(
4803 "https://localhost/jwks.json",
4804 false,
4805 &crate::ssrf::CompiledSsrfAllowlist::default(),
4806 )
4807 .await
4808 .expect_err("localhost resolution must be rejected");
4809 let msg = err.to_string();
4810 assert!(
4811 msg.contains("blocked IP") && msg.contains("loopback"),
4812 "got {msg:?}"
4813 );
4814 }
4815
4816 #[tokio::test]
4817 async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
4818 let err = screen_oauth_target(
4819 "http://127.0.0.1/jwks.json",
4820 true,
4821 &crate::ssrf::CompiledSsrfAllowlist::default(),
4822 )
4823 .await
4824 .expect_err("literal IPs must still be rejected when http is allowed");
4825 let msg = err.to_string();
4826 assert!(msg.contains("literal IPv4 addresses are forbidden"));
4827 }
4828
4829 #[tokio::test]
4830 async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
4831 let err = screen_oauth_target(
4832 "http://localhost/jwks.json",
4833 true,
4834 &crate::ssrf::CompiledSsrfAllowlist::default(),
4835 )
4836 .await
4837 .expect_err("private DNS resolution must still be rejected when http is allowed");
4838 let msg = err.to_string();
4839 assert!(
4840 msg.contains("blocked IP") && msg.contains("loopback"),
4841 "got {msg:?}"
4842 );
4843 }
4844
4845 #[tokio::test]
4846 async fn screen_oauth_target_allows_public_hostname() {
4847 screen_oauth_target(
4848 "https://example.com/.well-known/jwks.json",
4849 false,
4850 &crate::ssrf::CompiledSsrfAllowlist::default(),
4851 )
4852 .await
4853 .expect("public hostname should pass screening");
4854 }
4855
4856 fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
4862 let raw = OAuthSsrfAllowlist {
4863 hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
4864 cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
4865 };
4866 compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
4867 }
4868
4869 #[test]
4870 fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
4871 let raw = OAuthSsrfAllowlist {
4872 hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
4873 cidrs: vec![],
4874 };
4875 let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
4876 assert_eq!(compiled.host_count(), 1);
4877 assert!(compiled.host_allowed("rhbk.ops.example.com"));
4878 assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
4879 }
4880
4881 #[test]
4882 fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
4883 let raw = OAuthSsrfAllowlist {
4884 hosts: vec!["10.0.0.1".into()],
4885 cidrs: vec![],
4886 };
4887 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
4888 assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
4889 }
4890
4891 #[test]
4892 fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
4893 let raw = OAuthSsrfAllowlist {
4894 hosts: vec!["rhbk.ops.example.com:8443".into()],
4895 cidrs: vec![],
4896 };
4897 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
4898 assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
4899 }
4900
4901 #[test]
4904 fn internal_suffix_rejected_by_default() {
4905 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4906 for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
4907 assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
4908 }
4909 }
4910
4911 #[test]
4912 fn exact_allowlisted_internal_permitted() {
4913 let allow = make_allowlist(&["idp.internal"], &[]);
4914 assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
4915 assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
4916 }
4917
4918 #[test]
4919 fn subdomain_of_allowlisted_internal_still_rejected() {
4920 let allow = make_allowlist(&["idp.internal"], &[]);
4921 assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
4922 }
4923
4924 #[test]
4925 fn cidr_allowlist_does_not_bypass_suffix_denylist() {
4926 let allow = make_allowlist(&[], &["10.0.0.0/8"]);
4927 assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
4928 }
4929
4930 #[test]
4931 fn public_hostname_not_blocked_by_suffix() {
4932 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
4933 assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
4934 }
4935
4936 #[test]
4937 fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
4938 let raw = OAuthSsrfAllowlist {
4939 hosts: vec![],
4940 cidrs: vec!["not-a-cidr".into()],
4941 };
4942 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
4943 assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
4944 }
4945
4946 #[test]
4947 fn validate_rejects_misconfigured_allowlist() {
4948 let mut cfg = OAuthConfig::builder(
4949 "https://auth.example.com/",
4950 "mcp",
4951 "https://auth.example.com/jwks.json",
4952 )
4953 .build();
4954 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4955 hosts: vec!["10.0.0.1".into()],
4956 cidrs: vec![],
4957 });
4958 let err = cfg
4959 .validate()
4960 .expect_err("literal IP host must be rejected");
4961 assert!(
4962 err.to_string().contains("oauth.ssrf_allowlist"),
4963 "got {err}"
4964 );
4965 }
4966
4967 #[tokio::test]
4968 async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
4969 let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
4973 let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
4974 .await
4975 .expect_err("loopback must still be blocked when not in allowlist");
4976 let msg = err.to_string();
4977 assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
4978 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4979 assert!(msg.contains("SECURITY.md"), "got {msg:?}");
4980 }
4981
4982 #[tokio::test]
4983 async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
4984 let err = screen_oauth_target(
4987 "https://localhost/jwks.json",
4988 false,
4989 &crate::ssrf::CompiledSsrfAllowlist::default(),
4990 )
4991 .await
4992 .expect_err("loopback rejection");
4993 let msg = err.to_string();
4994 assert!(msg.contains("blocked IP"), "got {msg:?}");
4995 assert!(msg.contains("loopback"), "got {msg:?}");
4996 assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4998 }
4999
5000 #[tokio::test]
5001 async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
5002 let allow = make_allowlist(&["localhost"], &[]);
5004 screen_oauth_target("https://localhost/jwks.json", false, &allow)
5005 .await
5006 .expect("allowlisted host must pass");
5007 }
5008
5009 #[tokio::test]
5010 async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
5011 let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
5014 screen_oauth_target("https://localhost/jwks.json", false, &allow)
5015 .await
5016 .expect("allowlisted CIDR must pass");
5017 }
5018
5019 #[tokio::test]
5020 async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
5021 let mut cfg = OAuthConfig::builder(
5022 "https://auth.example.com/",
5023 "mcp",
5024 "https://auth.example.com/jwks.json",
5025 )
5026 .build();
5027 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
5028 hosts: vec![],
5029 cidrs: vec!["bad-cidr".into()],
5030 });
5031 let Err(err) = JwksCache::new(&cfg) else {
5032 panic!("invalid CIDR must fail JwksCache::new")
5033 };
5034 let msg = err.to_string();
5035 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
5036 }
5037
5038 #[tokio::test]
5039 async fn jwks_cache_new_invalid_ttl_is_err() {
5040 let cfg = OAuthConfig::builder(
5043 "https://auth.example.com/",
5044 "mcp",
5045 "https://auth.example.com/jwks.json",
5046 )
5047 .jwks_cache_ttl("not-a-duration")
5048 .build();
5049 let Err(err) = JwksCache::new(&cfg) else {
5050 panic!("invalid jwks_cache_ttl must fail JwksCache::new")
5051 };
5052 let msg = err.to_string();
5053 assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
5054 }
5055
5056 #[tokio::test]
5057 async fn audience_default_is_strict() {
5058 let kid = "test-audience-azp-default";
5059 let (pem, jwks) = generate_test_keypair(kid);
5060
5061 let mock_server = wiremock::MockServer::start().await;
5062 wiremock::Mock::given(wiremock::matchers::method("GET"))
5063 .and(wiremock::matchers::path("/jwks.json"))
5064 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5065 .mount(&mock_server)
5066 .await;
5067
5068 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5069 let config = test_config(&jwks_uri);
5070 let cache = test_cache(&config);
5071
5072 let now = jsonwebtoken::get_current_timestamp();
5073 let token = mint_token_with_claims(
5074 &pem,
5075 kid,
5076 &serde_json::json!({
5077 "iss": "https://auth.test.local",
5078 "aud": "https://some-other-resource.example.com",
5079 "azp": "https://mcp.test.local/mcp",
5080 "sub": "compat-client",
5081 "scope": "mcp:read",
5082 "exp": now + 3600,
5083 "iat": now,
5084 }),
5085 );
5086
5087 let failure = cache
5088 .validate_token_with_reason(&token)
5089 .await
5090 .expect_err("the default policy is Strict and must reject an azp-only match");
5091 assert_eq!(failure, JwtValidationFailure::Invalid);
5092 }
5093
5094 #[tokio::test]
5095 async fn audience_warn_still_accepts_azp() {
5096 let kid = "test-audience-warn-optin";
5097 let (pem, jwks) = generate_test_keypair(kid);
5098
5099 let mock_server = wiremock::MockServer::start().await;
5100 wiremock::Mock::given(wiremock::matchers::method("GET"))
5101 .and(wiremock::matchers::path("/jwks.json"))
5102 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5103 .mount(&mock_server)
5104 .await;
5105
5106 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5107 let mut config = test_config(&jwks_uri);
5108 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
5109 let cache = test_cache(&config);
5110
5111 let now = jsonwebtoken::get_current_timestamp();
5112 let token = mint_token_with_claims(
5113 &pem,
5114 kid,
5115 &serde_json::json!({
5116 "iss": "https://auth.test.local",
5117 "aud": "https://some-other-resource.example.com",
5118 "azp": "https://mcp.test.local/mcp",
5119 "sub": "warn-optin-client",
5120 "scope": "mcp:read",
5121 "exp": now + 3600,
5122 "iat": now,
5123 }),
5124 );
5125
5126 cache.validate_token_with_reason(&token).await.expect(
5127 "the audience_validation_mode=warn opt-out must still accept an azp-only match",
5128 );
5129 }
5130
5131 #[tokio::test]
5132 async fn legacy_strict_false_maps_to_warn() {
5133 let kid = "test-audience-legacy-false";
5134 let (pem, jwks) = generate_test_keypair(kid);
5135
5136 let mock_server = wiremock::MockServer::start().await;
5137 wiremock::Mock::given(wiremock::matchers::method("GET"))
5138 .and(wiremock::matchers::path("/jwks.json"))
5139 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5140 .mount(&mock_server)
5141 .await;
5142
5143 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5144 let mut config = test_config(&jwks_uri);
5145 #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
5148 {
5149 config.strict_audience_validation = Some(false);
5150 }
5151 let cache = test_cache(&config);
5152
5153 let now = jsonwebtoken::get_current_timestamp();
5154 let token = mint_token_with_claims(
5155 &pem,
5156 kid,
5157 &serde_json::json!({
5158 "iss": "https://auth.test.local",
5159 "aud": "https://some-other-resource.example.com",
5160 "azp": "https://mcp.test.local/mcp",
5161 "sub": "legacy-false-client",
5162 "scope": "mcp:read",
5163 "exp": now + 3600,
5164 "iat": now,
5165 }),
5166 );
5167
5168 cache
5169 .validate_token_with_reason(&token)
5170 .await
5171 .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
5172 }
5173
5174 #[tokio::test]
5175 async fn aud_match_always_accepts() {
5176 let kid = "test-audience-aud-match";
5177 let (pem, jwks) = generate_test_keypair(kid);
5178
5179 let mock_server = wiremock::MockServer::start().await;
5180 wiremock::Mock::given(wiremock::matchers::method("GET"))
5181 .and(wiremock::matchers::path("/jwks.json"))
5182 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5183 .mount(&mock_server)
5184 .await;
5185
5186 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5187 let config = test_config(&jwks_uri); let cache = test_cache(&config);
5189
5190 let now = jsonwebtoken::get_current_timestamp();
5191 let token = mint_token_with_claims(
5192 &pem,
5193 kid,
5194 &serde_json::json!({
5195 "iss": "https://auth.test.local",
5196 "aud": "https://mcp.test.local/mcp",
5197 "sub": "aud-match-client",
5198 "scope": "mcp:read",
5199 "exp": now + 3600,
5200 "iat": now,
5201 }),
5202 );
5203
5204 cache
5205 .validate_token_with_reason(&token)
5206 .await
5207 .expect("a matching aud must be accepted even under the Strict default");
5208 }
5209
5210 #[tokio::test]
5211 async fn strict_audience_validation_rejects_azp_only_match() {
5212 let kid = "test-audience-azp-strict";
5213 let (pem, jwks) = generate_test_keypair(kid);
5214
5215 let mock_server = wiremock::MockServer::start().await;
5216 wiremock::Mock::given(wiremock::matchers::method("GET"))
5217 .and(wiremock::matchers::path("/jwks.json"))
5218 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5219 .mount(&mock_server)
5220 .await;
5221
5222 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5223 let mut config = test_config(&jwks_uri);
5224 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
5225 {
5226 config.strict_audience_validation = Some(true);
5227 }
5228 let cache = test_cache(&config);
5229
5230 let now = jsonwebtoken::get_current_timestamp();
5231 let token = mint_token_with_claims(
5232 &pem,
5233 kid,
5234 &serde_json::json!({
5235 "iss": "https://auth.test.local",
5236 "aud": "https://some-other-resource.example.com",
5237 "azp": "https://mcp.test.local/mcp",
5238 "sub": "strict-client",
5239 "scope": "mcp:read",
5240 "exp": now + 3600,
5241 "iat": now,
5242 }),
5243 );
5244
5245 let failure = cache
5246 .validate_token_with_reason(&token)
5247 .await
5248 .expect_err("strict audience validation must ignore azp fallback");
5249 assert_eq!(failure, JwtValidationFailure::Invalid);
5250 }
5251
5252 #[tokio::test]
5253 async fn warn_mode_accepts_azp_only_match_and_warns_once() {
5254 let kid = "test-audience-warn-mode";
5255 let (pem, jwks) = generate_test_keypair(kid);
5256
5257 let mock_server = wiremock::MockServer::start().await;
5258 wiremock::Mock::given(wiremock::matchers::method("GET"))
5259 .and(wiremock::matchers::path("/jwks.json"))
5260 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5261 .mount(&mock_server)
5262 .await;
5263
5264 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5265 let mut config = test_config(&jwks_uri);
5266 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
5267 let cache = test_cache(&config);
5268
5269 let now = jsonwebtoken::get_current_timestamp();
5270 let claims = serde_json::json!({
5271 "iss": "https://auth.test.local",
5272 "aud": "https://some-other-resource.example.com",
5273 "azp": "https://mcp.test.local/mcp",
5274 "sub": "warn-client",
5275 "scope": "mcp:read",
5276 "exp": now + 3600,
5277 "iat": now,
5278 });
5279 let token = mint_token_with_claims(&pem, kid, &claims);
5280
5281 let identity = cache
5282 .validate_token_with_reason(&token)
5283 .await
5284 .expect("warn mode must accept azp-only match");
5285 assert_eq!(identity.role, "viewer");
5286 assert!(
5287 cache.azp_fallback_warned.load(Ordering::Relaxed),
5288 "warn-once flag should be set after first azp-only match"
5289 );
5290
5291 let token2 = mint_token_with_claims(&pem, kid, &claims);
5292 cache
5293 .validate_token_with_reason(&token2)
5294 .await
5295 .expect("warn mode must continue accepting subsequent matches");
5296 assert!(
5297 cache.azp_fallback_warned.load(Ordering::Relaxed),
5298 "warn-once flag must remain set; the assertion guards against accidental clearing"
5299 );
5300 }
5301
5302 #[tokio::test]
5303 async fn permissive_mode_accepts_azp_only_match_silently() {
5304 let kid = "test-audience-permissive-mode";
5305 let (pem, jwks) = generate_test_keypair(kid);
5306
5307 let mock_server = wiremock::MockServer::start().await;
5308 wiremock::Mock::given(wiremock::matchers::method("GET"))
5309 .and(wiremock::matchers::path("/jwks.json"))
5310 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5311 .mount(&mock_server)
5312 .await;
5313
5314 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5315 let mut config = test_config(&jwks_uri);
5316 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5317 let cache = test_cache(&config);
5318
5319 let now = jsonwebtoken::get_current_timestamp();
5320 let token = mint_token_with_claims(
5321 &pem,
5322 kid,
5323 &serde_json::json!({
5324 "iss": "https://auth.test.local",
5325 "aud": "https://some-other-resource.example.com",
5326 "azp": "https://mcp.test.local/mcp",
5327 "sub": "permissive-client",
5328 "scope": "mcp:read",
5329 "exp": now + 3600,
5330 "iat": now,
5331 }),
5332 );
5333
5334 cache
5335 .validate_token_with_reason(&token)
5336 .await
5337 .expect("permissive mode must accept azp-only match");
5338 assert!(
5339 !cache.azp_fallback_warned.load(Ordering::Relaxed),
5340 "permissive mode must not flip the warn-once flag"
5341 );
5342 }
5343
5344 #[test]
5345 fn audience_validation_mode_overrides_legacy_bool() {
5346 let mut config = OAuthConfig::default();
5347 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5348 {
5349 config.strict_audience_validation = Some(false);
5350 }
5351 config.audience_validation_mode = Some(AudienceValidationMode::Strict);
5352 assert_eq!(
5353 config.effective_audience_validation_mode(),
5354 AudienceValidationMode::Strict,
5355 "explicit mode must override legacy false"
5356 );
5357
5358 let mut config = OAuthConfig::default();
5359 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
5360 {
5361 config.strict_audience_validation = Some(true);
5362 }
5363 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
5364 assert_eq!(
5365 config.effective_audience_validation_mode(),
5366 AudienceValidationMode::Permissive,
5367 "explicit mode must override legacy true"
5368 );
5369 }
5370
5371 #[test]
5372 fn audience_validation_mode_default_is_strict_when_unset() {
5373 let config = OAuthConfig::default();
5374 assert_eq!(
5375 config.effective_audience_validation_mode(),
5376 AudienceValidationMode::Strict,
5377 "unset mode + unset bool must resolve to Strict (the secure default)"
5378 );
5379 }
5380
5381 #[test]
5382 fn audience_validation_legacy_bool_true_resolves_to_strict() {
5383 let mut config = OAuthConfig::default();
5384 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
5385 {
5386 config.strict_audience_validation = Some(true);
5387 }
5388 assert_eq!(
5389 config.effective_audience_validation_mode(),
5390 AudienceValidationMode::Strict,
5391 "legacy bool=true must resolve to Strict for backward compat"
5392 );
5393 }
5394
5395 #[derive(Clone, Default)]
5396 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
5397
5398 impl CapturedLogs {
5399 fn contents(&self) -> String {
5400 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
5401 String::from_utf8(bytes).unwrap_or_default()
5402 }
5403 }
5404
5405 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
5406
5407 impl std::io::Write for CapturedLogsWriter {
5408 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
5409 if let Ok(mut guard) = self.0.lock() {
5410 guard.extend_from_slice(buf);
5411 }
5412 Ok(buf.len())
5413 }
5414
5415 fn flush(&mut self) -> std::io::Result<()> {
5416 Ok(())
5417 }
5418 }
5419
5420 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
5421 type Writer = CapturedLogsWriter;
5422
5423 fn make_writer(&'a self) -> Self::Writer {
5424 CapturedLogsWriter(Arc::clone(&self.0))
5425 }
5426 }
5427
5428 #[tokio::test]
5429 async fn jwks_response_size_cap_returns_none_and_logs_warning() {
5430 let kid = "oversized-jwks";
5431 let (_pem, jwks) = generate_test_keypair(kid);
5432 let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
5433 oversized_body.push_str(&" ".repeat(4096));
5434
5435 let mock_server = wiremock::MockServer::start().await;
5436 wiremock::Mock::given(wiremock::matchers::method("GET"))
5437 .and(wiremock::matchers::path("/jwks.json"))
5438 .respond_with(
5439 wiremock::ResponseTemplate::new(200)
5440 .insert_header("content-type", "application/json")
5441 .set_body_string(oversized_body),
5442 )
5443 .mount(&mock_server)
5444 .await;
5445
5446 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5447 let mut config = test_config(&jwks_uri);
5448 config.jwks_max_response_bytes = 256;
5449 let cache = test_cache(&config);
5450
5451 let logs = CapturedLogs::default();
5452 let subscriber = tracing_subscriber::fmt()
5453 .with_writer(logs.clone())
5454 .with_ansi(false)
5455 .without_time()
5456 .finish();
5457 let _guard = tracing::subscriber::set_default(subscriber);
5458
5459 let result = cache.fetch_jwks().await;
5460 assert!(result.is_none(), "oversized JWKS must be dropped");
5461 assert!(
5462 logs.contents()
5463 .contains("JWKS response exceeded configured size cap"),
5464 "expected cap-exceeded warning in logs"
5465 );
5466 }
5467
5468 #[tokio::test]
5472 async fn redirect_rejection_log_does_not_echo_credentials() {
5473 let mock_server = wiremock::MockServer::start().await;
5474 wiremock::Mock::given(wiremock::matchers::method("GET"))
5475 .and(wiremock::matchers::path("/jwks.json"))
5476 .respond_with(
5477 wiremock::ResponseTemplate::new(302)
5478 .insert_header("location", "https://u:p@redirect-target.example/next"),
5479 )
5480 .mount(&mock_server)
5481 .await;
5482
5483 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5484 let config = test_config(&jwks_uri);
5485 let cache = test_cache(&config);
5486
5487 let logs = CapturedLogs::default();
5488 let subscriber = tracing_subscriber::fmt()
5489 .with_writer(logs.clone())
5490 .with_ansi(false)
5491 .without_time()
5492 .finish();
5493 let _guard = tracing::subscriber::set_default(subscriber);
5494
5495 let result = cache.fetch_jwks().await;
5496 assert!(result.is_none(), "rejected redirect must fail the fetch");
5497 let contents = logs.contents();
5498 assert!(
5499 contents.contains("oauth redirect rejected"),
5500 "expected redirect-rejection warning in logs: {contents}"
5501 );
5502 assert!(
5503 !contents.contains("u:p"),
5504 "rejection log must not echo userinfo credentials: {contents}"
5505 );
5506 }
5507
5508 #[tokio::test]
5509 async fn role_claim_keycloak_nested_array() {
5510 let kid = "test-role-1";
5511 let (pem, jwks) = generate_test_keypair(kid);
5512
5513 let mock_server = wiremock::MockServer::start().await;
5514 wiremock::Mock::given(wiremock::matchers::method("GET"))
5515 .and(wiremock::matchers::path("/jwks.json"))
5516 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5517 .mount(&mock_server)
5518 .await;
5519
5520 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5521 let config = test_config_with_role_claim(
5522 &jwks_uri,
5523 "realm_access.roles",
5524 vec![
5525 RoleMapping {
5526 claim_value: "mcp-admin".into(),
5527 role: "ops".into(),
5528 },
5529 RoleMapping {
5530 claim_value: "mcp-viewer".into(),
5531 role: "viewer".into(),
5532 },
5533 ],
5534 );
5535 let cache = test_cache(&config);
5536
5537 let now = jsonwebtoken::get_current_timestamp();
5538 let token = mint_token_with_claims(
5539 &pem,
5540 kid,
5541 &serde_json::json!({
5542 "iss": "https://auth.test.local",
5543 "aud": "https://mcp.test.local/mcp",
5544 "sub": "keycloak-user",
5545 "exp": now + 3600,
5546 "iat": now,
5547 "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
5548 }),
5549 );
5550
5551 let id = cache
5552 .validate_token(&token)
5553 .await
5554 .expect("should authenticate");
5555 assert_eq!(id.name, "keycloak-user");
5556 assert_eq!(id.role, "ops");
5557 }
5558
5559 #[tokio::test]
5560 async fn role_claim_flat_roles_array() {
5561 let kid = "test-role-2";
5562 let (pem, jwks) = generate_test_keypair(kid);
5563
5564 let mock_server = wiremock::MockServer::start().await;
5565 wiremock::Mock::given(wiremock::matchers::method("GET"))
5566 .and(wiremock::matchers::path("/jwks.json"))
5567 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5568 .mount(&mock_server)
5569 .await;
5570
5571 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5572 let config = test_config_with_role_claim(
5573 &jwks_uri,
5574 "roles",
5575 vec![
5576 RoleMapping {
5577 claim_value: "MCP.Admin".into(),
5578 role: "ops".into(),
5579 },
5580 RoleMapping {
5581 claim_value: "MCP.Reader".into(),
5582 role: "viewer".into(),
5583 },
5584 ],
5585 );
5586 let cache = test_cache(&config);
5587
5588 let now = jsonwebtoken::get_current_timestamp();
5589 let token = mint_token_with_claims(
5590 &pem,
5591 kid,
5592 &serde_json::json!({
5593 "iss": "https://auth.test.local",
5594 "aud": "https://mcp.test.local/mcp",
5595 "sub": "azure-ad-user",
5596 "exp": now + 3600,
5597 "iat": now,
5598 "roles": ["MCP.Reader", "OtherApp.Admin"]
5599 }),
5600 );
5601
5602 let id = cache
5603 .validate_token(&token)
5604 .await
5605 .expect("should authenticate");
5606 assert_eq!(id.name, "azure-ad-user");
5607 assert_eq!(id.role, "viewer");
5608 }
5609
5610 #[tokio::test]
5611 async fn role_claim_no_matching_value_rejected() {
5612 let kid = "test-role-3";
5613 let (pem, jwks) = generate_test_keypair(kid);
5614
5615 let mock_server = wiremock::MockServer::start().await;
5616 wiremock::Mock::given(wiremock::matchers::method("GET"))
5617 .and(wiremock::matchers::path("/jwks.json"))
5618 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5619 .mount(&mock_server)
5620 .await;
5621
5622 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5623 let config = test_config_with_role_claim(
5624 &jwks_uri,
5625 "roles",
5626 vec![RoleMapping {
5627 claim_value: "mcp-admin".into(),
5628 role: "ops".into(),
5629 }],
5630 );
5631 let cache = test_cache(&config);
5632
5633 let now = jsonwebtoken::get_current_timestamp();
5634 let token = mint_token_with_claims(
5635 &pem,
5636 kid,
5637 &serde_json::json!({
5638 "iss": "https://auth.test.local",
5639 "aud": "https://mcp.test.local/mcp",
5640 "sub": "limited-user",
5641 "exp": now + 3600,
5642 "iat": now,
5643 "roles": ["some-other-role"]
5644 }),
5645 );
5646
5647 assert!(cache.validate_token(&token).await.is_none());
5648 }
5649
5650 #[tokio::test]
5651 async fn role_claim_space_separated_string() {
5652 let kid = "test-role-4";
5653 let (pem, jwks) = generate_test_keypair(kid);
5654
5655 let mock_server = wiremock::MockServer::start().await;
5656 wiremock::Mock::given(wiremock::matchers::method("GET"))
5657 .and(wiremock::matchers::path("/jwks.json"))
5658 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5659 .mount(&mock_server)
5660 .await;
5661
5662 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5663 let config = test_config_with_role_claim(
5664 &jwks_uri,
5665 "custom_scope",
5666 vec![
5667 RoleMapping {
5668 claim_value: "write".into(),
5669 role: "ops".into(),
5670 },
5671 RoleMapping {
5672 claim_value: "read".into(),
5673 role: "viewer".into(),
5674 },
5675 ],
5676 );
5677 let cache = test_cache(&config);
5678
5679 let now = jsonwebtoken::get_current_timestamp();
5680 let token = mint_token_with_claims(
5681 &pem,
5682 kid,
5683 &serde_json::json!({
5684 "iss": "https://auth.test.local",
5685 "aud": "https://mcp.test.local/mcp",
5686 "sub": "custom-client",
5687 "exp": now + 3600,
5688 "iat": now,
5689 "custom_scope": "read audit"
5690 }),
5691 );
5692
5693 let id = cache
5694 .validate_token(&token)
5695 .await
5696 .expect("should authenticate");
5697 assert_eq!(id.name, "custom-client");
5698 assert_eq!(id.role, "viewer");
5699 }
5700
5701 #[tokio::test]
5702 async fn scope_backward_compat_without_role_claim() {
5703 let kid = "test-compat-1";
5705 let (pem, jwks) = generate_test_keypair(kid);
5706
5707 let mock_server = wiremock::MockServer::start().await;
5708 wiremock::Mock::given(wiremock::matchers::method("GET"))
5709 .and(wiremock::matchers::path("/jwks.json"))
5710 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5711 .mount(&mock_server)
5712 .await;
5713
5714 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5715 let config = test_config(&jwks_uri); let cache = test_cache(&config);
5717
5718 let token = mint_token(
5719 &pem,
5720 kid,
5721 "https://auth.test.local",
5722 "https://mcp.test.local/mcp",
5723 "legacy-bot",
5724 "mcp:admin other:scope",
5725 );
5726
5727 let id = cache
5728 .validate_token(&token)
5729 .await
5730 .expect("should authenticate");
5731 assert_eq!(id.name, "legacy-bot");
5732 assert_eq!(id.role, "ops"); }
5734
5735 #[tokio::test]
5740 async fn jwks_refresh_deduplication() {
5741 let kid = "test-dedup";
5744 let (pem, jwks) = generate_test_keypair(kid);
5745
5746 let mock_server = wiremock::MockServer::start().await;
5747 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5748 .and(wiremock::matchers::path("/jwks.json"))
5749 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5750 .expect(1) .mount(&mock_server)
5752 .await;
5753
5754 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5755 let config = test_config(&jwks_uri);
5756 let cache = Arc::new(test_cache(&config));
5757
5758 let token = mint_token(
5760 &pem,
5761 kid,
5762 "https://auth.test.local",
5763 "https://mcp.test.local/mcp",
5764 "concurrent-bot",
5765 "mcp:read",
5766 );
5767
5768 let mut handles = Vec::new();
5769 for _ in 0..5 {
5770 let c = Arc::clone(&cache);
5771 let t = token.clone();
5772 handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
5773 }
5774
5775 for h in handles {
5776 let result = h.await.unwrap();
5777 assert!(result.is_some(), "all concurrent requests should succeed");
5778 }
5779
5780 }
5782
5783 #[tokio::test]
5784 async fn jwks_refresh_cooldown_blocks_rapid_requests() {
5785 let kid = "test-cooldown";
5788 let (_pem, jwks) = generate_test_keypair(kid);
5789
5790 let mock_server = wiremock::MockServer::start().await;
5791 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
5792 .and(wiremock::matchers::path("/jwks.json"))
5793 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5794 .expect(1) .mount(&mock_server)
5796 .await;
5797
5798 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5799 let config = test_config(&jwks_uri);
5800 let cache = test_cache(&config);
5801
5802 let fake_token1 =
5804 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
5805 let _ = cache.validate_token(fake_token1).await;
5806
5807 let fake_token2 =
5810 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
5811 let _ = cache.validate_token(fake_token2).await;
5812
5813 let fake_token3 =
5815 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
5816 let _ = cache.validate_token(fake_token3).await;
5817
5818 }
5820
5821 fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
5824 OAuthProxyConfig {
5825 authorize_url: "https://example.invalid/auth".into(),
5826 token_url: token_url.into(),
5827 client_id: "mcp-client".into(),
5828 client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
5829 introspection_url: None,
5830 revocation_url: None,
5831 expose_admin_endpoints: false,
5832 require_auth_on_admin_endpoints: false,
5833 allow_unauthenticated_admin_endpoints: false,
5834 }
5835 }
5836
5837 fn test_http_client() -> OauthHttpClient {
5840 rustls::crypto::ring::default_provider()
5841 .install_default()
5842 .ok();
5843 let config = OAuthConfig::builder(
5844 "https://auth.test.local",
5845 "https://mcp.test.local/mcp",
5846 "https://auth.test.local/.well-known/jwks.json",
5847 )
5848 .allow_http_oauth_urls(true)
5849 .build();
5850 OauthHttpClient::with_config(&config)
5851 .expect("build test http client")
5852 .__test_allow_loopback_ssrf()
5853 }
5854
5855 #[tokio::test]
5856 async fn introspect_proxies_and_injects_client_credentials() {
5857 use wiremock::matchers::{body_string_contains, method, path};
5858
5859 let mock_server = wiremock::MockServer::start().await;
5860 wiremock::Mock::given(method("POST"))
5861 .and(path("/introspect"))
5862 .and(body_string_contains("client_id=mcp-client"))
5863 .and(body_string_contains("client_secret=shh"))
5864 .and(body_string_contains("token=abc"))
5865 .respond_with(
5866 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5867 "active": true,
5868 "scope": "read"
5869 })),
5870 )
5871 .expect(1)
5872 .mount(&mock_server)
5873 .await;
5874
5875 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5876 proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
5877
5878 let http = test_http_client();
5879 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5880 assert_eq!(resp.status(), 200);
5881 }
5882
5883 #[tokio::test]
5884 async fn token_proxy_fails_closed_on_oversized_upstream_response() {
5885 use http_body_util::BodyExt as _;
5886 use wiremock::matchers::{method, path};
5887
5888 let oversized = "x"
5890 .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
5891 let mock_server = wiremock::MockServer::start().await;
5892 wiremock::Mock::given(method("POST"))
5893 .and(path("/token"))
5894 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
5895 .expect(1)
5896 .mount(&mock_server)
5897 .await;
5898
5899 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5900 let http = test_http_client();
5901 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5902
5903 assert_eq!(
5905 resp.status(),
5906 502,
5907 "oversized upstream response must fail closed as 502"
5908 );
5909 let body = resp
5910 .into_body()
5911 .collect()
5912 .await
5913 .expect("collect body")
5914 .to_bytes();
5915 assert!(
5916 body.len() < 1024,
5917 "must return the small generic error body, not the oversized upstream body (got {} bytes)",
5918 body.len()
5919 );
5920 assert!(
5921 !body.windows(8).any(|w| w == b"xxxxxxxx"),
5922 "the oversized upstream payload must not be forwarded to the client"
5923 );
5924 }
5925
5926 #[tokio::test]
5927 async fn token_proxy_passes_through_normal_response() {
5928 use http_body_util::BodyExt as _;
5929 use wiremock::matchers::{method, path};
5930
5931 let mock_server = wiremock::MockServer::start().await;
5932 wiremock::Mock::given(method("POST"))
5933 .and(path("/token"))
5934 .respond_with(
5935 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5936 "access_token": "at-123",
5937 "token_type": "Bearer"
5938 })),
5939 )
5940 .expect(1)
5941 .mount(&mock_server)
5942 .await;
5943
5944 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5945 let http = test_http_client();
5946 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5947
5948 assert_eq!(
5949 resp.status(),
5950 200,
5951 "a normal-sized response must pass through"
5952 );
5953 let body = resp
5954 .into_body()
5955 .collect()
5956 .await
5957 .expect("collect body")
5958 .to_bytes();
5959 let json: serde_json::Value =
5960 serde_json::from_slice(&body).expect("upstream JSON preserved");
5961 assert_eq!(json["access_token"], "at-123");
5962 }
5963
5964 #[tokio::test]
5965 async fn introspect_returns_404_when_not_configured() {
5966 let proxy = proxy_cfg("https://example.invalid/token");
5967 let http = test_http_client();
5968 let resp = handle_introspect(&http, &proxy, "token=abc").await;
5969 assert_eq!(resp.status(), 404);
5970 }
5971
5972 #[tokio::test]
5973 async fn revoke_proxies_and_returns_upstream_status() {
5974 use wiremock::matchers::{method, path};
5975
5976 let mock_server = wiremock::MockServer::start().await;
5977 wiremock::Mock::given(method("POST"))
5978 .and(path("/revoke"))
5979 .respond_with(wiremock::ResponseTemplate::new(200))
5980 .expect(1)
5981 .mount(&mock_server)
5982 .await;
5983
5984 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5985 proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
5986
5987 let http = test_http_client();
5988 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5989 assert_eq!(resp.status(), 200);
5990 }
5991
5992 #[tokio::test]
5993 async fn revoke_returns_404_when_not_configured() {
5994 let proxy = proxy_cfg("https://example.invalid/token");
5995 let http = test_http_client();
5996 let resp = handle_revoke(&http, &proxy, "token=abc").await;
5997 assert_eq!(resp.status(), 404);
5998 }
5999
6000 #[test]
6001 fn metadata_advertises_endpoints_only_when_configured() {
6002 let mut cfg = test_config("https://auth.test.local/jwks.json");
6003 let m = authorization_server_metadata("https://mcp.local", &cfg);
6005 assert!(m.get("introspection_endpoint").is_none());
6006 assert!(m.get("revocation_endpoint").is_none());
6007
6008 let mut proxy = proxy_cfg("https://upstream.local/token");
6011 proxy.introspection_url = Some("https://upstream.local/introspect".into());
6012 proxy.revocation_url = Some("https://upstream.local/revoke".into());
6013 cfg.proxy = Some(proxy);
6014 let m = authorization_server_metadata("https://mcp.local", &cfg);
6015 assert!(
6016 m.get("introspection_endpoint").is_none(),
6017 "introspection must not be advertised when expose_admin_endpoints=false"
6018 );
6019 assert!(
6020 m.get("revocation_endpoint").is_none(),
6021 "revocation must not be advertised when expose_admin_endpoints=false"
6022 );
6023
6024 if let Some(p) = cfg.proxy.as_mut() {
6026 p.expose_admin_endpoints = true;
6027 p.revocation_url = None;
6028 }
6029 let m = authorization_server_metadata("https://mcp.local", &cfg);
6030 assert_eq!(
6031 m["introspection_endpoint"],
6032 serde_json::Value::String("https://mcp.local/introspect".into())
6033 );
6034 assert!(m.get("revocation_endpoint").is_none());
6035
6036 if let Some(p) = cfg.proxy.as_mut() {
6038 p.revocation_url = Some("https://upstream.local/revoke".into());
6039 }
6040 let m = authorization_server_metadata("https://mcp.local", &cfg);
6041 assert_eq!(
6042 m["revocation_endpoint"],
6043 serde_json::Value::String("https://mcp.local/revoke".into())
6044 );
6045 }
6046
6047 fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
6050 let mut cfg = validation_https_config();
6051 cfg.token_exchange = Some(tx);
6052 cfg
6053 }
6054
6055 fn tx_with(
6056 client_secret: Option<&str>,
6057 client_cert: Option<ClientCertConfig>,
6058 ) -> TokenExchangeConfig {
6059 TokenExchangeConfig::new(
6060 "https://idp.example.com/token".into(),
6061 "client".into(),
6062 client_secret.map(|s| secrecy::SecretString::new(s.into())),
6063 client_cert,
6064 "downstream".into(),
6065 )
6066 }
6067
6068 #[test]
6069 fn validate_rejects_token_exchange_without_client_auth() {
6070 let cfg = https_cfg_with_tx(tx_with(None, None));
6071 let err = cfg
6072 .validate()
6073 .expect_err("token_exchange without client auth must be rejected");
6074 let msg = err.to_string();
6075 assert!(
6076 msg.contains("requires client authentication"),
6077 "error must explain missing client auth; got {msg:?}"
6078 );
6079 }
6080
6081 #[test]
6082 fn validate_rejects_token_exchange_with_both_secret_and_cert() {
6083 let cc = ClientCertConfig {
6084 cert_path: PathBuf::from("/nonexistent/cert.pem"),
6085 key_path: PathBuf::from("/nonexistent/key.pem"),
6086 };
6087 let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
6088 let err = cfg
6089 .validate()
6090 .expect_err("client_secret + client_cert must be rejected");
6091 let msg = err.to_string();
6092 assert!(
6093 msg.contains("mutually") && msg.contains("exclusive"),
6094 "error must explain mutual exclusion; got {msg:?}"
6095 );
6096 }
6097
6098 #[cfg(not(feature = "oauth-mtls-client"))]
6099 #[test]
6100 fn validate_rejects_client_cert_without_feature() {
6101 let cc = ClientCertConfig {
6102 cert_path: PathBuf::from("/nonexistent/cert.pem"),
6103 key_path: PathBuf::from("/nonexistent/key.pem"),
6104 };
6105 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6106 let err = cfg
6107 .validate()
6108 .expect_err("client_cert without feature must be rejected");
6109 assert!(
6110 err.to_string().contains("oauth-mtls-client"),
6111 "error must reference the cargo feature; got {err}"
6112 );
6113 }
6114
6115 #[cfg(feature = "oauth-mtls-client")]
6116 #[test]
6117 fn validate_rejects_missing_client_cert_files() {
6118 let cc = ClientCertConfig {
6119 cert_path: PathBuf::from("/nonexistent/cert.pem"),
6120 key_path: PathBuf::from("/nonexistent/key.pem"),
6121 };
6122 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6123 let err = cfg
6124 .validate()
6125 .expect_err("missing cert file must be rejected");
6126 assert!(
6127 err.to_string().contains("unreadable"),
6128 "error must call out unreadable file; got {err}"
6129 );
6130 }
6131
6132 #[cfg(feature = "oauth-mtls-client")]
6133 #[test]
6134 fn validate_rejects_malformed_client_cert_pem() {
6135 let dir = std::env::temp_dir();
6136 let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
6137 let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
6138 std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
6139 std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
6140 let cc = ClientCertConfig {
6141 cert_path: cert.clone(),
6142 key_path: key.clone(),
6143 };
6144 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6145 let err = cfg.validate().expect_err("malformed PEM must be rejected");
6146 let _ = std::fs::remove_file(&cert);
6147 let _ = std::fs::remove_file(&key);
6148 assert!(
6149 err.to_string().contains("PEM parse failed"),
6150 "error must call out PEM parse failure; got {err}"
6151 );
6152 }
6153
6154 #[cfg(feature = "oauth-mtls-client")]
6155 fn write_self_signed_pem() -> (PathBuf, PathBuf) {
6156 let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
6157 let dir = std::env::temp_dir();
6158 let pid = std::process::id();
6159 let nonce: u64 = rand::random();
6160 let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
6161 let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
6162 std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
6163 std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
6164 (cert_path, key_path)
6165 }
6166
6167 #[cfg(feature = "oauth-mtls-client")]
6168 fn install_test_crypto_provider() {
6169 let _ = rustls::crypto::ring::default_provider().install_default();
6170 }
6171
6172 #[cfg(feature = "oauth-mtls-client")]
6173 #[test]
6174 fn validate_accepts_well_formed_client_cert() {
6175 install_test_crypto_provider();
6176 let (cert_path, key_path) = write_self_signed_pem();
6177 let cc = ClientCertConfig {
6178 cert_path: cert_path.clone(),
6179 key_path: key_path.clone(),
6180 };
6181 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6182 let res = cfg.validate();
6183 let _ = std::fs::remove_file(&cert_path);
6184 let _ = std::fs::remove_file(&key_path);
6185 res.expect("well-formed cert+key must validate");
6186 }
6187
6188 #[cfg(feature = "oauth-mtls-client")]
6189 #[test]
6190 fn client_for_returns_cached_mtls_client() {
6191 install_test_crypto_provider();
6192 let (cert_path, key_path) = write_self_signed_pem();
6193 let cc = ClientCertConfig {
6194 cert_path: cert_path.clone(),
6195 key_path: key_path.clone(),
6196 };
6197 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
6198 let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
6199 let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
6200 let cert_client = http.client_for(tx_ref);
6201 let inner_client = http.client_for(&tx_with(Some("s"), None));
6202 let _ = std::fs::remove_file(&cert_path);
6203 let _ = std::fs::remove_file(&key_path);
6204 assert!(
6205 !std::ptr::eq(cert_client, inner_client),
6206 "client_for must return distinct clients for cert vs no-cert configs"
6207 );
6208 }
6209
6210 #[cfg(feature = "oauth-mtls-client")]
6211 #[test]
6212 fn client_for_falls_back_to_inner_when_cache_miss() {
6213 install_test_crypto_provider();
6214 let cfg = validation_https_config();
6215 let http = OauthHttpClient::with_config(&cfg).expect("build client");
6216 let unrelated_cc = ClientCertConfig {
6217 cert_path: PathBuf::from("/cache/miss/cert.pem"),
6218 key_path: PathBuf::from("/cache/miss/key.pem"),
6219 };
6220 let tx_unknown = tx_with(None, Some(unrelated_cc));
6221 let fallback = http.client_for(&tx_unknown);
6222 let inner = http.client_for(&tx_with(Some("s"), None));
6223 assert!(
6224 std::ptr::eq(fallback, inner),
6225 "cache miss must fall back to inner client"
6226 );
6227 }
6228}