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