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