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_deref())?;
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<&[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_deref())?,
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.as_deref().is_none_or(|s| s.trim().is_empty()) {
2765 core::hint::cold_path();
2766 tracing::debug!(
2767 "JWT rejected: require_subject is set but the token has no non-blank `sub`"
2768 );
2769 return Err(JwtValidationFailure::Invalid);
2770 }
2771 self.check_audience(&claims)?;
2772 let role = self.resolve_role(&claims)?;
2773
2774 let sub = claims.sub.filter(|value| !value.trim().is_empty());
2777
2778 let preferred_username = claims
2782 .extra
2783 .get("preferred_username")
2784 .and_then(|v| v.as_str())
2785 .filter(|s| !s.trim().is_empty())
2786 .map(String::from);
2787 let name = preferred_username
2788 .or_else(|| sub.clone())
2789 .or_else(|| claims.azp.filter(|s| !s.trim().is_empty()))
2790 .or_else(|| claims.client_id.filter(|s| !s.trim().is_empty()))
2791 .unwrap_or_else(|| "oauth-client".into());
2792
2793 Ok(AuthIdentity {
2794 name,
2795 role,
2796 method: AuthMethod::OAuthJwt,
2797 raw_token: None,
2798 sub,
2799 })
2800 }
2801
2802 async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2818 let (key, alg) = self.select_jwks_key(token).await?;
2819
2820 let mut validation = self.validation_template.clone();
2824 validation.algorithms = vec![alg];
2825
2826 let token_owned = token.to_owned();
2829 let join =
2830 tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2831 .await;
2832
2833 let decode_result = match join {
2834 Ok(r) => r,
2835 Err(join_err) => {
2836 core::hint::cold_path();
2837 tracing::error!(
2838 error = %join_err,
2839 "JWT decode task panicked or was cancelled"
2840 );
2841 return Err(JwtValidationFailure::Invalid);
2842 }
2843 };
2844
2845 decode_result.map(|td| td.claims).map_err(|e| {
2846 core::hint::cold_path();
2847 let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2848 JwtValidationFailure::Expired
2849 } else {
2850 JwtValidationFailure::Invalid
2851 };
2852 tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2853 failure
2854 })
2855 }
2856
2857 #[allow(
2870 clippy::cognitive_complexity,
2871 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"
2872 )]
2873 async fn select_jwks_key(
2874 &self,
2875 token: &str,
2876 ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2877 let Ok(header) = decode_header(token) else {
2878 core::hint::cold_path();
2879 tracing::debug!("JWT header decode failed");
2880 return Err(JwtValidationFailure::Invalid);
2881 };
2882 let kid = header.kid.as_deref();
2883 tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2884
2885 if !self.allowed_algorithms.contains(&header.alg) {
2886 core::hint::cold_path();
2887 tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2888 return Err(JwtValidationFailure::Invalid);
2889 }
2890
2891 let Some(key) = self.find_key(kid, header.alg).await else {
2892 core::hint::cold_path();
2893 tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2894 return Err(JwtValidationFailure::Invalid);
2895 };
2896
2897 Ok((key, header.alg))
2898 }
2899
2900 fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2909 if claims.aud.contains(&self.expected_audience) {
2910 return Ok(());
2911 }
2912 let azp_match = claims
2913 .azp
2914 .as_deref()
2915 .is_some_and(|azp| azp == self.expected_audience);
2916 if azp_match {
2917 match self.audience_mode {
2918 AudienceValidationMode::Permissive => {
2919 if !self.azp_permissive_logged.swap(true, Ordering::Relaxed) {
2920 tracing::info!(
2921 expected = %self.expected_audience,
2922 "JWT accepted via azp-only audience fallback because \
2923 audience_validation_mode = \"permissive\". Acceptance is \
2924 intentionally wider than the spec; set \"warn\" or \"strict\" \
2925 to tighten it. This message logs once per process."
2926 );
2927 }
2928 return Ok(());
2929 }
2930 AudienceValidationMode::Warn => {
2931 if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2932 tracing::warn!(
2933 expected = %self.expected_audience,
2934 azp = claims.azp.as_deref().unwrap_or("-"),
2935 "JWT accepted via deprecated azp-only audience fallback. \
2936 Configure your IdP to populate aud, or set \
2937 audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2938 To silence this warning without changing acceptance, \
2939 set audience_validation_mode = \"permissive\". \
2940 This warning logs once per process."
2941 );
2942 }
2943 return Ok(());
2944 }
2945 AudienceValidationMode::Strict => {}
2946 }
2947 }
2948 core::hint::cold_path();
2949 self.log_audience_mismatch(claims);
2950 Err(JwtValidationFailure::Invalid)
2951 }
2952
2953 fn log_audience_mismatch(&self, claims: &Claims) {
2960 let expose = crate::diagnostics::oauth_claim_values();
2961 let aud = if expose {
2962 claims.aud.log_display()
2963 } else {
2964 "[REDACTED]".to_owned()
2965 };
2966 let azp = if expose {
2967 claims.azp.as_deref().unwrap_or("-")
2968 } else {
2969 "[REDACTED]"
2970 };
2971 tracing::debug!(
2972 aud = %aud,
2973 azp = azp,
2974 expected = %self.expected_audience,
2975 mode = self.audience_mode.as_str(),
2976 "JWT rejected: audience mismatch"
2977 );
2978 }
2979
2980 fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2986 if let Some(ref claim_path) = self.role_claim {
2987 let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2988 let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2989 values.extend(resolve_claim_path(&claims.extra, claim_path));
2990 return self
2991 .role_mappings
2992 .iter()
2993 .find(|m| values.contains(&m.claim_value.as_str()))
2994 .map(|m| m.role.clone())
2995 .ok_or(JwtValidationFailure::Invalid);
2996 }
2997
2998 let token_scopes: Vec<&str> = claims
2999 .scope
3000 .as_deref()
3001 .unwrap_or("")
3002 .split_whitespace()
3003 .collect();
3004
3005 self.scopes
3006 .iter()
3007 .find(|m| token_scopes.contains(&m.scope.as_str()))
3008 .map(|m| m.role.clone())
3009 .ok_or(JwtValidationFailure::Invalid)
3010 }
3011
3012 async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3018 {
3020 let guard = self.inner.read().await;
3021 if let Some(cached) = guard.as_ref()
3022 && !cached.is_expired()
3023 && let Some(key) = lookup_key(cached, kid, alg)
3024 {
3025 return Some(key);
3026 }
3027 }
3028
3029 self.refresh_with_cooldown().await;
3031
3032 let guard = self.inner.read().await;
3038 guard
3039 .as_ref()
3040 .filter(|cached| !cached.is_expired())
3041 .and_then(|cached| lookup_key(cached, kid, alg))
3042 }
3043
3044 async fn refresh_with_cooldown(&self) {
3064 let _guard = self.refresh_lock.lock().await;
3066
3067 {
3069 let last = self.last_refresh_attempt.read().await;
3070 if let Some(ts) = *last
3071 && ts.elapsed() < JWKS_REFRESH_COOLDOWN
3072 {
3073 tracing::info!(
3074 elapsed_ms = ts.elapsed().as_millis(),
3075 cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
3076 "JWKS refresh skipped (cooldown active)"
3077 );
3078 return;
3079 }
3080 }
3081
3082 {
3085 let mut last = self.last_refresh_attempt.write().await;
3086 *last = Some(Instant::now());
3087 }
3088
3089 let _ = self.refresh_inner().await;
3091 }
3092
3093 async fn refresh_inner(&self) -> Result<(), String> {
3102 let Some(jwks) = self.fetch_jwks().await else {
3103 return Ok(());
3104 };
3105 let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
3106 Ok(cache) => cache,
3107 Err(msg) => {
3108 tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
3109 return Err(msg);
3110 }
3111 };
3112
3113 tracing::debug!(
3114 named = keys.len(),
3115 unnamed = unnamed_keys.len(),
3116 "JWKS refreshed"
3117 );
3118
3119 let mut guard = self.inner.write().await;
3120 *guard = Some(CachedKeys {
3121 keys,
3122 unnamed_keys,
3123 fetched_at: Instant::now(),
3124 ttl: self.ttl,
3125 });
3126 drop(guard);
3127 Ok(())
3128 }
3129
3130 #[allow(
3132 clippy::cognitive_complexity,
3133 reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
3134 )]
3135 async fn fetch_jwks(&self) -> Option<JwkSet> {
3139 #[cfg(any(test, feature = "test-helpers"))]
3140 let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
3141 screen_oauth_target_with_test_override(
3142 &self.jwks_uri,
3143 self.allow_http,
3144 &self.allowlist,
3145 true,
3146 )
3147 .await
3148 } else {
3149 screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
3150 };
3151 #[cfg(not(any(test, feature = "test-helpers")))]
3152 let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
3153
3154 if let Err(error) = screening {
3155 tracing::warn!(
3156 error = %error,
3157 uri = %oauth_request_target_for_log(&self.jwks_uri),
3158 "failed to screen JWKS target"
3159 );
3160 return None;
3161 }
3162
3163 let mut resp = match self.http.get(&self.jwks_uri).send().await {
3164 Ok(resp) => resp,
3165 Err(e) => {
3166 tracing::warn!(
3167 error = %e.without_url(),
3168 uri = %oauth_request_target_for_log(&self.jwks_uri),
3169 "failed to fetch JWKS"
3170 );
3171 return None;
3172 }
3173 };
3174
3175 let initial_capacity =
3176 usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3177 let mut body = Vec::with_capacity(initial_capacity);
3178 while let Some(chunk) = match resp.chunk().await {
3179 Ok(chunk) => chunk,
3180 Err(error) => {
3181 tracing::warn!(
3182 error = %error.without_url(),
3183 uri = %oauth_request_target_for_log(&self.jwks_uri),
3184 "failed to read JWKS response"
3185 );
3186 return None;
3187 }
3188 } {
3189 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3190 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3191 if body_len.saturating_add(chunk_len) > self.max_response_bytes {
3192 tracing::warn!(
3193 uri = %oauth_request_target_for_log(&self.jwks_uri),
3194 max_bytes = self.max_response_bytes,
3195 "JWKS response exceeded configured size cap"
3196 );
3197 return None;
3198 }
3199 body.extend_from_slice(&chunk);
3200 }
3201
3202 match serde_json::from_slice::<JwkSet>(&body) {
3203 Ok(jwks) => Some(jwks),
3204 Err(error) => {
3205 tracing::warn!(
3206 error = %error,
3207 uri = %oauth_request_target_for_log(&self.jwks_uri),
3208 "failed to parse JWKS"
3209 );
3210 None
3211 }
3212 }
3213 }
3214
3215 #[cfg(any(test, feature = "test-helpers"))]
3224 #[doc(hidden)]
3225 pub async fn __test_refresh_now(&self) -> Result<(), String> {
3226 let jwks = self
3227 .fetch_jwks()
3228 .await
3229 .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
3230 let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
3231 let mut guard = self.inner.write().await;
3232 *guard = Some(CachedKeys {
3233 keys,
3234 unnamed_keys,
3235 fetched_at: Instant::now(),
3236 ttl: self.ttl,
3237 });
3238 drop(guard);
3239 Ok(())
3240 }
3241
3242 #[cfg(any(test, feature = "test-helpers"))]
3245 #[doc(hidden)]
3246 pub async fn __test_has_kid(&self, kid: &str) -> bool {
3247 let guard = self.inner.read().await;
3248 guard
3249 .as_ref()
3250 .is_some_and(|cache| cache.keys.contains_key(kid))
3251 }
3252}
3253
3254const MAX_LOGGED_KID_CHARS: usize = 64;
3257
3258fn truncate_kid_for_log(kid: &str) -> (String, bool) {
3265 if kid.chars().count() <= MAX_LOGGED_KID_CHARS {
3266 return (kid.to_owned(), false);
3267 }
3268 let head: String = kid.chars().take(MAX_LOGGED_KID_CHARS).collect();
3269 (format!("{head}...(truncated)"), true)
3270}
3271
3272fn jwk_kid_for_log(jwk: &jsonwebtoken::jwk::Jwk) -> (String, bool) {
3274 jwk.common
3275 .key_id
3276 .as_deref()
3277 .map_or_else(|| ("<no-kid>".to_owned(), false), truncate_kid_for_log)
3278}
3279
3280fn classify_jwk(jwk: &jsonwebtoken::jwk::Jwk) -> Option<(JwkAlg, DecodingKey)> {
3286 if !jwk_permits_signature_verification(jwk) {
3287 let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3288 tracing::debug!(
3289 kid = %kid_log,
3290 kid_truncated,
3291 "skipping JWKS key not permitted for signature verification (use/key_ops)"
3292 );
3293 return None;
3294 }
3295 let decoding_key = DecodingKey::from_jwk(jwk).ok()?;
3296 let alg = jwk_algorithm(jwk)?;
3297 if let JwkAlg::Family(family) = alg {
3298 let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3299 tracing::debug!(
3300 kid = %kid_log,
3301 kid_truncated,
3302 family = ?family,
3303 "JWKS key omits `alg`; inferring permitted algorithms from key type (RFC 7517 4.4)"
3304 );
3305 }
3306 Some((alg, decoding_key))
3307}
3308
3309fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
3310 if jwks.keys.len() > max_keys {
3311 return Err(format!(
3312 "jwks_key_count_exceeds_cap: got {} keys, max is {}",
3313 jwks.keys.len(),
3314 max_keys
3315 ));
3316 }
3317 let mut keys = HashMap::new();
3318 let mut unnamed_keys = Vec::new();
3319 for jwk in &jwks.keys {
3320 let Some((alg, decoding_key)) = classify_jwk(jwk) else {
3321 continue;
3322 };
3323 if let Some(ref kid) = jwk.common.key_id {
3324 if keys.insert(kid.clone(), (alg, decoding_key)).is_some() {
3325 let (kid_log, kid_truncated) = truncate_kid_for_log(kid);
3326 tracing::warn!(
3327 kid = %kid_log,
3328 kid_truncated,
3329 "duplicate kid in JWKS; later entry wins"
3330 );
3331 }
3332 } else {
3333 unnamed_keys.push((alg, decoding_key));
3334 }
3335 }
3336 Ok((keys, unnamed_keys))
3337}
3338
3339fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3341 if let Some(kid) = kid {
3342 if let Some((cached_alg, key)) = cached.keys.get(kid)
3347 && cached_alg.accepts(alg)
3348 {
3349 return Some(key.clone());
3350 }
3351 return None;
3352 }
3353 cached
3355 .unnamed_keys
3356 .iter()
3357 .find(|(a, _)| a.accepts(alg))
3358 .map(|(_, k)| k.clone())
3359}
3360
3361fn jwk_permits_signature_verification(jwk: &jsonwebtoken::jwk::Jwk) -> bool {
3374 use jsonwebtoken::jwk::{KeyOperations, PublicKeyUse};
3375
3376 let use_ok = match jwk.common.public_key_use {
3377 None | Some(PublicKeyUse::Signature) => true,
3378 Some(PublicKeyUse::Encryption | PublicKeyUse::Other(_)) => false,
3379 };
3380 let ops_ok = jwk
3383 .common
3384 .key_operations
3385 .as_ref()
3386 .is_none_or(|ops| ops.contains(&KeyOperations::Verify));
3387
3388 use_ok && ops_ok
3389}
3390
3391fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkAlg> {
3398 match jwk.common.key_algorithm {
3399 Some(declared) => explicit_jwk_algorithm(declared).map(JwkAlg::Explicit),
3400 None => infer_jwk_family(jwk).map(JwkAlg::Family),
3401 }
3402}
3403
3404#[allow(
3406 clippy::wildcard_enum_match_arm,
3407 reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
3408)]
3409fn explicit_jwk_algorithm(declared: jsonwebtoken::jwk::KeyAlgorithm) -> Option<Algorithm> {
3410 match declared {
3411 jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
3412 jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
3413 jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
3414 jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
3415 jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
3416 jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
3417 jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
3418 jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
3419 jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
3420 _ => None,
3421 }
3422}
3423
3424#[allow(
3433 clippy::wildcard_enum_match_arm,
3434 reason = "jsonwebtoken AlgorithmParameters and EllipticCurve are both #[non_exhaustive] external enums, so an exhaustive match is impossible; unmatched variants must fail closed to None"
3435)]
3436fn infer_jwk_family(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkKeyFamily> {
3437 use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve};
3438
3439 match jwk.algorithm {
3440 AlgorithmParameters::RSA(_) => Some(JwkKeyFamily::Rsa),
3441 AlgorithmParameters::EllipticCurve(ref ec) => match ec.curve {
3442 EllipticCurve::P256 => Some(JwkKeyFamily::EcP256),
3443 EllipticCurve::P384 => Some(JwkKeyFamily::EcP384),
3444 _ => None,
3445 },
3446 AlgorithmParameters::OctetKeyPair(ref okp) => match okp.curve {
3447 EllipticCurve::Ed25519 => Some(JwkKeyFamily::Ed25519),
3448 _ => None,
3449 },
3450 _ => None,
3451 }
3452}
3453
3454fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
3475 match path {
3476 "sub" => claims.sub.iter().cloned().collect(),
3477 "azp" => claims.azp.iter().cloned().collect(),
3478 "client_id" => claims.client_id.iter().cloned().collect(),
3479 "aud" => claims.aud.0.clone(),
3480 "scope" => claims
3481 .scope
3482 .as_deref()
3483 .unwrap_or("")
3484 .split_whitespace()
3485 .map(str::to_owned)
3486 .collect(),
3487 _ => Vec::new(),
3488 }
3489}
3490
3491fn resolve_claim_path<'a>(
3501 extra: &'a HashMap<String, serde_json::Value>,
3502 path: &str,
3503) -> Vec<&'a str> {
3504 let mut segments = path.split('.');
3505 let Some(first) = segments.next() else {
3506 return Vec::new();
3507 };
3508
3509 let mut current: Option<&serde_json::Value> = extra.get(first);
3510
3511 for segment in segments {
3512 current = current.and_then(|v| v.get(segment));
3513 }
3514
3515 match current {
3516 Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
3517 Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
3518 _ => Vec::new(),
3519 }
3520}
3521
3522#[derive(Debug, Deserialize)]
3528struct Claims {
3529 sub: Option<String>,
3531 #[serde(default)]
3534 aud: OneOrMany,
3535 azp: Option<String>,
3537 client_id: Option<String>,
3539 scope: Option<String>,
3541 #[serde(flatten)]
3543 extra: HashMap<String, serde_json::Value>,
3544}
3545
3546#[derive(Debug, Default)]
3548struct OneOrMany(Vec<String>);
3549
3550impl OneOrMany {
3551 fn contains(&self, value: &str) -> bool {
3552 self.0.iter().any(|v| v == value)
3553 }
3554
3555 fn log_display(&self) -> String {
3559 if self.0.is_empty() {
3560 "-".to_owned()
3561 } else {
3562 self.0.join(", ")
3563 }
3564 }
3565}
3566
3567fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
3577 match value {
3578 Some(serde_json::Value::String(s)) => s.clone(),
3579 Some(serde_json::Value::Array(items)) => {
3580 let joined = items
3581 .iter()
3582 .filter_map(serde_json::Value::as_str)
3583 .collect::<Vec<_>>()
3584 .join(", ");
3585 if joined.is_empty() {
3586 "-".to_owned()
3587 } else {
3588 joined
3589 }
3590 }
3591 Some(
3592 serde_json::Value::Null
3593 | serde_json::Value::Bool(_)
3594 | serde_json::Value::Number(_)
3595 | serde_json::Value::Object(_),
3596 )
3597 | None => "-".to_owned(),
3598 }
3599}
3600
3601fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
3605 value.and_then(serde_json::Value::as_str).unwrap_or("-")
3606}
3607
3608impl<'de> Deserialize<'de> for OneOrMany {
3609 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
3610 use serde::de;
3611
3612 struct Visitor;
3613 impl<'de> de::Visitor<'de> for Visitor {
3614 type Value = OneOrMany;
3615 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3616 f.write_str("a string or array of strings")
3617 }
3618 fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
3619 Ok(OneOrMany(vec![v.to_owned()]))
3620 }
3621 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
3622 let mut v = Vec::new();
3623 while let Some(s) = seq.next_element::<String>()? {
3624 v.push(s);
3625 }
3626 Ok(OneOrMany(v))
3627 }
3628 }
3629 deserializer.deserialize_any(Visitor)
3630 }
3631}
3632
3633#[must_use]
3640pub fn looks_like_jwt(token: &str) -> bool {
3641 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3642
3643 let mut parts = token.splitn(4, '.');
3644 let Some(header_b64) = parts.next() else {
3645 return false;
3646 };
3647 if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
3649 return false;
3650 }
3651 let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
3653 return false;
3654 };
3655 let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
3657 return false;
3658 };
3659 header.get("alg").is_some()
3660}
3661
3662fn resolve_authorization_servers<'a>(server_url: &'a str, config: &'a OAuthConfig) -> Vec<&'a str> {
3671 if let Some(ref explicit) = config.authorization_servers {
3672 return explicit.iter().map(String::as_str).collect();
3673 }
3674 if config.proxy.is_some() {
3682 vec![server_url]
3683 } else {
3684 vec![config.issuer.as_str()]
3685 }
3686}
3687
3688#[must_use]
3694pub fn protected_resource_metadata(
3695 resource_url: &str,
3696 server_url: &str,
3697 config: &OAuthConfig,
3698) -> serde_json::Value {
3699 let mut meta = serde_json::json!({
3700 "resource": resource_url,
3701 "bearer_methods_supported": ["header"],
3702 });
3703 let Some(obj) = meta.as_object_mut() else {
3704 return meta;
3705 };
3706 let auth_servers = resolve_authorization_servers(server_url, config);
3708 if !auth_servers.is_empty() {
3709 obj.insert(
3710 "authorization_servers".into(),
3711 serde_json::json!(auth_servers),
3712 );
3713 }
3714 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3715 if !scopes.is_empty() {
3716 obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3717 }
3718 meta
3719}
3720
3721#[must_use]
3733pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
3734 let issuer = config
3735 .authorization_server_metadata_issuer
3736 .as_deref()
3737 .unwrap_or(server_url);
3738 let mut meta = serde_json::json!({
3739 "issuer": issuer,
3740 "authorization_endpoint": format!("{server_url}/authorize"),
3741 "token_endpoint": format!("{server_url}/token"),
3742 "registration_endpoint": format!("{server_url}/register"),
3743 "response_types_supported": ["code"],
3744 "grant_types_supported": ["authorization_code", "refresh_token"],
3745 "code_challenge_methods_supported": ["S256"],
3746 "token_endpoint_auth_methods_supported": ["none"],
3747 });
3748 let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3750 if !scopes.is_empty()
3751 && let Some(obj) = meta.as_object_mut()
3752 {
3753 obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3754 }
3755 if let Some(proxy) = &config.proxy
3756 && proxy.expose_admin_endpoints
3757 && let Some(obj) = meta.as_object_mut()
3758 {
3759 if proxy.introspection_url.is_some() {
3760 obj.insert(
3761 "introspection_endpoint".into(),
3762 serde_json::Value::String(format!("{server_url}/introspect")),
3763 );
3764 }
3765 if proxy.revocation_url.is_some() {
3766 obj.insert(
3767 "revocation_endpoint".into(),
3768 serde_json::Value::String(format!("{server_url}/revoke")),
3769 );
3770 }
3771 if proxy.require_auth_on_admin_endpoints {
3772 obj.insert(
3773 "introspection_endpoint_auth_methods_supported".into(),
3774 serde_json::json!(["bearer"]),
3775 );
3776 obj.insert(
3777 "revocation_endpoint_auth_methods_supported".into(),
3778 serde_json::json!(["bearer"]),
3779 );
3780 }
3781 }
3782 meta
3783}
3784
3785#[must_use]
3798pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
3799 use axum::{
3800 http::{StatusCode, header},
3801 response::IntoResponse,
3802 };
3803
3804 let upstream_query =
3806 rewrite_client_auth_params(query, &proxy.client_id, proxy.strip_resource_param);
3807 let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
3808
3809 (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
3810}
3811
3812pub async fn handle_token(
3822 http: &OauthHttpClient,
3823 proxy: &OAuthProxyConfig,
3824 body: &str,
3825) -> axum::response::Response {
3826 use axum::{
3827 http::{StatusCode, header},
3828 response::IntoResponse,
3829 };
3830
3831 let mut upstream_body =
3833 rewrite_client_auth_params(body, &proxy.client_id, proxy.strip_resource_param);
3834
3835 if let Some(ref secret) = proxy.client_secret {
3837 use std::fmt::Write;
3838
3839 use secrecy::ExposeSecret;
3840 let _ = write!(
3841 upstream_body,
3842 "&client_secret={}",
3843 urlencoding::encode(secret.expose_secret())
3844 );
3845 }
3846
3847 let result = http
3848 .send_screened(
3849 &proxy.token_url,
3850 http.credential_client
3851 .post(&proxy.token_url)
3852 .header("Content-Type", "application/x-www-form-urlencoded")
3853 .body(upstream_body),
3854 )
3855 .await;
3856
3857 match result {
3858 Ok(resp) => {
3859 let status =
3860 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3861 let Ok(body_bytes) =
3862 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
3863 else {
3864 return oauth_error_response(
3865 StatusCode::BAD_GATEWAY,
3866 "server_error",
3867 "upstream response too large or unreadable",
3868 );
3869 };
3870 (
3871 status,
3872 [(header::CONTENT_TYPE, "application/json")],
3873 body_bytes,
3874 )
3875 .into_response()
3876 }
3877 Err(e) => {
3878 tracing::error!(error = %e, "OAuth token proxy request failed");
3879 (
3880 StatusCode::BAD_GATEWAY,
3881 [(header::CONTENT_TYPE, "application/json")],
3882 "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
3883 )
3884 .into_response()
3885 }
3886 }
3887}
3888
3889#[must_use]
3896pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
3897 let mut resp = serde_json::json!({
3898 "client_id": proxy.client_id,
3899 "token_endpoint_auth_method": "none",
3900 });
3901 if let Some(uris) = body.get("redirect_uris")
3902 && let Some(obj) = resp.as_object_mut()
3903 {
3904 obj.insert("redirect_uris".into(), uris.clone());
3905 }
3906 if let Some(name) = body.get("client_name")
3907 && let Some(obj) = resp.as_object_mut()
3908 {
3909 obj.insert("client_name".into(), name.clone());
3910 }
3911 resp
3912}
3913
3914pub async fn handle_introspect(
3922 http: &OauthHttpClient,
3923 proxy: &OAuthProxyConfig,
3924 body: &str,
3925) -> axum::response::Response {
3926 let Some(ref url) = proxy.introspection_url else {
3927 return oauth_error_response(
3928 axum::http::StatusCode::NOT_FOUND,
3929 "not_supported",
3930 "introspection endpoint is not configured",
3931 );
3932 };
3933 proxy_oauth_admin_request(http, proxy, url, body).await
3934}
3935
3936pub async fn handle_revoke(
3946 http: &OauthHttpClient,
3947 proxy: &OAuthProxyConfig,
3948 body: &str,
3949) -> axum::response::Response {
3950 let Some(ref url) = proxy.revocation_url else {
3951 return oauth_error_response(
3952 axum::http::StatusCode::NOT_FOUND,
3953 "not_supported",
3954 "revocation endpoint is not configured",
3955 );
3956 };
3957 proxy_oauth_admin_request(http, proxy, url, body).await
3958}
3959
3960async fn proxy_oauth_admin_request(
3967 http: &OauthHttpClient,
3968 proxy: &OAuthProxyConfig,
3969 upstream_url: &str,
3970 body: &str,
3971) -> axum::response::Response {
3972 use axum::{
3973 http::{StatusCode, header},
3974 response::IntoResponse,
3975 };
3976
3977 let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id, false);
3981 if let Some(ref secret) = proxy.client_secret {
3982 use std::fmt::Write;
3983
3984 use secrecy::ExposeSecret;
3985 let _ = write!(
3986 upstream_body,
3987 "&client_secret={}",
3988 urlencoding::encode(secret.expose_secret())
3989 );
3990 }
3991
3992 let result = http
3993 .send_screened(
3994 upstream_url,
3995 http.credential_client
3996 .post(upstream_url)
3997 .header("Content-Type", "application/x-www-form-urlencoded")
3998 .body(upstream_body),
3999 )
4000 .await;
4001
4002 match result {
4003 Ok(resp) => {
4004 let status =
4005 StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
4006 let content_type = resp
4007 .headers()
4008 .get(header::CONTENT_TYPE)
4009 .and_then(|v| v.to_str().ok())
4010 .unwrap_or("application/json")
4011 .to_owned();
4012 let Ok(body_bytes) =
4013 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
4014 else {
4015 return oauth_error_response(
4016 StatusCode::BAD_GATEWAY,
4017 "server_error",
4018 "upstream response too large or unreadable",
4019 );
4020 };
4021 (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
4022 }
4023 Err(e) => {
4024 tracing::error!(
4025 error = %e,
4026 url = %oauth_request_target_for_log(upstream_url),
4027 "OAuth admin proxy request failed"
4028 );
4029 oauth_error_response(
4030 StatusCode::BAD_GATEWAY,
4031 "server_error",
4032 "upstream endpoint unreachable",
4033 )
4034 }
4035 }
4036}
4037
4038async fn read_response_capped(
4051 mut resp: reqwest::Response,
4052 max_bytes: u64,
4053 context: &str,
4054) -> Result<Vec<u8>, ()> {
4055 let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
4056 let mut body = Vec::with_capacity(initial_capacity);
4057 loop {
4058 match resp.chunk().await {
4059 Ok(Some(chunk)) => {
4060 let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
4061 let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
4062 if body_len.saturating_add(chunk_len) > max_bytes {
4063 tracing::warn!(
4064 context = context,
4065 max_bytes = max_bytes,
4066 "upstream OAuth response exceeded size cap; failing closed"
4067 );
4068 return Err(());
4069 }
4070 body.extend_from_slice(&chunk);
4071 }
4072 Ok(None) => return Ok(body),
4073 Err(error) => {
4074 tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
4075 return Err(());
4076 }
4077 }
4078 }
4079}
4080
4081fn oauth_error_response(
4082 status: axum::http::StatusCode,
4083 error: &str,
4084 description: &str,
4085) -> axum::response::Response {
4086 use axum::{http::header, response::IntoResponse};
4087 let body = serde_json::json!({
4088 "error": error,
4089 "error_description": description,
4090 });
4091 (
4092 status,
4093 [(header::CONTENT_TYPE, "application/json")],
4094 body.to_string(),
4095 )
4096 .into_response()
4097}
4098
4099#[derive(Debug, Deserialize)]
4105struct OAuthErrorResponse {
4106 error: String,
4107 error_description: Option<String>,
4108}
4109
4110fn upstream_error_description_for_log(description: Option<&str>) -> &str {
4118 if crate::diagnostics::upstream_error_bodies() {
4119 description.unwrap_or("")
4120 } else {
4121 "[REDACTED]"
4122 }
4123}
4124
4125fn sanitize_oauth_error_code(raw: &str) -> &'static str {
4132 match raw {
4133 "invalid_request" => "invalid_request",
4134 "invalid_client" => "invalid_client",
4135 "invalid_grant" => "invalid_grant",
4136 "unauthorized_client" => "unauthorized_client",
4137 "unsupported_grant_type" => "unsupported_grant_type",
4138 "invalid_scope" => "invalid_scope",
4139 "temporarily_unavailable" => "temporarily_unavailable",
4140 "invalid_target" => "invalid_target",
4142 _ => "server_error",
4145 }
4146}
4147
4148pub async fn exchange_token(
4170 http: &OauthHttpClient,
4171 config: &TokenExchangeConfig,
4172 subject_token: &str,
4173) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4174 exchange_token_inner(http, config, subject_token, SuccessLogMode::Normal).await
4175}
4176
4177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4178enum SuccessLogMode {
4179 Normal,
4180 Suppress,
4181}
4182
4183async fn exchange_token_inner(
4184 http: &OauthHttpClient,
4185 config: &TokenExchangeConfig,
4186 subject_token: &str,
4187 success_log: SuccessLogMode,
4188) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4189 use secrecy::ExposeSecret;
4190
4191 let client = http.client_for(config);
4192 let mut req = client
4193 .post(&config.token_url)
4194 .header("Content-Type", "application/x-www-form-urlencoded")
4195 .header("Accept", "application/json");
4196
4197 if config.client_cert.is_none()
4206 && let Some(ref secret) = config.client_secret
4207 {
4208 use base64::Engine;
4209 let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
4210 "{}:{}",
4211 urlencoding::encode(&config.client_id),
4212 urlencoding::encode(secret.expose_secret()),
4213 ));
4214 req = req.header("Authorization", format!("Basic {credentials}"));
4215 }
4216
4217 let form_body = build_exchange_form(config, subject_token);
4218
4219 let resp = http
4220 .send_screened(&config.token_url, req.body(form_body))
4221 .await
4222 .map_err(|e| {
4223 tracing::error!(error = %e, "token exchange request failed");
4224 crate::error::RmcpServerKitError::Auth("server_error".into())
4226 })?;
4227
4228 let status = resp.status();
4229 let body_bytes =
4230 read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
4231 .await
4232 .map_err(|()| {
4233 crate::error::RmcpServerKitError::Auth("server_error".into())
4235 })?;
4236
4237 if !status.is_success() {
4238 core::hint::cold_path();
4239 let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
4242 let short_code = parsed
4243 .as_ref()
4244 .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
4245 if let Some(ref e) = parsed {
4246 let description = upstream_error_description_for_log(e.error_description.as_deref());
4247 tracing::warn!(
4248 status = %status,
4249 upstream_error = %e.error,
4250 upstream_error_description = description,
4251 client_code = %short_code,
4252 "token exchange rejected by authorization server",
4253 );
4254 } else {
4255 tracing::warn!(
4256 status = %status,
4257 client_code = %short_code,
4258 "token exchange rejected (unparseable upstream body)",
4259 );
4260 }
4261 return Err(crate::error::RmcpServerKitError::Auth(short_code.into()));
4262 }
4263
4264 let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
4265 tracing::error!(error = %e, "failed to parse token exchange response");
4266 crate::error::RmcpServerKitError::Auth("server_error".into())
4269 })?;
4270
4271 match success_log {
4272 SuccessLogMode::Normal => log_exchanged_token(&exchanged),
4273 SuccessLogMode::Suppress => {}
4274 }
4275
4276 Ok(exchanged)
4277}
4278
4279#[must_use = "DetachOutcome must be inspected to distinguish completion from cancel/timeout"]
4314pub async fn exchange_token_with_cancel(
4315 http: &OauthHttpClient,
4316 config: &TokenExchangeConfig,
4317 subject_token: &str,
4318 ct: &tokio_util::sync::CancellationToken,
4319 timeout: Option<Duration>,
4320) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4321 if ct.is_cancelled() {
4326 return crate::cancel::DetachOutcome::Cancelled;
4327 }
4328
4329 let (tx, rx) = tokio::sync::oneshot::channel();
4330 let http = http.clone();
4331 let config = config.clone();
4332 let subject_token = subject_token.to_owned();
4333
4334 tokio::spawn(
4341 async move {
4342 let result =
4343 exchange_token_inner(&http, &config, &subject_token, SuccessLogMode::Suppress)
4344 .await;
4345 if let Err(result) = tx.send(result) {
4346 audit_abandoned_exchange_result(result);
4347 }
4348 }
4349 .instrument(tracing::Span::current()),
4350 );
4351
4352 receive_exchange_result_with_cancel(rx, ct, timeout).await
4353}
4354
4355async fn receive_exchange_result_with_cancel(
4356 rx: tokio::sync::oneshot::Receiver<Result<ExchangedToken, crate::error::RmcpServerKitError>>,
4357 ct: &tokio_util::sync::CancellationToken,
4358 timeout: Option<Duration>,
4359) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4360 if let Some(t) = timeout {
4366 tokio::select! {
4367 biased;
4368 received = rx => map_exchange_receiver(received),
4369 () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4370 () = tokio::time::sleep(t) => crate::cancel::DetachOutcome::TimedOut,
4371 }
4372 } else {
4373 tokio::select! {
4374 biased;
4375 received = rx => map_exchange_receiver(received),
4376 () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4377 }
4378 }
4379}
4380
4381fn map_exchange_receiver(
4382 received: Result<
4383 Result<ExchangedToken, crate::error::RmcpServerKitError>,
4384 tokio::sync::oneshot::error::RecvError,
4385 >,
4386) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4387 match received {
4388 Ok(result) => crate::cancel::DetachOutcome::Completed(result),
4389 Err(error) => {
4390 tracing::error!(error = %error, "token exchange task ended before returning a result");
4391 crate::cancel::DetachOutcome::Completed(Err(
4392 crate::error::RmcpServerKitError::Internal("server_error".into()),
4393 ))
4394 }
4395 }
4396}
4397
4398fn audit_abandoned_exchange_result(
4399 result: Result<ExchangedToken, crate::error::RmcpServerKitError>,
4400) {
4401 match result {
4402 Ok(token) => {
4403 let (issued_token_type, issued_token_type_truncated) = token
4404 .issued_token_type
4405 .as_deref()
4406 .map_or_else(|| ("-".to_owned(), false), truncate_kid_for_log);
4407 tracing::warn!(
4408 expires_in = token.expires_in,
4409 issued_token_type = %issued_token_type,
4410 issued_token_type_truncated,
4411 "token exchange minted downstream token after caller detached; discarded token material"
4412 );
4413 }
4414 Err(error) => {
4415 tracing::debug!(error = %error, "token exchange failed after caller detached");
4416 }
4417 }
4418}
4419
4420fn push_form_param(body: &mut String, name: &str, value: &str) {
4421 body.push('&');
4422 body.push_str(name);
4423 body.push('=');
4424 body.push_str(&urlencoding::encode(value));
4425}
4426
4427fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
4435 let mut body = format!(
4436 "grant_type={}&subject_token={}&subject_token_type={}",
4437 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
4438 urlencoding::encode(subject_token),
4439 urlencoding::encode(TOKEN_TYPE_ACCESS_TOKEN),
4440 );
4441 if let Some(value) = config.requested_token_type.wire_value() {
4442 push_form_param(&mut body, "requested_token_type", value);
4443 }
4444 if let Some(audience) = config.audience.as_deref() {
4445 push_form_param(&mut body, "audience", audience);
4446 }
4447 if let Some(resource) = config.resource.as_deref() {
4448 push_form_param(&mut body, "resource", resource);
4449 }
4450 if let Some(scope) = config.scope.as_deref() {
4451 push_form_param(&mut body, "scope", scope);
4452 }
4453 if config.client_secret.is_none() {
4454 push_form_param(&mut body, "client_id", &config.client_id);
4455 }
4456 body
4457}
4458
4459fn log_exchanged_token(exchanged: &ExchangedToken) {
4462 use base64::Engine;
4463
4464 if !looks_like_jwt(&exchanged.access_token) {
4465 tracing::debug!(
4466 token_len = exchanged.access_token.len(),
4467 issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
4468 expires_in = exchanged.expires_in,
4469 "exchanged token (opaque)",
4470 );
4471 return;
4472 }
4473 let Some(payload) = exchanged.access_token.split('.').nth(1) else {
4474 return;
4475 };
4476 let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
4477 return;
4478 };
4479 let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
4480 return;
4481 };
4482 let expose_claims = crate::diagnostics::oauth_claim_values();
4483 let sub = gated_claim_str(claims.get("sub"), expose_claims);
4484 let aud = gated_claim_aud(claims.get("aud"), expose_claims);
4485 let azp = gated_claim_str(claims.get("azp"), expose_claims);
4486 let iss = gated_claim_str(claims.get("iss"), expose_claims);
4487 tracing::debug!(
4488 sub = sub,
4489 aud = %aud,
4490 azp = azp,
4491 iss = iss,
4492 expires_in = exchanged.expires_in,
4493 "exchanged token claims (JWT)",
4494 );
4495}
4496
4497fn gated_claim_str(value: Option<&serde_json::Value>, expose: bool) -> &str {
4498 if expose {
4499 fmt_json_str(value)
4500 } else {
4501 "[REDACTED]"
4502 }
4503}
4504
4505fn gated_claim_aud(value: Option<&serde_json::Value>, expose: bool) -> String {
4506 if expose {
4507 fmt_json_aud(value)
4508 } else {
4509 "[REDACTED]".to_owned()
4510 }
4511}
4512
4513const CLIENT_AUTH_PARAMS: [&str; 4] = [
4519 "client_id",
4520 "client_secret",
4521 "client_assertion",
4522 "client_assertion_type",
4523];
4524
4525fn rewrite_client_auth_params(
4547 params: &str,
4548 upstream_client_id: &str,
4549 strip_resource: bool,
4550) -> String {
4551 let mut out = url::form_urlencoded::Serializer::new(String::new());
4552 for (key, value) in url::form_urlencoded::parse(params.as_bytes()) {
4553 if CLIENT_AUTH_PARAMS.contains(&key.as_ref()) {
4554 continue;
4555 }
4556 if strip_resource && key.as_ref() == "resource" {
4560 continue;
4561 }
4562 out.append_pair(&key, &value);
4563 }
4564 out.append_pair("client_id", upstream_client_id);
4565 out.finish()
4566}
4567
4568#[cfg(test)]
4569mod tests {
4570 use std::{sync::Arc, time::Instant};
4571
4572 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
4573
4574 use super::*;
4575
4576 fn decoded_pairs(form: &str) -> Vec<(String, String)> {
4590 url::form_urlencoded::parse(form.as_bytes())
4591 .map(|(k, v)| (k.into_owned(), v.into_owned()))
4592 .collect()
4593 }
4594
4595 #[test]
4596 fn rewrite_drops_percent_encoded_client_id_key() {
4597 let out = rewrite_client_auth_params("%63lient_id=attacker&scope=read", "proxy-id", false);
4598 let pairs = decoded_pairs(&out);
4599 let client_ids: Vec<&String> = pairs
4600 .iter()
4601 .filter(|(k, _)| k == "client_id")
4602 .map(|(_, v)| v)
4603 .collect();
4604 assert_eq!(client_ids, vec!["proxy-id"], "smuggled client_id survived");
4605 }
4606
4607 #[test]
4608 fn rewrite_drops_underscore_encoded_client_id_key() {
4609 let out = rewrite_client_auth_params("client%5Fid=attacker&scope=read", "proxy-id", false);
4610 let pairs = decoded_pairs(&out);
4611 assert!(
4612 !pairs.iter().any(|(_, v)| v == "attacker"),
4613 "smuggled client_id survived: {pairs:?}"
4614 );
4615 }
4616
4617 #[test]
4618 fn rewrite_drops_caller_supplied_client_secret() {
4619 let out = rewrite_client_auth_params(
4620 "client_secret=attacker-secret&scope=read",
4621 "proxy-id",
4622 false,
4623 );
4624 let pairs = decoded_pairs(&out);
4625 assert!(
4626 !pairs.iter().any(|(k, _)| k == "client_secret"),
4627 "caller client_secret survived: {pairs:?}"
4628 );
4629 }
4630
4631 #[test]
4632 fn rewrite_drops_caller_supplied_client_assertion() {
4633 let out = rewrite_client_auth_params(
4634 "client_assertion=ey.evil&client_assertion_type=urn:evil&scope=read",
4635 "proxy-id",
4636 false,
4637 );
4638 let pairs = decoded_pairs(&out);
4639 assert!(
4640 !pairs
4641 .iter()
4642 .any(|(k, _)| k == "client_assertion" || k == "client_assertion_type"),
4643 "caller client assertion survived: {pairs:?}"
4644 );
4645 }
4646
4647 #[test]
4648 fn rewrite_collapses_duplicate_client_id_to_proxy_value() {
4649 let out =
4650 rewrite_client_auth_params("client_id=a&client_id=b&scope=read", "proxy-id", false);
4651 let pairs = decoded_pairs(&out);
4652 let client_ids: Vec<&String> = pairs
4653 .iter()
4654 .filter(|(k, _)| k == "client_id")
4655 .map(|(_, v)| v)
4656 .collect();
4657 assert_eq!(client_ids, vec!["proxy-id"]);
4658 }
4659
4660 #[test]
4661 fn rewrite_preserves_non_client_params_in_order_with_duplicates() {
4662 let out = rewrite_client_auth_params(
4663 "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4664 "proxy-id",
4665 false,
4666 );
4667 let pairs = decoded_pairs(&out);
4668 let non_client: Vec<(String, String)> = pairs
4669 .into_iter()
4670 .filter(|(k, _)| k != "client_id")
4671 .collect();
4672 assert_eq!(
4673 non_client,
4674 vec![
4675 ("scope".to_owned(), "read".to_owned()),
4676 ("resource".to_owned(), "a".to_owned()),
4677 ("state".to_owned(), "xyz".to_owned()),
4678 ("resource".to_owned(), "b".to_owned()),
4679 ("code_verifier".to_owned(), "v".to_owned()),
4680 ]
4681 );
4682 }
4683
4684 #[test]
4685 fn rewrite_strips_every_resource_param_when_enabled() {
4686 let out = rewrite_client_auth_params(
4690 "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4691 "proxy-id",
4692 true,
4693 );
4694 let non_client: Vec<(String, String)> = decoded_pairs(&out)
4695 .into_iter()
4696 .filter(|(k, _)| k != "client_id")
4697 .collect();
4698 assert_eq!(
4699 non_client,
4700 vec![
4701 ("scope".to_owned(), "read".to_owned()),
4702 ("state".to_owned(), "xyz".to_owned()),
4703 ("code_verifier".to_owned(), "v".to_owned()),
4704 ]
4705 );
4706 }
4707
4708 #[test]
4709 fn rewrite_strips_percent_encoded_resource_key() {
4710 let out = rewrite_client_auth_params("%72esource=sneaky&scope=read", "proxy-id", true);
4714 let pairs = decoded_pairs(&out);
4715 assert!(
4716 !pairs.iter().any(|(k, _)| k == "resource"),
4717 "percent-encoded resource survived: {pairs:?}"
4718 );
4719 assert!(pairs.contains(&("scope".to_owned(), "read".to_owned())));
4720 }
4721
4722 #[test]
4723 fn rewrite_never_strips_security_params_when_resource_stripping_enabled() {
4724 let input = "response_type=code&redirect_uri=https%3A%2F%2Fapp%2Fcb&state=s1\
4728 &code_challenge=cc&code_challenge_method=S256&nonce=n1&scope=read\
4729 &code_verifier=cv&grant_type=authorization_code&code=abc\
4730 &refresh_token=rt&resource=https%3A%2F%2Fapi";
4731 let pairs = decoded_pairs(&rewrite_client_auth_params(input, "proxy-id", true));
4732 for key in [
4733 "response_type",
4734 "redirect_uri",
4735 "state",
4736 "code_challenge",
4737 "code_challenge_method",
4738 "nonce",
4739 "scope",
4740 "code_verifier",
4741 "grant_type",
4742 "code",
4743 "refresh_token",
4744 ] {
4745 assert!(
4746 pairs.iter().any(|(k, _)| k == key),
4747 "{key} must never be stripped: {pairs:?}"
4748 );
4749 }
4750 assert!(!pairs.iter().any(|(k, _)| k == "resource"));
4751 }
4752
4753 #[test]
4754 fn rewrite_roundtrips_values_with_special_characters() {
4755 let input = url::form_urlencoded::Serializer::new(String::new())
4756 .append_pair("state", "a&b=c+d")
4757 .append_pair("scope", "réad ✓")
4758 .finish();
4759 let out = rewrite_client_auth_params(&input, "proxy-id", false);
4760 let pairs = decoded_pairs(&out);
4761 assert!(pairs.contains(&("state".to_owned(), "a&b=c+d".to_owned())));
4762 assert!(pairs.contains(&("scope".to_owned(), "réad ✓".to_owned())));
4763 }
4764
4765 #[test]
4766 fn rewrite_injects_client_id_when_absent() {
4767 let out = rewrite_client_auth_params("scope=read", "proxy-id", false);
4768 assert!(decoded_pairs(&out).contains(&("client_id".to_owned(), "proxy-id".to_owned())));
4769 }
4770
4771 #[test]
4772 fn looks_like_jwt_valid() {
4773 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
4775 let payload = URL_SAFE_NO_PAD.encode(b"{}");
4776 let token = format!("{header}.{payload}.signature");
4777 assert!(looks_like_jwt(&token));
4778 }
4779
4780 #[test]
4781 fn looks_like_jwt_rejects_opaque_token() {
4782 assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
4783 }
4784
4785 #[test]
4786 fn looks_like_jwt_rejects_two_segments() {
4787 let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
4788 let token = format!("{header}.payload");
4789 assert!(!looks_like_jwt(&token));
4790 }
4791
4792 #[test]
4793 fn looks_like_jwt_rejects_four_segments() {
4794 assert!(!looks_like_jwt("a.b.c.d"));
4795 }
4796
4797 #[test]
4798 fn looks_like_jwt_rejects_no_alg() {
4799 let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
4800 let payload = URL_SAFE_NO_PAD.encode(b"{}");
4801 let token = format!("{header}.{payload}.sig");
4802 assert!(!looks_like_jwt(&token));
4803 }
4804
4805 #[test]
4806 fn protected_resource_metadata_shape() {
4807 let config = OAuthConfig {
4808 require_subject: false,
4809 issuer: "https://auth.example.com".into(),
4810 audience: "https://mcp.example.com/mcp".into(),
4811 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4812 scopes: vec![
4813 ScopeMapping {
4814 scope: "mcp:read".into(),
4815 role: "viewer".into(),
4816 },
4817 ScopeMapping {
4818 scope: "mcp:admin".into(),
4819 role: "ops".into(),
4820 },
4821 ],
4822 role_claim: None,
4823 role_mappings: vec![],
4824 jwks_cache_ttl: "10m".into(),
4825 proxy: None,
4826 token_exchange: None,
4827 ca_cert_path: None,
4828 allow_http_oauth_urls: false,
4829 max_jwks_keys: default_max_jwks_keys(),
4830 allowed_algorithms: None,
4831 authorization_servers: None,
4832 authorization_server_metadata_issuer: None,
4833 #[allow(
4834 deprecated,
4835 reason = "test fixture: explicit value for the deprecated field"
4836 )]
4837 strict_audience_validation: None,
4838 audience_validation_mode: None,
4839 jwks_max_response_bytes: default_jwks_max_bytes(),
4840 ssrf_allowlist: None,
4841 };
4842 let meta = protected_resource_metadata(
4843 "https://mcp.example.com/mcp",
4844 "https://mcp.example.com",
4845 &config,
4846 );
4847 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4848 assert_eq!(meta["authorization_servers"][0], "https://auth.example.com");
4852 assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
4853 assert_eq!(meta["bearer_methods_supported"][0], "header");
4854 }
4855
4856 fn prm_for(
4858 proxy: Option<OAuthProxyConfig>,
4859 authorization_servers: Option<Vec<String>>,
4860 scopes: Vec<ScopeMapping>,
4861 ) -> serde_json::Value {
4862 let config = OAuthConfig {
4863 issuer: "https://auth.example.com".into(),
4864 audience: "https://mcp.example.com/mcp".into(),
4865 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4866 scopes,
4867 proxy,
4868 authorization_servers,
4869 ..OAuthConfig::default()
4870 };
4871 protected_resource_metadata(
4872 "https://mcp.example.com/mcp",
4873 "https://mcp.example.com",
4874 &config,
4875 )
4876 }
4877
4878 fn demo_proxy() -> OAuthProxyConfig {
4879 OAuthProxyConfig::builder(
4880 "https://auth.example.com/authorize",
4881 "https://auth.example.com/token",
4882 "mcp",
4883 )
4884 .build()
4885 }
4886
4887 #[test]
4888 fn prm_advertises_local_server_only_when_proxy_mounts_the_endpoints() {
4889 let meta = prm_for(Some(demo_proxy()), None, vec![]);
4892 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4893 }
4894
4895 #[test]
4896 fn prm_explicit_override_wins_over_topology() {
4897 let meta = prm_for(
4900 None,
4901 Some(vec!["https://mcp.example.com".to_owned()]),
4902 vec![],
4903 );
4904 assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4905
4906 let meta = prm_for(
4908 Some(demo_proxy()),
4909 Some(vec!["https://elsewhere.example".to_owned()]),
4910 vec![],
4911 );
4912 assert_eq!(
4913 meta["authorization_servers"][0],
4914 "https://elsewhere.example"
4915 );
4916 }
4917
4918 #[test]
4919 fn prm_omits_zero_valued_claims() {
4920 let meta = prm_for(None, Some(vec![]), vec![]);
4923 assert!(
4924 meta.get("authorization_servers").is_none(),
4925 "empty override must omit the claim: {meta}"
4926 );
4927 assert!(
4928 meta.get("scopes_supported").is_none(),
4929 "no configured scopes must omit the claim: {meta}"
4930 );
4931 assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4932 }
4933
4934 fn proxy_as_metadata_config() -> OAuthConfig {
4935 OAuthConfig {
4936 issuer: "https://auth.example.com".into(),
4937 audience: "https://mcp.example.com/mcp".into(),
4938 jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4939 proxy: Some(demo_proxy()),
4940 ..OAuthConfig::default()
4941 }
4942 }
4943
4944 #[test]
4945 fn as_metadata_issuer_defaults_to_the_origin_it_is_served_from() {
4946 let config = proxy_as_metadata_config();
4951 let meta = authorization_server_metadata("https://mcp.example.com", &config);
4952 assert_eq!(meta["issuer"], "https://mcp.example.com");
4953 assert_eq!(
4954 meta["authorization_endpoint"],
4955 "https://mcp.example.com/authorize"
4956 );
4957 assert!(
4958 meta.get("scopes_supported").is_none(),
4959 "RFC 8414 3.2: omit zero-valued claims: {meta}"
4960 );
4961 }
4962
4963 #[test]
4964 fn as_metadata_issuer_legacy_opt_out_restores_upstream_value() {
4965 let mut config = proxy_as_metadata_config();
4969 config.authorization_server_metadata_issuer = Some("https://auth.example.com".into());
4970 let meta = authorization_server_metadata("https://mcp.example.com", &config);
4971 assert_eq!(meta["issuer"], "https://auth.example.com");
4972 }
4973
4974 #[test]
4975 fn as_metadata_issuer_never_affects_token_validation() {
4976 let mut config = proxy_as_metadata_config();
4979 config.authorization_server_metadata_issuer = Some("https://mcp.example.com".into());
4980 assert_eq!(config.issuer, "https://auth.example.com");
4981 }
4982
4983 fn validation_https_config() -> OAuthConfig {
4988 OAuthConfig::builder(
4989 "https://auth.example.com",
4990 "mcp",
4991 "https://auth.example.com/.well-known/jwks.json",
4992 )
4993 .build()
4994 }
4995
4996 #[test]
4997 fn validate_rejects_non_conformant_discovery_metadata_urls() {
4998 for bad in [
4999 "https://user:pw@as.example.com",
5000 "http://as.example.com",
5001 "https://10.0.0.1",
5002 "not-a-url",
5003 ] {
5004 let mut cfg = validation_https_config();
5005 cfg.authorization_server_metadata_issuer = Some(bad.to_owned());
5006 cfg.validate().unwrap_err();
5007
5008 let mut cfg = validation_https_config();
5009 cfg.authorization_servers = Some(vec![bad.to_owned()]);
5010 let err = cfg.validate().unwrap_err().to_string();
5011 assert!(
5012 err.contains("authorization_servers[0]"),
5013 "error must identify the offending index; got {err:?}"
5014 );
5015 }
5016 }
5017
5018 #[test]
5019 fn validate_accepts_discovery_metadata_urls_and_the_empty_override() {
5020 let mut cfg = validation_https_config();
5021 cfg.authorization_server_metadata_issuer = Some("https://as.example.com".to_owned());
5022 cfg.authorization_servers = Some(vec!["https://as.example.com".to_owned()]);
5023 cfg.validate()
5024 .expect("well-formed https metadata must validate");
5025
5026 let mut cfg = validation_https_config();
5027 cfg.authorization_servers = Some(vec![]);
5028 cfg.validate()
5029 .expect("an empty list is the documented way to omit the claim entirely");
5030 }
5031
5032 #[test]
5033 fn validate_accepts_all_https_urls() {
5034 let cfg = validation_https_config();
5035 cfg.validate().expect("all-HTTPS config must validate");
5036 }
5037
5038 #[test]
5039 fn validate_rejects_empty_audience() {
5040 let mut cfg = validation_https_config();
5041 cfg.audience = String::new();
5042 let err = cfg.validate().expect_err("empty audience must be rejected");
5043 assert!(
5044 err.to_string().contains("oauth.audience"),
5045 "error must reference oauth.audience; got {err}"
5046 );
5047 }
5048
5049 fn assert_config_nonzero_error(err: crate::error::RmcpServerKitError, field: &str) {
5050 let crate::error::RmcpServerKitError::Config(msg) = err else {
5051 panic!("expected Config error for {field}");
5052 };
5053 assert!(
5054 msg.contains(field) && msg.contains("must be nonzero"),
5055 "error must name {field} and say must be nonzero; got {msg:?}"
5056 );
5057 }
5058
5059 #[test]
5060 fn rejects_zero_max_jwks_keys() {
5061 let mut cfg = validation_https_config();
5062 cfg.max_jwks_keys = 0;
5063 let err = cfg
5064 .validate()
5065 .expect_err("zero max_jwks_keys must be rejected");
5066 assert_config_nonzero_error(err, "oauth.max_jwks_keys");
5067 }
5068
5069 #[test]
5070 fn rejects_zero_jwks_max_response_bytes() {
5071 let mut cfg = validation_https_config();
5072 cfg.jwks_max_response_bytes = 0;
5073 let err = cfg
5074 .validate()
5075 .expect_err("zero jwks_max_response_bytes must be rejected");
5076 assert_config_nonzero_error(err, "oauth.jwks_max_response_bytes");
5077 }
5078
5079 #[test]
5080 fn oauth_config_partial_table_deserializes_then_validate_rejects_empty_fields() {
5081 let toml_src = r#"
5082role_claim = "realm_access.roles"
5083
5084[[role_mappings]]
5085claim_value = "mcp-admin"
5086role = "admin"
5087"#;
5088 let cfg: OAuthConfig = toml::from_str(toml_src).expect(
5089 "partial [oauth] table without issuer/audience/jwks_uri must deserialize via serde(default)",
5090 );
5091 assert_eq!(cfg.issuer, "", "omitted issuer must default to empty");
5092 assert_eq!(cfg.audience, "", "omitted audience must default to empty");
5093 assert_eq!(cfg.jwks_uri, "", "omitted jwks_uri must default to empty");
5094 assert_eq!(cfg.role_claim.as_deref(), Some("realm_access.roles"));
5095 assert_eq!(cfg.role_mappings.len(), 1);
5096 cfg.validate().expect_err(
5097 "empty issuer/jwks_uri/audience must still fail validate() (parse-don't-validate)",
5098 );
5099 }
5100
5101 #[test]
5102 fn validate_rejects_unparseable_jwks_cache_ttl() {
5103 let mut cfg = validation_https_config();
5104 cfg.jwks_cache_ttl = "not-a-duration".into();
5105 let err = cfg
5106 .validate()
5107 .expect_err("malformed jwks_cache_ttl must be rejected");
5108 let msg = err.to_string();
5109 assert!(
5110 msg.contains("jwks_cache_ttl"),
5111 "error must reference offending field; got {msg:?}"
5112 );
5113 }
5114
5115 #[test]
5116 fn validate_rejects_http_jwks_uri() {
5117 let mut cfg = validation_https_config();
5118 cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
5119 let err = cfg.validate().expect_err("http jwks_uri must be rejected");
5120 let msg = err.to_string();
5121 assert!(
5122 msg.contains("oauth.jwks_uri") && msg.contains("https"),
5123 "error must reference offending field + scheme requirement; got {msg:?}"
5124 );
5125 }
5126
5127 #[test]
5128 fn validate_rejects_http_proxy_authorize_url() {
5129 let mut cfg = validation_https_config();
5130 cfg.proxy = Some(
5131 OAuthProxyConfig::builder(
5132 "http://idp.example.com/authorize", "https://idp.example.com/token",
5134 "client",
5135 )
5136 .build(),
5137 );
5138 let err = cfg
5139 .validate()
5140 .expect_err("http authorize_url must be rejected");
5141 assert!(
5142 err.to_string().contains("oauth.proxy.authorize_url"),
5143 "error must reference proxy.authorize_url; got {err}"
5144 );
5145 }
5146
5147 #[test]
5148 fn validate_rejects_http_proxy_token_url() {
5149 let mut cfg = validation_https_config();
5150 cfg.proxy = Some(
5151 OAuthProxyConfig::builder(
5152 "https://idp.example.com/authorize",
5153 "http://idp.example.com/token", "client",
5155 )
5156 .build(),
5157 );
5158 let err = cfg.validate().expect_err("http token_url must be rejected");
5159 assert!(
5160 err.to_string().contains("oauth.proxy.token_url"),
5161 "error must reference proxy.token_url; got {err}"
5162 );
5163 }
5164
5165 #[test]
5166 fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
5167 let mut cfg = validation_https_config();
5168 cfg.proxy = Some(
5169 OAuthProxyConfig::builder(
5170 "https://idp.example.com/authorize",
5171 "https://idp.example.com/token",
5172 "client",
5173 )
5174 .introspection_url("http://idp.example.com/introspect")
5175 .build(),
5176 );
5177 let err = cfg
5178 .validate()
5179 .expect_err("http introspection_url must be rejected");
5180 assert!(err.to_string().contains("oauth.proxy.introspection_url"));
5181
5182 let mut cfg = validation_https_config();
5183 cfg.proxy = Some(
5184 OAuthProxyConfig::builder(
5185 "https://idp.example.com/authorize",
5186 "https://idp.example.com/token",
5187 "client",
5188 )
5189 .revocation_url("http://idp.example.com/revoke")
5190 .build(),
5191 );
5192 let err = cfg
5193 .validate()
5194 .expect_err("http revocation_url must be rejected");
5195 assert!(err.to_string().contains("oauth.proxy.revocation_url"));
5196 }
5197
5198 #[test]
5201 fn validate_rejects_exposed_admin_endpoints_without_auth() {
5202 let mut cfg = validation_https_config();
5203 cfg.proxy = Some(
5204 OAuthProxyConfig::builder(
5205 "https://idp.example.com/authorize",
5206 "https://idp.example.com/token",
5207 "client",
5208 )
5209 .introspection_url("https://idp.example.com/introspect")
5210 .expose_admin_endpoints(true)
5211 .build(),
5212 );
5213 let err = cfg
5214 .validate()
5215 .expect_err("expose_admin_endpoints without auth must fail");
5216 let msg = err.to_string();
5217 assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
5218 assert!(
5219 msg.contains("allow_unauthenticated_admin_endpoints"),
5220 "{msg}"
5221 );
5222 }
5223
5224 #[test]
5225 fn validate_accepts_exposed_admin_endpoints_with_auth() {
5226 let mut cfg = validation_https_config();
5227 cfg.proxy = Some(
5228 OAuthProxyConfig::builder(
5229 "https://idp.example.com/authorize",
5230 "https://idp.example.com/token",
5231 "client",
5232 )
5233 .introspection_url("https://idp.example.com/introspect")
5234 .expose_admin_endpoints(true)
5235 .require_auth_on_admin_endpoints(true)
5236 .build(),
5237 );
5238 cfg.validate()
5239 .expect("authed admin endpoints must validate");
5240 }
5241
5242 #[test]
5243 fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
5244 let mut cfg = validation_https_config();
5245 cfg.proxy = Some(
5246 OAuthProxyConfig::builder(
5247 "https://idp.example.com/authorize",
5248 "https://idp.example.com/token",
5249 "client",
5250 )
5251 .introspection_url("https://idp.example.com/introspect")
5252 .expose_admin_endpoints(true)
5253 .allow_unauthenticated_admin_endpoints(true)
5254 .build(),
5255 );
5256 cfg.validate()
5257 .expect("explicit unauth opt-out must validate");
5258 }
5259
5260 #[test]
5261 fn validate_accepts_unexposed_admin_endpoints_without_auth() {
5262 let mut cfg = validation_https_config();
5265 cfg.proxy = Some(
5266 OAuthProxyConfig::builder(
5267 "https://idp.example.com/authorize",
5268 "https://idp.example.com/token",
5269 "client",
5270 )
5271 .introspection_url("https://idp.example.com/introspect")
5272 .build(),
5273 );
5274 cfg.validate()
5275 .expect("unexposed admin endpoints must validate");
5276 }
5277
5278 #[test]
5279 fn validate_rejects_http_token_exchange_url() {
5280 let mut cfg = validation_https_config();
5281 cfg.token_exchange = Some(
5282 TokenExchangeConfig::new(
5283 "http://idp.example.com/token", "client",
5285 None,
5286 None,
5287 )
5288 .with_audience("downstream"),
5289 );
5290 let err = cfg
5291 .validate()
5292 .expect_err("http token_exchange.token_url must be rejected");
5293 assert!(
5294 err.to_string().contains("oauth.token_exchange.token_url"),
5295 "error must reference token_exchange.token_url; got {err}"
5296 );
5297 }
5298
5299 #[test]
5300 fn validate_rejects_unparseable_url() {
5301 let mut cfg = validation_https_config();
5302 cfg.jwks_uri = "not a url".into();
5303 let err = cfg
5304 .validate()
5305 .expect_err("unparseable URL must be rejected");
5306 assert!(err.to_string().contains("invalid URL"));
5307 }
5308
5309 #[test]
5310 fn validate_rejects_non_http_scheme() {
5311 let mut cfg = validation_https_config();
5312 cfg.jwks_uri = "file:///etc/passwd".into();
5313 let err = cfg.validate().expect_err("file:// scheme must be rejected");
5314 let msg = err.to_string();
5315 assert!(
5316 msg.contains("must use https scheme") && msg.contains("file"),
5317 "error must reject non-http(s) schemes; got {msg:?}"
5318 );
5319 }
5320
5321 #[test]
5322 fn validate_accepts_http_with_escape_hatch() {
5323 let mut cfg = OAuthConfig::builder(
5328 "http://auth.local",
5329 "mcp",
5330 "http://auth.local/.well-known/jwks.json",
5331 )
5332 .allow_http_oauth_urls(true)
5333 .build();
5334 cfg.proxy = Some(
5335 OAuthProxyConfig::builder(
5336 "http://idp.local/authorize",
5337 "http://idp.local/token",
5338 "client",
5339 )
5340 .introspection_url("http://idp.local/introspect")
5341 .revocation_url("http://idp.local/revoke")
5342 .build(),
5343 );
5344 cfg.token_exchange = Some(
5345 TokenExchangeConfig::new(
5346 "http://idp.local/token",
5347 "client",
5348 Some(secrecy::SecretString::new("dev-secret".into())),
5349 None,
5350 )
5351 .with_audience("downstream"),
5352 );
5353 cfg.validate()
5354 .expect("escape hatch must permit http on all URL fields");
5355 }
5356
5357 #[test]
5358 fn validate_with_escape_hatch_still_rejects_unparseable() {
5359 let mut cfg = validation_https_config();
5362 cfg.allow_http_oauth_urls = true;
5363 cfg.jwks_uri = "::not-a-url::".into();
5364 cfg.validate()
5365 .expect_err("escape hatch must NOT bypass URL parsing");
5366 }
5367
5368 #[tokio::test]
5369 async fn jwks_cache_rejects_redirect_downgrade_to_http() {
5370 rustls::crypto::ring::default_provider()
5385 .install_default()
5386 .ok();
5387
5388 let policy = reqwest::redirect::Policy::custom(|attempt| {
5389 if attempt.url().scheme() != "https" {
5390 attempt.error("redirect to non-HTTPS URL refused")
5391 } else if attempt.previous().len() >= 2 {
5392 attempt.error("too many redirects (max 2)")
5393 } else {
5394 attempt.follow()
5395 }
5396 });
5397 let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
5404 let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
5405 let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
5406 crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
5407 );
5408 let client = reqwest::Client::builder()
5409 .no_proxy()
5410 .dns_resolver(Arc::clone(&resolver))
5411 .timeout(Duration::from_secs(5))
5412 .connect_timeout(Duration::from_secs(3))
5413 .redirect(policy)
5414 .build()
5415 .expect("test client builds");
5416
5417 let mock = wiremock::MockServer::start().await;
5418 wiremock::Mock::given(wiremock::matchers::method("GET"))
5419 .and(wiremock::matchers::path("/jwks.json"))
5420 .respond_with(
5421 wiremock::ResponseTemplate::new(302)
5422 .insert_header("location", "http://example.invalid/jwks.json"),
5423 )
5424 .mount(&mock)
5425 .await;
5426
5427 let url = format!("{}/jwks.json", mock.uri());
5436 let err = client
5437 .get(&url)
5438 .send()
5439 .await
5440 .expect_err("redirect policy must reject scheme downgrade");
5441 let chain = format!("{err:#}");
5442 assert!(
5443 chain.contains("redirect to non-HTTPS URL refused")
5444 || chain.to_lowercase().contains("redirect"),
5445 "error must surface redirect-policy rejection; got {chain:?}"
5446 );
5447 }
5448
5449 use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
5454
5455 fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
5457 let mut rng = rsa::rand_core::OsRng;
5458 let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
5459 let private_pem = private_key
5460 .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
5461 .expect("PKCS8 PEM export")
5462 .to_string();
5463
5464 let public_key = private_key.to_public_key();
5465 let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
5466 let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
5467
5468 let jwks = serde_json::json!({
5469 "keys": [{
5470 "kty": "RSA",
5471 "use": "sig",
5472 "alg": "RS256",
5473 "kid": kid,
5474 "n": n,
5475 "e": e
5476 }]
5477 });
5478
5479 (private_pem, jwks)
5480 }
5481
5482 fn mint_token(
5484 private_pem: &str,
5485 kid: &str,
5486 issuer: &str,
5487 audience: &str,
5488 subject: &str,
5489 scope: &str,
5490 ) -> String {
5491 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5492 .expect("encoding key from PEM");
5493 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5494 header.kid = Some(kid.into());
5495
5496 let now = jsonwebtoken::get_current_timestamp();
5497 let claims = serde_json::json!({
5498 "iss": issuer,
5499 "aud": audience,
5500 "sub": subject,
5501 "scope": scope,
5502 "exp": now + 3600,
5503 "iat": now,
5504 });
5505
5506 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5507 }
5508
5509 fn mint_token_without_sub(
5511 private_pem: &str,
5512 kid: &str,
5513 issuer: &str,
5514 audience: &str,
5515 scope: &str,
5516 ) -> String {
5517 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5518 .expect("encoding key from PEM");
5519 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5520 header.kid = Some(kid.into());
5521 let now = jsonwebtoken::get_current_timestamp();
5522 let claims = serde_json::json!({
5523 "iss": issuer,
5524 "aud": audience,
5525 "scope": scope,
5526 "exp": now + 3600,
5527 "iat": now,
5528 });
5529 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5530 }
5531
5532 fn test_config(jwks_uri: &str) -> OAuthConfig {
5533 OAuthConfig {
5534 require_subject: false,
5535 issuer: "https://auth.test.local".into(),
5536 audience: "https://mcp.test.local/mcp".into(),
5537 jwks_uri: jwks_uri.into(),
5538 scopes: vec![
5539 ScopeMapping {
5540 scope: "mcp:read".into(),
5541 role: "viewer".into(),
5542 },
5543 ScopeMapping {
5544 scope: "mcp:admin".into(),
5545 role: "ops".into(),
5546 },
5547 ],
5548 role_claim: None,
5549 role_mappings: vec![],
5550 jwks_cache_ttl: "5m".into(),
5551 proxy: None,
5552 token_exchange: None,
5553 ca_cert_path: None,
5554 allow_http_oauth_urls: true,
5555 max_jwks_keys: default_max_jwks_keys(),
5556 allowed_algorithms: None,
5557 authorization_servers: None,
5558 authorization_server_metadata_issuer: None,
5559 #[allow(
5560 deprecated,
5561 reason = "test fixture: explicit value for the deprecated field"
5562 )]
5563 strict_audience_validation: None,
5564 audience_validation_mode: None,
5565 jwks_max_response_bytes: default_jwks_max_bytes(),
5566 ssrf_allowlist: None,
5567 }
5568 }
5569
5570 fn test_cache(config: &OAuthConfig) -> JwksCache {
5571 JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
5572 }
5573
5574 async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
5581 let kid = "test-h2-stale";
5582 let (pem, jwks) = generate_test_keypair(kid);
5583 let mock_server = wiremock::MockServer::start().await;
5584 wiremock::Mock::given(wiremock::matchers::method("GET"))
5585 .and(wiremock::matchers::path("/jwks.json"))
5586 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5587 .mount(&mock_server)
5588 .await;
5589 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5590 let mut config = test_config(&jwks_uri);
5591 config.jwks_cache_ttl = ttl.into();
5592 let cache = test_cache(&config);
5593 cache.__test_refresh_now().await.expect("prime JWKS cache");
5594 assert!(cache.__test_has_kid(kid).await, "kid must be primed");
5595
5596 mock_server.reset().await;
5597 wiremock::Mock::given(wiremock::matchers::method("GET"))
5598 .and(wiremock::matchers::path("/jwks.json"))
5599 .respond_with(wiremock::ResponseTemplate::new(503))
5600 .mount(&mock_server)
5601 .await;
5602
5603 let token = mint_token(
5604 &pem,
5605 kid,
5606 "https://auth.test.local",
5607 "https://mcp.test.local/mcp",
5608 "h2-client",
5609 "mcp:read",
5610 );
5611 (cache, token, mock_server)
5612 }
5613
5614 #[test]
5615 fn build_key_cache_last_duplicate_kid_wins() {
5616 let (_pem, jwks_json) = generate_test_keypair("dup-kid");
5617 let entry = jwks_json["keys"][0].clone();
5618 let merged = serde_json::json!({ "keys": [entry.clone(), entry] });
5619 let jwks: JwkSet = serde_json::from_value(merged).expect("merged jwks parses");
5620 assert_eq!(jwks.keys.len(), 2, "fixture must carry two colliding kids");
5621
5622 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5623 assert_eq!(keys.len(), 1, "colliding kids collapse to one entry");
5624 assert!(keys.contains_key("dup-kid"));
5625 assert!(unnamed.is_empty());
5626 }
5627
5628 #[test]
5629 fn build_key_cache_rejects_keys_not_marked_for_signature_verification() {
5630 let (_pem, jwks_json) = generate_test_keypair("enc-only");
5634
5635 let mut enc = jwks_json["keys"][0].clone();
5636 enc["use"] = serde_json::json!("enc");
5637 let jwks: JwkSet =
5638 serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5639 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5640 assert!(
5641 keys.is_empty(),
5642 "use=enc key must not be a verification key"
5643 );
5644 assert!(unnamed.is_empty());
5645
5646 let mut wrap_only = jwks_json["keys"][0].clone();
5647 wrap_only["key_ops"] = serde_json::json!(["wrapKey"]);
5648 let jwks: JwkSet = serde_json::from_value(serde_json::json!({ "keys": [wrap_only] }))
5649 .expect("jwks parses");
5650 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5651 assert!(keys.is_empty(), "key_ops without verify must be rejected");
5652 assert!(unnamed.is_empty());
5653 }
5654
5655 #[test]
5656 fn build_key_cache_accepts_sig_and_unconstrained_keys() {
5657 let (_pem, jwks_json) = generate_test_keypair("sig-key");
5658
5659 let jwks: JwkSet = serde_json::from_value(jwks_json.clone()).expect("jwks parses");
5661 let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5662 assert!(keys.contains_key("sig-key"));
5663
5664 let mut sig = jwks_json["keys"][0].clone();
5665 sig["use"] = serde_json::json!("sig");
5666 sig["key_ops"] = serde_json::json!(["verify"]);
5667 let jwks: JwkSet =
5668 serde_json::from_value(serde_json::json!({ "keys": [sig] })).expect("jwks parses");
5669 let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5670 assert!(keys.contains_key("sig-key"));
5671 }
5672
5673 fn jwks_without_alg(jwks: &serde_json::Value) -> JwkSet {
5682 let mut key = jwks["keys"][0].clone();
5683 if let Some(obj) = key.as_object_mut() {
5684 obj.remove("alg");
5685 }
5686 serde_json::from_value(serde_json::json!({ "keys": [key] })).expect("alg-less jwks parses")
5687 }
5688
5689 #[test]
5690 fn alg_less_rsa_key_is_cached_as_rsa_family() {
5691 let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5692 let jwks = jwks_without_alg(&jwks_json);
5693 assert!(
5694 jwks.keys[0].common.key_algorithm.is_none(),
5695 "fixture must omit `alg`"
5696 );
5697
5698 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5699 assert!(unnamed.is_empty());
5700 let (cached_alg, _) = keys.get("entra-kid").expect("alg-less key must be cached");
5701 assert_eq!(*cached_alg, JwkAlg::Family(JwkKeyFamily::Rsa));
5702 }
5703
5704 #[test]
5705 fn alg_less_rsa_key_accepts_rsa_family_and_rejects_others() {
5706 let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5707 let cached = CachedKeys {
5708 keys: build_key_cache(&jwks_without_alg(&jwks_json), 16)
5709 .expect("under key cap")
5710 .0,
5711 unnamed_keys: vec![],
5712 fetched_at: Instant::now(),
5713 ttl: Duration::from_secs(300),
5714 };
5715
5716 for alg in [
5717 Algorithm::RS256,
5718 Algorithm::RS384,
5719 Algorithm::RS512,
5720 Algorithm::PS256,
5721 Algorithm::PS384,
5722 Algorithm::PS512,
5723 ] {
5724 assert!(
5725 lookup_key(&cached, Some("entra-kid"), alg).is_some(),
5726 "{alg:?} is producible by an RSA key and must resolve"
5727 );
5728 }
5729 assert!(lookup_key(&cached, Some("entra-kid"), Algorithm::ES256).is_none());
5731 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
5733 }
5734
5735 #[test]
5736 fn alg_less_key_never_accepts_hmac_algorithm_confusion() {
5737 assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS256));
5742 assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS384));
5743 assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS512));
5744 assert!(!family_accepts(JwkKeyFamily::EcP256, Algorithm::HS256));
5745 assert!(!family_accepts(JwkKeyFamily::Ed25519, Algorithm::HS256));
5746 }
5747
5748 #[test]
5749 fn family_accepts_is_subset_of_accepted_algs() {
5750 let every_alg = [
5753 Algorithm::HS256,
5754 Algorithm::HS384,
5755 Algorithm::HS512,
5756 Algorithm::RS256,
5757 Algorithm::RS384,
5758 Algorithm::RS512,
5759 Algorithm::ES256,
5760 Algorithm::ES384,
5761 Algorithm::PS256,
5762 Algorithm::PS384,
5763 Algorithm::PS512,
5764 Algorithm::EdDSA,
5765 ];
5766 for family in [
5767 JwkKeyFamily::Rsa,
5768 JwkKeyFamily::EcP256,
5769 JwkKeyFamily::EcP384,
5770 JwkKeyFamily::Ed25519,
5771 ] {
5772 for alg in every_alg {
5773 if family_accepts(family, alg) {
5774 assert!(
5775 ACCEPTED_ALGS.contains(&alg),
5776 "{family:?} admits {alg:?}, which is outside ACCEPTED_ALGS"
5777 );
5778 }
5779 }
5780 }
5781 }
5782
5783 #[test]
5784 fn explicit_alg_still_pins_exactly_one_algorithm() {
5785 let (_pem, jwks_json) = generate_test_keypair("pinned");
5788 let jwks: JwkSet = serde_json::from_value(jwks_json).expect("jwks parses");
5789 let cached = CachedKeys {
5790 keys: build_key_cache(&jwks, 16).expect("under key cap").0,
5791 unnamed_keys: vec![],
5792 fetched_at: Instant::now(),
5793 ttl: Duration::from_secs(300),
5794 };
5795 assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS256).is_some());
5796 assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS384).is_none());
5797 }
5798
5799 #[test]
5800 fn alg_less_key_still_subject_to_use_and_key_ops_gate() {
5801 let (_pem, jwks_json) = generate_test_keypair("gated");
5805
5806 let mut enc = jwks_json["keys"][0].clone();
5807 if let Some(obj) = enc.as_object_mut() {
5808 obj.remove("alg");
5809 }
5810 enc["use"] = serde_json::json!("enc");
5811 let jwks: JwkSet =
5812 serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5813 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5814 assert!(
5815 keys.is_empty() && unnamed.is_empty(),
5816 "use=enc must be dropped"
5817 );
5818
5819 let mut wrap = jwks_json["keys"][0].clone();
5820 if let Some(obj) = wrap.as_object_mut() {
5821 obj.remove("alg");
5822 obj.remove("use");
5823 }
5824 wrap["key_ops"] = serde_json::json!(["wrapKey"]);
5825 let jwks: JwkSet =
5826 serde_json::from_value(serde_json::json!({ "keys": [wrap] })).expect("jwks parses");
5827 let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5828 assert!(
5829 keys.is_empty() && unnamed.is_empty(),
5830 "key_ops without verify must be dropped"
5831 );
5832 }
5833
5834 #[test]
5837 fn accepted_algorithm_names_cover_accepted_algs() {
5838 for alg in ACCEPTED_ALGS {
5841 let name = accepted_algorithm_name(*alg)
5842 .unwrap_or_else(|| panic!("{alg:?} is accepted but has no configurable name"));
5843 assert_eq!(accepted_algorithm_from_name(name), Some(*alg));
5844 }
5845 assert_eq!(
5846 accepted_algorithm_names().split(", ").count(),
5847 ACCEPTED_ALGS.len()
5848 );
5849 }
5850
5851 #[test]
5852 fn allowed_algorithms_cannot_widen_beyond_accepted_algs() {
5853 for name in ["HS256", "HS384", "HS512", "none", "ES512", "RS1"] {
5857 assert!(
5858 accepted_algorithm_from_name(name).is_none(),
5859 "{name} must not be resolvable"
5860 );
5861 let err = resolve_allowed_algorithms(Some(&[name.to_owned()]))
5862 .expect_err("must reject non-accepted algorithm");
5863 assert!(err.to_string().contains("unsupported algorithm"));
5864 }
5865 }
5866
5867 #[test]
5868 fn allowed_algorithms_rejects_empty_list() {
5869 let err =
5870 resolve_allowed_algorithms(Some(&[])).expect_err("empty list would reject every token");
5871 assert!(err.to_string().contains("must not be empty"));
5872 }
5873
5874 #[test]
5875 fn allowed_algorithms_defaults_to_full_accepted_set() {
5876 assert_eq!(
5877 resolve_allowed_algorithms(None).expect("default resolves"),
5878 ACCEPTED_ALGS.to_vec()
5879 );
5880 }
5881
5882 #[test]
5883 fn allowed_algorithms_narrows_and_dedups_case_insensitively() {
5884 let resolved = resolve_allowed_algorithms(Some(&[
5885 "rs256".to_owned(),
5886 "RS256".to_owned(),
5887 "ES384".to_owned(),
5888 ]))
5889 .expect("valid subset");
5890 assert_eq!(resolved, vec![Algorithm::RS256, Algorithm::ES384]);
5891 }
5892
5893 #[test]
5894 fn allowed_algorithms_surfaces_through_config_validate() {
5895 let mut cfg = test_config("https://idp.test.local/jwks.json");
5896 cfg.allowed_algorithms = Some(vec!["HS256".to_owned()]);
5897 let err = cfg.validate().expect_err("HS256 must fail validation");
5898 assert!(err.to_string().contains("unsupported algorithm"));
5899
5900 cfg.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5901 cfg.validate().expect("a valid subset must validate");
5902 }
5903
5904 #[tokio::test]
5905 async fn narrowed_allowed_algorithms_rejects_excluded_but_otherwise_valid_token() {
5906 let kid = "narrowing-kid";
5910 let (pem, jwks) = generate_test_keypair(kid);
5911
5912 let mock_server = wiremock::MockServer::start().await;
5913 wiremock::Mock::given(wiremock::matchers::method("GET"))
5914 .and(wiremock::matchers::path("/jwks.json"))
5915 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5916 .mount(&mock_server)
5917 .await;
5918
5919 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5920 let token = mint_token(
5921 &pem,
5922 kid,
5923 "https://auth.test.local",
5924 "https://mcp.test.local/mcp",
5925 "narrow-user",
5926 "mcp:admin",
5927 );
5928
5929 let mut permissive = test_config(&jwks_uri);
5930 permissive.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5931 assert!(
5932 test_cache(&permissive)
5933 .validate_token(&token)
5934 .await
5935 .is_some(),
5936 "RS256 token must authenticate when RS256 is allowed"
5937 );
5938
5939 let mut narrowed = test_config(&jwks_uri);
5940 narrowed.allowed_algorithms = Some(vec!["ES384".to_owned()]);
5941 assert!(
5942 test_cache(&narrowed).validate_token(&token).await.is_none(),
5943 "RS256 token must be rejected when only ES384 is allowed"
5944 );
5945 }
5946
5947 #[test]
5948 fn truncate_kid_for_log_bounds_hostile_input() {
5949 let short = "kid-1";
5950 assert_eq!(truncate_kid_for_log(short), (short.to_owned(), false));
5951
5952 let long = "k".repeat(4096);
5953 let (truncated, was_truncated) = truncate_kid_for_log(&long);
5954 assert!(was_truncated);
5955 assert!(truncated.ends_with("...(truncated)"));
5956 assert_eq!(
5957 truncated.chars().count(),
5958 MAX_LOGGED_KID_CHARS + "...(truncated)".chars().count()
5959 );
5960 }
5961
5962 #[test]
5963 fn truncate_kid_for_log_splits_on_char_boundary() {
5964 let multibyte = "\u{1f512}".repeat(MAX_LOGGED_KID_CHARS + 10);
5965 let (truncated, was_truncated) = truncate_kid_for_log(&multibyte);
5966 assert!(was_truncated);
5967 assert!(truncated.starts_with('\u{1f512}'));
5968 assert!(truncated.ends_with("...(truncated)"));
5969 }
5970
5971 #[test]
5972 fn truncate_kid_for_log_flag_marks_exact_boundary_as_untruncated() {
5973 let exact = "k".repeat(MAX_LOGGED_KID_CHARS);
5974 let (out, was_truncated) = truncate_kid_for_log(&exact);
5975 assert!(!was_truncated, "a kid exactly at the cap is not truncated");
5976 assert_eq!(out, exact);
5977 }
5978
5979 #[tokio::test]
5980 async fn expired_jwks_fails_closed_when_refresh_fails() {
5981 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
5982 tokio::time::sleep(Duration::from_millis(200)).await;
5983 let failure = cache
5984 .validate_token_with_reason(&token)
5985 .await
5986 .expect_err("an expired cache whose refresh fails must not serve the stale key");
5987 assert_eq!(failure, JwtValidationFailure::Invalid);
5988 }
5989
5990 #[tokio::test]
5991 async fn fresh_jwks_still_validates() {
5992 let kid = "test-h2-fresh";
5993 let (pem, jwks) = generate_test_keypair(kid);
5994 let mock_server = wiremock::MockServer::start().await;
5995 wiremock::Mock::given(wiremock::matchers::method("GET"))
5996 .and(wiremock::matchers::path("/jwks.json"))
5997 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5998 .mount(&mock_server)
5999 .await;
6000 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6001 let config = test_config(&jwks_uri); let cache = test_cache(&config);
6003 let token = mint_token(
6004 &pem,
6005 kid,
6006 "https://auth.test.local",
6007 "https://mcp.test.local/mcp",
6008 "h2-fresh-client",
6009 "mcp:read",
6010 );
6011 cache
6012 .validate_token_with_reason(&token)
6013 .await
6014 .expect("a reachable JWKS must still validate a matching token");
6015 }
6016
6017 #[tokio::test]
6018 async fn cooldown_active_plus_expired_fails_closed() {
6019 let (cache, token, _mock) = h2_prime_then_break("80ms").await;
6020 tokio::time::sleep(Duration::from_millis(200)).await;
6021 assert_eq!(
6024 cache
6025 .validate_token_with_reason(&token)
6026 .await
6027 .expect_err("first attempt must fail closed"),
6028 JwtValidationFailure::Invalid,
6029 );
6030 let failure = cache
6033 .validate_token_with_reason(&token)
6034 .await
6035 .expect_err("cooldown-active + expired cache must still fail closed");
6036 assert_eq!(failure, JwtValidationFailure::Invalid);
6037 }
6038
6039 #[tokio::test]
6040 async fn valid_jwt_returns_identity() {
6041 let kid = "test-key-1";
6042 let (pem, jwks) = generate_test_keypair(kid);
6043
6044 let mock_server = wiremock::MockServer::start().await;
6045 wiremock::Mock::given(wiremock::matchers::method("GET"))
6046 .and(wiremock::matchers::path("/jwks.json"))
6047 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6048 .mount(&mock_server)
6049 .await;
6050
6051 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6052 let config = test_config(&jwks_uri);
6053 let cache = test_cache(&config);
6054
6055 let token = mint_token(
6056 &pem,
6057 kid,
6058 "https://auth.test.local",
6059 "https://mcp.test.local/mcp",
6060 "ci-bot",
6061 "mcp:read mcp:other",
6062 );
6063
6064 let identity = cache.validate_token(&token).await;
6065 assert!(identity.is_some(), "valid JWT should authenticate");
6066 let id = identity.unwrap();
6067 assert_eq!(id.name, "ci-bot");
6068 assert_eq!(id.role, "viewer"); assert_eq!(id.method, AuthMethod::OAuthJwt);
6070 assert_eq!(id.sub.as_deref(), Some("ci-bot"));
6077 }
6078
6079 #[test]
6082 fn unknown_kid_with_named_keys_rejected() {
6083 let mut keys = HashMap::new();
6084 keys.insert(
6085 "kid-1".to_owned(),
6086 (
6087 JwkAlg::Explicit(Algorithm::RS256),
6088 DecodingKey::from_secret(b"named"),
6089 ),
6090 );
6091 let cached = CachedKeys {
6092 keys,
6093 unnamed_keys: vec![(
6094 JwkAlg::Explicit(Algorithm::RS256),
6095 DecodingKey::from_secret(b"unnamed"),
6096 )],
6097 fetched_at: Instant::now(),
6098 ttl: Duration::from_secs(300),
6099 };
6100 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
6102 assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
6106 assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
6108 }
6109
6110 #[test]
6111 fn no_kid_token_matches_unnamed_key() {
6112 let mut keys = HashMap::new();
6113 keys.insert(
6114 "kid-1".to_owned(),
6115 (
6116 JwkAlg::Explicit(Algorithm::RS256),
6117 DecodingKey::from_secret(b"named"),
6118 ),
6119 );
6120 let cached = CachedKeys {
6121 keys,
6122 unnamed_keys: vec![(
6123 JwkAlg::Explicit(Algorithm::RS256),
6124 DecodingKey::from_secret(b"unnamed"),
6125 )],
6126 fetched_at: Instant::now(),
6127 ttl: Duration::from_secs(300),
6128 };
6129 assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
6132 }
6133
6134 #[tokio::test]
6135 async fn require_subject_rejects_subject_less() {
6136 let kid = "test-key-reqsub";
6137 let (pem, jwks) = generate_test_keypair(kid);
6138 let mock_server = wiremock::MockServer::start().await;
6139 wiremock::Mock::given(wiremock::matchers::method("GET"))
6140 .and(wiremock::matchers::path("/jwks.json"))
6141 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6142 .mount(&mock_server)
6143 .await;
6144 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6145 let mut config = test_config(&jwks_uri);
6146 config.require_subject = true;
6147 let cache = test_cache(&config);
6148
6149 let no_sub = mint_token_without_sub(
6150 &pem,
6151 kid,
6152 "https://auth.test.local",
6153 "https://mcp.test.local/mcp",
6154 "mcp:read",
6155 );
6156 assert!(
6157 cache.validate_token(&no_sub).await.is_none(),
6158 "require_subject must reject a token with no sub"
6159 );
6160
6161 let with_sub = mint_token(
6162 &pem,
6163 kid,
6164 "https://auth.test.local",
6165 "https://mcp.test.local/mcp",
6166 "svc",
6167 "mcp:read",
6168 );
6169 assert!(
6170 cache.validate_token(&with_sub).await.is_some(),
6171 "a token carrying sub must still be accepted"
6172 );
6173 }
6174
6175 #[tokio::test]
6176 async fn subject_less_token_accepted_by_default() {
6177 let kid = "test-key-nosub-default";
6178 let (pem, jwks) = generate_test_keypair(kid);
6179 let mock_server = wiremock::MockServer::start().await;
6180 wiremock::Mock::given(wiremock::matchers::method("GET"))
6181 .and(wiremock::matchers::path("/jwks.json"))
6182 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6183 .mount(&mock_server)
6184 .await;
6185 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6186 let config = test_config(&jwks_uri); let cache = test_cache(&config);
6188 let no_sub = mint_token_without_sub(
6189 &pem,
6190 kid,
6191 "https://auth.test.local",
6192 "https://mcp.test.local/mcp",
6193 "mcp:read",
6194 );
6195 let identity = cache.validate_token(&no_sub).await;
6196 assert!(
6197 identity.is_some(),
6198 "the default policy must accept a sub-less (client-credentials) token"
6199 );
6200 assert!(
6205 identity.and_then(|id| id.sub).is_none(),
6206 "a sub-less token must not synthesise a subject"
6207 );
6208 }
6209
6210 fn mint_token_with_extra(
6211 private_pem: &str,
6212 kid: &str,
6213 issuer: &str,
6214 audience: &str,
6215 extra: &serde_json::Value,
6216 ) -> String {
6217 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
6218 .expect("encoding key from PEM");
6219 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
6220 header.kid = Some(kid.into());
6221 let now = jsonwebtoken::get_current_timestamp();
6222 let mut claims = serde_json::json!({
6223 "iss": issuer,
6224 "aud": audience,
6225 "scope": "mcp:read",
6226 "exp": now + 3600,
6227 "iat": now,
6228 });
6229 if let (Some(base), Some(extra)) = (claims.as_object_mut(), extra.as_object()) {
6230 for (key, value) in extra {
6231 base.insert(key.clone(), value.clone());
6232 }
6233 }
6234 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
6235 }
6236
6237 async fn blank_claim_cache(require_subject: bool) -> (JwksCache, String, wiremock::MockServer) {
6238 let kid = "blank-claim-kid";
6239 let (pem, jwks) = generate_test_keypair(kid);
6240 let mock_server = wiremock::MockServer::start().await;
6241 wiremock::Mock::given(wiremock::matchers::method("GET"))
6242 .and(wiremock::matchers::path("/jwks.json"))
6243 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6244 .mount(&mock_server)
6245 .await;
6246 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6247 let mut config = test_config(&jwks_uri);
6248 config.require_subject = require_subject;
6249 let cache = test_cache(&config);
6250 (cache, pem, mock_server)
6251 }
6252
6253 #[tokio::test]
6254 async fn oauth_blank_preferred_username_falls_through_to_sub() {
6255 let (cache, pem, _server) = blank_claim_cache(false).await;
6256 let token = mint_token_with_extra(
6257 &pem,
6258 "blank-claim-kid",
6259 "https://auth.test.local",
6260 "https://mcp.test.local/mcp",
6261 &serde_json::json!({ "sub": "real-sub", "preferred_username": "" }),
6262 );
6263 let id = cache
6264 .validate_token(&token)
6265 .await
6266 .expect("a token with a usable sub must authenticate");
6267 assert_eq!(
6268 id.name, "real-sub",
6269 "blank preferred_username must be skipped"
6270 );
6271 assert_eq!(id.sub.as_deref(), Some("real-sub"));
6272 }
6273
6274 #[tokio::test]
6275 async fn oauth_all_blank_claims_yield_non_blank_name_and_fingerprint() {
6276 let (cache, pem, _server) = blank_claim_cache(false).await;
6277 let token = mint_token_with_extra(
6278 &pem,
6279 "blank-claim-kid",
6280 "https://auth.test.local",
6281 "https://mcp.test.local/mcp",
6282 &serde_json::json!({
6283 "sub": "",
6284 "preferred_username": " ",
6285 "azp": "",
6286 "client_id": " ",
6287 }),
6288 );
6289 let id = cache
6290 .validate_token(&token)
6291 .await
6292 .expect("all-blank identity claims still authenticate on a valid token");
6293 assert_eq!(
6294 id.name, "oauth-client",
6295 "all-blank claims must fall to the sentinel"
6296 );
6297 assert!(id.sub.is_none(), "a blank sub must be stored as None");
6298 let _fingerprint = crate::session_binding::fingerprint(&id);
6300 }
6301
6302 #[tokio::test]
6303 async fn oauth_blank_sub_rejected_when_require_subject() {
6304 let (cache, pem, _server) = blank_claim_cache(true).await;
6305 let token = mint_token_with_extra(
6306 &pem,
6307 "blank-claim-kid",
6308 "https://auth.test.local",
6309 "https://mcp.test.local/mcp",
6310 &serde_json::json!({ "sub": " " }),
6311 );
6312 assert!(
6313 cache.validate_token(&token).await.is_none(),
6314 "require_subject must reject a blank sub"
6315 );
6316 }
6317
6318 #[tokio::test]
6319 async fn oauth_blank_sub_stored_as_none() {
6320 let (cache, pem, _server) = blank_claim_cache(false).await;
6321 let token = mint_token_with_extra(
6322 &pem,
6323 "blank-claim-kid",
6324 "https://auth.test.local",
6325 "https://mcp.test.local/mcp",
6326 &serde_json::json!({ "sub": "" }),
6327 );
6328 let id = cache
6329 .validate_token(&token)
6330 .await
6331 .expect("a blank sub is accepted by default (require_subject off)");
6332 assert!(id.sub.is_none(), "a blank sub must be stored as None");
6333 assert_eq!(id.name, "oauth-client");
6334 }
6335
6336 #[tokio::test]
6337 async fn oauth_blank_preferred_and_sub_fall_through_to_azp() {
6338 let (cache, pem, _server) = blank_claim_cache(false).await;
6339 let token = mint_token_with_extra(
6340 &pem,
6341 "blank-claim-kid",
6342 "https://auth.test.local",
6343 "https://mcp.test.local/mcp",
6344 &serde_json::json!({ "sub": "", "preferred_username": " ", "azp": "svc-account" }),
6345 );
6346 let id = cache
6347 .validate_token(&token)
6348 .await
6349 .expect("a usable azp must authenticate");
6350 assert_eq!(
6351 id.name, "svc-account",
6352 "must fall through to a non-blank azp"
6353 );
6354 assert!(id.sub.is_none(), "a blank sub must be stored as None");
6355 }
6356
6357 #[tokio::test]
6358 async fn oauth_blank_azp_falls_through_to_client_id() {
6359 let (cache, pem, _server) = blank_claim_cache(false).await;
6360 let token = mint_token_with_extra(
6361 &pem,
6362 "blank-claim-kid",
6363 "https://auth.test.local",
6364 "https://mcp.test.local/mcp",
6365 &serde_json::json!({
6366 "sub": "",
6367 "preferred_username": "",
6368 "azp": " ",
6369 "client_id": "svc-client",
6370 }),
6371 );
6372 let id = cache
6373 .validate_token(&token)
6374 .await
6375 .expect("a usable client_id must authenticate");
6376 assert_eq!(
6377 id.name, "svc-client",
6378 "must fall through past a blank azp to a non-blank client_id"
6379 );
6380 }
6381
6382 #[tokio::test]
6383 async fn credential_post_does_not_follow_redirect() {
6384 let mock = wiremock::MockServer::start().await;
6387 wiremock::Mock::given(wiremock::matchers::method("POST"))
6388 .and(wiremock::matchers::path("/followed"))
6389 .respond_with(wiremock::ResponseTemplate::new(200))
6390 .expect(0) .mount(&mock)
6392 .await;
6393 wiremock::Mock::given(wiremock::matchers::method("POST"))
6394 .and(wiremock::matchers::path("/token"))
6395 .respond_with(
6396 wiremock::ResponseTemplate::new(307)
6397 .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
6398 )
6399 .mount(&mock)
6400 .await;
6401
6402 let client = OauthHttpClient::build(None).expect("build oauth http client");
6403 let resp = client
6404 .credential_client
6405 .post(format!("{}/token", mock.uri()))
6406 .body("grant_type=client_credentials")
6407 .send()
6408 .await
6409 .expect("request sent");
6410 assert_eq!(
6411 resp.status().as_u16(),
6412 307,
6413 "credential client must surface the 307 rather than follow it"
6414 );
6415 }
6416
6417 fn test_token_exchange_config(token_url: String) -> TokenExchangeConfig {
6418 TokenExchangeConfig::new(
6419 token_url,
6420 "mcp-client",
6421 Some(secrecy::SecretString::new("test-client-secret".into())),
6422 None,
6423 )
6424 .with_audience("downstream-api")
6425 }
6426
6427 const ENC_GRANT: &str = "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange";
6428 const ENC_ACCESS: &str = "urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token";
6429
6430 #[test]
6431 fn build_exchange_form_is_byte_identical_to_pre_3_8_0_output() {
6432 let config = test_token_exchange_config("https://idp.example.com/token".into());
6433 let body = build_exchange_form(&config, "subj-token");
6434 assert_eq!(
6435 body,
6436 format!(
6437 "grant_type={ENC_GRANT}&subject_token=subj-token\
6438 &subject_token_type={ENC_ACCESS}&requested_token_type={ENC_ACCESS}\
6439 &audience=downstream-api"
6440 ),
6441 "a config predating 3.8.0 must produce an unchanged request body"
6442 );
6443 }
6444
6445 #[test]
6446 fn build_exchange_form_emits_only_required_params_when_all_optional_omitted() {
6447 let config =
6448 TokenExchangeConfig::new("https://idp.example.com/token", "public-client", None, None)
6449 .with_requested_token_type(RequestedTokenType::Omit);
6450 let body = build_exchange_form(&config, "subj");
6451 assert_eq!(
6452 body,
6453 format!(
6454 "grant_type={ENC_GRANT}&subject_token=subj\
6455 &subject_token_type={ENC_ACCESS}&client_id=public-client"
6456 ),
6457 "only the three RFC 8693 §2.1 REQUIRED params plus the public-client id"
6458 );
6459 }
6460
6461 #[test]
6462 fn build_exchange_form_keeps_rfc_parameter_order() {
6463 let config = test_token_exchange_config("https://idp.example.com/token".into())
6464 .with_resource("https://api.example.com/v1")
6465 .with_scope("read write")
6466 .with_requested_token_type(RequestedTokenType::Custom("urn:example:token".into()));
6467 let body = build_exchange_form(&config, "subj");
6468 let keys: Vec<&str> = body
6469 .split('&')
6470 .filter_map(|kv| kv.split('=').next())
6471 .collect();
6472 assert_eq!(
6473 keys,
6474 vec![
6475 "grant_type",
6476 "subject_token",
6477 "subject_token_type",
6478 "requested_token_type",
6479 "audience",
6480 "resource",
6481 "scope",
6482 ]
6483 );
6484 assert!(
6485 body.contains("&requested_token_type=urn%3Aexample%3Atoken"),
6486 "custom token type must be sent verbatim: {body}"
6487 );
6488 }
6489
6490 #[test]
6491 fn token_exchange_toml_omitting_new_keys_still_deserializes() {
6492 let cfg: TokenExchangeConfig = toml::from_str(
6493 "token_url = \"https://idp.example.com/token\"\n\
6494 client_id = \"client\"\n\
6495 audience = \"downstream\"\n",
6496 )
6497 .expect("a token_exchange table predating 3.8.0 must still parse");
6498 assert_eq!(cfg.audience.as_deref(), Some("downstream"));
6499 assert_eq!(cfg.resource, None);
6500 assert_eq!(cfg.scope, None);
6501 assert_eq!(cfg.requested_token_type, RequestedTokenType::AccessToken);
6502 }
6503
6504 #[test]
6505 fn upstream_error_description_is_redacted_by_default() {
6506 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6507 crate::diagnostics::set_diagnostic_exposure(
6508 &crate::diagnostics::DiagnosticExposure::default(),
6509 );
6510
6511 assert_eq!(
6512 upstream_error_description_for_log(Some("subject_token=eyJhbGciOi...")),
6513 "[REDACTED]",
6514 "upstream free-form text must not reach logs unless opted in"
6515 );
6516 assert_eq!(upstream_error_description_for_log(None), "[REDACTED]");
6517 }
6518
6519 #[test]
6520 fn upstream_error_description_is_shown_when_opted_in() {
6521 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6522 crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
6523 upstream_error_bodies: true,
6524 ..crate::diagnostics::DiagnosticExposure::default()
6525 });
6526
6527 assert_eq!(
6528 upstream_error_description_for_log(Some("audience not permitted")),
6529 "audience not permitted",
6530 "the debug switch must surface the upstream description verbatim"
6531 );
6532 assert_eq!(
6533 upstream_error_description_for_log(None),
6534 "",
6535 "an absent description renders empty, not the redaction marker"
6536 );
6537 }
6538
6539 #[test]
6540 fn requested_token_type_deserializes_from_plain_strings() {
6541 for (raw, expected) in [
6542 ("access_token", RequestedTokenType::AccessToken),
6543 ("omit", RequestedTokenType::Omit),
6544 (
6545 "urn:example:token",
6546 RequestedTokenType::Custom("urn:example:token".into()),
6547 ),
6548 ] {
6549 let cfg: TokenExchangeConfig = toml::from_str(&format!(
6550 "token_url = \"https://idp.example.com/token\"\n\
6551 client_id = \"client\"\n\
6552 requested_token_type = \"{raw}\"\n"
6553 ))
6554 .expect("requested_token_type must accept any string");
6555 assert_eq!(cfg.requested_token_type, expected, "input {raw}");
6556 }
6557 }
6558
6559 fn exchange_response(access_token: &str, issued_token_type: &str) -> serde_json::Value {
6560 serde_json::json!({
6561 "access_token": access_token,
6562 "expires_in": 3600_u64,
6563 "issued_token_type": issued_token_type,
6564 })
6565 }
6566
6567 fn unsigned_jwt_with_claims(claims: &serde_json::Value) -> String {
6568 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
6569 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims json"));
6570 format!("{header}.{payload}.signature")
6571 }
6572
6573 fn test_exchange_client() -> OauthHttpClient {
6574 let config = OAuthConfig::builder(
6575 "http://auth.test.local",
6576 "mcp",
6577 "http://auth.test.local/jwks.json",
6578 )
6579 .allow_http_oauth_urls(true)
6580 .build();
6581 OauthHttpClient::build(Some(&config))
6582 .expect("build oauth http client")
6583 .__test_allow_loopback_ssrf()
6584 }
6585
6586 fn unavailable_loopback_token_url() -> String {
6587 "http://127.0.0.1:1/token?client_secret=super-secret".to_owned()
6588 }
6589
6590 async fn recorded_request_count(mock: &wiremock::MockServer) -> usize {
6591 mock.received_requests()
6592 .await
6593 .expect("wiremock request recording is enabled")
6594 .len()
6595 }
6596
6597 async fn wait_for_recorded_request(mock: &wiremock::MockServer) {
6598 tokio::time::timeout(Duration::from_secs(15), async {
6602 loop {
6603 if recorded_request_count(mock).await > 0 {
6604 return;
6605 }
6606 tokio::time::sleep(Duration::from_millis(10)).await;
6607 }
6608 })
6609 .await
6610 .expect("token endpoint must record the in-flight request before cancellation");
6611 }
6612
6613 async fn wait_for_log_contains(logs: &CapturedLogs, needle: &str) {
6614 tokio::time::timeout(Duration::from_secs(15), async {
6619 loop {
6620 if logs.contents().contains(needle) {
6621 return;
6622 }
6623 tokio::time::sleep(Duration::from_millis(10)).await;
6624 }
6625 })
6626 .await
6627 .expect("detached token exchange must eventually emit its audit log");
6628 }
6629
6630 #[tokio::test]
6631 async fn send_screened_request_failure_sanitizes_url_and_reqwest_error() {
6632 let client = test_exchange_client();
6633 let screened_url = unavailable_loopback_token_url();
6634 let request_url = screened_url.replacen("//", "//u:p@", 1);
6635
6636 let error = client
6637 .send_screened(
6638 &screened_url,
6639 client
6640 .credential_client
6641 .post(&request_url)
6642 .body("grant_type=test"),
6643 )
6644 .await
6645 .expect_err("closed loopback port must fail the request");
6646
6647 let rendered = error.to_string();
6648 let sanitized = oauth_request_target_for_log(&screened_url);
6649 assert!(
6650 rendered.contains(&format!("oauth request {sanitized}")),
6651 "request failure must identify only the sanitized origin: {rendered}"
6652 );
6653 for leaked in ["u:p", "/token", "client_secret", "super-secret"] {
6654 assert!(
6655 !rendered.contains(leaked),
6656 "request failure must not echo raw URL component {leaked}: {rendered}"
6657 );
6658 }
6659 }
6660
6661 #[tokio::test]
6662 async fn exchange_token_request_failure_log_sanitizes_token_url() {
6663 let logs = CapturedLogs::default();
6664 let subscriber = tracing_subscriber::fmt()
6665 .with_max_level(tracing::Level::ERROR)
6666 .with_writer(logs.clone())
6667 .with_ansi(false)
6668 .without_time()
6669 .finish();
6670 let _guard = tracing::subscriber::set_default(subscriber);
6671
6672 let client = test_exchange_client();
6673 let token_url = unavailable_loopback_token_url();
6674 let config = test_token_exchange_config(token_url);
6675 let error = exchange_token(&client, &config, "subject-token")
6676 .await
6677 .expect_err("closed loopback port must fail exchange");
6678
6679 assert!(
6680 error.to_string().contains("server_error"),
6681 "client-visible exchange error must remain sanitized: {error}"
6682 );
6683 let contents = logs.contents();
6684 assert!(
6685 contents.contains("token exchange request failed"),
6686 "exchange failure must still be logged: {contents}"
6687 );
6688 assert!(
6689 contents.contains("oauth request http://127.0.0.1:1"),
6690 "exchange failure log must include only sanitized origin: {contents}"
6691 );
6692 for leaked in ["/token", "client_secret", "super-secret", "subject-token"] {
6693 assert!(
6694 !contents.contains(leaked),
6695 "exchange failure log must not echo raw URL/token component {leaked}: {contents}"
6696 );
6697 }
6698 }
6699
6700 #[tokio::test]
6701 async fn exchange_token_with_cancel_precancel_does_not_send() {
6702 let mock = wiremock::MockServer::start().await;
6703 wiremock::Mock::given(wiremock::matchers::method("POST"))
6704 .and(wiremock::matchers::path("/token"))
6705 .respond_with(
6706 wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6707 "downstream-token",
6708 "urn:ietf:params:oauth:token-type:access_token",
6709 )),
6710 )
6711 .mount(&mock)
6712 .await;
6713
6714 let client = test_exchange_client();
6715 let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6716 let ct = tokio_util::sync::CancellationToken::new();
6717 ct.cancel();
6718
6719 let outcome =
6720 exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6721
6722 assert!(
6723 matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6724 "pre-cancelled exchanges must not start work"
6725 );
6726 assert_eq!(
6727 recorded_request_count(&mock).await,
6728 0,
6729 "pre-cancel check must happen before cloning/spawning/sending"
6730 );
6731 }
6732
6733 #[tokio::test]
6734 async fn exchange_token_with_cancel_completes_normally() {
6735 let mock = wiremock::MockServer::start().await;
6736 wiremock::Mock::given(wiremock::matchers::method("POST"))
6737 .and(wiremock::matchers::path("/token"))
6738 .respond_with(
6739 wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6740 "downstream-token",
6741 "urn:ietf:params:oauth:token-type:access_token",
6742 )),
6743 )
6744 .expect(1)
6745 .mount(&mock)
6746 .await;
6747
6748 let client = test_exchange_client();
6749 let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6750 let ct = tokio_util::sync::CancellationToken::new();
6751
6752 let outcome =
6753 exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6754
6755 let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6756 panic!("uncancelled exchange must complete successfully")
6757 };
6758 assert_eq!(token.access_token, "downstream-token");
6759 mock.verify().await;
6760 }
6761
6762 #[tokio::test]
6763 async fn exchange_token_with_cancel_detaches_and_audits_abandoned_token() {
6764 let mock = wiremock::MockServer::start().await;
6765 let long_issued_token_type = format!(
6766 "urn:ietf:params:oauth:token-type:{}",
6767 "x".repeat(MAX_LOGGED_KID_CHARS + 32)
6768 );
6769 wiremock::Mock::given(wiremock::matchers::method("POST"))
6770 .and(wiremock::matchers::path("/token"))
6771 .respond_with(
6772 wiremock::ResponseTemplate::new(200)
6773 .set_delay(Duration::from_secs(2))
6780 .set_body_json(exchange_response(
6781 "abandoned-downstream-token",
6782 &long_issued_token_type,
6783 )),
6784 )
6785 .expect(1)
6786 .mount(&mock)
6787 .await;
6788
6789 let token_url = format!("{}/token", mock.uri());
6790 let token_url_host = url::Url::parse(&token_url)
6791 .expect("mock token URL parses")
6792 .host_str()
6793 .expect("mock token URL has host")
6794 .to_owned();
6795 let logs = CapturedLogs::default();
6796 let subscriber = tracing_subscriber::fmt()
6797 .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6798 .with_writer(logs.clone())
6799 .with_ansi(false)
6800 .without_time()
6801 .finish();
6802 let _guard = tracing::subscriber::set_default(subscriber);
6803
6804 let client = test_exchange_client();
6805 let config = test_token_exchange_config(token_url);
6806 let ct = tokio_util::sync::CancellationToken::new();
6807 let task_ct = ct.clone();
6808 let handle = tokio::spawn(async move {
6809 exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6810 });
6811
6812 wait_for_recorded_request(&mock).await;
6813 let cancelled_at = Instant::now();
6814 ct.cancel();
6815 let outcome = handle.await.expect("wrapper task must not panic");
6816
6817 assert!(
6818 matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6819 "caller must get an immediate cancellation outcome"
6820 );
6821 assert!(
6822 cancelled_at.elapsed() < Duration::from_millis(100),
6823 "wrapper must detach instead of waiting for the delayed upstream response"
6824 );
6825
6826 wait_for_log_contains(
6827 &logs,
6828 "token exchange minted downstream token after caller detached",
6829 )
6830 .await;
6831 mock.verify().await;
6832 let contents = logs.contents();
6833 assert!(
6834 contents.contains("issued_token_type_truncated=true"),
6835 "audit log must mark issuer-controlled token type truncation: {contents}"
6836 );
6837 assert!(
6838 !contents.contains("abandoned-downstream-token"),
6839 "audit log must not include downstream token material: {contents}"
6840 );
6841 assert!(
6842 !contents.contains("token_len="),
6843 "DEBUG success log must be suppressed on abandoned exchanges: {contents}"
6844 );
6845 assert!(
6846 !contents.contains(&long_issued_token_type),
6847 "detached logs must not include unbounded issued token type: {contents}"
6848 );
6849 for field in ["sub=", "aud=", "azp=", "iss="] {
6850 assert!(
6851 !contents.contains(field),
6852 "detached logs must not include JWT claim field {field}: {contents}"
6853 );
6854 }
6855 assert!(
6856 !contents.contains(&token_url_host),
6857 "detached success logs must not include token endpoint host: {contents}"
6858 );
6859 assert!(
6860 !contents.contains("subject-token"),
6861 "audit log must not include subject token material: {contents}"
6862 );
6863 assert!(
6864 !contents.contains("test-client-secret"),
6865 "audit log must not include client secret material: {contents}"
6866 );
6867 }
6868
6869 #[tokio::test]
6870 async fn exchange_token_with_cancel_detached_jwt_success_does_not_log_claims() {
6871 let mock = wiremock::MockServer::start().await;
6872 let jwt = unsigned_jwt_with_claims(&serde_json::json!({
6873 "sub": "detached-subject",
6874 "aud": "detached-audience",
6875 "azp": "detached-client",
6876 "iss": "https://issuer.example.test/realm",
6877 }));
6878 wiremock::Mock::given(wiremock::matchers::method("POST"))
6879 .and(wiremock::matchers::path("/token"))
6880 .respond_with(
6881 wiremock::ResponseTemplate::new(200)
6882 .set_delay(Duration::from_secs(2))
6886 .set_body_json(exchange_response(
6887 &jwt,
6888 "urn:ietf:params:oauth:token-type:access_token",
6889 )),
6890 )
6891 .expect(1)
6892 .mount(&mock)
6893 .await;
6894
6895 let logs = CapturedLogs::default();
6896 let subscriber = tracing_subscriber::fmt()
6897 .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6898 .with_writer(logs.clone())
6899 .with_ansi(false)
6900 .without_time()
6901 .finish();
6902 let _guard = tracing::subscriber::set_default(subscriber);
6903
6904 let client = test_exchange_client();
6905 let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6906 let ct = tokio_util::sync::CancellationToken::new();
6907 let task_ct = ct.clone();
6908 let handle = tokio::spawn(async move {
6909 exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6910 });
6911
6912 wait_for_recorded_request(&mock).await;
6913 ct.cancel();
6914 let outcome = handle.await.expect("wrapper task must not panic");
6915 assert!(
6916 matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6917 "caller must get cancellation while spawned JWT exchange continues"
6918 );
6919
6920 wait_for_log_contains(
6921 &logs,
6922 "token exchange minted downstream token after caller detached",
6923 )
6924 .await;
6925 mock.verify().await;
6926 let contents = logs.contents();
6927 assert!(
6928 !contents.contains(&jwt),
6929 "detached JWT success must not log token material: {contents}"
6930 );
6931 for leaked in [
6932 "sub=",
6933 "aud=",
6934 "azp=",
6935 "iss=",
6936 "detached-subject",
6937 "detached-audience",
6938 "detached-client",
6939 "issuer.example.test",
6940 ] {
6941 assert!(
6942 !contents.contains(leaked),
6943 "detached JWT success must not log claim material {leaked}: {contents}"
6944 );
6945 }
6946 }
6947
6948 #[tokio::test]
6949 async fn exchange_token_with_cancel_completion_wins_tie() {
6950 let (tx, rx) = tokio::sync::oneshot::channel();
6951 tx.send(Ok(ExchangedToken {
6952 access_token: "tie-winner".into(),
6953 expires_in: Some(3600),
6954 issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".into()),
6955 }))
6956 .expect("test receiver is alive");
6957 let ct = tokio_util::sync::CancellationToken::new();
6958 ct.cancel();
6959
6960 let outcome = receive_exchange_result_with_cancel(rx, &ct, None).await;
6961
6962 let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6963 panic!("ready completion must win over ready cancellation under biased select")
6964 };
6965 assert_eq!(token.access_token, "tie-winner");
6966 }
6967
6968 #[tokio::test]
6969 async fn jwks_get_still_follows_screened_redirect() {
6970 let mock = wiremock::MockServer::start().await;
6976 wiremock::Mock::given(wiremock::matchers::method("GET"))
6977 .and(wiremock::matchers::path("/jwks.json"))
6978 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
6979 "location",
6980 format!("{}/jwks-final.json", mock.uri()).as_str(),
6981 ))
6982 .mount(&mock)
6983 .await;
6984 wiremock::Mock::given(wiremock::matchers::method("GET"))
6985 .and(wiremock::matchers::path("/jwks-final.json"))
6986 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
6987 .expect(1)
6988 .mount(&mock)
6989 .await;
6990
6991 let mut allowlist = OAuthSsrfAllowlist::default();
6992 allowlist.cidrs.push("127.0.0.0/8".into());
6993 allowlist.cidrs.push("::1/128".into());
6994 let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
6995 config.allow_http_oauth_urls = true;
6996 config.ssrf_allowlist = Some(allowlist);
6997
6998 let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
6999 let resp = client
7000 .inner
7001 .get(format!("{}/jwks.json", mock.uri()))
7002 .send()
7003 .await
7004 .expect("request sent");
7005 assert_eq!(
7006 resp.status().as_u16(),
7007 200,
7008 "JWKS client must follow the screened redirect to the final endpoint"
7009 );
7010 assert_eq!(resp.text().await.expect("response body"), "reached");
7011 }
7012
7013 #[tokio::test]
7014 async fn wrong_issuer_rejected() {
7015 let kid = "test-key-2";
7016 let (pem, jwks) = generate_test_keypair(kid);
7017
7018 let mock_server = wiremock::MockServer::start().await;
7019 wiremock::Mock::given(wiremock::matchers::method("GET"))
7020 .and(wiremock::matchers::path("/jwks.json"))
7021 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7022 .mount(&mock_server)
7023 .await;
7024
7025 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7026 let config = test_config(&jwks_uri);
7027 let cache = test_cache(&config);
7028
7029 let token = mint_token(
7030 &pem,
7031 kid,
7032 "https://wrong-issuer.example.com", "https://mcp.test.local/mcp",
7034 "attacker",
7035 "mcp:admin",
7036 );
7037
7038 assert!(cache.validate_token(&token).await.is_none());
7039 }
7040
7041 #[tokio::test]
7042 async fn wrong_audience_rejected() {
7043 let kid = "test-key-3";
7044 let (pem, jwks) = generate_test_keypair(kid);
7045
7046 let mock_server = wiremock::MockServer::start().await;
7047 wiremock::Mock::given(wiremock::matchers::method("GET"))
7048 .and(wiremock::matchers::path("/jwks.json"))
7049 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7050 .mount(&mock_server)
7051 .await;
7052
7053 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7054 let config = test_config(&jwks_uri);
7055 let cache = test_cache(&config);
7056
7057 let token = mint_token(
7058 &pem,
7059 kid,
7060 "https://auth.test.local",
7061 "https://wrong-audience.example.com", "attacker",
7063 "mcp:admin",
7064 );
7065
7066 assert!(cache.validate_token(&token).await.is_none());
7067 }
7068
7069 #[tokio::test]
7070 async fn expired_jwt_rejected() {
7071 let kid = "test-key-4";
7072 let (pem, jwks) = generate_test_keypair(kid);
7073
7074 let mock_server = wiremock::MockServer::start().await;
7075 wiremock::Mock::given(wiremock::matchers::method("GET"))
7076 .and(wiremock::matchers::path("/jwks.json"))
7077 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7078 .mount(&mock_server)
7079 .await;
7080
7081 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7082 let config = test_config(&jwks_uri);
7083 let cache = test_cache(&config);
7084
7085 let encoding_key =
7087 jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
7088 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
7089 header.kid = Some(kid.into());
7090 let now = jsonwebtoken::get_current_timestamp();
7091 let claims = serde_json::json!({
7092 "iss": "https://auth.test.local",
7093 "aud": "https://mcp.test.local/mcp",
7094 "sub": "expired-bot",
7095 "scope": "mcp:read",
7096 "exp": now - 120,
7097 "iat": now - 3720,
7098 });
7099 let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
7100
7101 assert!(cache.validate_token(&token).await.is_none());
7102 }
7103
7104 #[tokio::test]
7105 async fn no_matching_scope_rejected() {
7106 let kid = "test-key-5";
7107 let (pem, jwks) = generate_test_keypair(kid);
7108
7109 let mock_server = wiremock::MockServer::start().await;
7110 wiremock::Mock::given(wiremock::matchers::method("GET"))
7111 .and(wiremock::matchers::path("/jwks.json"))
7112 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7113 .mount(&mock_server)
7114 .await;
7115
7116 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7117 let config = test_config(&jwks_uri);
7118 let cache = test_cache(&config);
7119
7120 let token = mint_token(
7121 &pem,
7122 kid,
7123 "https://auth.test.local",
7124 "https://mcp.test.local/mcp",
7125 "limited-bot",
7126 "some:other:scope", );
7128
7129 assert!(cache.validate_token(&token).await.is_none());
7130 }
7131
7132 #[tokio::test]
7133 async fn wrong_signing_key_rejected() {
7134 let kid = "test-key-6";
7135 let (_pem, jwks) = generate_test_keypair(kid);
7136
7137 let (attacker_pem, _) = generate_test_keypair(kid);
7139
7140 let mock_server = wiremock::MockServer::start().await;
7141 wiremock::Mock::given(wiremock::matchers::method("GET"))
7142 .and(wiremock::matchers::path("/jwks.json"))
7143 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7144 .mount(&mock_server)
7145 .await;
7146
7147 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7148 let config = test_config(&jwks_uri);
7149 let cache = test_cache(&config);
7150
7151 let token = mint_token(
7153 &attacker_pem,
7154 kid,
7155 "https://auth.test.local",
7156 "https://mcp.test.local/mcp",
7157 "attacker",
7158 "mcp:admin",
7159 );
7160
7161 assert!(cache.validate_token(&token).await.is_none());
7162 }
7163
7164 #[tokio::test]
7165 async fn admin_scope_maps_to_ops_role() {
7166 let kid = "test-key-7";
7167 let (pem, jwks) = generate_test_keypair(kid);
7168
7169 let mock_server = wiremock::MockServer::start().await;
7170 wiremock::Mock::given(wiremock::matchers::method("GET"))
7171 .and(wiremock::matchers::path("/jwks.json"))
7172 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7173 .mount(&mock_server)
7174 .await;
7175
7176 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7177 let config = test_config(&jwks_uri);
7178 let cache = test_cache(&config);
7179
7180 let token = mint_token(
7181 &pem,
7182 kid,
7183 "https://auth.test.local",
7184 "https://mcp.test.local/mcp",
7185 "admin-bot",
7186 "mcp:admin",
7187 );
7188
7189 let id = cache
7190 .validate_token(&token)
7191 .await
7192 .expect("should authenticate");
7193 assert_eq!(id.role, "ops");
7194 assert_eq!(id.name, "admin-bot");
7195 }
7196
7197 #[tokio::test]
7198 async fn entra_shaped_alg_less_jwks_authenticates_end_to_end() {
7199 let kid = "entra-e2e";
7203 let (pem, jwks) = generate_test_keypair(kid);
7204 let mut alg_less = jwks;
7205 if let Some(key) = alg_less["keys"][0].as_object_mut() {
7206 key.remove("alg");
7207 }
7208 assert!(
7209 alg_less["keys"][0].get("alg").is_none(),
7210 "fixture must reproduce Entra's alg-less shape"
7211 );
7212
7213 let mock_server = wiremock::MockServer::start().await;
7214 wiremock::Mock::given(wiremock::matchers::method("GET"))
7215 .and(wiremock::matchers::path("/jwks.json"))
7216 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&alg_less))
7217 .mount(&mock_server)
7218 .await;
7219
7220 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7221 let config = test_config(&jwks_uri);
7222 let cache = test_cache(&config);
7223
7224 let token = mint_token(
7225 &pem,
7226 kid,
7227 "https://auth.test.local",
7228 "https://mcp.test.local/mcp",
7229 "entra-user",
7230 "mcp:admin",
7231 );
7232
7233 let id = cache
7234 .validate_token(&token)
7235 .await
7236 .expect("an alg-less JWKS key must still authenticate (issue #17)");
7237 assert_eq!(id.name, "entra-user");
7238 }
7239
7240 #[tokio::test]
7241 async fn jwks_server_down_returns_none() {
7242 let config = test_config("http://127.0.0.1:1/jwks.json");
7244 let cache = test_cache(&config);
7245
7246 let kid = "orphan-key";
7247 let (pem, _) = generate_test_keypair(kid);
7248 let token = mint_token(
7249 &pem,
7250 kid,
7251 "https://auth.test.local",
7252 "https://mcp.test.local/mcp",
7253 "bot",
7254 "mcp:read",
7255 );
7256
7257 assert!(cache.validate_token(&token).await.is_none());
7258 }
7259
7260 #[test]
7265 fn resolve_claim_path_flat_string() {
7266 let mut extra = HashMap::new();
7267 extra.insert(
7268 "scope".into(),
7269 serde_json::Value::String("mcp:read mcp:admin".into()),
7270 );
7271 let values = resolve_claim_path(&extra, "scope");
7272 assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
7273 }
7274
7275 #[test]
7276 fn resolve_claim_path_flat_array() {
7277 let mut extra = HashMap::new();
7278 extra.insert(
7279 "roles".into(),
7280 serde_json::json!(["mcp-admin", "mcp-viewer"]),
7281 );
7282 let values = resolve_claim_path(&extra, "roles");
7283 assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
7284 }
7285
7286 #[test]
7287 fn resolve_claim_path_nested_keycloak() {
7288 let mut extra = HashMap::new();
7289 extra.insert(
7290 "realm_access".into(),
7291 serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
7292 );
7293 let values = resolve_claim_path(&extra, "realm_access.roles");
7294 assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
7295 }
7296
7297 #[test]
7298 fn resolve_claim_path_missing_returns_empty() {
7299 let extra = HashMap::new();
7300 assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
7301 }
7302
7303 #[test]
7304 fn resolve_claim_path_numeric_leaf_returns_empty() {
7305 let mut extra = HashMap::new();
7306 extra.insert("count".into(), serde_json::json!(42));
7307 assert!(resolve_claim_path(&extra, "count").is_empty());
7308 }
7309
7310 fn make_claims(json: serde_json::Value) -> Claims {
7311 serde_json::from_value(json).expect("test claims must deserialize")
7312 }
7313
7314 #[test]
7315 fn first_class_scope_claim_splits_on_whitespace() {
7316 let claims = make_claims(serde_json::json!({
7317 "iss": "https://issuer.example.com",
7318 "exp": 9_999_999_999_u64,
7319 "scope": "read write admin",
7320 }));
7321 let values = first_class_claim_values(&claims, "scope");
7322 assert_eq!(values, vec!["read", "write", "admin"]);
7323 }
7324
7325 #[test]
7326 fn first_class_sub_claim_returns_single_value() {
7327 let claims = make_claims(serde_json::json!({
7328 "iss": "https://issuer.example.com",
7329 "exp": 9_999_999_999_u64,
7330 "sub": "service-account-orders",
7331 }));
7332 let values = first_class_claim_values(&claims, "sub");
7333 assert_eq!(values, vec!["service-account-orders"]);
7334 }
7335
7336 #[test]
7337 fn first_class_aud_claim_returns_every_audience() {
7338 let claims = make_claims(serde_json::json!({
7339 "iss": "https://issuer.example.com",
7340 "exp": 9_999_999_999_u64,
7341 "aud": ["api-a", "api-b"],
7342 }));
7343 let values = first_class_claim_values(&claims, "aud");
7344 assert_eq!(values, vec!["api-a", "api-b"]);
7345 }
7346
7347 #[test]
7348 fn first_class_unknown_path_returns_empty() {
7349 let claims = make_claims(serde_json::json!({
7350 "iss": "https://issuer.example.com",
7351 "exp": 9_999_999_999_u64,
7352 }));
7353 assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
7354 }
7355
7356 fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
7362 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
7363 .expect("encoding key from PEM");
7364 let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
7365 header.kid = Some(kid.into());
7366 jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
7367 }
7368
7369 fn test_config_with_role_claim(
7370 jwks_uri: &str,
7371 role_claim: &str,
7372 role_mappings: Vec<RoleMapping>,
7373 ) -> OAuthConfig {
7374 OAuthConfig {
7375 require_subject: false,
7376 issuer: "https://auth.test.local".into(),
7377 audience: "https://mcp.test.local/mcp".into(),
7378 jwks_uri: jwks_uri.into(),
7379 scopes: vec![],
7380 role_claim: Some(role_claim.into()),
7381 role_mappings,
7382 jwks_cache_ttl: "5m".into(),
7383 proxy: None,
7384 token_exchange: None,
7385 ca_cert_path: None,
7386 allow_http_oauth_urls: true,
7387 max_jwks_keys: default_max_jwks_keys(),
7388 allowed_algorithms: None,
7389 authorization_servers: None,
7390 authorization_server_metadata_issuer: None,
7391 #[allow(
7392 deprecated,
7393 reason = "test fixture: explicit value for the deprecated field"
7394 )]
7395 strict_audience_validation: None,
7396 audience_validation_mode: None,
7397 jwks_max_response_bytes: default_jwks_max_bytes(),
7398 ssrf_allowlist: None,
7399 }
7400 }
7401
7402 #[tokio::test]
7403 async fn screen_oauth_target_rejects_literal_ip() {
7404 let err = screen_oauth_target(
7405 "https://127.0.0.1/jwks.json",
7406 false,
7407 &crate::ssrf::CompiledSsrfAllowlist::default(),
7408 )
7409 .await
7410 .expect_err("literal IPs must be rejected");
7411 let msg = err.to_string();
7412 assert!(msg.contains("literal IPv4 addresses are forbidden"));
7413 }
7414
7415 #[tokio::test]
7416 async fn screen_oauth_target_rejects_private_dns_resolution() {
7417 let err = screen_oauth_target(
7418 "https://localhost/jwks.json",
7419 false,
7420 &crate::ssrf::CompiledSsrfAllowlist::default(),
7421 )
7422 .await
7423 .expect_err("localhost resolution must be rejected");
7424 let msg = err.to_string();
7425 assert!(
7426 msg.contains("blocked IP") && msg.contains("loopback"),
7427 "got {msg:?}"
7428 );
7429 }
7430
7431 #[tokio::test]
7432 async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
7433 let err = screen_oauth_target(
7434 "http://127.0.0.1/jwks.json",
7435 true,
7436 &crate::ssrf::CompiledSsrfAllowlist::default(),
7437 )
7438 .await
7439 .expect_err("literal IPs must still be rejected when http is allowed");
7440 let msg = err.to_string();
7441 assert!(msg.contains("literal IPv4 addresses are forbidden"));
7442 }
7443
7444 #[tokio::test]
7445 async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
7446 let err = screen_oauth_target(
7447 "http://localhost/jwks.json",
7448 true,
7449 &crate::ssrf::CompiledSsrfAllowlist::default(),
7450 )
7451 .await
7452 .expect_err("private DNS resolution must still be rejected when http is allowed");
7453 let msg = err.to_string();
7454 assert!(
7455 msg.contains("blocked IP") && msg.contains("loopback"),
7456 "got {msg:?}"
7457 );
7458 }
7459
7460 #[tokio::test]
7461 async fn screen_oauth_target_allows_public_hostname() {
7462 screen_oauth_target(
7463 "https://example.com/.well-known/jwks.json",
7464 false,
7465 &crate::ssrf::CompiledSsrfAllowlist::default(),
7466 )
7467 .await
7468 .expect("public hostname should pass screening");
7469 }
7470
7471 fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
7477 let raw = OAuthSsrfAllowlist {
7478 hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
7479 cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
7480 };
7481 compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
7482 }
7483
7484 #[test]
7485 fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
7486 let raw = OAuthSsrfAllowlist {
7487 hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
7488 cidrs: vec![],
7489 };
7490 let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
7491 assert_eq!(compiled.host_count(), 1);
7492 assert!(compiled.host_allowed("rhbk.ops.example.com"));
7493 assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
7494 }
7495
7496 #[test]
7497 fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
7498 let raw = OAuthSsrfAllowlist {
7499 hosts: vec!["10.0.0.1".into()],
7500 cidrs: vec![],
7501 };
7502 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
7503 assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
7504 }
7505
7506 #[test]
7507 fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
7508 let raw = OAuthSsrfAllowlist {
7509 hosts: vec!["rhbk.ops.example.com:8443".into()],
7510 cidrs: vec![],
7511 };
7512 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
7513 assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
7514 }
7515
7516 #[test]
7519 fn internal_suffix_rejected_by_default() {
7520 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7521 for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
7522 assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
7523 }
7524 }
7525
7526 #[test]
7527 fn exact_allowlisted_internal_permitted() {
7528 let allow = make_allowlist(&["idp.internal"], &[]);
7529 assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
7530 assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
7531 }
7532
7533 #[test]
7534 fn subdomain_of_allowlisted_internal_still_rejected() {
7535 let allow = make_allowlist(&["idp.internal"], &[]);
7536 assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
7537 }
7538
7539 #[test]
7540 fn cidr_allowlist_does_not_bypass_suffix_denylist() {
7541 let allow = make_allowlist(&[], &["10.0.0.0/8"]);
7542 assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
7543 }
7544
7545 #[test]
7546 fn public_hostname_not_blocked_by_suffix() {
7547 let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7548 assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
7549 }
7550
7551 #[test]
7552 fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
7553 let raw = OAuthSsrfAllowlist {
7554 hosts: vec![],
7555 cidrs: vec!["not-a-cidr".into()],
7556 };
7557 let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
7558 assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
7559 }
7560
7561 #[test]
7562 fn validate_rejects_misconfigured_allowlist() {
7563 let mut cfg = OAuthConfig::builder(
7564 "https://auth.example.com/",
7565 "mcp",
7566 "https://auth.example.com/jwks.json",
7567 )
7568 .build();
7569 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7570 hosts: vec!["10.0.0.1".into()],
7571 cidrs: vec![],
7572 });
7573 let err = cfg
7574 .validate()
7575 .expect_err("literal IP host must be rejected");
7576 assert!(
7577 err.to_string().contains("oauth.ssrf_allowlist"),
7578 "got {err}"
7579 );
7580 }
7581
7582 #[tokio::test]
7583 async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
7584 let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
7588 let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
7589 .await
7590 .expect_err("loopback must still be blocked when not in allowlist");
7591 let msg = err.to_string();
7592 assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
7593 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7594 assert!(msg.contains("SECURITY.md"), "got {msg:?}");
7595 }
7596
7597 #[tokio::test]
7598 async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
7599 let err = screen_oauth_target(
7602 "https://localhost/jwks.json",
7603 false,
7604 &crate::ssrf::CompiledSsrfAllowlist::default(),
7605 )
7606 .await
7607 .expect_err("loopback rejection");
7608 let msg = err.to_string();
7609 assert!(msg.contains("blocked IP"), "got {msg:?}");
7610 assert!(msg.contains("loopback"), "got {msg:?}");
7611 assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7613 }
7614
7615 #[tokio::test]
7616 async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
7617 let allow = make_allowlist(&["localhost"], &[]);
7619 screen_oauth_target("https://localhost/jwks.json", false, &allow)
7620 .await
7621 .expect("allowlisted host must pass");
7622 }
7623
7624 #[tokio::test]
7625 async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
7626 let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
7629 screen_oauth_target("https://localhost/jwks.json", false, &allow)
7630 .await
7631 .expect("allowlisted CIDR must pass");
7632 }
7633
7634 #[tokio::test]
7635 async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
7636 let mut cfg = OAuthConfig::builder(
7637 "https://auth.example.com/",
7638 "mcp",
7639 "https://auth.example.com/jwks.json",
7640 )
7641 .build();
7642 cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7643 hosts: vec![],
7644 cidrs: vec!["bad-cidr".into()],
7645 });
7646 let Err(err) = JwksCache::new(&cfg) else {
7647 panic!("invalid CIDR must fail JwksCache::new")
7648 };
7649 let msg = err.to_string();
7650 assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7651 }
7652
7653 #[tokio::test]
7654 async fn jwks_cache_new_invalid_ttl_is_err() {
7655 let cfg = OAuthConfig::builder(
7658 "https://auth.example.com/",
7659 "mcp",
7660 "https://auth.example.com/jwks.json",
7661 )
7662 .jwks_cache_ttl("not-a-duration")
7663 .build();
7664 let Err(err) = JwksCache::new(&cfg) else {
7665 panic!("invalid jwks_cache_ttl must fail JwksCache::new")
7666 };
7667 let msg = err.to_string();
7668 assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
7669 }
7670
7671 #[tokio::test]
7672 async fn audience_default_is_strict() {
7673 let kid = "test-audience-azp-default";
7674 let (pem, jwks) = generate_test_keypair(kid);
7675
7676 let mock_server = wiremock::MockServer::start().await;
7677 wiremock::Mock::given(wiremock::matchers::method("GET"))
7678 .and(wiremock::matchers::path("/jwks.json"))
7679 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7680 .mount(&mock_server)
7681 .await;
7682
7683 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7684 let config = test_config(&jwks_uri);
7685 let cache = test_cache(&config);
7686
7687 let now = jsonwebtoken::get_current_timestamp();
7688 let token = mint_token_with_claims(
7689 &pem,
7690 kid,
7691 &serde_json::json!({
7692 "iss": "https://auth.test.local",
7693 "aud": "https://some-other-resource.example.com",
7694 "azp": "https://mcp.test.local/mcp",
7695 "sub": "compat-client",
7696 "scope": "mcp:read",
7697 "exp": now + 3600,
7698 "iat": now,
7699 }),
7700 );
7701
7702 let failure = cache
7703 .validate_token_with_reason(&token)
7704 .await
7705 .expect_err("the default policy is Strict and must reject an azp-only match");
7706 assert_eq!(failure, JwtValidationFailure::Invalid);
7707 }
7708
7709 #[tokio::test]
7710 async fn audience_warn_still_accepts_azp() {
7711 let kid = "test-audience-warn-optin";
7712 let (pem, jwks) = generate_test_keypair(kid);
7713
7714 let mock_server = wiremock::MockServer::start().await;
7715 wiremock::Mock::given(wiremock::matchers::method("GET"))
7716 .and(wiremock::matchers::path("/jwks.json"))
7717 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7718 .mount(&mock_server)
7719 .await;
7720
7721 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7722 let mut config = test_config(&jwks_uri);
7723 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7724 let cache = test_cache(&config);
7725
7726 let now = jsonwebtoken::get_current_timestamp();
7727 let token = mint_token_with_claims(
7728 &pem,
7729 kid,
7730 &serde_json::json!({
7731 "iss": "https://auth.test.local",
7732 "aud": "https://some-other-resource.example.com",
7733 "azp": "https://mcp.test.local/mcp",
7734 "sub": "warn-optin-client",
7735 "scope": "mcp:read",
7736 "exp": now + 3600,
7737 "iat": now,
7738 }),
7739 );
7740
7741 cache.validate_token_with_reason(&token).await.expect(
7742 "the audience_validation_mode=warn opt-out must still accept an azp-only match",
7743 );
7744 }
7745
7746 #[tokio::test]
7747 async fn legacy_strict_false_maps_to_warn() {
7748 let kid = "test-audience-legacy-false";
7749 let (pem, jwks) = generate_test_keypair(kid);
7750
7751 let mock_server = wiremock::MockServer::start().await;
7752 wiremock::Mock::given(wiremock::matchers::method("GET"))
7753 .and(wiremock::matchers::path("/jwks.json"))
7754 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7755 .mount(&mock_server)
7756 .await;
7757
7758 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7759 let mut config = test_config(&jwks_uri);
7760 #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
7763 {
7764 config.strict_audience_validation = Some(false);
7765 }
7766 let cache = test_cache(&config);
7767
7768 let now = jsonwebtoken::get_current_timestamp();
7769 let token = mint_token_with_claims(
7770 &pem,
7771 kid,
7772 &serde_json::json!({
7773 "iss": "https://auth.test.local",
7774 "aud": "https://some-other-resource.example.com",
7775 "azp": "https://mcp.test.local/mcp",
7776 "sub": "legacy-false-client",
7777 "scope": "mcp:read",
7778 "exp": now + 3600,
7779 "iat": now,
7780 }),
7781 );
7782
7783 cache
7784 .validate_token_with_reason(&token)
7785 .await
7786 .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
7787 }
7788
7789 #[tokio::test]
7790 async fn aud_match_always_accepts() {
7791 let kid = "test-audience-aud-match";
7792 let (pem, jwks) = generate_test_keypair(kid);
7793
7794 let mock_server = wiremock::MockServer::start().await;
7795 wiremock::Mock::given(wiremock::matchers::method("GET"))
7796 .and(wiremock::matchers::path("/jwks.json"))
7797 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7798 .mount(&mock_server)
7799 .await;
7800
7801 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7802 let config = test_config(&jwks_uri); let cache = test_cache(&config);
7804
7805 let now = jsonwebtoken::get_current_timestamp();
7806 let token = mint_token_with_claims(
7807 &pem,
7808 kid,
7809 &serde_json::json!({
7810 "iss": "https://auth.test.local",
7811 "aud": "https://mcp.test.local/mcp",
7812 "sub": "aud-match-client",
7813 "scope": "mcp:read",
7814 "exp": now + 3600,
7815 "iat": now,
7816 }),
7817 );
7818
7819 cache
7820 .validate_token_with_reason(&token)
7821 .await
7822 .expect("a matching aud must be accepted even under the Strict default");
7823 }
7824
7825 #[tokio::test]
7826 async fn strict_audience_validation_rejects_azp_only_match() {
7827 let kid = "test-audience-azp-strict";
7828 let (pem, jwks) = generate_test_keypair(kid);
7829
7830 let mock_server = wiremock::MockServer::start().await;
7831 wiremock::Mock::given(wiremock::matchers::method("GET"))
7832 .and(wiremock::matchers::path("/jwks.json"))
7833 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7834 .mount(&mock_server)
7835 .await;
7836
7837 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7838 let mut config = test_config(&jwks_uri);
7839 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
7840 {
7841 config.strict_audience_validation = Some(true);
7842 }
7843 let cache = test_cache(&config);
7844
7845 let now = jsonwebtoken::get_current_timestamp();
7846 let token = mint_token_with_claims(
7847 &pem,
7848 kid,
7849 &serde_json::json!({
7850 "iss": "https://auth.test.local",
7851 "aud": "https://some-other-resource.example.com",
7852 "azp": "https://mcp.test.local/mcp",
7853 "sub": "strict-client",
7854 "scope": "mcp:read",
7855 "exp": now + 3600,
7856 "iat": now,
7857 }),
7858 );
7859
7860 let failure = cache
7861 .validate_token_with_reason(&token)
7862 .await
7863 .expect_err("strict audience validation must ignore azp fallback");
7864 assert_eq!(failure, JwtValidationFailure::Invalid);
7865 }
7866
7867 #[tokio::test]
7868 async fn warn_mode_accepts_azp_only_match_and_warns_once() {
7869 let kid = "test-audience-warn-mode";
7870 let (pem, jwks) = generate_test_keypair(kid);
7871
7872 let mock_server = wiremock::MockServer::start().await;
7873 wiremock::Mock::given(wiremock::matchers::method("GET"))
7874 .and(wiremock::matchers::path("/jwks.json"))
7875 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7876 .mount(&mock_server)
7877 .await;
7878
7879 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7880 let mut config = test_config(&jwks_uri);
7881 config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7882 let cache = test_cache(&config);
7883
7884 let now = jsonwebtoken::get_current_timestamp();
7885 let claims = serde_json::json!({
7886 "iss": "https://auth.test.local",
7887 "aud": "https://some-other-resource.example.com",
7888 "azp": "https://mcp.test.local/mcp",
7889 "sub": "warn-client",
7890 "scope": "mcp:read",
7891 "exp": now + 3600,
7892 "iat": now,
7893 });
7894 let token = mint_token_with_claims(&pem, kid, &claims);
7895
7896 let identity = cache
7897 .validate_token_with_reason(&token)
7898 .await
7899 .expect("warn mode must accept azp-only match");
7900 assert_eq!(identity.role, "viewer");
7901 assert!(
7902 cache.azp_fallback_warned.load(Ordering::Relaxed),
7903 "warn-once flag should be set after first azp-only match"
7904 );
7905
7906 let token2 = mint_token_with_claims(&pem, kid, &claims);
7907 cache
7908 .validate_token_with_reason(&token2)
7909 .await
7910 .expect("warn mode must continue accepting subsequent matches");
7911 assert!(
7912 cache.azp_fallback_warned.load(Ordering::Relaxed),
7913 "warn-once flag must remain set; the assertion guards against accidental clearing"
7914 );
7915 }
7916
7917 #[tokio::test]
7918 async fn permissive_mode_accepts_azp_only_match_silently() {
7919 let kid = "test-audience-permissive-mode";
7920 let (pem, jwks) = generate_test_keypair(kid);
7921
7922 let mock_server = wiremock::MockServer::start().await;
7923 wiremock::Mock::given(wiremock::matchers::method("GET"))
7924 .and(wiremock::matchers::path("/jwks.json"))
7925 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7926 .mount(&mock_server)
7927 .await;
7928
7929 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7930 let mut config = test_config(&jwks_uri);
7931 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7932 let cache = test_cache(&config);
7933
7934 let now = jsonwebtoken::get_current_timestamp();
7935 let token = mint_token_with_claims(
7936 &pem,
7937 kid,
7938 &serde_json::json!({
7939 "iss": "https://auth.test.local",
7940 "aud": "https://some-other-resource.example.com",
7941 "azp": "https://mcp.test.local/mcp",
7942 "sub": "permissive-client",
7943 "scope": "mcp:read",
7944 "exp": now + 3600,
7945 "iat": now,
7946 }),
7947 );
7948
7949 cache
7950 .validate_token_with_reason(&token)
7951 .await
7952 .expect("permissive mode must accept azp-only match");
7953 assert!(
7954 !cache.azp_fallback_warned.load(Ordering::Relaxed),
7955 "permissive mode must not flip the warn-once flag"
7956 );
7957 assert!(
7958 cache.azp_permissive_logged.load(Ordering::Relaxed),
7959 "permissive mode must record its own once-per-process log flag"
7960 );
7961 }
7962
7963 #[test]
7964 fn audience_validation_mode_overrides_legacy_bool() {
7965 let mut config = OAuthConfig::default();
7966 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7967 {
7968 config.strict_audience_validation = Some(false);
7969 }
7970 config.audience_validation_mode = Some(AudienceValidationMode::Strict);
7971 assert_eq!(
7972 config.effective_audience_validation_mode(),
7973 AudienceValidationMode::Strict,
7974 "explicit mode must override legacy false"
7975 );
7976
7977 let mut config = OAuthConfig::default();
7978 #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7979 {
7980 config.strict_audience_validation = Some(true);
7981 }
7982 config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7983 assert_eq!(
7984 config.effective_audience_validation_mode(),
7985 AudienceValidationMode::Permissive,
7986 "explicit mode must override legacy true"
7987 );
7988 }
7989
7990 #[test]
7991 fn audience_validation_mode_default_is_strict_when_unset() {
7992 let config = OAuthConfig::default();
7993 assert_eq!(
7994 config.effective_audience_validation_mode(),
7995 AudienceValidationMode::Strict,
7996 "unset mode + unset bool must resolve to Strict (the secure default)"
7997 );
7998 }
7999
8000 #[test]
8001 fn audience_validation_legacy_bool_true_resolves_to_strict() {
8002 let mut config = OAuthConfig::default();
8003 #[allow(deprecated, reason = "covers the legacy bool resolution path")]
8004 {
8005 config.strict_audience_validation = Some(true);
8006 }
8007 assert_eq!(
8008 config.effective_audience_validation_mode(),
8009 AudienceValidationMode::Strict,
8010 "legacy bool=true must resolve to Strict for backward compat"
8011 );
8012 }
8013
8014 #[derive(Clone, Default)]
8015 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
8016
8017 impl CapturedLogs {
8018 fn contents(&self) -> String {
8019 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
8020 String::from_utf8(bytes).unwrap_or_default()
8021 }
8022 }
8023
8024 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
8025
8026 impl std::io::Write for CapturedLogsWriter {
8027 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
8028 if let Ok(mut guard) = self.0.lock() {
8029 guard.extend_from_slice(buf);
8030 }
8031 Ok(buf.len())
8032 }
8033
8034 fn flush(&mut self) -> std::io::Result<()> {
8035 Ok(())
8036 }
8037 }
8038
8039 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
8040 type Writer = CapturedLogsWriter;
8041
8042 fn make_writer(&'a self) -> Self::Writer {
8043 CapturedLogsWriter(Arc::clone(&self.0))
8044 }
8045 }
8046
8047 fn exchanged_token_for_debug(secret: &str) -> ExchangedToken {
8048 ExchangedToken {
8049 access_token: secret.to_owned(),
8050 expires_in: Some(3600),
8051 issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".to_owned()),
8052 }
8053 }
8054
8055 fn exchanged_jwt_with_sensitive_claims() -> ExchangedToken {
8056 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
8057 let payload = URL_SAFE_NO_PAD.encode(
8058 br#"{"sub":"subject-secret","aud":["aud-secret"],"azp":"azp-secret","iss":"issuer-secret"}"#,
8059 );
8060 exchanged_token_for_debug(&format!("{header}.{payload}.signature"))
8061 }
8062
8063 #[test]
8064 fn exchanged_token_debug_redacts_access_token_by_default() {
8065 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8066 crate::diagnostics::set_diagnostic_exposure(
8067 &crate::diagnostics::DiagnosticExposure::default(),
8068 );
8069 let secret = "oauth-access-token-secret";
8070
8071 let rendered = format!("{:?}", exchanged_token_for_debug(secret));
8072
8073 assert!(rendered.contains("[REDACTED]"));
8074 assert!(
8075 !rendered.contains(secret),
8076 "Debug output must not contain plaintext access token: {rendered}"
8077 );
8078 assert!(rendered.contains("expires_in"));
8079 assert!(rendered.contains("issued_token_type"));
8080 }
8081
8082 #[test]
8083 fn exchanged_token_debug_can_show_access_token_when_enabled() {
8084 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8085 crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
8086 plaintext_oauth_tokens: true,
8087 ..crate::diagnostics::DiagnosticExposure::default()
8088 });
8089 let secret = "oauth-access-token-secret";
8090
8091 let rendered = format!("{:?}", exchanged_token_for_debug(secret));
8092
8093 assert!(rendered.contains(secret));
8094 }
8095
8096 #[test]
8097 fn exchanged_token_claim_log_redacts_claim_values_by_default() {
8098 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8099 crate::diagnostics::set_diagnostic_exposure(
8100 &crate::diagnostics::DiagnosticExposure::default(),
8101 );
8102 let logs = CapturedLogs::default();
8103 let subscriber = tracing_subscriber::fmt()
8104 .with_max_level(tracing::Level::DEBUG)
8105 .with_writer(logs.clone())
8106 .with_ansi(false)
8107 .without_time()
8108 .finish();
8109 let _subscriber_guard = tracing::subscriber::set_default(subscriber);
8110
8111 log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
8112
8113 let contents = logs.contents();
8114 assert!(contents.contains("[REDACTED]"));
8115 for secret in [
8116 "subject-secret",
8117 "aud-secret",
8118 "azp-secret",
8119 "issuer-secret",
8120 ] {
8121 assert!(
8122 !contents.contains(secret),
8123 "claim log must not contain {secret}: {contents}"
8124 );
8125 }
8126 assert!(contents.contains("expires_in"));
8127 }
8128
8129 #[test]
8130 fn exchanged_token_claim_log_can_show_claim_values_when_enabled() {
8131 let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8132 crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
8133 oauth_claim_values: true,
8134 ..crate::diagnostics::DiagnosticExposure::default()
8135 });
8136 let logs = CapturedLogs::default();
8137 let subscriber = tracing_subscriber::fmt()
8138 .with_max_level(tracing::Level::DEBUG)
8139 .with_writer(logs.clone())
8140 .with_ansi(false)
8141 .without_time()
8142 .finish();
8143 let _subscriber_guard = tracing::subscriber::set_default(subscriber);
8144
8145 log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
8146
8147 let contents = logs.contents();
8148 for secret in [
8149 "subject-secret",
8150 "aud-secret",
8151 "azp-secret",
8152 "issuer-secret",
8153 ] {
8154 assert!(
8155 contents.contains(secret),
8156 "claim log must contain {secret} when enabled: {contents}"
8157 );
8158 }
8159 }
8160
8161 #[tokio::test]
8162 async fn jwks_response_size_cap_returns_none_and_logs_warning() {
8163 let kid = "oversized-jwks";
8164 let (_pem, jwks) = generate_test_keypair(kid);
8165 let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
8166 oversized_body.push_str(&" ".repeat(4096));
8167
8168 let mock_server = wiremock::MockServer::start().await;
8169 wiremock::Mock::given(wiremock::matchers::method("GET"))
8170 .and(wiremock::matchers::path("/jwks.json"))
8171 .respond_with(
8172 wiremock::ResponseTemplate::new(200)
8173 .insert_header("content-type", "application/json")
8174 .set_body_string(oversized_body),
8175 )
8176 .mount(&mock_server)
8177 .await;
8178
8179 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8180 let mut config = test_config(&jwks_uri);
8181 config.jwks_max_response_bytes = 256;
8182 let cache = test_cache(&config);
8183
8184 let logs = CapturedLogs::default();
8185 let subscriber = tracing_subscriber::fmt()
8186 .with_writer(logs.clone())
8187 .with_ansi(false)
8188 .without_time()
8189 .finish();
8190 let _guard = tracing::subscriber::set_default(subscriber);
8191
8192 let result = cache.fetch_jwks().await;
8193 assert!(result.is_none(), "oversized JWKS must be dropped");
8194 assert!(
8195 logs.contents()
8196 .contains("JWKS response exceeded configured size cap"),
8197 "expected cap-exceeded warning in logs"
8198 );
8199 }
8200
8201 #[tokio::test]
8205 async fn redirect_rejection_log_does_not_echo_credentials() {
8206 let mock_server = wiremock::MockServer::start().await;
8207 wiremock::Mock::given(wiremock::matchers::method("GET"))
8208 .and(wiremock::matchers::path("/jwks.json"))
8209 .respond_with(
8210 wiremock::ResponseTemplate::new(302)
8211 .insert_header("location", "https://u:p@redirect-target.example/next"),
8212 )
8213 .mount(&mock_server)
8214 .await;
8215
8216 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8217 let config = test_config(&jwks_uri);
8218 let cache = test_cache(&config);
8219
8220 let logs = CapturedLogs::default();
8221 let subscriber = tracing_subscriber::fmt()
8222 .with_writer(logs.clone())
8223 .with_ansi(false)
8224 .without_time()
8225 .finish();
8226 let _guard = tracing::subscriber::set_default(subscriber);
8227
8228 let result = cache.fetch_jwks().await;
8229 assert!(result.is_none(), "rejected redirect must fail the fetch");
8230 let contents = logs.contents();
8231 assert!(
8232 contents.contains("oauth redirect rejected"),
8233 "expected redirect-rejection warning in logs: {contents}"
8234 );
8235 assert!(
8236 !contents.contains("u:p"),
8237 "rejection log must not echo userinfo credentials: {contents}"
8238 );
8239 }
8240
8241 #[tokio::test]
8242 async fn jwks_fetch_failure_log_sanitizes_url_and_reqwest_error() {
8243 let config = test_config("http://127.0.0.1:1/jwks.json?client_secret=super-secret");
8244 let cache = test_cache(&config);
8245
8246 let logs = CapturedLogs::default();
8247 let subscriber = tracing_subscriber::fmt()
8248 .with_max_level(tracing::Level::WARN)
8249 .with_writer(logs.clone())
8250 .with_ansi(false)
8251 .without_time()
8252 .finish();
8253 let _guard = tracing::subscriber::set_default(subscriber);
8254
8255 let result = cache.fetch_jwks().await;
8256 assert!(
8257 result.is_none(),
8258 "closed loopback port must fail JWKS fetch"
8259 );
8260 let contents = logs.contents();
8261 assert!(
8262 contents.contains("failed to fetch JWKS"),
8263 "JWKS failure must still be logged: {contents}"
8264 );
8265 assert!(
8266 contents.contains("uri=http://127.0.0.1:1"),
8267 "JWKS failure log must include only sanitized origin: {contents}"
8268 );
8269 for leaked in ["/jwks.json", "client_secret", "super-secret"] {
8270 assert!(
8271 !contents.contains(leaked),
8272 "JWKS failure log must not echo raw URL component {leaked}: {contents}"
8273 );
8274 }
8275 }
8276
8277 #[tokio::test]
8278 async fn role_claim_keycloak_nested_array() {
8279 let kid = "test-role-1";
8280 let (pem, jwks) = generate_test_keypair(kid);
8281
8282 let mock_server = wiremock::MockServer::start().await;
8283 wiremock::Mock::given(wiremock::matchers::method("GET"))
8284 .and(wiremock::matchers::path("/jwks.json"))
8285 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8286 .mount(&mock_server)
8287 .await;
8288
8289 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8290 let config = test_config_with_role_claim(
8291 &jwks_uri,
8292 "realm_access.roles",
8293 vec![
8294 RoleMapping {
8295 claim_value: "mcp-admin".into(),
8296 role: "ops".into(),
8297 },
8298 RoleMapping {
8299 claim_value: "mcp-viewer".into(),
8300 role: "viewer".into(),
8301 },
8302 ],
8303 );
8304 let cache = test_cache(&config);
8305
8306 let now = jsonwebtoken::get_current_timestamp();
8307 let token = mint_token_with_claims(
8308 &pem,
8309 kid,
8310 &serde_json::json!({
8311 "iss": "https://auth.test.local",
8312 "aud": "https://mcp.test.local/mcp",
8313 "sub": "keycloak-user",
8314 "exp": now + 3600,
8315 "iat": now,
8316 "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
8317 }),
8318 );
8319
8320 let id = cache
8321 .validate_token(&token)
8322 .await
8323 .expect("should authenticate");
8324 assert_eq!(id.name, "keycloak-user");
8325 assert_eq!(id.role, "ops");
8326 }
8327
8328 #[tokio::test]
8329 async fn role_claim_flat_roles_array() {
8330 let kid = "test-role-2";
8331 let (pem, jwks) = generate_test_keypair(kid);
8332
8333 let mock_server = wiremock::MockServer::start().await;
8334 wiremock::Mock::given(wiremock::matchers::method("GET"))
8335 .and(wiremock::matchers::path("/jwks.json"))
8336 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8337 .mount(&mock_server)
8338 .await;
8339
8340 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8341 let config = test_config_with_role_claim(
8342 &jwks_uri,
8343 "roles",
8344 vec![
8345 RoleMapping {
8346 claim_value: "MCP.Admin".into(),
8347 role: "ops".into(),
8348 },
8349 RoleMapping {
8350 claim_value: "MCP.Reader".into(),
8351 role: "viewer".into(),
8352 },
8353 ],
8354 );
8355 let cache = test_cache(&config);
8356
8357 let now = jsonwebtoken::get_current_timestamp();
8358 let token = mint_token_with_claims(
8359 &pem,
8360 kid,
8361 &serde_json::json!({
8362 "iss": "https://auth.test.local",
8363 "aud": "https://mcp.test.local/mcp",
8364 "sub": "azure-ad-user",
8365 "exp": now + 3600,
8366 "iat": now,
8367 "roles": ["MCP.Reader", "OtherApp.Admin"]
8368 }),
8369 );
8370
8371 let id = cache
8372 .validate_token(&token)
8373 .await
8374 .expect("should authenticate");
8375 assert_eq!(id.name, "azure-ad-user");
8376 assert_eq!(id.role, "viewer");
8377 }
8378
8379 #[tokio::test]
8380 async fn role_claim_no_matching_value_rejected() {
8381 let kid = "test-role-3";
8382 let (pem, jwks) = generate_test_keypair(kid);
8383
8384 let mock_server = wiremock::MockServer::start().await;
8385 wiremock::Mock::given(wiremock::matchers::method("GET"))
8386 .and(wiremock::matchers::path("/jwks.json"))
8387 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8388 .mount(&mock_server)
8389 .await;
8390
8391 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8392 let config = test_config_with_role_claim(
8393 &jwks_uri,
8394 "roles",
8395 vec![RoleMapping {
8396 claim_value: "mcp-admin".into(),
8397 role: "ops".into(),
8398 }],
8399 );
8400 let cache = test_cache(&config);
8401
8402 let now = jsonwebtoken::get_current_timestamp();
8403 let token = mint_token_with_claims(
8404 &pem,
8405 kid,
8406 &serde_json::json!({
8407 "iss": "https://auth.test.local",
8408 "aud": "https://mcp.test.local/mcp",
8409 "sub": "limited-user",
8410 "exp": now + 3600,
8411 "iat": now,
8412 "roles": ["some-other-role"]
8413 }),
8414 );
8415
8416 assert!(cache.validate_token(&token).await.is_none());
8417 }
8418
8419 #[tokio::test]
8420 async fn role_claim_space_separated_string() {
8421 let kid = "test-role-4";
8422 let (pem, jwks) = generate_test_keypair(kid);
8423
8424 let mock_server = wiremock::MockServer::start().await;
8425 wiremock::Mock::given(wiremock::matchers::method("GET"))
8426 .and(wiremock::matchers::path("/jwks.json"))
8427 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8428 .mount(&mock_server)
8429 .await;
8430
8431 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8432 let config = test_config_with_role_claim(
8433 &jwks_uri,
8434 "custom_scope",
8435 vec![
8436 RoleMapping {
8437 claim_value: "write".into(),
8438 role: "ops".into(),
8439 },
8440 RoleMapping {
8441 claim_value: "read".into(),
8442 role: "viewer".into(),
8443 },
8444 ],
8445 );
8446 let cache = test_cache(&config);
8447
8448 let now = jsonwebtoken::get_current_timestamp();
8449 let token = mint_token_with_claims(
8450 &pem,
8451 kid,
8452 &serde_json::json!({
8453 "iss": "https://auth.test.local",
8454 "aud": "https://mcp.test.local/mcp",
8455 "sub": "custom-client",
8456 "exp": now + 3600,
8457 "iat": now,
8458 "custom_scope": "read audit"
8459 }),
8460 );
8461
8462 let id = cache
8463 .validate_token(&token)
8464 .await
8465 .expect("should authenticate");
8466 assert_eq!(id.name, "custom-client");
8467 assert_eq!(id.role, "viewer");
8468 }
8469
8470 #[tokio::test]
8471 async fn scope_backward_compat_without_role_claim() {
8472 let kid = "test-compat-1";
8474 let (pem, jwks) = generate_test_keypair(kid);
8475
8476 let mock_server = wiremock::MockServer::start().await;
8477 wiremock::Mock::given(wiremock::matchers::method("GET"))
8478 .and(wiremock::matchers::path("/jwks.json"))
8479 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8480 .mount(&mock_server)
8481 .await;
8482
8483 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8484 let config = test_config(&jwks_uri); let cache = test_cache(&config);
8486
8487 let token = mint_token(
8488 &pem,
8489 kid,
8490 "https://auth.test.local",
8491 "https://mcp.test.local/mcp",
8492 "legacy-bot",
8493 "mcp:admin other:scope",
8494 );
8495
8496 let id = cache
8497 .validate_token(&token)
8498 .await
8499 .expect("should authenticate");
8500 assert_eq!(id.name, "legacy-bot");
8501 assert_eq!(id.role, "ops"); }
8503
8504 #[tokio::test]
8509 async fn jwks_refresh_deduplication() {
8510 let kid = "test-dedup";
8513 let (pem, jwks) = generate_test_keypair(kid);
8514
8515 let mock_server = wiremock::MockServer::start().await;
8516 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8517 .and(wiremock::matchers::path("/jwks.json"))
8518 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8519 .expect(1) .mount(&mock_server)
8521 .await;
8522
8523 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8524 let config = test_config(&jwks_uri);
8525 let cache = Arc::new(test_cache(&config));
8526
8527 let token = mint_token(
8529 &pem,
8530 kid,
8531 "https://auth.test.local",
8532 "https://mcp.test.local/mcp",
8533 "concurrent-bot",
8534 "mcp:read",
8535 );
8536
8537 let mut handles = Vec::new();
8538 for _ in 0..5 {
8539 let c = Arc::clone(&cache);
8540 let t = token.clone();
8541 handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
8542 }
8543
8544 for h in handles {
8545 let result = h.await.unwrap();
8546 assert!(result.is_some(), "all concurrent requests should succeed");
8547 }
8548
8549 }
8551
8552 #[tokio::test]
8553 async fn jwks_refresh_cooldown_blocks_rapid_requests() {
8554 let kid = "test-cooldown";
8557 let (_pem, jwks) = generate_test_keypair(kid);
8558
8559 let mock_server = wiremock::MockServer::start().await;
8560 let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8561 .and(wiremock::matchers::path("/jwks.json"))
8562 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8563 .expect(1) .mount(&mock_server)
8565 .await;
8566
8567 let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8568 let config = test_config(&jwks_uri);
8569 let cache = test_cache(&config);
8570
8571 let fake_token1 =
8573 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
8574 let _ = cache.validate_token(fake_token1).await;
8575
8576 let fake_token2 =
8579 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
8580 let _ = cache.validate_token(fake_token2).await;
8581
8582 let fake_token3 =
8584 "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
8585 let _ = cache.validate_token(fake_token3).await;
8586
8587 }
8589
8590 fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
8593 OAuthProxyConfig {
8594 authorize_url: "https://example.invalid/auth".into(),
8595 token_url: token_url.into(),
8596 client_id: "mcp-client".into(),
8597 client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
8598 introspection_url: None,
8599 revocation_url: None,
8600 expose_admin_endpoints: false,
8601 require_auth_on_admin_endpoints: false,
8602 allow_unauthenticated_admin_endpoints: false,
8603 strip_resource_param: false,
8604 }
8605 }
8606
8607 fn test_http_client() -> OauthHttpClient {
8610 rustls::crypto::ring::default_provider()
8611 .install_default()
8612 .ok();
8613 let config = OAuthConfig::builder(
8614 "https://auth.test.local",
8615 "https://mcp.test.local/mcp",
8616 "https://auth.test.local/.well-known/jwks.json",
8617 )
8618 .allow_http_oauth_urls(true)
8619 .build();
8620 OauthHttpClient::with_config(&config)
8621 .expect("build test http client")
8622 .__test_allow_loopback_ssrf()
8623 }
8624
8625 #[tokio::test]
8626 async fn introspect_proxies_and_injects_client_credentials() {
8627 use wiremock::matchers::{body_string_contains, method, path};
8628
8629 let mock_server = wiremock::MockServer::start().await;
8630 wiremock::Mock::given(method("POST"))
8631 .and(path("/introspect"))
8632 .and(body_string_contains("client_id=mcp-client"))
8633 .and(body_string_contains("client_secret=shh"))
8634 .and(body_string_contains("token=abc"))
8635 .respond_with(
8636 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8637 "active": true,
8638 "scope": "read"
8639 })),
8640 )
8641 .expect(1)
8642 .mount(&mock_server)
8643 .await;
8644
8645 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8646 proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
8647
8648 let http = test_http_client();
8649 let resp = handle_introspect(&http, &proxy, "token=abc").await;
8650 assert_eq!(resp.status(), 200);
8651 }
8652
8653 #[tokio::test]
8654 async fn token_proxy_fails_closed_on_oversized_upstream_response() {
8655 use http_body_util::BodyExt as _;
8656 use wiremock::matchers::{method, path};
8657
8658 let oversized = "x"
8660 .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
8661 let mock_server = wiremock::MockServer::start().await;
8662 wiremock::Mock::given(method("POST"))
8663 .and(path("/token"))
8664 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
8665 .expect(1)
8666 .mount(&mock_server)
8667 .await;
8668
8669 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8670 let http = test_http_client();
8671 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8672
8673 assert_eq!(
8675 resp.status(),
8676 502,
8677 "oversized upstream response must fail closed as 502"
8678 );
8679 let body = resp
8680 .into_body()
8681 .collect()
8682 .await
8683 .expect("collect body")
8684 .to_bytes();
8685 assert!(
8686 body.len() < 1024,
8687 "must return the small generic error body, not the oversized upstream body (got {} bytes)",
8688 body.len()
8689 );
8690 assert!(
8691 !body.windows(8).any(|w| w == b"xxxxxxxx"),
8692 "the oversized upstream payload must not be forwarded to the client"
8693 );
8694 }
8695
8696 #[tokio::test]
8697 async fn token_proxy_passes_through_normal_response() {
8698 use http_body_util::BodyExt as _;
8699 use wiremock::matchers::{method, path};
8700
8701 let mock_server = wiremock::MockServer::start().await;
8702 wiremock::Mock::given(method("POST"))
8703 .and(path("/token"))
8704 .respond_with(
8705 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8706 "access_token": "at-123",
8707 "token_type": "Bearer"
8708 })),
8709 )
8710 .expect(1)
8711 .mount(&mock_server)
8712 .await;
8713
8714 let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8715 let http = test_http_client();
8716 let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8717
8718 assert_eq!(
8719 resp.status(),
8720 200,
8721 "a normal-sized response must pass through"
8722 );
8723 let body = resp
8724 .into_body()
8725 .collect()
8726 .await
8727 .expect("collect body")
8728 .to_bytes();
8729 let json: serde_json::Value =
8730 serde_json::from_slice(&body).expect("upstream JSON preserved");
8731 assert_eq!(json["access_token"], "at-123");
8732 }
8733
8734 #[tokio::test]
8735 async fn introspect_returns_404_when_not_configured() {
8736 let proxy = proxy_cfg("https://example.invalid/token");
8737 let http = test_http_client();
8738 let resp = handle_introspect(&http, &proxy, "token=abc").await;
8739 assert_eq!(resp.status(), 404);
8740 }
8741
8742 #[tokio::test]
8743 async fn revoke_proxies_and_returns_upstream_status() {
8744 use wiremock::matchers::{method, path};
8745
8746 let mock_server = wiremock::MockServer::start().await;
8747 wiremock::Mock::given(method("POST"))
8748 .and(path("/revoke"))
8749 .respond_with(wiremock::ResponseTemplate::new(200))
8750 .expect(1)
8751 .mount(&mock_server)
8752 .await;
8753
8754 let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8755 proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
8756
8757 let http = test_http_client();
8758 let resp = handle_revoke(&http, &proxy, "token=abc").await;
8759 assert_eq!(resp.status(), 200);
8760 }
8761
8762 #[tokio::test]
8763 async fn revoke_returns_404_when_not_configured() {
8764 let proxy = proxy_cfg("https://example.invalid/token");
8765 let http = test_http_client();
8766 let resp = handle_revoke(&http, &proxy, "token=abc").await;
8767 assert_eq!(resp.status(), 404);
8768 }
8769
8770 #[test]
8771 fn metadata_advertises_endpoints_only_when_configured() {
8772 let mut cfg = test_config("https://auth.test.local/jwks.json");
8773 let m = authorization_server_metadata("https://mcp.local", &cfg);
8775 assert!(m.get("introspection_endpoint").is_none());
8776 assert!(m.get("revocation_endpoint").is_none());
8777
8778 let mut proxy = proxy_cfg("https://upstream.local/token");
8781 proxy.introspection_url = Some("https://upstream.local/introspect".into());
8782 proxy.revocation_url = Some("https://upstream.local/revoke".into());
8783 cfg.proxy = Some(proxy);
8784 let m = authorization_server_metadata("https://mcp.local", &cfg);
8785 assert!(
8786 m.get("introspection_endpoint").is_none(),
8787 "introspection must not be advertised when expose_admin_endpoints=false"
8788 );
8789 assert!(
8790 m.get("revocation_endpoint").is_none(),
8791 "revocation must not be advertised when expose_admin_endpoints=false"
8792 );
8793
8794 if let Some(p) = cfg.proxy.as_mut() {
8796 p.expose_admin_endpoints = true;
8797 p.revocation_url = None;
8798 }
8799 let m = authorization_server_metadata("https://mcp.local", &cfg);
8800 assert_eq!(
8801 m["introspection_endpoint"],
8802 serde_json::Value::String("https://mcp.local/introspect".into())
8803 );
8804 assert!(m.get("revocation_endpoint").is_none());
8805
8806 if let Some(p) = cfg.proxy.as_mut() {
8808 p.revocation_url = Some("https://upstream.local/revoke".into());
8809 }
8810 let m = authorization_server_metadata("https://mcp.local", &cfg);
8811 assert_eq!(
8812 m["revocation_endpoint"],
8813 serde_json::Value::String("https://mcp.local/revoke".into())
8814 );
8815 }
8816
8817 fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
8820 let mut cfg = validation_https_config();
8821 cfg.token_exchange = Some(tx);
8822 cfg
8823 }
8824
8825 fn tx_with(
8826 client_secret: Option<&str>,
8827 client_cert: Option<ClientCertConfig>,
8828 ) -> TokenExchangeConfig {
8829 TokenExchangeConfig::new(
8830 "https://idp.example.com/token",
8831 "client",
8832 client_secret.map(|s| secrecy::SecretString::new(s.into())),
8833 client_cert,
8834 )
8835 .with_audience("downstream")
8836 }
8837
8838 #[test]
8839 fn validate_rejects_non_uri_custom_requested_token_type() {
8840 for bad in ["acess_token", "not a uri", "urn:bad%zz:token"] {
8841 let tx = tx_with(Some("s"), None)
8842 .with_requested_token_type(RequestedTokenType::Custom(bad.to_owned()));
8843 let err = https_cfg_with_tx(tx)
8844 .validate()
8845 .expect_err("a custom token type that is not a URI must be rejected")
8846 .to_string();
8847 assert!(
8848 err.contains("requested_token_type"),
8849 "error must name the offending field for {bad:?}; got {err:?}"
8850 );
8851 }
8852 }
8853
8854 #[test]
8855 fn validate_accepts_uri_custom_requested_token_type_including_fragments() {
8856 for good in [
8857 "urn:ietf:params:oauth:token-type:saml2",
8858 "https://vendor.example/token-type",
8859 "urn:example:token#v2",
8860 ] {
8861 let tx = tx_with(Some("s"), None)
8862 .with_requested_token_type(RequestedTokenType::Custom(good.to_owned()));
8863 https_cfg_with_tx(tx).validate().unwrap_or_else(|e| {
8864 panic!(
8865 "RFC 8693 §3 only requires a URI; {good:?} must be accepted \
8866 (the no-fragment rule is RFC 8707's, for `resource` only): {e}"
8867 )
8868 });
8869 }
8870 }
8871
8872 #[test]
8873 fn validate_rejects_empty_optional_token_exchange_params() {
8874 let base = || tx_with(Some("s"), None);
8875 let cases = [
8876 (base().with_audience(""), "audience"),
8877 (base().with_resource(""), "resource"),
8878 (base().with_scope(""), "scope"),
8879 (
8880 base().with_requested_token_type(RequestedTokenType::Custom(String::new())),
8881 "requested_token_type",
8882 ),
8883 ];
8884 for (tx, field) in cases {
8885 let cfg = https_cfg_with_tx(tx);
8886 let err = cfg
8887 .validate()
8888 .expect_err("an empty optional parameter must be rejected");
8889 let msg = err.to_string();
8890 assert!(
8891 msg.contains(field) && msg.contains("must not be empty"),
8892 "error must name {field} and explain emptiness; got {msg:?}"
8893 );
8894 }
8895 }
8896
8897 #[test]
8898 fn validate_rejects_non_conformant_resource_uri() {
8899 for (value, expected) in [
8900 ("not-an-absolute-uri", "absolute URI"),
8901 ("https://api.example.com/v1#frag", "fragment"),
8902 ("https://api.example.com/a b", "valid URI characters"),
8903 ("https://api.example.com/%zz", "valid URI characters"),
8904 ("https://api.example.com/\u{e9}", "valid URI characters"),
8905 ] {
8906 let cfg = https_cfg_with_tx(tx_with(Some("s"), None).with_resource(value));
8907 let err = cfg
8908 .validate()
8909 .expect_err("resource must satisfy RFC 8707 §2");
8910 let msg = err.to_string();
8911 assert!(
8912 msg.contains(expected),
8913 "error for {value:?} must mention {expected:?}; got {msg:?}"
8914 );
8915 }
8916 }
8917
8918 #[test]
8919 fn validate_accepts_token_exchange_with_all_optional_params_omitted() {
8920 let mut tx = tx_with(Some("s"), None);
8921 tx.audience = None;
8922 tx.requested_token_type = RequestedTokenType::Omit;
8923 https_cfg_with_tx(tx)
8924 .validate()
8925 .expect("omitting every RFC 8693 §2.1 OPTIONAL parameter must be valid");
8926 }
8927
8928 #[test]
8929 fn validate_rejects_token_exchange_without_client_auth() {
8930 let cfg = https_cfg_with_tx(tx_with(None, None));
8931 let err = cfg
8932 .validate()
8933 .expect_err("token_exchange without client auth must be rejected");
8934 let msg = err.to_string();
8935 assert!(
8936 msg.contains("requires client authentication"),
8937 "error must explain missing client auth; got {msg:?}"
8938 );
8939 }
8940
8941 #[test]
8942 fn validate_rejects_token_exchange_with_both_secret_and_cert() {
8943 let cc = ClientCertConfig {
8944 cert_path: PathBuf::from("/nonexistent/cert.pem"),
8945 key_path: PathBuf::from("/nonexistent/key.pem"),
8946 };
8947 let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
8948 let err = cfg
8949 .validate()
8950 .expect_err("client_secret + client_cert must be rejected");
8951 let msg = err.to_string();
8952 assert!(
8953 msg.contains("mutually") && msg.contains("exclusive"),
8954 "error must explain mutual exclusion; got {msg:?}"
8955 );
8956 }
8957
8958 #[cfg(not(feature = "oauth-mtls-client"))]
8959 #[test]
8960 fn validate_rejects_client_cert_without_feature() {
8961 let cc = ClientCertConfig {
8962 cert_path: PathBuf::from("/nonexistent/cert.pem"),
8963 key_path: PathBuf::from("/nonexistent/key.pem"),
8964 };
8965 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8966 let err = cfg
8967 .validate()
8968 .expect_err("client_cert without feature must be rejected");
8969 assert!(
8970 err.to_string().contains("oauth-mtls-client"),
8971 "error must reference the cargo feature; got {err}"
8972 );
8973 }
8974
8975 #[cfg(feature = "oauth-mtls-client")]
8976 #[test]
8977 fn validate_rejects_missing_client_cert_files() {
8978 let cc = ClientCertConfig {
8979 cert_path: PathBuf::from("/nonexistent/cert.pem"),
8980 key_path: PathBuf::from("/nonexistent/key.pem"),
8981 };
8982 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8983 let err = cfg
8984 .validate()
8985 .expect_err("missing cert file must be rejected");
8986 assert!(
8987 err.to_string().contains("unreadable"),
8988 "error must call out unreadable file; got {err}"
8989 );
8990 }
8991
8992 #[cfg(feature = "oauth-mtls-client")]
8993 #[test]
8994 fn validate_rejects_malformed_client_cert_pem() {
8995 let dir = std::env::temp_dir();
8996 let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
8997 let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
8998 std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
8999 std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
9000 let cc = ClientCertConfig {
9001 cert_path: cert.clone(),
9002 key_path: key.clone(),
9003 };
9004 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
9005 let err = cfg.validate().expect_err("malformed PEM must be rejected");
9006 let _ = std::fs::remove_file(&cert);
9007 let _ = std::fs::remove_file(&key);
9008 assert!(
9009 err.to_string().contains("PEM parse failed"),
9010 "error must call out PEM parse failure; got {err}"
9011 );
9012 }
9013
9014 #[cfg(feature = "oauth-mtls-client")]
9015 fn write_self_signed_pem() -> (PathBuf, PathBuf) {
9016 let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
9017 let dir = std::env::temp_dir();
9018 let pid = std::process::id();
9019 let nonce: u64 = rand::random();
9020 let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
9021 let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
9022 std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
9023 std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
9024 (cert_path, key_path)
9025 }
9026
9027 #[cfg(feature = "oauth-mtls-client")]
9028 fn install_test_crypto_provider() {
9029 let _ = rustls::crypto::ring::default_provider().install_default();
9030 }
9031
9032 #[cfg(feature = "oauth-mtls-client")]
9033 #[test]
9034 fn validate_accepts_well_formed_client_cert() {
9035 install_test_crypto_provider();
9036 let (cert_path, key_path) = write_self_signed_pem();
9037 let cc = ClientCertConfig {
9038 cert_path: cert_path.clone(),
9039 key_path: key_path.clone(),
9040 };
9041 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
9042 let res = cfg.validate();
9043 let _ = std::fs::remove_file(&cert_path);
9044 let _ = std::fs::remove_file(&key_path);
9045 res.expect("well-formed cert+key must validate");
9046 }
9047
9048 #[cfg(feature = "oauth-mtls-client")]
9049 #[test]
9050 fn client_for_returns_cached_mtls_client() {
9051 install_test_crypto_provider();
9052 let (cert_path, key_path) = write_self_signed_pem();
9053 let cc = ClientCertConfig {
9054 cert_path: cert_path.clone(),
9055 key_path: key_path.clone(),
9056 };
9057 let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
9058 let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
9059 let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
9060 let cert_client = http.client_for(tx_ref);
9061 let inner_client = http.client_for(&tx_with(Some("s"), None));
9062 let _ = std::fs::remove_file(&cert_path);
9063 let _ = std::fs::remove_file(&key_path);
9064 assert!(
9065 !std::ptr::eq(cert_client, inner_client),
9066 "client_for must return distinct clients for cert vs no-cert configs"
9067 );
9068 }
9069
9070 #[cfg(feature = "oauth-mtls-client")]
9071 #[test]
9072 fn client_for_falls_back_to_inner_when_cache_miss() {
9073 install_test_crypto_provider();
9074 let cfg = validation_https_config();
9075 let http = OauthHttpClient::with_config(&cfg).expect("build client");
9076 let unrelated_cc = ClientCertConfig {
9077 cert_path: PathBuf::from("/cache/miss/cert.pem"),
9078 key_path: PathBuf::from("/cache/miss/key.pem"),
9079 };
9080 let tx_unknown = tx_with(None, Some(unrelated_cc));
9081 let fallback = http.client_for(&tx_unknown);
9082 let inner = http.client_for(&tx_with(Some("s"), None));
9083 assert!(
9084 std::ptr::eq(fallback, inner),
9085 "cache miss must fall back to inner client"
9086 );
9087 }
9088}