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