Skip to main content

rmcp_server_kit/
oauth.rs

1//! OAuth 2.1 JWT bearer token validation with JWKS caching.
2//!
3//! When enabled, Bearer tokens that look like JWTs (three base64-separated
4//! segments with a valid JSON header containing `"alg"`) are validated
5//! against a JWKS fetched from the configured Authorization Server.
6//! Token scopes are mapped to RBAC roles via explicit configuration.
7//!
8//! ## OAuth 2.1 Proxy
9//!
10//! When `OAuthConfig::proxy` is set, the MCP server acts as an OAuth 2.1
11//! authorization server facade, proxying `/authorize` and `/token` to an
12//! upstream identity provider (e.g. Keycloak).  MCP clients discover this server as the
13//! authorization server via Protected Resource Metadata (RFC 9728) and
14//! perform the standard Authorization Code + PKCE flow transparently.
15
16use std::{
17    collections::HashMap,
18    path::PathBuf,
19    sync::{
20        Arc,
21        atomic::{AtomicBool, Ordering},
22    },
23    time::{Duration, Instant},
24};
25
26use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
27use serde::Deserialize;
28use tokio::{net::lookup_host, sync::RwLock};
29
30use crate::auth::{AuthIdentity, AuthMethod};
31
32// ---------------------------------------------------------------------------
33// Shared OAuth redirect-policy helper
34// ---------------------------------------------------------------------------
35
36/// Outcome of evaluating a single OAuth redirect hop against the
37/// shared policy used by both [`OauthHttpClient::build`] and
38/// [`JwksCache::new`].
39///
40/// `Ok(())` means the redirect should be followed; `Err(reason)` means
41/// the closure should reject it. Callers are responsible for emitting
42/// the `tracing::warn!` rejection log so the policy stays a pure
43/// function (no I/O, no logging) and so the closures keep their
44/// cognitive complexity below the crate-wide clippy threshold.
45///
46/// The policy mirrors the documented behaviour exactly:
47///   1. `https -> http` redirect downgrades are *always* rejected.
48///   2. Non-`https` targets are accepted only when `allow_http` is true
49///      *and* the destination scheme is `http`.
50///   3. Targets resolving to disallowed IP ranges (private / loopback /
51///      link-local / multicast / broadcast / unspecified /
52///      cloud-metadata) are rejected via
53///      [`crate::ssrf::redirect_target_reason_with_allowlist`], which
54///      consults the operator-supplied allowlist while keeping
55///      cloud-metadata addresses unbypassable.
56///   4. The hop count is capped at 2 (i.e. at most 2 prior redirects).
57fn evaluate_oauth_redirect(
58    attempt: &reqwest::redirect::Attempt<'_>,
59    allow_http: bool,
60    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
61) -> Result<(), String> {
62    let prev_https = attempt
63        .previous()
64        .last()
65        .is_some_and(|prev| prev.scheme() == "https");
66    let target_url = attempt.url();
67    let dest_scheme = target_url.scheme();
68    if dest_scheme != "https" {
69        if prev_https {
70            return Err("redirect downgrades https -> http".to_owned());
71        }
72        if !allow_http || dest_scheme != "http" {
73            return Err("redirect to non-HTTP(S) URL refused".to_owned());
74        }
75    }
76    if let Some(reason) = crate::ssrf::redirect_target_reason_with_allowlist(target_url, allowlist)
77    {
78        return Err(format!("redirect target forbidden: {reason}"));
79    }
80    if attempt.previous().len() >= 2 {
81        return Err("too many redirects (max 2)".to_owned());
82    }
83    Ok(())
84}
85
86/// Screen an OAuth/JWKS target before the initial outbound connect.
87///
88/// This complements the per-redirect-hop guard in
89/// [`evaluate_oauth_redirect`]: redirects are screened synchronously via
90/// [`crate::ssrf::redirect_target_reason_with_allowlist`], while the
91/// initial request target is screened here after DNS resolution so
92/// hostnames resolving to loopback/private/link-local/metadata space
93/// are rejected before any TCP dial occurs.
94///
95/// **Cloud-metadata addresses (IPv4 `169.254.169.254`, Alibaba/Tencent
96/// `100.100.100.200`, AWS IPv6 `fd00:ec2::254`, GCP IPv6
97/// `fd20:ce::254`) are blocked unconditionally** -- the operator
98/// allowlist cannot re-allow them.
99///
100/// This single core is compiled identically under ALL cfgs, so the test
101/// suite always exercises the exact code production runs. Production
102/// callers go through [`screen_oauth_target`], which hardcodes
103/// `test_allow_loopback_ssrf = false`; the test-only bypass wrapper is
104/// [`screen_oauth_target_with_test_override`].
105async fn screen_oauth_target_core(
106    url: &str,
107    allow_http: bool,
108    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
109    test_allow_loopback_ssrf: bool,
110) -> Result<(), crate::error::McpxError> {
111    let parsed = check_oauth_url("oauth target", url, allow_http)?;
112    if test_allow_loopback_ssrf {
113        return Ok(());
114    }
115    if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
116        return Err(crate::error::McpxError::Config(format!(
117            "OAuth target forbidden ({reason}): {url}"
118        )));
119    }
120
121    let host = parsed.host_str().ok_or_else(|| {
122        crate::error::McpxError::Config(format!("OAuth target URL has no host: {url}"))
123    })?;
124    let port = parsed.port_or_known_default().ok_or_else(|| {
125        crate::error::McpxError::Config(format!("OAuth target URL has no known port: {url}"))
126    })?;
127
128    let addrs = lookup_host((host, port)).await.map_err(|error| {
129        crate::error::McpxError::Config(format!("OAuth target DNS resolution {url}: {error}"))
130    })?;
131
132    let host_allowed = !allowlist.is_empty() && allowlist.host_allowed(host);
133    let mut any_addr = false;
134    for addr in addrs {
135        any_addr = true;
136        let ip = addr.ip();
137        if let Some(reason) = crate::ssrf::ip_block_reason(ip) {
138            // Cloud-metadata is unbypassable. Use the strict message
139            // that does NOT advertise the allowlist knob.
140            if reason == "cloud_metadata" {
141                return Err(crate::error::McpxError::Config(format!(
142                    "OAuth target resolved to blocked IP ({reason}): {url}"
143                )));
144            }
145            // Default-empty-allowlist path: preserve the historical
146            // message verbatim so existing tests continue to pass and
147            // operators get the same diagnostic they had before.
148            if allowlist.is_empty() {
149                return Err(crate::error::McpxError::Config(format!(
150                    "OAuth target resolved to blocked IP ({reason}): {url}"
151                )));
152            }
153            // Allowlist-configured path: consult host + per-IP allowlist.
154            if host_allowed || allowlist.ip_allowed(ip) {
155                continue;
156            }
157            return Err(crate::error::McpxError::Config(format!(
158                "OAuth target blocked: hostname {host} resolved to {ip} ({reason}). \
159                 To allow, add the hostname to oauth.ssrf_allowlist.hosts or the CIDR \
160                 to oauth.ssrf_allowlist.cidrs (operators only -- see SECURITY.md). \
161                 URL: {url}"
162            )));
163        }
164    }
165    if !any_addr {
166        return Err(crate::error::McpxError::Config(format!(
167            "OAuth target DNS resolution returned no addresses: {url}"
168        )));
169    }
170
171    Ok(())
172}
173
174/// Production entry point for OAuth/JWKS target screening. Delegates to
175/// [`screen_oauth_target_core`] with the loopback bypass hardcoded off.
176async fn screen_oauth_target(
177    url: &str,
178    allow_http: bool,
179    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
180) -> Result<(), crate::error::McpxError> {
181    screen_oauth_target_core(url, allow_http, allowlist, false).await
182}
183
184/// Test-only wrapper exposing the loopback-SSRF bypass flag of
185/// [`screen_oauth_target_core`] so higher-level OAuth flows can run
186/// against loopback-backed mock fixtures.
187#[cfg(any(test, feature = "test-helpers"))]
188async fn screen_oauth_target_with_test_override(
189    url: &str,
190    allow_http: bool,
191    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
192    test_allow_loopback_ssrf: bool,
193) -> Result<(), crate::error::McpxError> {
194    screen_oauth_target_core(url, allow_http, allowlist, test_allow_loopback_ssrf).await
195}
196
197// ---------------------------------------------------------------------------
198// HTTP client wrapper
199// ---------------------------------------------------------------------------
200
201/// HTTP client used by [`exchange_token`] and the OAuth 2.1 proxy
202/// handlers ([`handle_token`], [`handle_introspect`], [`handle_revoke`]).
203///
204/// Wraps an internal HTTP backend so callers do not depend on the
205/// concrete crate. Construct one per process and reuse across requests
206/// (the underlying connection pool is shared internally via
207/// [`Clone`] - cheap, refcounted).
208///
209/// **Hardening (since 1.2.1).** When constructed via [`with_config`]
210/// (preferred), the internal client refuses any redirect that downgrades
211/// the scheme from `https` to `http`, even when the original request URL
212/// was HTTPS. This closes a class of metadata-poisoning attacks where a
213/// hostile or compromised upstream `IdP` returns `302 Location: http://...`
214/// and the resulting plaintext hop is intercepted by a network-positioned
215/// attacker to siphon bearer tokens, refresh tokens, or introspection
216/// traffic. When the caller has set [`OAuthConfig::allow_http_oauth_urls`]
217/// to `true` (development only), HTTP-to-HTTP redirects are still permitted
218/// but HTTPS-to-HTTP downgrades are *always* rejected.
219///
220/// [`with_config`] also honours [`OAuthConfig::ca_cert_path`] (if set) and
221/// adds the supplied PEM CA bundle to the system roots so that
222/// every OAuth-bound HTTP request -- not just the JWKS fetch -- can
223/// trust enterprise/internal certificate authorities. This restores
224/// the behaviour that existed pre-`0.10.0` before the `OauthHttpClient`
225/// wrapper landed.
226///
227/// The legacy [`new`](Self::new) constructor (no-arg) is preserved for
228/// source compatibility but is `#[deprecated]`: it returns a client with
229/// system-roots-only TLS trust and the strictest redirect policy
230/// (HTTPS-only, never permits plain HTTP). Migrate to
231/// [`with_config`](Self::with_config) at the earliest opportunity so
232/// that token / introspection / revocation / exchange traffic inherits
233/// the same CA trust and `allow_http_oauth_urls` toggle as the JWKS
234/// fetch client.
235///
236/// [`with_config`]: Self::with_config
237#[derive(Clone)]
238pub struct OauthHttpClient {
239    inner: reqwest::Client,
240    allow_http: bool,
241    /// Compiled SSRF allowlist applied to the initial-target screen and
242    /// to literal-IP redirect-hop screening. Wrapped in `Arc` so cloning
243    /// the client (which is cheap and refcounted) does not deep-copy
244    /// the parsed CIDR / host vectors.
245    allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
246    /// M-H4: per-`(cert_path, key_path)` cache of cert-bearing
247    /// `reqwest::Client`s. Built eagerly with `redirect::Policy::none()`
248    /// so an attacker-controlled 3xx cannot re-present the client cert
249    /// to a different host (RFC 8705 ยง2 attack surface).
250    #[cfg(feature = "oauth-mtls-client")]
251    mtls_clients: Arc<HashMap<MtlsClientKey, reqwest::Client>>,
252    /// M-H2: shared loopback bypass observed by both `send_screened`'s
253    /// pre-flight check AND the `SsrfScreeningResolver` installed on
254    /// `inner`. Flipping the bit via `__test_allow_loopback_ssrf` must
255    /// reach the already-built `reqwest::Client`, so a per-snapshot
256    /// `bool` (Oracle review B1) is forbidden.
257    #[cfg(any(test, feature = "test-helpers"))]
258    test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
259}
260
261/// M-H4: cache key for cert-bearing `reqwest::Client`s. Path-based
262/// (not contents-based) -- in-place cert rotation is not picked up
263/// without restart (documented limitation in `CHANGELOG.md` 1.6.0).
264#[cfg(feature = "oauth-mtls-client")]
265#[derive(Debug, Clone, Hash, Eq, PartialEq)]
266struct MtlsClientKey {
267    cert_path: PathBuf,
268    key_path: PathBuf,
269}
270
271impl OauthHttpClient {
272    /// Build a client from the OAuth configuration (preferred since 1.2.1).
273    ///
274    /// Defaults: `connect_timeout = 10s`, total `timeout = 30s`,
275    /// scheme-downgrade-rejecting redirect policy (max 2 hops),
276    /// optional custom CA trust via [`OAuthConfig::ca_cert_path`],
277    /// and HTTP-to-HTTP redirects gated by
278    /// [`OAuthConfig::allow_http_oauth_urls`] (dev-only).
279    ///
280    /// Pass the same `&OAuthConfig` you supplied to
281    /// [`JwksCache::new`] / `serve()` so the OAuth-bound HTTP traffic
282    /// inherits identical CA trust and HTTPS-only redirect policy.
283    ///
284    /// # Errors
285    ///
286    /// Returns [`crate::error::McpxError::Startup`] if the configured
287    /// `ca_cert_path` cannot be read or parsed, or if the underlying
288    /// HTTP client cannot be constructed (e.g. TLS backend init failure).
289    pub fn with_config(config: &OAuthConfig) -> Result<Self, crate::error::McpxError> {
290        Self::build(Some(config))
291    }
292
293    /// Build a client with default settings (system CA roots only,
294    /// strict HTTPS-only redirect policy).
295    ///
296    /// **Deprecated since 1.2.1.** This constructor cannot honour
297    /// [`OAuthConfig::ca_cert_path`] (so token / introspection /
298    /// revocation / exchange traffic falls back to the system trust
299    /// store, breaking enterprise PKI deployments) and ignores the
300    /// [`OAuthConfig::allow_http_oauth_urls`] dev-mode toggle (so
301    /// HTTP-to-HTTP redirects are unconditionally refused). Both of
302    /// these are bugs that the new [`with_config`](Self::with_config)
303    /// constructor fixes.
304    ///
305    /// The redirect policy still rejects `https -> http` downgrades,
306    /// matching the security posture of [`with_config`](Self::with_config).
307    ///
308    /// Migrate to [`with_config`](Self::with_config) and pass the same
309    /// `&OAuthConfig` your `serve()` call uses.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`crate::error::McpxError::Startup`] if the underlying
314    /// HTTP client cannot be constructed (e.g. TLS backend init failure).
315    #[deprecated(
316        since = "1.2.1",
317        note = "use OauthHttpClient::with_config(&OAuthConfig) so token/introspect/revoke/exchange traffic inherits ca_cert_path and the allow_http_oauth_urls toggle"
318    )]
319    pub fn new() -> Result<Self, crate::error::McpxError> {
320        Self::build(None)
321    }
322
323    /// Internal builder shared by [`new`](Self::new) (config = `None`)
324    /// and [`with_config`](Self::with_config) (config = `Some`).
325    fn build(config: Option<&OAuthConfig>) -> Result<Self, crate::error::McpxError> {
326        let allow_http = config.is_some_and(|c| c.allow_http_oauth_urls);
327
328        // Compile the operator SSRF allowlist (if any) up front. Surface
329        // CIDR / host parse errors as Startup so misconfiguration fails
330        // fast at server boot, mirroring how OAuthConfig::validate
331        // surfaces them as Config errors.
332        let allowlist = match config.and_then(|c| c.ssrf_allowlist.as_ref()) {
333            Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
334                crate::error::McpxError::Startup(format!("oauth http client: {e}"))
335            })?),
336            None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
337        };
338
339        // Clone an Arc into the redirect closure so the policy can
340        // consult the operator allowlist without re-parsing.
341        let redirect_allowlist = Arc::clone(&allowlist);
342
343        // M-H2: shared bypass holder created BEFORE the resolver so
344        // the resolver, send_screened, and the cached `inner` client
345        // all observe the same atomic.
346        #[cfg(any(test, feature = "test-helpers"))]
347        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
348            Arc::new(AtomicBool::new(false));
349        #[cfg(not(any(test, feature = "test-helpers")))]
350        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
351
352        let resolver: Arc<dyn reqwest::dns::Resolve> =
353            Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
354                Arc::clone(&allowlist),
355                // M-H2/B1: TestLoopbackBypass aliases to Arc<AtomicBool> in test
356                // builds and to `()` in production. Value clone is required
357                // because the type vanishes outside test cfg.
358                #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
359                test_bypass.clone(),
360            ));
361
362        let mut builder = reqwest::Client::builder()
363            // M-H2/N1: disable reqwest's auto-proxy detection. Without
364            // this, HTTP_PROXY / HTTPS_PROXY env vars route DNS through
365            // the proxy and bypass our SsrfScreeningResolver entirely.
366            .no_proxy()
367            .dns_resolver(Arc::clone(&resolver))
368            .connect_timeout(Duration::from_secs(10))
369            .timeout(Duration::from_secs(30))
370            .redirect(reqwest::redirect::Policy::custom(move |attempt| {
371                // SECURITY: a redirect from `https` to `http` is *always*
372                // rejected, even when `allow_http_oauth_urls` is true.
373                // The flag controls whether the *original* request URL
374                // may be plain HTTP; it never authorises a downgrade
375                // mid-flight. An `http -> http` redirect is permitted
376                // only when the flag is true (dev-only). The full
377                // policy lives in `evaluate_oauth_redirect` so the
378                // OauthHttpClient and JwksCache closures stay
379                // byte-for-byte identical.
380                match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
381                    Ok(()) => attempt.follow(),
382                    Err(reason) => {
383                        // Sanitized target: the rejected URL may carry
384                        // userinfo credentials (the rejection reason
385                        // itself is URL-free).
386                        tracing::warn!(
387                            reason = %reason,
388                            target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
389                            "oauth redirect rejected"
390                        );
391                        attempt.error(reason)
392                    }
393                }
394            }));
395
396        if let Some(cfg) = config
397            && let Some(ref ca_path) = cfg.ca_cert_path
398        {
399            // Pre-startup blocking I/O: this constructor runs from
400            // `serve()`'s pre-startup phase (and from test code), so
401            // synchronous file I/O is intentional. Do not wrap in
402            // `spawn_blocking` -- the constructor is sync by contract.
403            let pem = std::fs::read(ca_path).map_err(|e| {
404                crate::error::McpxError::Startup(format!(
405                    "oauth http client: read ca_cert_path {}: {e}",
406                    ca_path.display()
407                ))
408            })?;
409            let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
410                crate::error::McpxError::Startup(format!(
411                    "oauth http client: parse ca_cert_path {}: {e}",
412                    ca_path.display()
413                ))
414            })?;
415            builder = builder.add_root_certificate(cert);
416        }
417
418        let inner = builder.build().map_err(|e| {
419            crate::error::McpxError::Startup(format!("oauth http client init: {e}"))
420        })?;
421
422        #[cfg(feature = "oauth-mtls-client")]
423        let mtls_clients = build_mtls_clients(config, &allowlist, &test_bypass)?;
424
425        Ok(Self {
426            inner,
427            allow_http,
428            allowlist,
429            #[cfg(feature = "oauth-mtls-client")]
430            mtls_clients,
431            #[cfg(any(test, feature = "test-helpers"))]
432            test_allow_loopback_ssrf: test_bypass,
433        })
434    }
435
436    async fn send_screened(
437        &self,
438        url: &str,
439        request: reqwest::RequestBuilder,
440    ) -> Result<reqwest::Response, crate::error::McpxError> {
441        #[cfg(any(test, feature = "test-helpers"))]
442        if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
443            screen_oauth_target_with_test_override(url, self.allow_http, &self.allowlist, true)
444                .await?;
445        } else {
446            screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
447        }
448        #[cfg(not(any(test, feature = "test-helpers")))]
449        screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
450        request.send().await.map_err(|error| {
451            crate::error::McpxError::Config(format!("oauth request {url}: {error}"))
452        })
453    }
454
455    /// Test-only: disable initial-target SSRF screening for loopback-backed
456    /// fixtures. This is unreachable from normal production builds and exists
457    /// only so tests can exercise higher-level OAuth flows against local mock
458    /// servers.
459    #[cfg(any(test, feature = "test-helpers"))]
460    #[doc(hidden)]
461    #[must_use]
462    pub fn __test_allow_loopback_ssrf(self) -> Self {
463        // M-H2/B1: flip the SHARED atomic so the resolver inside
464        // `inner` and the pre-flight check both observe the bypass.
465        self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
466        self
467    }
468
469    /// Test-only: issue a `GET` against an arbitrary URL using the
470    /// configured client (redirect policy, CA trust, timeouts all
471    /// applied). Used by integration tests to exercise the redirect-
472    /// downgrade and CA-trust regressions without going through
473    /// `exchange_token`. Not part of the public API.
474    #[doc(hidden)]
475    pub async fn __test_get(&self, url: &str) -> reqwest::Result<reqwest::Response> {
476        self.inner.get(url).send().await
477    }
478
479    /// Test-only: borrow the inner `reqwest::Client` so the M-H2
480    /// env-proxy matrix test (`tests/e2e.rs::ssrf_no_proxy_*`) can
481    /// drive `.get(...).send()` directly and observe whether the
482    /// SsrfScreeningResolver fired (vs. the proxy short-circuiting
483    /// the request). Not part of the public API.
484    #[cfg(any(test, feature = "test-helpers"))]
485    #[doc(hidden)]
486    #[must_use]
487    pub fn __test_inner_client(&self) -> &reqwest::Client {
488        &self.inner
489    }
490
491    /// M-H4: select the cert-bearing `reqwest::Client` cached for
492    /// `cfg.client_cert`'s paths, else the shared `inner` client.
493    /// Defence-in-depth: a missing cache entry falls through to
494    /// `inner`; combined with the Authorization-header skip in
495    /// `exchange_token`, this surfaces as an upstream auth failure
496    /// rather than silent secret-bearer fallback.
497    #[cfg(feature = "oauth-mtls-client")]
498    fn client_for(&self, cfg: &TokenExchangeConfig) -> &reqwest::Client {
499        if let Some(cc) = &cfg.client_cert {
500            let key = MtlsClientKey {
501                cert_path: cc.cert_path.clone(),
502                key_path: cc.key_path.clone(),
503            };
504            if let Some(client) = self.mtls_clients.get(&key) {
505                return client;
506            }
507        }
508        &self.inner
509    }
510
511    #[cfg(not(feature = "oauth-mtls-client"))]
512    fn client_for(&self, _cfg: &TokenExchangeConfig) -> &reqwest::Client {
513        &self.inner
514    }
515}
516
517impl std::fmt::Debug for OauthHttpClient {
518    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519        f.debug_struct("OauthHttpClient").finish_non_exhaustive()
520    }
521}
522
523// ---------------------------------------------------------------------------
524// Configuration
525// ---------------------------------------------------------------------------
526
527/// Operator-trusted SSRF allowlist for OAuth/JWKS targets that resolve
528/// to addresses normally blocked by the post-DNS SSRF guard.
529///
530/// **Default: empty.** With both fields empty (or this struct unset),
531/// the existing fail-closed behavior is unchanged: any OAuth/JWKS URL
532/// resolving to RFC 1918, loopback, link-local, CGNAT, multicast,
533/// broadcast, unspecified, IPv6 unique-local / link-local / multicast,
534/// documentation, benchmarking, or reserved ranges is rejected before
535/// connect.
536///
537/// **Cloud-metadata addresses remain unbypassable** -- operators
538/// cannot opt in to metadata-service exposure. This carve-out covers:
539///
540/// - IPv4 `169.254.169.254` (AWS / GCP / Azure).
541/// - IPv4 `100.100.100.200` (Alibaba Cloud / Tencent Cloud).
542/// - IPv6 `fd00:ec2::254` (AWS IMDSv2 over IPv6).
543/// - IPv6 `fd20:ce::254` (GCP).
544///
545/// See `SECURITY.md` ยง "Operator allowlist".
546///
547/// Both lists are evaluated additively: a target is allowed if its
548/// hostname is in [`hosts`](Self::hosts) **or** every resolved IP for
549/// the target falls within at least one CIDR in [`cidrs`](Self::cidrs).
550///
551/// The allowlist applies to all six configured OAuth URL fields
552/// ([`OAuthConfig::issuer`], [`OAuthConfig::jwks_uri`],
553/// [`OAuthProxyConfig::authorize_url`], [`OAuthProxyConfig::token_url`],
554/// [`OAuthProxyConfig::introspection_url`],
555/// [`OAuthProxyConfig::revocation_url`],
556/// [`TokenExchangeConfig::token_url`]) and to the per-redirect-hop
557/// SSRF guard when a redirect target is a literal IP in a configured
558/// CIDR.
559///
560/// Entries are validated at startup: literal IPs in `hosts`, non-zero
561/// host bits in `cidrs`, malformed CIDRs, and entries containing
562/// ports / userinfo / paths are all rejected by
563/// [`OAuthConfig::validate`].
564///
565/// # Example
566///
567/// ```no_run
568/// use rmcp_server_kit::oauth::{OAuthConfig, OAuthSsrfAllowlist};
569///
570/// let mut allowlist = OAuthSsrfAllowlist::default();
571/// allowlist.hosts.push("rhbk.ops.example.com".into());
572/// allowlist.cidrs.push("10.0.0.0/8".into());
573/// let cfg = OAuthConfig::builder(
574///     "https://rhbk.ops.example.com/realms/ops",
575///     "mcp",
576///     "https://rhbk.ops.example.com/realms/ops/protocol/openid-connect/certs",
577/// )
578/// .ssrf_allowlist(allowlist)
579/// .build();
580/// cfg.validate().expect("operator allowlist parses");
581/// ```
582#[derive(Debug, Clone, Default, Deserialize)]
583#[non_exhaustive]
584pub struct OAuthSsrfAllowlist {
585    /// Hostnames allowed to resolve into otherwise-blocked address
586    /// ranges. Exact match, case-insensitive, no wildcards. Each entry
587    /// must be a bare DNS hostname: no scheme, no port, no userinfo,
588    /// not a literal IP.
589    #[serde(default)]
590    pub hosts: Vec<String>,
591    /// CIDR blocks whose addresses are considered trusted even when
592    /// the address would otherwise be blocked. Accepts both IPv4
593    /// (e.g. `10.0.0.0/8`) and IPv6 (e.g. `fd00::/8`).
594    ///
595    /// Cloud-metadata addresses inside any listed range remain blocked.
596    #[serde(default)]
597    pub cidrs: Vec<String>,
598}
599
600/// Compile and validate an operator allowlist into the runtime form.
601///
602/// Lowercases hostnames, rejects literal-IP and ill-formed host
603/// entries, parses + validates each CIDR (see [`crate::ssrf::CidrEntry::parse`]).
604/// Returns a `String` error suitable for embedding in
605/// [`crate::error::McpxError::Config`] / [`crate::error::McpxError::Startup`].
606fn compile_oauth_ssrf_allowlist(
607    raw: &OAuthSsrfAllowlist,
608) -> Result<crate::ssrf::CompiledSsrfAllowlist, String> {
609    let mut hosts: Vec<String> = Vec::with_capacity(raw.hosts.len());
610    for (idx, entry) in raw.hosts.iter().enumerate() {
611        let trimmed = entry.trim();
612        if trimmed.is_empty() {
613            return Err(format!("oauth.ssrf_allowlist.hosts[{idx}]: empty entry"));
614        }
615        // Reject embedded port / path / userinfo / query / fragment
616        // before reaching the URL parser, so the error is clearer than
617        // a generic "invalid host" diagnostic.
618        if trimmed.contains([':', '/', '@', '?', '#']) {
619            return Err(format!(
620                "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: must be a bare DNS hostname \
621                 (no scheme, port, path, userinfo, query, or fragment)"
622            ));
623        }
624        match url::Host::parse(trimmed) {
625            Ok(url::Host::Domain(_)) => {}
626            Ok(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
627                return Err(format!(
628                    "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: literal IPs are forbidden \
629                     here -- list them via oauth.ssrf_allowlist.cidrs instead"
630                ));
631            }
632            Err(e) => {
633                return Err(format!(
634                    "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: invalid hostname: {e}"
635                ));
636            }
637        }
638        hosts.push(trimmed.to_ascii_lowercase());
639    }
640    hosts.sort();
641    hosts.dedup();
642
643    let mut cidrs = Vec::with_capacity(raw.cidrs.len());
644    for (idx, entry) in raw.cidrs.iter().enumerate() {
645        let parsed = crate::ssrf::CidrEntry::parse(entry)
646            .map_err(|e| format!("oauth.ssrf_allowlist.cidrs[{idx}]: {e}"))?;
647        cidrs.push(parsed);
648    }
649
650    Ok(crate::ssrf::CompiledSsrfAllowlist::new(hosts, cidrs))
651}
652
653/// OAuth 2.1 JWT configuration.
654#[derive(Debug, Clone, Deserialize)]
655#[non_exhaustive]
656pub struct OAuthConfig {
657    /// Token issuer (`iss` claim). Must match exactly.
658    pub issuer: String,
659    /// Expected audience (`aud` claim). Must match exactly.
660    pub audience: String,
661    /// JWKS endpoint URL (e.g. `https://auth.example.com/.well-known/jwks.json`).
662    pub jwks_uri: String,
663    /// Scope-to-role mappings. First matching scope wins.
664    /// Used when `role_claim` is absent (default behavior).
665    #[serde(default)]
666    pub scopes: Vec<ScopeMapping>,
667    /// JWT claim path to extract roles from (dot-notation for nested claims).
668    ///
669    /// Examples: `"scope"` (default), `"roles"`, `"realm_access.roles"`.
670    /// When set, the claim value is matched against `role_mappings` instead
671    /// of `scopes`. Supports both space-separated strings and JSON arrays.
672    pub role_claim: Option<String>,
673    /// Claim-value-to-role mappings. Used when `role_claim` is set.
674    /// First matching value wins.
675    #[serde(default)]
676    pub role_mappings: Vec<RoleMapping>,
677    /// How long to cache JWKS keys before re-fetching.
678    /// Parsed as a humantime duration (e.g. "10m", "1h"). Default: "10m".
679    #[serde(default = "default_jwks_cache_ttl")]
680    pub jwks_cache_ttl: String,
681    /// OAuth proxy configuration.  When set, the server exposes
682    /// `/authorize`, `/token`, and `/register` endpoints that proxy
683    /// to the upstream identity provider (e.g. Keycloak).
684    pub proxy: Option<OAuthProxyConfig>,
685    /// Token exchange configuration (RFC 8693).  When set, the server
686    /// can exchange an inbound MCP-scoped access token for a downstream
687    /// API-scoped access token via the authorization server's token
688    /// endpoint.
689    pub token_exchange: Option<TokenExchangeConfig>,
690    /// Optional path to a PEM CA bundle for OAuth-bound HTTP traffic.
691    /// Added to the system/built-in roots, not a replacement.
692    ///
693    /// **Scope (since 1.2.1).** When the [`OauthHttpClient`] is
694    /// constructed via [`OauthHttpClient::with_config`] (preferred),
695    /// this CA bundle is honoured by *every* OAuth-bound HTTP
696    /// request: the JWKS key fetch, token exchange, introspection,
697    /// revocation, and the OAuth proxy handlers. Application crates
698    /// may auto-populate this from their own configuration (e.g. an
699    /// upstream-API CA path); any application-owned HTTP clients
700    /// outside the kit must still configure their own CA trust
701    /// separately. The deprecated [`OauthHttpClient::new`] no-arg
702    /// constructor cannot honour this field -- migrate to
703    /// [`OauthHttpClient::with_config`] for full coverage.
704    #[serde(default)]
705    pub ca_cert_path: Option<PathBuf>,
706    /// Allow plain-HTTP (non-TLS) URLs for OAuth endpoints (`jwks_uri`,
707    /// `proxy.authorize_url`, `proxy.token_url`, `proxy.introspection_url`,
708    /// `proxy.revocation_url`, `token_exchange.token_url`).
709    ///
710    /// **Default: `false`.** Strongly discouraged in production: a
711    /// network-positioned attacker can MITM JWKS responses and substitute
712    /// signing keys (forging arbitrary tokens), or MITM the token / proxy
713    /// endpoints to steal credentials and codes. Enable only for
714    /// development against a local `IdP` without TLS, ideally bound to
715    /// `127.0.0.1`. JWKS-cache redirects to non-HTTPS targets are still
716    /// rejected even when this flag is `true`.
717    #[serde(default)]
718    pub allow_http_oauth_urls: bool,
719    /// Operator-trusted SSRF allowlist for OAuth/JWKS targets.
720    ///
721    /// **Default: `None`** (fail-closed; current behavior preserved).
722    /// When set, the listed hostnames and CIDR blocks may resolve into
723    /// otherwise-blocked address ranges (RFC 1918, loopback, link-local,
724    /// CGNAT, IPv6 unique-local, ...). **Cloud-metadata addresses
725    /// remain unbypassable regardless of this setting** -- see
726    /// [`OAuthSsrfAllowlist`] and `SECURITY.md` ยง "Operator allowlist".
727    #[serde(default)]
728    pub ssrf_allowlist: Option<OAuthSsrfAllowlist>,
729    /// Maximum number of keys accepted from a JWKS refresh response.
730    /// Requests returning more keys than this are rejected fail-closed
731    /// (cache remains empty / unchanged). Default: 256.
732    #[serde(default = "default_max_jwks_keys")]
733    pub max_jwks_keys: usize,
734    /// Enforce strict audience validation using only the JWT `aud` claim.
735    ///
736    /// **Deprecated since 1.7.0.** Use [`OAuthConfig::audience_validation_mode`]
737    /// instead. When [`OAuthConfig::audience_validation_mode`] is `None`,
738    /// this flag is consulted: `true` resolves to
739    /// [`AudienceValidationMode::Strict`], `false`/unset resolves to
740    /// [`AudienceValidationMode::Warn`] (the new default โ€” accepts
741    /// `azp`-only matches with a one-shot warn log; previously silent).
742    #[serde(default)]
743    #[deprecated(
744        since = "1.7.0",
745        note = "use `audience_validation_mode` instead; this field is consulted only when `audience_validation_mode` is None"
746    )]
747    pub strict_audience_validation: bool,
748    /// How the resource server treats `azp` when validating JWT audience.
749    ///
750    /// When `None` (default), resolution falls back to
751    /// [`OAuthConfig::strict_audience_validation`] for backward
752    /// compatibility: `true` โ‡’ [`AudienceValidationMode::Strict`],
753    /// `false`/unset โ‡’ [`AudienceValidationMode::Warn`].
754    /// Set this field explicitly to make the policy unambiguous.
755    #[serde(default)]
756    pub audience_validation_mode: Option<AudienceValidationMode>,
757    /// Maximum size of a JWKS HTTP response body in bytes.
758    /// Responses exceeding this cap are refused and logged; the cache
759    /// remains empty / unchanged. Default: 1 MiB.
760    #[serde(default = "default_jwks_max_bytes")]
761    pub jwks_max_response_bytes: u64,
762}
763
764fn default_jwks_cache_ttl() -> String {
765    "10m".into()
766}
767
768const fn default_max_jwks_keys() -> usize {
769    256
770}
771
772const fn default_jwks_max_bytes() -> u64 {
773    1024 * 1024
774}
775
776/// How the resource server treats `azp` when validating JWT audience.
777///
778/// **Background.** RFC 9068 ยง4 + OIDC Core ยง2 establish `aud` as the
779/// authoritative resource-server claim and `azp` as the authorized-party
780/// (client) claim. Some OAuth deployments โ€” typically when the MCP server
781/// acts as both OAuth client *and* resource server (the documented
782/// [`OAuthProxyConfig`] topology) โ€” issue tokens where the configured
783/// audience appears only in `azp`. This enum lets operators decide
784/// whether that historic compatibility fallback is honored, surfaced via
785/// a one-shot warning, or refused.
786///
787/// **Default**: [`AudienceValidationMode::Warn`] โ€” accepts `azp`-only
788/// matches but emits a `tracing::warn!` once per process so operators
789/// can detect and migrate token-issuing IdP configurations toward
790/// populating `aud` correctly. Future major versions may default to
791/// [`AudienceValidationMode::Strict`].
792#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
793#[serde(rename_all = "snake_case")]
794#[non_exhaustive]
795pub enum AudienceValidationMode {
796    /// Accept `aud` matches and `azp`-only matches silently. Pre-1.7
797    /// behavior. Use only when the IdP cannot be reconfigured to
798    /// populate `aud`.
799    Permissive,
800    /// Accept `aud` matches silently. Accept `azp`-only matches with a
801    /// one-shot `tracing::warn!` per process. Reject neither. **Default
802    /// since 1.7.0.**
803    #[default]
804    Warn,
805    /// Accept only `aud` matches. Reject `azp`-only matches as audience
806    /// mismatch. Recommended for new deployments and any IdP that can
807    /// be configured to populate `aud` reliably.
808    Strict,
809}
810
811impl AudienceValidationMode {
812    /// Stable lower-case label for logs and diagnostics.
813    ///
814    /// Used so structured log fields render as a plain token
815    /// (e.g. `mode="warn"`) rather than the `Debug` form.
816    #[must_use]
817    pub(crate) const fn as_str(self) -> &'static str {
818        match self {
819            Self::Permissive => "permissive",
820            Self::Warn => "warn",
821            Self::Strict => "strict",
822        }
823    }
824}
825
826impl Default for OAuthConfig {
827    fn default() -> Self {
828        Self {
829            issuer: String::new(),
830            audience: String::new(),
831            jwks_uri: String::new(),
832            scopes: Vec::new(),
833            role_claim: None,
834            role_mappings: Vec::new(),
835            jwks_cache_ttl: default_jwks_cache_ttl(),
836            proxy: None,
837            token_exchange: None,
838            ca_cert_path: None,
839            allow_http_oauth_urls: false,
840            max_jwks_keys: default_max_jwks_keys(),
841            #[allow(
842                deprecated,
843                reason = "default-construct deprecated field for backward compat"
844            )]
845            strict_audience_validation: false,
846            audience_validation_mode: None,
847            jwks_max_response_bytes: default_jwks_max_bytes(),
848            ssrf_allowlist: None,
849        }
850    }
851}
852
853impl OAuthConfig {
854    /// Resolve the effective audience-validation policy.
855    ///
856    /// Precedence: explicit `audience_validation_mode` overrides the
857    /// legacy `strict_audience_validation` flag. When neither is set,
858    /// the default is [`AudienceValidationMode::Warn`].
859    #[must_use]
860    pub fn effective_audience_validation_mode(&self) -> AudienceValidationMode {
861        if let Some(mode) = self.audience_validation_mode {
862            return mode;
863        }
864        #[allow(deprecated, reason = "intentional: legacy flag resolution path")]
865        if self.strict_audience_validation {
866            AudienceValidationMode::Strict
867        } else {
868            AudienceValidationMode::Warn
869        }
870    }
871
872    /// Start building an [`OAuthConfig`] with the three required fields.
873    ///
874    /// All other fields default to the same values as
875    /// [`OAuthConfig::default`] (empty scopes/role mappings, no proxy or
876    /// token exchange, a JWKS cache TTL of `10m`).
877    pub fn builder(
878        issuer: impl Into<String>,
879        audience: impl Into<String>,
880        jwks_uri: impl Into<String>,
881    ) -> OAuthConfigBuilder {
882        OAuthConfigBuilder {
883            inner: Self {
884                issuer: issuer.into(),
885                audience: audience.into(),
886                jwks_uri: jwks_uri.into(),
887                ..Self::default()
888            },
889        }
890    }
891
892    /// Validate the URL fields against the HTTPS-only policy.
893    ///
894    /// Each of `jwks_uri`, `proxy.authorize_url`, `proxy.token_url`,
895    /// `proxy.introspection_url`, `proxy.revocation_url`, and
896    /// `token_exchange.token_url` is parsed and its scheme checked.
897    ///
898    /// Schemes other than `https` are rejected unless
899    /// [`OAuthConfig::allow_http_oauth_urls`] is `true`, in which case
900    /// `http` is also permitted (parse failures and other schemes are
901    /// always rejected).
902    ///
903    /// # Errors
904    ///
905    /// Returns [`crate::error::McpxError::Config`] when any field fails
906    /// to parse or violates the scheme policy.
907    pub fn validate(&self) -> Result<(), crate::error::McpxError> {
908        let allow_http = self.allow_http_oauth_urls;
909        let url = check_oauth_url("oauth.issuer", &self.issuer, allow_http)?;
910        if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
911            return Err(crate::error::McpxError::Config(format!(
912                "oauth.issuer forbidden ({reason})"
913            )));
914        }
915        let url = check_oauth_url("oauth.jwks_uri", &self.jwks_uri, allow_http)?;
916        if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
917            return Err(crate::error::McpxError::Config(format!(
918                "oauth.jwks_uri forbidden ({reason})"
919            )));
920        }
921        if let Some(proxy) = &self.proxy {
922            let url = check_oauth_url(
923                "oauth.proxy.authorize_url",
924                &proxy.authorize_url,
925                allow_http,
926            )?;
927            if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
928                return Err(crate::error::McpxError::Config(format!(
929                    "oauth.proxy.authorize_url forbidden ({reason})"
930                )));
931            }
932            let url = check_oauth_url("oauth.proxy.token_url", &proxy.token_url, allow_http)?;
933            if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
934                return Err(crate::error::McpxError::Config(format!(
935                    "oauth.proxy.token_url forbidden ({reason})"
936                )));
937            }
938            if let Some(url) = &proxy.introspection_url {
939                let parsed = check_oauth_url("oauth.proxy.introspection_url", url, allow_http)?;
940                if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
941                    return Err(crate::error::McpxError::Config(format!(
942                        "oauth.proxy.introspection_url forbidden ({reason})"
943                    )));
944                }
945            }
946            if let Some(url) = &proxy.revocation_url {
947                let parsed = check_oauth_url("oauth.proxy.revocation_url", url, allow_http)?;
948                if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
949                    return Err(crate::error::McpxError::Config(format!(
950                        "oauth.proxy.revocation_url forbidden ({reason})"
951                    )));
952                }
953            }
954            // M3: refuse to start with admin endpoints exposed but no
955            // auth in front of them, unless the operator has explicitly
956            // opted out via `allow_unauthenticated_admin_endpoints`. The
957            // unauthenticated combination proxies arbitrary tokens to
958            // the upstream IdP and is only safe behind an authenticated
959            // reverse proxy / ingress.
960            if proxy.expose_admin_endpoints
961                && !proxy.require_auth_on_admin_endpoints
962                && !proxy.allow_unauthenticated_admin_endpoints
963            {
964                return Err(crate::error::McpxError::Config(
965                    "oauth.proxy: expose_admin_endpoints = true requires \
966                     require_auth_on_admin_endpoints = true (recommended) \
967                     or allow_unauthenticated_admin_endpoints = true \
968                     (explicit opt-out, only safe behind an authenticated \
969                     reverse proxy)"
970                        .into(),
971                ));
972            }
973        }
974        if let Some(tx) = &self.token_exchange {
975            let url = check_oauth_url("oauth.token_exchange.token_url", &tx.token_url, allow_http)?;
976            if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
977                return Err(crate::error::McpxError::Config(format!(
978                    "oauth.token_exchange.token_url forbidden ({reason})"
979                )));
980            }
981            // M-H4: enforce RFC 8705 ยง2 mutual exclusion + feature gate
982            // for token-exchange client authentication. See helper.
983            validate_token_exchange_client_auth(tx)?;
984        }
985        // Compile the operator allowlist (if any) at config-validate
986        // time so misconfiguration is rejected up-front, before any
987        // outbound HTTP client is ever built.
988        if let Some(raw) = &self.ssrf_allowlist {
989            let compiled = compile_oauth_ssrf_allowlist(raw).map_err(|e| {
990                crate::error::McpxError::Config(format!("oauth.ssrf_allowlist: {e}"))
991            })?;
992            if !compiled.is_empty() {
993                tracing::warn!(
994                    host_count = compiled.host_count(),
995                    cidr_count = compiled.cidr_count(),
996                    "oauth.ssrf_allowlist is configured: private/loopback OAuth/JWKS targets \
997                     are now reachable. Cloud-metadata addresses remain blocked. \
998                     See SECURITY.md \"Operator allowlist\"."
999                );
1000            }
1001        }
1002        // Validate jwks_cache_ttl parses as a humantime duration so the
1003        // limiter constructor can rely on a non-fallback value (M5).
1004        humantime::parse_duration(&self.jwks_cache_ttl).map_err(|e| {
1005            crate::error::McpxError::Config(format!(
1006                "oauth.jwks_cache_ttl {:?} is not a valid humantime duration (e.g. \"10m\", \"1h30m\"): {e}",
1007                self.jwks_cache_ttl
1008            ))
1009        })?;
1010        Ok(())
1011    }
1012}
1013
1014/// M-H4: enforce RFC 8705 ยง2 mutual exclusion (`client_secret` xor
1015/// `client_cert`) + cargo-feature gating for token-exchange client
1016/// authentication. Without this a `client_cert`-only config silently
1017/// disables client auth at the token endpoint (the runtime path
1018/// simply omits the Authorization header).
1019fn validate_token_exchange_client_auth(
1020    tx: &TokenExchangeConfig,
1021) -> Result<(), crate::error::McpxError> {
1022    match (&tx.client_cert, tx.client_secret.is_some()) {
1023        (Some(_), true) => Err(crate::error::McpxError::Config(
1024            "oauth.token_exchange: client_cert and client_secret are mutually \
1025             exclusive (RFC 8705 ยง2). Set exactly one."
1026                .into(),
1027        )),
1028        (None, false) => Err(crate::error::McpxError::Config(
1029            "oauth.token_exchange: token exchange requires client authentication. \
1030             Set either client_secret (RFC 6749 ยง2.3.1) or client_cert (RFC 8705 ยง2)."
1031                .into(),
1032        )),
1033        (Some(cc), false) => validate_client_cert_config(cc),
1034        (None, true) => Ok(()),
1035    }
1036}
1037
1038/// Validate a [`ClientCertConfig`] for RFC 8705 ยง2 mTLS client auth.
1039///
1040/// Without the `oauth-mtls-client` cargo feature this fails closed with
1041/// a [`crate::error::McpxError::Config`] (M-H4: a `client_cert`-only
1042/// config previously silently disabled client authentication). With the
1043/// feature on, this performs the same PEM read + parse the runtime path
1044/// would do, so missing files / malformed PEM / mismatched key&cert /
1045/// encrypted (passphrase-protected) keys all surface at validate time
1046/// rather than at first token-exchange request.
1047///
1048/// The returned error message includes the file path; the underlying
1049/// IO / parse error stays in a `tracing::warn!` log line.
1050fn validate_client_cert_config(cc: &ClientCertConfig) -> Result<(), crate::error::McpxError> {
1051    #[cfg(not(feature = "oauth-mtls-client"))]
1052    {
1053        let _ = cc;
1054        Err(crate::error::McpxError::Config(
1055            "oauth.token_exchange.client_cert requires the `oauth-mtls-client` cargo feature; \
1056             rebuild rmcp-server-kit with --features oauth-mtls-client (or have your \
1057             application crate enable it via `rmcp-server-kit/oauth-mtls-client`), or remove \
1058             the field"
1059                .into(),
1060        ))
1061    }
1062    #[cfg(feature = "oauth-mtls-client")]
1063    {
1064        let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1065            tracing::warn!(error = %e, path = %cc.cert_path.display(), "client cert read failed");
1066            crate::error::McpxError::Config(format!(
1067                "oauth.token_exchange.client_cert.cert_path unreadable: {}",
1068                cc.cert_path.display()
1069            ))
1070        })?;
1071        let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1072            tracing::warn!(error = %e, path = %cc.key_path.display(), "client cert key read failed");
1073            crate::error::McpxError::Config(format!(
1074                "oauth.token_exchange.client_cert.key_path unreadable: {}",
1075                cc.key_path.display()
1076            ))
1077        })?;
1078        let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1079        combined.extend_from_slice(&cert_bytes);
1080        if !cert_bytes.ends_with(b"\n") {
1081            combined.push(b'\n');
1082        }
1083        combined.extend_from_slice(&key_bytes);
1084        let _identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1085            tracing::warn!(
1086                error = %e,
1087                cert_path = %cc.cert_path.display(),
1088                key_path = %cc.key_path.display(),
1089                "client cert PEM parse failed"
1090            );
1091            crate::error::McpxError::Config(format!(
1092                "oauth.token_exchange.client_cert: PEM parse failed (cert={}, key={})",
1093                cc.cert_path.display(),
1094                cc.key_path.display()
1095            ))
1096        })?;
1097        Ok(())
1098    }
1099}
1100
1101/// M-H4: build the `(cert_path, key_path) -> reqwest::Client` cache
1102/// consulted by [`OauthHttpClient::client_for`]. Each cert-bearing
1103/// client uses `redirect::Policy::none()` (RFC 8705 ยง2: never present
1104/// the client cert to a redirect target the operator did not approve)
1105/// and inherits the same `ca_cert_path`, connect/total timeouts as
1106/// the shared `inner` client. Returns an empty map when no
1107/// `token_exchange.client_cert` is configured.
1108#[cfg(feature = "oauth-mtls-client")]
1109fn build_mtls_clients(
1110    config: Option<&OAuthConfig>,
1111    allowlist: &Arc<crate::ssrf::CompiledSsrfAllowlist>,
1112    test_bypass: &crate::ssrf_resolver::TestLoopbackBypass,
1113) -> Result<Arc<HashMap<MtlsClientKey, reqwest::Client>>, crate::error::McpxError> {
1114    let mut map: HashMap<MtlsClientKey, reqwest::Client> = HashMap::new();
1115    let Some(cfg) = config else {
1116        return Ok(Arc::new(map));
1117    };
1118    let Some(tx) = &cfg.token_exchange else {
1119        return Ok(Arc::new(map));
1120    };
1121    let Some(cc) = &tx.client_cert else {
1122        return Ok(Arc::new(map));
1123    };
1124
1125    let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1126        crate::error::McpxError::Startup(format!(
1127            "oauth http client mTLS: read cert_path {}: {e}",
1128            cc.cert_path.display()
1129        ))
1130    })?;
1131    let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1132        crate::error::McpxError::Startup(format!(
1133            "oauth http client mTLS: read key_path {}: {e}",
1134            cc.key_path.display()
1135        ))
1136    })?;
1137    let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1138    combined.extend_from_slice(&cert_bytes);
1139    if !cert_bytes.ends_with(b"\n") {
1140        combined.push(b'\n');
1141    }
1142    combined.extend_from_slice(&key_bytes);
1143    let identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1144        crate::error::McpxError::Startup(format!(
1145            "oauth http client mTLS: PEM parse (cert={}, key={}): {e}",
1146            cc.cert_path.display(),
1147            cc.key_path.display()
1148        ))
1149    })?;
1150
1151    let resolver: Arc<dyn reqwest::dns::Resolve> =
1152        Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1153            Arc::clone(allowlist),
1154            // M-H2/B1: TestLoopbackBypass aliases to Arc<AtomicBool> in test
1155            // builds and to `()` in production. We need a value clone here
1156            // (not Arc::clone) because the type vanishes outside test cfg;
1157            // the allow is justified by the feature-gated type alias.
1158            #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1159            test_bypass.clone(),
1160        ));
1161
1162    let mut builder = reqwest::Client::builder()
1163        // M-H2/N1: same proxy + DNS hardening as the shared client.
1164        .no_proxy()
1165        .dns_resolver(Arc::clone(&resolver))
1166        .connect_timeout(Duration::from_secs(10))
1167        .timeout(Duration::from_secs(30))
1168        .redirect(reqwest::redirect::Policy::none())
1169        .identity(identity);
1170
1171    if let Some(ref ca_path) = cfg.ca_cert_path {
1172        let pem = std::fs::read(ca_path).map_err(|e| {
1173            crate::error::McpxError::Startup(format!(
1174                "oauth http client mTLS: read ca_cert_path {}: {e}",
1175                ca_path.display()
1176            ))
1177        })?;
1178        let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
1179            crate::error::McpxError::Startup(format!(
1180                "oauth http client mTLS: parse ca_cert_path {}: {e}",
1181                ca_path.display()
1182            ))
1183        })?;
1184        builder = builder.add_root_certificate(cert);
1185    }
1186
1187    let client = builder.build().map_err(|e| {
1188        crate::error::McpxError::Startup(format!("oauth http client mTLS init: {e}"))
1189    })?;
1190    map.insert(
1191        MtlsClientKey {
1192            cert_path: cc.cert_path.clone(),
1193            key_path: cc.key_path.clone(),
1194        },
1195        client,
1196    );
1197    Ok(Arc::new(map))
1198}
1199
1200/// Parse `raw` as a URL and enforce the HTTPS-only policy.
1201///
1202/// Returns `Ok(())` for `https://...`, and also for `http://...` when
1203/// `allow_http` is `true`. All other schemes (and parse failures) are
1204/// rejected with a [`crate::error::McpxError::Config`] referencing the
1205/// caller-supplied `field` name for diagnostics.
1206fn check_oauth_url(
1207    field: &str,
1208    raw: &str,
1209    allow_http: bool,
1210) -> Result<url::Url, crate::error::McpxError> {
1211    let parsed = url::Url::parse(raw).map_err(|e| {
1212        crate::error::McpxError::Config(format!("{field}: invalid URL {raw:?}: {e}"))
1213    })?;
1214    if !parsed.username().is_empty() || parsed.password().is_some() {
1215        return Err(crate::error::McpxError::Config(format!(
1216            "{field} rejected: URL contains userinfo (credentials in URL are forbidden)"
1217        )));
1218    }
1219    match parsed.scheme() {
1220        "https" => Ok(parsed),
1221        "http" if allow_http => Ok(parsed),
1222        "http" => Err(crate::error::McpxError::Config(format!(
1223            "{field}: must use https scheme (got http; set allow_http_oauth_urls=true \
1224             to override - strongly discouraged in production)"
1225        ))),
1226        other => Err(crate::error::McpxError::Config(format!(
1227            "{field}: must use https scheme (got {other:?})"
1228        ))),
1229    }
1230}
1231
1232/// Builder for [`OAuthConfig`].
1233///
1234/// Obtain via [`OAuthConfig::builder`]. All setters consume `self` and
1235/// return a new builder, so they compose fluently. Call
1236/// [`OAuthConfigBuilder::build`] to produce the final [`OAuthConfig`].
1237#[derive(Debug, Clone)]
1238#[must_use = "builders do nothing until `.build()` is called"]
1239pub struct OAuthConfigBuilder {
1240    inner: OAuthConfig,
1241}
1242
1243impl OAuthConfigBuilder {
1244    /// Replace the scope-to-role mappings.
1245    pub fn scopes(mut self, scopes: Vec<ScopeMapping>) -> Self {
1246        self.inner.scopes = scopes;
1247        self
1248    }
1249
1250    /// Append a single scope-to-role mapping.
1251    pub fn scope(mut self, scope: impl Into<String>, role: impl Into<String>) -> Self {
1252        self.inner.scopes.push(ScopeMapping {
1253            scope: scope.into(),
1254            role: role.into(),
1255        });
1256        self
1257    }
1258
1259    /// Set the JWT claim path used to extract roles directly (without
1260    /// going through `scope` mappings).
1261    pub fn role_claim(mut self, claim: impl Into<String>) -> Self {
1262        self.inner.role_claim = Some(claim.into());
1263        self
1264    }
1265
1266    /// Replace the claim-value-to-role mappings.
1267    pub fn role_mappings(mut self, mappings: Vec<RoleMapping>) -> Self {
1268        self.inner.role_mappings = mappings;
1269        self
1270    }
1271
1272    /// Append a single claim-value-to-role mapping (used with
1273    /// [`Self::role_claim`]).
1274    pub fn role_mapping(mut self, claim_value: impl Into<String>, role: impl Into<String>) -> Self {
1275        self.inner.role_mappings.push(RoleMapping {
1276            claim_value: claim_value.into(),
1277            role: role.into(),
1278        });
1279        self
1280    }
1281
1282    /// Override the JWKS cache TTL (humantime string, e.g. `"5m"`).
1283    /// Defaults to `"10m"`.
1284    pub fn jwks_cache_ttl(mut self, ttl: impl Into<String>) -> Self {
1285        self.inner.jwks_cache_ttl = ttl.into();
1286        self
1287    }
1288
1289    /// Attach an OAuth proxy configuration. When set, the server
1290    /// exposes `/authorize`, `/token`, and `/register` endpoints.
1291    pub fn proxy(mut self, proxy: OAuthProxyConfig) -> Self {
1292        self.inner.proxy = Some(proxy);
1293        self
1294    }
1295
1296    /// Attach an RFC 8693 token exchange configuration.
1297    pub fn token_exchange(mut self, token_exchange: TokenExchangeConfig) -> Self {
1298        self.inner.token_exchange = Some(token_exchange);
1299        self
1300    }
1301
1302    /// Provide a PEM CA bundle path used for all OAuth-bound HTTPS traffic
1303    /// originated by this crate (JWKS fetches and the optional OAuth proxy
1304    /// `/authorize`, `/token`, `/register`, `/introspect`, `/revoke`,
1305    /// `/.well-known/oauth-authorization-server` upstream calls).
1306    pub fn ca_cert_path(mut self, path: impl Into<PathBuf>) -> Self {
1307        self.inner.ca_cert_path = Some(path.into());
1308        self
1309    }
1310
1311    /// Allow plain-HTTP (non-TLS) URLs for OAuth endpoints.
1312    ///
1313    /// **Default: `false`.** See the field-level documentation on
1314    /// [`OAuthConfig::allow_http_oauth_urls`] for the security caveats
1315    /// before enabling this.
1316    pub const fn allow_http_oauth_urls(mut self, allow: bool) -> Self {
1317        self.inner.allow_http_oauth_urls = allow;
1318        self
1319    }
1320
1321    /// Toggle strict audience validation so only the JWT `aud` claim is
1322    /// considered and the compatibility fallback to `azp` is disabled.
1323    ///
1324    /// **Deprecated since 1.7.0.** Prefer
1325    /// [`OAuthConfigBuilder::audience_validation_mode`] for explicit
1326    /// three-state policy. This method clears
1327    /// `audience_validation_mode` so the legacy bool resolution path
1328    /// applies.
1329    #[deprecated(since = "1.7.0", note = "use `audience_validation_mode` instead")]
1330    pub const fn strict_audience_validation(mut self, strict: bool) -> Self {
1331        #[allow(
1332            deprecated,
1333            reason = "intentional: deprecated builder forwards to deprecated field"
1334        )]
1335        {
1336            self.inner.strict_audience_validation = strict;
1337        }
1338        self.inner.audience_validation_mode = None;
1339        self
1340    }
1341
1342    /// Set the audience-validation policy explicitly.
1343    ///
1344    /// Takes precedence over the deprecated
1345    /// [`OAuthConfigBuilder::strict_audience_validation`] flag. See
1346    /// [`AudienceValidationMode`] for variant semantics. Defaults to
1347    /// [`AudienceValidationMode::Warn`] when neither this method nor the
1348    /// legacy flag is set.
1349    pub const fn audience_validation_mode(mut self, mode: AudienceValidationMode) -> Self {
1350        self.inner.audience_validation_mode = Some(mode);
1351        self
1352    }
1353
1354    /// Override the maximum JWKS response body size in bytes.
1355    pub const fn jwks_max_response_bytes(mut self, bytes: u64) -> Self {
1356        self.inner.jwks_max_response_bytes = bytes;
1357        self
1358    }
1359
1360    /// Set the operator SSRF allowlist for OAuth/JWKS targets.
1361    ///
1362    /// **Operator-only.** Use only when an in-cluster IdP (e.g. Keycloak)
1363    /// resolves to private/loopback address space and must be reached.
1364    /// Cloud-metadata addresses (AWS/GCP/Alibaba IPv4 + IPv6) remain
1365    /// blocked regardless of allowlist contents -- see
1366    /// [`OAuthSsrfAllowlist`] and `SECURITY.md`  "Operator allowlist".
1367    pub fn ssrf_allowlist(mut self, allowlist: OAuthSsrfAllowlist) -> Self {
1368        self.inner.ssrf_allowlist = Some(allowlist);
1369        self
1370    }
1371
1372    /// Finalise the builder and return the [`OAuthConfig`].
1373    #[must_use]
1374    pub fn build(self) -> OAuthConfig {
1375        self.inner
1376    }
1377}
1378
1379/// Maps an OAuth scope string to an RBAC role name.
1380#[derive(Debug, Clone, Deserialize)]
1381#[non_exhaustive]
1382pub struct ScopeMapping {
1383    /// OAuth scope string to match against the token's `scope` claim.
1384    pub scope: String,
1385    /// RBAC role granted when the scope is present.
1386    pub role: String,
1387}
1388
1389/// Maps a JWT claim value to an RBAC role name.
1390/// Used with `OAuthConfig::role_claim` for non-scope-based role extraction
1391/// (e.g. Keycloak `realm_access.roles`, Azure AD `roles`).
1392#[derive(Debug, Clone, Deserialize)]
1393#[non_exhaustive]
1394pub struct RoleMapping {
1395    /// Expected value of the configured role claim (e.g. `admin`).
1396    pub claim_value: String,
1397    /// RBAC role granted when `claim_value` is present in the claim.
1398    pub role: String,
1399}
1400
1401/// Configuration for RFC 8693 token exchange.
1402///
1403/// The MCP server uses this to exchange an inbound user access token
1404/// (audience = MCP server) for a downstream access token (audience =
1405/// the upstream API the application calls) via the authorization
1406/// server's token endpoint.
1407#[derive(Debug, Clone, Deserialize)]
1408#[non_exhaustive]
1409pub struct TokenExchangeConfig {
1410    /// Authorization server token endpoint used for the exchange
1411    /// (e.g. `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token`).
1412    pub token_url: String,
1413    /// OAuth `client_id` of the MCP server (the requester).
1414    pub client_id: String,
1415    /// OAuth `client_secret` for confidential-client authentication
1416    /// (RFC 6749 ยง2.3.1 HTTP Basic). Mutually exclusive with
1417    /// `client_cert` -- [`OAuthConfig::validate`] rejects configs
1418    /// that set both, or neither.
1419    pub client_secret: Option<secrecy::SecretString>,
1420    /// Client certificate for RFC 8705 ยง2 mTLS client authentication.
1421    /// When set, the exchange request authenticates by presenting the
1422    /// configured cert at TLS handshake (no Authorization header is
1423    /// sent). Requires the `oauth-mtls-client` cargo feature; without
1424    /// it, [`OAuthConfig::validate`] fails closed.
1425    ///
1426    /// **Scope**: implements RFC 8705 ยง2 only (PKI-bound client
1427    /// auth). RFC 8705 ยง3 self-signed client auth and the
1428    /// `cnf.x5t#S256` certificate-bound access-token confirmation
1429    /// claim are NOT enforced; the issued access token behaves like a
1430    /// bearer token once minted. In-place certificate rotation is
1431    /// not picked up without restart.
1432    pub client_cert: Option<ClientCertConfig>,
1433    /// Target audience - the `client_id` of the downstream API
1434    /// (e.g. `upstream-api`).  The exchanged token will have this
1435    /// value in its `aud` claim.
1436    pub audience: String,
1437}
1438
1439impl TokenExchangeConfig {
1440    /// Create a new token exchange configuration.
1441    #[must_use]
1442    pub fn new(
1443        token_url: String,
1444        client_id: String,
1445        client_secret: Option<secrecy::SecretString>,
1446        client_cert: Option<ClientCertConfig>,
1447        audience: String,
1448    ) -> Self {
1449        Self {
1450            token_url,
1451            client_id,
1452            client_secret,
1453            client_cert,
1454            audience,
1455        }
1456    }
1457}
1458
1459/// Client certificate paths for RFC 8705 ยง2 mTLS client
1460/// authentication at the token exchange endpoint. Requires the
1461/// `oauth-mtls-client` cargo feature.
1462#[derive(Debug, Clone, Deserialize)]
1463#[non_exhaustive]
1464pub struct ClientCertConfig {
1465    /// Path to the PEM-encoded client certificate (X.509, single
1466    /// leaf or full chain). Read once at server startup.
1467    pub cert_path: PathBuf,
1468    /// Path to the PEM-encoded private key (PKCS#8 or RSA / EC).
1469    /// Encrypted (passphrase-protected) keys are NOT supported and
1470    /// fail closed at config validation.
1471    pub key_path: PathBuf,
1472}
1473
1474impl ClientCertConfig {
1475    /// Construct a `ClientCertConfig`. Required because the struct is
1476    /// `#[non_exhaustive]` and so cannot be built with a struct literal
1477    /// from outside the crate.
1478    #[must_use]
1479    pub fn new(cert_path: PathBuf, key_path: PathBuf) -> Self {
1480        Self {
1481            cert_path,
1482            key_path,
1483        }
1484    }
1485}
1486
1487/// Successful response from an RFC 8693 token exchange.
1488#[derive(Debug, Deserialize)]
1489#[non_exhaustive]
1490pub struct ExchangedToken {
1491    /// The newly issued access token.
1492    pub access_token: String,
1493    /// Token lifetime in seconds (if provided by the authorization server).
1494    pub expires_in: Option<u64>,
1495    /// Token type identifier (e.g.
1496    /// `urn:ietf:params:oauth:token-type:access_token`).
1497    pub issued_token_type: Option<String>,
1498}
1499
1500/// Configuration for proxying OAuth 2.1 flows to an upstream identity provider.
1501///
1502/// When present, the MCP server exposes `/authorize`, `/token`, and
1503/// `/register` endpoints that proxy to the upstream identity provider
1504/// (e.g. Keycloak). MCP clients see this server as the authorization
1505/// server and perform a standard Authorization Code + PKCE flow.
1506#[derive(Debug, Clone, Deserialize, Default)]
1507#[non_exhaustive]
1508pub struct OAuthProxyConfig {
1509    /// Upstream authorization endpoint (e.g.
1510    /// `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth`).
1511    pub authorize_url: String,
1512    /// Upstream token endpoint (e.g.
1513    /// `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token`).
1514    pub token_url: String,
1515    /// OAuth `client_id` registered at the upstream identity provider.
1516    pub client_id: String,
1517    /// OAuth `client_secret` (for confidential clients). Omit for public clients.
1518    pub client_secret: Option<secrecy::SecretString>,
1519    /// Optional upstream RFC 7662 introspection endpoint. When set
1520    /// **and** [`Self::expose_admin_endpoints`] is `true`, the server
1521    /// exposes a local `/introspect` endpoint that proxies to it.
1522    #[serde(default)]
1523    pub introspection_url: Option<String>,
1524    /// Optional upstream RFC 7009 revocation endpoint. When set
1525    /// **and** [`Self::expose_admin_endpoints`] is `true`, the server
1526    /// exposes a local `/revoke` endpoint that proxies to it.
1527    #[serde(default)]
1528    pub revocation_url: Option<String>,
1529    /// Whether to expose the OAuth admin endpoints (`/introspect`,
1530    /// `/revoke`) and advertise them in the authorization-server
1531    /// metadata document.
1532    ///
1533    /// **Default: `false`.** These endpoints are unauthenticated at the
1534    /// transport layer (the OAuth proxy router is mounted outside the
1535    /// MCP auth middleware) and proxy directly to the upstream `IdP`. If
1536    /// enabled, you are responsible for restricting access at the
1537    /// network boundary (firewall, reverse proxy, mTLS) or by routing
1538    /// the entire rmcp-server-kit process behind an authenticated ingress. Leaving
1539    /// this `false` (the default) makes the endpoints return 404.
1540    #[serde(default)]
1541    pub expose_admin_endpoints: bool,
1542    /// Require the normal authentication middleware before the local
1543    /// `/introspect` and `/revoke` proxy endpoints are reached.
1544    ///
1545    /// **Default: `false` for backward compatibility.** New deployments
1546    /// should set this to `true` when exposing admin endpoints.
1547    #[serde(default)]
1548    pub require_auth_on_admin_endpoints: bool,
1549    /// Explicit operator opt-out for the M3 startup check that rejects
1550    /// `expose_admin_endpoints = true` combined with
1551    /// `require_auth_on_admin_endpoints = false`.
1552    ///
1553    /// **Default: `false`.** Setting this to `true` allows the unauth
1554    /// admin-endpoint combination to start, which is only safe when the
1555    /// rmcp-server-kit process sits behind an authenticated reverse
1556    /// proxy / ingress that screens `/introspect` and `/revoke` itself.
1557    /// Production deployments should leave this `false` and instead set
1558    /// `require_auth_on_admin_endpoints = true`.
1559    #[serde(default)]
1560    pub allow_unauthenticated_admin_endpoints: bool,
1561}
1562
1563impl OAuthProxyConfig {
1564    /// Start building an [`OAuthProxyConfig`] with the three required
1565    /// upstream fields.
1566    ///
1567    /// Optional settings (`client_secret`, `introspection_url`,
1568    /// `revocation_url`, `expose_admin_endpoints`) default to their
1569    /// [`Default`] values and can be set via the corresponding builder
1570    /// methods.
1571    pub fn builder(
1572        authorize_url: impl Into<String>,
1573        token_url: impl Into<String>,
1574        client_id: impl Into<String>,
1575    ) -> OAuthProxyConfigBuilder {
1576        OAuthProxyConfigBuilder {
1577            inner: Self {
1578                authorize_url: authorize_url.into(),
1579                token_url: token_url.into(),
1580                client_id: client_id.into(),
1581                ..Self::default()
1582            },
1583        }
1584    }
1585}
1586
1587/// Builder for [`OAuthProxyConfig`].
1588///
1589/// Obtain via [`OAuthProxyConfig::builder`]. See the type-level docs on
1590/// [`OAuthProxyConfig`] and in particular the security caveats on
1591/// [`OAuthProxyConfig::expose_admin_endpoints`].
1592#[derive(Debug, Clone)]
1593#[must_use = "builders do nothing until `.build()` is called"]
1594pub struct OAuthProxyConfigBuilder {
1595    inner: OAuthProxyConfig,
1596}
1597
1598impl OAuthProxyConfigBuilder {
1599    /// Set the upstream OAuth client secret. Omit for public clients.
1600    pub fn client_secret(mut self, secret: secrecy::SecretString) -> Self {
1601        self.inner.client_secret = Some(secret);
1602        self
1603    }
1604
1605    /// Configure the upstream RFC 7662 introspection endpoint. Only
1606    /// advertised and reachable when
1607    /// [`Self::expose_admin_endpoints`] is also set to `true`.
1608    pub fn introspection_url(mut self, url: impl Into<String>) -> Self {
1609        self.inner.introspection_url = Some(url.into());
1610        self
1611    }
1612
1613    /// Configure the upstream RFC 7009 revocation endpoint. Only
1614    /// advertised and reachable when
1615    /// [`Self::expose_admin_endpoints`] is also set to `true`.
1616    pub fn revocation_url(mut self, url: impl Into<String>) -> Self {
1617        self.inner.revocation_url = Some(url.into());
1618        self
1619    }
1620
1621    /// Opt in to exposing the `/introspect` and `/revoke` admin
1622    /// endpoints and advertising them in the authorization-server
1623    /// metadata document.
1624    ///
1625    /// **Security:** see the field-level documentation on
1626    /// [`OAuthProxyConfig::expose_admin_endpoints`] for the caveats
1627    /// before enabling this.
1628    pub const fn expose_admin_endpoints(mut self, expose: bool) -> Self {
1629        self.inner.expose_admin_endpoints = expose;
1630        self
1631    }
1632
1633    /// Require the normal authentication middleware on `/introspect` and
1634    /// `/revoke`.
1635    pub const fn require_auth_on_admin_endpoints(mut self, require: bool) -> Self {
1636        self.inner.require_auth_on_admin_endpoints = require;
1637        self
1638    }
1639
1640    /// Explicit opt-out for the M3 startup check that rejects exposing
1641    /// `/introspect`/`/revoke` without authentication. See
1642    /// [`OAuthProxyConfig::allow_unauthenticated_admin_endpoints`].
1643    pub const fn allow_unauthenticated_admin_endpoints(mut self, allow: bool) -> Self {
1644        self.inner.allow_unauthenticated_admin_endpoints = allow;
1645        self
1646    }
1647
1648    /// Finalise the builder and return the [`OAuthProxyConfig`].
1649    #[must_use]
1650    pub fn build(self) -> OAuthProxyConfig {
1651        self.inner
1652    }
1653}
1654
1655// ---------------------------------------------------------------------------
1656// JWKS cache
1657// ---------------------------------------------------------------------------
1658
1659/// `kid`-indexed map of (algorithm, decoding key) pairs plus a list of
1660/// unnamed keys. Produced by [`build_key_cache`] and consumed by
1661/// [`JwksCache::refresh_inner`].
1662type JwksKeyCache = (
1663    HashMap<String, (Algorithm, DecodingKey)>,
1664    Vec<(Algorithm, DecodingKey)>,
1665);
1666
1667struct CachedKeys {
1668    /// `kid` -> (Algorithm, `DecodingKey`)
1669    keys: HashMap<String, (Algorithm, DecodingKey)>,
1670    /// Keys without a kid, indexed by algorithm family.
1671    unnamed_keys: Vec<(Algorithm, DecodingKey)>,
1672    fetched_at: Instant,
1673    ttl: Duration,
1674}
1675
1676impl CachedKeys {
1677    fn is_expired(&self) -> bool {
1678        self.fetched_at.elapsed() >= self.ttl
1679    }
1680}
1681
1682/// Thread-safe JWKS key cache with automatic refresh.
1683///
1684/// Includes protections against denial-of-service via invalid JWTs:
1685/// - **Refresh cooldown**: At most one refresh per 10 seconds, regardless of
1686///   cache misses. This prevents attackers from flooding the upstream JWKS
1687///   endpoint by sending JWTs with fabricated `kid` values.
1688/// - **Concurrent deduplication**: Only one refresh in flight at a time;
1689///   concurrent waiters share the same fetch result.
1690#[allow(
1691    missing_debug_implementations,
1692    reason = "contains reqwest::Client and DecodingKey cache with no Debug impl"
1693)]
1694#[non_exhaustive]
1695pub struct JwksCache {
1696    jwks_uri: String,
1697    ttl: Duration,
1698    max_jwks_keys: usize,
1699    max_response_bytes: u64,
1700    allow_http: bool,
1701    inner: RwLock<Option<CachedKeys>>,
1702    http: reqwest::Client,
1703    validation_template: Validation,
1704    /// Expected audience value from config; checked against `aud` and,
1705    /// per `audience_mode`, optionally `azp`.
1706    expected_audience: String,
1707    audience_mode: AudienceValidationMode,
1708    /// Set to `true` after the first `azp`-only audience match while in
1709    /// [`AudienceValidationMode::Warn`], so the deprecation warning logs
1710    /// at most once per process lifetime.
1711    azp_fallback_warned: AtomicBool,
1712    scopes: Vec<ScopeMapping>,
1713    role_claim: Option<String>,
1714    role_mappings: Vec<RoleMapping>,
1715    /// Tracks the last refresh attempt timestamp. Enforces a 10-second cooldown
1716    /// between refresh attempts to prevent abuse via fabricated JWTs with invalid kids.
1717    last_refresh_attempt: RwLock<Option<Instant>>,
1718    /// Serializes concurrent refresh attempts so only one fetch is in flight.
1719    refresh_lock: tokio::sync::Mutex<()>,
1720    /// Compiled operator SSRF allowlist (empty by default = original
1721    /// fail-closed behaviour). Wrapped in `Arc` so the redirect-policy
1722    /// closure can capture a cheap clone without inflating the cache size.
1723    allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
1724    /// M-H2/B1: shared loopback bypass; same Arc is captured by the
1725    /// SSRF resolver inside the cached `reqwest::Client`. See the
1726    /// matching field on `OauthHttpClient`.
1727    #[cfg(any(test, feature = "test-helpers"))]
1728    test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
1729}
1730
1731/// Minimum cooldown between JWKS refresh attempts (prevents abuse).
1732const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(10);
1733
1734/// Upper bound on an upstream OAuth proxy response body (`/token`,
1735/// `/introspect`, `/revoke`, and RFC 8693 token exchange).
1736///
1737/// The upstream is the operator-configured, SSRF-screened authorization
1738/// server, so this is defense-in-depth rather than an attacker-facing
1739/// control โ€” but it keeps the proxy paths symmetric with the bounded JWKS
1740/// fetch (`jwks_max_response_bytes`) so a misbehaving or compromised IdP
1741/// cannot make the server buffer an unbounded response. 1 MiB comfortably
1742/// covers token, introspection, and revocation JSON payloads.
1743const OAUTH_PROXY_MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
1744
1745/// Algorithms we accept from JWKS-served keys.
1746const ACCEPTED_ALGS: &[Algorithm] = &[
1747    Algorithm::RS256,
1748    Algorithm::RS384,
1749    Algorithm::RS512,
1750    Algorithm::ES256,
1751    Algorithm::ES384,
1752    Algorithm::PS256,
1753    Algorithm::PS384,
1754    Algorithm::PS512,
1755    Algorithm::EdDSA,
1756];
1757
1758/// Coarse JWT validation failure classification for auth diagnostics.
1759#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1760#[non_exhaustive]
1761pub enum JwtValidationFailure {
1762    /// JWT was well-formed but expired per `exp` validation.
1763    Expired,
1764    /// JWT failed validation for all other reasons.
1765    Invalid,
1766}
1767
1768impl JwksCache {
1769    /// Build a new cache from OAuth configuration.
1770    ///
1771    /// # Errors
1772    ///
1773    /// Returns an error if the CA bundle cannot be read, the HTTP client
1774    /// cannot be built, or `config.jwks_cache_ttl` is not a valid
1775    /// humantime duration. [`OAuthConfig::validate`] (run automatically by
1776    /// the typed
1777    /// [`McpServerConfig::validate`](crate::transport::McpServerConfig::validate)
1778    /// pipeline) rejects invalid TTLs up front, so the TTL branch is
1779    /// unreachable for validated configs.
1780    pub fn new(config: &OAuthConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
1781        // Ensure crypto providers are installed (idempotent -- ok() ignores
1782        // the error if already installed by another call in the same process).
1783        rustls::crypto::ring::default_provider()
1784            .install_default()
1785            .ok();
1786        jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
1787            .install_default()
1788            .ok();
1789
1790        let ttl = humantime::parse_duration(&config.jwks_cache_ttl).map_err(|error| {
1791            format!(
1792                "invalid jwks_cache_ttl {:?}: {error}",
1793                config.jwks_cache_ttl
1794            )
1795        })?;
1796
1797        let mut validation = Validation::new(Algorithm::RS256);
1798        // Note: validation.algorithms is overridden per-decode to [header.alg]
1799        // because jsonwebtoken 10.x requires all listed algorithms to share
1800        // the same key family. The ACCEPTED_ALGS whitelist is checked
1801        // separately before looking up the key.
1802        //
1803        // Audience validation is done manually after decode: we accept the
1804        // token if `aud` contains `config.audience` OR `azp == config.audience`.
1805        // This is correct per RFC 9068 Sec.4 + OIDC Core Sec.2: `aud` lists
1806        // resource servers, `azp` identifies the authorized client. When the
1807        // MCP server is both the OAuth client and the resource server (as in
1808        // our proxy setup), the configured audience may appear in either claim.
1809        validation.validate_aud = false;
1810        validation.set_issuer(&[&config.issuer]);
1811        validation.set_required_spec_claims(&["exp", "iss"]);
1812        validation.validate_exp = true;
1813        validation.validate_nbf = true;
1814
1815        let allow_http = config.allow_http_oauth_urls;
1816
1817        // Compile operator allowlist up-front so misconfiguration is
1818        // surfaced at startup rather than on first JWKS fetch.
1819        let allowlist = match config.ssrf_allowlist.as_ref() {
1820            Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1821                Box::<dyn std::error::Error + Send + Sync>::from(format!(
1822                    "oauth.ssrf_allowlist: {e}"
1823                ))
1824            })?),
1825            None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
1826        };
1827        let redirect_allowlist = Arc::clone(&allowlist);
1828
1829        // M-H2: see OauthHttpClient::build for rationale; same pattern.
1830        #[cfg(any(test, feature = "test-helpers"))]
1831        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
1832            Arc::new(AtomicBool::new(false));
1833        #[cfg(not(any(test, feature = "test-helpers")))]
1834        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
1835
1836        let resolver: Arc<dyn reqwest::dns::Resolve> =
1837            Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1838                Arc::clone(&allowlist),
1839                #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1840                test_bypass.clone(),
1841            ));
1842
1843        let mut http_builder = reqwest::Client::builder()
1844            // M-H2/N1: see OauthHttpClient::build.
1845            .no_proxy()
1846            .dns_resolver(Arc::clone(&resolver))
1847            .timeout(Duration::from_secs(10))
1848            .connect_timeout(Duration::from_secs(3))
1849            .redirect(reqwest::redirect::Policy::custom(move |attempt| {
1850                // SECURITY: a redirect from `https` to `http` is *always*
1851                // rejected, even when `allow_http_oauth_urls` is true.
1852                // The flag controls whether the *original* request URL
1853                // may be plain HTTP; it never authorises a downgrade
1854                // mid-flight. An `http -> http` redirect is permitted
1855                // only when the flag is true (dev-only). The full
1856                // policy lives in `evaluate_oauth_redirect` so the
1857                // OauthHttpClient and JwksCache closures stay
1858                // byte-for-byte identical.
1859                match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
1860                    Ok(()) => attempt.follow(),
1861                    Err(reason) => {
1862                        // Sanitized target: the rejected URL may carry
1863                        // userinfo credentials (the rejection reason
1864                        // itself is URL-free).
1865                        tracing::warn!(
1866                            reason = %reason,
1867                            target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
1868                            "oauth redirect rejected"
1869                        );
1870                        attempt.error(reason)
1871                    }
1872                }
1873            }));
1874
1875        if let Some(ref ca_path) = config.ca_cert_path {
1876            // Pre-startup blocking I/O โ€” runs before the runtime begins
1877            // serving requests, so blocking the current thread here is
1878            // intentional. Do not wrap in `spawn_blocking`: the constructor
1879            // is synchronous by contract and is called from `serve()`'s
1880            // pre-startup phase.
1881            let pem = std::fs::read(ca_path)?;
1882            let cert = reqwest::tls::Certificate::from_pem(&pem)?;
1883            http_builder = http_builder.add_root_certificate(cert);
1884        }
1885
1886        let http = http_builder.build()?;
1887
1888        Ok(Self {
1889            jwks_uri: config.jwks_uri.clone(),
1890            ttl,
1891            max_jwks_keys: config.max_jwks_keys,
1892            max_response_bytes: config.jwks_max_response_bytes,
1893            allow_http,
1894            inner: RwLock::new(None),
1895            http,
1896            validation_template: validation,
1897            expected_audience: config.audience.clone(),
1898            audience_mode: config.effective_audience_validation_mode(),
1899            azp_fallback_warned: AtomicBool::new(false),
1900            scopes: config.scopes.clone(),
1901            role_claim: config.role_claim.clone(),
1902            role_mappings: config.role_mappings.clone(),
1903            last_refresh_attempt: RwLock::new(None),
1904            refresh_lock: tokio::sync::Mutex::new(()),
1905            allowlist,
1906            #[cfg(any(test, feature = "test-helpers"))]
1907            test_allow_loopback_ssrf: test_bypass,
1908        })
1909    }
1910
1911    /// Test-only: disable initial-target SSRF screening for loopback-backed
1912    /// fixtures. This is unreachable from normal production builds and exists
1913    /// only so tests can fetch JWKS from local mock servers.
1914    #[cfg(any(test, feature = "test-helpers"))]
1915    #[doc(hidden)]
1916    #[must_use]
1917    pub fn __test_allow_loopback_ssrf(self) -> Self {
1918        // M-H2/B1: flip the SHARED atomic so the resolver inside the
1919        // cached client and the pre-flight check both observe the bypass.
1920        self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
1921        self
1922    }
1923
1924    /// Validate a JWT Bearer token. Returns `Some(AuthIdentity)` on success.
1925    pub async fn validate_token(&self, token: &str) -> Option<AuthIdentity> {
1926        self.validate_token_with_reason(token).await.ok()
1927    }
1928
1929    /// Validate a JWT Bearer token with failure classification.
1930    ///
1931    /// # Errors
1932    ///
1933    /// Returns [`JwtValidationFailure::Expired`] when the JWT is expired,
1934    /// or [`JwtValidationFailure::Invalid`] for all other validation failures.
1935    // cancel-safe: composed of cancel-safe `decode_claims` (spawn_blocking
1936    // decode, no shared state) plus pure, side-effect-free claim checks
1937    // (`check_audience`, `resolve_role`). No partial state on cancellation.
1938    pub async fn validate_token_with_reason(
1939        &self,
1940        token: &str,
1941    ) -> Result<AuthIdentity, JwtValidationFailure> {
1942        let claims = self.decode_claims(token).await?;
1943
1944        self.check_audience(&claims)?;
1945        let role = self.resolve_role(&claims)?;
1946
1947        // Identity: prefer human-readable `preferred_username` (Keycloak/OIDC),
1948        // then `sub`, then `azp` (authorized party), then `client_id`.
1949        let sub = claims.sub;
1950        let name = claims
1951            .extra
1952            .get("preferred_username")
1953            .and_then(|v| v.as_str())
1954            .map(String::from)
1955            .or_else(|| sub.clone())
1956            .or(claims.azp)
1957            .or(claims.client_id)
1958            .unwrap_or_else(|| "oauth-client".into());
1959
1960        Ok(AuthIdentity {
1961            name,
1962            role,
1963            method: AuthMethod::OAuthJwt,
1964            raw_token: None,
1965            sub,
1966        })
1967    }
1968
1969    /// Decode and fully verify a JWT, returning its claims.
1970    ///
1971    /// Performs header decode, algorithm allow-list check, JWKS key lookup
1972    /// (with on-demand refresh), signature verification, and standard
1973    /// claim validation (exp/nbf/iss) against the template.
1974    ///
1975    /// The CPU-bound `jsonwebtoken::decode` call (RSA / ECDSA signature
1976    /// verification) is offloaded to [`tokio::task::spawn_blocking`] so a
1977    /// burst of concurrent JWT validations never starves other tasks on
1978    /// the multi-threaded runtime's worker pool. The blocking pool absorbs
1979    /// the verification cost; the async path stays responsive.
1980    // cancel-safe: `select_jwks_key` (cancel-safe: read-only lookup + idempotent
1981    // refresh) then a `spawn_blocking` decode whose `JoinHandle`, if dropped on
1982    // cancellation, detaches the verification (it completes off-task). No shared
1983    // state is mutated on this path.
1984    async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
1985        let (key, alg) = self.select_jwks_key(token).await?;
1986
1987        // Build a per-decode validation scoped to the header's algorithm.
1988        // jsonwebtoken requires ALL algorithms in the list to share the
1989        // same family as the key, so we restrict to [alg] only.
1990        let mut validation = self.validation_template.clone();
1991        validation.algorithms = vec![alg];
1992
1993        // Move the (cheap) clones into the blocking task so the verifier
1994        // does not hold a reference into the request's async scope.
1995        let token_owned = token.to_owned();
1996        let join =
1997            tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
1998                .await;
1999
2000        let decode_result = match join {
2001            Ok(r) => r,
2002            Err(join_err) => {
2003                core::hint::cold_path();
2004                tracing::error!(
2005                    error = %join_err,
2006                    "JWT decode task panicked or was cancelled"
2007                );
2008                return Err(JwtValidationFailure::Invalid);
2009            }
2010        };
2011
2012        decode_result.map(|td| td.claims).map_err(|e| {
2013            core::hint::cold_path();
2014            let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2015                JwtValidationFailure::Expired
2016            } else {
2017                JwtValidationFailure::Invalid
2018            };
2019            tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2020            failure
2021        })
2022    }
2023
2024    /// Decode the JWT header, check the algorithm against the allow-list,
2025    /// and look up the matching JWKS key (refreshing on miss).
2026    //
2027    // Complexity: 28/25. Three structured early-returns each pair a
2028    // `cold_path()` hint with a distinct `tracing::debug!` site so the
2029    // failure is observable. Collapsing them into a combinator chain
2030    // would lose those structured-field log sites without reducing
2031    // real cognitive load.
2032    #[allow(
2033        clippy::cognitive_complexity,
2034        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"
2035    )]
2036    async fn select_jwks_key(
2037        &self,
2038        token: &str,
2039    ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2040        let Ok(header) = decode_header(token) else {
2041            core::hint::cold_path();
2042            tracing::debug!("JWT header decode failed");
2043            return Err(JwtValidationFailure::Invalid);
2044        };
2045        let kid = header.kid.as_deref();
2046        tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2047
2048        if !ACCEPTED_ALGS.contains(&header.alg) {
2049            core::hint::cold_path();
2050            tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2051            return Err(JwtValidationFailure::Invalid);
2052        }
2053
2054        let Some(key) = self.find_key(kid, header.alg).await else {
2055            core::hint::cold_path();
2056            tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2057            return Err(JwtValidationFailure::Invalid);
2058        };
2059
2060        Ok((key, header.alg))
2061    }
2062
2063    /// Manual audience check.
2064    ///
2065    /// Resolves per [`AudienceValidationMode`]: `aud` matches always
2066    /// accept silently. `azp`-only matches accept silently in
2067    /// [`AudienceValidationMode::Permissive`], accept with a one-shot
2068    /// `tracing::warn!` per process in [`AudienceValidationMode::Warn`],
2069    /// and reject in [`AudienceValidationMode::Strict`]. No-claim-match
2070    /// always rejects.
2071    fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2072        if claims.aud.contains(&self.expected_audience) {
2073            return Ok(());
2074        }
2075        let azp_match = claims
2076            .azp
2077            .as_deref()
2078            .is_some_and(|azp| azp == self.expected_audience);
2079        if azp_match {
2080            match self.audience_mode {
2081                AudienceValidationMode::Permissive => return Ok(()),
2082                AudienceValidationMode::Warn => {
2083                    if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2084                        tracing::warn!(
2085                            expected = %self.expected_audience,
2086                            azp = claims.azp.as_deref().unwrap_or("-"),
2087                            "JWT accepted via deprecated azp-only audience fallback. \
2088                             Configure your IdP to populate aud, or set \
2089                             audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2090                             To silence this warning without changing acceptance, \
2091                             set audience_validation_mode = \"permissive\". \
2092                             This warning logs once per process."
2093                        );
2094                    }
2095                    return Ok(());
2096                }
2097                AudienceValidationMode::Strict => {}
2098            }
2099        }
2100        core::hint::cold_path();
2101        tracing::debug!(
2102            aud = %claims.aud.log_display(),
2103            azp = claims.azp.as_deref().unwrap_or("-"),
2104            expected = %self.expected_audience,
2105            mode = self.audience_mode.as_str(),
2106            "JWT rejected: audience mismatch"
2107        );
2108        Err(JwtValidationFailure::Invalid)
2109    }
2110
2111    /// Resolve the role for this token.
2112    ///
2113    /// When `role_claim` is set, extract values from the given claim path
2114    /// and match against `role_mappings`. Otherwise, match space-separated
2115    /// tokens in the `scope` claim against configured scope mappings.
2116    fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2117        if let Some(ref claim_path) = self.role_claim {
2118            let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2119            let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2120            values.extend(resolve_claim_path(&claims.extra, claim_path));
2121            return self
2122                .role_mappings
2123                .iter()
2124                .find(|m| values.contains(&m.claim_value.as_str()))
2125                .map(|m| m.role.clone())
2126                .ok_or(JwtValidationFailure::Invalid);
2127        }
2128
2129        let token_scopes: Vec<&str> = claims
2130            .scope
2131            .as_deref()
2132            .unwrap_or("")
2133            .split_whitespace()
2134            .collect();
2135
2136        self.scopes
2137            .iter()
2138            .find(|m| token_scopes.contains(&m.scope.as_str()))
2139            .map(|m| m.role.clone())
2140            .ok_or(JwtValidationFailure::Invalid)
2141    }
2142
2143    /// Look up a decoding key by kid + algorithm. Refreshes JWKS on miss,
2144    /// subject to cooldown and deduplication constraints.
2145    // cancel-safe: reads the key cache under a `tokio::sync::RwLock` and, on a
2146    // miss, delegates to the idempotent `refresh_with_cooldown`. Cancellation at
2147    // any await leaves the cache in its prior consistent state.
2148    async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2149        // Try cached keys first.
2150        {
2151            let guard = self.inner.read().await;
2152            if let Some(cached) = guard.as_ref()
2153                && !cached.is_expired()
2154                && let Some(key) = lookup_key(cached, kid, alg)
2155            {
2156                return Some(key);
2157            }
2158        }
2159
2160        // Cache miss or expired -- refresh (with cooldown/deduplication).
2161        self.refresh_with_cooldown().await;
2162
2163        let guard = self.inner.read().await;
2164        guard
2165            .as_ref()
2166            .and_then(|cached| lookup_key(cached, kid, alg))
2167    }
2168
2169    /// Refresh JWKS with cooldown and concurrent deduplication.
2170    ///
2171    /// - Only one refresh in flight at a time (concurrent waiters share result).
2172    /// - At most one refresh per [`JWKS_REFRESH_COOLDOWN`] (10 seconds).
2173    ///
2174    /// # Cancellation
2175    ///
2176    /// **NOT cancel-safe by design.** `last_refresh_attempt` is committed
2177    /// *before* the fetch so that a burst of failing or cancelled refreshes
2178    /// cannot hammer the JWKS endpoint (the invalid-JWT โ†’ JWKS-refresh DoS
2179    /// class; see `AGENTS.md` pitfall #2). The consequence is a deliberate
2180    /// trade-off: if this future is cancelled between the timestamp write and
2181    /// cache publication, a genuinely-new `kid` may be rejected for up to
2182    /// [`JWKS_REFRESH_COOLDOWN`] (10s). Endpoint DoS protection is preferred
2183    /// over immediate post-cancellation retriability. Do **not** "fix" this by
2184    /// bypassing the cooldown on unknown-`kid` requests โ€” that reopens the
2185    /// DoS-amplification vector the cooldown exists to close.
2186    // NOT cancel-safe: see the `# Cancellation` section above โ€” cooldown is
2187    // committed before the fetch to throttle JWKS-endpoint abuse.
2188    async fn refresh_with_cooldown(&self) {
2189        // Acquire the mutex to serialize refresh attempts.
2190        let _guard = self.refresh_lock.lock().await;
2191
2192        // Check cooldown: skip if we refreshed recently.
2193        {
2194            let last = self.last_refresh_attempt.read().await;
2195            if let Some(ts) = *last
2196                && ts.elapsed() < JWKS_REFRESH_COOLDOWN
2197            {
2198                tracing::debug!(
2199                    elapsed_ms = ts.elapsed().as_millis(),
2200                    cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
2201                    "JWKS refresh skipped (cooldown active)"
2202                );
2203                return;
2204            }
2205        }
2206
2207        // Update last refresh timestamp BEFORE the fetch attempt.
2208        // This ensures the cooldown applies even if the fetch fails.
2209        {
2210            let mut last = self.last_refresh_attempt.write().await;
2211            *last = Some(Instant::now());
2212        }
2213
2214        // Perform the actual fetch.
2215        let _ = self.refresh_inner().await;
2216    }
2217
2218    /// Fetch JWKS from the configured URI and update the cache.
2219    ///
2220    /// Internal implementation - callers should use [`Self::refresh_with_cooldown`]
2221    /// to respect rate limiting.
2222    // cancel-safe (cache integrity): the cache is published via a single
2223    // `*guard = Some(..)` assignment under the `tokio::sync::RwLock` write lock
2224    // at the end. Cancellation before that point leaves the prior cache intact;
2225    // it never observes a half-built cache.
2226    async fn refresh_inner(&self) -> Result<(), String> {
2227        let Some(jwks) = self.fetch_jwks().await else {
2228            return Ok(());
2229        };
2230        let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
2231            Ok(cache) => cache,
2232            Err(msg) => {
2233                tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
2234                return Err(msg);
2235            }
2236        };
2237
2238        tracing::debug!(
2239            named = keys.len(),
2240            unnamed = unnamed_keys.len(),
2241            "JWKS refreshed"
2242        );
2243
2244        let mut guard = self.inner.write().await;
2245        *guard = Some(CachedKeys {
2246            keys,
2247            unnamed_keys,
2248            fetched_at: Instant::now(),
2249            ttl: self.ttl,
2250        });
2251        drop(guard);
2252        Ok(())
2253    }
2254
2255    /// Fetch and parse the JWKS document. Returns `None` and logs on failure.
2256    #[allow(
2257        clippy::cognitive_complexity,
2258        reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
2259    )]
2260    async fn fetch_jwks(&self) -> Option<JwkSet> {
2261        #[cfg(any(test, feature = "test-helpers"))]
2262        let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
2263            screen_oauth_target_with_test_override(
2264                &self.jwks_uri,
2265                self.allow_http,
2266                &self.allowlist,
2267                true,
2268            )
2269            .await
2270        } else {
2271            screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
2272        };
2273        #[cfg(not(any(test, feature = "test-helpers")))]
2274        let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
2275
2276        if let Err(error) = screening {
2277            tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to screen JWKS target");
2278            return None;
2279        }
2280
2281        let mut resp = match self.http.get(&self.jwks_uri).send().await {
2282            Ok(resp) => resp,
2283            Err(e) => {
2284                tracing::warn!(error = %e, uri = %self.jwks_uri, "failed to fetch JWKS");
2285                return None;
2286            }
2287        };
2288
2289        let initial_capacity =
2290            usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
2291        let mut body = Vec::with_capacity(initial_capacity);
2292        while let Some(chunk) = match resp.chunk().await {
2293            Ok(chunk) => chunk,
2294            Err(error) => {
2295                tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to read JWKS response");
2296                return None;
2297            }
2298        } {
2299            let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
2300            let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
2301            if body_len.saturating_add(chunk_len) > self.max_response_bytes {
2302                tracing::warn!(
2303                    uri = %self.jwks_uri,
2304                    max_bytes = self.max_response_bytes,
2305                    "JWKS response exceeded configured size cap"
2306                );
2307                return None;
2308            }
2309            body.extend_from_slice(&chunk);
2310        }
2311
2312        match serde_json::from_slice::<JwkSet>(&body) {
2313            Ok(jwks) => Some(jwks),
2314            Err(error) => {
2315                tracing::warn!(error = %error, uri = %self.jwks_uri, "failed to parse JWKS");
2316                None
2317            }
2318        }
2319    }
2320
2321    /// Test-only: drive `refresh_inner` now, surfacing the
2322    /// `build_key_cache` error string. Used by `tests/jwks_key_cap.rs`.
2323    #[cfg(any(test, feature = "test-helpers"))]
2324    #[doc(hidden)]
2325    pub async fn __test_refresh_now(&self) -> Result<(), String> {
2326        let jwks = self
2327            .fetch_jwks()
2328            .await
2329            .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
2330        let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
2331        let mut guard = self.inner.write().await;
2332        *guard = Some(CachedKeys {
2333            keys,
2334            unnamed_keys,
2335            fetched_at: Instant::now(),
2336            ttl: self.ttl,
2337        });
2338        drop(guard);
2339        Ok(())
2340    }
2341
2342    /// Test-only: returns whether the cache currently contains the
2343    /// supplied kid. Read-only; takes the cache lock briefly.
2344    #[cfg(any(test, feature = "test-helpers"))]
2345    #[doc(hidden)]
2346    pub async fn __test_has_kid(&self, kid: &str) -> bool {
2347        let guard = self.inner.read().await;
2348        guard
2349            .as_ref()
2350            .is_some_and(|cache| cache.keys.contains_key(kid))
2351    }
2352}
2353
2354/// Partition a JWKS into a kid-indexed map plus a list of unnamed keys.
2355fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
2356    if jwks.keys.len() > max_keys {
2357        return Err(format!(
2358            "jwks_key_count_exceeds_cap: got {} keys, max is {}",
2359            jwks.keys.len(),
2360            max_keys
2361        ));
2362    }
2363    let mut keys = HashMap::new();
2364    let mut unnamed_keys = Vec::new();
2365    for jwk in &jwks.keys {
2366        let Ok(decoding_key) = DecodingKey::from_jwk(jwk) else {
2367            continue;
2368        };
2369        let Some(alg) = jwk_algorithm(jwk) else {
2370            continue;
2371        };
2372        if let Some(ref kid) = jwk.common.key_id {
2373            keys.insert(kid.clone(), (alg, decoding_key));
2374        } else {
2375            unnamed_keys.push((alg, decoding_key));
2376        }
2377    }
2378    Ok((keys, unnamed_keys))
2379}
2380
2381/// Look up a key from the cache by kid (if present) or by algorithm.
2382fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
2383    if let Some(kid) = kid
2384        && let Some((cached_alg, key)) = cached.keys.get(kid)
2385        && *cached_alg == alg
2386    {
2387        return Some(key.clone());
2388    }
2389    // Fall back to unnamed keys matching algorithm.
2390    cached
2391        .unnamed_keys
2392        .iter()
2393        .find(|(a, _)| *a == alg)
2394        .map(|(_, k)| k.clone())
2395}
2396
2397/// Extract the algorithm from a JWK's common parameters.
2398#[allow(
2399    clippy::wildcard_enum_match_arm,
2400    reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
2401)]
2402fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<Algorithm> {
2403    jwk.common.key_algorithm.and_then(|ka| match ka {
2404        jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
2405        jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
2406        jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
2407        jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
2408        jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
2409        jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
2410        jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
2411        jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
2412        jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
2413        _ => None,
2414    })
2415}
2416
2417// ---------------------------------------------------------------------------
2418// Claim path resolution
2419// ---------------------------------------------------------------------------
2420
2421/// Resolve a `role_claim` path against the explicit [`Claims`] fields
2422/// (`sub`, `aud`, `azp`, `client_id`, `scope`).
2423///
2424/// Operators commonly configure `role_claim = "scope"` or `"sub"` /
2425/// `"client_id"` to map first-class JWT claims to roles. These claims are
2426/// captured by [`Claims`] as named fields, so they never appear in the
2427/// `extra` map that [`resolve_claim_path`] inspects. This helper bridges
2428/// that gap by returning owned `String`s for those first-class fields
2429/// when the claim path matches one of them; the caller layers the result
2430/// over [`resolve_claim_path`] so dot-paths into custom claims continue
2431/// to work.
2432///
2433/// `scope` is split on whitespace per the OAuth 2.0 convention so a token
2434/// like `scope = "read write"` matches `claim_value = "read"` or
2435/// `"write"`. `aud` returns every audience entry. Other fields return
2436/// their value as a single element when present.
2437fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
2438    match path {
2439        "sub" => claims.sub.iter().cloned().collect(),
2440        "azp" => claims.azp.iter().cloned().collect(),
2441        "client_id" => claims.client_id.iter().cloned().collect(),
2442        "aud" => claims.aud.0.clone(),
2443        "scope" => claims
2444            .scope
2445            .as_deref()
2446            .unwrap_or("")
2447            .split_whitespace()
2448            .map(str::to_owned)
2449            .collect(),
2450        _ => Vec::new(),
2451    }
2452}
2453
2454/// Resolve a dot-separated claim path to a list of string values.
2455///
2456/// Handles three shapes:
2457/// - **String**: split on whitespace (OAuth `scope` convention).
2458/// - **Array of strings**: each element becomes a value (Keycloak `realm_access.roles`).
2459/// - **Nested object**: traversed by dot-separated segments (e.g. `realm_access.roles`).
2460///
2461/// Returns an empty vec if the path does not exist or the leaf is not a
2462/// string/array.
2463fn resolve_claim_path<'a>(
2464    extra: &'a HashMap<String, serde_json::Value>,
2465    path: &str,
2466) -> Vec<&'a str> {
2467    let mut segments = path.split('.');
2468    let Some(first) = segments.next() else {
2469        return Vec::new();
2470    };
2471
2472    let mut current: Option<&serde_json::Value> = extra.get(first);
2473
2474    for segment in segments {
2475        current = current.and_then(|v| v.get(segment));
2476    }
2477
2478    match current {
2479        Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
2480        Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
2481        _ => Vec::new(),
2482    }
2483}
2484
2485// ---------------------------------------------------------------------------
2486// JWT claims
2487// ---------------------------------------------------------------------------
2488
2489/// Standard + common JWT claims we care about.
2490#[derive(Debug, Deserialize)]
2491struct Claims {
2492    /// Subject (user or service account).
2493    sub: Option<String>,
2494    /// Audience - resource servers the token is intended for.
2495    /// Can be a single string or an array of strings per RFC 7519 Sec.4.1.3.
2496    #[serde(default)]
2497    aud: OneOrMany,
2498    /// Authorized party (OIDC Core Sec.2) - the OAuth client that was issued the token.
2499    azp: Option<String>,
2500    /// Client ID (some providers use this instead of azp).
2501    client_id: Option<String>,
2502    /// Space-separated scope string (OAuth 2.0 convention).
2503    scope: Option<String>,
2504    /// All remaining claims, captured for `role_claim` dot-path resolution.
2505    #[serde(flatten)]
2506    extra: HashMap<String, serde_json::Value>,
2507}
2508
2509/// Deserializes a JWT claim that can be either a single string or an array of strings.
2510#[derive(Debug, Default)]
2511struct OneOrMany(Vec<String>);
2512
2513impl OneOrMany {
2514    fn contains(&self, value: &str) -> bool {
2515        self.0.iter().any(|v| v == value)
2516    }
2517
2518    /// Render the audience list as a single comma-separated string for
2519    /// structured logging (e.g. `aud="a, b"`), preserving every entry so
2520    /// no debugging signal is lost. An empty list renders as `"-"`.
2521    fn log_display(&self) -> String {
2522        if self.0.is_empty() {
2523            "-".to_owned()
2524        } else {
2525            self.0.join(", ")
2526        }
2527    }
2528}
2529
2530/// Format a JSON `aud` claim (string OR array of strings) for structured
2531/// logging without losing shape.
2532///
2533/// The `aud` claim is legitimately either a single string or an array
2534/// (RFC 7519 ยง4.1.3). Rendering via `serde_json::Value::as_str()` alone
2535/// would drop array audiences (returns `None` โ†’ `"-"`), hiding real
2536/// values in the log. This joins arrays with `", "`, passes strings
2537/// through, and falls back to `"-"` only when the claim is truly absent
2538/// or an unexpected JSON type.
2539fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
2540    match value {
2541        Some(serde_json::Value::String(s)) => s.clone(),
2542        Some(serde_json::Value::Array(items)) => {
2543            let joined = items
2544                .iter()
2545                .filter_map(serde_json::Value::as_str)
2546                .collect::<Vec<_>>()
2547                .join(", ");
2548            if joined.is_empty() {
2549                "-".to_owned()
2550            } else {
2551                joined
2552            }
2553        }
2554        Some(
2555            serde_json::Value::Null
2556            | serde_json::Value::Bool(_)
2557            | serde_json::Value::Number(_)
2558            | serde_json::Value::Object(_),
2559        )
2560        | None => "-".to_owned(),
2561    }
2562}
2563
2564/// Render an optional JSON claim as a plain string for logging, without the
2565/// `Debug` wrapper/escaping (e.g. `sub="alice"` not `sub=Some(String("alice"))`).
2566/// Non-string or absent claims render as `"-"`.
2567fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
2568    value.and_then(serde_json::Value::as_str).unwrap_or("-")
2569}
2570
2571impl<'de> Deserialize<'de> for OneOrMany {
2572    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2573        use serde::de;
2574
2575        struct Visitor;
2576        impl<'de> de::Visitor<'de> for Visitor {
2577            type Value = OneOrMany;
2578            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2579                f.write_str("a string or array of strings")
2580            }
2581            fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
2582                Ok(OneOrMany(vec![v.to_owned()]))
2583            }
2584            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
2585                let mut v = Vec::new();
2586                while let Some(s) = seq.next_element::<String>()? {
2587                    v.push(s);
2588                }
2589                Ok(OneOrMany(v))
2590            }
2591        }
2592        deserializer.deserialize_any(Visitor)
2593    }
2594}
2595
2596// ---------------------------------------------------------------------------
2597// JWT detection heuristic
2598// ---------------------------------------------------------------------------
2599
2600/// Returns true if the token looks like a JWT (3 dot-separated segments
2601/// where the first segment decodes to JSON containing `"alg"`).
2602#[must_use]
2603pub fn looks_like_jwt(token: &str) -> bool {
2604    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2605
2606    let mut parts = token.splitn(4, '.');
2607    let Some(header_b64) = parts.next() else {
2608        return false;
2609    };
2610    // Must have exactly 3 segments.
2611    if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
2612        return false;
2613    }
2614    // Try to decode the header segment.
2615    let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
2616        return false;
2617    };
2618    // Check for "alg" key in the JSON.
2619    let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
2620        return false;
2621    };
2622    header.get("alg").is_some()
2623}
2624
2625// ---------------------------------------------------------------------------
2626// Protected Resource Metadata (RFC 9728)
2627// ---------------------------------------------------------------------------
2628
2629/// Build the Protected Resource Metadata JSON response.
2630///
2631/// When an OAuth proxy is configured, `authorization_servers` points to
2632/// the MCP server itself (the proxy facade).  Otherwise it points directly
2633/// to the upstream issuer.
2634#[must_use]
2635pub fn protected_resource_metadata(
2636    resource_url: &str,
2637    server_url: &str,
2638    config: &OAuthConfig,
2639) -> serde_json::Value {
2640    // Always point to the local server -- when a proxy is configured the
2641    // server exposes /authorize, /token, /register locally.  When an
2642    // application provides its own chained OAuth flow (via extra_router)
2643    // without a proxy, the auth server is still the local server.
2644    let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2645    let auth_server = server_url;
2646    serde_json::json!({
2647        "resource": resource_url,
2648        "authorization_servers": [auth_server],
2649        "scopes_supported": scopes,
2650        "bearer_methods_supported": ["header"]
2651    })
2652}
2653
2654/// Build the Authorization Server Metadata JSON response (RFC 8414).
2655///
2656/// Returned at `GET /.well-known/oauth-authorization-server` so MCP
2657/// clients can discover the authorization and token endpoints.
2658#[must_use]
2659pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
2660    let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
2661    let mut meta = serde_json::json!({
2662        "issuer": &config.issuer,
2663        "authorization_endpoint": format!("{server_url}/authorize"),
2664        "token_endpoint": format!("{server_url}/token"),
2665        "registration_endpoint": format!("{server_url}/register"),
2666        "response_types_supported": ["code"],
2667        "grant_types_supported": ["authorization_code", "refresh_token"],
2668        "code_challenge_methods_supported": ["S256"],
2669        "scopes_supported": scopes,
2670        "token_endpoint_auth_methods_supported": ["none"],
2671    });
2672    if let Some(proxy) = &config.proxy
2673        && proxy.expose_admin_endpoints
2674        && let Some(obj) = meta.as_object_mut()
2675    {
2676        if proxy.introspection_url.is_some() {
2677            obj.insert(
2678                "introspection_endpoint".into(),
2679                serde_json::Value::String(format!("{server_url}/introspect")),
2680            );
2681        }
2682        if proxy.revocation_url.is_some() {
2683            obj.insert(
2684                "revocation_endpoint".into(),
2685                serde_json::Value::String(format!("{server_url}/revoke")),
2686            );
2687        }
2688        if proxy.require_auth_on_admin_endpoints {
2689            obj.insert(
2690                "introspection_endpoint_auth_methods_supported".into(),
2691                serde_json::json!(["bearer"]),
2692            );
2693            obj.insert(
2694                "revocation_endpoint_auth_methods_supported".into(),
2695                serde_json::json!(["bearer"]),
2696            );
2697        }
2698    }
2699    meta
2700}
2701
2702// ---------------------------------------------------------------------------
2703// OAuth 2.1 Proxy Handlers
2704// ---------------------------------------------------------------------------
2705
2706/// Handle `GET /authorize` - redirect to the upstream authorize URL.
2707///
2708/// Forwards all OAuth query parameters (`response_type`, `client_id`,
2709/// `redirect_uri`, `scope`, `state`, `code_challenge`,
2710/// `code_challenge_method`) to the upstream identity provider.
2711/// The upstream provider (e.g. Keycloak) presents the login UI and
2712/// redirects the user back to the MCP client's `redirect_uri` with an
2713/// authorization code.
2714#[must_use]
2715pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
2716    use axum::{
2717        http::{StatusCode, header},
2718        response::IntoResponse,
2719    };
2720
2721    // Replace the client_id in the query with the upstream client_id.
2722    let upstream_query = replace_client_id(query, &proxy.client_id);
2723    let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
2724
2725    (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
2726}
2727
2728/// Handle `POST /token` - proxy the token request to the upstream provider.
2729///
2730/// Forwards the request body (authorization code exchange or refresh token
2731/// grant) to the upstream token endpoint, injecting client credentials
2732/// when configured (confidential client). Returns the upstream response as-is.
2733pub async fn handle_token(
2734    http: &OauthHttpClient,
2735    proxy: &OAuthProxyConfig,
2736    body: &str,
2737) -> axum::response::Response {
2738    use axum::{
2739        http::{StatusCode, header},
2740        response::IntoResponse,
2741    };
2742
2743    // Replace client_id in the form body with the upstream client_id.
2744    let mut upstream_body = replace_client_id(body, &proxy.client_id);
2745
2746    // For confidential clients, inject the client_secret.
2747    if let Some(ref secret) = proxy.client_secret {
2748        use std::fmt::Write;
2749
2750        use secrecy::ExposeSecret;
2751        let _ = write!(
2752            upstream_body,
2753            "&client_secret={}",
2754            urlencoding::encode(secret.expose_secret())
2755        );
2756    }
2757
2758    let result = http
2759        .send_screened(
2760            &proxy.token_url,
2761            http.inner
2762                .post(&proxy.token_url)
2763                .header("Content-Type", "application/x-www-form-urlencoded")
2764                .body(upstream_body),
2765        )
2766        .await;
2767
2768    match result {
2769        Ok(resp) => {
2770            let status =
2771                StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
2772            let Ok(body_bytes) =
2773                read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
2774            else {
2775                return oauth_error_response(
2776                    StatusCode::BAD_GATEWAY,
2777                    "server_error",
2778                    "upstream response too large or unreadable",
2779                );
2780            };
2781            (
2782                status,
2783                [(header::CONTENT_TYPE, "application/json")],
2784                body_bytes,
2785            )
2786                .into_response()
2787        }
2788        Err(e) => {
2789            tracing::error!(error = %e, "OAuth token proxy request failed");
2790            (
2791                StatusCode::BAD_GATEWAY,
2792                [(header::CONTENT_TYPE, "application/json")],
2793                "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
2794            )
2795                .into_response()
2796        }
2797    }
2798}
2799
2800/// Handle `POST /register` - return the pre-configured `client_id`.
2801///
2802/// MCP clients call this to discover which `client_id` to use in the
2803/// authorization flow.  We return the upstream `client_id` from config
2804/// and echo back any `redirect_uris` from the request body (required
2805/// by the MCP SDK's Zod validation).
2806#[must_use]
2807pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
2808    let mut resp = serde_json::json!({
2809        "client_id": proxy.client_id,
2810        "token_endpoint_auth_method": "none",
2811    });
2812    if let Some(uris) = body.get("redirect_uris")
2813        && let Some(obj) = resp.as_object_mut()
2814    {
2815        obj.insert("redirect_uris".into(), uris.clone());
2816    }
2817    if let Some(name) = body.get("client_name")
2818        && let Some(obj) = resp.as_object_mut()
2819    {
2820        obj.insert("client_name".into(), name.clone());
2821    }
2822    resp
2823}
2824
2825/// Handle `POST /introspect` - RFC 7662 token introspection proxy.
2826///
2827/// Forwards the request body to the upstream introspection endpoint,
2828/// injecting client credentials when configured. Returns the upstream
2829/// response as-is.  Requires `proxy.introspection_url` to be `Some`.
2830pub async fn handle_introspect(
2831    http: &OauthHttpClient,
2832    proxy: &OAuthProxyConfig,
2833    body: &str,
2834) -> axum::response::Response {
2835    let Some(ref url) = proxy.introspection_url else {
2836        return oauth_error_response(
2837            axum::http::StatusCode::NOT_FOUND,
2838            "not_supported",
2839            "introspection endpoint is not configured",
2840        );
2841    };
2842    proxy_oauth_admin_request(http, proxy, url, body).await
2843}
2844
2845/// Handle `POST /revoke` - RFC 7009 token revocation proxy.
2846///
2847/// Forwards the request body to the upstream revocation endpoint,
2848/// injecting client credentials when configured. Returns the upstream
2849/// response as-is (per RFC 7009, typically 200 with empty body).
2850/// Requires `proxy.revocation_url` to be `Some`.
2851pub async fn handle_revoke(
2852    http: &OauthHttpClient,
2853    proxy: &OAuthProxyConfig,
2854    body: &str,
2855) -> axum::response::Response {
2856    let Some(ref url) = proxy.revocation_url else {
2857        return oauth_error_response(
2858            axum::http::StatusCode::NOT_FOUND,
2859            "not_supported",
2860            "revocation endpoint is not configured",
2861        );
2862    };
2863    proxy_oauth_admin_request(http, proxy, url, body).await
2864}
2865
2866/// Shared proxy for introspection/revocation: injects `client_id` and
2867/// `client_secret` (when configured) and forwards the form-encoded body
2868/// upstream, returning the upstream status/body verbatim.
2869async fn proxy_oauth_admin_request(
2870    http: &OauthHttpClient,
2871    proxy: &OAuthProxyConfig,
2872    upstream_url: &str,
2873    body: &str,
2874) -> axum::response::Response {
2875    use axum::{
2876        http::{StatusCode, header},
2877        response::IntoResponse,
2878    };
2879
2880    let mut upstream_body = replace_client_id(body, &proxy.client_id);
2881    if let Some(ref secret) = proxy.client_secret {
2882        use std::fmt::Write;
2883
2884        use secrecy::ExposeSecret;
2885        let _ = write!(
2886            upstream_body,
2887            "&client_secret={}",
2888            urlencoding::encode(secret.expose_secret())
2889        );
2890    }
2891
2892    let result = http
2893        .send_screened(
2894            upstream_url,
2895            http.inner
2896                .post(upstream_url)
2897                .header("Content-Type", "application/x-www-form-urlencoded")
2898                .body(upstream_body),
2899        )
2900        .await;
2901
2902    match result {
2903        Ok(resp) => {
2904            let status =
2905                StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
2906            let content_type = resp
2907                .headers()
2908                .get(header::CONTENT_TYPE)
2909                .and_then(|v| v.to_str().ok())
2910                .unwrap_or("application/json")
2911                .to_owned();
2912            let Ok(body_bytes) =
2913                read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
2914            else {
2915                return oauth_error_response(
2916                    StatusCode::BAD_GATEWAY,
2917                    "server_error",
2918                    "upstream response too large or unreadable",
2919                );
2920            };
2921            (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
2922        }
2923        Err(e) => {
2924            tracing::error!(error = %e, url = %upstream_url, "OAuth admin proxy request failed");
2925            oauth_error_response(
2926                StatusCode::BAD_GATEWAY,
2927                "server_error",
2928                "upstream endpoint unreachable",
2929            )
2930        }
2931    }
2932}
2933
2934/// Read an upstream response body, aborting if it exceeds `max_bytes`.
2935///
2936/// Mirrors the bounded-streaming read used for JWKS
2937/// ([`JwksCache::fetch_jwks`]) so OAuth proxy paths never buffer an
2938/// unbounded upstream response. Fails **closed**: on a transport error or
2939/// a body that grows past the cap it returns `Err(())` (the caller maps
2940/// this to a generic `502`); it never returns a truncated body that a
2941/// caller might forward as if complete. `context` is an authority-only
2942/// label for logs (never a full URL with credentials).
2943async fn read_response_capped(
2944    mut resp: reqwest::Response,
2945    max_bytes: u64,
2946    context: &str,
2947) -> Result<Vec<u8>, ()> {
2948    let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
2949    let mut body = Vec::with_capacity(initial_capacity);
2950    loop {
2951        match resp.chunk().await {
2952            Ok(Some(chunk)) => {
2953                let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
2954                let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
2955                if body_len.saturating_add(chunk_len) > max_bytes {
2956                    tracing::warn!(
2957                        context = context,
2958                        max_bytes = max_bytes,
2959                        "upstream OAuth response exceeded size cap; failing closed"
2960                    );
2961                    return Err(());
2962                }
2963                body.extend_from_slice(&chunk);
2964            }
2965            Ok(None) => return Ok(body),
2966            Err(error) => {
2967                tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
2968                return Err(());
2969            }
2970        }
2971    }
2972}
2973
2974fn oauth_error_response(
2975    status: axum::http::StatusCode,
2976    error: &str,
2977    description: &str,
2978) -> axum::response::Response {
2979    use axum::{http::header, response::IntoResponse};
2980    let body = serde_json::json!({
2981        "error": error,
2982        "error_description": description,
2983    });
2984    (
2985        status,
2986        [(header::CONTENT_TYPE, "application/json")],
2987        body.to_string(),
2988    )
2989        .into_response()
2990}
2991
2992// ---------------------------------------------------------------------------
2993// RFC 8693 Token Exchange
2994// ---------------------------------------------------------------------------
2995
2996/// OAuth error response body from the authorization server.
2997#[derive(Debug, Deserialize)]
2998struct OAuthErrorResponse {
2999    error: String,
3000    error_description: Option<String>,
3001}
3002
3003/// Map an upstream OAuth error code to an allowlisted short code suitable
3004/// for client exposure.
3005///
3006/// Returns one of the RFC 6749 ยง5.2 / RFC 8693 standard codes. Unknown or
3007/// non-standard codes collapse to `server_error` to avoid leaking
3008/// authorization-server implementation details to MCP clients.
3009fn sanitize_oauth_error_code(raw: &str) -> &'static str {
3010    match raw {
3011        "invalid_request" => "invalid_request",
3012        "invalid_client" => "invalid_client",
3013        "invalid_grant" => "invalid_grant",
3014        "unauthorized_client" => "unauthorized_client",
3015        "unsupported_grant_type" => "unsupported_grant_type",
3016        "invalid_scope" => "invalid_scope",
3017        "temporarily_unavailable" => "temporarily_unavailable",
3018        // RFC 8693 token-exchange specific.
3019        "invalid_target" => "invalid_target",
3020        // Anything else (including upstream-specific codes that may leak
3021        // implementation details) collapses to a generic short code.
3022        _ => "server_error",
3023    }
3024}
3025
3026/// Exchange an inbound access token for a downstream access token
3027/// via RFC 8693 token exchange.
3028///
3029/// The MCP server calls this to swap a user's MCP-scoped JWT
3030/// (`subject_token`) for a new JWT scoped to a downstream API
3031/// identified by [`TokenExchangeConfig::audience`].
3032///
3033/// # Errors
3034///
3035/// Returns an error if the HTTP request fails, the authorization
3036/// server rejects the exchange, or the response cannot be parsed.
3037pub async fn exchange_token(
3038    http: &OauthHttpClient,
3039    config: &TokenExchangeConfig,
3040    subject_token: &str,
3041) -> Result<ExchangedToken, crate::error::McpxError> {
3042    use secrecy::ExposeSecret;
3043
3044    let client = http.client_for(config);
3045    let mut req = client
3046        .post(&config.token_url)
3047        .header("Content-Type", "application/x-www-form-urlencoded")
3048        .header("Accept", "application/json");
3049
3050    // M-H4: client authentication strategy.
3051    //   * `client_secret` set -> RFC 6749 ยง2.3.1 HTTP Basic.
3052    //   * `client_cert`   set -> RFC 8705 ยง2 mTLS via the cert-bearing
3053    //     `reqwest::Client` selected by `client_for`. NO Authorization
3054    //     header is sent: presenting a TLS client certificate at
3055    //     handshake time *is* the client authentication.
3056    // `OAuthConfig::validate` enforces exactly-one-of so neither both
3057    // nor neither reach this code path.
3058    if config.client_cert.is_none()
3059        && let Some(ref secret) = config.client_secret
3060    {
3061        use base64::Engine;
3062        let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
3063            "{}:{}",
3064            urlencoding::encode(&config.client_id),
3065            urlencoding::encode(secret.expose_secret()),
3066        ));
3067        req = req.header("Authorization", format!("Basic {credentials}"));
3068    }
3069
3070    let form_body = build_exchange_form(config, subject_token);
3071
3072    let resp = http
3073        .send_screened(&config.token_url, req.body(form_body))
3074        .await
3075        .map_err(|e| {
3076            tracing::error!(error = %e, "token exchange request failed");
3077            // Do NOT leak upstream URL, reqwest internals, or DNS detail to clients.
3078            crate::error::McpxError::Auth("server_error".into())
3079        })?;
3080
3081    let status = resp.status();
3082    let body_bytes =
3083        read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
3084            .await
3085            .map_err(|()| {
3086                // read_response_capped already logged the cause (oversize / transport).
3087                crate::error::McpxError::Auth("server_error".into())
3088            })?;
3089
3090    if !status.is_success() {
3091        core::hint::cold_path();
3092        // Parse upstream error for logging only; client-visible payload is a
3093        // sanitized short code from the RFC 6749 ยง5.2 / RFC 8693 allowlist.
3094        let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
3095        let short_code = parsed
3096            .as_ref()
3097            .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
3098        if let Some(ref e) = parsed {
3099            tracing::warn!(
3100                status = %status,
3101                upstream_error = %e.error,
3102                upstream_error_description = e.error_description.as_deref().unwrap_or(""),
3103                client_code = %short_code,
3104                "token exchange rejected by authorization server",
3105            );
3106        } else {
3107            tracing::warn!(
3108                status = %status,
3109                client_code = %short_code,
3110                "token exchange rejected (unparseable upstream body)",
3111            );
3112        }
3113        return Err(crate::error::McpxError::Auth(short_code.into()));
3114    }
3115
3116    let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
3117        tracing::error!(error = %e, "failed to parse token exchange response");
3118        // Avoid surfacing serde internals; map to sanitized short code so
3119        // McpxError::into_response cannot leak parser detail to the client.
3120        crate::error::McpxError::Auth("server_error".into())
3121    })?;
3122
3123    log_exchanged_token(&exchanged);
3124
3125    Ok(exchanged)
3126}
3127
3128/// Build the RFC 8693 token-exchange form body. Adds `client_id` when the
3129/// client is public (no `client_secret`).
3130fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
3131    let body = format!(
3132        "grant_type={}&subject_token={}&subject_token_type={}&requested_token_type={}&audience={}",
3133        urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
3134        urlencoding::encode(subject_token),
3135        urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3136        urlencoding::encode("urn:ietf:params:oauth:token-type:access_token"),
3137        urlencoding::encode(&config.audience),
3138    );
3139    if config.client_secret.is_none() {
3140        format!(
3141            "{body}&client_id={}",
3142            urlencoding::encode(&config.client_id)
3143        )
3144    } else {
3145        body
3146    }
3147}
3148
3149/// Debug-log the exchanged token. For JWTs, decode and log claim summary;
3150/// for opaque tokens, log length + issued type.
3151fn log_exchanged_token(exchanged: &ExchangedToken) {
3152    use base64::Engine;
3153
3154    if !looks_like_jwt(&exchanged.access_token) {
3155        tracing::debug!(
3156            token_len = exchanged.access_token.len(),
3157            issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
3158            expires_in = exchanged.expires_in,
3159            "exchanged token (opaque)",
3160        );
3161        return;
3162    }
3163    let Some(payload) = exchanged.access_token.split('.').nth(1) else {
3164        return;
3165    };
3166    let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
3167        return;
3168    };
3169    let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
3170        return;
3171    };
3172    tracing::debug!(
3173        sub = fmt_json_str(claims.get("sub")),
3174        aud = %fmt_json_aud(claims.get("aud")),
3175        azp = fmt_json_str(claims.get("azp")),
3176        iss = fmt_json_str(claims.get("iss")),
3177        expires_in = exchanged.expires_in,
3178        "exchanged token claims (JWT)",
3179    );
3180}
3181
3182/// Replace or inject the `client_id` parameter in a query/form string.
3183fn replace_client_id(params: &str, upstream_client_id: &str) -> String {
3184    let encoded_id = urlencoding::encode(upstream_client_id);
3185    let mut parts: Vec<String> = params
3186        .split('&')
3187        .filter(|p| !p.starts_with("client_id="))
3188        .map(String::from)
3189        .collect();
3190    parts.push(format!("client_id={encoded_id}"));
3191    parts.join("&")
3192}
3193
3194#[cfg(test)]
3195mod tests {
3196    use std::sync::Arc;
3197
3198    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3199
3200    use super::*;
3201
3202    #[test]
3203    fn looks_like_jwt_valid() {
3204        // Minimal valid JWT structure: base64({"alg":"RS256"}).base64({}).sig
3205        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
3206        let payload = URL_SAFE_NO_PAD.encode(b"{}");
3207        let token = format!("{header}.{payload}.signature");
3208        assert!(looks_like_jwt(&token));
3209    }
3210
3211    #[test]
3212    fn looks_like_jwt_rejects_opaque_token() {
3213        assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
3214    }
3215
3216    #[test]
3217    fn looks_like_jwt_rejects_two_segments() {
3218        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
3219        let token = format!("{header}.payload");
3220        assert!(!looks_like_jwt(&token));
3221    }
3222
3223    #[test]
3224    fn looks_like_jwt_rejects_four_segments() {
3225        assert!(!looks_like_jwt("a.b.c.d"));
3226    }
3227
3228    #[test]
3229    fn looks_like_jwt_rejects_no_alg() {
3230        let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
3231        let payload = URL_SAFE_NO_PAD.encode(b"{}");
3232        let token = format!("{header}.{payload}.sig");
3233        assert!(!looks_like_jwt(&token));
3234    }
3235
3236    #[test]
3237    fn protected_resource_metadata_shape() {
3238        let config = OAuthConfig {
3239            issuer: "https://auth.example.com".into(),
3240            audience: "https://mcp.example.com/mcp".into(),
3241            jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
3242            scopes: vec![
3243                ScopeMapping {
3244                    scope: "mcp:read".into(),
3245                    role: "viewer".into(),
3246                },
3247                ScopeMapping {
3248                    scope: "mcp:admin".into(),
3249                    role: "ops".into(),
3250                },
3251            ],
3252            role_claim: None,
3253            role_mappings: vec![],
3254            jwks_cache_ttl: "10m".into(),
3255            proxy: None,
3256            token_exchange: None,
3257            ca_cert_path: None,
3258            allow_http_oauth_urls: false,
3259            max_jwks_keys: default_max_jwks_keys(),
3260            #[allow(
3261                deprecated,
3262                reason = "test fixture: explicit value for the deprecated field"
3263            )]
3264            strict_audience_validation: false,
3265            audience_validation_mode: None,
3266            jwks_max_response_bytes: default_jwks_max_bytes(),
3267            ssrf_allowlist: None,
3268        };
3269        let meta = protected_resource_metadata(
3270            "https://mcp.example.com/mcp",
3271            "https://mcp.example.com",
3272            &config,
3273        );
3274        assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
3275        assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
3276        assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
3277        assert_eq!(meta["bearer_methods_supported"][0], "header");
3278    }
3279
3280    // -----------------------------------------------------------------------
3281    // F2: OAuth URL HTTPS-only validation (CVE-class: MITM JWKS / token URL)
3282    // -----------------------------------------------------------------------
3283
3284    fn validation_https_config() -> OAuthConfig {
3285        OAuthConfig::builder(
3286            "https://auth.example.com",
3287            "mcp",
3288            "https://auth.example.com/.well-known/jwks.json",
3289        )
3290        .build()
3291    }
3292
3293    #[test]
3294    fn validate_accepts_all_https_urls() {
3295        let cfg = validation_https_config();
3296        cfg.validate().expect("all-HTTPS config must validate");
3297    }
3298
3299    #[test]
3300    fn validate_rejects_unparseable_jwks_cache_ttl() {
3301        let mut cfg = validation_https_config();
3302        cfg.jwks_cache_ttl = "not-a-duration".into();
3303        let err = cfg
3304            .validate()
3305            .expect_err("malformed jwks_cache_ttl must be rejected");
3306        let msg = err.to_string();
3307        assert!(
3308            msg.contains("jwks_cache_ttl"),
3309            "error must reference offending field; got {msg:?}"
3310        );
3311    }
3312
3313    #[test]
3314    fn validate_rejects_http_jwks_uri() {
3315        let mut cfg = validation_https_config();
3316        cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
3317        let err = cfg.validate().expect_err("http jwks_uri must be rejected");
3318        let msg = err.to_string();
3319        assert!(
3320            msg.contains("oauth.jwks_uri") && msg.contains("https"),
3321            "error must reference offending field + scheme requirement; got {msg:?}"
3322        );
3323    }
3324
3325    #[test]
3326    fn validate_rejects_http_proxy_authorize_url() {
3327        let mut cfg = validation_https_config();
3328        cfg.proxy = Some(
3329            OAuthProxyConfig::builder(
3330                "http://idp.example.com/authorize", // <-- HTTP, must be rejected
3331                "https://idp.example.com/token",
3332                "client",
3333            )
3334            .build(),
3335        );
3336        let err = cfg
3337            .validate()
3338            .expect_err("http authorize_url must be rejected");
3339        assert!(
3340            err.to_string().contains("oauth.proxy.authorize_url"),
3341            "error must reference proxy.authorize_url; got {err}"
3342        );
3343    }
3344
3345    #[test]
3346    fn validate_rejects_http_proxy_token_url() {
3347        let mut cfg = validation_https_config();
3348        cfg.proxy = Some(
3349            OAuthProxyConfig::builder(
3350                "https://idp.example.com/authorize",
3351                "http://idp.example.com/token", // <-- HTTP, must be rejected
3352                "client",
3353            )
3354            .build(),
3355        );
3356        let err = cfg.validate().expect_err("http token_url must be rejected");
3357        assert!(
3358            err.to_string().contains("oauth.proxy.token_url"),
3359            "error must reference proxy.token_url; got {err}"
3360        );
3361    }
3362
3363    #[test]
3364    fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
3365        let mut cfg = validation_https_config();
3366        cfg.proxy = Some(
3367            OAuthProxyConfig::builder(
3368                "https://idp.example.com/authorize",
3369                "https://idp.example.com/token",
3370                "client",
3371            )
3372            .introspection_url("http://idp.example.com/introspect")
3373            .build(),
3374        );
3375        let err = cfg
3376            .validate()
3377            .expect_err("http introspection_url must be rejected");
3378        assert!(err.to_string().contains("oauth.proxy.introspection_url"));
3379
3380        let mut cfg = validation_https_config();
3381        cfg.proxy = Some(
3382            OAuthProxyConfig::builder(
3383                "https://idp.example.com/authorize",
3384                "https://idp.example.com/token",
3385                "client",
3386            )
3387            .revocation_url("http://idp.example.com/revoke")
3388            .build(),
3389        );
3390        let err = cfg
3391            .validate()
3392            .expect_err("http revocation_url must be rejected");
3393        assert!(err.to_string().contains("oauth.proxy.revocation_url"));
3394    }
3395
3396    // -- M3 regression: unauthenticated /introspect and /revoke must fail validate --
3397
3398    #[test]
3399    fn validate_rejects_exposed_admin_endpoints_without_auth() {
3400        let mut cfg = validation_https_config();
3401        cfg.proxy = Some(
3402            OAuthProxyConfig::builder(
3403                "https://idp.example.com/authorize",
3404                "https://idp.example.com/token",
3405                "client",
3406            )
3407            .introspection_url("https://idp.example.com/introspect")
3408            .expose_admin_endpoints(true)
3409            .build(),
3410        );
3411        let err = cfg
3412            .validate()
3413            .expect_err("expose_admin_endpoints without auth must fail");
3414        let msg = err.to_string();
3415        assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
3416        assert!(
3417            msg.contains("allow_unauthenticated_admin_endpoints"),
3418            "{msg}"
3419        );
3420    }
3421
3422    #[test]
3423    fn validate_accepts_exposed_admin_endpoints_with_auth() {
3424        let mut cfg = validation_https_config();
3425        cfg.proxy = Some(
3426            OAuthProxyConfig::builder(
3427                "https://idp.example.com/authorize",
3428                "https://idp.example.com/token",
3429                "client",
3430            )
3431            .introspection_url("https://idp.example.com/introspect")
3432            .expose_admin_endpoints(true)
3433            .require_auth_on_admin_endpoints(true)
3434            .build(),
3435        );
3436        cfg.validate()
3437            .expect("authed admin endpoints must validate");
3438    }
3439
3440    #[test]
3441    fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
3442        let mut cfg = validation_https_config();
3443        cfg.proxy = Some(
3444            OAuthProxyConfig::builder(
3445                "https://idp.example.com/authorize",
3446                "https://idp.example.com/token",
3447                "client",
3448            )
3449            .introspection_url("https://idp.example.com/introspect")
3450            .expose_admin_endpoints(true)
3451            .allow_unauthenticated_admin_endpoints(true)
3452            .build(),
3453        );
3454        cfg.validate()
3455            .expect("explicit unauth opt-out must validate");
3456    }
3457
3458    #[test]
3459    fn validate_accepts_unexposed_admin_endpoints_without_auth() {
3460        // The default safe shape: expose_admin_endpoints = false. The
3461        // M3 check must not fire because the routes are not mounted.
3462        let mut cfg = validation_https_config();
3463        cfg.proxy = Some(
3464            OAuthProxyConfig::builder(
3465                "https://idp.example.com/authorize",
3466                "https://idp.example.com/token",
3467                "client",
3468            )
3469            .introspection_url("https://idp.example.com/introspect")
3470            .build(),
3471        );
3472        cfg.validate()
3473            .expect("unexposed admin endpoints must validate");
3474    }
3475
3476    #[test]
3477    fn validate_rejects_http_token_exchange_url() {
3478        let mut cfg = validation_https_config();
3479        cfg.token_exchange = Some(TokenExchangeConfig::new(
3480            "http://idp.example.com/token".into(), // <-- HTTP
3481            "client".into(),
3482            None,
3483            None,
3484            "downstream".into(),
3485        ));
3486        let err = cfg
3487            .validate()
3488            .expect_err("http token_exchange.token_url must be rejected");
3489        assert!(
3490            err.to_string().contains("oauth.token_exchange.token_url"),
3491            "error must reference token_exchange.token_url; got {err}"
3492        );
3493    }
3494
3495    #[test]
3496    fn validate_rejects_unparseable_url() {
3497        let mut cfg = validation_https_config();
3498        cfg.jwks_uri = "not a url".into();
3499        let err = cfg
3500            .validate()
3501            .expect_err("unparseable URL must be rejected");
3502        assert!(err.to_string().contains("invalid URL"));
3503    }
3504
3505    #[test]
3506    fn validate_rejects_non_http_scheme() {
3507        let mut cfg = validation_https_config();
3508        cfg.jwks_uri = "file:///etc/passwd".into();
3509        let err = cfg.validate().expect_err("file:// scheme must be rejected");
3510        let msg = err.to_string();
3511        assert!(
3512            msg.contains("must use https scheme") && msg.contains("file"),
3513            "error must reject non-http(s) schemes; got {msg:?}"
3514        );
3515    }
3516
3517    #[test]
3518    fn validate_accepts_http_with_escape_hatch() {
3519        // F2 escape-hatch: `allow_http_oauth_urls = true` permits HTTP for
3520        // dev/test against local IdPs without TLS. Document the security
3521        // tradeoff (see field doc) and verify all 6 URL fields are accepted
3522        // when the flag is set.
3523        let mut cfg = OAuthConfig::builder(
3524            "http://auth.local",
3525            "mcp",
3526            "http://auth.local/.well-known/jwks.json",
3527        )
3528        .allow_http_oauth_urls(true)
3529        .build();
3530        cfg.proxy = Some(
3531            OAuthProxyConfig::builder(
3532                "http://idp.local/authorize",
3533                "http://idp.local/token",
3534                "client",
3535            )
3536            .introspection_url("http://idp.local/introspect")
3537            .revocation_url("http://idp.local/revoke")
3538            .build(),
3539        );
3540        cfg.token_exchange = Some(TokenExchangeConfig::new(
3541            "http://idp.local/token".into(),
3542            "client".into(),
3543            Some(secrecy::SecretString::new("dev-secret".into())),
3544            None,
3545            "downstream".into(),
3546        ));
3547        cfg.validate()
3548            .expect("escape hatch must permit http on all URL fields");
3549    }
3550
3551    #[test]
3552    fn validate_with_escape_hatch_still_rejects_unparseable() {
3553        // Even with the escape hatch, malformed URLs are rejected so
3554        // garbage configuration cannot silently degrade to no-op.
3555        let mut cfg = validation_https_config();
3556        cfg.allow_http_oauth_urls = true;
3557        cfg.jwks_uri = "::not-a-url::".into();
3558        cfg.validate()
3559            .expect_err("escape hatch must NOT bypass URL parsing");
3560    }
3561
3562    #[tokio::test]
3563    async fn jwks_cache_rejects_redirect_downgrade_to_http() {
3564        // F2.4 (Oracle modification A): even when the configured `jwks_uri`
3565        // is HTTPS, a `302 Location: http://...` from the JWKS host must
3566        // be refused by the reqwest redirect policy. Without this guard,
3567        // a network-positioned attacker who can spoof the upstream IdP
3568        // could redirect the JWKS fetch to plaintext and inject signing
3569        // keys, forging arbitrary JWTs.
3570        //
3571        // We assert at the reqwest-client level (rather than through
3572        // `validate_token`) so the assertion is precise: it pins the
3573        // policy to "reject scheme downgrade" rather than the broader
3574        // "JWKS fetch failed for any reason".
3575
3576        // Install the same rustls crypto provider JwksCache::new uses,
3577        // so the test client can build with TLS support.
3578        rustls::crypto::ring::default_provider()
3579            .install_default()
3580            .ok();
3581
3582        let policy = reqwest::redirect::Policy::custom(|attempt| {
3583            if attempt.url().scheme() != "https" {
3584                attempt.error("redirect to non-HTTPS URL refused")
3585            } else if attempt.previous().len() >= 2 {
3586                attempt.error("too many redirects (max 2)")
3587            } else {
3588                attempt.follow()
3589            }
3590        });
3591        // M-H2: even though this is a redirect-policy test harness
3592        // (not a production code path), wire the same resolver +
3593        // .no_proxy() so the audit-trail invariant "every reqwest
3594        // builder in this crate uses SsrfScreeningResolver" holds.
3595        // Loopback bypass is enabled so the wiremock fixture stays
3596        // reachable.
3597        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
3598        let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
3599        let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
3600            crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
3601        );
3602        let client = reqwest::Client::builder()
3603            .no_proxy()
3604            .dns_resolver(Arc::clone(&resolver))
3605            .timeout(Duration::from_secs(5))
3606            .connect_timeout(Duration::from_secs(3))
3607            .redirect(policy)
3608            .build()
3609            .expect("test client builds");
3610
3611        let mock = wiremock::MockServer::start().await;
3612        wiremock::Mock::given(wiremock::matchers::method("GET"))
3613            .and(wiremock::matchers::path("/jwks.json"))
3614            .respond_with(
3615                wiremock::ResponseTemplate::new(302)
3616                    .insert_header("location", "http://example.invalid/jwks.json"),
3617            )
3618            .mount(&mock)
3619            .await;
3620
3621        // Emulate an HTTPS jwks_uri that 302s to HTTP.  We can't easily
3622        // bring up an HTTPS wiremock, so we simulate the kernel of the
3623        // policy: the same client that JwksCache uses must refuse the
3624        // redirect target.  reqwest invokes the redirect policy
3625        // regardless of source scheme, so an HTTP -> HTTP redirect with
3626        // policy `custom(... if scheme != https then error ...)` still
3627        // yields the redirect-rejection error path.  That is sufficient
3628        // to lock in the policy semantics.
3629        let url = format!("{}/jwks.json", mock.uri());
3630        let err = client
3631            .get(&url)
3632            .send()
3633            .await
3634            .expect_err("redirect policy must reject scheme downgrade");
3635        let chain = format!("{err:#}");
3636        assert!(
3637            chain.contains("redirect to non-HTTPS URL refused")
3638                || chain.to_lowercase().contains("redirect"),
3639            "error must surface redirect-policy rejection; got {chain:?}"
3640        );
3641    }
3642
3643    // -----------------------------------------------------------------------
3644    // Integration tests with in-process RSA keypair + wiremock JWKS
3645    // -----------------------------------------------------------------------
3646
3647    use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
3648
3649    /// Generate an RSA-2048 keypair and return `(private_pem, jwks_json)`.
3650    fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
3651        let mut rng = rsa::rand_core::OsRng;
3652        let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
3653        let private_pem = private_key
3654            .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
3655            .expect("PKCS8 PEM export")
3656            .to_string();
3657
3658        let public_key = private_key.to_public_key();
3659        let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
3660        let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
3661
3662        let jwks = serde_json::json!({
3663            "keys": [{
3664                "kty": "RSA",
3665                "use": "sig",
3666                "alg": "RS256",
3667                "kid": kid,
3668                "n": n,
3669                "e": e
3670            }]
3671        });
3672
3673        (private_pem, jwks)
3674    }
3675
3676    /// Mint a signed JWT with the given claims.
3677    fn mint_token(
3678        private_pem: &str,
3679        kid: &str,
3680        issuer: &str,
3681        audience: &str,
3682        subject: &str,
3683        scope: &str,
3684    ) -> String {
3685        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
3686            .expect("encoding key from PEM");
3687        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
3688        header.kid = Some(kid.into());
3689
3690        let now = jsonwebtoken::get_current_timestamp();
3691        let claims = serde_json::json!({
3692            "iss": issuer,
3693            "aud": audience,
3694            "sub": subject,
3695            "scope": scope,
3696            "exp": now + 3600,
3697            "iat": now,
3698        });
3699
3700        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
3701    }
3702
3703    fn test_config(jwks_uri: &str) -> OAuthConfig {
3704        OAuthConfig {
3705            issuer: "https://auth.test.local".into(),
3706            audience: "https://mcp.test.local/mcp".into(),
3707            jwks_uri: jwks_uri.into(),
3708            scopes: vec![
3709                ScopeMapping {
3710                    scope: "mcp:read".into(),
3711                    role: "viewer".into(),
3712                },
3713                ScopeMapping {
3714                    scope: "mcp:admin".into(),
3715                    role: "ops".into(),
3716                },
3717            ],
3718            role_claim: None,
3719            role_mappings: vec![],
3720            jwks_cache_ttl: "5m".into(),
3721            proxy: None,
3722            token_exchange: None,
3723            ca_cert_path: None,
3724            allow_http_oauth_urls: true,
3725            max_jwks_keys: default_max_jwks_keys(),
3726            #[allow(
3727                deprecated,
3728                reason = "test fixture: explicit value for the deprecated field"
3729            )]
3730            strict_audience_validation: false,
3731            audience_validation_mode: None,
3732            jwks_max_response_bytes: default_jwks_max_bytes(),
3733            ssrf_allowlist: None,
3734        }
3735    }
3736
3737    fn test_cache(config: &OAuthConfig) -> JwksCache {
3738        JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
3739    }
3740
3741    #[tokio::test]
3742    async fn valid_jwt_returns_identity() {
3743        let kid = "test-key-1";
3744        let (pem, jwks) = generate_test_keypair(kid);
3745
3746        let mock_server = wiremock::MockServer::start().await;
3747        wiremock::Mock::given(wiremock::matchers::method("GET"))
3748            .and(wiremock::matchers::path("/jwks.json"))
3749            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3750            .mount(&mock_server)
3751            .await;
3752
3753        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3754        let config = test_config(&jwks_uri);
3755        let cache = test_cache(&config);
3756
3757        let token = mint_token(
3758            &pem,
3759            kid,
3760            "https://auth.test.local",
3761            "https://mcp.test.local/mcp",
3762            "ci-bot",
3763            "mcp:read mcp:other",
3764        );
3765
3766        let identity = cache.validate_token(&token).await;
3767        assert!(identity.is_some(), "valid JWT should authenticate");
3768        let id = identity.unwrap();
3769        assert_eq!(id.name, "ci-bot");
3770        assert_eq!(id.role, "viewer"); // first matching scope
3771        assert_eq!(id.method, AuthMethod::OAuthJwt);
3772    }
3773
3774    #[tokio::test]
3775    async fn wrong_issuer_rejected() {
3776        let kid = "test-key-2";
3777        let (pem, jwks) = generate_test_keypair(kid);
3778
3779        let mock_server = wiremock::MockServer::start().await;
3780        wiremock::Mock::given(wiremock::matchers::method("GET"))
3781            .and(wiremock::matchers::path("/jwks.json"))
3782            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3783            .mount(&mock_server)
3784            .await;
3785
3786        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3787        let config = test_config(&jwks_uri);
3788        let cache = test_cache(&config);
3789
3790        let token = mint_token(
3791            &pem,
3792            kid,
3793            "https://wrong-issuer.example.com", // wrong
3794            "https://mcp.test.local/mcp",
3795            "attacker",
3796            "mcp:admin",
3797        );
3798
3799        assert!(cache.validate_token(&token).await.is_none());
3800    }
3801
3802    #[tokio::test]
3803    async fn wrong_audience_rejected() {
3804        let kid = "test-key-3";
3805        let (pem, jwks) = generate_test_keypair(kid);
3806
3807        let mock_server = wiremock::MockServer::start().await;
3808        wiremock::Mock::given(wiremock::matchers::method("GET"))
3809            .and(wiremock::matchers::path("/jwks.json"))
3810            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3811            .mount(&mock_server)
3812            .await;
3813
3814        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3815        let config = test_config(&jwks_uri);
3816        let cache = test_cache(&config);
3817
3818        let token = mint_token(
3819            &pem,
3820            kid,
3821            "https://auth.test.local",
3822            "https://wrong-audience.example.com", // wrong
3823            "attacker",
3824            "mcp:admin",
3825        );
3826
3827        assert!(cache.validate_token(&token).await.is_none());
3828    }
3829
3830    #[tokio::test]
3831    async fn expired_jwt_rejected() {
3832        let kid = "test-key-4";
3833        let (pem, jwks) = generate_test_keypair(kid);
3834
3835        let mock_server = wiremock::MockServer::start().await;
3836        wiremock::Mock::given(wiremock::matchers::method("GET"))
3837            .and(wiremock::matchers::path("/jwks.json"))
3838            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3839            .mount(&mock_server)
3840            .await;
3841
3842        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3843        let config = test_config(&jwks_uri);
3844        let cache = test_cache(&config);
3845
3846        // Create a token that expired 2 minutes ago (past the 60s leeway).
3847        let encoding_key =
3848            jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
3849        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
3850        header.kid = Some(kid.into());
3851        let now = jsonwebtoken::get_current_timestamp();
3852        let claims = serde_json::json!({
3853            "iss": "https://auth.test.local",
3854            "aud": "https://mcp.test.local/mcp",
3855            "sub": "expired-bot",
3856            "scope": "mcp:read",
3857            "exp": now - 120,
3858            "iat": now - 3720,
3859        });
3860        let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
3861
3862        assert!(cache.validate_token(&token).await.is_none());
3863    }
3864
3865    #[tokio::test]
3866    async fn no_matching_scope_rejected() {
3867        let kid = "test-key-5";
3868        let (pem, jwks) = generate_test_keypair(kid);
3869
3870        let mock_server = wiremock::MockServer::start().await;
3871        wiremock::Mock::given(wiremock::matchers::method("GET"))
3872            .and(wiremock::matchers::path("/jwks.json"))
3873            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3874            .mount(&mock_server)
3875            .await;
3876
3877        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3878        let config = test_config(&jwks_uri);
3879        let cache = test_cache(&config);
3880
3881        let token = mint_token(
3882            &pem,
3883            kid,
3884            "https://auth.test.local",
3885            "https://mcp.test.local/mcp",
3886            "limited-bot",
3887            "some:other:scope", // no matching scope
3888        );
3889
3890        assert!(cache.validate_token(&token).await.is_none());
3891    }
3892
3893    #[tokio::test]
3894    async fn wrong_signing_key_rejected() {
3895        let kid = "test-key-6";
3896        let (_pem, jwks) = generate_test_keypair(kid);
3897
3898        // Generate a DIFFERENT keypair for signing (attacker key).
3899        let (attacker_pem, _) = generate_test_keypair(kid);
3900
3901        let mock_server = wiremock::MockServer::start().await;
3902        wiremock::Mock::given(wiremock::matchers::method("GET"))
3903            .and(wiremock::matchers::path("/jwks.json"))
3904            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3905            .mount(&mock_server)
3906            .await;
3907
3908        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3909        let config = test_config(&jwks_uri);
3910        let cache = test_cache(&config);
3911
3912        // Sign with attacker key but JWKS has legitimate public key.
3913        let token = mint_token(
3914            &attacker_pem,
3915            kid,
3916            "https://auth.test.local",
3917            "https://mcp.test.local/mcp",
3918            "attacker",
3919            "mcp:admin",
3920        );
3921
3922        assert!(cache.validate_token(&token).await.is_none());
3923    }
3924
3925    #[tokio::test]
3926    async fn admin_scope_maps_to_ops_role() {
3927        let kid = "test-key-7";
3928        let (pem, jwks) = generate_test_keypair(kid);
3929
3930        let mock_server = wiremock::MockServer::start().await;
3931        wiremock::Mock::given(wiremock::matchers::method("GET"))
3932            .and(wiremock::matchers::path("/jwks.json"))
3933            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
3934            .mount(&mock_server)
3935            .await;
3936
3937        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
3938        let config = test_config(&jwks_uri);
3939        let cache = test_cache(&config);
3940
3941        let token = mint_token(
3942            &pem,
3943            kid,
3944            "https://auth.test.local",
3945            "https://mcp.test.local/mcp",
3946            "admin-bot",
3947            "mcp:admin",
3948        );
3949
3950        let id = cache
3951            .validate_token(&token)
3952            .await
3953            .expect("should authenticate");
3954        assert_eq!(id.role, "ops");
3955        assert_eq!(id.name, "admin-bot");
3956    }
3957
3958    #[tokio::test]
3959    async fn jwks_server_down_returns_none() {
3960        // Point to a non-existent server.
3961        let config = test_config("http://127.0.0.1:1/jwks.json");
3962        let cache = test_cache(&config);
3963
3964        let kid = "orphan-key";
3965        let (pem, _) = generate_test_keypair(kid);
3966        let token = mint_token(
3967            &pem,
3968            kid,
3969            "https://auth.test.local",
3970            "https://mcp.test.local/mcp",
3971            "bot",
3972            "mcp:read",
3973        );
3974
3975        assert!(cache.validate_token(&token).await.is_none());
3976    }
3977
3978    // -----------------------------------------------------------------------
3979    // resolve_claim_path tests
3980    // -----------------------------------------------------------------------
3981
3982    #[test]
3983    fn resolve_claim_path_flat_string() {
3984        let mut extra = HashMap::new();
3985        extra.insert(
3986            "scope".into(),
3987            serde_json::Value::String("mcp:read mcp:admin".into()),
3988        );
3989        let values = resolve_claim_path(&extra, "scope");
3990        assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
3991    }
3992
3993    #[test]
3994    fn resolve_claim_path_flat_array() {
3995        let mut extra = HashMap::new();
3996        extra.insert(
3997            "roles".into(),
3998            serde_json::json!(["mcp-admin", "mcp-viewer"]),
3999        );
4000        let values = resolve_claim_path(&extra, "roles");
4001        assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
4002    }
4003
4004    #[test]
4005    fn resolve_claim_path_nested_keycloak() {
4006        let mut extra = HashMap::new();
4007        extra.insert(
4008            "realm_access".into(),
4009            serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
4010        );
4011        let values = resolve_claim_path(&extra, "realm_access.roles");
4012        assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
4013    }
4014
4015    #[test]
4016    fn resolve_claim_path_missing_returns_empty() {
4017        let extra = HashMap::new();
4018        assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
4019    }
4020
4021    #[test]
4022    fn resolve_claim_path_numeric_leaf_returns_empty() {
4023        let mut extra = HashMap::new();
4024        extra.insert("count".into(), serde_json::json!(42));
4025        assert!(resolve_claim_path(&extra, "count").is_empty());
4026    }
4027
4028    fn make_claims(json: serde_json::Value) -> Claims {
4029        serde_json::from_value(json).expect("test claims must deserialize")
4030    }
4031
4032    #[test]
4033    fn first_class_scope_claim_splits_on_whitespace() {
4034        let claims = make_claims(serde_json::json!({
4035            "iss": "https://issuer.example.com",
4036            "exp": 9_999_999_999_u64,
4037            "scope": "read write admin",
4038        }));
4039        let values = first_class_claim_values(&claims, "scope");
4040        assert_eq!(values, vec!["read", "write", "admin"]);
4041    }
4042
4043    #[test]
4044    fn first_class_sub_claim_returns_single_value() {
4045        let claims = make_claims(serde_json::json!({
4046            "iss": "https://issuer.example.com",
4047            "exp": 9_999_999_999_u64,
4048            "sub": "service-account-orders",
4049        }));
4050        let values = first_class_claim_values(&claims, "sub");
4051        assert_eq!(values, vec!["service-account-orders"]);
4052    }
4053
4054    #[test]
4055    fn first_class_aud_claim_returns_every_audience() {
4056        let claims = make_claims(serde_json::json!({
4057            "iss": "https://issuer.example.com",
4058            "exp": 9_999_999_999_u64,
4059            "aud": ["api-a", "api-b"],
4060        }));
4061        let values = first_class_claim_values(&claims, "aud");
4062        assert_eq!(values, vec!["api-a", "api-b"]);
4063    }
4064
4065    #[test]
4066    fn first_class_unknown_path_returns_empty() {
4067        let claims = make_claims(serde_json::json!({
4068            "iss": "https://issuer.example.com",
4069            "exp": 9_999_999_999_u64,
4070        }));
4071        assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
4072    }
4073
4074    // -----------------------------------------------------------------------
4075    // role_claim integration tests (wiremock)
4076    // -----------------------------------------------------------------------
4077
4078    /// Mint a JWT with arbitrary custom claims (for `role_claim` testing).
4079    fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
4080        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
4081            .expect("encoding key from PEM");
4082        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
4083        header.kid = Some(kid.into());
4084        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
4085    }
4086
4087    fn test_config_with_role_claim(
4088        jwks_uri: &str,
4089        role_claim: &str,
4090        role_mappings: Vec<RoleMapping>,
4091    ) -> OAuthConfig {
4092        OAuthConfig {
4093            issuer: "https://auth.test.local".into(),
4094            audience: "https://mcp.test.local/mcp".into(),
4095            jwks_uri: jwks_uri.into(),
4096            scopes: vec![],
4097            role_claim: Some(role_claim.into()),
4098            role_mappings,
4099            jwks_cache_ttl: "5m".into(),
4100            proxy: None,
4101            token_exchange: None,
4102            ca_cert_path: None,
4103            allow_http_oauth_urls: true,
4104            max_jwks_keys: default_max_jwks_keys(),
4105            #[allow(
4106                deprecated,
4107                reason = "test fixture: explicit value for the deprecated field"
4108            )]
4109            strict_audience_validation: false,
4110            audience_validation_mode: None,
4111            jwks_max_response_bytes: default_jwks_max_bytes(),
4112            ssrf_allowlist: None,
4113        }
4114    }
4115
4116    #[tokio::test]
4117    async fn screen_oauth_target_rejects_literal_ip() {
4118        let err = screen_oauth_target(
4119            "https://127.0.0.1/jwks.json",
4120            false,
4121            &crate::ssrf::CompiledSsrfAllowlist::default(),
4122        )
4123        .await
4124        .expect_err("literal IPs must be rejected");
4125        let msg = err.to_string();
4126        assert!(msg.contains("literal IPv4 addresses are forbidden"));
4127    }
4128
4129    #[tokio::test]
4130    async fn screen_oauth_target_rejects_private_dns_resolution() {
4131        let err = screen_oauth_target(
4132            "https://localhost/jwks.json",
4133            false,
4134            &crate::ssrf::CompiledSsrfAllowlist::default(),
4135        )
4136        .await
4137        .expect_err("localhost resolution must be rejected");
4138        let msg = err.to_string();
4139        assert!(
4140            msg.contains("blocked IP") && msg.contains("loopback"),
4141            "got {msg:?}"
4142        );
4143    }
4144
4145    #[tokio::test]
4146    async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
4147        let err = screen_oauth_target(
4148            "http://127.0.0.1/jwks.json",
4149            true,
4150            &crate::ssrf::CompiledSsrfAllowlist::default(),
4151        )
4152        .await
4153        .expect_err("literal IPs must still be rejected when http is allowed");
4154        let msg = err.to_string();
4155        assert!(msg.contains("literal IPv4 addresses are forbidden"));
4156    }
4157
4158    #[tokio::test]
4159    async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
4160        let err = screen_oauth_target(
4161            "http://localhost/jwks.json",
4162            true,
4163            &crate::ssrf::CompiledSsrfAllowlist::default(),
4164        )
4165        .await
4166        .expect_err("private DNS resolution must still be rejected when http is allowed");
4167        let msg = err.to_string();
4168        assert!(
4169            msg.contains("blocked IP") && msg.contains("loopback"),
4170            "got {msg:?}"
4171        );
4172    }
4173
4174    #[tokio::test]
4175    async fn screen_oauth_target_allows_public_hostname() {
4176        screen_oauth_target(
4177            "https://example.com/.well-known/jwks.json",
4178            false,
4179            &crate::ssrf::CompiledSsrfAllowlist::default(),
4180        )
4181        .await
4182        .expect("public hostname should pass screening");
4183    }
4184
4185    // -----------------------------------------------------------------------
4186    // Operator SSRF allowlist (1.4.0)
4187    // -----------------------------------------------------------------------
4188
4189    /// Helper: compile an allowlist from string literals.
4190    fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
4191        let raw = OAuthSsrfAllowlist {
4192            hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
4193            cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
4194        };
4195        compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
4196    }
4197
4198    #[test]
4199    fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
4200        let raw = OAuthSsrfAllowlist {
4201            hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
4202            cidrs: vec![],
4203        };
4204        let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
4205        assert_eq!(compiled.host_count(), 1);
4206        assert!(compiled.host_allowed("rhbk.ops.example.com"));
4207        assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
4208    }
4209
4210    #[test]
4211    fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
4212        let raw = OAuthSsrfAllowlist {
4213            hosts: vec!["10.0.0.1".into()],
4214            cidrs: vec![],
4215        };
4216        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
4217        assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
4218    }
4219
4220    #[test]
4221    fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
4222        let raw = OAuthSsrfAllowlist {
4223            hosts: vec!["rhbk.ops.example.com:8443".into()],
4224            cidrs: vec![],
4225        };
4226        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
4227        assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
4228    }
4229
4230    #[test]
4231    fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
4232        let raw = OAuthSsrfAllowlist {
4233            hosts: vec![],
4234            cidrs: vec!["not-a-cidr".into()],
4235        };
4236        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
4237        assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
4238    }
4239
4240    #[test]
4241    fn validate_rejects_misconfigured_allowlist() {
4242        let mut cfg = OAuthConfig::builder(
4243            "https://auth.example.com/",
4244            "mcp",
4245            "https://auth.example.com/jwks.json",
4246        )
4247        .build();
4248        cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4249            hosts: vec!["10.0.0.1".into()],
4250            cidrs: vec![],
4251        });
4252        let err = cfg
4253            .validate()
4254            .expect_err("literal IP host must be rejected");
4255        assert!(
4256            err.to_string().contains("oauth.ssrf_allowlist"),
4257            "got {err}"
4258        );
4259    }
4260
4261    #[tokio::test]
4262    async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
4263        // localhost resolves to loopback; with a *non-empty* allowlist that
4264        // doesn't cover loopback, we expect the new verbose error referencing
4265        // the config field.
4266        let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
4267        let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
4268            .await
4269            .expect_err("loopback must still be blocked when not in allowlist");
4270        let msg = err.to_string();
4271        assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
4272        assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4273        assert!(msg.contains("SECURITY.md"), "got {msg:?}");
4274    }
4275
4276    #[tokio::test]
4277    async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
4278        // The default (empty) allowlist must continue to emit the
4279        // pre-1.4.0 wording so existing operator runbooks keep working.
4280        let err = screen_oauth_target(
4281            "https://localhost/jwks.json",
4282            false,
4283            &crate::ssrf::CompiledSsrfAllowlist::default(),
4284        )
4285        .await
4286        .expect_err("loopback rejection");
4287        let msg = err.to_string();
4288        assert!(msg.contains("blocked IP"), "got {msg:?}");
4289        assert!(msg.contains("loopback"), "got {msg:?}");
4290        // The legacy message must NOT advertise the new knob.
4291        assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4292    }
4293
4294    #[tokio::test]
4295    async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
4296        // localhost -> 127.0.0.1; allowlisting the hostname must let it through.
4297        let allow = make_allowlist(&["localhost"], &[]);
4298        screen_oauth_target("https://localhost/jwks.json", false, &allow)
4299            .await
4300            .expect("allowlisted host must pass");
4301    }
4302
4303    #[tokio::test]
4304    async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
4305        // localhost may resolve to 127.0.0.1 and/or ::1 depending on the OS;
4306        // allowlist both loopback ranges to make the test stable cross-platform.
4307        let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
4308        screen_oauth_target("https://localhost/jwks.json", false, &allow)
4309            .await
4310            .expect("allowlisted CIDR must pass");
4311    }
4312
4313    #[tokio::test]
4314    async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
4315        let mut cfg = OAuthConfig::builder(
4316            "https://auth.example.com/",
4317            "mcp",
4318            "https://auth.example.com/jwks.json",
4319        )
4320        .build();
4321        cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
4322            hosts: vec![],
4323            cidrs: vec!["bad-cidr".into()],
4324        });
4325        let Err(err) = JwksCache::new(&cfg) else {
4326            panic!("invalid CIDR must fail JwksCache::new")
4327        };
4328        let msg = err.to_string();
4329        assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
4330    }
4331
4332    #[tokio::test]
4333    async fn jwks_cache_new_invalid_ttl_is_err() {
4334        // An unvalidated config with a bogus TTL must surface as Err, not
4335        // as the formerly-documented panic.
4336        let cfg = OAuthConfig::builder(
4337            "https://auth.example.com/",
4338            "mcp",
4339            "https://auth.example.com/jwks.json",
4340        )
4341        .jwks_cache_ttl("not-a-duration")
4342        .build();
4343        let Err(err) = JwksCache::new(&cfg) else {
4344            panic!("invalid jwks_cache_ttl must fail JwksCache::new")
4345        };
4346        let msg = err.to_string();
4347        assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
4348    }
4349
4350    #[tokio::test]
4351    async fn audience_falls_back_to_azp_by_default() {
4352        let kid = "test-audience-azp-default";
4353        let (pem, jwks) = generate_test_keypair(kid);
4354
4355        let mock_server = wiremock::MockServer::start().await;
4356        wiremock::Mock::given(wiremock::matchers::method("GET"))
4357            .and(wiremock::matchers::path("/jwks.json"))
4358            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4359            .mount(&mock_server)
4360            .await;
4361
4362        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4363        let config = test_config(&jwks_uri);
4364        let cache = test_cache(&config);
4365
4366        let now = jsonwebtoken::get_current_timestamp();
4367        let token = mint_token_with_claims(
4368            &pem,
4369            kid,
4370            &serde_json::json!({
4371                "iss": "https://auth.test.local",
4372                "aud": "https://some-other-resource.example.com",
4373                "azp": "https://mcp.test.local/mcp",
4374                "sub": "compat-client",
4375                "scope": "mcp:read",
4376                "exp": now + 3600,
4377                "iat": now,
4378            }),
4379        );
4380
4381        let identity = cache
4382            .validate_token_with_reason(&token)
4383            .await
4384            .expect("azp fallback should remain enabled by default");
4385        assert_eq!(identity.role, "viewer");
4386    }
4387
4388    #[tokio::test]
4389    async fn strict_audience_validation_rejects_azp_only_match() {
4390        let kid = "test-audience-azp-strict";
4391        let (pem, jwks) = generate_test_keypair(kid);
4392
4393        let mock_server = wiremock::MockServer::start().await;
4394        wiremock::Mock::given(wiremock::matchers::method("GET"))
4395            .and(wiremock::matchers::path("/jwks.json"))
4396            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4397            .mount(&mock_server)
4398            .await;
4399
4400        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4401        let mut config = test_config(&jwks_uri);
4402        #[allow(deprecated, reason = "covers the legacy bool resolution path")]
4403        {
4404            config.strict_audience_validation = true;
4405        }
4406        let cache = test_cache(&config);
4407
4408        let now = jsonwebtoken::get_current_timestamp();
4409        let token = mint_token_with_claims(
4410            &pem,
4411            kid,
4412            &serde_json::json!({
4413                "iss": "https://auth.test.local",
4414                "aud": "https://some-other-resource.example.com",
4415                "azp": "https://mcp.test.local/mcp",
4416                "sub": "strict-client",
4417                "scope": "mcp:read",
4418                "exp": now + 3600,
4419                "iat": now,
4420            }),
4421        );
4422
4423        let failure = cache
4424            .validate_token_with_reason(&token)
4425            .await
4426            .expect_err("strict audience validation must ignore azp fallback");
4427        assert_eq!(failure, JwtValidationFailure::Invalid);
4428    }
4429
4430    #[tokio::test]
4431    async fn warn_mode_accepts_azp_only_match_and_warns_once() {
4432        let kid = "test-audience-warn-mode";
4433        let (pem, jwks) = generate_test_keypair(kid);
4434
4435        let mock_server = wiremock::MockServer::start().await;
4436        wiremock::Mock::given(wiremock::matchers::method("GET"))
4437            .and(wiremock::matchers::path("/jwks.json"))
4438            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4439            .mount(&mock_server)
4440            .await;
4441
4442        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4443        let mut config = test_config(&jwks_uri);
4444        config.audience_validation_mode = Some(AudienceValidationMode::Warn);
4445        let cache = test_cache(&config);
4446
4447        let now = jsonwebtoken::get_current_timestamp();
4448        let claims = serde_json::json!({
4449            "iss": "https://auth.test.local",
4450            "aud": "https://some-other-resource.example.com",
4451            "azp": "https://mcp.test.local/mcp",
4452            "sub": "warn-client",
4453            "scope": "mcp:read",
4454            "exp": now + 3600,
4455            "iat": now,
4456        });
4457        let token = mint_token_with_claims(&pem, kid, &claims);
4458
4459        let identity = cache
4460            .validate_token_with_reason(&token)
4461            .await
4462            .expect("warn mode must accept azp-only match");
4463        assert_eq!(identity.role, "viewer");
4464        assert!(
4465            cache.azp_fallback_warned.load(Ordering::Relaxed),
4466            "warn-once flag should be set after first azp-only match"
4467        );
4468
4469        let token2 = mint_token_with_claims(&pem, kid, &claims);
4470        cache
4471            .validate_token_with_reason(&token2)
4472            .await
4473            .expect("warn mode must continue accepting subsequent matches");
4474        assert!(
4475            cache.azp_fallback_warned.load(Ordering::Relaxed),
4476            "warn-once flag must remain set; the assertion guards against accidental clearing"
4477        );
4478    }
4479
4480    #[tokio::test]
4481    async fn permissive_mode_accepts_azp_only_match_silently() {
4482        let kid = "test-audience-permissive-mode";
4483        let (pem, jwks) = generate_test_keypair(kid);
4484
4485        let mock_server = wiremock::MockServer::start().await;
4486        wiremock::Mock::given(wiremock::matchers::method("GET"))
4487            .and(wiremock::matchers::path("/jwks.json"))
4488            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4489            .mount(&mock_server)
4490            .await;
4491
4492        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4493        let mut config = test_config(&jwks_uri);
4494        config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
4495        let cache = test_cache(&config);
4496
4497        let now = jsonwebtoken::get_current_timestamp();
4498        let token = mint_token_with_claims(
4499            &pem,
4500            kid,
4501            &serde_json::json!({
4502                "iss": "https://auth.test.local",
4503                "aud": "https://some-other-resource.example.com",
4504                "azp": "https://mcp.test.local/mcp",
4505                "sub": "permissive-client",
4506                "scope": "mcp:read",
4507                "exp": now + 3600,
4508                "iat": now,
4509            }),
4510        );
4511
4512        cache
4513            .validate_token_with_reason(&token)
4514            .await
4515            .expect("permissive mode must accept azp-only match");
4516        assert!(
4517            !cache.azp_fallback_warned.load(Ordering::Relaxed),
4518            "permissive mode must not flip the warn-once flag"
4519        );
4520    }
4521
4522    #[test]
4523    fn audience_validation_mode_overrides_legacy_bool() {
4524        let mut config = OAuthConfig::default();
4525        #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
4526        {
4527            config.strict_audience_validation = false;
4528        }
4529        config.audience_validation_mode = Some(AudienceValidationMode::Strict);
4530        assert_eq!(
4531            config.effective_audience_validation_mode(),
4532            AudienceValidationMode::Strict,
4533            "explicit mode must override legacy false"
4534        );
4535
4536        let mut config = OAuthConfig::default();
4537        #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
4538        {
4539            config.strict_audience_validation = true;
4540        }
4541        config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
4542        assert_eq!(
4543            config.effective_audience_validation_mode(),
4544            AudienceValidationMode::Permissive,
4545            "explicit mode must override legacy true"
4546        );
4547    }
4548
4549    #[test]
4550    fn audience_validation_mode_default_is_warn_when_unset() {
4551        let config = OAuthConfig::default();
4552        assert_eq!(
4553            config.effective_audience_validation_mode(),
4554            AudienceValidationMode::Warn,
4555            "unset mode + unset bool must resolve to Warn (the new default)"
4556        );
4557    }
4558
4559    #[test]
4560    fn audience_validation_legacy_bool_true_resolves_to_strict() {
4561        let mut config = OAuthConfig::default();
4562        #[allow(deprecated, reason = "covers the legacy bool resolution path")]
4563        {
4564            config.strict_audience_validation = true;
4565        }
4566        assert_eq!(
4567            config.effective_audience_validation_mode(),
4568            AudienceValidationMode::Strict,
4569            "legacy bool=true must resolve to Strict for backward compat"
4570        );
4571    }
4572
4573    #[derive(Clone, Default)]
4574    struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
4575
4576    impl CapturedLogs {
4577        fn contents(&self) -> String {
4578            let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
4579            String::from_utf8(bytes).unwrap_or_default()
4580        }
4581    }
4582
4583    struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
4584
4585    impl std::io::Write for CapturedLogsWriter {
4586        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4587            if let Ok(mut guard) = self.0.lock() {
4588                guard.extend_from_slice(buf);
4589            }
4590            Ok(buf.len())
4591        }
4592
4593        fn flush(&mut self) -> std::io::Result<()> {
4594            Ok(())
4595        }
4596    }
4597
4598    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
4599        type Writer = CapturedLogsWriter;
4600
4601        fn make_writer(&'a self) -> Self::Writer {
4602            CapturedLogsWriter(Arc::clone(&self.0))
4603        }
4604    }
4605
4606    #[tokio::test]
4607    async fn jwks_response_size_cap_returns_none_and_logs_warning() {
4608        let kid = "oversized-jwks";
4609        let (_pem, jwks) = generate_test_keypair(kid);
4610        let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
4611        oversized_body.push_str(&" ".repeat(4096));
4612
4613        let mock_server = wiremock::MockServer::start().await;
4614        wiremock::Mock::given(wiremock::matchers::method("GET"))
4615            .and(wiremock::matchers::path("/jwks.json"))
4616            .respond_with(
4617                wiremock::ResponseTemplate::new(200)
4618                    .insert_header("content-type", "application/json")
4619                    .set_body_string(oversized_body),
4620            )
4621            .mount(&mock_server)
4622            .await;
4623
4624        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4625        let mut config = test_config(&jwks_uri);
4626        config.jwks_max_response_bytes = 256;
4627        let cache = test_cache(&config);
4628
4629        let logs = CapturedLogs::default();
4630        let subscriber = tracing_subscriber::fmt()
4631            .with_writer(logs.clone())
4632            .with_ansi(false)
4633            .without_time()
4634            .finish();
4635        let _guard = tracing::subscriber::set_default(subscriber);
4636
4637        let result = cache.fetch_jwks().await;
4638        assert!(result.is_none(), "oversized JWKS must be dropped");
4639        assert!(
4640            logs.contents()
4641                .contains("JWKS response exceeded configured size cap"),
4642            "expected cap-exceeded warning in logs"
4643        );
4644    }
4645
4646    /// A redirect to a userinfo-bearing target is rejected, and the
4647    /// rejection warn log must not echo the embedded credentials
4648    /// (sanitized to scheme+host+port only).
4649    #[tokio::test]
4650    async fn redirect_rejection_log_does_not_echo_credentials() {
4651        let mock_server = wiremock::MockServer::start().await;
4652        wiremock::Mock::given(wiremock::matchers::method("GET"))
4653            .and(wiremock::matchers::path("/jwks.json"))
4654            .respond_with(
4655                wiremock::ResponseTemplate::new(302)
4656                    .insert_header("location", "https://u:p@redirect-target.example/next"),
4657            )
4658            .mount(&mock_server)
4659            .await;
4660
4661        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4662        let config = test_config(&jwks_uri);
4663        let cache = test_cache(&config);
4664
4665        let logs = CapturedLogs::default();
4666        let subscriber = tracing_subscriber::fmt()
4667            .with_writer(logs.clone())
4668            .with_ansi(false)
4669            .without_time()
4670            .finish();
4671        let _guard = tracing::subscriber::set_default(subscriber);
4672
4673        let result = cache.fetch_jwks().await;
4674        assert!(result.is_none(), "rejected redirect must fail the fetch");
4675        let contents = logs.contents();
4676        assert!(
4677            contents.contains("oauth redirect rejected"),
4678            "expected redirect-rejection warning in logs: {contents}"
4679        );
4680        assert!(
4681            !contents.contains("u:p"),
4682            "rejection log must not echo userinfo credentials: {contents}"
4683        );
4684    }
4685
4686    #[tokio::test]
4687    async fn role_claim_keycloak_nested_array() {
4688        let kid = "test-role-1";
4689        let (pem, jwks) = generate_test_keypair(kid);
4690
4691        let mock_server = wiremock::MockServer::start().await;
4692        wiremock::Mock::given(wiremock::matchers::method("GET"))
4693            .and(wiremock::matchers::path("/jwks.json"))
4694            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4695            .mount(&mock_server)
4696            .await;
4697
4698        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4699        let config = test_config_with_role_claim(
4700            &jwks_uri,
4701            "realm_access.roles",
4702            vec![
4703                RoleMapping {
4704                    claim_value: "mcp-admin".into(),
4705                    role: "ops".into(),
4706                },
4707                RoleMapping {
4708                    claim_value: "mcp-viewer".into(),
4709                    role: "viewer".into(),
4710                },
4711            ],
4712        );
4713        let cache = test_cache(&config);
4714
4715        let now = jsonwebtoken::get_current_timestamp();
4716        let token = mint_token_with_claims(
4717            &pem,
4718            kid,
4719            &serde_json::json!({
4720                "iss": "https://auth.test.local",
4721                "aud": "https://mcp.test.local/mcp",
4722                "sub": "keycloak-user",
4723                "exp": now + 3600,
4724                "iat": now,
4725                "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
4726            }),
4727        );
4728
4729        let id = cache
4730            .validate_token(&token)
4731            .await
4732            .expect("should authenticate");
4733        assert_eq!(id.name, "keycloak-user");
4734        assert_eq!(id.role, "ops");
4735    }
4736
4737    #[tokio::test]
4738    async fn role_claim_flat_roles_array() {
4739        let kid = "test-role-2";
4740        let (pem, jwks) = generate_test_keypair(kid);
4741
4742        let mock_server = wiremock::MockServer::start().await;
4743        wiremock::Mock::given(wiremock::matchers::method("GET"))
4744            .and(wiremock::matchers::path("/jwks.json"))
4745            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4746            .mount(&mock_server)
4747            .await;
4748
4749        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4750        let config = test_config_with_role_claim(
4751            &jwks_uri,
4752            "roles",
4753            vec![
4754                RoleMapping {
4755                    claim_value: "MCP.Admin".into(),
4756                    role: "ops".into(),
4757                },
4758                RoleMapping {
4759                    claim_value: "MCP.Reader".into(),
4760                    role: "viewer".into(),
4761                },
4762            ],
4763        );
4764        let cache = test_cache(&config);
4765
4766        let now = jsonwebtoken::get_current_timestamp();
4767        let token = mint_token_with_claims(
4768            &pem,
4769            kid,
4770            &serde_json::json!({
4771                "iss": "https://auth.test.local",
4772                "aud": "https://mcp.test.local/mcp",
4773                "sub": "azure-ad-user",
4774                "exp": now + 3600,
4775                "iat": now,
4776                "roles": ["MCP.Reader", "OtherApp.Admin"]
4777            }),
4778        );
4779
4780        let id = cache
4781            .validate_token(&token)
4782            .await
4783            .expect("should authenticate");
4784        assert_eq!(id.name, "azure-ad-user");
4785        assert_eq!(id.role, "viewer");
4786    }
4787
4788    #[tokio::test]
4789    async fn role_claim_no_matching_value_rejected() {
4790        let kid = "test-role-3";
4791        let (pem, jwks) = generate_test_keypair(kid);
4792
4793        let mock_server = wiremock::MockServer::start().await;
4794        wiremock::Mock::given(wiremock::matchers::method("GET"))
4795            .and(wiremock::matchers::path("/jwks.json"))
4796            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4797            .mount(&mock_server)
4798            .await;
4799
4800        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4801        let config = test_config_with_role_claim(
4802            &jwks_uri,
4803            "roles",
4804            vec![RoleMapping {
4805                claim_value: "mcp-admin".into(),
4806                role: "ops".into(),
4807            }],
4808        );
4809        let cache = test_cache(&config);
4810
4811        let now = jsonwebtoken::get_current_timestamp();
4812        let token = mint_token_with_claims(
4813            &pem,
4814            kid,
4815            &serde_json::json!({
4816                "iss": "https://auth.test.local",
4817                "aud": "https://mcp.test.local/mcp",
4818                "sub": "limited-user",
4819                "exp": now + 3600,
4820                "iat": now,
4821                "roles": ["some-other-role"]
4822            }),
4823        );
4824
4825        assert!(cache.validate_token(&token).await.is_none());
4826    }
4827
4828    #[tokio::test]
4829    async fn role_claim_space_separated_string() {
4830        let kid = "test-role-4";
4831        let (pem, jwks) = generate_test_keypair(kid);
4832
4833        let mock_server = wiremock::MockServer::start().await;
4834        wiremock::Mock::given(wiremock::matchers::method("GET"))
4835            .and(wiremock::matchers::path("/jwks.json"))
4836            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4837            .mount(&mock_server)
4838            .await;
4839
4840        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4841        let config = test_config_with_role_claim(
4842            &jwks_uri,
4843            "custom_scope",
4844            vec![
4845                RoleMapping {
4846                    claim_value: "write".into(),
4847                    role: "ops".into(),
4848                },
4849                RoleMapping {
4850                    claim_value: "read".into(),
4851                    role: "viewer".into(),
4852                },
4853            ],
4854        );
4855        let cache = test_cache(&config);
4856
4857        let now = jsonwebtoken::get_current_timestamp();
4858        let token = mint_token_with_claims(
4859            &pem,
4860            kid,
4861            &serde_json::json!({
4862                "iss": "https://auth.test.local",
4863                "aud": "https://mcp.test.local/mcp",
4864                "sub": "custom-client",
4865                "exp": now + 3600,
4866                "iat": now,
4867                "custom_scope": "read audit"
4868            }),
4869        );
4870
4871        let id = cache
4872            .validate_token(&token)
4873            .await
4874            .expect("should authenticate");
4875        assert_eq!(id.name, "custom-client");
4876        assert_eq!(id.role, "viewer");
4877    }
4878
4879    #[tokio::test]
4880    async fn scope_backward_compat_without_role_claim() {
4881        // Verify existing `scopes` behavior still works when role_claim is None.
4882        let kid = "test-compat-1";
4883        let (pem, jwks) = generate_test_keypair(kid);
4884
4885        let mock_server = wiremock::MockServer::start().await;
4886        wiremock::Mock::given(wiremock::matchers::method("GET"))
4887            .and(wiremock::matchers::path("/jwks.json"))
4888            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4889            .mount(&mock_server)
4890            .await;
4891
4892        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4893        let config = test_config(&jwks_uri); // role_claim: None, uses scopes
4894        let cache = test_cache(&config);
4895
4896        let token = mint_token(
4897            &pem,
4898            kid,
4899            "https://auth.test.local",
4900            "https://mcp.test.local/mcp",
4901            "legacy-bot",
4902            "mcp:admin other:scope",
4903        );
4904
4905        let id = cache
4906            .validate_token(&token)
4907            .await
4908            .expect("should authenticate");
4909        assert_eq!(id.name, "legacy-bot");
4910        assert_eq!(id.role, "ops"); // mcp:admin -> ops via scopes
4911    }
4912
4913    // -----------------------------------------------------------------------
4914    // JWKS refresh cooldown tests
4915    // -----------------------------------------------------------------------
4916
4917    #[tokio::test]
4918    async fn jwks_refresh_deduplication() {
4919        // Verify that concurrent requests with unknown kids result in exactly
4920        // one JWKS fetch, not one per request (deduplication via mutex).
4921        let kid = "test-dedup";
4922        let (pem, jwks) = generate_test_keypair(kid);
4923
4924        let mock_server = wiremock::MockServer::start().await;
4925        let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
4926            .and(wiremock::matchers::path("/jwks.json"))
4927            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4928            .expect(1) // Should be called exactly once
4929            .mount(&mock_server)
4930            .await;
4931
4932        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4933        let config = test_config(&jwks_uri);
4934        let cache = Arc::new(test_cache(&config));
4935
4936        // Create 5 concurrent validation requests with the same valid token.
4937        let token = mint_token(
4938            &pem,
4939            kid,
4940            "https://auth.test.local",
4941            "https://mcp.test.local/mcp",
4942            "concurrent-bot",
4943            "mcp:read",
4944        );
4945
4946        let mut handles = Vec::new();
4947        for _ in 0..5 {
4948            let c = Arc::clone(&cache);
4949            let t = token.clone();
4950            handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
4951        }
4952
4953        for h in handles {
4954            let result = h.await.unwrap();
4955            assert!(result.is_some(), "all concurrent requests should succeed");
4956        }
4957
4958        // The expect(1) assertion on the mock verifies only one fetch occurred.
4959    }
4960
4961    #[tokio::test]
4962    async fn jwks_refresh_cooldown_blocks_rapid_requests() {
4963        // Verify that rapid sequential requests with unknown kids (cache misses)
4964        // only trigger one JWKS fetch due to cooldown.
4965        let kid = "test-cooldown";
4966        let (_pem, jwks) = generate_test_keypair(kid);
4967
4968        let mock_server = wiremock::MockServer::start().await;
4969        let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
4970            .and(wiremock::matchers::path("/jwks.json"))
4971            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
4972            .expect(1) // Should be called exactly once despite multiple misses
4973            .mount(&mock_server)
4974            .await;
4975
4976        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
4977        let config = test_config(&jwks_uri);
4978        let cache = test_cache(&config);
4979
4980        // First request with unknown kid triggers a refresh.
4981        let fake_token1 =
4982            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
4983        let _ = cache.validate_token(fake_token1).await;
4984
4985        // Second request with a different unknown kid should NOT trigger refresh
4986        // because we're within the 10-second cooldown.
4987        let fake_token2 =
4988            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
4989        let _ = cache.validate_token(fake_token2).await;
4990
4991        // Third request with yet another unknown kid - still within cooldown.
4992        let fake_token3 =
4993            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
4994        let _ = cache.validate_token(fake_token3).await;
4995
4996        // The expect(1) assertion verifies only one fetch occurred.
4997    }
4998
4999    // -- introspection / revocation proxy --
5000
5001    fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
5002        OAuthProxyConfig {
5003            authorize_url: "https://example.invalid/auth".into(),
5004            token_url: token_url.into(),
5005            client_id: "mcp-client".into(),
5006            client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
5007            introspection_url: None,
5008            revocation_url: None,
5009            expose_admin_endpoints: false,
5010            require_auth_on_admin_endpoints: false,
5011            allow_unauthenticated_admin_endpoints: false,
5012        }
5013    }
5014
5015    /// Build an HTTP client for tests. Ensures a rustls crypto provider
5016    /// is installed (normally done inside `JwksCache::new`).
5017    fn test_http_client() -> OauthHttpClient {
5018        rustls::crypto::ring::default_provider()
5019            .install_default()
5020            .ok();
5021        let config = OAuthConfig::builder(
5022            "https://auth.test.local",
5023            "https://mcp.test.local/mcp",
5024            "https://auth.test.local/.well-known/jwks.json",
5025        )
5026        .allow_http_oauth_urls(true)
5027        .build();
5028        OauthHttpClient::with_config(&config)
5029            .expect("build test http client")
5030            .__test_allow_loopback_ssrf()
5031    }
5032
5033    #[tokio::test]
5034    async fn introspect_proxies_and_injects_client_credentials() {
5035        use wiremock::matchers::{body_string_contains, method, path};
5036
5037        let mock_server = wiremock::MockServer::start().await;
5038        wiremock::Mock::given(method("POST"))
5039            .and(path("/introspect"))
5040            .and(body_string_contains("client_id=mcp-client"))
5041            .and(body_string_contains("client_secret=shh"))
5042            .and(body_string_contains("token=abc"))
5043            .respond_with(
5044                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5045                    "active": true,
5046                    "scope": "read"
5047                })),
5048            )
5049            .expect(1)
5050            .mount(&mock_server)
5051            .await;
5052
5053        let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5054        proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
5055
5056        let http = test_http_client();
5057        let resp = handle_introspect(&http, &proxy, "token=abc").await;
5058        assert_eq!(resp.status(), 200);
5059    }
5060
5061    #[tokio::test]
5062    async fn token_proxy_fails_closed_on_oversized_upstream_response() {
5063        use http_body_util::BodyExt as _;
5064        use wiremock::matchers::{method, path};
5065
5066        // Upstream returns a body far larger than OAUTH_PROXY_MAX_RESPONSE_BYTES.
5067        let oversized = "x"
5068            .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
5069        let mock_server = wiremock::MockServer::start().await;
5070        wiremock::Mock::given(method("POST"))
5071            .and(path("/token"))
5072            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
5073            .expect(1)
5074            .mount(&mock_server)
5075            .await;
5076
5077        let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5078        let http = test_http_client();
5079        let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5080
5081        // Must fail closed with 502, and MUST NOT forward the oversized body.
5082        assert_eq!(
5083            resp.status(),
5084            502,
5085            "oversized upstream response must fail closed as 502"
5086        );
5087        let body = resp
5088            .into_body()
5089            .collect()
5090            .await
5091            .expect("collect body")
5092            .to_bytes();
5093        assert!(
5094            body.len() < 1024,
5095            "must return the small generic error body, not the oversized upstream body (got {} bytes)",
5096            body.len()
5097        );
5098        assert!(
5099            !body.windows(8).any(|w| w == b"xxxxxxxx"),
5100            "the oversized upstream payload must not be forwarded to the client"
5101        );
5102    }
5103
5104    #[tokio::test]
5105    async fn token_proxy_passes_through_normal_response() {
5106        use http_body_util::BodyExt as _;
5107        use wiremock::matchers::{method, path};
5108
5109        let mock_server = wiremock::MockServer::start().await;
5110        wiremock::Mock::given(method("POST"))
5111            .and(path("/token"))
5112            .respond_with(
5113                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5114                    "access_token": "at-123",
5115                    "token_type": "Bearer"
5116                })),
5117            )
5118            .expect(1)
5119            .mount(&mock_server)
5120            .await;
5121
5122        let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5123        let http = test_http_client();
5124        let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
5125
5126        assert_eq!(
5127            resp.status(),
5128            200,
5129            "a normal-sized response must pass through"
5130        );
5131        let body = resp
5132            .into_body()
5133            .collect()
5134            .await
5135            .expect("collect body")
5136            .to_bytes();
5137        let json: serde_json::Value =
5138            serde_json::from_slice(&body).expect("upstream JSON preserved");
5139        assert_eq!(json["access_token"], "at-123");
5140    }
5141
5142    #[tokio::test]
5143    async fn introspect_returns_404_when_not_configured() {
5144        let proxy = proxy_cfg("https://example.invalid/token");
5145        let http = test_http_client();
5146        let resp = handle_introspect(&http, &proxy, "token=abc").await;
5147        assert_eq!(resp.status(), 404);
5148    }
5149
5150    #[tokio::test]
5151    async fn revoke_proxies_and_returns_upstream_status() {
5152        use wiremock::matchers::{method, path};
5153
5154        let mock_server = wiremock::MockServer::start().await;
5155        wiremock::Mock::given(method("POST"))
5156            .and(path("/revoke"))
5157            .respond_with(wiremock::ResponseTemplate::new(200))
5158            .expect(1)
5159            .mount(&mock_server)
5160            .await;
5161
5162        let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
5163        proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
5164
5165        let http = test_http_client();
5166        let resp = handle_revoke(&http, &proxy, "token=abc").await;
5167        assert_eq!(resp.status(), 200);
5168    }
5169
5170    #[tokio::test]
5171    async fn revoke_returns_404_when_not_configured() {
5172        let proxy = proxy_cfg("https://example.invalid/token");
5173        let http = test_http_client();
5174        let resp = handle_revoke(&http, &proxy, "token=abc").await;
5175        assert_eq!(resp.status(), 404);
5176    }
5177
5178    #[test]
5179    fn metadata_advertises_endpoints_only_when_configured() {
5180        let mut cfg = test_config("https://auth.test.local/jwks.json");
5181        // Without proxy configured, no introspection/revocation advertised.
5182        let m = authorization_server_metadata("https://mcp.local", &cfg);
5183        assert!(m.get("introspection_endpoint").is_none());
5184        assert!(m.get("revocation_endpoint").is_none());
5185
5186        // With proxy + introspection_url but expose_admin_endpoints = false
5187        // (the secure default): endpoints MUST NOT be advertised.
5188        let mut proxy = proxy_cfg("https://upstream.local/token");
5189        proxy.introspection_url = Some("https://upstream.local/introspect".into());
5190        proxy.revocation_url = Some("https://upstream.local/revoke".into());
5191        cfg.proxy = Some(proxy);
5192        let m = authorization_server_metadata("https://mcp.local", &cfg);
5193        assert!(
5194            m.get("introspection_endpoint").is_none(),
5195            "introspection must not be advertised when expose_admin_endpoints=false"
5196        );
5197        assert!(
5198            m.get("revocation_endpoint").is_none(),
5199            "revocation must not be advertised when expose_admin_endpoints=false"
5200        );
5201
5202        // Opt in: expose_admin_endpoints = true + introspection_url only.
5203        if let Some(p) = cfg.proxy.as_mut() {
5204            p.expose_admin_endpoints = true;
5205            p.revocation_url = None;
5206        }
5207        let m = authorization_server_metadata("https://mcp.local", &cfg);
5208        assert_eq!(
5209            m["introspection_endpoint"],
5210            serde_json::Value::String("https://mcp.local/introspect".into())
5211        );
5212        assert!(m.get("revocation_endpoint").is_none());
5213
5214        // Add revocation_url.
5215        if let Some(p) = cfg.proxy.as_mut() {
5216            p.revocation_url = Some("https://upstream.local/revoke".into());
5217        }
5218        let m = authorization_server_metadata("https://mcp.local", &cfg);
5219        assert_eq!(
5220            m["revocation_endpoint"],
5221            serde_json::Value::String("https://mcp.local/revoke".into())
5222        );
5223    }
5224
5225    // ---------- M-H4: token-exchange client authentication ----------
5226
5227    fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
5228        let mut cfg = validation_https_config();
5229        cfg.token_exchange = Some(tx);
5230        cfg
5231    }
5232
5233    fn tx_with(
5234        client_secret: Option<&str>,
5235        client_cert: Option<ClientCertConfig>,
5236    ) -> TokenExchangeConfig {
5237        TokenExchangeConfig::new(
5238            "https://idp.example.com/token".into(),
5239            "client".into(),
5240            client_secret.map(|s| secrecy::SecretString::new(s.into())),
5241            client_cert,
5242            "downstream".into(),
5243        )
5244    }
5245
5246    #[test]
5247    fn validate_rejects_token_exchange_without_client_auth() {
5248        let cfg = https_cfg_with_tx(tx_with(None, None));
5249        let err = cfg
5250            .validate()
5251            .expect_err("token_exchange without client auth must be rejected");
5252        let msg = err.to_string();
5253        assert!(
5254            msg.contains("requires client authentication"),
5255            "error must explain missing client auth; got {msg:?}"
5256        );
5257    }
5258
5259    #[test]
5260    fn validate_rejects_token_exchange_with_both_secret_and_cert() {
5261        let cc = ClientCertConfig {
5262            cert_path: PathBuf::from("/nonexistent/cert.pem"),
5263            key_path: PathBuf::from("/nonexistent/key.pem"),
5264        };
5265        let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
5266        let err = cfg
5267            .validate()
5268            .expect_err("client_secret + client_cert must be rejected");
5269        let msg = err.to_string();
5270        assert!(
5271            msg.contains("mutually") && msg.contains("exclusive"),
5272            "error must explain mutual exclusion; got {msg:?}"
5273        );
5274    }
5275
5276    #[cfg(not(feature = "oauth-mtls-client"))]
5277    #[test]
5278    fn validate_rejects_client_cert_without_feature() {
5279        let cc = ClientCertConfig {
5280            cert_path: PathBuf::from("/nonexistent/cert.pem"),
5281            key_path: PathBuf::from("/nonexistent/key.pem"),
5282        };
5283        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5284        let err = cfg
5285            .validate()
5286            .expect_err("client_cert without feature must be rejected");
5287        assert!(
5288            err.to_string().contains("oauth-mtls-client"),
5289            "error must reference the cargo feature; got {err}"
5290        );
5291    }
5292
5293    #[cfg(feature = "oauth-mtls-client")]
5294    #[test]
5295    fn validate_rejects_missing_client_cert_files() {
5296        let cc = ClientCertConfig {
5297            cert_path: PathBuf::from("/nonexistent/cert.pem"),
5298            key_path: PathBuf::from("/nonexistent/key.pem"),
5299        };
5300        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5301        let err = cfg
5302            .validate()
5303            .expect_err("missing cert file must be rejected");
5304        assert!(
5305            err.to_string().contains("unreadable"),
5306            "error must call out unreadable file; got {err}"
5307        );
5308    }
5309
5310    #[cfg(feature = "oauth-mtls-client")]
5311    #[test]
5312    fn validate_rejects_malformed_client_cert_pem() {
5313        let dir = std::env::temp_dir();
5314        let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
5315        let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
5316        std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
5317        std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
5318        let cc = ClientCertConfig {
5319            cert_path: cert.clone(),
5320            key_path: key.clone(),
5321        };
5322        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5323        let err = cfg.validate().expect_err("malformed PEM must be rejected");
5324        let _ = std::fs::remove_file(&cert);
5325        let _ = std::fs::remove_file(&key);
5326        assert!(
5327            err.to_string().contains("PEM parse failed"),
5328            "error must call out PEM parse failure; got {err}"
5329        );
5330    }
5331
5332    #[cfg(feature = "oauth-mtls-client")]
5333    fn write_self_signed_pem() -> (PathBuf, PathBuf) {
5334        let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
5335        let dir = std::env::temp_dir();
5336        let pid = std::process::id();
5337        let nonce: u64 = rand::random();
5338        let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
5339        let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
5340        std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
5341        std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
5342        (cert_path, key_path)
5343    }
5344
5345    #[cfg(feature = "oauth-mtls-client")]
5346    fn install_test_crypto_provider() {
5347        let _ = rustls::crypto::ring::default_provider().install_default();
5348    }
5349
5350    #[cfg(feature = "oauth-mtls-client")]
5351    #[test]
5352    fn validate_accepts_well_formed_client_cert() {
5353        install_test_crypto_provider();
5354        let (cert_path, key_path) = write_self_signed_pem();
5355        let cc = ClientCertConfig {
5356            cert_path: cert_path.clone(),
5357            key_path: key_path.clone(),
5358        };
5359        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5360        let res = cfg.validate();
5361        let _ = std::fs::remove_file(&cert_path);
5362        let _ = std::fs::remove_file(&key_path);
5363        res.expect("well-formed cert+key must validate");
5364    }
5365
5366    #[cfg(feature = "oauth-mtls-client")]
5367    #[test]
5368    fn client_for_returns_cached_mtls_client() {
5369        install_test_crypto_provider();
5370        let (cert_path, key_path) = write_self_signed_pem();
5371        let cc = ClientCertConfig {
5372            cert_path: cert_path.clone(),
5373            key_path: key_path.clone(),
5374        };
5375        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
5376        let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
5377        let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
5378        let cert_client = http.client_for(tx_ref);
5379        let inner_client = http.client_for(&tx_with(Some("s"), None));
5380        let _ = std::fs::remove_file(&cert_path);
5381        let _ = std::fs::remove_file(&key_path);
5382        assert!(
5383            !std::ptr::eq(cert_client, inner_client),
5384            "client_for must return distinct clients for cert vs no-cert configs"
5385        );
5386    }
5387
5388    #[cfg(feature = "oauth-mtls-client")]
5389    #[test]
5390    fn client_for_falls_back_to_inner_when_cache_miss() {
5391        install_test_crypto_provider();
5392        let cfg = validation_https_config();
5393        let http = OauthHttpClient::with_config(&cfg).expect("build client");
5394        let unrelated_cc = ClientCertConfig {
5395            cert_path: PathBuf::from("/cache/miss/cert.pem"),
5396            key_path: PathBuf::from("/cache/miss/key.pem"),
5397        };
5398        let tx_unknown = tx_with(None, Some(unrelated_cc));
5399        let fallback = http.client_for(&tx_unknown);
5400        let inner = http.client_for(&tx_with(Some("s"), None));
5401        assert!(
5402            std::ptr::eq(fallback, inner),
5403            "cache miss must fall back to inner client"
5404        );
5405    }
5406}