1use std::{
17 collections::HashMap,
18 fmt,
19 path::PathBuf,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, Ordering},
23 },
24 time::{Duration, Instant},
25};
26
27use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
28use serde::Deserialize;
29use tokio::{net::lookup_host, sync::RwLock};
30use tracing::Instrument;
31
32use crate::auth::{AuthIdentity, AuthMethod};
33
34fn evaluate_oauth_redirect(
60 attempt: &reqwest::redirect::Attempt<'_>,
61 allow_http: bool,
62 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
63) -> Result<(), String> {
64 let prev_https = attempt
65 .previous()
66 .last()
67 .is_some_and(|prev| prev.scheme() == "https");
68 let target_url = attempt.url();
69 let dest_scheme = target_url.scheme();
70 if dest_scheme != "https" {
71 if prev_https {
72 return Err("redirect downgrades https -> http".to_owned());
73 }
74 if !allow_http || dest_scheme != "http" {
75 return Err("redirect to non-HTTP(S) URL refused".to_owned());
76 }
77 }
78 if let Some(reason) = crate::ssrf::redirect_target_reason_with_allowlist(target_url, allowlist)
79 {
80 return Err(format!("redirect target forbidden: {reason}"));
81 }
82 if attempt.previous().len() >= 2 {
83 return Err("too many redirects (max 2)".to_owned());
84 }
85 Ok(())
86}
87
88#[allow(
99 clippy::case_sensitive_file_extension_comparisons,
100 reason = "these are DNS-name suffixes on an already-lowercased host, not file extensions"
101)]
102fn oauth_internal_suffix_blocked(
103 host: &str,
104 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
105) -> bool {
106 let host_canon = host.strip_suffix('.').unwrap_or(host);
107 let host_lower = host_canon.to_ascii_lowercase();
108 let is_internal = host_lower.ends_with(".localhost")
109 || host_lower.ends_with(".local")
110 || host_lower.ends_with(".internal");
111 is_internal && (allowlist.is_empty() || !allowlist.host_allowed(host_canon))
113}
114
115async fn screen_oauth_target_core(
137 url: &str,
138 allow_http: bool,
139 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
140 test_allow_loopback_ssrf: bool,
141) -> Result<(), crate::error::RmcpServerKitError> {
142 let target = oauth_request_target_for_log(url);
143 let parsed = check_oauth_url("oauth target", url, allow_http)?;
144 if test_allow_loopback_ssrf {
145 return Ok(());
146 }
147 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
148 return Err(crate::error::RmcpServerKitError::Config(format!(
149 "OAuth target forbidden ({reason}): {target}"
150 )));
151 }
152
153 let host = parsed.host_str().ok_or_else(|| {
154 crate::error::RmcpServerKitError::Config(format!("OAuth target URL has no host: {target}"))
155 })?;
156 if oauth_internal_suffix_blocked(host, allowlist) {
157 return Err(crate::error::RmcpServerKitError::Config(format!(
158 "OAuth target forbidden (internal hostname suffix): {target}"
159 )));
160 }
161 let port = parsed.port_or_known_default().ok_or_else(|| {
162 crate::error::RmcpServerKitError::Config(format!(
163 "OAuth target URL has no known port: {target}"
164 ))
165 })?;
166
167 let addrs = lookup_host((host, port)).await.map_err(|error| {
168 crate::error::RmcpServerKitError::Config(format!(
169 "OAuth target DNS resolution {target}: {error}"
170 ))
171 })?;
172
173 let host_allowed = !allowlist.is_empty() && allowlist.host_allowed(host);
174 let mut any_addr = false;
175 for addr in addrs {
176 any_addr = true;
177 let ip = addr.ip();
178 if let Some(reason) = crate::ssrf::ip_block_reason(ip) {
179 if reason == "cloud_metadata" {
182 return Err(crate::error::RmcpServerKitError::Config(format!(
183 "OAuth target resolved to blocked IP ({reason}): {target}"
184 )));
185 }
186 if allowlist.is_empty() {
190 return Err(crate::error::RmcpServerKitError::Config(format!(
191 "OAuth target resolved to blocked IP ({reason}): {target}"
192 )));
193 }
194 if host_allowed || allowlist.ip_allowed(ip) {
196 continue;
197 }
198 return Err(crate::error::RmcpServerKitError::Config(format!(
199 "OAuth target blocked: hostname {host} resolved to {ip} ({reason}). \
200 To allow, add the hostname to oauth.ssrf_allowlist.hosts or the CIDR \
201 to oauth.ssrf_allowlist.cidrs (operators only -- see SECURITY.md). \
202 URL: {target}"
203 )));
204 }
205 }
206 if !any_addr {
207 return Err(crate::error::RmcpServerKitError::Config(format!(
208 "OAuth target DNS resolution returned no addresses: {target}"
209 )));
210 }
211
212 Ok(())
213}
214
215async fn screen_oauth_target(
218 url: &str,
219 allow_http: bool,
220 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
221) -> Result<(), crate::error::RmcpServerKitError> {
222 screen_oauth_target_core(url, allow_http, allowlist, false).await
223}
224
225#[cfg(any(test, feature = "test-helpers"))]
229async fn screen_oauth_target_with_test_override(
230 url: &str,
231 allow_http: bool,
232 allowlist: &crate::ssrf::CompiledSsrfAllowlist,
233 test_allow_loopback_ssrf: bool,
234) -> Result<(), crate::error::RmcpServerKitError> {
235 screen_oauth_target_core(url, allow_http, allowlist, test_allow_loopback_ssrf).await
236}
237
238#[derive(Clone)]
279pub struct OauthHttpClient {
280 #[cfg(any(test, feature = "test-helpers"))]
288 inner: reqwest::Client,
289 credential_client: reqwest::Client,
296 allow_http: bool,
297 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
302 #[cfg(feature = "oauth-mtls-client")]
307 mtls_clients: Arc<HashMap<MtlsClientKey, reqwest::Client>>,
308 #[cfg(any(test, feature = "test-helpers"))]
314 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
315}
316
317#[cfg(feature = "oauth-mtls-client")]
321#[derive(Debug, Clone, Hash, Eq, PartialEq)]
322struct MtlsClientKey {
323 cert_path: PathBuf,
324 key_path: PathBuf,
325}
326
327impl OauthHttpClient {
328 pub fn with_config(config: &OAuthConfig) -> Result<Self, crate::error::RmcpServerKitError> {
346 Self::build(Some(config))
347 }
348
349 #[deprecated(
372 since = "1.2.1",
373 note = "use OauthHttpClient::with_config(&OAuthConfig) so token/introspect/revoke/exchange traffic inherits ca_cert_path and the allow_http_oauth_urls toggle"
374 )]
375 pub fn new() -> Result<Self, crate::error::RmcpServerKitError> {
376 Self::build(None)
377 }
378
379 fn build(config: Option<&OAuthConfig>) -> Result<Self, crate::error::RmcpServerKitError> {
382 rustls::crypto::ring::default_provider()
389 .install_default()
390 .ok();
391
392 let allow_http = config.is_some_and(|c| c.allow_http_oauth_urls);
393
394 let allowlist = match config.and_then(|c| c.ssrf_allowlist.as_ref()) {
399 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
400 crate::error::RmcpServerKitError::Startup(format!("oauth http client: {e}"))
401 })?),
402 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
403 };
404
405 #[cfg(any(test, feature = "test-helpers"))]
410 let redirect_allowlist = Arc::clone(&allowlist);
411
412 #[cfg(any(test, feature = "test-helpers"))]
416 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
417 Arc::new(AtomicBool::new(false));
418 #[cfg(not(any(test, feature = "test-helpers")))]
419 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
420
421 #[allow(
426 clippy::clone_on_ref_ptr,
427 clippy::clone_on_copy,
428 clippy::unit_arg,
429 reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
430 )]
431 let resolver: Arc<dyn reqwest::dns::Resolve> =
432 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
433 Arc::clone(&allowlist),
434 test_bypass.clone(),
435 ));
436
437 let ca_pem: Option<Vec<u8>> = if let Some(cfg) = config
441 && let Some(ref ca_path) = cfg.ca_cert_path
442 {
443 Some(std::fs::read(ca_path).map_err(|e| {
444 crate::error::RmcpServerKitError::Startup(format!(
445 "oauth http client: read ca_cert_path {}: {e}",
446 ca_path.display()
447 ))
448 })?)
449 } else {
450 None
451 };
452
453 let make_base = || -> Result<reqwest::ClientBuilder, crate::error::RmcpServerKitError> {
457 let mut b = reqwest::Client::builder()
458 .no_proxy()
459 .dns_resolver(Arc::clone(&resolver))
460 .connect_timeout(Duration::from_secs(10))
461 .timeout(Duration::from_secs(30));
462 if let Some(ref pem) = ca_pem {
463 let cert = reqwest::tls::Certificate::from_pem(pem).map_err(|e| {
464 crate::error::RmcpServerKitError::Startup(format!(
465 "oauth http client: parse ca_cert_path: {e}"
466 ))
467 })?;
468 b = b.add_root_certificate(cert);
469 }
470 Ok(b)
471 };
472
473 #[cfg(any(test, feature = "test-helpers"))]
480 let inner =
481 make_base()?
482 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
483 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
484 Ok(()) => attempt.follow(),
485 Err(reason) => {
486 tracing::warn!(
487 reason = %reason,
488 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
489 "oauth redirect rejected"
490 );
491 attempt.error(reason)
492 }
493 }
494 }))
495 .build()
496 .map_err(|e| {
497 crate::error::RmcpServerKitError::Startup(format!(
498 "oauth http client init: {e}"
499 ))
500 })?;
501
502 let credential_client = make_base()?
515 .redirect(reqwest::redirect::Policy::none())
516 .build()
517 .map_err(|e| {
518 crate::error::RmcpServerKitError::Startup(format!("oauth http client init: {e}"))
519 })?;
520
521 #[cfg(feature = "oauth-mtls-client")]
522 let mtls_clients = build_mtls_clients(config, &allowlist, &test_bypass)?;
523
524 Ok(Self {
525 #[cfg(any(test, feature = "test-helpers"))]
526 inner,
527 credential_client,
528 allow_http,
529 allowlist,
530 #[cfg(feature = "oauth-mtls-client")]
531 mtls_clients,
532 #[cfg(any(test, feature = "test-helpers"))]
533 test_allow_loopback_ssrf: test_bypass,
534 })
535 }
536
537 async fn send_screened(
541 &self,
542 url: &str,
543 request: reqwest::RequestBuilder,
544 ) -> Result<reqwest::Response, crate::error::RmcpServerKitError> {
545 #[cfg(any(test, feature = "test-helpers"))]
546 if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
547 screen_oauth_target_with_test_override(url, self.allow_http, &self.allowlist, true)
548 .await?;
549 } else {
550 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
551 }
552 #[cfg(not(any(test, feature = "test-helpers")))]
553 screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
554 request.send().await.map_err(|error| {
555 let target = oauth_request_target_for_log(url);
556 let error = error.without_url();
557 crate::error::RmcpServerKitError::Config(format!("oauth request {target}: {error}"))
558 })
559 }
560
561 #[cfg(any(test, feature = "test-helpers"))]
571 #[doc(hidden)]
572 #[must_use]
573 pub fn __test_allow_loopback_ssrf(self) -> Self {
574 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
577 self
578 }
579
580 #[cfg(any(test, feature = "test-helpers"))]
591 #[doc(hidden)]
592 pub async fn __test_get(&self, url: &str) -> reqwest::Result<reqwest::Response> {
593 self.inner.get(url).send().await
594 }
595
596 #[cfg(any(test, feature = "test-helpers"))]
607 #[doc(hidden)]
608 #[must_use]
609 pub fn __test_inner_client(&self) -> &reqwest::Client {
610 &self.inner
611 }
612
613 #[cfg(feature = "oauth-mtls-client")]
620 fn client_for(&self, cfg: &TokenExchangeConfig) -> &reqwest::Client {
621 if let Some(cc) = &cfg.client_cert {
622 let key = MtlsClientKey {
623 cert_path: cc.cert_path.clone(),
624 key_path: cc.key_path.clone(),
625 };
626 if let Some(client) = self.mtls_clients.get(&key) {
627 return client;
628 }
629 }
630 &self.credential_client
631 }
632
633 #[cfg(not(feature = "oauth-mtls-client"))]
634 fn client_for(&self, _cfg: &TokenExchangeConfig) -> &reqwest::Client {
635 &self.credential_client
636 }
637}
638
639impl fmt::Debug for OauthHttpClient {
640 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
641 f.debug_struct("OauthHttpClient").finish_non_exhaustive()
642 }
643}
644
645fn oauth_request_target_for_log(raw: &str) -> String {
646 url::Url::parse(raw).map_or_else(
647 |_| "<unparseable-url>".to_owned(),
648 |url| crate::ssrf::sanitized_url_for_log(&url),
649 )
650}
651
652#[derive(Debug, Clone, Default, Deserialize)]
715#[serde(deny_unknown_fields)]
716#[non_exhaustive]
717pub struct OAuthSsrfAllowlist {
718 #[serde(default)]
723 pub hosts: Vec<String>,
724 #[serde(default)]
730 pub cidrs: Vec<String>,
731}
732
733fn compile_oauth_ssrf_allowlist(
740 raw: &OAuthSsrfAllowlist,
741) -> Result<crate::ssrf::CompiledSsrfAllowlist, String> {
742 let mut hosts: Vec<String> = Vec::with_capacity(raw.hosts.len());
743 for (idx, entry) in raw.hosts.iter().enumerate() {
744 let trimmed = entry.trim();
745 if trimmed.is_empty() {
746 return Err(format!("oauth.ssrf_allowlist.hosts[{idx}]: empty entry"));
747 }
748 if trimmed.contains([':', '/', '@', '?', '#']) {
752 return Err(format!(
753 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: must be a bare DNS hostname \
754 (no scheme, port, path, userinfo, query, or fragment)"
755 ));
756 }
757 match url::Host::parse(trimmed) {
758 Ok(url::Host::Domain(_)) => {}
759 Ok(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
760 return Err(format!(
761 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: literal IPs are forbidden \
762 here -- list them via oauth.ssrf_allowlist.cidrs instead"
763 ));
764 }
765 Err(e) => {
766 return Err(format!(
767 "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: invalid hostname: {e}"
768 ));
769 }
770 }
771 hosts.push(trimmed.to_ascii_lowercase());
772 }
773 hosts.sort();
774 hosts.dedup();
775
776 let mut cidrs = Vec::with_capacity(raw.cidrs.len());
777 for (idx, entry) in raw.cidrs.iter().enumerate() {
778 let parsed = crate::ssrf::CidrEntry::parse(entry)
779 .map_err(|e| format!("oauth.ssrf_allowlist.cidrs[{idx}]: {e}"))?;
780 cidrs.push(parsed);
781 }
782
783 Ok(crate::ssrf::CompiledSsrfAllowlist::new(hosts, cidrs))
784}
785
786#[derive(Debug, Clone, Deserialize)]
788#[serde(deny_unknown_fields)]
789#[non_exhaustive]
790pub struct OAuthConfig {
791 #[serde(default)]
800 pub issuer: String,
801 #[serde(default)]
807 pub audience: String,
808 #[serde(default)]
813 pub jwks_uri: String,
814 #[serde(default)]
817 pub scopes: Vec<ScopeMapping>,
818 pub role_claim: Option<String>,
824 #[serde(default)]
827 pub role_mappings: Vec<RoleMapping>,
828 #[serde(default = "default_jwks_cache_ttl")]
831 pub jwks_cache_ttl: String,
832 pub proxy: Option<OAuthProxyConfig>,
836 pub token_exchange: Option<TokenExchangeConfig>,
841 #[serde(default)]
856 pub ca_cert_path: Option<PathBuf>,
857 #[serde(default)]
873 pub allow_http_oauth_urls: bool,
874 #[serde(default)]
883 pub ssrf_allowlist: Option<OAuthSsrfAllowlist>,
884 #[serde(default = "default_max_jwks_keys")]
888 pub max_jwks_keys: usize,
889 #[serde(default)]
904 pub allowed_algorithms: Option<Vec<String>>,
905 #[serde(default)]
927 pub authorization_servers: Option<Vec<String>>,
928 #[serde(default)]
950 pub authorization_server_metadata_issuer: Option<String>,
951 #[serde(default)]
956 pub require_subject: bool,
957 #[serde(default)]
966 #[deprecated(
967 since = "1.7.0",
968 note = "use `audience_validation_mode` instead; this field is consulted only when `audience_validation_mode` is None"
969 )]
970 pub strict_audience_validation: Option<bool>,
971 #[serde(default)]
980 pub audience_validation_mode: Option<AudienceValidationMode>,
981 #[serde(default = "default_jwks_max_bytes")]
985 pub jwks_max_response_bytes: u64,
986}
987
988fn default_jwks_cache_ttl() -> String {
989 "10m".into()
990}
991
992const fn default_max_jwks_keys() -> usize {
993 256
994}
995
996const fn default_jwks_max_bytes() -> u64 {
997 1024 * 1024
998}
999
1000#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1017#[serde(rename_all = "snake_case")]
1018#[non_exhaustive]
1019pub enum AudienceValidationMode {
1020 Permissive,
1024 Warn,
1027 #[default]
1031 Strict,
1032}
1033
1034impl AudienceValidationMode {
1035 #[must_use]
1040 pub(crate) const fn as_str(self) -> &'static str {
1041 match self {
1042 Self::Permissive => "permissive",
1043 Self::Warn => "warn",
1044 Self::Strict => "strict",
1045 }
1046 }
1047}
1048
1049impl Default for OAuthConfig {
1050 fn default() -> Self {
1051 Self {
1052 issuer: String::new(),
1053 audience: String::new(),
1054 jwks_uri: String::new(),
1055 scopes: Vec::new(),
1056 role_claim: None,
1057 role_mappings: Vec::new(),
1058 jwks_cache_ttl: default_jwks_cache_ttl(),
1059 proxy: None,
1060 token_exchange: None,
1061 ca_cert_path: None,
1062 allow_http_oauth_urls: false,
1063 max_jwks_keys: default_max_jwks_keys(),
1064 allowed_algorithms: None,
1065 authorization_servers: None,
1066 authorization_server_metadata_issuer: None,
1067 require_subject: false,
1068 #[allow(
1069 deprecated,
1070 reason = "default-construct deprecated field for backward compat"
1071 )]
1072 strict_audience_validation: None,
1073 audience_validation_mode: None,
1074 jwks_max_response_bytes: default_jwks_max_bytes(),
1075 ssrf_allowlist: None,
1076 }
1077 }
1078}
1079
1080impl OAuthConfig {
1081 #[must_use]
1088 pub fn effective_audience_validation_mode(&self) -> AudienceValidationMode {
1089 if let Some(mode) = self.audience_validation_mode {
1090 return mode;
1091 }
1092 #[allow(deprecated, reason = "intentional: legacy flag resolution path")]
1093 match self.strict_audience_validation {
1094 Some(true) | None => AudienceValidationMode::Strict,
1095 Some(false) => AudienceValidationMode::Warn,
1096 }
1097 }
1098
1099 pub fn builder(
1105 issuer: impl Into<String>,
1106 audience: impl Into<String>,
1107 jwks_uri: impl Into<String>,
1108 ) -> OAuthConfigBuilder {
1109 OAuthConfigBuilder {
1110 inner: Self {
1111 issuer: issuer.into(),
1112 audience: audience.into(),
1113 jwks_uri: jwks_uri.into(),
1114 ..Self::default()
1115 },
1116 }
1117 }
1118
1119 pub fn validate(&self) -> Result<(), crate::error::RmcpServerKitError> {
1135 validate_oauth_capacity_knobs(self)?;
1136 resolve_allowed_algorithms(self.allowed_algorithms.as_ref())?;
1137
1138 let allow_http = self.allow_http_oauth_urls;
1139 let url = check_oauth_url("oauth.issuer", &self.issuer, allow_http)?;
1140 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1141 return Err(crate::error::RmcpServerKitError::Config(format!(
1142 "oauth.issuer forbidden ({reason})"
1143 )));
1144 }
1145 let url = check_oauth_url("oauth.jwks_uri", &self.jwks_uri, allow_http)?;
1146 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1147 return Err(crate::error::RmcpServerKitError::Config(format!(
1148 "oauth.jwks_uri forbidden ({reason})"
1149 )));
1150 }
1151 self.validate_discovery_metadata_urls(allow_http)?;
1152 if self.audience.is_empty() {
1157 return Err(crate::error::RmcpServerKitError::Config(
1158 "oauth.audience must not be empty".into(),
1159 ));
1160 }
1161 if let Some(proxy) = &self.proxy {
1162 let url = check_oauth_url(
1163 "oauth.proxy.authorize_url",
1164 &proxy.authorize_url,
1165 allow_http,
1166 )?;
1167 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1168 return Err(crate::error::RmcpServerKitError::Config(format!(
1169 "oauth.proxy.authorize_url forbidden ({reason})"
1170 )));
1171 }
1172 let url = check_oauth_url("oauth.proxy.token_url", &proxy.token_url, allow_http)?;
1173 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1174 return Err(crate::error::RmcpServerKitError::Config(format!(
1175 "oauth.proxy.token_url forbidden ({reason})"
1176 )));
1177 }
1178 if let Some(url) = &proxy.introspection_url {
1179 let parsed = check_oauth_url("oauth.proxy.introspection_url", url, allow_http)?;
1180 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1181 return Err(crate::error::RmcpServerKitError::Config(format!(
1182 "oauth.proxy.introspection_url forbidden ({reason})"
1183 )));
1184 }
1185 }
1186 if let Some(url) = &proxy.revocation_url {
1187 let parsed = check_oauth_url("oauth.proxy.revocation_url", url, allow_http)?;
1188 if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1189 return Err(crate::error::RmcpServerKitError::Config(format!(
1190 "oauth.proxy.revocation_url forbidden ({reason})"
1191 )));
1192 }
1193 }
1194 if proxy.expose_admin_endpoints
1201 && !proxy.require_auth_on_admin_endpoints
1202 && !proxy.allow_unauthenticated_admin_endpoints
1203 {
1204 return Err(crate::error::RmcpServerKitError::Config(
1205 "oauth.proxy: expose_admin_endpoints = true requires \
1206 require_auth_on_admin_endpoints = true (recommended) \
1207 or allow_unauthenticated_admin_endpoints = true \
1208 (explicit opt-out, only safe behind an authenticated \
1209 reverse proxy)"
1210 .into(),
1211 ));
1212 }
1213 }
1214 if let Some(tx) = &self.token_exchange {
1215 let url = check_oauth_url("oauth.token_exchange.token_url", &tx.token_url, allow_http)?;
1216 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1217 return Err(crate::error::RmcpServerKitError::Config(format!(
1218 "oauth.token_exchange.token_url forbidden ({reason})"
1219 )));
1220 }
1221 validate_token_exchange_client_auth(tx)?;
1224 validate_token_exchange_optional_params(tx)?;
1225 }
1226 if let Some(raw) = &self.ssrf_allowlist {
1230 let compiled = compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1231 crate::error::RmcpServerKitError::Config(format!("oauth.ssrf_allowlist: {e}"))
1232 })?;
1233 if !compiled.is_empty() {
1234 tracing::warn!(
1235 host_count = compiled.host_count(),
1236 cidr_count = compiled.cidr_count(),
1237 "oauth.ssrf_allowlist is configured: private/loopback OAuth/JWKS targets \
1238 are now reachable. Cloud-metadata addresses remain blocked. \
1239 See SECURITY.md \"Operator allowlist\"."
1240 );
1241 }
1242 }
1243 humantime::parse_duration(&self.jwks_cache_ttl).map_err(|e| {
1246 crate::error::RmcpServerKitError::Config(format!(
1247 "oauth.jwks_cache_ttl {:?} is not a valid humantime duration (e.g. \"10m\", \"1h30m\"): {e}",
1248 self.jwks_cache_ttl
1249 ))
1250 })?;
1251 Ok(())
1252 }
1253
1254 fn validate_discovery_metadata_urls(
1263 &self,
1264 allow_http: bool,
1265 ) -> Result<(), crate::error::RmcpServerKitError> {
1266 if let Some(ref issuer) = self.authorization_server_metadata_issuer {
1267 let url = check_oauth_url(
1268 "oauth.authorization_server_metadata_issuer",
1269 issuer,
1270 allow_http,
1271 )?;
1272 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1273 return Err(crate::error::RmcpServerKitError::Config(format!(
1274 "oauth.authorization_server_metadata_issuer forbidden ({reason})"
1275 )));
1276 }
1277 }
1278 if let Some(ref servers) = self.authorization_servers {
1281 for (index, server) in servers.iter().enumerate() {
1282 let field = format!("oauth.authorization_servers[{index}]");
1283 let url = check_oauth_url(&field, server, allow_http)?;
1284 if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1285 return Err(crate::error::RmcpServerKitError::Config(format!(
1286 "{field} forbidden ({reason})"
1287 )));
1288 }
1289 }
1290 }
1291 Ok(())
1292 }
1293}
1294
1295fn validate_token_exchange_client_auth(
1301 tx: &TokenExchangeConfig,
1302) -> Result<(), crate::error::RmcpServerKitError> {
1303 match (&tx.client_cert, tx.client_secret.is_some()) {
1304 (Some(_), true) => Err(crate::error::RmcpServerKitError::Config(
1305 "oauth.token_exchange: client_cert and client_secret are mutually \
1306 exclusive (RFC 8705 §2). Set exactly one."
1307 .into(),
1308 )),
1309 (None, false) => Err(crate::error::RmcpServerKitError::Config(
1310 "oauth.token_exchange: token exchange requires client authentication. \
1311 Set either client_secret (RFC 6749 §2.3.1) or client_cert (RFC 8705 §2)."
1312 .into(),
1313 )),
1314 (Some(cc), false) => validate_client_cert_config(cc),
1315 (None, true) => Ok(()),
1316 }
1317}
1318
1319fn is_rfc3986_uri_char(c: char) -> bool {
1328 matches!(
1329 c,
1330 'A'..='Z'
1331 | 'a'..='z'
1332 | '0'..='9'
1333 | '-' | '.' | '_' | '~'
1334 | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
1335 | ':' | '/' | '?' | '#' | '[' | ']' | '@'
1336 | '%'
1337 )
1338}
1339
1340fn has_valid_pct_encoding(raw: &str) -> bool {
1342 let bytes = raw.as_bytes();
1343 let mut idx = 0;
1344 while let Some(byte) = bytes.get(idx) {
1345 if *byte == b'%' {
1346 let (Some(hi), Some(lo)) = (bytes.get(idx + 1), bytes.get(idx + 2)) else {
1347 return false;
1348 };
1349 if !hi.is_ascii_hexdigit() || !lo.is_ascii_hexdigit() {
1350 return false;
1351 }
1352 idx += 3;
1353 } else {
1354 idx += 1;
1355 }
1356 }
1357 true
1358}
1359
1360fn validate_token_exchange_optional_params(
1370 tx: &TokenExchangeConfig,
1371) -> Result<(), crate::error::RmcpServerKitError> {
1372 fn empty_field(field: &str) -> crate::error::RmcpServerKitError {
1373 crate::error::RmcpServerKitError::Config(format!(
1374 "oauth.token_exchange.{field} must not be empty; omit the key entirely \
1375 to leave the RFC 8693 §2.1 parameter out of the request"
1376 ))
1377 }
1378
1379 if tx.audience.as_deref().is_some_and(str::is_empty) {
1380 return Err(empty_field("audience"));
1381 }
1382 if tx.scope.as_deref().is_some_and(str::is_empty) {
1383 return Err(empty_field("scope"));
1384 }
1385 if let RequestedTokenType::Custom(ref uri) = tx.requested_token_type {
1386 if uri.is_empty() {
1387 return Err(empty_field("requested_token_type"));
1388 }
1389 if !uri.chars().all(is_rfc3986_uri_char) || !has_valid_pct_encoding(uri) {
1396 return Err(crate::error::RmcpServerKitError::Config(
1397 "oauth.token_exchange.requested_token_type custom value must be an RFC 3986 \
1398 absolute URI using valid URI characters and percent-encoding (RFC 8693 §3)"
1399 .into(),
1400 ));
1401 }
1402 url::Url::parse(uri).map_err(|e| {
1403 crate::error::RmcpServerKitError::Config(format!(
1404 "oauth.token_exchange.requested_token_type custom value must be an absolute \
1405 URI (RFC 8693 §3): {e}"
1406 ))
1407 })?;
1408 }
1409 if let Some(resource) = tx.resource.as_deref() {
1410 if resource.is_empty() {
1411 return Err(empty_field("resource"));
1412 }
1413 if !resource.chars().all(is_rfc3986_uri_char) || !has_valid_pct_encoding(resource) {
1414 return Err(crate::error::RmcpServerKitError::Config(
1415 "oauth.token_exchange.resource must be an RFC 3986 absolute URI using valid \
1416 URI characters and percent-encoding (RFC 8707 §2)"
1417 .into(),
1418 ));
1419 }
1420 let parsed = url::Url::parse(resource).map_err(|e| {
1421 crate::error::RmcpServerKitError::Config(format!(
1422 "oauth.token_exchange.resource must be an absolute URI (RFC 8707 §2): {e}"
1423 ))
1424 })?;
1425 if parsed.fragment().is_some() {
1426 return Err(crate::error::RmcpServerKitError::Config(
1427 "oauth.token_exchange.resource must not include a fragment component \
1428 (RFC 8707 §2)"
1429 .into(),
1430 ));
1431 }
1432 }
1433 Ok(())
1434}
1435
1436fn validate_client_cert_config(
1449 cc: &ClientCertConfig,
1450) -> Result<(), crate::error::RmcpServerKitError> {
1451 #[cfg(not(feature = "oauth-mtls-client"))]
1452 {
1453 let _ = cc;
1454 Err(crate::error::RmcpServerKitError::Config(
1455 "oauth.token_exchange.client_cert requires the `oauth-mtls-client` cargo feature; \
1456 rebuild rmcp-server-kit with --features oauth-mtls-client (or have your \
1457 application crate enable it via `rmcp-server-kit/oauth-mtls-client`), or remove \
1458 the field"
1459 .into(),
1460 ))
1461 }
1462 #[cfg(feature = "oauth-mtls-client")]
1463 {
1464 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1465 tracing::warn!(error = %e, path = %cc.cert_path.display(), "client cert read failed");
1466 crate::error::RmcpServerKitError::Config(format!(
1467 "oauth.token_exchange.client_cert.cert_path unreadable: {}",
1468 cc.cert_path.display()
1469 ))
1470 })?;
1471 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1472 tracing::warn!(error = %e, path = %cc.key_path.display(), "client cert key read failed");
1473 crate::error::RmcpServerKitError::Config(format!(
1474 "oauth.token_exchange.client_cert.key_path unreadable: {}",
1475 cc.key_path.display()
1476 ))
1477 })?;
1478 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1479 combined.extend_from_slice(&cert_bytes);
1480 if !cert_bytes.ends_with(b"\n") {
1481 combined.push(b'\n');
1482 }
1483 combined.extend_from_slice(&key_bytes);
1484 let _identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1485 tracing::warn!(
1486 error = %e,
1487 cert_path = %cc.cert_path.display(),
1488 key_path = %cc.key_path.display(),
1489 "client cert PEM parse failed"
1490 );
1491 crate::error::RmcpServerKitError::Config(format!(
1492 "oauth.token_exchange.client_cert: PEM parse failed (cert={}, key={})",
1493 cc.cert_path.display(),
1494 cc.key_path.display()
1495 ))
1496 })?;
1497 Ok(())
1498 }
1499}
1500
1501#[cfg(feature = "oauth-mtls-client")]
1509fn build_mtls_clients(
1510 config: Option<&OAuthConfig>,
1511 allowlist: &Arc<crate::ssrf::CompiledSsrfAllowlist>,
1512 test_bypass: &crate::ssrf_resolver::TestLoopbackBypass,
1513) -> Result<Arc<HashMap<MtlsClientKey, reqwest::Client>>, crate::error::RmcpServerKitError> {
1514 let mut map: HashMap<MtlsClientKey, reqwest::Client> = HashMap::new();
1515 let Some(cfg) = config else {
1516 return Ok(Arc::new(map));
1517 };
1518 let Some(tx) = &cfg.token_exchange else {
1519 return Ok(Arc::new(map));
1520 };
1521 let Some(cc) = &tx.client_cert else {
1522 return Ok(Arc::new(map));
1523 };
1524
1525 let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1526 crate::error::RmcpServerKitError::Startup(format!(
1527 "oauth http client mTLS: read cert_path {}: {e}",
1528 cc.cert_path.display()
1529 ))
1530 })?;
1531 let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1532 crate::error::RmcpServerKitError::Startup(format!(
1533 "oauth http client mTLS: read key_path {}: {e}",
1534 cc.key_path.display()
1535 ))
1536 })?;
1537 let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1538 combined.extend_from_slice(&cert_bytes);
1539 if !cert_bytes.ends_with(b"\n") {
1540 combined.push(b'\n');
1541 }
1542 combined.extend_from_slice(&key_bytes);
1543 let identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1544 crate::error::RmcpServerKitError::Startup(format!(
1545 "oauth http client mTLS: PEM parse (cert={}, key={}): {e}",
1546 cc.cert_path.display(),
1547 cc.key_path.display()
1548 ))
1549 })?;
1550
1551 let resolver: Arc<dyn reqwest::dns::Resolve> =
1552 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1553 Arc::clone(allowlist),
1554 #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1559 test_bypass.clone(),
1560 ));
1561
1562 let mut builder = reqwest::Client::builder()
1563 .no_proxy()
1565 .dns_resolver(Arc::clone(&resolver))
1566 .connect_timeout(Duration::from_secs(10))
1567 .timeout(Duration::from_secs(30))
1568 .redirect(reqwest::redirect::Policy::none())
1569 .identity(identity);
1570
1571 if let Some(ref ca_path) = cfg.ca_cert_path {
1572 let pem = std::fs::read(ca_path).map_err(|e| {
1573 crate::error::RmcpServerKitError::Startup(format!(
1574 "oauth http client mTLS: read ca_cert_path {}: {e}",
1575 ca_path.display()
1576 ))
1577 })?;
1578 let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
1579 crate::error::RmcpServerKitError::Startup(format!(
1580 "oauth http client mTLS: parse ca_cert_path {}: {e}",
1581 ca_path.display()
1582 ))
1583 })?;
1584 builder = builder.add_root_certificate(cert);
1585 }
1586
1587 let client = builder.build().map_err(|e| {
1588 crate::error::RmcpServerKitError::Startup(format!("oauth http client mTLS init: {e}"))
1589 })?;
1590 map.insert(
1591 MtlsClientKey {
1592 cert_path: cc.cert_path.clone(),
1593 key_path: cc.key_path.clone(),
1594 },
1595 client,
1596 );
1597 Ok(Arc::new(map))
1598}
1599
1600fn check_oauth_url(
1607 field: &str,
1608 raw: &str,
1609 allow_http: bool,
1610) -> Result<url::Url, crate::error::RmcpServerKitError> {
1611 let parsed = url::Url::parse(raw).map_err(|e| {
1612 crate::error::RmcpServerKitError::Config(format!(
1613 "{field}: invalid URL <unparseable-url>: {e}"
1614 ))
1615 })?;
1616 if !parsed.username().is_empty() || parsed.password().is_some() {
1617 return Err(crate::error::RmcpServerKitError::Config(format!(
1618 "{field} rejected: URL contains userinfo (credentials in URL are forbidden)"
1619 )));
1620 }
1621 match parsed.scheme() {
1622 "https" => Ok(parsed),
1623 "http" if allow_http => Ok(parsed),
1624 "http" => Err(crate::error::RmcpServerKitError::Config(format!(
1625 "{field}: must use https scheme (got http; set allow_http_oauth_urls=true \
1626 to override - strongly discouraged in production)"
1627 ))),
1628 other => Err(crate::error::RmcpServerKitError::Config(format!(
1629 "{field}: must use https scheme (got {other:?})"
1630 ))),
1631 }
1632}
1633
1634fn validate_oauth_capacity_knobs(
1635 config: &OAuthConfig,
1636) -> Result<(), crate::error::RmcpServerKitError> {
1637 (config.max_jwks_keys != 0).ok_or_else(|| {
1638 crate::error::RmcpServerKitError::Config("oauth.max_jwks_keys must be nonzero".into())
1639 })?;
1640 (config.jwks_max_response_bytes != 0).ok_or_else(|| {
1641 crate::error::RmcpServerKitError::Config(
1642 "oauth.jwks_max_response_bytes must be nonzero".into(),
1643 )
1644 })?;
1645 Ok(())
1646}
1647
1648#[derive(Debug, Clone)]
1654#[must_use = "builders do nothing until `.build()` is called"]
1655pub struct OAuthConfigBuilder {
1656 inner: OAuthConfig,
1657}
1658
1659impl OAuthConfigBuilder {
1660 pub fn allowed_algorithms(
1666 mut self,
1667 algorithms: impl IntoIterator<Item = impl Into<String>>,
1668 ) -> Self {
1669 self.inner.allowed_algorithms =
1670 Some(algorithms.into_iter().map(Into::into).collect::<Vec<_>>());
1671 self
1672 }
1673
1674 pub fn authorization_server_metadata_issuer(mut self, issuer: impl Into<String>) -> Self {
1682 self.inner.authorization_server_metadata_issuer = Some(issuer.into());
1683 self
1684 }
1685
1686 pub fn authorization_servers(
1694 mut self,
1695 servers: impl IntoIterator<Item = impl Into<String>>,
1696 ) -> Self {
1697 self.inner.authorization_servers =
1698 Some(servers.into_iter().map(Into::into).collect::<Vec<_>>());
1699 self
1700 }
1701
1702 pub fn scopes(mut self, scopes: Vec<ScopeMapping>) -> Self {
1704 self.inner.scopes = scopes;
1705 self
1706 }
1707
1708 pub fn scope(mut self, scope: impl Into<String>, role: impl Into<String>) -> Self {
1710 self.inner.scopes.push(ScopeMapping {
1711 scope: scope.into(),
1712 role: role.into(),
1713 });
1714 self
1715 }
1716
1717 pub fn role_claim(mut self, claim: impl Into<String>) -> Self {
1720 self.inner.role_claim = Some(claim.into());
1721 self
1722 }
1723
1724 pub fn role_mappings(mut self, mappings: Vec<RoleMapping>) -> Self {
1726 self.inner.role_mappings = mappings;
1727 self
1728 }
1729
1730 pub fn role_mapping(mut self, claim_value: impl Into<String>, role: impl Into<String>) -> Self {
1733 self.inner.role_mappings.push(RoleMapping {
1734 claim_value: claim_value.into(),
1735 role: role.into(),
1736 });
1737 self
1738 }
1739
1740 pub fn jwks_cache_ttl(mut self, ttl: impl Into<String>) -> Self {
1743 self.inner.jwks_cache_ttl = ttl.into();
1744 self
1745 }
1746
1747 pub fn proxy(mut self, proxy: OAuthProxyConfig) -> Self {
1750 self.inner.proxy = Some(proxy);
1751 self
1752 }
1753
1754 pub fn token_exchange(mut self, token_exchange: TokenExchangeConfig) -> Self {
1756 self.inner.token_exchange = Some(token_exchange);
1757 self
1758 }
1759
1760 pub fn ca_cert_path(mut self, path: impl Into<PathBuf>) -> Self {
1765 self.inner.ca_cert_path = Some(path.into());
1766 self
1767 }
1768
1769 pub const fn allow_http_oauth_urls(mut self, allow: bool) -> Self {
1775 self.inner.allow_http_oauth_urls = allow;
1776 self
1777 }
1778
1779 #[deprecated(since = "1.7.0", note = "use `audience_validation_mode` instead")]
1788 pub const fn strict_audience_validation(mut self, strict: bool) -> Self {
1789 #[allow(
1790 deprecated,
1791 reason = "intentional: deprecated builder forwards to deprecated field"
1792 )]
1793 {
1794 self.inner.strict_audience_validation = Some(strict);
1795 }
1796 self.inner.audience_validation_mode = None;
1797 self
1798 }
1799
1800 pub const fn audience_validation_mode(mut self, mode: AudienceValidationMode) -> Self {
1808 self.inner.audience_validation_mode = Some(mode);
1809 self
1810 }
1811
1812 pub const fn require_subject(mut self, require: bool) -> Self {
1818 self.inner.require_subject = require;
1819 self
1820 }
1821
1822 pub const fn jwks_max_response_bytes(mut self, bytes: u64) -> Self {
1824 self.inner.jwks_max_response_bytes = bytes;
1825 self
1826 }
1827
1828 pub fn ssrf_allowlist(mut self, allowlist: OAuthSsrfAllowlist) -> Self {
1836 self.inner.ssrf_allowlist = Some(allowlist);
1837 self
1838 }
1839
1840 #[must_use]
1842 pub fn build(self) -> OAuthConfig {
1843 self.inner
1844 }
1845}
1846
1847#[derive(Debug, Clone, Deserialize)]
1849#[serde(deny_unknown_fields)]
1850#[non_exhaustive]
1851pub struct ScopeMapping {
1852 pub scope: String,
1854 pub role: String,
1856}
1857
1858#[derive(Debug, Clone, Deserialize)]
1862#[serde(deny_unknown_fields)]
1863#[non_exhaustive]
1864pub struct RoleMapping {
1865 pub claim_value: String,
1867 pub role: String,
1869}
1870
1871const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
1872
1873#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
1885#[serde(from = "String")]
1886#[non_exhaustive]
1887pub enum RequestedTokenType {
1888 #[default]
1893 AccessToken,
1894 Omit,
1896 Custom(String),
1898}
1899
1900impl From<String> for RequestedTokenType {
1901 fn from(value: String) -> Self {
1902 match value.as_str() {
1903 "access_token" => Self::AccessToken,
1904 "omit" => Self::Omit,
1905 _ => Self::Custom(value),
1906 }
1907 }
1908}
1909
1910impl RequestedTokenType {
1911 fn wire_value(&self) -> Option<&str> {
1913 match *self {
1914 Self::AccessToken => Some(TOKEN_TYPE_ACCESS_TOKEN),
1915 Self::Omit => None,
1916 Self::Custom(ref uri) => Some(uri.as_str()),
1917 }
1918 }
1919}
1920
1921#[derive(Debug, Clone, Deserialize)]
1928#[serde(deny_unknown_fields)]
1929#[non_exhaustive]
1930pub struct TokenExchangeConfig {
1931 pub token_url: String,
1934 pub client_id: String,
1936 pub client_secret: Option<secrecy::SecretString>,
1941 pub client_cert: Option<ClientCertConfig>,
1954 #[serde(default)]
1961 pub audience: Option<String>,
1962 #[serde(default)]
1969 pub resource: Option<String>,
1970 #[serde(default)]
1973 pub scope: Option<String>,
1974 #[serde(default)]
1980 pub requested_token_type: RequestedTokenType,
1981}
1982
1983impl TokenExchangeConfig {
1984 #[must_use]
1990 pub fn new(
1991 token_url: impl Into<String>,
1992 client_id: impl Into<String>,
1993 client_secret: Option<secrecy::SecretString>,
1994 client_cert: Option<ClientCertConfig>,
1995 ) -> Self {
1996 Self {
1997 token_url: token_url.into(),
1998 client_id: client_id.into(),
1999 client_secret,
2000 client_cert,
2001 audience: None,
2002 resource: None,
2003 scope: None,
2004 requested_token_type: RequestedTokenType::default(),
2005 }
2006 }
2007
2008 #[must_use]
2010 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
2011 self.audience = Some(audience.into());
2012 self
2013 }
2014
2015 #[must_use]
2017 pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
2018 self.resource = Some(resource.into());
2019 self
2020 }
2021
2022 #[must_use]
2024 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
2025 self.scope = Some(scope.into());
2026 self
2027 }
2028
2029 #[must_use]
2031 pub fn with_requested_token_type(mut self, requested_token_type: RequestedTokenType) -> Self {
2032 self.requested_token_type = requested_token_type;
2033 self
2034 }
2035}
2036
2037#[derive(Debug, Clone, Deserialize)]
2041#[serde(deny_unknown_fields)]
2042#[non_exhaustive]
2043pub struct ClientCertConfig {
2044 pub cert_path: PathBuf,
2047 pub key_path: PathBuf,
2051}
2052
2053impl ClientCertConfig {
2054 #[must_use]
2058 pub fn new(cert_path: PathBuf, key_path: PathBuf) -> Self {
2059 Self {
2060 cert_path,
2061 key_path,
2062 }
2063 }
2064}
2065
2066#[derive(Deserialize)]
2068#[non_exhaustive]
2069pub struct ExchangedToken {
2070 pub access_token: String,
2072 pub expires_in: Option<u64>,
2074 pub issued_token_type: Option<String>,
2077}
2078
2079impl fmt::Debug for ExchangedToken {
2080 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2081 let Self {
2082 access_token,
2083 expires_in,
2084 issued_token_type,
2085 } = self;
2086 let access_token = if crate::diagnostics::plaintext_oauth_tokens() {
2087 access_token.as_str()
2088 } else {
2089 "[REDACTED]"
2090 };
2091 f.debug_struct("ExchangedToken")
2092 .field("access_token", &access_token)
2093 .field("expires_in", expires_in)
2094 .field("issued_token_type", issued_token_type)
2095 .finish()
2096 }
2097}
2098
2099#[derive(Debug, Clone, Deserialize, Default)]
2106#[serde(deny_unknown_fields)]
2107#[allow(
2108 clippy::struct_excessive_bools,
2109 reason = "flat TOML sub-table of independent operator toggles; collapsing them into an enum would break both the public API and the deserialized schema"
2110)]
2111#[non_exhaustive]
2112pub struct OAuthProxyConfig {
2113 pub authorize_url: String,
2116 pub token_url: String,
2119 pub client_id: String,
2121 pub client_secret: Option<secrecy::SecretString>,
2123 #[serde(default)]
2127 pub introspection_url: Option<String>,
2128 #[serde(default)]
2132 pub revocation_url: Option<String>,
2133 #[serde(default)]
2145 pub expose_admin_endpoints: bool,
2146 #[serde(default)]
2152 pub require_auth_on_admin_endpoints: bool,
2153 #[serde(default)]
2164 pub allow_unauthenticated_admin_endpoints: bool,
2165 #[serde(default)]
2186 pub strip_resource_param: bool,
2187}
2188
2189impl OAuthProxyConfig {
2190 pub fn builder(
2198 authorize_url: impl Into<String>,
2199 token_url: impl Into<String>,
2200 client_id: impl Into<String>,
2201 ) -> OAuthProxyConfigBuilder {
2202 OAuthProxyConfigBuilder {
2203 inner: Self {
2204 authorize_url: authorize_url.into(),
2205 token_url: token_url.into(),
2206 client_id: client_id.into(),
2207 ..Self::default()
2208 },
2209 }
2210 }
2211}
2212
2213#[derive(Debug, Clone)]
2219#[must_use = "builders do nothing until `.build()` is called"]
2220pub struct OAuthProxyConfigBuilder {
2221 inner: OAuthProxyConfig,
2222}
2223
2224impl OAuthProxyConfigBuilder {
2225 pub fn client_secret(mut self, secret: secrecy::SecretString) -> Self {
2227 self.inner.client_secret = Some(secret);
2228 self
2229 }
2230
2231 pub fn introspection_url(mut self, url: impl Into<String>) -> Self {
2235 self.inner.introspection_url = Some(url.into());
2236 self
2237 }
2238
2239 pub fn revocation_url(mut self, url: impl Into<String>) -> Self {
2243 self.inner.revocation_url = Some(url.into());
2244 self
2245 }
2246
2247 pub const fn expose_admin_endpoints(mut self, expose: bool) -> Self {
2255 self.inner.expose_admin_endpoints = expose;
2256 self
2257 }
2258
2259 pub const fn require_auth_on_admin_endpoints(mut self, require: bool) -> Self {
2262 self.inner.require_auth_on_admin_endpoints = require;
2263 self
2264 }
2265
2266 pub const fn allow_unauthenticated_admin_endpoints(mut self, allow: bool) -> Self {
2270 self.inner.allow_unauthenticated_admin_endpoints = allow;
2271 self
2272 }
2273
2274 pub const fn strip_resource_param(mut self, strip: bool) -> Self {
2279 self.inner.strip_resource_param = strip;
2280 self
2281 }
2282
2283 #[must_use]
2285 pub fn build(self) -> OAuthProxyConfig {
2286 self.inner
2287 }
2288}
2289
2290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2308enum JwkKeyFamily {
2309 Rsa,
2311 EcP256,
2313 EcP384,
2315 Ed25519,
2317}
2318
2319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2321enum JwkAlg {
2322 Explicit(Algorithm),
2324 Family(JwkKeyFamily),
2327}
2328
2329impl JwkAlg {
2330 fn accepts(self, alg: Algorithm) -> bool {
2337 match self {
2338 Self::Explicit(declared) => declared == alg,
2339 Self::Family(family) => family_accepts(family, alg),
2340 }
2341 }
2342}
2343
2344const fn family_accepts(family: JwkKeyFamily, alg: Algorithm) -> bool {
2351 match family {
2352 JwkKeyFamily::Rsa => matches!(
2353 alg,
2354 Algorithm::RS256
2355 | Algorithm::RS384
2356 | Algorithm::RS512
2357 | Algorithm::PS256
2358 | Algorithm::PS384
2359 | Algorithm::PS512
2360 ),
2361 JwkKeyFamily::EcP256 => matches!(alg, Algorithm::ES256),
2362 JwkKeyFamily::EcP384 => matches!(alg, Algorithm::ES384),
2363 JwkKeyFamily::Ed25519 => matches!(alg, Algorithm::EdDSA),
2364 }
2365}
2366
2367type JwksKeyCache = (
2371 HashMap<String, (JwkAlg, DecodingKey)>,
2372 Vec<(JwkAlg, DecodingKey)>,
2373);
2374
2375struct CachedKeys {
2376 keys: HashMap<String, (JwkAlg, DecodingKey)>,
2378 unnamed_keys: Vec<(JwkAlg, DecodingKey)>,
2380 fetched_at: Instant,
2381 ttl: Duration,
2382}
2383
2384const _JWKS_REFRESH_COOLDOWN_DOC_ANCHOR: &str = "JWKS_REFRESH_COOLDOWN";
2385
2386impl CachedKeys {
2387 fn is_expired(&self) -> bool {
2388 self.fetched_at.elapsed() >= self.ttl
2389 }
2390}
2391
2392#[allow(
2401 missing_debug_implementations,
2402 reason = "contains reqwest::Client and DecodingKey cache with no Debug impl"
2403)]
2404#[non_exhaustive]
2405pub struct JwksCache {
2406 jwks_uri: String,
2407 ttl: Duration,
2408 max_jwks_keys: usize,
2409 allowed_algorithms: Vec<Algorithm>,
2412 max_response_bytes: u64,
2413 allow_http: bool,
2414 inner: RwLock<Option<CachedKeys>>,
2415 http: reqwest::Client,
2416 validation_template: Validation,
2417 expected_audience: String,
2420 audience_mode: AudienceValidationMode,
2421 require_subject: bool,
2422 azp_fallback_warned: AtomicBool,
2426 azp_permissive_logged: AtomicBool,
2429 scopes: Vec<ScopeMapping>,
2430 role_claim: Option<String>,
2431 role_mappings: Vec<RoleMapping>,
2432 last_refresh_attempt: RwLock<Option<Instant>>,
2435 refresh_lock: tokio::sync::Mutex<()>,
2437 allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
2441 #[cfg(any(test, feature = "test-helpers"))]
2445 test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
2446}
2447
2448const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(10);
2449
2450const OAUTH_PROXY_MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
2460
2461const ACCEPTED_ALGS: &[Algorithm] = &[
2469 Algorithm::RS256,
2470 Algorithm::RS384,
2471 Algorithm::RS512,
2472 Algorithm::ES256,
2473 Algorithm::ES384,
2474 Algorithm::PS256,
2475 Algorithm::PS384,
2476 Algorithm::PS512,
2477 Algorithm::EdDSA,
2478];
2479
2480#[allow(
2487 clippy::wildcard_enum_match_arm,
2488 reason = "jsonwebtoken Algorithm is #[non_exhaustive], so an exhaustive match is impossible; HS*, `none`, and any future variant must fail closed to None"
2489)]
2490fn accepted_algorithm_name(alg: Algorithm) -> Option<&'static str> {
2491 match alg {
2492 Algorithm::RS256 => Some("RS256"),
2493 Algorithm::RS384 => Some("RS384"),
2494 Algorithm::RS512 => Some("RS512"),
2495 Algorithm::ES256 => Some("ES256"),
2496 Algorithm::ES384 => Some("ES384"),
2497 Algorithm::PS256 => Some("PS256"),
2498 Algorithm::PS384 => Some("PS384"),
2499 Algorithm::PS512 => Some("PS512"),
2500 Algorithm::EdDSA => Some("EdDSA"),
2501 _ => None,
2502 }
2503}
2504
2505fn accepted_algorithm_from_name(name: &str) -> Option<Algorithm> {
2511 ACCEPTED_ALGS
2512 .iter()
2513 .copied()
2514 .find(|alg| accepted_algorithm_name(*alg).is_some_and(|n| n.eq_ignore_ascii_case(name)))
2515}
2516
2517fn accepted_algorithm_names() -> String {
2519 ACCEPTED_ALGS
2520 .iter()
2521 .filter_map(|alg| accepted_algorithm_name(*alg))
2522 .collect::<Vec<_>>()
2523 .join(", ")
2524}
2525
2526pub(crate) fn resolve_allowed_algorithms(
2535 configured: Option<&Vec<String>>,
2536) -> Result<Vec<Algorithm>, crate::error::RmcpServerKitError> {
2537 let Some(names) = configured else {
2538 return Ok(ACCEPTED_ALGS.to_vec());
2539 };
2540 if names.is_empty() {
2541 return Err(crate::error::RmcpServerKitError::Config(
2542 "oauth.allowed_algorithms must not be empty; omit the field to accept the default set"
2543 .into(),
2544 ));
2545 }
2546 let mut resolved = Vec::with_capacity(names.len());
2547 for name in names {
2548 let Some(alg) = accepted_algorithm_from_name(name) else {
2549 return Err(crate::error::RmcpServerKitError::Config(format!(
2550 "oauth.allowed_algorithms contains unsupported algorithm {name:?}; \
2551 permitted values are: {}",
2552 accepted_algorithm_names()
2553 )));
2554 };
2555 if !resolved.contains(&alg) {
2556 resolved.push(alg);
2557 }
2558 }
2559 Ok(resolved)
2560}
2561
2562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2564#[non_exhaustive]
2565pub enum JwtValidationFailure {
2566 Expired,
2568 Invalid,
2570}
2571
2572impl JwksCache {
2573 pub fn new(config: &OAuthConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
2585 rustls::crypto::ring::default_provider()
2588 .install_default()
2589 .ok();
2590 jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
2591 .install_default()
2592 .ok();
2593
2594 let ttl = humantime::parse_duration(&config.jwks_cache_ttl).map_err(|error| {
2595 format!(
2596 "invalid jwks_cache_ttl {:?}: {error}",
2597 config.jwks_cache_ttl
2598 )
2599 })?;
2600
2601 let mut validation = Validation::new(Algorithm::RS256);
2602 validation.validate_aud = false;
2614 validation.set_issuer(&[&config.issuer]);
2615 validation.set_required_spec_claims(&["exp", "iss"]);
2616 validation.validate_exp = true;
2617 validation.validate_nbf = true;
2618
2619 let allow_http = config.allow_http_oauth_urls;
2620
2621 let allowlist = match config.ssrf_allowlist.as_ref() {
2624 Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
2625 Box::<dyn std::error::Error + Send + Sync>::from(format!(
2626 "oauth.ssrf_allowlist: {e}"
2627 ))
2628 })?),
2629 None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
2630 };
2631 let redirect_allowlist = Arc::clone(&allowlist);
2632
2633 #[cfg(any(test, feature = "test-helpers"))]
2635 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
2636 Arc::new(AtomicBool::new(false));
2637 #[cfg(not(any(test, feature = "test-helpers")))]
2638 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
2639
2640 #[allow(
2641 clippy::clone_on_ref_ptr,
2642 clippy::clone_on_copy,
2643 clippy::unit_arg,
2644 reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
2645 )]
2646 let resolver: Arc<dyn reqwest::dns::Resolve> =
2647 Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
2648 Arc::clone(&allowlist),
2649 test_bypass.clone(),
2650 ));
2651
2652 let mut http_builder = reqwest::Client::builder()
2653 .no_proxy()
2655 .dns_resolver(Arc::clone(&resolver))
2656 .timeout(Duration::from_secs(10))
2657 .connect_timeout(Duration::from_secs(3))
2658 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
2659 match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
2669 Ok(()) => attempt.follow(),
2670 Err(reason) => {
2671 tracing::warn!(
2675 reason = %reason,
2676 target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
2677 "oauth redirect rejected"
2678 );
2679 attempt.error(reason)
2680 }
2681 }
2682 }));
2683
2684 if let Some(ref ca_path) = config.ca_cert_path {
2685 let pem = std::fs::read(ca_path)?;
2691 let cert = reqwest::tls::Certificate::from_pem(&pem)?;
2692 http_builder = http_builder.add_root_certificate(cert);
2693 }
2694
2695 let http = http_builder.build()?;
2696
2697 Ok(Self {
2698 jwks_uri: config.jwks_uri.clone(),
2699 ttl,
2700 max_jwks_keys: config.max_jwks_keys,
2701 allowed_algorithms: resolve_allowed_algorithms(config.allowed_algorithms.as_ref())?,
2702 max_response_bytes: config.jwks_max_response_bytes,
2703 allow_http,
2704 inner: RwLock::new(None),
2705 http,
2706 validation_template: validation,
2707 expected_audience: config.audience.clone(),
2708 audience_mode: config.effective_audience_validation_mode(),
2709 require_subject: config.require_subject,
2710 azp_fallback_warned: AtomicBool::new(false),
2711 azp_permissive_logged: AtomicBool::new(false),
2712 scopes: config.scopes.clone(),
2713 role_claim: config.role_claim.clone(),
2714 role_mappings: config.role_mappings.clone(),
2715 last_refresh_attempt: RwLock::new(None),
2716 refresh_lock: tokio::sync::Mutex::new(()),
2717 allowlist,
2718 #[cfg(any(test, feature = "test-helpers"))]
2719 test_allow_loopback_ssrf: test_bypass,
2720 })
2721 }
2722
2723 #[cfg(any(test, feature = "test-helpers"))]
2732 #[doc(hidden)]
2733 #[must_use]
2734 pub fn __test_allow_loopback_ssrf(self) -> Self {
2735 self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
2738 self
2739 }
2740
2741 pub async fn validate_token(&self, token: &str) -> Option<AuthIdentity> {
2743 self.validate_token_with_reason(token).await.ok()
2744 }
2745
2746 pub async fn validate_token_with_reason(
2756 &self,
2757 token: &str,
2758 ) -> Result<AuthIdentity, JwtValidationFailure> {
2759 let claims = self.decode_claims(token).await?;
2760
2761 if self.require_subject && claims.sub.is_none() {
2762 core::hint::cold_path();
2763 tracing::debug!("JWT rejected: require_subject is set but the token has no `sub`");
2764 return Err(JwtValidationFailure::Invalid);
2765 }
2766 self.check_audience(&claims)?;
2767 let role = self.resolve_role(&claims)?;
2768
2769 let sub = claims.sub;
2772 let name = claims
2773 .extra
2774 .get("preferred_username")
2775 .and_then(|v| v.as_str())
2776 .map(String::from)
2777 .or_else(|| sub.clone())
2778 .or(claims.azp)
2779 .or(claims.client_id)
2780 .unwrap_or_else(|| "oauth-client".into());
2781
2782 Ok(AuthIdentity {
2783 name,
2784 role,
2785 method: AuthMethod::OAuthJwt,
2786 raw_token: None,
2787 sub,
2788 })
2789 }
2790
2791 async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2807 let (key, alg) = self.select_jwks_key(token).await?;
2808
2809 let mut validation = self.validation_template.clone();
2813 validation.algorithms = vec![alg];
2814
2815 let token_owned = token.to_owned();
2818 let join =
2819 tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2820 .await;
2821
2822 let decode_result = match join {
2823 Ok(r) => r,
2824 Err(join_err) => {
2825 core::hint::cold_path();
2826 tracing::error!(
2827 error = %join_err,
2828 "JWT decode task panicked or was cancelled"
2829 );
2830 return Err(JwtValidationFailure::Invalid);
2831 }
2832 };
2833
2834 decode_result.map(|td| td.claims).map_err(|e| {
2835 core::hint::cold_path();
2836 let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2837 JwtValidationFailure::Expired
2838 } else {
2839 JwtValidationFailure::Invalid
2840 };
2841 tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2842 failure
2843 })
2844 }
2845
2846 #[allow(
2859 clippy::cognitive_complexity,
2860 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"
2861 )]
2862 async fn select_jwks_key(
2863 &self,
2864 token: &str,
2865 ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2866 let Ok(header) = decode_header(token) else {
2867 core::hint::cold_path();
2868 tracing::debug!("JWT header decode failed");
2869 return Err(JwtValidationFailure::Invalid);
2870 };
2871 let kid = header.kid.as_deref();
2872 tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2873
2874 if !self.allowed_algorithms.contains(&header.alg) {
2875 core::hint::cold_path();
2876 tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2877 return Err(JwtValidationFailure::Invalid);
2878 }
2879
2880 let Some(key) = self.find_key(kid, header.alg).await else {
2881 core::hint::cold_path();
2882 tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2883 return Err(JwtValidationFailure::Invalid);
2884 };
2885
2886 Ok((key, header.alg))
2887 }
2888
2889 fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2898 if claims.aud.contains(&self.expected_audience) {
2899 return Ok(());
2900 }
2901 let azp_match = claims
2902 .azp
2903 .as_deref()
2904 .is_some_and(|azp| azp == self.expected_audience);
2905 if azp_match {
2906 match self.audience_mode {
2907 AudienceValidationMode::Permissive => {
2908 if !self.azp_permissive_logged.swap(true, Ordering::Relaxed) {
2909 tracing::info!(
2910 expected = %self.expected_audience,
2911 "JWT accepted via azp-only audience fallback because \
2912 audience_validation_mode = \"permissive\". Acceptance is \
2913 intentionally wider than the spec; set \"warn\" or \"strict\" \
2914 to tighten it. This message logs once per process."
2915 );
2916 }
2917 return Ok(());
2918 }
2919 AudienceValidationMode::Warn => {
2920 if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2921 tracing::warn!(
2922 expected = %self.expected_audience,
2923 azp = claims.azp.as_deref().unwrap_or("-"),
2924 "JWT accepted via deprecated azp-only audience fallback. \
2925 Configure your IdP to populate aud, or set \
2926 audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2927 To silence this warning without changing acceptance, \
2928 set audience_validation_mode = \"permissive\". \
2929 This warning logs once per process."
2930 );
2931 }
2932 return Ok(());
2933 }
2934 AudienceValidationMode::Strict => {}
2935 }
2936 }
2937 core::hint::cold_path();
2938 self.log_audience_mismatch(claims);
2939 Err(JwtValidationFailure::Invalid)
2940 }
2941
2942 fn log_audience_mismatch(&self, claims: &Claims) {
2949 let expose = crate::diagnostics::oauth_claim_values();
2950 let aud = if expose {
2951 claims.aud.log_display()
2952 } else {
2953 "[REDACTED]".to_owned()
2954 };
2955 let azp = if expose {
2956 claims.azp.as_deref().unwrap_or("-")
2957 } else {
2958 "[REDACTED]"
2959 };
2960 tracing::debug!(
2961 aud = %aud,
2962 azp = azp,
2963 expected = %self.expected_audience,
2964 mode = self.audience_mode.as_str(),
2965 "JWT rejected: audience mismatch"
2966 );
2967 }
2968
2969 fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2975 if let Some(ref claim_path) = self.role_claim {
2976 let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2977 let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2978 values.extend(resolve_claim_path(&claims.extra, claim_path));
2979 return self
2980 .role_mappings
2981 .iter()
2982 .find(|m| values.contains(&m.claim_value.as_str()))
2983 .map(|m| m.role.clone())
2984 .ok_or(JwtValidationFailure::Invalid);
2985 }
2986
2987 let token_scopes: Vec<&str> = claims
2988 .scope
2989 .as_deref()
2990 .unwrap_or("")
2991 .split_whitespace()
2992 .collect();
2993
2994 self.scopes
2995 .iter()
2996 .find(|m| token_scopes.contains(&m.scope.as_str()))
2997 .map(|m| m.role.clone())
2998 .ok_or(JwtValidationFailure::Invalid)
2999 }
3000
3001 async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3007 {
3009 let guard = self.inner.read().await;
3010 if let Some(cached) = guard.as_ref()
3011 && !cached.is_expired()
3012 && let Some(key) = lookup_key(cached, kid, alg)
3013 {
3014 return Some(key);
3015 }
3016 }
3017
3018 self.refresh_with_cooldown().await;
3020
3021 let guard = self.inner.read().await;
3027 guard
3028 .as_ref()
3029 .filter(|cached| !cached.is_expired())
3030 .and_then(|cached| lookup_key(cached, kid, alg))
3031 }
3032
3033 async fn refresh_with_cooldown(&self) {
3053 let _guard = self.refresh_lock.lock().await;
3055
3056 {
3058 let last = self.last_refresh_attempt.read().await;
3059 if let Some(ts) = *last
3060 && ts.elapsed() < JWKS_REFRESH_COOLDOWN
3061 {
3062 tracing::info!(
3063 elapsed_ms = ts.elapsed().as_millis(),
3064 cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
3065 "JWKS refresh skipped (cooldown active)"
3066 );
3067 return;
3068 }
3069 }
3070
3071 {
3074 let mut last = self.last_refresh_attempt.write().await;
3075 *last = Some(Instant::now());
3076 }
3077
3078 let _ = self.refresh_inner().await;
3080 }
3081
3082 async fn refresh_inner(&self) -> Result<(), String> {
3091 let Some(jwks) = self.fetch_jwks().await else {
3092 return Ok(());
3093 };
3094 let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
3095 Ok(cache) => cache,
3096 Err(msg) => {
3097 tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
3098 return Err(msg);
3099 }
3100 };
3101
3102 tracing::debug!(
3103 named = keys.len(),
3104 unnamed = unnamed_keys.len(),
3105 "JWKS refreshed"
3106 );
3107
3108 let mut guard = self.inner.write().await;
3109 *guard = Some(CachedKeys {
3110 keys,
3111 unnamed_keys,
3112 fetched_at: Instant::now(),
3113 ttl: self.ttl,
3114 });
3115 drop(guard);
3116 Ok(())
3117 }
3118
3119 #[allow(
3121 clippy::cognitive_complexity,
3122 reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
3123 )]
3124 async fn fetch_jwks(&self) -> Option<JwkSet> {
3128 #[cfg(any(test, feature = "test-helpers"))]
3129 let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
3130 screen_oauth_target_with_test_override(
3131 &self.jwks_uri,
3132 self.allow_http,
3133 &self.allowlist,
3134 true,
3135 )
3136 .await
3137 } else {
3138 screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
3139 };
3140 #[cfg(not(any(test, feature = "test-helpers")))]
3141 let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
3142
3143 if let Err(error) = screening {
3144 tracing::warn!(
3145 error = %error,
3146 uri = %oauth_request_target_for_log(&self.jwks_uri),
3147 "failed to screen JWKS target"
3148 );
3149 return None;
3150 }
3151
3152 let mut resp = match self.http.get(&self.jwks_uri).send().await {
3153 Ok(resp) => resp,
3154 Err(e) => {
3155 tracing::warn!(
3156 error = %e.without_url(),
3157 uri = %oauth_request_target_for_log(&self.jwks_uri),
3158 "failed to fetch JWKS"
3159 );
3160 return None;
3161 }
3162 };
3163
3164 let initial_capacity =
3165 usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3166 let mut body = Vec::with_capacity(initial_capacity);
3167 while let Some(chunk) = match resp.chunk().await {
3168 Ok(chunk) => chunk,
3169 Err(error) => {
3170 tracing::warn!(
3171 error = %error.without_url(),
3172 uri = %oauth_request_target_for_log(&self.jwks_uri),
3173 "failed to read JWKS response"
3174 );
3175 return None;
3176 }
3177 } {
3178 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3179 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3180 if body_len.saturating_add(chunk_len) > self.max_response_bytes {
3181 tracing::warn!(
3182 uri = %oauth_request_target_for_log(&self.jwks_uri),
3183 max_bytes = self.max_response_bytes,
3184 "JWKS response exceeded configured size cap"
3185 );
3186 return None;
3187 }
3188 body.extend_from_slice(&chunk);
3189 }
3190
3191 match serde_json::from_slice::<JwkSet>(&body) {
3192 Ok(jwks) => Some(jwks),
3193 Err(error) => {
3194 tracing::warn!(
3195 error = %error,
3196 uri = %oauth_request_target_for_log(&self.jwks_uri),
3197 "failed to parse JWKS"
3198 );
3199 None
3200 }
3201 }
3202 }
3203
3204 #[cfg(any(test, feature = "test-helpers"))]
3213 #[doc(hidden)]
3214 pub async fn __test_refresh_now(&self) -> Result<(), String> {
3215 let jwks = self
3216 .fetch_jwks()
3217 .await
3218 .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
3219 let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
3220 let mut guard = self.inner.write().await;
3221 *guard = Some(CachedKeys {
3222 keys,
3223 unnamed_keys,
3224 fetched_at: Instant::now(),
3225 ttl: self.ttl,
3226 });
3227 drop(guard);
3228 Ok(())
3229 }
3230
3231 #[cfg(any(test, feature = "test-helpers"))]
3234 #[doc(hidden)]
3235 pub async fn __test_has_kid(&self, kid: &str) -> bool {
3236 let guard = self.inner.read().await;
3237 guard
3238 .as_ref()
3239 .is_some_and(|cache| cache.keys.contains_key(kid))
3240 }
3241}
3242
3243const MAX_LOGGED_KID_CHARS: usize = 64;
3246
3247fn truncate_kid_for_log(kid: &str) -> (String, bool) {
3254 if kid.chars().count() <= MAX_LOGGED_KID_CHARS {
3255 return (kid.to_owned(), false);
3256 }
3257 let head: String = kid.chars().take(MAX_LOGGED_KID_CHARS).collect();
3258 (format!("{head}...(truncated)"), true)
3259}
3260
3261fn jwk_kid_for_log(jwk: &jsonwebtoken::jwk::Jwk) -> (String, bool) {
3263 jwk.common
3264 .key_id
3265 .as_deref()
3266 .map_or_else(|| ("<no-kid>".to_owned(), false), truncate_kid_for_log)
3267}
3268
3269fn classify_jwk(jwk: &jsonwebtoken::jwk::Jwk) -> Option<(JwkAlg, DecodingKey)> {
3275 if !jwk_permits_signature_verification(jwk) {
3276 let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3277 tracing::debug!(
3278 kid = %kid_log,
3279 kid_truncated,
3280 "skipping JWKS key not permitted for signature verification (use/key_ops)"
3281 );
3282 return None;
3283 }
3284 let decoding_key = DecodingKey::from_jwk(jwk).ok()?;
3285 let alg = jwk_algorithm(jwk)?;
3286 if let JwkAlg::Family(family) = alg {
3287 let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3288 tracing::debug!(
3289 kid = %kid_log,
3290 kid_truncated,
3291 family = ?family,
3292 "JWKS key omits `alg`; inferring permitted algorithms from key type (RFC 7517 4.4)"
3293 );
3294 }
3295 Some((alg, decoding_key))
3296}
3297
3298fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
3299 if jwks.keys.len() > max_keys {
3300 return Err(format!(
3301 "jwks_key_count_exceeds_cap: got {} keys, max is {}",
3302 jwks.keys.len(),
3303 max_keys
3304 ));
3305 }
3306 let mut keys = HashMap::new();
3307 let mut unnamed_keys = Vec::new();
3308 for jwk in &jwks.keys {
3309 let Some((alg, decoding_key)) = classify_jwk(jwk) else {
3310 continue;
3311 };
3312 if let Some(ref kid) = jwk.common.key_id {
3313 if keys.insert(kid.clone(), (alg, decoding_key)).is_some() {
3314 let (kid_log, kid_truncated) = truncate_kid_for_log(kid);
3315 tracing::warn!(
3316 kid = %kid_log,
3317 kid_truncated,
3318 "duplicate kid in JWKS; later entry wins"
3319 );
3320 }
3321 } else {
3322 unnamed_keys.push((alg, decoding_key));
3323 }
3324 }
3325 Ok((keys, unnamed_keys))
3326}
3327
3328fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3330 if let Some(kid) = kid {
3331 if let Some((cached_alg, key)) = cached.keys.get(kid)
3336 && cached_alg.accepts(alg)
3337 {
3338 return Some(key.clone());
3339 }
3340 return None;
3341 }
3342 cached
3344 .unnamed_keys
3345 .iter()
3346 .find(|(a, _)| a.accepts(alg))
3347 .map(|(_, k)| k.clone())
3348}
3349
3350fn jwk_permits_signature_verification(jwk: &jsonwebtoken::jwk::Jwk) -> bool {
3363 use jsonwebtoken::jwk::{KeyOperations, PublicKeyUse};
3364
3365 let use_ok = match jwk.common.public_key_use {
3366 None | Some(PublicKeyUse::Signature) => true,
3367 Some(PublicKeyUse::Encryption | PublicKeyUse::Other(_)) => false,
3368 };
3369 let ops_ok = jwk
3372 .common
3373 .key_operations
3374 .as_ref()
3375 .is_none_or(|ops| ops.contains(&KeyOperations::Verify));
3376
3377 use_ok && ops_ok
3378}
3379
3380fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkAlg> {
3387 match jwk.common.key_algorithm {
3388 Some(declared) => explicit_jwk_algorithm(declared).map(JwkAlg::Explicit),
3389 None => infer_jwk_family(jwk).map(JwkAlg::Family),
3390 }
3391}
3392
3393#[allow(
3395 clippy::wildcard_enum_match_arm,
3396 reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
3397)]
3398fn explicit_jwk_algorithm(declared: jsonwebtoken::jwk::KeyAlgorithm) -> Option<Algorithm> {
3399 match declared {
3400 jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
3401 jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
3402 jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
3403 jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
3404 jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
3405 jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
3406 jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
3407 jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
3408 jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
3409 _ => None,
3410 }
3411}
3412
3413#[allow(
3422 clippy::wildcard_enum_match_arm,
3423 reason = "jsonwebtoken AlgorithmParameters and EllipticCurve are both #[non_exhaustive] external enums, so an exhaustive match is impossible; unmatched variants must fail closed to None"
3424)]
3425fn infer_jwk_family(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkKeyFamily> {
3426 use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve};
3427
3428 match jwk.algorithm {
3429 AlgorithmParameters::RSA(_) => Some(JwkKeyFamily::Rsa),
3430 AlgorithmParameters::EllipticCurve(ref ec) => match ec.curve {
3431 EllipticCurve::P256 => Some(JwkKeyFamily::EcP256),
3432 EllipticCurve::P384 => Some(JwkKeyFamily::EcP384),
3433 _ => None,
3434 },
3435 AlgorithmParameters::OctetKeyPair(ref okp) => match okp.curve {
3436 EllipticCurve::Ed25519 => Some(JwkKeyFamily::Ed25519),
3437 _ => None,
3438 },
3439 _ => None,
3440 }
3441}
3442
3443fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
3464 match path {
3465 "sub" => claims.sub.iter().cloned().collect(),
3466 "azp" => claims.azp.iter().cloned().collect(),
3467 "client_id" => claims.client_id.iter().cloned().collect(),
3468 "aud" => claims.aud.0.clone(),
3469 "scope" => claims
3470 .scope
3471 .as_deref()
3472 .unwrap_or("")
3473 .split_whitespace()
3474 .map(str::to_owned)
3475 .collect(),
3476 _ => Vec::new(),
3477 }
3478}
3479
3480fn resolve_claim_path<'a>(
3490 extra: &'a HashMap<String, serde_json::Value>,
3491 path: &str,
3492) -> Vec<&'a str> {
3493 let mut segments = path.split('.');
3494 let Some(first) = segments.next() else {
3495 return Vec::new();
3496 };
3497
3498 let mut current: Option<&serde_json::Value> = extra.get(first);
3499
3500 for segment in segments {
3501 current = current.and_then(|v| v.get(segment));
3502 }
3503
3504 match current {
3505 Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
3506 Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
3507 _ => Vec::new(),
3508 }
3509}
3510
3511#[derive(Debug, Deserialize)]
3517struct Claims {
3518 sub: Option<String>,
3520 #[serde(default)]
3523 aud: OneOrMany,
3524 azp: Option<String>,
3526 client_id: Option<String>,
3528 scope: Option<String>,
3530 #[serde(flatten)]
3532 extra: HashMap<String, serde_json::Value>,
3533}
3534
3535#[derive(Debug, Default)]
3537struct OneOrMany(Vec<String>);
3538
3539impl OneOrMany {
3540 fn contains(&self, value: &str) -> bool {
3541 self.0.iter().any(|v| v == value)
3542 }
3543
3544 fn log_display(&self) -> String {
3548 if self.0.is_empty() {
3549 "-".to_owned()
3550 } else {
3551 self.0.join(", ")
3552 }
3553 }
3554}
3555
3556fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
3566 match value {
3567 Some(serde_json::Value::String(s)) => s.clone(),
3568 Some(serde_json::Value::Array(items)) => {
3569 let joined = items
3570 .iter()
3571 .filter_map(serde_json::Value::as_str)
3572 .collect::<Vec<_>>()
3573 .join(", ");
3574 if joined.is_empty() {
3575 "-".to_owned()
3576 } else {
3577 joined
3578 }
3579 }
3580 Some(
3581 serde_json::Value::Null
3582 | serde_json::Value::Bool(_)
3583 | serde_json::Value::Number(_)
3584 | serde_json::Value::Object(_),
3585 )
3586 | None => "-".to_owned(),
3587 }
3588}
3589
3590fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
3594 value.and_then(serde_json::Value::as_str).unwrap_or("-")
3595}
3596
3597impl<'de> Deserialize<'de> for OneOrMany {
3598 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
3599 use serde::de;
3600
3601 struct Visitor;
3602 impl<'de> de::Visitor<'de> for Visitor {
3603 type Value = OneOrMany;
3604 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3605 f.write_str("a string or array of strings")
3606 }
3607 fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
3608 Ok(OneOrMany(vec![v.to_owned()]))
3609 }
3610 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
3611 let mut v = Vec::new();
3612 while let Some(s) = seq.next_element::<String>()? {
3613 v.push(s);
3614 }
3615 Ok(OneOrMany(v))
3616 }
3617 }
3618 deserializer.deserialize_any(Visitor)
3619 }
3620}
3621
3622#[must_use]
3629pub fn looks_like_jwt(token: &str) -> bool {
3630 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3631
3632 let mut parts = token.splitn(4, '.');
3633 let Some(header_b64) = parts.next() else {
3634 return false;
3635 };
3636 if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
3638 return false;
3639 }
3640 let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
3642 return false;
3643 };
3644 let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
3646 return false;
3647 };
3648 header.get("alg").is_some()
3649}
3650
3651fn resolve_authorization_servers<'a>(server_url: &'a str, config: &'a OAuthConfig) -> Vec<&'a str> {
3660 if let Some(ref explicit) = config.authorization_servers {
3661 return explicit.iter().map(String::as_str).collect();
3662 }
3663 if config.proxy.is_some() {
3671 vec![server_url]
3672 } else {
3673 vec![config.issuer.as_str()]
3674 }
3675}
3676
3677#[must_use]
3683pub fn protected_resource_metadata(
3684 resource_url: &str,
3685 server_url: &str,
3686 config: &OAuthConfig,
3687) -> serde_json::Value {
3688 let mut meta = serde_json::json!({
3689 "resource": resource_url,
3690 "bearer_methods_supported": ["header"],
3691 });
3692 let Some(obj) = meta.as_object_mut() else {
3693 return meta;
3694 };
3695 let auth_servers = resolve_authorization_servers(server_url, config);
3697 if !auth_servers.is_empty() {
3698 obj.insert(
3699 "authorization_servers".into(),
3700 serde_json::json!(auth_servers),
3701 );
3702 }
3703 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3704 if !scopes.is_empty() {
3705 obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3706 }
3707 meta
3708}
3709
3710#[must_use]
3722pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
3723 let issuer = config
3724 .authorization_server_metadata_issuer
3725 .as_deref()
3726 .unwrap_or(server_url);
3727 let mut meta = serde_json::json!({
3728 "issuer": issuer,
3729 "authorization_endpoint": format!("{server_url}/authorize"),
3730 "token_endpoint": format!("{server_url}/token"),
3731 "registration_endpoint": format!("{server_url}/register"),
3732 "response_types_supported": ["code"],
3733 "grant_types_supported": ["authorization_code", "refresh_token"],
3734 "code_challenge_methods_supported": ["S256"],
3735 "token_endpoint_auth_methods_supported": ["none"],
3736 });
3737 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3739 if !scopes.is_empty()
3740 && let Some(obj) = meta.as_object_mut()
3741 {
3742 obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3743 }
3744 if let Some(proxy) = &config.proxy
3745 && proxy.expose_admin_endpoints
3746 && let Some(obj) = meta.as_object_mut()
3747 {
3748 if proxy.introspection_url.is_some() {
3749 obj.insert(
3750 "introspection_endpoint".into(),
3751 serde_json::Value::String(format!("{server_url}/introspect")),
3752 );
3753 }
3754 if proxy.revocation_url.is_some() {
3755 obj.insert(
3756 "revocation_endpoint".into(),
3757 serde_json::Value::String(format!("{server_url}/revoke")),
3758 );
3759 }
3760 if proxy.require_auth_on_admin_endpoints {
3761 obj.insert(
3762 "introspection_endpoint_auth_methods_supported".into(),
3763 serde_json::json!(["bearer"]),
3764 );
3765 obj.insert(
3766 "revocation_endpoint_auth_methods_supported".into(),
3767 serde_json::json!(["bearer"]),
3768 );
3769 }
3770 }
3771 meta
3772}
3773
3774#[must_use]
3787pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
3788 use axum::{
3789 http::{StatusCode, header},
3790 response::IntoResponse,
3791 };
3792
3793 let upstream_query =
3795 rewrite_client_auth_params(query, &proxy.client_id, proxy.strip_resource_param);
3796 let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
3797
3798 (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
3799}
3800
3801pub async fn handle_token(
3811 http: &OauthHttpClient,
3812 proxy: &OAuthProxyConfig,
3813 body: &str,
3814) -> axum::response::Response {
3815 use axum::{
3816 http::{StatusCode, header},
3817 response::IntoResponse,
3818 };
3819
3820 let mut upstream_body =
3822 rewrite_client_auth_params(body, &proxy.client_id, proxy.strip_resource_param);
3823
3824 if let Some(ref secret) = proxy.client_secret {
3826 use std::fmt::Write;
3827
3828 use secrecy::ExposeSecret;
3829 let _ = write!(
3830 upstream_body,
3831 "&client_secret={}",
3832 urlencoding::encode(secret.expose_secret())
3833 );
3834 }
3835
3836 let result = http
3837 .send_screened(
3838 &proxy.token_url,
3839 http.credential_client
3840 .post(&proxy.token_url)
3841 .header("Content-Type", "application/x-www-form-urlencoded")
3842 .body(upstream_body),
3843 )
3844 .await;
3845
3846 match result {
3847 Ok(resp) => {
3848 let status =
3849 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3850 let Ok(body_bytes) =
3851 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
3852 else {
3853 return oauth_error_response(
3854 StatusCode::BAD_GATEWAY,
3855 "server_error",
3856 "upstream response too large or unreadable",
3857 );
3858 };
3859 (
3860 status,
3861 [(header::CONTENT_TYPE, "application/json")],
3862 body_bytes,
3863 )
3864 .into_response()
3865 }
3866 Err(e) => {
3867 tracing::error!(error = %e, "OAuth token proxy request failed");
3868 (
3869 StatusCode::BAD_GATEWAY,
3870 [(header::CONTENT_TYPE, "application/json")],
3871 "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
3872 )
3873 .into_response()
3874 }
3875 }
3876}
3877
3878#[must_use]
3885pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
3886 let mut resp = serde_json::json!({
3887 "client_id": proxy.client_id,
3888 "token_endpoint_auth_method": "none",
3889 });
3890 if let Some(uris) = body.get("redirect_uris")
3891 && let Some(obj) = resp.as_object_mut()
3892 {
3893 obj.insert("redirect_uris".into(), uris.clone());
3894 }
3895 if let Some(name) = body.get("client_name")
3896 && let Some(obj) = resp.as_object_mut()
3897 {
3898 obj.insert("client_name".into(), name.clone());
3899 }
3900 resp
3901}
3902
3903pub async fn handle_introspect(
3911 http: &OauthHttpClient,
3912 proxy: &OAuthProxyConfig,
3913 body: &str,
3914) -> axum::response::Response {
3915 let Some(ref url) = proxy.introspection_url else {
3916 return oauth_error_response(
3917 axum::http::StatusCode::NOT_FOUND,
3918 "not_supported",
3919 "introspection endpoint is not configured",
3920 );
3921 };
3922 proxy_oauth_admin_request(http, proxy, url, body).await
3923}
3924
3925pub async fn handle_revoke(
3935 http: &OauthHttpClient,
3936 proxy: &OAuthProxyConfig,
3937 body: &str,
3938) -> axum::response::Response {
3939 let Some(ref url) = proxy.revocation_url else {
3940 return oauth_error_response(
3941 axum::http::StatusCode::NOT_FOUND,
3942 "not_supported",
3943 "revocation endpoint is not configured",
3944 );
3945 };
3946 proxy_oauth_admin_request(http, proxy, url, body).await
3947}
3948
3949async fn proxy_oauth_admin_request(
3956 http: &OauthHttpClient,
3957 proxy: &OAuthProxyConfig,
3958 upstream_url: &str,
3959 body: &str,
3960) -> axum::response::Response {
3961 use axum::{
3962 http::{StatusCode, header},
3963 response::IntoResponse,
3964 };
3965
3966 let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id, false);
3970 if let Some(ref secret) = proxy.client_secret {
3971 use std::fmt::Write;
3972
3973 use secrecy::ExposeSecret;
3974 let _ = write!(
3975 upstream_body,
3976 "&client_secret={}",
3977 urlencoding::encode(secret.expose_secret())
3978 );
3979 }
3980
3981 let result = http
3982 .send_screened(
3983 upstream_url,
3984 http.credential_client
3985 .post(upstream_url)
3986 .header("Content-Type", "application/x-www-form-urlencoded")
3987 .body(upstream_body),
3988 )
3989 .await;
3990
3991 match result {
3992 Ok(resp) => {
3993 let status =
3994 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3995 let content_type = resp
3996 .headers()
3997 .get(header::CONTENT_TYPE)
3998 .and_then(|v| v.to_str().ok())
3999 .unwrap_or("application/json")
4000 .to_owned();
4001 let Ok(body_bytes) =
4002 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
4003 else {
4004 return oauth_error_response(
4005 StatusCode::BAD_GATEWAY,
4006 "server_error",
4007 "upstream response too large or unreadable",
4008 );
4009 };
4010 (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
4011 }
4012 Err(e) => {
4013 tracing::error!(
4014 error = %e,
4015 url = %oauth_request_target_for_log(upstream_url),
4016 "OAuth admin proxy request failed"
4017 );
4018 oauth_error_response(
4019 StatusCode::BAD_GATEWAY,
4020 "server_error",
4021 "upstream endpoint unreachable",
4022 )
4023 }
4024 }
4025}
4026
4027async fn read_response_capped(
4040 mut resp: reqwest::Response,
4041 max_bytes: u64,
4042 context: &str,
4043) -> Result<Vec<u8>, ()> {
4044 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
4045 let mut body = Vec::with_capacity(initial_capacity);
4046 loop {
4047 match resp.chunk().await {
4048 Ok(Some(chunk)) => {
4049 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
4050 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
4051 if body_len.saturating_add(chunk_len) > max_bytes {
4052 tracing::warn!(
4053 context = context,
4054 max_bytes = max_bytes,
4055 "upstream OAuth response exceeded size cap; failing closed"
4056 );
4057 return Err(());
4058 }
4059 body.extend_from_slice(&chunk);
4060 }
4061 Ok(None) => return Ok(body),
4062 Err(error) => {
4063 tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
4064 return Err(());
4065 }
4066 }
4067 }
4068}
4069
4070fn oauth_error_response(
4071 status: axum::http::StatusCode,
4072 error: &str,
4073 description: &str,
4074) -> axum::response::Response {
4075 use axum::{http::header, response::IntoResponse};
4076 let body = serde_json::json!({
4077 "error": error,
4078 "error_description": description,
4079 });
4080 (
4081 status,
4082 [(header::CONTENT_TYPE, "application/json")],
4083 body.to_string(),
4084 )
4085 .into_response()
4086}
4087
4088#[derive(Debug, Deserialize)]
4094struct OAuthErrorResponse {
4095 error: String,
4096 error_description: Option<String>,
4097}
4098
4099fn upstream_error_description_for_log(description: Option<&str>) -> &str {
4107 if crate::diagnostics::upstream_error_bodies() {
4108 description.unwrap_or("")
4109 } else {
4110 "[REDACTED]"
4111 }
4112}
4113
4114fn sanitize_oauth_error_code(raw: &str) -> &'static str {
4121 match raw {
4122 "invalid_request" => "invalid_request",
4123 "invalid_client" => "invalid_client",
4124 "invalid_grant" => "invalid_grant",
4125 "unauthorized_client" => "unauthorized_client",
4126 "unsupported_grant_type" => "unsupported_grant_type",
4127 "invalid_scope" => "invalid_scope",
4128 "temporarily_unavailable" => "temporarily_unavailable",
4129 "invalid_target" => "invalid_target",
4131 _ => "server_error",
4134 }
4135}
4136
4137pub async fn exchange_token(
4159 http: &OauthHttpClient,
4160 config: &TokenExchangeConfig,
4161 subject_token: &str,
4162) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4163 exchange_token_inner(http, config, subject_token, SuccessLogMode::Normal).await
4164}
4165
4166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4167enum SuccessLogMode {
4168 Normal,
4169 Suppress,
4170}
4171
4172async fn exchange_token_inner(
4173 http: &OauthHttpClient,
4174 config: &TokenExchangeConfig,
4175 subject_token: &str,
4176 success_log: SuccessLogMode,
4177) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4178 use secrecy::ExposeSecret;
4179
4180 let client = http.client_for(config);
4181 let mut req = client
4182 .post(&config.token_url)
4183 .header("Content-Type", "application/x-www-form-urlencoded")
4184 .header("Accept", "application/json");
4185
4186 if config.client_cert.is_none()
4195 && let Some(ref secret) = config.client_secret
4196 {
4197 use base64::Engine;
4198 let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
4199 "{}:{}",
4200 urlencoding::encode(&config.client_id),
4201 urlencoding::encode(secret.expose_secret()),
4202 ));
4203 req = req.header("Authorization", format!("Basic {credentials}"));
4204 }
4205
4206 let form_body = build_exchange_form(config, subject_token);
4207
4208 let resp = http
4209 .send_screened(&config.token_url, req.body(form_body))
4210 .await
4211 .map_err(|e| {
4212 tracing::error!(error = %e, "token exchange request failed");
4213 crate::error::RmcpServerKitError::Auth("server_error".into())
4215 })?;
4216
4217 let status = resp.status();
4218 let body_bytes =
4219 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
4220 .await
4221 .map_err(|()| {
4222 crate::error::RmcpServerKitError::Auth("server_error".into())
4224 })?;
4225
4226 if !status.is_success() {
4227 core::hint::cold_path();
4228 let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
4231 let short_code = parsed
4232 .as_ref()
4233 .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
4234 if let Some(ref e) = parsed {
4235 let description = upstream_error_description_for_log(e.error_description.as_deref());
4236 tracing::warn!(
4237 status = %status,
4238 upstream_error = %e.error,
4239 upstream_error_description = description,
4240 client_code = %short_code,
4241 "token exchange rejected by authorization server",
4242 );
4243 } else {
4244 tracing::warn!(
4245 status = %status,
4246 client_code = %short_code,
4247 "token exchange rejected (unparseable upstream body)",
4248 );
4249 }
4250 return Err(crate::error::RmcpServerKitError::Auth(short_code.into()));
4251 }
4252
4253 let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
4254 tracing::error!(error = %e, "failed to parse token exchange response");
4255 crate::error::RmcpServerKitError::Auth("server_error".into())
4258 })?;
4259
4260 match success_log {
4261 SuccessLogMode::Normal => log_exchanged_token(&exchanged),
4262 SuccessLogMode::Suppress => {}
4263 }
4264
4265 Ok(exchanged)
4266}
4267
4268#[must_use = "DetachOutcome must be inspected to distinguish completion from cancel/timeout"]
4303pub async fn exchange_token_with_cancel(
4304 http: &OauthHttpClient,
4305 config: &TokenExchangeConfig,
4306 subject_token: &str,
4307 ct: &tokio_util::sync::CancellationToken,
4308 timeout: Option<Duration>,
4309) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4310 if ct.is_cancelled() {
4315 return crate::cancel::DetachOutcome::Cancelled;
4316 }
4317
4318 let (tx, rx) = tokio::sync::oneshot::channel();
4319 let http = http.clone();
4320 let config = config.clone();
4321 let subject_token = subject_token.to_owned();
4322
4323 tokio::spawn(
4330 async move {
4331 let result =
4332 exchange_token_inner(&http, &config, &subject_token, SuccessLogMode::Suppress)
4333 .await;
4334 if let Err(result) = tx.send(result) {
4335 audit_abandoned_exchange_result(result);
4336 }
4337 }
4338 .instrument(tracing::Span::current()),
4339 );
4340
4341 receive_exchange_result_with_cancel(rx, ct, timeout).await
4342}
4343
4344async fn receive_exchange_result_with_cancel(
4345 rx: tokio::sync::oneshot::Receiver<Result<ExchangedToken, crate::error::RmcpServerKitError>>,
4346 ct: &tokio_util::sync::CancellationToken,
4347 timeout: Option<Duration>,
4348) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4349 if let Some(t) = timeout {
4355 tokio::select! {
4356 biased;
4357 received = rx => map_exchange_receiver(received),
4358 () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4359 () = tokio::time::sleep(t) => crate::cancel::DetachOutcome::TimedOut,
4360 }
4361 } else {
4362 tokio::select! {
4363 biased;
4364 received = rx => map_exchange_receiver(received),
4365 () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4366 }
4367 }
4368}
4369
4370fn map_exchange_receiver(
4371 received: Result<
4372 Result<ExchangedToken, crate::error::RmcpServerKitError>,
4373 tokio::sync::oneshot::error::RecvError,
4374 >,
4375) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4376 match received {
4377 Ok(result) => crate::cancel::DetachOutcome::Completed(result),
4378 Err(error) => {
4379 tracing::error!(error = %error, "token exchange task ended before returning a result");
4380 crate::cancel::DetachOutcome::Completed(Err(
4381 crate::error::RmcpServerKitError::Internal("server_error".into()),
4382 ))
4383 }
4384 }
4385}
4386
4387fn audit_abandoned_exchange_result(
4388 result: Result<ExchangedToken, crate::error::RmcpServerKitError>,
4389) {
4390 match result {
4391 Ok(token) => {
4392 let (issued_token_type, issued_token_type_truncated) = token
4393 .issued_token_type
4394 .as_deref()
4395 .map_or_else(|| ("-".to_owned(), false), truncate_kid_for_log);
4396 tracing::warn!(
4397 expires_in = token.expires_in,
4398 issued_token_type = %issued_token_type,
4399 issued_token_type_truncated,
4400 "token exchange minted downstream token after caller detached; discarded token material"
4401 );
4402 }
4403 Err(error) => {
4404 tracing::debug!(error = %error, "token exchange failed after caller detached");
4405 }
4406 }
4407}
4408
4409fn push_form_param(body: &mut String, name: &str, value: &str) {
4410 body.push('&');
4411 body.push_str(name);
4412 body.push('=');
4413 body.push_str(&urlencoding::encode(value));
4414}
4415
4416fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
4424 let mut body = format!(
4425 "grant_type={}&subject_token={}&subject_token_type={}",
4426 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
4427 urlencoding::encode(subject_token),
4428 urlencoding::encode(TOKEN_TYPE_ACCESS_TOKEN),
4429 );
4430 if let Some(value) = config.requested_token_type.wire_value() {
4431 push_form_param(&mut body, "requested_token_type", value);
4432 }
4433 if let Some(audience) = config.audience.as_deref() {
4434 push_form_param(&mut body, "audience", audience);
4435 }
4436 if let Some(resource) = config.resource.as_deref() {
4437 push_form_param(&mut body, "resource", resource);
4438 }
4439 if let Some(scope) = config.scope.as_deref() {
4440 push_form_param(&mut body, "scope", scope);
4441 }
4442 if config.client_secret.is_none() {
4443 push_form_param(&mut body, "client_id", &config.client_id);
4444 }
4445 body
4446}
4447
4448fn log_exchanged_token(exchanged: &ExchangedToken) {
4451 use base64::Engine;
4452
4453 if !looks_like_jwt(&exchanged.access_token) {
4454 tracing::debug!(
4455 token_len = exchanged.access_token.len(),
4456 issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
4457 expires_in = exchanged.expires_in,
4458 "exchanged token (opaque)",
4459 );
4460 return;
4461 }
4462 let Some(payload) = exchanged.access_token.split('.').nth(1) else {
4463 return;
4464 };
4465 let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
4466 return;
4467 };
4468 let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
4469 return;
4470 };
4471 let expose_claims = crate::diagnostics::oauth_claim_values();
4472 let sub = gated_claim_str(claims.get("sub"), expose_claims);
4473 let aud = gated_claim_aud(claims.get("aud"), expose_claims);
4474 let azp = gated_claim_str(claims.get("azp"), expose_claims);
4475 let iss = gated_claim_str(claims.get("iss"), expose_claims);
4476 tracing::debug!(
4477 sub = sub,
4478 aud = %aud,
4479 azp = azp,
4480 iss = iss,
4481 expires_in = exchanged.expires_in,
4482 "exchanged token claims (JWT)",
4483 );
4484}
4485
4486fn gated_claim_str(value: Option<&serde_json::Value>, expose: bool) -> &str {
4487 if expose {
4488 fmt_json_str(value)
4489 } else {
4490 "[REDACTED]"
4491 }
4492}
4493
4494fn gated_claim_aud(value: Option<&serde_json::Value>, expose: bool) -> String {
4495 if expose {
4496 fmt_json_aud(value)
4497 } else {
4498 "[REDACTED]".to_owned()
4499 }
4500}
4501
4502const CLIENT_AUTH_PARAMS: [&str; 4] = [
4508 "client_id",
4509 "client_secret",
4510 "client_assertion",
4511 "client_assertion_type",
4512];
4513
4514fn rewrite_client_auth_params(
4536 params: &str,
4537 upstream_client_id: &str,
4538 strip_resource: bool,
4539) -> String {
4540 let mut out = url::form_urlencoded::Serializer::new(String::new());
4541 for (key, value) in url::form_urlencoded::parse(params.as_bytes()) {
4542 if CLIENT_AUTH_PARAMS.contains(&key.as_ref()) {
4543 continue;
4544 }
4545 if strip_resource && key.as_ref() == "resource" {
4549 continue;
4550 }
4551 out.append_pair(&key, &value);
4552 }
4553 out.append_pair("client_id", upstream_client_id);
4554 out.finish()
4555}
4556
4557#[cfg(test)]
4558mod tests {
4559 use std::{sync::Arc, time::Instant};
4560
4561 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
4562
4563 use super::*;
4564
4565 fn decoded_pairs(form: &str) -> Vec<(String, String)> {
4579 url::form_urlencoded::parse(form.as_bytes())
4580 .map(|(k, v)| (k.into_owned(), v.into_owned()))
4581 .collect()
4582 }
4583
4584 #[test]
4585 fn rewrite_drops_percent_encoded_client_id_key() {
4586 let out = rewrite_client_auth_params("%63lient_id=attacker&scope=read", "proxy-id", false);
4587 let pairs = decoded_pairs(&out);
4588 let client_ids: Vec<&String> = pairs
4589 .iter()
4590 .filter(|(k, _)| k == "client_id")
4591 .map(|(_, v)| v)
4592 .collect();
4593 assert_eq!(client_ids, vec!["proxy-id"], "smuggled client_id survived");
4594 }
4595
4596 #[test]
4597 fn rewrite_drops_underscore_encoded_client_id_key() {
4598 let out = rewrite_client_auth_params("client%5Fid=attacker&scope=read", "proxy-id", false);
4599 let pairs = decoded_pairs(&out);
4600 assert!(
4601 !pairs.iter().any(|(_, v)| v == "attacker"),
4602 "smuggled client_id survived: {pairs:?}"
4603 );
4604 }
4605
4606 #[test]
4607 fn rewrite_drops_caller_supplied_client_secret() {
4608 let out = rewrite_client_auth_params(
4609 "client_secret=attacker-secret&scope=read",
4610 "proxy-id",
4611 false,
4612 );
4613 let pairs = decoded_pairs(&out);
4614 assert!(
4615 !pairs.iter().any(|(k, _)| k == "client_secret"),
4616 "caller client_secret survived: {pairs:?}"
4617 );
4618 }
4619
4620 #[test]
4621 fn rewrite_drops_caller_supplied_client_assertion() {
4622 let out = rewrite_client_auth_params(
4623 "client_assertion=ey.evil&client_assertion_type=urn:evil&scope=read",
4624 "proxy-id",
4625 false,
4626 );
4627 let pairs = decoded_pairs(&out);
4628 assert!(
4629 !pairs
4630 .iter()
4631 .any(|(k, _)| k == "client_assertion" || k == "client_assertion_type"),
4632 "caller client assertion survived: {pairs:?}"
4633 );
4634 }
4635
4636 #[test]
4637 fn rewrite_collapses_duplicate_client_id_to_proxy_value() {
4638 let out =
4639 rewrite_client_auth_params("client_id=a&client_id=b&scope=read", "proxy-id", false);
4640 let pairs = decoded_pairs(&out);
4641 let client_ids: Vec<&String> = pairs
4642 .iter()
4643 .filter(|(k, _)| k == "client_id")
4644 .map(|(_, v)| v)
4645 .collect();
4646 assert_eq!(client_ids, vec!["proxy-id"]);
4647 }
4648
4649 #[test]
4650 fn rewrite_preserves_non_client_params_in_order_with_duplicates() {
4651 let out = rewrite_client_auth_params(
4652 "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4653 "proxy-id",
4654 false,
4655 );
4656 let pairs = decoded_pairs(&out);
4657 let non_client: Vec<(String, String)> = pairs
4658 .into_iter()
4659 .filter(|(k, _)| k != "client_id")
4660 .collect();
4661 assert_eq!(
4662 non_client,
4663 vec![
4664 ("scope".to_owned(), "read".to_owned()),
4665 ("resource".to_owned(), "a".to_owned()),
4666 ("state".to_owned(), "xyz".to_owned()),
4667 ("resource".to_owned(), "b".to_owned()),
4668 ("code_verifier".to_owned(), "v".to_owned()),
4669 ]
4670 );
4671 }
4672
4673 #[test]
4674 fn rewrite_strips_every_resource_param_when_enabled() {
4675 let out = rewrite_client_auth_params(
4679 "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4680 "proxy-id",
4681 true,
4682 );
4683 let non_client: Vec<(String, String)> = decoded_pairs(&out)
4684 .into_iter()
4685 .filter(|(k, _)| k != "client_id")
4686 .collect();
4687 assert_eq!(
4688 non_client,
4689 vec![
4690 ("scope".to_owned(), "read".to_owned()),
4691 ("state".to_owned(), "xyz".to_owned()),
4692 ("code_verifier".to_owned(), "v".to_owned()),
4693 ]
4694 );
4695 }
4696
4697 #[test]
4698 fn rewrite_strips_percent_encoded_resource_key() {
4699 let out = rewrite_client_auth_params("%72esource=sneaky&scope=read", "proxy-id", true);
4703 let pairs = decoded_pairs(&out);
4704 assert!(
4705 !pairs.iter().any(|(k, _)| k == "resource"),
4706 "percent-encoded resource survived: {pairs:?}"
4707 );
4708 assert!(pairs.contains(&("scope".to_owned(), "read".to_owned())));
4709 }
4710
4711 #[test]
4712 fn rewrite_never_strips_security_params_when_resource_stripping_enabled() {
4713 let input = "response_type=code&redirect_uri=https%3A%2F%2Fapp%2Fcb&state=s1\
4717 &code_challenge=cc&code_challenge_method=S256&nonce=n1&scope=read\
4718 &code_verifier=cv&grant_type=authorization_code&code=abc\
4719 &refresh_token=rt&resource=https%3A%2F%2Fapi";
4720 let pairs = decoded_pairs(&rewrite_client_auth_params(input, "proxy-id", true));
4721 for key in [
4722 "response_type",
4723 "redirect_uri",
4724 "state",
4725 "code_challenge",
4726 "code_challenge_method",
4727 "nonce",
4728 "scope",
4729 "code_verifier",
4730 "grant_type",
4731 "code",
4732 "refresh_token",
4733 ] {
4734 assert!(
4735 pairs.iter().any(|(k, _)| k == key),
4736 "{key} must never be stripped: {pairs:?}"
4737 );
4738 }
4739 assert!(!pairs.iter().any(|(k, _)| k == "resource"));
4740 }
4741
4742 #[test]
4743 fn rewrite_roundtrips_values_with_special_characters() {
4744 let input = url::form_urlencoded::Serializer::new(String::new())
4745 .append_pair("state", "a&b=c+d")
4746 .append_pair("scope", "réad ✓")
4747 .finish();
4748 let out = rewrite_client_auth_params(&input, "proxy-id", false);
4749 let pairs = decoded_pairs(&out);
4750 assert!(pairs.contains(&("state".to_owned(), "a&b=c+d".to_owned())));
4751 assert!(pairs.contains(&("scope".to_owned(), "réad ✓".to_owned())));
4752 }
4753
4754 #[test]
4755 fn rewrite_injects_client_id_when_absent() {
4756 let out = rewrite_client_auth_params("scope=read", "proxy-id", false);
4757 assert!(decoded_pairs(&out).contains(&("client_id".to_owned(), "proxy-id".to_owned())));
4758 }
4759
4760 #[test]
4761 fn looks_like_jwt_valid() {
4762 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
4764 let payload = URL_SAFE_NO_PAD.encode(b"{}");
4765 let token = format!("{header}.{payload}.signature");
4766 assert!(looks_like_jwt(&token));
4767 }
4768
4769 #[test]
4770 fn looks_like_jwt_rejects_opaque_token() {
4771 assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
4772 }
4773
4774 #[test]
4775 fn looks_like_jwt_rejects_two_segments() {
4776 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
4777 let token = format!("{header}.payload");
4778 assert!(!looks_like_jwt(&token));
4779 }
4780
4781 #[test]
4782 fn looks_like_jwt_rejects_four_segments() {
4783 assert!(!looks_like_jwt("a.b.c.d"));
4784 }
4785
4786 #[test]
4787 fn looks_like_jwt_rejects_no_alg() {
4788 let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
4789 let payload = URL_SAFE_NO_PAD.encode(b"{}");
4790 let token = format!("{header}.{payload}.sig");
4791 assert!(!looks_like_jwt(&token));
4792 }
4793
4794 #[test]
4795 fn protected_resource_metadata_shape() {
4796 let config = OAuthConfig {
4797 require_subject: false,
4798 issuer: "https://auth.example.com".into(),
4799 audience: "https://mcp.example.com/mcp".into(),
4800 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4801 scopes: vec![
4802 ScopeMapping {
4803 scope: "mcp:read".into(),
4804 role: "viewer".into(),
4805 },
4806 ScopeMapping {
4807 scope: "mcp:admin".into(),
4808 role: "ops".into(),
4809 },
4810 ],
4811 role_claim: None,
4812 role_mappings: vec![],
4813 jwks_cache_ttl: "10m".into(),
4814 proxy: None,
4815 token_exchange: None,
4816 ca_cert_path: None,
4817 allow_http_oauth_urls: false,
4818 max_jwks_keys: default_max_jwks_keys(),
4819 allowed_algorithms: None,
4820 authorization_servers: None,
4821 authorization_server_metadata_issuer: None,
4822 #[allow(
4823 deprecated,
4824 reason = "test fixture: explicit value for the deprecated field"
4825 )]
4826 strict_audience_validation: None,
4827 audience_validation_mode: None,
4828 jwks_max_response_bytes: default_jwks_max_bytes(),
4829 ssrf_allowlist: None,
4830 };
4831 let meta = protected_resource_metadata(
4832 "https://mcp.example.com/mcp",
4833 "https://mcp.example.com",
4834 &config,
4835 );
4836 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4837 assert_eq!(meta["authorization_servers"][0], "https://auth.example.com");
4841 assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
4842 assert_eq!(meta["bearer_methods_supported"][0], "header");
4843 }
4844
4845 fn prm_for(
4847 proxy: Option<OAuthProxyConfig>,
4848 authorization_servers: Option<Vec<String>>,
4849 scopes: Vec<ScopeMapping>,
4850 ) -> serde_json::Value {
4851 let config = OAuthConfig {
4852 issuer: "https://auth.example.com".into(),
4853 audience: "https://mcp.example.com/mcp".into(),
4854 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4855 scopes,
4856 proxy,
4857 authorization_servers,
4858 ..OAuthConfig::default()
4859 };
4860 protected_resource_metadata(
4861 "https://mcp.example.com/mcp",
4862 "https://mcp.example.com",
4863 &config,
4864 )
4865 }
4866
4867 fn demo_proxy() -> OAuthProxyConfig {
4868 OAuthProxyConfig::builder(
4869 "https://auth.example.com/authorize",
4870 "https://auth.example.com/token",
4871 "mcp",
4872 )
4873 .build()
4874 }
4875
4876 #[test]
4877 fn prm_advertises_local_server_only_when_proxy_mounts_the_endpoints() {
4878 let meta = prm_for(Some(demo_proxy()), None, vec![]);
4881 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4882 }
4883
4884 #[test]
4885 fn prm_explicit_override_wins_over_topology() {
4886 let meta = prm_for(
4889 None,
4890 Some(vec!["https://mcp.example.com".to_owned()]),
4891 vec![],
4892 );
4893 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4894
4895 let meta = prm_for(
4897 Some(demo_proxy()),
4898 Some(vec!["https://elsewhere.example".to_owned()]),
4899 vec![],
4900 );
4901 assert_eq!(
4902 meta["authorization_servers"][0],
4903 "https://elsewhere.example"
4904 );
4905 }
4906
4907 #[test]
4908 fn prm_omits_zero_valued_claims() {
4909 let meta = prm_for(None, Some(vec![]), vec![]);
4912 assert!(
4913 meta.get("authorization_servers").is_none(),
4914 "empty override must omit the claim: {meta}"
4915 );
4916 assert!(
4917 meta.get("scopes_supported").is_none(),
4918 "no configured scopes must omit the claim: {meta}"
4919 );
4920 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4921 }
4922
4923 fn proxy_as_metadata_config() -> OAuthConfig {
4924 OAuthConfig {
4925 issuer: "https://auth.example.com".into(),
4926 audience: "https://mcp.example.com/mcp".into(),
4927 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4928 proxy: Some(demo_proxy()),
4929 ..OAuthConfig::default()
4930 }
4931 }
4932
4933 #[test]
4934 fn as_metadata_issuer_defaults_to_the_origin_it_is_served_from() {
4935 let config = proxy_as_metadata_config();
4940 let meta = authorization_server_metadata("https://mcp.example.com", &config);
4941 assert_eq!(meta["issuer"], "https://mcp.example.com");
4942 assert_eq!(
4943 meta["authorization_endpoint"],
4944 "https://mcp.example.com/authorize"
4945 );
4946 assert!(
4947 meta.get("scopes_supported").is_none(),
4948 "RFC 8414 3.2: omit zero-valued claims: {meta}"
4949 );
4950 }
4951
4952 #[test]
4953 fn as_metadata_issuer_legacy_opt_out_restores_upstream_value() {
4954 let mut config = proxy_as_metadata_config();
4958 config.authorization_server_metadata_issuer = Some("https://auth.example.com".into());
4959 let meta = authorization_server_metadata("https://mcp.example.com", &config);
4960 assert_eq!(meta["issuer"], "https://auth.example.com");
4961 }
4962
4963 #[test]
4964 fn as_metadata_issuer_never_affects_token_validation() {
4965 let mut config = proxy_as_metadata_config();
4968 config.authorization_server_metadata_issuer = Some("https://mcp.example.com".into());
4969 assert_eq!(config.issuer, "https://auth.example.com");
4970 }
4971
4972 fn validation_https_config() -> OAuthConfig {
4977 OAuthConfig::builder(
4978 "https://auth.example.com",
4979 "mcp",
4980 "https://auth.example.com/.well-known/jwks.json",
4981 )
4982 .build()
4983 }
4984
4985 #[test]
4986 fn validate_rejects_non_conformant_discovery_metadata_urls() {
4987 for bad in [
4988 "https://user:pw@as.example.com",
4989 "http://as.example.com",
4990 "https://10.0.0.1",
4991 "not-a-url",
4992 ] {
4993 let mut cfg = validation_https_config();
4994 cfg.authorization_server_metadata_issuer = Some(bad.to_owned());
4995 cfg.validate().unwrap_err();
4996
4997 let mut cfg = validation_https_config();
4998 cfg.authorization_servers = Some(vec![bad.to_owned()]);
4999 let err = cfg.validate().unwrap_err().to_string();
5000 assert!(
5001 err.contains("authorization_servers[0]"),
5002 "error must identify the offending index; got {err:?}"
5003 );
5004 }
5005 }
5006
5007 #[test]
5008 fn validate_accepts_discovery_metadata_urls_and_the_empty_override() {
5009 let mut cfg = validation_https_config();
5010 cfg.authorization_server_metadata_issuer = Some("https://as.example.com".to_owned());
5011 cfg.authorization_servers = Some(vec!["https://as.example.com".to_owned()]);
5012 cfg.validate()
5013 .expect("well-formed https metadata must validate");
5014
5015 let mut cfg = validation_https_config();
5016 cfg.authorization_servers = Some(vec![]);
5017 cfg.validate()
5018 .expect("an empty list is the documented way to omit the claim entirely");
5019 }
5020
5021 #[test]
5022 fn validate_accepts_all_https_urls() {
5023 let cfg = validation_https_config();
5024 cfg.validate().expect("all-HTTPS config must validate");
5025 }
5026
5027 #[test]
5028 fn validate_rejects_empty_audience() {
5029 let mut cfg = validation_https_config();
5030 cfg.audience = String::new();
5031 let err = cfg.validate().expect_err("empty audience must be rejected");
5032 assert!(
5033 err.to_string().contains("oauth.audience"),
5034 "error must reference oauth.audience; got {err}"
5035 );
5036 }
5037
5038 fn assert_config_nonzero_error(err: crate::error::RmcpServerKitError, field: &str) {
5039 let crate::error::RmcpServerKitError::Config(msg) = err else {
5040 panic!("expected Config error for {field}");
5041 };
5042 assert!(
5043 msg.contains(field) && msg.contains("must be nonzero"),
5044 "error must name {field} and say must be nonzero; got {msg:?}"
5045 );
5046 }
5047
5048 #[test]
5049 fn rejects_zero_max_jwks_keys() {
5050 let mut cfg = validation_https_config();
5051 cfg.max_jwks_keys = 0;
5052 let err = cfg
5053 .validate()
5054 .expect_err("zero max_jwks_keys must be rejected");
5055 assert_config_nonzero_error(err, "oauth.max_jwks_keys");
5056 }
5057
5058 #[test]
5059 fn rejects_zero_jwks_max_response_bytes() {
5060 let mut cfg = validation_https_config();
5061 cfg.jwks_max_response_bytes = 0;
5062 let err = cfg
5063 .validate()
5064 .expect_err("zero jwks_max_response_bytes must be rejected");
5065 assert_config_nonzero_error(err, "oauth.jwks_max_response_bytes");
5066 }
5067
5068 #[test]
5069 fn oauth_config_partial_table_deserializes_then_validate_rejects_empty_fields() {
5070 let toml_src = r#"
5071role_claim = "realm_access.roles"
5072
5073[[role_mappings]]
5074claim_value = "mcp-admin"
5075role = "admin"
5076"#;
5077 let cfg: OAuthConfig = toml::from_str(toml_src).expect(
5078 "partial [oauth] table without issuer/audience/jwks_uri must deserialize via serde(default)",
5079 );
5080 assert_eq!(cfg.issuer, "", "omitted issuer must default to empty");
5081 assert_eq!(cfg.audience, "", "omitted audience must default to empty");
5082 assert_eq!(cfg.jwks_uri, "", "omitted jwks_uri must default to empty");
5083 assert_eq!(cfg.role_claim.as_deref(), Some("realm_access.roles"));
5084 assert_eq!(cfg.role_mappings.len(), 1);
5085 cfg.validate().expect_err(
5086 "empty issuer/jwks_uri/audience must still fail validate() (parse-don't-validate)",
5087 );
5088 }
5089
5090 #[test]
5091 fn validate_rejects_unparseable_jwks_cache_ttl() {
5092 let mut cfg = validation_https_config();
5093 cfg.jwks_cache_ttl = "not-a-duration".into();
5094 let err = cfg
5095 .validate()
5096 .expect_err("malformed jwks_cache_ttl must be rejected");
5097 let msg = err.to_string();
5098 assert!(
5099 msg.contains("jwks_cache_ttl"),
5100 "error must reference offending field; got {msg:?}"
5101 );
5102 }
5103
5104 #[test]
5105 fn validate_rejects_http_jwks_uri() {
5106 let mut cfg = validation_https_config();
5107 cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
5108 let err = cfg.validate().expect_err("http jwks_uri must be rejected");
5109 let msg = err.to_string();
5110 assert!(
5111 msg.contains("oauth.jwks_uri") && msg.contains("https"),
5112 "error must reference offending field + scheme requirement; got {msg:?}"
5113 );
5114 }
5115
5116 #[test]
5117 fn validate_rejects_http_proxy_authorize_url() {
5118 let mut cfg = validation_https_config();
5119 cfg.proxy = Some(
5120 OAuthProxyConfig::builder(
5121 "http://idp.example.com/authorize", "https://idp.example.com/token",
5123 "client",
5124 )
5125 .build(),
5126 );
5127 let err = cfg
5128 .validate()
5129 .expect_err("http authorize_url must be rejected");
5130 assert!(
5131 err.to_string().contains("oauth.proxy.authorize_url"),
5132 "error must reference proxy.authorize_url; got {err}"
5133 );
5134 }
5135
5136 #[test]
5137 fn validate_rejects_http_proxy_token_url() {
5138 let mut cfg = validation_https_config();
5139 cfg.proxy = Some(
5140 OAuthProxyConfig::builder(
5141 "https://idp.example.com/authorize",
5142 "http://idp.example.com/token", "client",
5144 )
5145 .build(),
5146 );
5147 let err = cfg.validate().expect_err("http token_url must be rejected");
5148 assert!(
5149 err.to_string().contains("oauth.proxy.token_url"),
5150 "error must reference proxy.token_url; got {err}"
5151 );
5152 }
5153
5154 #[test]
5155 fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
5156 let mut cfg = validation_https_config();
5157 cfg.proxy = Some(
5158 OAuthProxyConfig::builder(
5159 "https://idp.example.com/authorize",
5160 "https://idp.example.com/token",
5161 "client",
5162 )
5163 .introspection_url("http://idp.example.com/introspect")
5164 .build(),
5165 );
5166 let err = cfg
5167 .validate()
5168 .expect_err("http introspection_url must be rejected");
5169 assert!(err.to_string().contains("oauth.proxy.introspection_url"));
5170
5171 let mut cfg = validation_https_config();
5172 cfg.proxy = Some(
5173 OAuthProxyConfig::builder(
5174 "https://idp.example.com/authorize",
5175 "https://idp.example.com/token",
5176 "client",
5177 )
5178 .revocation_url("http://idp.example.com/revoke")
5179 .build(),
5180 );
5181 let err = cfg
5182 .validate()
5183 .expect_err("http revocation_url must be rejected");
5184 assert!(err.to_string().contains("oauth.proxy.revocation_url"));
5185 }
5186
5187 #[test]
5190 fn validate_rejects_exposed_admin_endpoints_without_auth() {
5191 let mut cfg = validation_https_config();
5192 cfg.proxy = Some(
5193 OAuthProxyConfig::builder(
5194 "https://idp.example.com/authorize",
5195 "https://idp.example.com/token",
5196 "client",
5197 )
5198 .introspection_url("https://idp.example.com/introspect")
5199 .expose_admin_endpoints(true)
5200 .build(),
5201 );
5202 let err = cfg
5203 .validate()
5204 .expect_err("expose_admin_endpoints without auth must fail");
5205 let msg = err.to_string();
5206 assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
5207 assert!(
5208 msg.contains("allow_unauthenticated_admin_endpoints"),
5209 "{msg}"
5210 );
5211 }
5212
5213 #[test]
5214 fn validate_accepts_exposed_admin_endpoints_with_auth() {
5215 let mut cfg = validation_https_config();
5216 cfg.proxy = Some(
5217 OAuthProxyConfig::builder(
5218 "https://idp.example.com/authorize",
5219 "https://idp.example.com/token",
5220 "client",
5221 )
5222 .introspection_url("https://idp.example.com/introspect")
5223 .expose_admin_endpoints(true)
5224 .require_auth_on_admin_endpoints(true)
5225 .build(),
5226 );
5227 cfg.validate()
5228 .expect("authed admin endpoints must validate");
5229 }
5230
5231 #[test]
5232 fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
5233 let mut cfg = validation_https_config();
5234 cfg.proxy = Some(
5235 OAuthProxyConfig::builder(
5236 "https://idp.example.com/authorize",
5237 "https://idp.example.com/token",
5238 "client",
5239 )
5240 .introspection_url("https://idp.example.com/introspect")
5241 .expose_admin_endpoints(true)
5242 .allow_unauthenticated_admin_endpoints(true)
5243 .build(),
5244 );
5245 cfg.validate()
5246 .expect("explicit unauth opt-out must validate");
5247 }
5248
5249 #[test]
5250 fn validate_accepts_unexposed_admin_endpoints_without_auth() {
5251 let mut cfg = validation_https_config();
5254 cfg.proxy = Some(
5255 OAuthProxyConfig::builder(
5256 "https://idp.example.com/authorize",
5257 "https://idp.example.com/token",
5258 "client",
5259 )
5260 .introspection_url("https://idp.example.com/introspect")
5261 .build(),
5262 );
5263 cfg.validate()
5264 .expect("unexposed admin endpoints must validate");
5265 }
5266
5267 #[test]
5268 fn validate_rejects_http_token_exchange_url() {
5269 let mut cfg = validation_https_config();
5270 cfg.token_exchange = Some(
5271 TokenExchangeConfig::new(
5272 "http://idp.example.com/token", "client",
5274 None,
5275 None,
5276 )
5277 .with_audience("downstream"),
5278 );
5279 let err = cfg
5280 .validate()
5281 .expect_err("http token_exchange.token_url must be rejected");
5282 assert!(
5283 err.to_string().contains("oauth.token_exchange.token_url"),
5284 "error must reference token_exchange.token_url; got {err}"
5285 );
5286 }
5287
5288 #[test]
5289 fn validate_rejects_unparseable_url() {
5290 let mut cfg = validation_https_config();
5291 cfg.jwks_uri = "not a url".into();
5292 let err = cfg
5293 .validate()
5294 .expect_err("unparseable URL must be rejected");
5295 assert!(err.to_string().contains("invalid URL"));
5296 }
5297
5298 #[test]
5299 fn validate_rejects_non_http_scheme() {
5300 let mut cfg = validation_https_config();
5301 cfg.jwks_uri = "file:///etc/passwd".into();
5302 let err = cfg.validate().expect_err("file:// scheme must be rejected");
5303 let msg = err.to_string();
5304 assert!(
5305 msg.contains("must use https scheme") && msg.contains("file"),
5306 "error must reject non-http(s) schemes; got {msg:?}"
5307 );
5308 }
5309
5310 #[test]
5311 fn validate_accepts_http_with_escape_hatch() {
5312 let mut cfg = OAuthConfig::builder(
5317 "http://auth.local",
5318 "mcp",
5319 "http://auth.local/.well-known/jwks.json",
5320 )
5321 .allow_http_oauth_urls(true)
5322 .build();
5323 cfg.proxy = Some(
5324 OAuthProxyConfig::builder(
5325 "http://idp.local/authorize",
5326 "http://idp.local/token",
5327 "client",
5328 )
5329 .introspection_url("http://idp.local/introspect")
5330 .revocation_url("http://idp.local/revoke")
5331 .build(),
5332 );
5333 cfg.token_exchange = Some(
5334 TokenExchangeConfig::new(
5335 "http://idp.local/token",
5336 "client",
5337 Some(secrecy::SecretString::new("dev-secret".into())),
5338 None,
5339 )
5340 .with_audience("downstream"),
5341 );
5342 cfg.validate()
5343 .expect("escape hatch must permit http on all URL fields");
5344 }
5345
5346 #[test]
5347 fn validate_with_escape_hatch_still_rejects_unparseable() {
5348 let mut cfg = validation_https_config();
5351 cfg.allow_http_oauth_urls = true;
5352 cfg.jwks_uri = "::not-a-url::".into();
5353 cfg.validate()
5354 .expect_err("escape hatch must NOT bypass URL parsing");
5355 }
5356
5357 #[tokio::test]
5358 async fn jwks_cache_rejects_redirect_downgrade_to_http() {
5359 rustls::crypto::ring::default_provider()
5374 .install_default()
5375 .ok();
5376
5377 let policy = reqwest::redirect::Policy::custom(|attempt| {
5378 if attempt.url().scheme() != "https" {
5379 attempt.error("redirect to non-HTTPS URL refused")
5380 } else if attempt.previous().len() >= 2 {
5381 attempt.error("too many redirects (max 2)")
5382 } else {
5383 attempt.follow()
5384 }
5385 });
5386 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
5393 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
5394 let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
5395 crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
5396 );
5397 let client = reqwest::Client::builder()
5398 .no_proxy()
5399 .dns_resolver(Arc::clone(&resolver))
5400 .timeout(Duration::from_secs(5))
5401 .connect_timeout(Duration::from_secs(3))
5402 .redirect(policy)
5403 .build()
5404 .expect("test client builds");
5405
5406 let mock = wiremock::MockServer::start().await;
5407 wiremock::Mock::given(wiremock::matchers::method("GET"))
5408 .and(wiremock::matchers::path("/jwks.json"))
5409 .respond_with(
5410 wiremock::ResponseTemplate::new(302)
5411 .insert_header("location", "http://example.invalid/jwks.json"),
5412 )
5413 .mount(&mock)
5414 .await;
5415
5416 let url = format!("{}/jwks.json", mock.uri());
5425 let err = client
5426 .get(&url)
5427 .send()
5428 .await
5429 .expect_err("redirect policy must reject scheme downgrade");
5430 let chain = format!("{err:#}");
5431 assert!(
5432 chain.contains("redirect to non-HTTPS URL refused")
5433 || chain.to_lowercase().contains("redirect"),
5434 "error must surface redirect-policy rejection; got {chain:?}"
5435 );
5436 }
5437
5438 use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
5443
5444 fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
5446 let mut rng = rsa::rand_core::OsRng;
5447 let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
5448 let private_pem = private_key
5449 .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
5450 .expect("PKCS8 PEM export")
5451 .to_string();
5452
5453 let public_key = private_key.to_public_key();
5454 let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
5455 let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
5456
5457 let jwks = serde_json::json!({
5458 "keys": [{
5459 "kty": "RSA",
5460 "use": "sig",
5461 "alg": "RS256",
5462 "kid": kid,
5463 "n": n,
5464 "e": e
5465 }]
5466 });
5467
5468 (private_pem, jwks)
5469 }
5470
5471 fn mint_token(
5473 private_pem: &str,
5474 kid: &str,
5475 issuer: &str,
5476 audience: &str,
5477 subject: &str,
5478 scope: &str,
5479 ) -> String {
5480 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5481 .expect("encoding key from PEM");
5482 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5483 header.kid = Some(kid.into());
5484
5485 let now = jsonwebtoken::get_current_timestamp();
5486 let claims = serde_json::json!({
5487 "iss": issuer,
5488 "aud": audience,
5489 "sub": subject,
5490 "scope": scope,
5491 "exp": now + 3600,
5492 "iat": now,
5493 });
5494
5495 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5496 }
5497
5498 fn mint_token_without_sub(
5500 private_pem: &str,
5501 kid: &str,
5502 issuer: &str,
5503 audience: &str,
5504 scope: &str,
5505 ) -> String {
5506 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5507 .expect("encoding key from PEM");
5508 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5509 header.kid = Some(kid.into());
5510 let now = jsonwebtoken::get_current_timestamp();
5511 let claims = serde_json::json!({
5512 "iss": issuer,
5513 "aud": audience,
5514 "scope": scope,
5515 "exp": now + 3600,
5516 "iat": now,
5517 });
5518 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5519 }
5520
5521 fn test_config(jwks_uri: &str) -> OAuthConfig {
5522 OAuthConfig {
5523 require_subject: false,
5524 issuer: "https://auth.test.local".into(),
5525 audience: "https://mcp.test.local/mcp".into(),
5526 jwks_uri: jwks_uri.into(),
5527 scopes: vec![
5528 ScopeMapping {
5529 scope: "mcp:read".into(),
5530 role: "viewer".into(),
5531 },
5532 ScopeMapping {
5533 scope: "mcp:admin".into(),
5534 role: "ops".into(),
5535 },
5536 ],
5537 role_claim: None,
5538 role_mappings: vec![],
5539 jwks_cache_ttl: "5m".into(),
5540 proxy: None,
5541 token_exchange: None,
5542 ca_cert_path: None,
5543 allow_http_oauth_urls: true,
5544 max_jwks_keys: default_max_jwks_keys(),
5545 allowed_algorithms: None,
5546 authorization_servers: None,
5547 authorization_server_metadata_issuer: None,
5548 #[allow(
5549 deprecated,
5550 reason = "test fixture: explicit value for the deprecated field"
5551 )]
5552 strict_audience_validation: None,
5553 audience_validation_mode: None,
5554 jwks_max_response_bytes: default_jwks_max_bytes(),
5555 ssrf_allowlist: None,
5556 }
5557 }
5558
5559 fn test_cache(config: &OAuthConfig) -> JwksCache {
5560 JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
5561 }
5562
5563 async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
5570 let kid = "test-h2-stale";
5571 let (pem, jwks) = generate_test_keypair(kid);
5572 let mock_server = wiremock::MockServer::start().await;
5573 wiremock::Mock::given(wiremock::matchers::method("GET"))
5574 .and(wiremock::matchers::path("/jwks.json"))
5575 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5576 .mount(&mock_server)
5577 .await;
5578 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5579 let mut config = test_config(&jwks_uri);
5580 config.jwks_cache_ttl = ttl.into();
5581 let cache = test_cache(&config);
5582 cache.__test_refresh_now().await.expect("prime JWKS cache");
5583 assert!(cache.__test_has_kid(kid).await, "kid must be primed");
5584
5585 mock_server.reset().await;
5586 wiremock::Mock::given(wiremock::matchers::method("GET"))
5587 .and(wiremock::matchers::path("/jwks.json"))
5588 .respond_with(wiremock::ResponseTemplate::new(503))
5589 .mount(&mock_server)
5590 .await;
5591
5592 let token = mint_token(
5593 &pem,
5594 kid,
5595 "https://auth.test.local",
5596 "https://mcp.test.local/mcp",
5597 "h2-client",
5598 "mcp:read",
5599 );
5600 (cache, token, mock_server)
5601 }
5602
5603 #[test]
5604 fn build_key_cache_last_duplicate_kid_wins() {
5605 let (_pem, jwks_json) = generate_test_keypair("dup-kid");
5606 let entry = jwks_json["keys"][0].clone();
5607 let merged = serde_json::json!({ "keys": [entry.clone(), entry] });
5608 let jwks: JwkSet = serde_json::from_value(merged).expect("merged jwks parses");
5609 assert_eq!(jwks.keys.len(), 2, "fixture must carry two colliding kids");
5610
5611 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5612 assert_eq!(keys.len(), 1, "colliding kids collapse to one entry");
5613 assert!(keys.contains_key("dup-kid"));
5614 assert!(unnamed.is_empty());
5615 }
5616
5617 #[test]
5618 fn build_key_cache_rejects_keys_not_marked_for_signature_verification() {
5619 let (_pem, jwks_json) = generate_test_keypair("enc-only");
5623
5624 let mut enc = jwks_json["keys"][0].clone();
5625 enc["use"] = serde_json::json!("enc");
5626 let jwks: JwkSet =
5627 serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5628 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5629 assert!(
5630 keys.is_empty(),
5631 "use=enc key must not be a verification key"
5632 );
5633 assert!(unnamed.is_empty());
5634
5635 let mut wrap_only = jwks_json["keys"][0].clone();
5636 wrap_only["key_ops"] = serde_json::json!(["wrapKey"]);
5637 let jwks: JwkSet = serde_json::from_value(serde_json::json!({ "keys": [wrap_only] }))
5638 .expect("jwks parses");
5639 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5640 assert!(keys.is_empty(), "key_ops without verify must be rejected");
5641 assert!(unnamed.is_empty());
5642 }
5643
5644 #[test]
5645 fn build_key_cache_accepts_sig_and_unconstrained_keys() {
5646 let (_pem, jwks_json) = generate_test_keypair("sig-key");
5647
5648 let jwks: JwkSet = serde_json::from_value(jwks_json.clone()).expect("jwks parses");
5650 let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5651 assert!(keys.contains_key("sig-key"));
5652
5653 let mut sig = jwks_json["keys"][0].clone();
5654 sig["use"] = serde_json::json!("sig");
5655 sig["key_ops"] = serde_json::json!(["verify"]);
5656 let jwks: JwkSet =
5657 serde_json::from_value(serde_json::json!({ "keys": [sig] })).expect("jwks parses");
5658 let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5659 assert!(keys.contains_key("sig-key"));
5660 }
5661
5662 fn jwks_without_alg(jwks: &serde_json::Value) -> JwkSet {
5671 let mut key = jwks["keys"][0].clone();
5672 if let Some(obj) = key.as_object_mut() {
5673 obj.remove("alg");
5674 }
5675 serde_json::from_value(serde_json::json!({ "keys": [key] })).expect("alg-less jwks parses")
5676 }
5677
5678 #[test]
5679 fn alg_less_rsa_key_is_cached_as_rsa_family() {
5680 let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5681 let jwks = jwks_without_alg(&jwks_json);
5682 assert!(
5683 jwks.keys[0].common.key_algorithm.is_none(),
5684 "fixture must omit `alg`"
5685 );
5686
5687 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5688 assert!(unnamed.is_empty());
5689 let (cached_alg, _) = keys.get("entra-kid").expect("alg-less key must be cached");
5690 assert_eq!(*cached_alg, JwkAlg::Family(JwkKeyFamily::Rsa));
5691 }
5692
5693 #[test]
5694 fn alg_less_rsa_key_accepts_rsa_family_and_rejects_others() {
5695 let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5696 let cached = CachedKeys {
5697 keys: build_key_cache(&jwks_without_alg(&jwks_json), 16)
5698 .expect("under key cap")
5699 .0,
5700 unnamed_keys: vec![],
5701 fetched_at: Instant::now(),
5702 ttl: Duration::from_secs(300),
5703 };
5704
5705 for alg in [
5706 Algorithm::RS256,
5707 Algorithm::RS384,
5708 Algorithm::RS512,
5709 Algorithm::PS256,
5710 Algorithm::PS384,
5711 Algorithm::PS512,
5712 ] {
5713 assert!(
5714 lookup_key(&cached, Some("entra-kid"), alg).is_some(),
5715 "{alg:?} is producible by an RSA key and must resolve"
5716 );
5717 }
5718 assert!(lookup_key(&cached, Some("entra-kid"), Algorithm::ES256).is_none());
5720 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
5722 }
5723
5724 #[test]
5725 fn alg_less_key_never_accepts_hmac_algorithm_confusion() {
5726 assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS256));
5731 assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS384));
5732 assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS512));
5733 assert!(!family_accepts(JwkKeyFamily::EcP256, Algorithm::HS256));
5734 assert!(!family_accepts(JwkKeyFamily::Ed25519, Algorithm::HS256));
5735 }
5736
5737 #[test]
5738 fn family_accepts_is_subset_of_accepted_algs() {
5739 let every_alg = [
5742 Algorithm::HS256,
5743 Algorithm::HS384,
5744 Algorithm::HS512,
5745 Algorithm::RS256,
5746 Algorithm::RS384,
5747 Algorithm::RS512,
5748 Algorithm::ES256,
5749 Algorithm::ES384,
5750 Algorithm::PS256,
5751 Algorithm::PS384,
5752 Algorithm::PS512,
5753 Algorithm::EdDSA,
5754 ];
5755 for family in [
5756 JwkKeyFamily::Rsa,
5757 JwkKeyFamily::EcP256,
5758 JwkKeyFamily::EcP384,
5759 JwkKeyFamily::Ed25519,
5760 ] {
5761 for alg in every_alg {
5762 if family_accepts(family, alg) {
5763 assert!(
5764 ACCEPTED_ALGS.contains(&alg),
5765 "{family:?} admits {alg:?}, which is outside ACCEPTED_ALGS"
5766 );
5767 }
5768 }
5769 }
5770 }
5771
5772 #[test]
5773 fn explicit_alg_still_pins_exactly_one_algorithm() {
5774 let (_pem, jwks_json) = generate_test_keypair("pinned");
5777 let jwks: JwkSet = serde_json::from_value(jwks_json).expect("jwks parses");
5778 let cached = CachedKeys {
5779 keys: build_key_cache(&jwks, 16).expect("under key cap").0,
5780 unnamed_keys: vec![],
5781 fetched_at: Instant::now(),
5782 ttl: Duration::from_secs(300),
5783 };
5784 assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS256).is_some());
5785 assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS384).is_none());
5786 }
5787
5788 #[test]
5789 fn alg_less_key_still_subject_to_use_and_key_ops_gate() {
5790 let (_pem, jwks_json) = generate_test_keypair("gated");
5794
5795 let mut enc = jwks_json["keys"][0].clone();
5796 if let Some(obj) = enc.as_object_mut() {
5797 obj.remove("alg");
5798 }
5799 enc["use"] = serde_json::json!("enc");
5800 let jwks: JwkSet =
5801 serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5802 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5803 assert!(
5804 keys.is_empty() && unnamed.is_empty(),
5805 "use=enc must be dropped"
5806 );
5807
5808 let mut wrap = jwks_json["keys"][0].clone();
5809 if let Some(obj) = wrap.as_object_mut() {
5810 obj.remove("alg");
5811 obj.remove("use");
5812 }
5813 wrap["key_ops"] = serde_json::json!(["wrapKey"]);
5814 let jwks: JwkSet =
5815 serde_json::from_value(serde_json::json!({ "keys": [wrap] })).expect("jwks parses");
5816 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5817 assert!(
5818 keys.is_empty() && unnamed.is_empty(),
5819 "key_ops without verify must be dropped"
5820 );
5821 }
5822
5823 #[test]
5826 fn accepted_algorithm_names_cover_accepted_algs() {
5827 for alg in ACCEPTED_ALGS {
5830 let name = accepted_algorithm_name(*alg)
5831 .unwrap_or_else(|| panic!("{alg:?} is accepted but has no configurable name"));
5832 assert_eq!(accepted_algorithm_from_name(name), Some(*alg));
5833 }
5834 assert_eq!(
5835 accepted_algorithm_names().split(", ").count(),
5836 ACCEPTED_ALGS.len()
5837 );
5838 }
5839
5840 #[test]
5841 fn allowed_algorithms_cannot_widen_beyond_accepted_algs() {
5842 for name in ["HS256", "HS384", "HS512", "none", "ES512", "RS1"] {
5846 assert!(
5847 accepted_algorithm_from_name(name).is_none(),
5848 "{name} must not be resolvable"
5849 );
5850 let err = resolve_allowed_algorithms(Some(&vec![name.to_owned()]))
5851 .expect_err("must reject non-accepted algorithm");
5852 assert!(err.to_string().contains("unsupported algorithm"));
5853 }
5854 }
5855
5856 #[test]
5857 fn allowed_algorithms_rejects_empty_list() {
5858 let err = resolve_allowed_algorithms(Some(&Vec::new()))
5859 .expect_err("empty list would reject every token");
5860 assert!(err.to_string().contains("must not be empty"));
5861 }
5862
5863 #[test]
5864 fn allowed_algorithms_defaults_to_full_accepted_set() {
5865 assert_eq!(
5866 resolve_allowed_algorithms(None).expect("default resolves"),
5867 ACCEPTED_ALGS.to_vec()
5868 );
5869 }
5870
5871 #[test]
5872 fn allowed_algorithms_narrows_and_dedups_case_insensitively() {
5873 let resolved = resolve_allowed_algorithms(Some(&vec![
5874 "rs256".to_owned(),
5875 "RS256".to_owned(),
5876 "ES384".to_owned(),
5877 ]))
5878 .expect("valid subset");
5879 assert_eq!(resolved, vec![Algorithm::RS256, Algorithm::ES384]);
5880 }
5881
5882 #[test]
5883 fn allowed_algorithms_surfaces_through_config_validate() {
5884 let mut cfg = test_config("https://idp.test.local/jwks.json");
5885 cfg.allowed_algorithms = Some(vec!["HS256".to_owned()]);
5886 let err = cfg.validate().expect_err("HS256 must fail validation");
5887 assert!(err.to_string().contains("unsupported algorithm"));
5888
5889 cfg.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5890 cfg.validate().expect("a valid subset must validate");
5891 }
5892
5893 #[tokio::test]
5894 async fn narrowed_allowed_algorithms_rejects_excluded_but_otherwise_valid_token() {
5895 let kid = "narrowing-kid";
5899 let (pem, jwks) = generate_test_keypair(kid);
5900
5901 let mock_server = wiremock::MockServer::start().await;
5902 wiremock::Mock::given(wiremock::matchers::method("GET"))
5903 .and(wiremock::matchers::path("/jwks.json"))
5904 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5905 .mount(&mock_server)
5906 .await;
5907
5908 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5909 let token = mint_token(
5910 &pem,
5911 kid,
5912 "https://auth.test.local",
5913 "https://mcp.test.local/mcp",
5914 "narrow-user",
5915 "mcp:admin",
5916 );
5917
5918 let mut permissive = test_config(&jwks_uri);
5919 permissive.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5920 assert!(
5921 test_cache(&permissive)
5922 .validate_token(&token)
5923 .await
5924 .is_some(),
5925 "RS256 token must authenticate when RS256 is allowed"
5926 );
5927
5928 let mut narrowed = test_config(&jwks_uri);
5929 narrowed.allowed_algorithms = Some(vec!["ES384".to_owned()]);
5930 assert!(
5931 test_cache(&narrowed).validate_token(&token).await.is_none(),
5932 "RS256 token must be rejected when only ES384 is allowed"
5933 );
5934 }
5935
5936 #[test]
5937 fn truncate_kid_for_log_bounds_hostile_input() {
5938 let short = "kid-1";
5939 assert_eq!(truncate_kid_for_log(short), (short.to_owned(), false));
5940
5941 let long = "k".repeat(4096);
5942 let (truncated, was_truncated) = truncate_kid_for_log(&long);
5943 assert!(was_truncated);
5944 assert!(truncated.ends_with("...(truncated)"));
5945 assert_eq!(
5946 truncated.chars().count(),
5947 MAX_LOGGED_KID_CHARS + "...(truncated)".chars().count()
5948 );
5949 }
5950
5951 #[test]
5952 fn truncate_kid_for_log_splits_on_char_boundary() {
5953 let multibyte = "\u{1f512}".repeat(MAX_LOGGED_KID_CHARS + 10);
5954 let (truncated, was_truncated) = truncate_kid_for_log(&multibyte);
5955 assert!(was_truncated);
5956 assert!(truncated.starts_with('\u{1f512}'));
5957 assert!(truncated.ends_with("...(truncated)"));
5958 }
5959
5960 #[test]
5961 fn truncate_kid_for_log_flag_marks_exact_boundary_as_untruncated() {
5962 let exact = "k".repeat(MAX_LOGGED_KID_CHARS);
5963 let (out, was_truncated) = truncate_kid_for_log(&exact);
5964 assert!(!was_truncated, "a kid exactly at the cap is not truncated");
5965 assert_eq!(out, exact);
5966 }
5967
5968 #[tokio::test]
5969 async fn expired_jwks_fails_closed_when_refresh_fails() {
5970 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
5971 tokio::time::sleep(Duration::from_millis(200)).await;
5972 let failure = cache
5973 .validate_token_with_reason(&token)
5974 .await
5975 .expect_err("an expired cache whose refresh fails must not serve the stale key");
5976 assert_eq!(failure, JwtValidationFailure::Invalid);
5977 }
5978
5979 #[tokio::test]
5980 async fn fresh_jwks_still_validates() {
5981 let kid = "test-h2-fresh";
5982 let (pem, jwks) = generate_test_keypair(kid);
5983 let mock_server = wiremock::MockServer::start().await;
5984 wiremock::Mock::given(wiremock::matchers::method("GET"))
5985 .and(wiremock::matchers::path("/jwks.json"))
5986 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5987 .mount(&mock_server)
5988 .await;
5989 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5990 let config = test_config(&jwks_uri); let cache = test_cache(&config);
5992 let token = mint_token(
5993 &pem,
5994 kid,
5995 "https://auth.test.local",
5996 "https://mcp.test.local/mcp",
5997 "h2-fresh-client",
5998 "mcp:read",
5999 );
6000 cache
6001 .validate_token_with_reason(&token)
6002 .await
6003 .expect("a reachable JWKS must still validate a matching token");
6004 }
6005
6006 #[tokio::test]
6007 async fn cooldown_active_plus_expired_fails_closed() {
6008 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
6009 tokio::time::sleep(Duration::from_millis(200)).await;
6010 assert_eq!(
6013 cache
6014 .validate_token_with_reason(&token)
6015 .await
6016 .expect_err("first attempt must fail closed"),
6017 JwtValidationFailure::Invalid,
6018 );
6019 let failure = cache
6022 .validate_token_with_reason(&token)
6023 .await
6024 .expect_err("cooldown-active + expired cache must still fail closed");
6025 assert_eq!(failure, JwtValidationFailure::Invalid);
6026 }
6027
6028 #[tokio::test]
6029 async fn valid_jwt_returns_identity() {
6030 let kid = "test-key-1";
6031 let (pem, jwks) = generate_test_keypair(kid);
6032
6033 let mock_server = wiremock::MockServer::start().await;
6034 wiremock::Mock::given(wiremock::matchers::method("GET"))
6035 .and(wiremock::matchers::path("/jwks.json"))
6036 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6037 .mount(&mock_server)
6038 .await;
6039
6040 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6041 let config = test_config(&jwks_uri);
6042 let cache = test_cache(&config);
6043
6044 let token = mint_token(
6045 &pem,
6046 kid,
6047 "https://auth.test.local",
6048 "https://mcp.test.local/mcp",
6049 "ci-bot",
6050 "mcp:read mcp:other",
6051 );
6052
6053 let identity = cache.validate_token(&token).await;
6054 assert!(identity.is_some(), "valid JWT should authenticate");
6055 let id = identity.unwrap();
6056 assert_eq!(id.name, "ci-bot");
6057 assert_eq!(id.role, "viewer"); assert_eq!(id.method, AuthMethod::OAuthJwt);
6059 }
6060
6061 #[test]
6064 fn unknown_kid_with_named_keys_rejected() {
6065 let mut keys = HashMap::new();
6066 keys.insert(
6067 "kid-1".to_owned(),
6068 (
6069 JwkAlg::Explicit(Algorithm::RS256),
6070 DecodingKey::from_secret(b"named"),
6071 ),
6072 );
6073 let cached = CachedKeys {
6074 keys,
6075 unnamed_keys: vec![(
6076 JwkAlg::Explicit(Algorithm::RS256),
6077 DecodingKey::from_secret(b"unnamed"),
6078 )],
6079 fetched_at: Instant::now(),
6080 ttl: Duration::from_secs(300),
6081 };
6082 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
6084 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
6088 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
6090 }
6091
6092 #[test]
6093 fn no_kid_token_matches_unnamed_key() {
6094 let mut keys = HashMap::new();
6095 keys.insert(
6096 "kid-1".to_owned(),
6097 (
6098 JwkAlg::Explicit(Algorithm::RS256),
6099 DecodingKey::from_secret(b"named"),
6100 ),
6101 );
6102 let cached = CachedKeys {
6103 keys,
6104 unnamed_keys: vec![(
6105 JwkAlg::Explicit(Algorithm::RS256),
6106 DecodingKey::from_secret(b"unnamed"),
6107 )],
6108 fetched_at: Instant::now(),
6109 ttl: Duration::from_secs(300),
6110 };
6111 assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
6114 }
6115
6116 #[tokio::test]
6117 async fn require_subject_rejects_subject_less() {
6118 let kid = "test-key-reqsub";
6119 let (pem, jwks) = generate_test_keypair(kid);
6120 let mock_server = wiremock::MockServer::start().await;
6121 wiremock::Mock::given(wiremock::matchers::method("GET"))
6122 .and(wiremock::matchers::path("/jwks.json"))
6123 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6124 .mount(&mock_server)
6125 .await;
6126 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6127 let mut config = test_config(&jwks_uri);
6128 config.require_subject = true;
6129 let cache = test_cache(&config);
6130
6131 let no_sub = mint_token_without_sub(
6132 &pem,
6133 kid,
6134 "https://auth.test.local",
6135 "https://mcp.test.local/mcp",
6136 "mcp:read",
6137 );
6138 assert!(
6139 cache.validate_token(&no_sub).await.is_none(),
6140 "require_subject must reject a token with no sub"
6141 );
6142
6143 let with_sub = mint_token(
6144 &pem,
6145 kid,
6146 "https://auth.test.local",
6147 "https://mcp.test.local/mcp",
6148 "svc",
6149 "mcp:read",
6150 );
6151 assert!(
6152 cache.validate_token(&with_sub).await.is_some(),
6153 "a token carrying sub must still be accepted"
6154 );
6155 }
6156
6157 #[tokio::test]
6158 async fn subject_less_token_accepted_by_default() {
6159 let kid = "test-key-nosub-default";
6160 let (pem, jwks) = generate_test_keypair(kid);
6161 let mock_server = wiremock::MockServer::start().await;
6162 wiremock::Mock::given(wiremock::matchers::method("GET"))
6163 .and(wiremock::matchers::path("/jwks.json"))
6164 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6165 .mount(&mock_server)
6166 .await;
6167 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6168 let config = test_config(&jwks_uri); let cache = test_cache(&config);
6170 let no_sub = mint_token_without_sub(
6171 &pem,
6172 kid,
6173 "https://auth.test.local",
6174 "https://mcp.test.local/mcp",
6175 "mcp:read",
6176 );
6177 assert!(
6178 cache.validate_token(&no_sub).await.is_some(),
6179 "the default policy must accept a sub-less (client-credentials) token"
6180 );
6181 }
6182
6183 #[tokio::test]
6184 async fn credential_post_does_not_follow_redirect() {
6185 let mock = wiremock::MockServer::start().await;
6188 wiremock::Mock::given(wiremock::matchers::method("POST"))
6189 .and(wiremock::matchers::path("/followed"))
6190 .respond_with(wiremock::ResponseTemplate::new(200))
6191 .expect(0) .mount(&mock)
6193 .await;
6194 wiremock::Mock::given(wiremock::matchers::method("POST"))
6195 .and(wiremock::matchers::path("/token"))
6196 .respond_with(
6197 wiremock::ResponseTemplate::new(307)
6198 .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
6199 )
6200 .mount(&mock)
6201 .await;
6202
6203 let client = OauthHttpClient::build(None).expect("build oauth http client");
6204 let resp = client
6205 .credential_client
6206 .post(format!("{}/token", mock.uri()))
6207 .body("grant_type=client_credentials")
6208 .send()
6209 .await
6210 .expect("request sent");
6211 assert_eq!(
6212 resp.status().as_u16(),
6213 307,
6214 "credential client must surface the 307 rather than follow it"
6215 );
6216 }
6217
6218 fn test_token_exchange_config(token_url: String) -> TokenExchangeConfig {
6219 TokenExchangeConfig::new(
6220 token_url,
6221 "mcp-client",
6222 Some(secrecy::SecretString::new("test-client-secret".into())),
6223 None,
6224 )
6225 .with_audience("downstream-api")
6226 }
6227
6228 const ENC_GRANT: &str = "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange";
6229 const ENC_ACCESS: &str = "urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token";
6230
6231 #[test]
6232 fn build_exchange_form_is_byte_identical_to_pre_3_8_0_output() {
6233 let config = test_token_exchange_config("https://idp.example.com/token".into());
6234 let body = build_exchange_form(&config, "subj-token");
6235 assert_eq!(
6236 body,
6237 format!(
6238 "grant_type={ENC_GRANT}&subject_token=subj-token\
6239 &subject_token_type={ENC_ACCESS}&requested_token_type={ENC_ACCESS}\
6240 &audience=downstream-api"
6241 ),
6242 "a config predating 3.8.0 must produce an unchanged request body"
6243 );
6244 }
6245
6246 #[test]
6247 fn build_exchange_form_emits_only_required_params_when_all_optional_omitted() {
6248 let config =
6249 TokenExchangeConfig::new("https://idp.example.com/token", "public-client", None, None)
6250 .with_requested_token_type(RequestedTokenType::Omit);
6251 let body = build_exchange_form(&config, "subj");
6252 assert_eq!(
6253 body,
6254 format!(
6255 "grant_type={ENC_GRANT}&subject_token=subj\
6256 &subject_token_type={ENC_ACCESS}&client_id=public-client"
6257 ),
6258 "only the three RFC 8693 §2.1 REQUIRED params plus the public-client id"
6259 );
6260 }
6261
6262 #[test]
6263 fn build_exchange_form_keeps_rfc_parameter_order() {
6264 let config = test_token_exchange_config("https://idp.example.com/token".into())
6265 .with_resource("https://api.example.com/v1")
6266 .with_scope("read write")
6267 .with_requested_token_type(RequestedTokenType::Custom("urn:example:token".into()));
6268 let body = build_exchange_form(&config, "subj");
6269 let keys: Vec<&str> = body
6270 .split('&')
6271 .filter_map(|kv| kv.split('=').next())
6272 .collect();
6273 assert_eq!(
6274 keys,
6275 vec![
6276 "grant_type",
6277 "subject_token",
6278 "subject_token_type",
6279 "requested_token_type",
6280 "audience",
6281 "resource",
6282 "scope",
6283 ]
6284 );
6285 assert!(
6286 body.contains("&requested_token_type=urn%3Aexample%3Atoken"),
6287 "custom token type must be sent verbatim: {body}"
6288 );
6289 }
6290
6291 #[test]
6292 fn token_exchange_toml_omitting_new_keys_still_deserializes() {
6293 let cfg: TokenExchangeConfig = toml::from_str(
6294 "token_url = \"https://idp.example.com/token\"\n\
6295 client_id = \"client\"\n\
6296 audience = \"downstream\"\n",
6297 )
6298 .expect("a token_exchange table predating 3.8.0 must still parse");
6299 assert_eq!(cfg.audience.as_deref(), Some("downstream"));
6300 assert_eq!(cfg.resource, None);
6301 assert_eq!(cfg.scope, None);
6302 assert_eq!(cfg.requested_token_type, RequestedTokenType::AccessToken);
6303 }
6304
6305 #[test]
6306 fn upstream_error_description_is_redacted_by_default() {
6307 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6308 crate::diagnostics::set_diagnostic_exposure(
6309 &crate::diagnostics::DiagnosticExposure::default(),
6310 );
6311
6312 assert_eq!(
6313 upstream_error_description_for_log(Some("subject_token=eyJhbGciOi...")),
6314 "[REDACTED]",
6315 "upstream free-form text must not reach logs unless opted in"
6316 );
6317 assert_eq!(upstream_error_description_for_log(None), "[REDACTED]");
6318 }
6319
6320 #[test]
6321 fn upstream_error_description_is_shown_when_opted_in() {
6322 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6323 crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
6324 upstream_error_bodies: true,
6325 ..crate::diagnostics::DiagnosticExposure::default()
6326 });
6327
6328 assert_eq!(
6329 upstream_error_description_for_log(Some("audience not permitted")),
6330 "audience not permitted",
6331 "the debug switch must surface the upstream description verbatim"
6332 );
6333 assert_eq!(
6334 upstream_error_description_for_log(None),
6335 "",
6336 "an absent description renders empty, not the redaction marker"
6337 );
6338 }
6339
6340 #[test]
6341 fn requested_token_type_deserializes_from_plain_strings() {
6342 for (raw, expected) in [
6343 ("access_token", RequestedTokenType::AccessToken),
6344 ("omit", RequestedTokenType::Omit),
6345 (
6346 "urn:example:token",
6347 RequestedTokenType::Custom("urn:example:token".into()),
6348 ),
6349 ] {
6350 let cfg: TokenExchangeConfig = toml::from_str(&format!(
6351 "token_url = \"https://idp.example.com/token\"\n\
6352 client_id = \"client\"\n\
6353 requested_token_type = \"{raw}\"\n"
6354 ))
6355 .expect("requested_token_type must accept any string");
6356 assert_eq!(cfg.requested_token_type, expected, "input {raw}");
6357 }
6358 }
6359
6360 fn exchange_response(access_token: &str, issued_token_type: &str) -> serde_json::Value {
6361 serde_json::json!({
6362 "access_token": access_token,
6363 "expires_in": 3600_u64,
6364 "issued_token_type": issued_token_type,
6365 })
6366 }
6367
6368 fn unsigned_jwt_with_claims(claims: &serde_json::Value) -> String {
6369 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
6370 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims json"));
6371 format!("{header}.{payload}.signature")
6372 }
6373
6374 fn test_exchange_client() -> OauthHttpClient {
6375 let config = OAuthConfig::builder(
6376 "http://auth.test.local",
6377 "mcp",
6378 "http://auth.test.local/jwks.json",
6379 )
6380 .allow_http_oauth_urls(true)
6381 .build();
6382 OauthHttpClient::build(Some(&config))
6383 .expect("build oauth http client")
6384 .__test_allow_loopback_ssrf()
6385 }
6386
6387 fn unavailable_loopback_token_url() -> String {
6388 "http://127.0.0.1:1/token?client_secret=super-secret".to_owned()
6389 }
6390
6391 async fn recorded_request_count(mock: &wiremock::MockServer) -> usize {
6392 mock.received_requests()
6393 .await
6394 .expect("wiremock request recording is enabled")
6395 .len()
6396 }
6397
6398 async fn wait_for_recorded_request(mock: &wiremock::MockServer) {
6399 tokio::time::timeout(Duration::from_secs(15), async {
6403 loop {
6404 if recorded_request_count(mock).await > 0 {
6405 return;
6406 }
6407 tokio::time::sleep(Duration::from_millis(10)).await;
6408 }
6409 })
6410 .await
6411 .expect("token endpoint must record the in-flight request before cancellation");
6412 }
6413
6414 async fn wait_for_log_contains(logs: &CapturedLogs, needle: &str) {
6415 tokio::time::timeout(Duration::from_secs(15), async {
6420 loop {
6421 if logs.contents().contains(needle) {
6422 return;
6423 }
6424 tokio::time::sleep(Duration::from_millis(10)).await;
6425 }
6426 })
6427 .await
6428 .expect("detached token exchange must eventually emit its audit log");
6429 }
6430
6431 #[tokio::test]
6432 async fn send_screened_request_failure_sanitizes_url_and_reqwest_error() {
6433 let client = test_exchange_client();
6434 let screened_url = unavailable_loopback_token_url();
6435 let request_url = screened_url.replacen("//", "//u:p@", 1);
6436
6437 let error = client
6438 .send_screened(
6439 &screened_url,
6440 client
6441 .credential_client
6442 .post(&request_url)
6443 .body("grant_type=test"),
6444 )
6445 .await
6446 .expect_err("closed loopback port must fail the request");
6447
6448 let rendered = error.to_string();
6449 let sanitized = oauth_request_target_for_log(&screened_url);
6450 assert!(
6451 rendered.contains(&format!("oauth request {sanitized}")),
6452 "request failure must identify only the sanitized origin: {rendered}"
6453 );
6454 for leaked in ["u:p", "/token", "client_secret", "super-secret"] {
6455 assert!(
6456 !rendered.contains(leaked),
6457 "request failure must not echo raw URL component {leaked}: {rendered}"
6458 );
6459 }
6460 }
6461
6462 #[tokio::test]
6463 async fn exchange_token_request_failure_log_sanitizes_token_url() {
6464 let logs = CapturedLogs::default();
6465 let subscriber = tracing_subscriber::fmt()
6466 .with_max_level(tracing::Level::ERROR)
6467 .with_writer(logs.clone())
6468 .with_ansi(false)
6469 .without_time()
6470 .finish();
6471 let _guard = tracing::subscriber::set_default(subscriber);
6472
6473 let client = test_exchange_client();
6474 let token_url = unavailable_loopback_token_url();
6475 let config = test_token_exchange_config(token_url);
6476 let error = exchange_token(&client, &config, "subject-token")
6477 .await
6478 .expect_err("closed loopback port must fail exchange");
6479
6480 assert!(
6481 error.to_string().contains("server_error"),
6482 "client-visible exchange error must remain sanitized: {error}"
6483 );
6484 let contents = logs.contents();
6485 assert!(
6486 contents.contains("token exchange request failed"),
6487 "exchange failure must still be logged: {contents}"
6488 );
6489 assert!(
6490 contents.contains("oauth request http://127.0.0.1:1"),
6491 "exchange failure log must include only sanitized origin: {contents}"
6492 );
6493 for leaked in ["/token", "client_secret", "super-secret", "subject-token"] {
6494 assert!(
6495 !contents.contains(leaked),
6496 "exchange failure log must not echo raw URL/token component {leaked}: {contents}"
6497 );
6498 }
6499 }
6500
6501 #[tokio::test]
6502 async fn exchange_token_with_cancel_precancel_does_not_send() {
6503 let mock = wiremock::MockServer::start().await;
6504 wiremock::Mock::given(wiremock::matchers::method("POST"))
6505 .and(wiremock::matchers::path("/token"))
6506 .respond_with(
6507 wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6508 "downstream-token",
6509 "urn:ietf:params:oauth:token-type:access_token",
6510 )),
6511 )
6512 .mount(&mock)
6513 .await;
6514
6515 let client = test_exchange_client();
6516 let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6517 let ct = tokio_util::sync::CancellationToken::new();
6518 ct.cancel();
6519
6520 let outcome =
6521 exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6522
6523 assert!(
6524 matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6525 "pre-cancelled exchanges must not start work"
6526 );
6527 assert_eq!(
6528 recorded_request_count(&mock).await,
6529 0,
6530 "pre-cancel check must happen before cloning/spawning/sending"
6531 );
6532 }
6533
6534 #[tokio::test]
6535 async fn exchange_token_with_cancel_completes_normally() {
6536 let mock = wiremock::MockServer::start().await;
6537 wiremock::Mock::given(wiremock::matchers::method("POST"))
6538 .and(wiremock::matchers::path("/token"))
6539 .respond_with(
6540 wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6541 "downstream-token",
6542 "urn:ietf:params:oauth:token-type:access_token",
6543 )),
6544 )
6545 .expect(1)
6546 .mount(&mock)
6547 .await;
6548
6549 let client = test_exchange_client();
6550 let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6551 let ct = tokio_util::sync::CancellationToken::new();
6552
6553 let outcome =
6554 exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6555
6556 let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6557 panic!("uncancelled exchange must complete successfully")
6558 };
6559 assert_eq!(token.access_token, "downstream-token");
6560 mock.verify().await;
6561 }
6562
6563 #[tokio::test]
6564 async fn exchange_token_with_cancel_detaches_and_audits_abandoned_token() {
6565 let mock = wiremock::MockServer::start().await;
6566 let long_issued_token_type = format!(
6567 "urn:ietf:params:oauth:token-type:{}",
6568 "x".repeat(MAX_LOGGED_KID_CHARS + 32)
6569 );
6570 wiremock::Mock::given(wiremock::matchers::method("POST"))
6571 .and(wiremock::matchers::path("/token"))
6572 .respond_with(
6573 wiremock::ResponseTemplate::new(200)
6574 .set_delay(Duration::from_secs(2))
6581 .set_body_json(exchange_response(
6582 "abandoned-downstream-token",
6583 &long_issued_token_type,
6584 )),
6585 )
6586 .expect(1)
6587 .mount(&mock)
6588 .await;
6589
6590 let token_url = format!("{}/token", mock.uri());
6591 let token_url_host = url::Url::parse(&token_url)
6592 .expect("mock token URL parses")
6593 .host_str()
6594 .expect("mock token URL has host")
6595 .to_owned();
6596 let logs = CapturedLogs::default();
6597 let subscriber = tracing_subscriber::fmt()
6598 .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6599 .with_writer(logs.clone())
6600 .with_ansi(false)
6601 .without_time()
6602 .finish();
6603 let _guard = tracing::subscriber::set_default(subscriber);
6604
6605 let client = test_exchange_client();
6606 let config = test_token_exchange_config(token_url);
6607 let ct = tokio_util::sync::CancellationToken::new();
6608 let task_ct = ct.clone();
6609 let handle = tokio::spawn(async move {
6610 exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6611 });
6612
6613 wait_for_recorded_request(&mock).await;
6614 let cancelled_at = Instant::now();
6615 ct.cancel();
6616 let outcome = handle.await.expect("wrapper task must not panic");
6617
6618 assert!(
6619 matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6620 "caller must get an immediate cancellation outcome"
6621 );
6622 assert!(
6623 cancelled_at.elapsed() < Duration::from_millis(100),
6624 "wrapper must detach instead of waiting for the delayed upstream response"
6625 );
6626
6627 wait_for_log_contains(
6628 &logs,
6629 "token exchange minted downstream token after caller detached",
6630 )
6631 .await;
6632 mock.verify().await;
6633 let contents = logs.contents();
6634 assert!(
6635 contents.contains("issued_token_type_truncated=true"),
6636 "audit log must mark issuer-controlled token type truncation: {contents}"
6637 );
6638 assert!(
6639 !contents.contains("abandoned-downstream-token"),
6640 "audit log must not include downstream token material: {contents}"
6641 );
6642 assert!(
6643 !contents.contains("token_len="),
6644 "DEBUG success log must be suppressed on abandoned exchanges: {contents}"
6645 );
6646 assert!(
6647 !contents.contains(&long_issued_token_type),
6648 "detached logs must not include unbounded issued token type: {contents}"
6649 );
6650 for field in ["sub=", "aud=", "azp=", "iss="] {
6651 assert!(
6652 !contents.contains(field),
6653 "detached logs must not include JWT claim field {field}: {contents}"
6654 );
6655 }
6656 assert!(
6657 !contents.contains(&token_url_host),
6658 "detached success logs must not include token endpoint host: {contents}"
6659 );
6660 assert!(
6661 !contents.contains("subject-token"),
6662 "audit log must not include subject token material: {contents}"
6663 );
6664 assert!(
6665 !contents.contains("test-client-secret"),
6666 "audit log must not include client secret material: {contents}"
6667 );
6668 }
6669
6670 #[tokio::test]
6671 async fn exchange_token_with_cancel_detached_jwt_success_does_not_log_claims() {
6672 let mock = wiremock::MockServer::start().await;
6673 let jwt = unsigned_jwt_with_claims(&serde_json::json!({
6674 "sub": "detached-subject",
6675 "aud": "detached-audience",
6676 "azp": "detached-client",
6677 "iss": "https://issuer.example.test/realm",
6678 }));
6679 wiremock::Mock::given(wiremock::matchers::method("POST"))
6680 .and(wiremock::matchers::path("/token"))
6681 .respond_with(
6682 wiremock::ResponseTemplate::new(200)
6683 .set_delay(Duration::from_secs(2))
6687 .set_body_json(exchange_response(
6688 &jwt,
6689 "urn:ietf:params:oauth:token-type:access_token",
6690 )),
6691 )
6692 .expect(1)
6693 .mount(&mock)
6694 .await;
6695
6696 let logs = CapturedLogs::default();
6697 let subscriber = tracing_subscriber::fmt()
6698 .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6699 .with_writer(logs.clone())
6700 .with_ansi(false)
6701 .without_time()
6702 .finish();
6703 let _guard = tracing::subscriber::set_default(subscriber);
6704
6705 let client = test_exchange_client();
6706 let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6707 let ct = tokio_util::sync::CancellationToken::new();
6708 let task_ct = ct.clone();
6709 let handle = tokio::spawn(async move {
6710 exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6711 });
6712
6713 wait_for_recorded_request(&mock).await;
6714 ct.cancel();
6715 let outcome = handle.await.expect("wrapper task must not panic");
6716 assert!(
6717 matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6718 "caller must get cancellation while spawned JWT exchange continues"
6719 );
6720
6721 wait_for_log_contains(
6722 &logs,
6723 "token exchange minted downstream token after caller detached",
6724 )
6725 .await;
6726 mock.verify().await;
6727 let contents = logs.contents();
6728 assert!(
6729 !contents.contains(&jwt),
6730 "detached JWT success must not log token material: {contents}"
6731 );
6732 for leaked in [
6733 "sub=",
6734 "aud=",
6735 "azp=",
6736 "iss=",
6737 "detached-subject",
6738 "detached-audience",
6739 "detached-client",
6740 "issuer.example.test",
6741 ] {
6742 assert!(
6743 !contents.contains(leaked),
6744 "detached JWT success must not log claim material {leaked}: {contents}"
6745 );
6746 }
6747 }
6748
6749 #[tokio::test]
6750 async fn exchange_token_with_cancel_completion_wins_tie() {
6751 let (tx, rx) = tokio::sync::oneshot::channel();
6752 tx.send(Ok(ExchangedToken {
6753 access_token: "tie-winner".into(),
6754 expires_in: Some(3600),
6755 issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".into()),
6756 }))
6757 .expect("test receiver is alive");
6758 let ct = tokio_util::sync::CancellationToken::new();
6759 ct.cancel();
6760
6761 let outcome = receive_exchange_result_with_cancel(rx, &ct, None).await;
6762
6763 let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6764 panic!("ready completion must win over ready cancellation under biased select")
6765 };
6766 assert_eq!(token.access_token, "tie-winner");
6767 }
6768
6769 #[tokio::test]
6770 async fn jwks_get_still_follows_screened_redirect() {
6771 let mock = wiremock::MockServer::start().await;
6777 wiremock::Mock::given(wiremock::matchers::method("GET"))
6778 .and(wiremock::matchers::path("/jwks.json"))
6779 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
6780 "location",
6781 format!("{}/jwks-final.json", mock.uri()).as_str(),
6782 ))
6783 .mount(&mock)
6784 .await;
6785 wiremock::Mock::given(wiremock::matchers::method("GET"))
6786 .and(wiremock::matchers::path("/jwks-final.json"))
6787 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
6788 .expect(1)
6789 .mount(&mock)
6790 .await;
6791
6792 let mut allowlist = OAuthSsrfAllowlist::default();
6793 allowlist.cidrs.push("127.0.0.0/8".into());
6794 allowlist.cidrs.push("::1/128".into());
6795 let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
6796 config.allow_http_oauth_urls = true;
6797 config.ssrf_allowlist = Some(allowlist);
6798
6799 let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
6800 let resp = client
6801 .inner
6802 .get(format!("{}/jwks.json", mock.uri()))
6803 .send()
6804 .await
6805 .expect("request sent");
6806 assert_eq!(
6807 resp.status().as_u16(),
6808 200,
6809 "JWKS client must follow the screened redirect to the final endpoint"
6810 );
6811 assert_eq!(resp.text().await.expect("response body"), "reached");
6812 }
6813
6814 #[tokio::test]
6815 async fn wrong_issuer_rejected() {
6816 let kid = "test-key-2";
6817 let (pem, jwks) = generate_test_keypair(kid);
6818
6819 let mock_server = wiremock::MockServer::start().await;
6820 wiremock::Mock::given(wiremock::matchers::method("GET"))
6821 .and(wiremock::matchers::path("/jwks.json"))
6822 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6823 .mount(&mock_server)
6824 .await;
6825
6826 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6827 let config = test_config(&jwks_uri);
6828 let cache = test_cache(&config);
6829
6830 let token = mint_token(
6831 &pem,
6832 kid,
6833 "https://wrong-issuer.example.com", "https://mcp.test.local/mcp",
6835 "attacker",
6836 "mcp:admin",
6837 );
6838
6839 assert!(cache.validate_token(&token).await.is_none());
6840 }
6841
6842 #[tokio::test]
6843 async fn wrong_audience_rejected() {
6844 let kid = "test-key-3";
6845 let (pem, jwks) = generate_test_keypair(kid);
6846
6847 let mock_server = wiremock::MockServer::start().await;
6848 wiremock::Mock::given(wiremock::matchers::method("GET"))
6849 .and(wiremock::matchers::path("/jwks.json"))
6850 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6851 .mount(&mock_server)
6852 .await;
6853
6854 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6855 let config = test_config(&jwks_uri);
6856 let cache = test_cache(&config);
6857
6858 let token = mint_token(
6859 &pem,
6860 kid,
6861 "https://auth.test.local",
6862 "https://wrong-audience.example.com", "attacker",
6864 "mcp:admin",
6865 );
6866
6867 assert!(cache.validate_token(&token).await.is_none());
6868 }
6869
6870 #[tokio::test]
6871 async fn expired_jwt_rejected() {
6872 let kid = "test-key-4";
6873 let (pem, jwks) = generate_test_keypair(kid);
6874
6875 let mock_server = wiremock::MockServer::start().await;
6876 wiremock::Mock::given(wiremock::matchers::method("GET"))
6877 .and(wiremock::matchers::path("/jwks.json"))
6878 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6879 .mount(&mock_server)
6880 .await;
6881
6882 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6883 let config = test_config(&jwks_uri);
6884 let cache = test_cache(&config);
6885
6886 let encoding_key =
6888 jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
6889 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
6890 header.kid = Some(kid.into());
6891 let now = jsonwebtoken::get_current_timestamp();
6892 let claims = serde_json::json!({
6893 "iss": "https://auth.test.local",
6894 "aud": "https://mcp.test.local/mcp",
6895 "sub": "expired-bot",
6896 "scope": "mcp:read",
6897 "exp": now - 120,
6898 "iat": now - 3720,
6899 });
6900 let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
6901
6902 assert!(cache.validate_token(&token).await.is_none());
6903 }
6904
6905 #[tokio::test]
6906 async fn no_matching_scope_rejected() {
6907 let kid = "test-key-5";
6908 let (pem, jwks) = generate_test_keypair(kid);
6909
6910 let mock_server = wiremock::MockServer::start().await;
6911 wiremock::Mock::given(wiremock::matchers::method("GET"))
6912 .and(wiremock::matchers::path("/jwks.json"))
6913 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6914 .mount(&mock_server)
6915 .await;
6916
6917 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6918 let config = test_config(&jwks_uri);
6919 let cache = test_cache(&config);
6920
6921 let token = mint_token(
6922 &pem,
6923 kid,
6924 "https://auth.test.local",
6925 "https://mcp.test.local/mcp",
6926 "limited-bot",
6927 "some:other:scope", );
6929
6930 assert!(cache.validate_token(&token).await.is_none());
6931 }
6932
6933 #[tokio::test]
6934 async fn wrong_signing_key_rejected() {
6935 let kid = "test-key-6";
6936 let (_pem, jwks) = generate_test_keypair(kid);
6937
6938 let (attacker_pem, _) = generate_test_keypair(kid);
6940
6941 let mock_server = wiremock::MockServer::start().await;
6942 wiremock::Mock::given(wiremock::matchers::method("GET"))
6943 .and(wiremock::matchers::path("/jwks.json"))
6944 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6945 .mount(&mock_server)
6946 .await;
6947
6948 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6949 let config = test_config(&jwks_uri);
6950 let cache = test_cache(&config);
6951
6952 let token = mint_token(
6954 &attacker_pem,
6955 kid,
6956 "https://auth.test.local",
6957 "https://mcp.test.local/mcp",
6958 "attacker",
6959 "mcp:admin",
6960 );
6961
6962 assert!(cache.validate_token(&token).await.is_none());
6963 }
6964
6965 #[tokio::test]
6966 async fn admin_scope_maps_to_ops_role() {
6967 let kid = "test-key-7";
6968 let (pem, jwks) = generate_test_keypair(kid);
6969
6970 let mock_server = wiremock::MockServer::start().await;
6971 wiremock::Mock::given(wiremock::matchers::method("GET"))
6972 .and(wiremock::matchers::path("/jwks.json"))
6973 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6974 .mount(&mock_server)
6975 .await;
6976
6977 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6978 let config = test_config(&jwks_uri);
6979 let cache = test_cache(&config);
6980
6981 let token = mint_token(
6982 &pem,
6983 kid,
6984 "https://auth.test.local",
6985 "https://mcp.test.local/mcp",
6986 "admin-bot",
6987 "mcp:admin",
6988 );
6989
6990 let id = cache
6991 .validate_token(&token)
6992 .await
6993 .expect("should authenticate");
6994 assert_eq!(id.role, "ops");
6995 assert_eq!(id.name, "admin-bot");
6996 }
6997
6998 #[tokio::test]
6999 async fn entra_shaped_alg_less_jwks_authenticates_end_to_end() {
7000 let kid = "entra-e2e";
7004 let (pem, jwks) = generate_test_keypair(kid);
7005 let mut alg_less = jwks;
7006 if let Some(key) = alg_less["keys"][0].as_object_mut() {
7007 key.remove("alg");
7008 }
7009 assert!(
7010 alg_less["keys"][0].get("alg").is_none(),
7011 "fixture must reproduce Entra's alg-less shape"
7012 );
7013
7014 let mock_server = wiremock::MockServer::start().await;
7015 wiremock::Mock::given(wiremock::matchers::method("GET"))
7016 .and(wiremock::matchers::path("/jwks.json"))
7017 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&alg_less))
7018 .mount(&mock_server)
7019 .await;
7020
7021 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7022 let config = test_config(&jwks_uri);
7023 let cache = test_cache(&config);
7024
7025 let token = mint_token(
7026 &pem,
7027 kid,
7028 "https://auth.test.local",
7029 "https://mcp.test.local/mcp",
7030 "entra-user",
7031 "mcp:admin",
7032 );
7033
7034 let id = cache
7035 .validate_token(&token)
7036 .await
7037 .expect("an alg-less JWKS key must still authenticate (issue #17)");
7038 assert_eq!(id.name, "entra-user");
7039 }
7040
7041 #[tokio::test]
7042 async fn jwks_server_down_returns_none() {
7043 let config = test_config("http://127.0.0.1:1/jwks.json");
7045 let cache = test_cache(&config);
7046
7047 let kid = "orphan-key";
7048 let (pem, _) = generate_test_keypair(kid);
7049 let token = mint_token(
7050 &pem,
7051 kid,
7052 "https://auth.test.local",
7053 "https://mcp.test.local/mcp",
7054 "bot",
7055 "mcp:read",
7056 );
7057
7058 assert!(cache.validate_token(&token).await.is_none());
7059 }
7060
7061 #[test]
7066 fn resolve_claim_path_flat_string() {
7067 let mut extra = HashMap::new();
7068 extra.insert(
7069 "scope".into(),
7070 serde_json::Value::String("mcp:read mcp:admin".into()),
7071 );
7072 let values = resolve_claim_path(&extra, "scope");
7073 assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
7074 }
7075
7076 #[test]
7077 fn resolve_claim_path_flat_array() {
7078 let mut extra = HashMap::new();
7079 extra.insert(
7080 "roles".into(),
7081 serde_json::json!(["mcp-admin", "mcp-viewer"]),
7082 );
7083 let values = resolve_claim_path(&extra, "roles");
7084 assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
7085 }
7086
7087 #[test]
7088 fn resolve_claim_path_nested_keycloak() {
7089 let mut extra = HashMap::new();
7090 extra.insert(
7091 "realm_access".into(),
7092 serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
7093 );
7094 let values = resolve_claim_path(&extra, "realm_access.roles");
7095 assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
7096 }
7097
7098 #[test]
7099 fn resolve_claim_path_missing_returns_empty() {
7100 let extra = HashMap::new();
7101 assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
7102 }
7103
7104 #[test]
7105 fn resolve_claim_path_numeric_leaf_returns_empty() {
7106 let mut extra = HashMap::new();
7107 extra.insert("count".into(), serde_json::json!(42));
7108 assert!(resolve_claim_path(&extra, "count").is_empty());
7109 }
7110
7111 fn make_claims(json: serde_json::Value) -> Claims {
7112 serde_json::from_value(json).expect("test claims must deserialize")
7113 }
7114
7115 #[test]
7116 fn first_class_scope_claim_splits_on_whitespace() {
7117 let claims = make_claims(serde_json::json!({
7118 "iss": "https://issuer.example.com",
7119 "exp": 9_999_999_999_u64,
7120 "scope": "read write admin",
7121 }));
7122 let values = first_class_claim_values(&claims, "scope");
7123 assert_eq!(values, vec!["read", "write", "admin"]);
7124 }
7125
7126 #[test]
7127 fn first_class_sub_claim_returns_single_value() {
7128 let claims = make_claims(serde_json::json!({
7129 "iss": "https://issuer.example.com",
7130 "exp": 9_999_999_999_u64,
7131 "sub": "service-account-orders",
7132 }));
7133 let values = first_class_claim_values(&claims, "sub");
7134 assert_eq!(values, vec!["service-account-orders"]);
7135 }
7136
7137 #[test]
7138 fn first_class_aud_claim_returns_every_audience() {
7139 let claims = make_claims(serde_json::json!({
7140 "iss": "https://issuer.example.com",
7141 "exp": 9_999_999_999_u64,
7142 "aud": ["api-a", "api-b"],
7143 }));
7144 let values = first_class_claim_values(&claims, "aud");
7145 assert_eq!(values, vec!["api-a", "api-b"]);
7146 }
7147
7148 #[test]
7149 fn first_class_unknown_path_returns_empty() {
7150 let claims = make_claims(serde_json::json!({
7151 "iss": "https://issuer.example.com",
7152 "exp": 9_999_999_999_u64,
7153 }));
7154 assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
7155 }
7156
7157 fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
7163 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
7164 .expect("encoding key from PEM");
7165 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
7166 header.kid = Some(kid.into());
7167 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
7168 }
7169
7170 fn test_config_with_role_claim(
7171 jwks_uri: &str,
7172 role_claim: &str,
7173 role_mappings: Vec<RoleMapping>,
7174 ) -> OAuthConfig {
7175 OAuthConfig {
7176 require_subject: false,
7177 issuer: "https://auth.test.local".into(),
7178 audience: "https://mcp.test.local/mcp".into(),
7179 jwks_uri: jwks_uri.into(),
7180 scopes: vec![],
7181 role_claim: Some(role_claim.into()),
7182 role_mappings,
7183 jwks_cache_ttl: "5m".into(),
7184 proxy: None,
7185 token_exchange: None,
7186 ca_cert_path: None,
7187 allow_http_oauth_urls: true,
7188 max_jwks_keys: default_max_jwks_keys(),
7189 allowed_algorithms: None,
7190 authorization_servers: None,
7191 authorization_server_metadata_issuer: None,
7192 #[allow(
7193 deprecated,
7194 reason = "test fixture: explicit value for the deprecated field"
7195 )]
7196 strict_audience_validation: None,
7197 audience_validation_mode: None,
7198 jwks_max_response_bytes: default_jwks_max_bytes(),
7199 ssrf_allowlist: None,
7200 }
7201 }
7202
7203 #[tokio::test]
7204 async fn screen_oauth_target_rejects_literal_ip() {
7205 let err = screen_oauth_target(
7206 "https://127.0.0.1/jwks.json",
7207 false,
7208 &crate::ssrf::CompiledSsrfAllowlist::default(),
7209 )
7210 .await
7211 .expect_err("literal IPs must be rejected");
7212 let msg = err.to_string();
7213 assert!(msg.contains("literal IPv4 addresses are forbidden"));
7214 }
7215
7216 #[tokio::test]
7217 async fn screen_oauth_target_rejects_private_dns_resolution() {
7218 let err = screen_oauth_target(
7219 "https://localhost/jwks.json",
7220 false,
7221 &crate::ssrf::CompiledSsrfAllowlist::default(),
7222 )
7223 .await
7224 .expect_err("localhost resolution must be rejected");
7225 let msg = err.to_string();
7226 assert!(
7227 msg.contains("blocked IP") && msg.contains("loopback"),
7228 "got {msg:?}"
7229 );
7230 }
7231
7232 #[tokio::test]
7233 async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
7234 let err = screen_oauth_target(
7235 "http://127.0.0.1/jwks.json",
7236 true,
7237 &crate::ssrf::CompiledSsrfAllowlist::default(),
7238 )
7239 .await
7240 .expect_err("literal IPs must still be rejected when http is allowed");
7241 let msg = err.to_string();
7242 assert!(msg.contains("literal IPv4 addresses are forbidden"));
7243 }
7244
7245 #[tokio::test]
7246 async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
7247 let err = screen_oauth_target(
7248 "http://localhost/jwks.json",
7249 true,
7250 &crate::ssrf::CompiledSsrfAllowlist::default(),
7251 )
7252 .await
7253 .expect_err("private DNS resolution must still be rejected when http is allowed");
7254 let msg = err.to_string();
7255 assert!(
7256 msg.contains("blocked IP") && msg.contains("loopback"),
7257 "got {msg:?}"
7258 );
7259 }
7260
7261 #[tokio::test]
7262 async fn screen_oauth_target_allows_public_hostname() {
7263 screen_oauth_target(
7264 "https://example.com/.well-known/jwks.json",
7265 false,
7266 &crate::ssrf::CompiledSsrfAllowlist::default(),
7267 )
7268 .await
7269 .expect("public hostname should pass screening");
7270 }
7271
7272 fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
7278 let raw = OAuthSsrfAllowlist {
7279 hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
7280 cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
7281 };
7282 compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
7283 }
7284
7285 #[test]
7286 fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
7287 let raw = OAuthSsrfAllowlist {
7288 hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
7289 cidrs: vec![],
7290 };
7291 let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
7292 assert_eq!(compiled.host_count(), 1);
7293 assert!(compiled.host_allowed("rhbk.ops.example.com"));
7294 assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
7295 }
7296
7297 #[test]
7298 fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
7299 let raw = OAuthSsrfAllowlist {
7300 hosts: vec!["10.0.0.1".into()],
7301 cidrs: vec![],
7302 };
7303 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
7304 assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
7305 }
7306
7307 #[test]
7308 fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
7309 let raw = OAuthSsrfAllowlist {
7310 hosts: vec!["rhbk.ops.example.com:8443".into()],
7311 cidrs: vec![],
7312 };
7313 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
7314 assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
7315 }
7316
7317 #[test]
7320 fn internal_suffix_rejected_by_default() {
7321 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7322 for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
7323 assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
7324 }
7325 }
7326
7327 #[test]
7328 fn exact_allowlisted_internal_permitted() {
7329 let allow = make_allowlist(&["idp.internal"], &[]);
7330 assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
7331 assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
7332 }
7333
7334 #[test]
7335 fn subdomain_of_allowlisted_internal_still_rejected() {
7336 let allow = make_allowlist(&["idp.internal"], &[]);
7337 assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
7338 }
7339
7340 #[test]
7341 fn cidr_allowlist_does_not_bypass_suffix_denylist() {
7342 let allow = make_allowlist(&[], &["10.0.0.0/8"]);
7343 assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
7344 }
7345
7346 #[test]
7347 fn public_hostname_not_blocked_by_suffix() {
7348 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7349 assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
7350 }
7351
7352 #[test]
7353 fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
7354 let raw = OAuthSsrfAllowlist {
7355 hosts: vec![],
7356 cidrs: vec!["not-a-cidr".into()],
7357 };
7358 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
7359 assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
7360 }
7361
7362 #[test]
7363 fn validate_rejects_misconfigured_allowlist() {
7364 let mut cfg = OAuthConfig::builder(
7365 "https://auth.example.com/",
7366 "mcp",
7367 "https://auth.example.com/jwks.json",
7368 )
7369 .build();
7370 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7371 hosts: vec!["10.0.0.1".into()],
7372 cidrs: vec![],
7373 });
7374 let err = cfg
7375 .validate()
7376 .expect_err("literal IP host must be rejected");
7377 assert!(
7378 err.to_string().contains("oauth.ssrf_allowlist"),
7379 "got {err}"
7380 );
7381 }
7382
7383 #[tokio::test]
7384 async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
7385 let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
7389 let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
7390 .await
7391 .expect_err("loopback must still be blocked when not in allowlist");
7392 let msg = err.to_string();
7393 assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
7394 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7395 assert!(msg.contains("SECURITY.md"), "got {msg:?}");
7396 }
7397
7398 #[tokio::test]
7399 async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
7400 let err = screen_oauth_target(
7403 "https://localhost/jwks.json",
7404 false,
7405 &crate::ssrf::CompiledSsrfAllowlist::default(),
7406 )
7407 .await
7408 .expect_err("loopback rejection");
7409 let msg = err.to_string();
7410 assert!(msg.contains("blocked IP"), "got {msg:?}");
7411 assert!(msg.contains("loopback"), "got {msg:?}");
7412 assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7414 }
7415
7416 #[tokio::test]
7417 async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
7418 let allow = make_allowlist(&["localhost"], &[]);
7420 screen_oauth_target("https://localhost/jwks.json", false, &allow)
7421 .await
7422 .expect("allowlisted host must pass");
7423 }
7424
7425 #[tokio::test]
7426 async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
7427 let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
7430 screen_oauth_target("https://localhost/jwks.json", false, &allow)
7431 .await
7432 .expect("allowlisted CIDR must pass");
7433 }
7434
7435 #[tokio::test]
7436 async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
7437 let mut cfg = OAuthConfig::builder(
7438 "https://auth.example.com/",
7439 "mcp",
7440 "https://auth.example.com/jwks.json",
7441 )
7442 .build();
7443 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7444 hosts: vec![],
7445 cidrs: vec!["bad-cidr".into()],
7446 });
7447 let Err(err) = JwksCache::new(&cfg) else {
7448 panic!("invalid CIDR must fail JwksCache::new")
7449 };
7450 let msg = err.to_string();
7451 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7452 }
7453
7454 #[tokio::test]
7455 async fn jwks_cache_new_invalid_ttl_is_err() {
7456 let cfg = OAuthConfig::builder(
7459 "https://auth.example.com/",
7460 "mcp",
7461 "https://auth.example.com/jwks.json",
7462 )
7463 .jwks_cache_ttl("not-a-duration")
7464 .build();
7465 let Err(err) = JwksCache::new(&cfg) else {
7466 panic!("invalid jwks_cache_ttl must fail JwksCache::new")
7467 };
7468 let msg = err.to_string();
7469 assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
7470 }
7471
7472 #[tokio::test]
7473 async fn audience_default_is_strict() {
7474 let kid = "test-audience-azp-default";
7475 let (pem, jwks) = generate_test_keypair(kid);
7476
7477 let mock_server = wiremock::MockServer::start().await;
7478 wiremock::Mock::given(wiremock::matchers::method("GET"))
7479 .and(wiremock::matchers::path("/jwks.json"))
7480 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7481 .mount(&mock_server)
7482 .await;
7483
7484 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7485 let config = test_config(&jwks_uri);
7486 let cache = test_cache(&config);
7487
7488 let now = jsonwebtoken::get_current_timestamp();
7489 let token = mint_token_with_claims(
7490 &pem,
7491 kid,
7492 &serde_json::json!({
7493 "iss": "https://auth.test.local",
7494 "aud": "https://some-other-resource.example.com",
7495 "azp": "https://mcp.test.local/mcp",
7496 "sub": "compat-client",
7497 "scope": "mcp:read",
7498 "exp": now + 3600,
7499 "iat": now,
7500 }),
7501 );
7502
7503 let failure = cache
7504 .validate_token_with_reason(&token)
7505 .await
7506 .expect_err("the default policy is Strict and must reject an azp-only match");
7507 assert_eq!(failure, JwtValidationFailure::Invalid);
7508 }
7509
7510 #[tokio::test]
7511 async fn audience_warn_still_accepts_azp() {
7512 let kid = "test-audience-warn-optin";
7513 let (pem, jwks) = generate_test_keypair(kid);
7514
7515 let mock_server = wiremock::MockServer::start().await;
7516 wiremock::Mock::given(wiremock::matchers::method("GET"))
7517 .and(wiremock::matchers::path("/jwks.json"))
7518 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7519 .mount(&mock_server)
7520 .await;
7521
7522 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7523 let mut config = test_config(&jwks_uri);
7524 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7525 let cache = test_cache(&config);
7526
7527 let now = jsonwebtoken::get_current_timestamp();
7528 let token = mint_token_with_claims(
7529 &pem,
7530 kid,
7531 &serde_json::json!({
7532 "iss": "https://auth.test.local",
7533 "aud": "https://some-other-resource.example.com",
7534 "azp": "https://mcp.test.local/mcp",
7535 "sub": "warn-optin-client",
7536 "scope": "mcp:read",
7537 "exp": now + 3600,
7538 "iat": now,
7539 }),
7540 );
7541
7542 cache.validate_token_with_reason(&token).await.expect(
7543 "the audience_validation_mode=warn opt-out must still accept an azp-only match",
7544 );
7545 }
7546
7547 #[tokio::test]
7548 async fn legacy_strict_false_maps_to_warn() {
7549 let kid = "test-audience-legacy-false";
7550 let (pem, jwks) = generate_test_keypair(kid);
7551
7552 let mock_server = wiremock::MockServer::start().await;
7553 wiremock::Mock::given(wiremock::matchers::method("GET"))
7554 .and(wiremock::matchers::path("/jwks.json"))
7555 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7556 .mount(&mock_server)
7557 .await;
7558
7559 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7560 let mut config = test_config(&jwks_uri);
7561 #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
7564 {
7565 config.strict_audience_validation = Some(false);
7566 }
7567 let cache = test_cache(&config);
7568
7569 let now = jsonwebtoken::get_current_timestamp();
7570 let token = mint_token_with_claims(
7571 &pem,
7572 kid,
7573 &serde_json::json!({
7574 "iss": "https://auth.test.local",
7575 "aud": "https://some-other-resource.example.com",
7576 "azp": "https://mcp.test.local/mcp",
7577 "sub": "legacy-false-client",
7578 "scope": "mcp:read",
7579 "exp": now + 3600,
7580 "iat": now,
7581 }),
7582 );
7583
7584 cache
7585 .validate_token_with_reason(&token)
7586 .await
7587 .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
7588 }
7589
7590 #[tokio::test]
7591 async fn aud_match_always_accepts() {
7592 let kid = "test-audience-aud-match";
7593 let (pem, jwks) = generate_test_keypair(kid);
7594
7595 let mock_server = wiremock::MockServer::start().await;
7596 wiremock::Mock::given(wiremock::matchers::method("GET"))
7597 .and(wiremock::matchers::path("/jwks.json"))
7598 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7599 .mount(&mock_server)
7600 .await;
7601
7602 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7603 let config = test_config(&jwks_uri); let cache = test_cache(&config);
7605
7606 let now = jsonwebtoken::get_current_timestamp();
7607 let token = mint_token_with_claims(
7608 &pem,
7609 kid,
7610 &serde_json::json!({
7611 "iss": "https://auth.test.local",
7612 "aud": "https://mcp.test.local/mcp",
7613 "sub": "aud-match-client",
7614 "scope": "mcp:read",
7615 "exp": now + 3600,
7616 "iat": now,
7617 }),
7618 );
7619
7620 cache
7621 .validate_token_with_reason(&token)
7622 .await
7623 .expect("a matching aud must be accepted even under the Strict default");
7624 }
7625
7626 #[tokio::test]
7627 async fn strict_audience_validation_rejects_azp_only_match() {
7628 let kid = "test-audience-azp-strict";
7629 let (pem, jwks) = generate_test_keypair(kid);
7630
7631 let mock_server = wiremock::MockServer::start().await;
7632 wiremock::Mock::given(wiremock::matchers::method("GET"))
7633 .and(wiremock::matchers::path("/jwks.json"))
7634 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7635 .mount(&mock_server)
7636 .await;
7637
7638 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7639 let mut config = test_config(&jwks_uri);
7640 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
7641 {
7642 config.strict_audience_validation = Some(true);
7643 }
7644 let cache = test_cache(&config);
7645
7646 let now = jsonwebtoken::get_current_timestamp();
7647 let token = mint_token_with_claims(
7648 &pem,
7649 kid,
7650 &serde_json::json!({
7651 "iss": "https://auth.test.local",
7652 "aud": "https://some-other-resource.example.com",
7653 "azp": "https://mcp.test.local/mcp",
7654 "sub": "strict-client",
7655 "scope": "mcp:read",
7656 "exp": now + 3600,
7657 "iat": now,
7658 }),
7659 );
7660
7661 let failure = cache
7662 .validate_token_with_reason(&token)
7663 .await
7664 .expect_err("strict audience validation must ignore azp fallback");
7665 assert_eq!(failure, JwtValidationFailure::Invalid);
7666 }
7667
7668 #[tokio::test]
7669 async fn warn_mode_accepts_azp_only_match_and_warns_once() {
7670 let kid = "test-audience-warn-mode";
7671 let (pem, jwks) = generate_test_keypair(kid);
7672
7673 let mock_server = wiremock::MockServer::start().await;
7674 wiremock::Mock::given(wiremock::matchers::method("GET"))
7675 .and(wiremock::matchers::path("/jwks.json"))
7676 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7677 .mount(&mock_server)
7678 .await;
7679
7680 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7681 let mut config = test_config(&jwks_uri);
7682 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7683 let cache = test_cache(&config);
7684
7685 let now = jsonwebtoken::get_current_timestamp();
7686 let claims = serde_json::json!({
7687 "iss": "https://auth.test.local",
7688 "aud": "https://some-other-resource.example.com",
7689 "azp": "https://mcp.test.local/mcp",
7690 "sub": "warn-client",
7691 "scope": "mcp:read",
7692 "exp": now + 3600,
7693 "iat": now,
7694 });
7695 let token = mint_token_with_claims(&pem, kid, &claims);
7696
7697 let identity = cache
7698 .validate_token_with_reason(&token)
7699 .await
7700 .expect("warn mode must accept azp-only match");
7701 assert_eq!(identity.role, "viewer");
7702 assert!(
7703 cache.azp_fallback_warned.load(Ordering::Relaxed),
7704 "warn-once flag should be set after first azp-only match"
7705 );
7706
7707 let token2 = mint_token_with_claims(&pem, kid, &claims);
7708 cache
7709 .validate_token_with_reason(&token2)
7710 .await
7711 .expect("warn mode must continue accepting subsequent matches");
7712 assert!(
7713 cache.azp_fallback_warned.load(Ordering::Relaxed),
7714 "warn-once flag must remain set; the assertion guards against accidental clearing"
7715 );
7716 }
7717
7718 #[tokio::test]
7719 async fn permissive_mode_accepts_azp_only_match_silently() {
7720 let kid = "test-audience-permissive-mode";
7721 let (pem, jwks) = generate_test_keypair(kid);
7722
7723 let mock_server = wiremock::MockServer::start().await;
7724 wiremock::Mock::given(wiremock::matchers::method("GET"))
7725 .and(wiremock::matchers::path("/jwks.json"))
7726 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7727 .mount(&mock_server)
7728 .await;
7729
7730 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7731 let mut config = test_config(&jwks_uri);
7732 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7733 let cache = test_cache(&config);
7734
7735 let now = jsonwebtoken::get_current_timestamp();
7736 let token = mint_token_with_claims(
7737 &pem,
7738 kid,
7739 &serde_json::json!({
7740 "iss": "https://auth.test.local",
7741 "aud": "https://some-other-resource.example.com",
7742 "azp": "https://mcp.test.local/mcp",
7743 "sub": "permissive-client",
7744 "scope": "mcp:read",
7745 "exp": now + 3600,
7746 "iat": now,
7747 }),
7748 );
7749
7750 cache
7751 .validate_token_with_reason(&token)
7752 .await
7753 .expect("permissive mode must accept azp-only match");
7754 assert!(
7755 !cache.azp_fallback_warned.load(Ordering::Relaxed),
7756 "permissive mode must not flip the warn-once flag"
7757 );
7758 assert!(
7759 cache.azp_permissive_logged.load(Ordering::Relaxed),
7760 "permissive mode must record its own once-per-process log flag"
7761 );
7762 }
7763
7764 #[test]
7765 fn audience_validation_mode_overrides_legacy_bool() {
7766 let mut config = OAuthConfig::default();
7767 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7768 {
7769 config.strict_audience_validation = Some(false);
7770 }
7771 config.audience_validation_mode = Some(AudienceValidationMode::Strict);
7772 assert_eq!(
7773 config.effective_audience_validation_mode(),
7774 AudienceValidationMode::Strict,
7775 "explicit mode must override legacy false"
7776 );
7777
7778 let mut config = OAuthConfig::default();
7779 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7780 {
7781 config.strict_audience_validation = Some(true);
7782 }
7783 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7784 assert_eq!(
7785 config.effective_audience_validation_mode(),
7786 AudienceValidationMode::Permissive,
7787 "explicit mode must override legacy true"
7788 );
7789 }
7790
7791 #[test]
7792 fn audience_validation_mode_default_is_strict_when_unset() {
7793 let config = OAuthConfig::default();
7794 assert_eq!(
7795 config.effective_audience_validation_mode(),
7796 AudienceValidationMode::Strict,
7797 "unset mode + unset bool must resolve to Strict (the secure default)"
7798 );
7799 }
7800
7801 #[test]
7802 fn audience_validation_legacy_bool_true_resolves_to_strict() {
7803 let mut config = OAuthConfig::default();
7804 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
7805 {
7806 config.strict_audience_validation = Some(true);
7807 }
7808 assert_eq!(
7809 config.effective_audience_validation_mode(),
7810 AudienceValidationMode::Strict,
7811 "legacy bool=true must resolve to Strict for backward compat"
7812 );
7813 }
7814
7815 #[derive(Clone, Default)]
7816 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
7817
7818 impl CapturedLogs {
7819 fn contents(&self) -> String {
7820 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
7821 String::from_utf8(bytes).unwrap_or_default()
7822 }
7823 }
7824
7825 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
7826
7827 impl std::io::Write for CapturedLogsWriter {
7828 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
7829 if let Ok(mut guard) = self.0.lock() {
7830 guard.extend_from_slice(buf);
7831 }
7832 Ok(buf.len())
7833 }
7834
7835 fn flush(&mut self) -> std::io::Result<()> {
7836 Ok(())
7837 }
7838 }
7839
7840 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
7841 type Writer = CapturedLogsWriter;
7842
7843 fn make_writer(&'a self) -> Self::Writer {
7844 CapturedLogsWriter(Arc::clone(&self.0))
7845 }
7846 }
7847
7848 fn exchanged_token_for_debug(secret: &str) -> ExchangedToken {
7849 ExchangedToken {
7850 access_token: secret.to_owned(),
7851 expires_in: Some(3600),
7852 issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".to_owned()),
7853 }
7854 }
7855
7856 fn exchanged_jwt_with_sensitive_claims() -> ExchangedToken {
7857 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
7858 let payload = URL_SAFE_NO_PAD.encode(
7859 br#"{"sub":"subject-secret","aud":["aud-secret"],"azp":"azp-secret","iss":"issuer-secret"}"#,
7860 );
7861 exchanged_token_for_debug(&format!("{header}.{payload}.signature"))
7862 }
7863
7864 #[test]
7865 fn exchanged_token_debug_redacts_access_token_by_default() {
7866 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7867 crate::diagnostics::set_diagnostic_exposure(
7868 &crate::diagnostics::DiagnosticExposure::default(),
7869 );
7870 let secret = "oauth-access-token-secret";
7871
7872 let rendered = format!("{:?}", exchanged_token_for_debug(secret));
7873
7874 assert!(rendered.contains("[REDACTED]"));
7875 assert!(
7876 !rendered.contains(secret),
7877 "Debug output must not contain plaintext access token: {rendered}"
7878 );
7879 assert!(rendered.contains("expires_in"));
7880 assert!(rendered.contains("issued_token_type"));
7881 }
7882
7883 #[test]
7884 fn exchanged_token_debug_can_show_access_token_when_enabled() {
7885 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7886 crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
7887 plaintext_oauth_tokens: true,
7888 ..crate::diagnostics::DiagnosticExposure::default()
7889 });
7890 let secret = "oauth-access-token-secret";
7891
7892 let rendered = format!("{:?}", exchanged_token_for_debug(secret));
7893
7894 assert!(rendered.contains(secret));
7895 }
7896
7897 #[test]
7898 fn exchanged_token_claim_log_redacts_claim_values_by_default() {
7899 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7900 crate::diagnostics::set_diagnostic_exposure(
7901 &crate::diagnostics::DiagnosticExposure::default(),
7902 );
7903 let logs = CapturedLogs::default();
7904 let subscriber = tracing_subscriber::fmt()
7905 .with_max_level(tracing::Level::DEBUG)
7906 .with_writer(logs.clone())
7907 .with_ansi(false)
7908 .without_time()
7909 .finish();
7910 let _subscriber_guard = tracing::subscriber::set_default(subscriber);
7911
7912 log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
7913
7914 let contents = logs.contents();
7915 assert!(contents.contains("[REDACTED]"));
7916 for secret in [
7917 "subject-secret",
7918 "aud-secret",
7919 "azp-secret",
7920 "issuer-secret",
7921 ] {
7922 assert!(
7923 !contents.contains(secret),
7924 "claim log must not contain {secret}: {contents}"
7925 );
7926 }
7927 assert!(contents.contains("expires_in"));
7928 }
7929
7930 #[test]
7931 fn exchanged_token_claim_log_can_show_claim_values_when_enabled() {
7932 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7933 crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
7934 oauth_claim_values: true,
7935 ..crate::diagnostics::DiagnosticExposure::default()
7936 });
7937 let logs = CapturedLogs::default();
7938 let subscriber = tracing_subscriber::fmt()
7939 .with_max_level(tracing::Level::DEBUG)
7940 .with_writer(logs.clone())
7941 .with_ansi(false)
7942 .without_time()
7943 .finish();
7944 let _subscriber_guard = tracing::subscriber::set_default(subscriber);
7945
7946 log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
7947
7948 let contents = logs.contents();
7949 for secret in [
7950 "subject-secret",
7951 "aud-secret",
7952 "azp-secret",
7953 "issuer-secret",
7954 ] {
7955 assert!(
7956 contents.contains(secret),
7957 "claim log must contain {secret} when enabled: {contents}"
7958 );
7959 }
7960 }
7961
7962 #[tokio::test]
7963 async fn jwks_response_size_cap_returns_none_and_logs_warning() {
7964 let kid = "oversized-jwks";
7965 let (_pem, jwks) = generate_test_keypair(kid);
7966 let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
7967 oversized_body.push_str(&" ".repeat(4096));
7968
7969 let mock_server = wiremock::MockServer::start().await;
7970 wiremock::Mock::given(wiremock::matchers::method("GET"))
7971 .and(wiremock::matchers::path("/jwks.json"))
7972 .respond_with(
7973 wiremock::ResponseTemplate::new(200)
7974 .insert_header("content-type", "application/json")
7975 .set_body_string(oversized_body),
7976 )
7977 .mount(&mock_server)
7978 .await;
7979
7980 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7981 let mut config = test_config(&jwks_uri);
7982 config.jwks_max_response_bytes = 256;
7983 let cache = test_cache(&config);
7984
7985 let logs = CapturedLogs::default();
7986 let subscriber = tracing_subscriber::fmt()
7987 .with_writer(logs.clone())
7988 .with_ansi(false)
7989 .without_time()
7990 .finish();
7991 let _guard = tracing::subscriber::set_default(subscriber);
7992
7993 let result = cache.fetch_jwks().await;
7994 assert!(result.is_none(), "oversized JWKS must be dropped");
7995 assert!(
7996 logs.contents()
7997 .contains("JWKS response exceeded configured size cap"),
7998 "expected cap-exceeded warning in logs"
7999 );
8000 }
8001
8002 #[tokio::test]
8006 async fn redirect_rejection_log_does_not_echo_credentials() {
8007 let mock_server = wiremock::MockServer::start().await;
8008 wiremock::Mock::given(wiremock::matchers::method("GET"))
8009 .and(wiremock::matchers::path("/jwks.json"))
8010 .respond_with(
8011 wiremock::ResponseTemplate::new(302)
8012 .insert_header("location", "https://u:p@redirect-target.example/next"),
8013 )
8014 .mount(&mock_server)
8015 .await;
8016
8017 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8018 let config = test_config(&jwks_uri);
8019 let cache = test_cache(&config);
8020
8021 let logs = CapturedLogs::default();
8022 let subscriber = tracing_subscriber::fmt()
8023 .with_writer(logs.clone())
8024 .with_ansi(false)
8025 .without_time()
8026 .finish();
8027 let _guard = tracing::subscriber::set_default(subscriber);
8028
8029 let result = cache.fetch_jwks().await;
8030 assert!(result.is_none(), "rejected redirect must fail the fetch");
8031 let contents = logs.contents();
8032 assert!(
8033 contents.contains("oauth redirect rejected"),
8034 "expected redirect-rejection warning in logs: {contents}"
8035 );
8036 assert!(
8037 !contents.contains("u:p"),
8038 "rejection log must not echo userinfo credentials: {contents}"
8039 );
8040 }
8041
8042 #[tokio::test]
8043 async fn jwks_fetch_failure_log_sanitizes_url_and_reqwest_error() {
8044 let config = test_config("http://127.0.0.1:1/jwks.json?client_secret=super-secret");
8045 let cache = test_cache(&config);
8046
8047 let logs = CapturedLogs::default();
8048 let subscriber = tracing_subscriber::fmt()
8049 .with_max_level(tracing::Level::WARN)
8050 .with_writer(logs.clone())
8051 .with_ansi(false)
8052 .without_time()
8053 .finish();
8054 let _guard = tracing::subscriber::set_default(subscriber);
8055
8056 let result = cache.fetch_jwks().await;
8057 assert!(
8058 result.is_none(),
8059 "closed loopback port must fail JWKS fetch"
8060 );
8061 let contents = logs.contents();
8062 assert!(
8063 contents.contains("failed to fetch JWKS"),
8064 "JWKS failure must still be logged: {contents}"
8065 );
8066 assert!(
8067 contents.contains("uri=http://127.0.0.1:1"),
8068 "JWKS failure log must include only sanitized origin: {contents}"
8069 );
8070 for leaked in ["/jwks.json", "client_secret", "super-secret"] {
8071 assert!(
8072 !contents.contains(leaked),
8073 "JWKS failure log must not echo raw URL component {leaked}: {contents}"
8074 );
8075 }
8076 }
8077
8078 #[tokio::test]
8079 async fn role_claim_keycloak_nested_array() {
8080 let kid = "test-role-1";
8081 let (pem, jwks) = generate_test_keypair(kid);
8082
8083 let mock_server = wiremock::MockServer::start().await;
8084 wiremock::Mock::given(wiremock::matchers::method("GET"))
8085 .and(wiremock::matchers::path("/jwks.json"))
8086 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8087 .mount(&mock_server)
8088 .await;
8089
8090 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8091 let config = test_config_with_role_claim(
8092 &jwks_uri,
8093 "realm_access.roles",
8094 vec![
8095 RoleMapping {
8096 claim_value: "mcp-admin".into(),
8097 role: "ops".into(),
8098 },
8099 RoleMapping {
8100 claim_value: "mcp-viewer".into(),
8101 role: "viewer".into(),
8102 },
8103 ],
8104 );
8105 let cache = test_cache(&config);
8106
8107 let now = jsonwebtoken::get_current_timestamp();
8108 let token = mint_token_with_claims(
8109 &pem,
8110 kid,
8111 &serde_json::json!({
8112 "iss": "https://auth.test.local",
8113 "aud": "https://mcp.test.local/mcp",
8114 "sub": "keycloak-user",
8115 "exp": now + 3600,
8116 "iat": now,
8117 "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
8118 }),
8119 );
8120
8121 let id = cache
8122 .validate_token(&token)
8123 .await
8124 .expect("should authenticate");
8125 assert_eq!(id.name, "keycloak-user");
8126 assert_eq!(id.role, "ops");
8127 }
8128
8129 #[tokio::test]
8130 async fn role_claim_flat_roles_array() {
8131 let kid = "test-role-2";
8132 let (pem, jwks) = generate_test_keypair(kid);
8133
8134 let mock_server = wiremock::MockServer::start().await;
8135 wiremock::Mock::given(wiremock::matchers::method("GET"))
8136 .and(wiremock::matchers::path("/jwks.json"))
8137 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8138 .mount(&mock_server)
8139 .await;
8140
8141 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8142 let config = test_config_with_role_claim(
8143 &jwks_uri,
8144 "roles",
8145 vec![
8146 RoleMapping {
8147 claim_value: "MCP.Admin".into(),
8148 role: "ops".into(),
8149 },
8150 RoleMapping {
8151 claim_value: "MCP.Reader".into(),
8152 role: "viewer".into(),
8153 },
8154 ],
8155 );
8156 let cache = test_cache(&config);
8157
8158 let now = jsonwebtoken::get_current_timestamp();
8159 let token = mint_token_with_claims(
8160 &pem,
8161 kid,
8162 &serde_json::json!({
8163 "iss": "https://auth.test.local",
8164 "aud": "https://mcp.test.local/mcp",
8165 "sub": "azure-ad-user",
8166 "exp": now + 3600,
8167 "iat": now,
8168 "roles": ["MCP.Reader", "OtherApp.Admin"]
8169 }),
8170 );
8171
8172 let id = cache
8173 .validate_token(&token)
8174 .await
8175 .expect("should authenticate");
8176 assert_eq!(id.name, "azure-ad-user");
8177 assert_eq!(id.role, "viewer");
8178 }
8179
8180 #[tokio::test]
8181 async fn role_claim_no_matching_value_rejected() {
8182 let kid = "test-role-3";
8183 let (pem, jwks) = generate_test_keypair(kid);
8184
8185 let mock_server = wiremock::MockServer::start().await;
8186 wiremock::Mock::given(wiremock::matchers::method("GET"))
8187 .and(wiremock::matchers::path("/jwks.json"))
8188 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8189 .mount(&mock_server)
8190 .await;
8191
8192 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8193 let config = test_config_with_role_claim(
8194 &jwks_uri,
8195 "roles",
8196 vec![RoleMapping {
8197 claim_value: "mcp-admin".into(),
8198 role: "ops".into(),
8199 }],
8200 );
8201 let cache = test_cache(&config);
8202
8203 let now = jsonwebtoken::get_current_timestamp();
8204 let token = mint_token_with_claims(
8205 &pem,
8206 kid,
8207 &serde_json::json!({
8208 "iss": "https://auth.test.local",
8209 "aud": "https://mcp.test.local/mcp",
8210 "sub": "limited-user",
8211 "exp": now + 3600,
8212 "iat": now,
8213 "roles": ["some-other-role"]
8214 }),
8215 );
8216
8217 assert!(cache.validate_token(&token).await.is_none());
8218 }
8219
8220 #[tokio::test]
8221 async fn role_claim_space_separated_string() {
8222 let kid = "test-role-4";
8223 let (pem, jwks) = generate_test_keypair(kid);
8224
8225 let mock_server = wiremock::MockServer::start().await;
8226 wiremock::Mock::given(wiremock::matchers::method("GET"))
8227 .and(wiremock::matchers::path("/jwks.json"))
8228 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8229 .mount(&mock_server)
8230 .await;
8231
8232 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8233 let config = test_config_with_role_claim(
8234 &jwks_uri,
8235 "custom_scope",
8236 vec![
8237 RoleMapping {
8238 claim_value: "write".into(),
8239 role: "ops".into(),
8240 },
8241 RoleMapping {
8242 claim_value: "read".into(),
8243 role: "viewer".into(),
8244 },
8245 ],
8246 );
8247 let cache = test_cache(&config);
8248
8249 let now = jsonwebtoken::get_current_timestamp();
8250 let token = mint_token_with_claims(
8251 &pem,
8252 kid,
8253 &serde_json::json!({
8254 "iss": "https://auth.test.local",
8255 "aud": "https://mcp.test.local/mcp",
8256 "sub": "custom-client",
8257 "exp": now + 3600,
8258 "iat": now,
8259 "custom_scope": "read audit"
8260 }),
8261 );
8262
8263 let id = cache
8264 .validate_token(&token)
8265 .await
8266 .expect("should authenticate");
8267 assert_eq!(id.name, "custom-client");
8268 assert_eq!(id.role, "viewer");
8269 }
8270
8271 #[tokio::test]
8272 async fn scope_backward_compat_without_role_claim() {
8273 let kid = "test-compat-1";
8275 let (pem, jwks) = generate_test_keypair(kid);
8276
8277 let mock_server = wiremock::MockServer::start().await;
8278 wiremock::Mock::given(wiremock::matchers::method("GET"))
8279 .and(wiremock::matchers::path("/jwks.json"))
8280 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8281 .mount(&mock_server)
8282 .await;
8283
8284 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8285 let config = test_config(&jwks_uri); let cache = test_cache(&config);
8287
8288 let token = mint_token(
8289 &pem,
8290 kid,
8291 "https://auth.test.local",
8292 "https://mcp.test.local/mcp",
8293 "legacy-bot",
8294 "mcp:admin other:scope",
8295 );
8296
8297 let id = cache
8298 .validate_token(&token)
8299 .await
8300 .expect("should authenticate");
8301 assert_eq!(id.name, "legacy-bot");
8302 assert_eq!(id.role, "ops"); }
8304
8305 #[tokio::test]
8310 async fn jwks_refresh_deduplication() {
8311 let kid = "test-dedup";
8314 let (pem, jwks) = generate_test_keypair(kid);
8315
8316 let mock_server = wiremock::MockServer::start().await;
8317 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8318 .and(wiremock::matchers::path("/jwks.json"))
8319 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8320 .expect(1) .mount(&mock_server)
8322 .await;
8323
8324 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8325 let config = test_config(&jwks_uri);
8326 let cache = Arc::new(test_cache(&config));
8327
8328 let token = mint_token(
8330 &pem,
8331 kid,
8332 "https://auth.test.local",
8333 "https://mcp.test.local/mcp",
8334 "concurrent-bot",
8335 "mcp:read",
8336 );
8337
8338 let mut handles = Vec::new();
8339 for _ in 0..5 {
8340 let c = Arc::clone(&cache);
8341 let t = token.clone();
8342 handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
8343 }
8344
8345 for h in handles {
8346 let result = h.await.unwrap();
8347 assert!(result.is_some(), "all concurrent requests should succeed");
8348 }
8349
8350 }
8352
8353 #[tokio::test]
8354 async fn jwks_refresh_cooldown_blocks_rapid_requests() {
8355 let kid = "test-cooldown";
8358 let (_pem, jwks) = generate_test_keypair(kid);
8359
8360 let mock_server = wiremock::MockServer::start().await;
8361 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8362 .and(wiremock::matchers::path("/jwks.json"))
8363 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8364 .expect(1) .mount(&mock_server)
8366 .await;
8367
8368 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8369 let config = test_config(&jwks_uri);
8370 let cache = test_cache(&config);
8371
8372 let fake_token1 =
8374 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
8375 let _ = cache.validate_token(fake_token1).await;
8376
8377 let fake_token2 =
8380 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
8381 let _ = cache.validate_token(fake_token2).await;
8382
8383 let fake_token3 =
8385 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
8386 let _ = cache.validate_token(fake_token3).await;
8387
8388 }
8390
8391 fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
8394 OAuthProxyConfig {
8395 authorize_url: "https://example.invalid/auth".into(),
8396 token_url: token_url.into(),
8397 client_id: "mcp-client".into(),
8398 client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
8399 introspection_url: None,
8400 revocation_url: None,
8401 expose_admin_endpoints: false,
8402 require_auth_on_admin_endpoints: false,
8403 allow_unauthenticated_admin_endpoints: false,
8404 strip_resource_param: false,
8405 }
8406 }
8407
8408 fn test_http_client() -> OauthHttpClient {
8411 rustls::crypto::ring::default_provider()
8412 .install_default()
8413 .ok();
8414 let config = OAuthConfig::builder(
8415 "https://auth.test.local",
8416 "https://mcp.test.local/mcp",
8417 "https://auth.test.local/.well-known/jwks.json",
8418 )
8419 .allow_http_oauth_urls(true)
8420 .build();
8421 OauthHttpClient::with_config(&config)
8422 .expect("build test http client")
8423 .__test_allow_loopback_ssrf()
8424 }
8425
8426 #[tokio::test]
8427 async fn introspect_proxies_and_injects_client_credentials() {
8428 use wiremock::matchers::{body_string_contains, method, path};
8429
8430 let mock_server = wiremock::MockServer::start().await;
8431 wiremock::Mock::given(method("POST"))
8432 .and(path("/introspect"))
8433 .and(body_string_contains("client_id=mcp-client"))
8434 .and(body_string_contains("client_secret=shh"))
8435 .and(body_string_contains("token=abc"))
8436 .respond_with(
8437 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8438 "active": true,
8439 "scope": "read"
8440 })),
8441 )
8442 .expect(1)
8443 .mount(&mock_server)
8444 .await;
8445
8446 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8447 proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
8448
8449 let http = test_http_client();
8450 let resp = handle_introspect(&http, &proxy, "token=abc").await;
8451 assert_eq!(resp.status(), 200);
8452 }
8453
8454 #[tokio::test]
8455 async fn token_proxy_fails_closed_on_oversized_upstream_response() {
8456 use http_body_util::BodyExt as _;
8457 use wiremock::matchers::{method, path};
8458
8459 let oversized = "x"
8461 .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
8462 let mock_server = wiremock::MockServer::start().await;
8463 wiremock::Mock::given(method("POST"))
8464 .and(path("/token"))
8465 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
8466 .expect(1)
8467 .mount(&mock_server)
8468 .await;
8469
8470 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8471 let http = test_http_client();
8472 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8473
8474 assert_eq!(
8476 resp.status(),
8477 502,
8478 "oversized upstream response must fail closed as 502"
8479 );
8480 let body = resp
8481 .into_body()
8482 .collect()
8483 .await
8484 .expect("collect body")
8485 .to_bytes();
8486 assert!(
8487 body.len() < 1024,
8488 "must return the small generic error body, not the oversized upstream body (got {} bytes)",
8489 body.len()
8490 );
8491 assert!(
8492 !body.windows(8).any(|w| w == b"xxxxxxxx"),
8493 "the oversized upstream payload must not be forwarded to the client"
8494 );
8495 }
8496
8497 #[tokio::test]
8498 async fn token_proxy_passes_through_normal_response() {
8499 use http_body_util::BodyExt as _;
8500 use wiremock::matchers::{method, path};
8501
8502 let mock_server = wiremock::MockServer::start().await;
8503 wiremock::Mock::given(method("POST"))
8504 .and(path("/token"))
8505 .respond_with(
8506 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8507 "access_token": "at-123",
8508 "token_type": "Bearer"
8509 })),
8510 )
8511 .expect(1)
8512 .mount(&mock_server)
8513 .await;
8514
8515 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8516 let http = test_http_client();
8517 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8518
8519 assert_eq!(
8520 resp.status(),
8521 200,
8522 "a normal-sized response must pass through"
8523 );
8524 let body = resp
8525 .into_body()
8526 .collect()
8527 .await
8528 .expect("collect body")
8529 .to_bytes();
8530 let json: serde_json::Value =
8531 serde_json::from_slice(&body).expect("upstream JSON preserved");
8532 assert_eq!(json["access_token"], "at-123");
8533 }
8534
8535 #[tokio::test]
8536 async fn introspect_returns_404_when_not_configured() {
8537 let proxy = proxy_cfg("https://example.invalid/token");
8538 let http = test_http_client();
8539 let resp = handle_introspect(&http, &proxy, "token=abc").await;
8540 assert_eq!(resp.status(), 404);
8541 }
8542
8543 #[tokio::test]
8544 async fn revoke_proxies_and_returns_upstream_status() {
8545 use wiremock::matchers::{method, path};
8546
8547 let mock_server = wiremock::MockServer::start().await;
8548 wiremock::Mock::given(method("POST"))
8549 .and(path("/revoke"))
8550 .respond_with(wiremock::ResponseTemplate::new(200))
8551 .expect(1)
8552 .mount(&mock_server)
8553 .await;
8554
8555 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8556 proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
8557
8558 let http = test_http_client();
8559 let resp = handle_revoke(&http, &proxy, "token=abc").await;
8560 assert_eq!(resp.status(), 200);
8561 }
8562
8563 #[tokio::test]
8564 async fn revoke_returns_404_when_not_configured() {
8565 let proxy = proxy_cfg("https://example.invalid/token");
8566 let http = test_http_client();
8567 let resp = handle_revoke(&http, &proxy, "token=abc").await;
8568 assert_eq!(resp.status(), 404);
8569 }
8570
8571 #[test]
8572 fn metadata_advertises_endpoints_only_when_configured() {
8573 let mut cfg = test_config("https://auth.test.local/jwks.json");
8574 let m = authorization_server_metadata("https://mcp.local", &cfg);
8576 assert!(m.get("introspection_endpoint").is_none());
8577 assert!(m.get("revocation_endpoint").is_none());
8578
8579 let mut proxy = proxy_cfg("https://upstream.local/token");
8582 proxy.introspection_url = Some("https://upstream.local/introspect".into());
8583 proxy.revocation_url = Some("https://upstream.local/revoke".into());
8584 cfg.proxy = Some(proxy);
8585 let m = authorization_server_metadata("https://mcp.local", &cfg);
8586 assert!(
8587 m.get("introspection_endpoint").is_none(),
8588 "introspection must not be advertised when expose_admin_endpoints=false"
8589 );
8590 assert!(
8591 m.get("revocation_endpoint").is_none(),
8592 "revocation must not be advertised when expose_admin_endpoints=false"
8593 );
8594
8595 if let Some(p) = cfg.proxy.as_mut() {
8597 p.expose_admin_endpoints = true;
8598 p.revocation_url = None;
8599 }
8600 let m = authorization_server_metadata("https://mcp.local", &cfg);
8601 assert_eq!(
8602 m["introspection_endpoint"],
8603 serde_json::Value::String("https://mcp.local/introspect".into())
8604 );
8605 assert!(m.get("revocation_endpoint").is_none());
8606
8607 if let Some(p) = cfg.proxy.as_mut() {
8609 p.revocation_url = Some("https://upstream.local/revoke".into());
8610 }
8611 let m = authorization_server_metadata("https://mcp.local", &cfg);
8612 assert_eq!(
8613 m["revocation_endpoint"],
8614 serde_json::Value::String("https://mcp.local/revoke".into())
8615 );
8616 }
8617
8618 fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
8621 let mut cfg = validation_https_config();
8622 cfg.token_exchange = Some(tx);
8623 cfg
8624 }
8625
8626 fn tx_with(
8627 client_secret: Option<&str>,
8628 client_cert: Option<ClientCertConfig>,
8629 ) -> TokenExchangeConfig {
8630 TokenExchangeConfig::new(
8631 "https://idp.example.com/token",
8632 "client",
8633 client_secret.map(|s| secrecy::SecretString::new(s.into())),
8634 client_cert,
8635 )
8636 .with_audience("downstream")
8637 }
8638
8639 #[test]
8640 fn validate_rejects_non_uri_custom_requested_token_type() {
8641 for bad in ["acess_token", "not a uri", "urn:bad%zz:token"] {
8642 let tx = tx_with(Some("s"), None)
8643 .with_requested_token_type(RequestedTokenType::Custom(bad.to_owned()));
8644 let err = https_cfg_with_tx(tx)
8645 .validate()
8646 .expect_err("a custom token type that is not a URI must be rejected")
8647 .to_string();
8648 assert!(
8649 err.contains("requested_token_type"),
8650 "error must name the offending field for {bad:?}; got {err:?}"
8651 );
8652 }
8653 }
8654
8655 #[test]
8656 fn validate_accepts_uri_custom_requested_token_type_including_fragments() {
8657 for good in [
8658 "urn:ietf:params:oauth:token-type:saml2",
8659 "https://vendor.example/token-type",
8660 "urn:example:token#v2",
8661 ] {
8662 let tx = tx_with(Some("s"), None)
8663 .with_requested_token_type(RequestedTokenType::Custom(good.to_owned()));
8664 https_cfg_with_tx(tx).validate().unwrap_or_else(|e| {
8665 panic!(
8666 "RFC 8693 §3 only requires a URI; {good:?} must be accepted \
8667 (the no-fragment rule is RFC 8707's, for `resource` only): {e}"
8668 )
8669 });
8670 }
8671 }
8672
8673 #[test]
8674 fn validate_rejects_empty_optional_token_exchange_params() {
8675 let base = || tx_with(Some("s"), None);
8676 let cases = [
8677 (base().with_audience(""), "audience"),
8678 (base().with_resource(""), "resource"),
8679 (base().with_scope(""), "scope"),
8680 (
8681 base().with_requested_token_type(RequestedTokenType::Custom(String::new())),
8682 "requested_token_type",
8683 ),
8684 ];
8685 for (tx, field) in cases {
8686 let cfg = https_cfg_with_tx(tx);
8687 let err = cfg
8688 .validate()
8689 .expect_err("an empty optional parameter must be rejected");
8690 let msg = err.to_string();
8691 assert!(
8692 msg.contains(field) && msg.contains("must not be empty"),
8693 "error must name {field} and explain emptiness; got {msg:?}"
8694 );
8695 }
8696 }
8697
8698 #[test]
8699 fn validate_rejects_non_conformant_resource_uri() {
8700 for (value, expected) in [
8701 ("not-an-absolute-uri", "absolute URI"),
8702 ("https://api.example.com/v1#frag", "fragment"),
8703 ("https://api.example.com/a b", "valid URI characters"),
8704 ("https://api.example.com/%zz", "valid URI characters"),
8705 ("https://api.example.com/\u{e9}", "valid URI characters"),
8706 ] {
8707 let cfg = https_cfg_with_tx(tx_with(Some("s"), None).with_resource(value));
8708 let err = cfg
8709 .validate()
8710 .expect_err("resource must satisfy RFC 8707 §2");
8711 let msg = err.to_string();
8712 assert!(
8713 msg.contains(expected),
8714 "error for {value:?} must mention {expected:?}; got {msg:?}"
8715 );
8716 }
8717 }
8718
8719 #[test]
8720 fn validate_accepts_token_exchange_with_all_optional_params_omitted() {
8721 let mut tx = tx_with(Some("s"), None);
8722 tx.audience = None;
8723 tx.requested_token_type = RequestedTokenType::Omit;
8724 https_cfg_with_tx(tx)
8725 .validate()
8726 .expect("omitting every RFC 8693 §2.1 OPTIONAL parameter must be valid");
8727 }
8728
8729 #[test]
8730 fn validate_rejects_token_exchange_without_client_auth() {
8731 let cfg = https_cfg_with_tx(tx_with(None, None));
8732 let err = cfg
8733 .validate()
8734 .expect_err("token_exchange without client auth must be rejected");
8735 let msg = err.to_string();
8736 assert!(
8737 msg.contains("requires client authentication"),
8738 "error must explain missing client auth; got {msg:?}"
8739 );
8740 }
8741
8742 #[test]
8743 fn validate_rejects_token_exchange_with_both_secret_and_cert() {
8744 let cc = ClientCertConfig {
8745 cert_path: PathBuf::from("/nonexistent/cert.pem"),
8746 key_path: PathBuf::from("/nonexistent/key.pem"),
8747 };
8748 let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
8749 let err = cfg
8750 .validate()
8751 .expect_err("client_secret + client_cert must be rejected");
8752 let msg = err.to_string();
8753 assert!(
8754 msg.contains("mutually") && msg.contains("exclusive"),
8755 "error must explain mutual exclusion; got {msg:?}"
8756 );
8757 }
8758
8759 #[cfg(not(feature = "oauth-mtls-client"))]
8760 #[test]
8761 fn validate_rejects_client_cert_without_feature() {
8762 let cc = ClientCertConfig {
8763 cert_path: PathBuf::from("/nonexistent/cert.pem"),
8764 key_path: PathBuf::from("/nonexistent/key.pem"),
8765 };
8766 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8767 let err = cfg
8768 .validate()
8769 .expect_err("client_cert without feature must be rejected");
8770 assert!(
8771 err.to_string().contains("oauth-mtls-client"),
8772 "error must reference the cargo feature; got {err}"
8773 );
8774 }
8775
8776 #[cfg(feature = "oauth-mtls-client")]
8777 #[test]
8778 fn validate_rejects_missing_client_cert_files() {
8779 let cc = ClientCertConfig {
8780 cert_path: PathBuf::from("/nonexistent/cert.pem"),
8781 key_path: PathBuf::from("/nonexistent/key.pem"),
8782 };
8783 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8784 let err = cfg
8785 .validate()
8786 .expect_err("missing cert file must be rejected");
8787 assert!(
8788 err.to_string().contains("unreadable"),
8789 "error must call out unreadable file; got {err}"
8790 );
8791 }
8792
8793 #[cfg(feature = "oauth-mtls-client")]
8794 #[test]
8795 fn validate_rejects_malformed_client_cert_pem() {
8796 let dir = std::env::temp_dir();
8797 let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
8798 let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
8799 std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
8800 std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
8801 let cc = ClientCertConfig {
8802 cert_path: cert.clone(),
8803 key_path: key.clone(),
8804 };
8805 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8806 let err = cfg.validate().expect_err("malformed PEM must be rejected");
8807 let _ = std::fs::remove_file(&cert);
8808 let _ = std::fs::remove_file(&key);
8809 assert!(
8810 err.to_string().contains("PEM parse failed"),
8811 "error must call out PEM parse failure; got {err}"
8812 );
8813 }
8814
8815 #[cfg(feature = "oauth-mtls-client")]
8816 fn write_self_signed_pem() -> (PathBuf, PathBuf) {
8817 let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
8818 let dir = std::env::temp_dir();
8819 let pid = std::process::id();
8820 let nonce: u64 = rand::random();
8821 let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
8822 let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
8823 std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
8824 std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
8825 (cert_path, key_path)
8826 }
8827
8828 #[cfg(feature = "oauth-mtls-client")]
8829 fn install_test_crypto_provider() {
8830 let _ = rustls::crypto::ring::default_provider().install_default();
8831 }
8832
8833 #[cfg(feature = "oauth-mtls-client")]
8834 #[test]
8835 fn validate_accepts_well_formed_client_cert() {
8836 install_test_crypto_provider();
8837 let (cert_path, key_path) = write_self_signed_pem();
8838 let cc = ClientCertConfig {
8839 cert_path: cert_path.clone(),
8840 key_path: key_path.clone(),
8841 };
8842 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8843 let res = cfg.validate();
8844 let _ = std::fs::remove_file(&cert_path);
8845 let _ = std::fs::remove_file(&key_path);
8846 res.expect("well-formed cert+key must validate");
8847 }
8848
8849 #[cfg(feature = "oauth-mtls-client")]
8850 #[test]
8851 fn client_for_returns_cached_mtls_client() {
8852 install_test_crypto_provider();
8853 let (cert_path, key_path) = write_self_signed_pem();
8854 let cc = ClientCertConfig {
8855 cert_path: cert_path.clone(),
8856 key_path: key_path.clone(),
8857 };
8858 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8859 let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
8860 let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
8861 let cert_client = http.client_for(tx_ref);
8862 let inner_client = http.client_for(&tx_with(Some("s"), None));
8863 let _ = std::fs::remove_file(&cert_path);
8864 let _ = std::fs::remove_file(&key_path);
8865 assert!(
8866 !std::ptr::eq(cert_client, inner_client),
8867 "client_for must return distinct clients for cert vs no-cert configs"
8868 );
8869 }
8870
8871 #[cfg(feature = "oauth-mtls-client")]
8872 #[test]
8873 fn client_for_falls_back_to_inner_when_cache_miss() {
8874 install_test_crypto_provider();
8875 let cfg = validation_https_config();
8876 let http = OauthHttpClient::with_config(&cfg).expect("build client");
8877 let unrelated_cc = ClientCertConfig {
8878 cert_path: PathBuf::from("/cache/miss/cert.pem"),
8879 key_path: PathBuf::from("/cache/miss/key.pem"),
8880 };
8881 let tx_unknown = tx_with(None, Some(unrelated_cc));
8882 let fallback = http.client_for(&tx_unknown);
8883 let inner = http.client_for(&tx_with(Some("s"), None));
8884 assert!(
8885 std::ptr::eq(fallback, inner),
8886 "cache miss must fall back to inner client"
8887 );
8888 }
8889}