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