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    fmt,
19    path::PathBuf,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, Ordering},
23    },
24    time::{Duration, Instant},
25};
26
27use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
28use serde::Deserialize;
29use tokio::{net::lookup_host, sync::RwLock};
30use tracing::Instrument;
31
32use crate::auth::{AuthIdentity, AuthMethod};
33
34// ---------------------------------------------------------------------------
35// Shared OAuth redirect-policy helper
36// ---------------------------------------------------------------------------
37
38/// Outcome of evaluating a single OAuth redirect hop against the
39/// shared policy used by both [`OauthHttpClient::build`] and
40/// [`JwksCache::new`].
41///
42/// `Ok(())` means the redirect should be followed; `Err(reason)` means
43/// the closure should reject it. Callers are responsible for emitting
44/// the `tracing::warn!` rejection log so the policy stays a pure
45/// function (no I/O, no logging) and so the closures keep their
46/// cognitive complexity below the crate-wide clippy threshold.
47///
48/// The policy mirrors the documented behaviour exactly:
49///   1. `https -> http` redirect downgrades are *always* rejected.
50///   2. Non-`https` targets are accepted only when `allow_http` is true
51///      *and* the destination scheme is `http`.
52///   3. Targets resolving to disallowed IP ranges (private / loopback /
53///      link-local / multicast / broadcast / unspecified /
54///      cloud-metadata) are rejected via
55///      [`crate::ssrf::redirect_target_reason_with_allowlist`], which
56///      consults the operator-supplied allowlist while keeping
57///      cloud-metadata addresses unbypassable.
58///   4. The hop count is capped at 2 (i.e. at most 2 prior redirects).
59fn evaluate_oauth_redirect(
60    attempt: &reqwest::redirect::Attempt<'_>,
61    allow_http: bool,
62    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
63) -> Result<(), String> {
64    let prev_https = attempt
65        .previous()
66        .last()
67        .is_some_and(|prev| prev.scheme() == "https");
68    let target_url = attempt.url();
69    let dest_scheme = target_url.scheme();
70    if dest_scheme != "https" {
71        if prev_https {
72            return Err("redirect downgrades https -> http".to_owned());
73        }
74        if !allow_http || dest_scheme != "http" {
75            return Err("redirect to non-HTTP(S) URL refused".to_owned());
76        }
77    }
78    if let Some(reason) = crate::ssrf::redirect_target_reason_with_allowlist(target_url, allowlist)
79    {
80        return Err(format!("redirect target forbidden: {reason}"));
81    }
82    if attempt.previous().len() >= 2 {
83        return Err("too many redirects (max 2)".to_owned());
84    }
85    Ok(())
86}
87
88/// True when `host` ends in a well-known internal suffix (`.localhost`,
89/// `.local`, `.internal`) and is not exactly allow-listed. A trailing
90/// FQDN-root dot is canonicalized first so `idp.internal.` cannot bypass
91/// the check. OAuth targets only -- CRL fetches build an empty allowlist
92/// and are out of scope.
93///
94/// Exact `localhost` is deliberately NOT matched here: it resolves to
95/// loopback and is already blocked by the post-DNS IP screen, and an
96/// operator may legitimately reach a local IdP via an explicit loopback
97/// CIDR allowlist.
98#[allow(
99    clippy::case_sensitive_file_extension_comparisons,
100    reason = "these are DNS-name suffixes on an already-lowercased host, not file extensions"
101)]
102fn oauth_internal_suffix_blocked(
103    host: &str,
104    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
105) -> bool {
106    let host_canon = host.strip_suffix('.').unwrap_or(host);
107    let host_lower = host_canon.to_ascii_lowercase();
108    let is_internal = host_lower.ends_with(".localhost")
109        || host_lower.ends_with(".local")
110        || host_lower.ends_with(".internal");
111    // Blocked when internal, unless the exact host is in a non-empty allowlist.
112    is_internal && (allowlist.is_empty() || !allowlist.host_allowed(host_canon))
113}
114
115/// Screen an OAuth/JWKS target before the initial outbound connect.
116///
117/// This complements the per-redirect-hop guard in
118/// [`evaluate_oauth_redirect`]: redirects are screened synchronously via
119/// [`crate::ssrf::redirect_target_reason_with_allowlist`], while the
120/// initial request target is screened here after DNS resolution so
121/// hostnames resolving to loopback/private/link-local/metadata space
122/// are rejected before any TCP dial occurs.
123///
124/// **Cloud-metadata addresses (IPv4 `169.254.169.254`, Alibaba/Tencent
125/// `100.100.100.200`, AWS IPv6 `fd00:ec2::254`, GCP IPv6
126/// `fd20:ce::254`) are blocked unconditionally** -- the operator
127/// allowlist cannot re-allow them.
128///
129/// This single core is compiled identically under ALL cfgs, so the test
130/// suite always exercises the exact code production runs. Production
131/// callers go through [`screen_oauth_target`], which hardcodes
132/// `test_allow_loopback_ssrf = false`; the test-only bypass wrapper is
133/// [`screen_oauth_target_with_test_override`].
134// cancel-safe: performs DNS resolution and pure screening, publishing no
135// shared state; cancellation just discards the verdict.
136async fn screen_oauth_target_core(
137    url: &str,
138    allow_http: bool,
139    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
140    test_allow_loopback_ssrf: bool,
141) -> Result<(), crate::error::RmcpServerKitError> {
142    let target = oauth_request_target_for_log(url);
143    let parsed = check_oauth_url("oauth target", url, allow_http)?;
144    if test_allow_loopback_ssrf {
145        return Ok(());
146    }
147    if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
148        return Err(crate::error::RmcpServerKitError::Config(format!(
149            "OAuth target forbidden ({reason}): {target}"
150        )));
151    }
152
153    let host = parsed.host_str().ok_or_else(|| {
154        crate::error::RmcpServerKitError::Config(format!("OAuth target URL has no host: {target}"))
155    })?;
156    if oauth_internal_suffix_blocked(host, allowlist) {
157        return Err(crate::error::RmcpServerKitError::Config(format!(
158            "OAuth target forbidden (internal hostname suffix): {target}"
159        )));
160    }
161    let port = parsed.port_or_known_default().ok_or_else(|| {
162        crate::error::RmcpServerKitError::Config(format!(
163            "OAuth target URL has no known port: {target}"
164        ))
165    })?;
166
167    let addrs = lookup_host((host, port)).await.map_err(|error| {
168        crate::error::RmcpServerKitError::Config(format!(
169            "OAuth target DNS resolution {target}: {error}"
170        ))
171    })?;
172
173    let host_allowed = !allowlist.is_empty() && allowlist.host_allowed(host);
174    let mut any_addr = false;
175    for addr in addrs {
176        any_addr = true;
177        let ip = addr.ip();
178        if let Some(reason) = crate::ssrf::ip_block_reason(ip) {
179            // Cloud-metadata is unbypassable. Use the strict message
180            // that does NOT advertise the allowlist knob.
181            if reason == "cloud_metadata" {
182                return Err(crate::error::RmcpServerKitError::Config(format!(
183                    "OAuth target resolved to blocked IP ({reason}): {target}"
184                )));
185            }
186            // Default-empty-allowlist path: preserve the historical
187            // message verbatim so existing tests continue to pass and
188            // operators get the same diagnostic they had before.
189            if allowlist.is_empty() {
190                return Err(crate::error::RmcpServerKitError::Config(format!(
191                    "OAuth target resolved to blocked IP ({reason}): {target}"
192                )));
193            }
194            // Allowlist-configured path: consult host + per-IP allowlist.
195            if host_allowed || allowlist.ip_allowed(ip) {
196                continue;
197            }
198            return Err(crate::error::RmcpServerKitError::Config(format!(
199                "OAuth target blocked: hostname {host} resolved to {ip} ({reason}). \
200                 To allow, add the hostname to oauth.ssrf_allowlist.hosts or the CIDR \
201                 to oauth.ssrf_allowlist.cidrs (operators only -- see SECURITY.md). \
202                 URL: {target}"
203            )));
204        }
205    }
206    if !any_addr {
207        return Err(crate::error::RmcpServerKitError::Config(format!(
208            "OAuth target DNS resolution returned no addresses: {target}"
209        )));
210    }
211
212    Ok(())
213}
214
215/// Production entry point for OAuth/JWKS target screening. Delegates to
216/// [`screen_oauth_target_core`] with the loopback bypass hardcoded off.
217async fn screen_oauth_target(
218    url: &str,
219    allow_http: bool,
220    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
221) -> Result<(), crate::error::RmcpServerKitError> {
222    screen_oauth_target_core(url, allow_http, allowlist, false).await
223}
224
225/// Test-only wrapper exposing the loopback-SSRF bypass flag of
226/// [`screen_oauth_target_core`] so higher-level OAuth flows can run
227/// against loopback-backed mock fixtures.
228#[cfg(any(test, feature = "test-helpers"))]
229async fn screen_oauth_target_with_test_override(
230    url: &str,
231    allow_http: bool,
232    allowlist: &crate::ssrf::CompiledSsrfAllowlist,
233    test_allow_loopback_ssrf: bool,
234) -> Result<(), crate::error::RmcpServerKitError> {
235    screen_oauth_target_core(url, allow_http, allowlist, test_allow_loopback_ssrf).await
236}
237
238// ---------------------------------------------------------------------------
239// HTTP client wrapper
240// ---------------------------------------------------------------------------
241
242/// HTTP client used by [`exchange_token`] and the OAuth 2.1 proxy
243/// handlers ([`handle_token`], [`handle_introspect`], [`handle_revoke`]).
244///
245/// Wraps an internal HTTP backend so callers do not depend on the
246/// concrete crate. Construct one per process and reuse across requests
247/// (the underlying connection pool is shared internally via
248/// [`Clone`] - cheap, refcounted).
249///
250/// **Hardening (since 1.2.1).** When constructed via [`with_config`]
251/// (preferred), the internal client refuses any redirect that downgrades
252/// the scheme from `https` to `http`, even when the original request URL
253/// was HTTPS. This closes a class of metadata-poisoning attacks where a
254/// hostile or compromised upstream `IdP` returns `302 Location: http://...`
255/// and the resulting plaintext hop is intercepted by a network-positioned
256/// attacker to siphon bearer tokens, refresh tokens, or introspection
257/// traffic. When the caller has set [`OAuthConfig::allow_http_oauth_urls`]
258/// to `true` (development only), HTTP-to-HTTP redirects are still permitted
259/// but HTTPS-to-HTTP downgrades are *always* rejected.
260///
261/// [`with_config`] also honours [`OAuthConfig::ca_cert_path`] (if set) and
262/// adds the supplied PEM CA bundle to the system roots so that
263/// every OAuth-bound HTTP request -- not just the JWKS fetch -- can
264/// trust enterprise/internal certificate authorities. This restores
265/// the behaviour that existed pre-`0.10.0` before the `OauthHttpClient`
266/// wrapper landed.
267///
268/// The legacy [`new`](Self::new) constructor (no-arg) is preserved for
269/// source compatibility but is `#[deprecated]`: it returns a client with
270/// system-roots-only TLS trust and the strictest redirect policy
271/// (HTTPS-only, never permits plain HTTP). Migrate to
272/// [`with_config`](Self::with_config) at the earliest opportunity so
273/// that token / introspection / revocation / exchange traffic inherits
274/// the same CA trust and `allow_http_oauth_urls` toggle as the JWKS
275/// fetch client.
276///
277/// [`with_config`]: Self::with_config
278#[derive(Clone)]
279pub struct OauthHttpClient {
280    /// Screened-redirect JWKS/discovery client: follows redirects, but every
281    /// hop passes `evaluate_oauth_redirect`. Post-M7 production credential
282    /// traffic uses `credential_client` and JWKS fetching uses `JwksCache`,
283    /// so nothing in a production build reads this field; it exists only to
284    /// back the redirect-policy regression tests (`__test_get`,
285    /// `__test_inner_client`, `jwks_get_still_follows_screened_redirect`),
286    /// which are themselves `cfg`-gated to the same predicate.
287    #[cfg(any(test, feature = "test-helpers"))]
288    inner: reqwest::Client,
289    /// M7: dedicated client for credential-bearing POSTs (token /
290    /// introspection / revocation / RFC 8693 exchange). Built with
291    /// `redirect::Policy::none()` so a 307/308 from a compromised or
292    /// open-redirecting endpoint cannot re-send the `client_secret`
293    /// body to another host. Shares `inner`'s `no_proxy`,
294    /// `SsrfScreeningResolver`, and CA trust.
295    credential_client: reqwest::Client,
296    allow_http: bool,
297    /// Compiled SSRF allowlist applied to the initial-target screen and
298    /// to literal-IP redirect-hop screening. Wrapped in `Arc` so cloning
299    /// the client (which is cheap and refcounted) does not deep-copy
300    /// the parsed CIDR / host vectors.
301    allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
302    /// M-H4: per-`(cert_path, key_path)` cache of cert-bearing
303    /// `reqwest::Client`s. Built eagerly with `redirect::Policy::none()`
304    /// so an attacker-controlled 3xx cannot re-present the client cert
305    /// to a different host (RFC 8705 §2 attack surface).
306    #[cfg(feature = "oauth-mtls-client")]
307    mtls_clients: Arc<HashMap<MtlsClientKey, reqwest::Client>>,
308    /// M-H2: shared loopback bypass observed by both `send_screened`'s
309    /// pre-flight check AND the `SsrfScreeningResolver` installed on
310    /// `inner`. Flipping the bit via `__test_allow_loopback_ssrf` must
311    /// reach the already-built `reqwest::Client`, so a per-snapshot
312    /// `bool` (Oracle review B1) is forbidden.
313    #[cfg(any(test, feature = "test-helpers"))]
314    test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
315}
316
317/// M-H4: cache key for cert-bearing `reqwest::Client`s. Path-based
318/// (not contents-based) -- in-place cert rotation is not picked up
319/// without restart (documented limitation in `CHANGELOG.md` 1.6.0).
320#[cfg(feature = "oauth-mtls-client")]
321#[derive(Debug, Clone, Hash, Eq, PartialEq)]
322struct MtlsClientKey {
323    cert_path: PathBuf,
324    key_path: PathBuf,
325}
326
327impl OauthHttpClient {
328    /// Build a client from the OAuth configuration (preferred since 1.2.1).
329    ///
330    /// Defaults: `connect_timeout = 10s`, total `timeout = 30s`,
331    /// scheme-downgrade-rejecting redirect policy (max 2 hops),
332    /// optional custom CA trust via [`OAuthConfig::ca_cert_path`],
333    /// and HTTP-to-HTTP redirects gated by
334    /// [`OAuthConfig::allow_http_oauth_urls`] (dev-only).
335    ///
336    /// Pass the same `&OAuthConfig` you supplied to
337    /// [`JwksCache::new`] / `serve()` so the OAuth-bound HTTP traffic
338    /// inherits identical CA trust and HTTPS-only redirect policy.
339    ///
340    /// # Errors
341    ///
342    /// Returns [`crate::error::RmcpServerKitError::Startup`] if the configured
343    /// `ca_cert_path` cannot be read or parsed, or if the underlying
344    /// HTTP client cannot be constructed (e.g. TLS backend init failure).
345    pub fn with_config(config: &OAuthConfig) -> Result<Self, crate::error::RmcpServerKitError> {
346        Self::build(Some(config))
347    }
348
349    /// Build a client with default settings (system CA roots only,
350    /// strict HTTPS-only redirect policy).
351    ///
352    /// **Deprecated since 1.2.1.** This constructor cannot honour
353    /// [`OAuthConfig::ca_cert_path`] (so token / introspection /
354    /// revocation / exchange traffic falls back to the system trust
355    /// store, breaking enterprise PKI deployments) and ignores the
356    /// [`OAuthConfig::allow_http_oauth_urls`] dev-mode toggle (so
357    /// HTTP-to-HTTP redirects are unconditionally refused). Both of
358    /// these are bugs that the new [`with_config`](Self::with_config)
359    /// constructor fixes.
360    ///
361    /// The redirect policy still rejects `https -> http` downgrades,
362    /// matching the security posture of [`with_config`](Self::with_config).
363    ///
364    /// Migrate to [`with_config`](Self::with_config) and pass the same
365    /// `&OAuthConfig` your `serve()` call uses.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`crate::error::RmcpServerKitError::Startup`] if the underlying
370    /// HTTP client cannot be constructed (e.g. TLS backend init failure).
371    #[deprecated(
372        since = "1.2.1",
373        note = "use OauthHttpClient::with_config(&OAuthConfig) so token/introspect/revoke/exchange traffic inherits ca_cert_path and the allow_http_oauth_urls toggle"
374    )]
375    pub fn new() -> Result<Self, crate::error::RmcpServerKitError> {
376        Self::build(None)
377    }
378
379    /// Internal builder shared by [`new`](Self::new) (config = `None`)
380    /// and [`with_config`](Self::with_config) (config = `Some`).
381    fn build(config: Option<&OAuthConfig>) -> Result<Self, crate::error::RmcpServerKitError> {
382        // Install the rustls crypto provider before constructing any reqwest
383        // client (idempotent -- `ok()` ignores the error when a provider was
384        // already installed elsewhere in the process). Without this a
385        // standalone `OauthHttpClient::new`/`with_config` built before
386        // `JwksCache::new` or TLS setup would panic inside reqwest with
387        // "no rustls crypto provider is configured".
388        rustls::crypto::ring::default_provider()
389            .install_default()
390            .ok();
391
392        let allow_http = config.is_some_and(|c| c.allow_http_oauth_urls);
393
394        // Compile the operator SSRF allowlist (if any) up front. Surface
395        // CIDR / host parse errors as Startup so misconfiguration fails
396        // fast at server boot, mirroring how OAuthConfig::validate
397        // surfaces them as Config errors.
398        let allowlist = match config.and_then(|c| c.ssrf_allowlist.as_ref()) {
399            Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
400                crate::error::RmcpServerKitError::Startup(format!("oauth http client: {e}"))
401            })?),
402            None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
403        };
404
405        // Clone an Arc into the redirect closure so the policy can
406        // consult the operator allowlist without re-parsing. Only the
407        // screened-redirect `inner` client needs it, so it shares that
408        // client's cfg gate.
409        #[cfg(any(test, feature = "test-helpers"))]
410        let redirect_allowlist = Arc::clone(&allowlist);
411
412        // M-H2: shared bypass holder created BEFORE the resolver so
413        // the resolver, send_screened, and the cached `inner` client
414        // all observe the same atomic.
415        #[cfg(any(test, feature = "test-helpers"))]
416        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
417            Arc::new(AtomicBool::new(false));
418        #[cfg(not(any(test, feature = "test-helpers")))]
419        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
420
421        // M-H2/B1: TestLoopbackBypass aliases to Arc<AtomicBool> in test
422        // builds and to `()` in production. The `.clone()` is required in
423        // test builds; in production the alias is a unit, which is why the
424        // unit-value lints are allowed alongside the Arc one.
425        #[allow(
426            clippy::clone_on_ref_ptr,
427            clippy::clone_on_copy,
428            clippy::unit_arg,
429            reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
430        )]
431        let resolver: Arc<dyn reqwest::dns::Resolve> =
432            Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
433                Arc::clone(&allowlist),
434                test_bypass.clone(),
435            ));
436
437        // Read the optional CA bundle once; reused by both clients below.
438        // Pre-startup blocking I/O is intentional -- the constructor is sync
439        // by contract and runs from `serve()`'s pre-startup phase.
440        let ca_pem: Option<Vec<u8>> = if let Some(cfg) = config
441            && let Some(ref ca_path) = cfg.ca_cert_path
442        {
443            Some(std::fs::read(ca_path).map_err(|e| {
444                crate::error::RmcpServerKitError::Startup(format!(
445                    "oauth http client: read ca_cert_path {}: {e}",
446                    ca_path.display()
447                ))
448            })?)
449        } else {
450            None
451        };
452
453        // Base builder shared by both clients: `no_proxy` (so HTTP(S)_PROXY
454        // env vars cannot bypass the SsrfScreeningResolver), the SSRF
455        // resolver, timeouts, and CA trust. Only the redirect policy differs.
456        let make_base = || -> Result<reqwest::ClientBuilder, crate::error::RmcpServerKitError> {
457            let mut b = reqwest::Client::builder()
458                .no_proxy()
459                .dns_resolver(Arc::clone(&resolver))
460                .connect_timeout(Duration::from_secs(10))
461                .timeout(Duration::from_secs(30));
462            if let Some(ref pem) = ca_pem {
463                let cert = reqwest::tls::Certificate::from_pem(pem).map_err(|e| {
464                    crate::error::RmcpServerKitError::Startup(format!(
465                        "oauth http client: parse ca_cert_path: {e}"
466                    ))
467                })?;
468                b = b.add_root_certificate(cert);
469            }
470            Ok(b)
471        };
472
473        // JWKS / discovery client: follows redirects, but every hop is screened
474        // by `evaluate_oauth_redirect` (https->http downgrade, literal-IP
475        // target, and userinfo are all rejected). Production reads JWKS via
476        // `JwksCache` and credentials via `credential_client`, so this client
477        // backs only the redirect-policy regression tests and is not built in
478        // a minimal `oauth` build.
479        #[cfg(any(test, feature = "test-helpers"))]
480        let inner =
481            make_base()?
482                .redirect(reqwest::redirect::Policy::custom(move |attempt| {
483                    match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
484                        Ok(()) => attempt.follow(),
485                        Err(reason) => {
486                            tracing::warn!(
487                                reason = %reason,
488                                target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
489                                "oauth redirect rejected"
490                            );
491                            attempt.error(reason)
492                        }
493                    }
494                }))
495                .build()
496                .map_err(|e| {
497                    crate::error::RmcpServerKitError::Startup(format!(
498                        "oauth http client init: {e}"
499                    ))
500                })?;
501
502        // M7: credential-POST client -- NEVER follows redirects. A 307/308 from
503        // a compromised or open-redirecting token/introspection/revocation
504        // endpoint must not re-send the `client_secret`-bearing body to another
505        // host (RFC 8705 §2). Mirrors the `Policy::none()` mTLS cert clients.
506        //
507        // Shares the "oauth http client init" error label with the gated
508        // `inner` build above: both consume the same `make_base()` config, so
509        // a `ClientBuilder::build()` failure is a shared TLS-backend fault
510        // rather than a property of either client. Using one label keeps the
511        // operator-visible startup error identical whether or not `inner` is
512        // compiled in. Genuine misconfiguration (allowlist, ca_cert_path read
513        // and parse) is already reported by `make_base()` itself.
514        let credential_client = make_base()?
515            .redirect(reqwest::redirect::Policy::none())
516            .build()
517            .map_err(|e| {
518                crate::error::RmcpServerKitError::Startup(format!("oauth http client init: {e}"))
519            })?;
520
521        #[cfg(feature = "oauth-mtls-client")]
522        let mtls_clients = build_mtls_clients(config, &allowlist, &test_bypass)?;
523
524        Ok(Self {
525            #[cfg(any(test, feature = "test-helpers"))]
526            inner,
527            credential_client,
528            allow_http,
529            allowlist,
530            #[cfg(feature = "oauth-mtls-client")]
531            mtls_clients,
532            #[cfg(any(test, feature = "test-helpers"))]
533            test_allow_loopback_ssrf: test_bypass,
534        })
535    }
536
537    // cancel-safe: SSRF screening only reads allowlist/config; `reqwest` owns
538    // the request during `send`, so cancellation abandons upstream I/O without
539    // mutating OAuth client or JWKS cache state.
540    async fn send_screened(
541        &self,
542        url: &str,
543        request: reqwest::RequestBuilder,
544    ) -> Result<reqwest::Response, crate::error::RmcpServerKitError> {
545        #[cfg(any(test, feature = "test-helpers"))]
546        if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
547            screen_oauth_target_with_test_override(url, self.allow_http, &self.allowlist, true)
548                .await?;
549        } else {
550            screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
551        }
552        #[cfg(not(any(test, feature = "test-helpers")))]
553        screen_oauth_target(url, self.allow_http, &self.allowlist).await?;
554        request.send().await.map_err(|error| {
555            let target = oauth_request_target_for_log(url);
556            let error = error.without_url();
557            crate::error::RmcpServerKitError::Config(format!("oauth request {target}: {error}"))
558        })
559    }
560
561    /// Test-only: disable initial-target SSRF screening for loopback-backed
562    /// fixtures. This is unreachable from normal production builds and exists
563    /// only so tests can exercise higher-level OAuth flows against local mock
564    /// servers.
565    ///
566    /// # ⚠️ Security
567    ///
568    /// Disables the OAuth SSRF guard's loopback rejection, allowing requests to
569    /// loopback-backed targets that production OAuth screening would reject.
570    #[cfg(any(test, feature = "test-helpers"))]
571    #[doc(hidden)]
572    #[must_use]
573    pub fn __test_allow_loopback_ssrf(self) -> Self {
574        // M-H2/B1: flip the SHARED atomic so the resolver inside
575        // `inner` and the pre-flight check both observe the bypass.
576        self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
577        self
578    }
579
580    /// Test-only: issue a `GET` against an arbitrary URL using the
581    /// configured client (redirect policy, CA trust, timeouts all
582    /// applied). Used by integration tests to exercise the redirect-
583    /// downgrade and CA-trust regressions without going through
584    /// `exchange_token`. Not part of the public API.
585    ///
586    /// # ⚠️ Security
587    ///
588    /// Calls `self.inner.get(url).send()` directly, bypassing `send_screened`
589    /// and its initial-target SSRF and scheme checks for caller-supplied URLs.
590    #[cfg(any(test, feature = "test-helpers"))]
591    #[doc(hidden)]
592    pub async fn __test_get(&self, url: &str) -> reqwest::Result<reqwest::Response> {
593        self.inner.get(url).send().await
594    }
595
596    /// Test-only: borrow the inner `reqwest::Client` so the M-H2
597    /// env-proxy matrix test (`tests/e2e.rs::ssrf_no_proxy_*`) can
598    /// drive `.get(...).send()` directly and observe whether the
599    /// SsrfScreeningResolver fired (vs. the proxy short-circuiting
600    /// the request). Not part of the public API.
601    ///
602    /// # ⚠️ Security
603    ///
604    /// Exposes the raw `reqwest::Client`, enabling callers to bypass
605    /// `send_screened` and its initial-target SSRF and scheme checks.
606    #[cfg(any(test, feature = "test-helpers"))]
607    #[doc(hidden)]
608    #[must_use]
609    pub fn __test_inner_client(&self) -> &reqwest::Client {
610        &self.inner
611    }
612
613    /// M-H4: select the cert-bearing `reqwest::Client` cached for
614    /// `cfg.client_cert`'s paths, else the shared no-redirect
615    /// `credential_client`. Defence-in-depth: a missing cache entry falls
616    /// through to `credential_client`; combined with the Authorization-header
617    /// skip in `exchange_token`, this surfaces as an upstream auth failure
618    /// rather than silent secret-bearer fallback.
619    #[cfg(feature = "oauth-mtls-client")]
620    fn client_for(&self, cfg: &TokenExchangeConfig) -> &reqwest::Client {
621        if let Some(cc) = &cfg.client_cert {
622            let key = MtlsClientKey {
623                cert_path: cc.cert_path.clone(),
624                key_path: cc.key_path.clone(),
625            };
626            if let Some(client) = self.mtls_clients.get(&key) {
627                return client;
628            }
629        }
630        &self.credential_client
631    }
632
633    #[cfg(not(feature = "oauth-mtls-client"))]
634    fn client_for(&self, _cfg: &TokenExchangeConfig) -> &reqwest::Client {
635        &self.credential_client
636    }
637}
638
639impl fmt::Debug for OauthHttpClient {
640    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
641        f.debug_struct("OauthHttpClient").finish_non_exhaustive()
642    }
643}
644
645fn oauth_request_target_for_log(raw: &str) -> String {
646    url::Url::parse(raw).map_or_else(
647        |_| "<unparseable-url>".to_owned(),
648        |url| crate::ssrf::sanitized_url_for_log(&url),
649    )
650}
651
652// ---------------------------------------------------------------------------
653// Configuration
654// ---------------------------------------------------------------------------
655
656/// Operator-trusted SSRF allowlist for OAuth/JWKS targets that resolve
657/// to addresses normally blocked by the post-DNS SSRF guard.
658///
659/// **Default: empty.** With both fields empty (or this struct unset),
660/// the existing fail-closed behavior is unchanged: any OAuth/JWKS URL
661/// resolving to RFC 1918, loopback, link-local, CGNAT, multicast,
662/// broadcast, unspecified, IPv6 unique-local / link-local / multicast,
663/// documentation, benchmarking, or reserved ranges is rejected before
664/// connect.
665///
666/// **Cloud-metadata addresses remain unbypassable** -- operators
667/// cannot opt in to metadata-service exposure. This carve-out covers:
668///
669/// - IPv4 `169.254.169.254` (AWS / GCP / Azure).
670/// - IPv4 `100.100.100.200` (Alibaba Cloud / Tencent Cloud).
671/// - IPv6 `fd00:ec2::254` (AWS IMDSv2 over IPv6).
672/// - IPv6 `fd20:ce::254` (GCP).
673///
674/// See `SECURITY.md` § "Operator allowlist".
675///
676/// Both lists are evaluated additively: a target is allowed if its
677/// hostname is in [`hosts`](Self::hosts) **or** every resolved IP for
678/// the target falls within at least one CIDR in [`cidrs`](Self::cidrs).
679///
680/// The allowlist applies to all six configured OAuth URL fields
681/// ([`OAuthConfig::issuer`], [`OAuthConfig::jwks_uri`],
682/// [`OAuthProxyConfig::authorize_url`], [`OAuthProxyConfig::token_url`],
683/// [`OAuthProxyConfig::introspection_url`],
684/// [`OAuthProxyConfig::revocation_url`],
685/// [`TokenExchangeConfig::token_url`]) and to the per-redirect-hop
686/// SSRF guard when a redirect target is a literal IP in a configured
687/// CIDR.
688///
689/// Entries are validated at startup: literal IPs in `hosts`, non-zero
690/// host bits in `cidrs`, malformed CIDRs, and entries containing
691/// ports / userinfo / paths are all rejected by
692/// [`OAuthConfig::validate`].
693///
694/// # Example
695///
696/// ```no_run
697/// use rmcp_server_kit::oauth::{OAuthConfig, OAuthSsrfAllowlist};
698///
699/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
700/// let mut allowlist = OAuthSsrfAllowlist::default();
701/// allowlist.hosts.push("rhbk.ops.example.com".into());
702/// allowlist.cidrs.push("10.0.0.0/8".into());
703/// let cfg = OAuthConfig::builder(
704///     "https://rhbk.ops.example.com/realms/ops",
705///     "mcp",
706///     "https://rhbk.ops.example.com/realms/ops/protocol/openid-connect/certs",
707/// )
708/// .ssrf_allowlist(allowlist)
709/// .build();
710/// cfg.validate()?;
711/// # Ok(())
712/// # }
713/// ```
714#[derive(Debug, Clone, Default, Deserialize)]
715#[serde(deny_unknown_fields)]
716#[non_exhaustive]
717pub struct OAuthSsrfAllowlist {
718    /// Hostnames allowed to resolve into otherwise-blocked address
719    /// ranges. Exact match, case-insensitive, no wildcards. Each entry
720    /// must be a bare DNS hostname: no scheme, no port, no userinfo,
721    /// not a literal IP.
722    #[serde(default)]
723    pub hosts: Vec<String>,
724    /// CIDR blocks whose addresses are considered trusted even when
725    /// the address would otherwise be blocked. Accepts both IPv4
726    /// (e.g. `10.0.0.0/8`) and IPv6 (e.g. `fd00::/8`).
727    ///
728    /// Cloud-metadata addresses inside any listed range remain blocked.
729    #[serde(default)]
730    pub cidrs: Vec<String>,
731}
732
733/// Compile and validate an operator allowlist into the runtime form.
734///
735/// Lowercases hostnames, rejects literal-IP and ill-formed host
736/// entries, parses + validates each CIDR (see [`crate::ssrf::CidrEntry::parse`]).
737/// Returns a `String` error suitable for embedding in
738/// [`crate::error::RmcpServerKitError::Config`] / [`crate::error::RmcpServerKitError::Startup`].
739fn compile_oauth_ssrf_allowlist(
740    raw: &OAuthSsrfAllowlist,
741) -> Result<crate::ssrf::CompiledSsrfAllowlist, String> {
742    let mut hosts: Vec<String> = Vec::with_capacity(raw.hosts.len());
743    for (idx, entry) in raw.hosts.iter().enumerate() {
744        let trimmed = entry.trim();
745        if trimmed.is_empty() {
746            return Err(format!("oauth.ssrf_allowlist.hosts[{idx}]: empty entry"));
747        }
748        // Reject embedded port / path / userinfo / query / fragment
749        // before reaching the URL parser, so the error is clearer than
750        // a generic "invalid host" diagnostic.
751        if trimmed.contains([':', '/', '@', '?', '#']) {
752            return Err(format!(
753                "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: must be a bare DNS hostname \
754                 (no scheme, port, path, userinfo, query, or fragment)"
755            ));
756        }
757        match url::Host::parse(trimmed) {
758            Ok(url::Host::Domain(_)) => {}
759            Ok(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
760                return Err(format!(
761                    "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: literal IPs are forbidden \
762                     here -- list them via oauth.ssrf_allowlist.cidrs instead"
763                ));
764            }
765            Err(e) => {
766                return Err(format!(
767                    "oauth.ssrf_allowlist.hosts[{idx}] = {trimmed:?}: invalid hostname: {e}"
768                ));
769            }
770        }
771        hosts.push(trimmed.to_ascii_lowercase());
772    }
773    hosts.sort();
774    hosts.dedup();
775
776    let mut cidrs = Vec::with_capacity(raw.cidrs.len());
777    for (idx, entry) in raw.cidrs.iter().enumerate() {
778        let parsed = crate::ssrf::CidrEntry::parse(entry)
779            .map_err(|e| format!("oauth.ssrf_allowlist.cidrs[{idx}]: {e}"))?;
780        cidrs.push(parsed);
781    }
782
783    Ok(crate::ssrf::CompiledSsrfAllowlist::new(hosts, cidrs))
784}
785
786/// OAuth 2.1 JWT configuration.
787#[derive(Debug, Clone, Deserialize)]
788#[serde(deny_unknown_fields)]
789#[non_exhaustive]
790pub struct OAuthConfig {
791    /// Token issuer (`iss` claim). Must match exactly.
792    ///
793    /// `#[serde(default)]` so a partially-specified `[oauth]` table — one that
794    /// carries only `role_claim`/`role_mappings`, with the URL and audience
795    /// fields supplied by a downstream env-override layer applied after TOML
796    /// parsing — still deserializes. An empty value is rejected at
797    /// [`OAuthConfig::validate`] time (parse-don't-validate): the HTTPS URL
798    /// check fails on an empty string.
799    #[serde(default)]
800    pub issuer: String,
801    /// Expected audience (`aud` claim). Must match exactly.
802    ///
803    /// Defaulted like [`OAuthConfig::issuer`]. Unlike the URL fields it is not
804    /// a URL, so [`OAuthConfig::validate`] guards it with an explicit
805    /// non-empty check.
806    #[serde(default)]
807    pub audience: String,
808    /// JWKS endpoint URL (e.g. `https://auth.example.com/.well-known/jwks.json`).
809    ///
810    /// Defaulted like [`OAuthConfig::issuer`]; an empty value is rejected by
811    /// the HTTPS URL check in [`OAuthConfig::validate`].
812    #[serde(default)]
813    pub jwks_uri: String,
814    /// Scope-to-role mappings. First matching scope wins.
815    /// Used when `role_claim` is absent (default behavior).
816    #[serde(default)]
817    pub scopes: Vec<ScopeMapping>,
818    /// JWT claim path to extract roles from (dot-notation for nested claims).
819    ///
820    /// Examples: `"scope"` (default), `"roles"`, `"realm_access.roles"`.
821    /// When set, the claim value is matched against `role_mappings` instead
822    /// of `scopes`. Supports both space-separated strings and JSON arrays.
823    pub role_claim: Option<String>,
824    /// Claim-value-to-role mappings. Used when `role_claim` is set.
825    /// First matching value wins.
826    #[serde(default)]
827    pub role_mappings: Vec<RoleMapping>,
828    /// How long to cache JWKS keys before re-fetching.
829    /// Parsed as a humantime duration (e.g. "10m", "1h"). Default: "10m".
830    #[serde(default = "default_jwks_cache_ttl")]
831    pub jwks_cache_ttl: String,
832    /// OAuth proxy configuration.  When set, the server exposes
833    /// `/authorize`, `/token`, and `/register` endpoints that proxy
834    /// to the upstream identity provider (e.g. Keycloak).
835    pub proxy: Option<OAuthProxyConfig>,
836    /// Token exchange configuration (RFC 8693).  When set, the server
837    /// can exchange an inbound MCP-scoped access token for a downstream
838    /// API-scoped access token via the authorization server's token
839    /// endpoint.
840    pub token_exchange: Option<TokenExchangeConfig>,
841    /// Optional path to a PEM CA bundle for OAuth-bound HTTP traffic.
842    /// Added to the system/built-in roots, not a replacement.
843    ///
844    /// **Scope (since 1.2.1).** When the [`OauthHttpClient`] is
845    /// constructed via [`OauthHttpClient::with_config`] (preferred),
846    /// this CA bundle is honoured by *every* OAuth-bound HTTP
847    /// request: the JWKS key fetch, token exchange, introspection,
848    /// revocation, and the OAuth proxy handlers. Application crates
849    /// may auto-populate this from their own configuration (e.g. an
850    /// upstream-API CA path); any application-owned HTTP clients
851    /// outside the kit must still configure their own CA trust
852    /// separately. The deprecated [`OauthHttpClient::new`] no-arg
853    /// constructor cannot honour this field -- migrate to
854    /// [`OauthHttpClient::with_config`] for full coverage.
855    #[serde(default)]
856    pub ca_cert_path: Option<PathBuf>,
857    /// Allow plain-HTTP (non-TLS) URLs for OAuth endpoints (`jwks_uri`,
858    /// `proxy.authorize_url`, `proxy.token_url`, `proxy.introspection_url`,
859    /// `proxy.revocation_url`, `token_exchange.token_url`).
860    ///
861    /// **Default: `false`.** Strongly discouraged in production: a
862    /// network-positioned attacker can MITM JWKS responses and substitute
863    /// signing keys (forging arbitrary tokens), or MITM the token / proxy
864    /// endpoints to steal credentials and codes. Enable only for
865    /// development against a local `IdP` without TLS, ideally bound to
866    /// `127.0.0.1`.
867    ///
868    /// Redirect handling when this flag is `true`: an HTTPS → HTTP
869    /// *downgrade* is always rejected, but an HTTP → HTTP redirect is
870    /// permitted (the target must still pass SSRF screening). When the flag
871    /// is `false`, every non-HTTPS redirect target is rejected.
872    #[serde(default)]
873    pub allow_http_oauth_urls: bool,
874    /// Operator-trusted SSRF allowlist for OAuth/JWKS targets.
875    ///
876    /// **Default: `None`** (fail-closed; current behavior preserved).
877    /// When set, the listed hostnames and CIDR blocks may resolve into
878    /// otherwise-blocked address ranges (RFC 1918, loopback, link-local,
879    /// CGNAT, IPv6 unique-local, ...). **Cloud-metadata addresses
880    /// remain unbypassable regardless of this setting** -- see
881    /// [`OAuthSsrfAllowlist`] and `SECURITY.md` § "Operator allowlist".
882    #[serde(default)]
883    pub ssrf_allowlist: Option<OAuthSsrfAllowlist>,
884    /// Maximum number of keys accepted from a JWKS refresh response.
885    /// Requests returning more keys than this are rejected fail-closed
886    /// (cache remains empty / unchanged). Default: 256.
887    #[serde(default = "default_max_jwks_keys")]
888    pub max_jwks_keys: usize,
889    /// Optional allowlist of accepted JWT signing algorithms.
890    ///
891    /// **Default `None`**, which accepts the crate's built-in set:
892    /// `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `PS256`, `PS384`,
893    /// `PS512`, `EdDSA`.
894    ///
895    /// When set, it must be a non-empty **subset** of that built-in set;
896    /// anything else fails [`OAuthConfig::validate`]. Names are matched
897    /// case-insensitively. This knob can only ever NARROW the accepted
898    /// algorithms -- it cannot re-enable `HS*` or `none`, so an operator
899    /// cannot use it to open an algorithm-confusion hole.
900    ///
901    /// Use it to pin a deployment to exactly what its identity provider
902    /// signs with, e.g. `["RS256"]` for Microsoft Entra v2.0.
903    #[serde(default)]
904    pub allowed_algorithms: Option<Vec<String>>,
905    /// Authorization servers advertised in RFC 9728 Protected Resource
906    /// Metadata.
907    ///
908    /// **Default `None` = resolved from topology**, which is the RFC-correct
909    /// answer in both directions:
910    ///
911    /// - [`OAuthConfig::proxy`] configured -> this server's public URL. The
912    ///   proxy really does mount `/authorize`, `/token`, `/register`, and
913    ///   `/.well-known/oauth-authorization-server`.
914    /// - no proxy -> the upstream [`OAuthConfig::issuer`]. This process mounts
915    ///   no authorization-server endpoints, so advertising itself would send
916    ///   RFC 9728 discovery to a URL that returns 404.
917    ///
918    /// **Set this explicitly if your application mounts its own `/authorize`
919    /// and `/token` through `McpServerConfig::with_extra_router` without
920    /// configuring [`OAuthConfig::proxy`]** — that server *is* the
921    /// authorization server, and the crate cannot detect it. Set it to the
922    /// server's public URL.
923    ///
924    /// `Some(vec![])` omits `authorization_servers` from the document
925    /// entirely, per RFC 9728 3.2 (zero-valued claims must be omitted).
926    #[serde(default)]
927    pub authorization_servers: Option<Vec<String>>,
928    /// `issuer` published in the RFC 8414 Authorization Server Metadata
929    /// document served by the built-in proxy.
930    ///
931    /// **Default `None` = this server's own public URL**, which is what
932    /// RFC 8414 3.3 requires: the published `issuer` MUST be identical to the
933    /// identifier the metadata URL was built from, and this document is served
934    /// from the local origin. RFC 8414 6.2 additionally requires *clients* to
935    /// reject a mismatch, so the previous behaviour (publishing the upstream
936    /// issuer) was rejected outright by conformant clients.
937    ///
938    /// **Legacy opt-out.** Set this to your upstream
939    /// [`OAuthConfig::issuer`] to restore the pre-3.8 value. The one case that
940    /// needs it: an upstream `IdP` that emits RFC 9207 `iss` in the
941    /// authorization response *and* clients that validate it. The proxy does
942    /// not own the front channel — `/authorize` redirects to the upstream,
943    /// which redirects straight back to the client's `redirect_uri` without
944    /// passing through this process — so it cannot reconcile a local `issuer`
945    /// with an upstream-stamped `iss`.
946    ///
947    /// Token validation is unaffected either way: inbound JWT `iss` claims are
948    /// always checked against [`OAuthConfig::issuer`].
949    #[serde(default)]
950    pub authorization_server_metadata_issuer: Option<String>,
951    /// Require the JWT `sub` (subject) claim. **Default: `false`** (current
952    /// behavior). When `true`, a token without `sub` is rejected. Leave
953    /// `false` for OAuth client-credentials / machine-to-machine tokens,
954    /// which legitimately carry no subject.
955    #[serde(default)]
956    pub require_subject: bool,
957    /// Enforce strict audience validation using only the JWT `aud` claim.
958    ///
959    /// **Deprecated since 1.7.0.** Use [`OAuthConfig::audience_validation_mode`]
960    /// instead. Consulted only when [`OAuthConfig::audience_validation_mode`]
961    /// is `None`: `Some(true)` resolves to [`AudienceValidationMode::Strict`],
962    /// `Some(false)` resolves to [`AudienceValidationMode::Warn`], and `None`
963    /// (the default) resolves to [`AudienceValidationMode::Strict`] — the
964    /// secure default that rejects `azp`-only audience matches.
965    #[serde(default)]
966    #[deprecated(
967        since = "1.7.0",
968        note = "use `audience_validation_mode` instead; this field is consulted only when `audience_validation_mode` is None"
969    )]
970    pub strict_audience_validation: Option<bool>,
971    /// How the resource server treats `azp` when validating JWT audience.
972    ///
973    /// When `None` (default), resolution falls back to the deprecated
974    /// [`OAuthConfig::strict_audience_validation`] flag: `Some(true)` ⇒
975    /// [`AudienceValidationMode::Strict`], `Some(false)` ⇒
976    /// [`AudienceValidationMode::Warn`], and `None` ⇒
977    /// [`AudienceValidationMode::Strict`] (the secure default).
978    /// Set this field explicitly to make the policy unambiguous.
979    #[serde(default)]
980    pub audience_validation_mode: Option<AudienceValidationMode>,
981    /// Maximum size of a JWKS HTTP response body in bytes.
982    /// Responses exceeding this cap are refused and logged; the cache
983    /// remains empty / unchanged. Default: 1 MiB.
984    #[serde(default = "default_jwks_max_bytes")]
985    pub jwks_max_response_bytes: u64,
986}
987
988fn default_jwks_cache_ttl() -> String {
989    "10m".into()
990}
991
992const fn default_max_jwks_keys() -> usize {
993    256
994}
995
996const fn default_jwks_max_bytes() -> u64 {
997    1024 * 1024
998}
999
1000/// How the resource server treats `azp` when validating JWT audience.
1001///
1002/// **Background.** RFC 9068 §4 + OIDC Core §2 establish `aud` as the
1003/// authoritative resource-server claim and `azp` as the authorized-party
1004/// (client) claim. Some OAuth deployments — typically when the MCP server
1005/// acts as both OAuth client *and* resource server (the documented
1006/// [`OAuthProxyConfig`] topology) — issue tokens where the configured
1007/// audience appears only in `azp`. This enum lets operators decide
1008/// whether that historic compatibility fallback is honored, surfaced via
1009/// a one-shot warning, or refused.
1010///
1011/// **Default**: [`AudienceValidationMode::Strict`] — rejects `azp`-only
1012/// matches so a token whose configured audience appears only in `azp`
1013/// is refused. To keep the previous `azp`-accepting behavior, set
1014/// `audience_validation_mode = "warn"` (one-shot warning per process) or
1015/// `"permissive"` (silent).
1016#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1017#[serde(rename_all = "snake_case")]
1018#[non_exhaustive]
1019pub enum AudienceValidationMode {
1020    /// Accept `aud` matches and `azp`-only matches silently. Pre-1.7
1021    /// behavior. Use only when the IdP cannot be reconfigured to
1022    /// populate `aud`.
1023    Permissive,
1024    /// Accept `aud` matches silently. Accept `azp`-only matches with a
1025    /// one-shot `tracing::warn!` per process. Reject neither.
1026    Warn,
1027    /// Accept only `aud` matches. Reject `azp`-only matches as audience
1028    /// mismatch. **Default** — recommended for new deployments and any
1029    /// IdP that can be configured to populate `aud` reliably.
1030    #[default]
1031    Strict,
1032}
1033
1034impl AudienceValidationMode {
1035    /// Stable lower-case label for logs and diagnostics.
1036    ///
1037    /// Used so structured log fields render as a plain token
1038    /// (e.g. `mode="warn"`) rather than the `Debug` form.
1039    #[must_use]
1040    pub(crate) const fn as_str(self) -> &'static str {
1041        match self {
1042            Self::Permissive => "permissive",
1043            Self::Warn => "warn",
1044            Self::Strict => "strict",
1045        }
1046    }
1047}
1048
1049impl Default for OAuthConfig {
1050    fn default() -> Self {
1051        Self {
1052            issuer: String::new(),
1053            audience: String::new(),
1054            jwks_uri: String::new(),
1055            scopes: Vec::new(),
1056            role_claim: None,
1057            role_mappings: Vec::new(),
1058            jwks_cache_ttl: default_jwks_cache_ttl(),
1059            proxy: None,
1060            token_exchange: None,
1061            ca_cert_path: None,
1062            allow_http_oauth_urls: false,
1063            max_jwks_keys: default_max_jwks_keys(),
1064            allowed_algorithms: None,
1065            authorization_servers: None,
1066            authorization_server_metadata_issuer: None,
1067            require_subject: false,
1068            #[allow(
1069                deprecated,
1070                reason = "default-construct deprecated field for backward compat"
1071            )]
1072            strict_audience_validation: None,
1073            audience_validation_mode: None,
1074            jwks_max_response_bytes: default_jwks_max_bytes(),
1075            ssrf_allowlist: None,
1076        }
1077    }
1078}
1079
1080impl OAuthConfig {
1081    /// Resolve the effective audience-validation policy.
1082    ///
1083    /// Precedence: explicit `audience_validation_mode` overrides the
1084    /// legacy `strict_audience_validation` flag. When neither is set,
1085    /// the default is [`AudienceValidationMode::Strict`] (secure default;
1086    /// `azp`-only matches are rejected).
1087    #[must_use]
1088    pub fn effective_audience_validation_mode(&self) -> AudienceValidationMode {
1089        if let Some(mode) = self.audience_validation_mode {
1090            return mode;
1091        }
1092        #[allow(deprecated, reason = "intentional: legacy flag resolution path")]
1093        match self.strict_audience_validation {
1094            Some(true) | None => AudienceValidationMode::Strict,
1095            Some(false) => AudienceValidationMode::Warn,
1096        }
1097    }
1098
1099    /// Start building an [`OAuthConfig`] with the three required fields.
1100    ///
1101    /// All other fields default to the same values as
1102    /// [`OAuthConfig::default`] (empty scopes/role mappings, no proxy or
1103    /// token exchange, a JWKS cache TTL of `10m`).
1104    pub fn builder(
1105        issuer: impl Into<String>,
1106        audience: impl Into<String>,
1107        jwks_uri: impl Into<String>,
1108    ) -> OAuthConfigBuilder {
1109        OAuthConfigBuilder {
1110            inner: Self {
1111                issuer: issuer.into(),
1112                audience: audience.into(),
1113                jwks_uri: jwks_uri.into(),
1114                ..Self::default()
1115            },
1116        }
1117    }
1118
1119    /// Validate the URL fields against the HTTPS-only policy.
1120    ///
1121    /// Each of `jwks_uri`, `proxy.authorize_url`, `proxy.token_url`,
1122    /// `proxy.introspection_url`, `proxy.revocation_url`, and
1123    /// `token_exchange.token_url` is parsed and its scheme checked.
1124    ///
1125    /// Schemes other than `https` are rejected unless
1126    /// [`OAuthConfig::allow_http_oauth_urls`] is `true`, in which case
1127    /// `http` is also permitted (parse failures and other schemes are
1128    /// always rejected).
1129    ///
1130    /// # Errors
1131    ///
1132    /// Returns [`crate::error::RmcpServerKitError::Config`] when any field fails
1133    /// to parse or violates the scheme policy.
1134    pub fn validate(&self) -> Result<(), crate::error::RmcpServerKitError> {
1135        validate_oauth_capacity_knobs(self)?;
1136        resolve_allowed_algorithms(self.allowed_algorithms.as_ref())?;
1137
1138        let allow_http = self.allow_http_oauth_urls;
1139        let url = check_oauth_url("oauth.issuer", &self.issuer, allow_http)?;
1140        if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1141            return Err(crate::error::RmcpServerKitError::Config(format!(
1142                "oauth.issuer forbidden ({reason})"
1143            )));
1144        }
1145        let url = check_oauth_url("oauth.jwks_uri", &self.jwks_uri, allow_http)?;
1146        if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1147            return Err(crate::error::RmcpServerKitError::Config(format!(
1148                "oauth.jwks_uri forbidden ({reason})"
1149            )));
1150        }
1151        self.validate_discovery_metadata_urls(allow_http)?;
1152        // `audience` is not a URL, so the `check_oauth_url` calls above do not
1153        // cover it. Guard it explicitly: with `#[serde(default)]` an omitted
1154        // audience is an empty string that would otherwise pass validation and
1155        // then fail-closed silently at runtime (Strict mode matches nothing).
1156        if self.audience.is_empty() {
1157            return Err(crate::error::RmcpServerKitError::Config(
1158                "oauth.audience must not be empty".into(),
1159            ));
1160        }
1161        if let Some(proxy) = &self.proxy {
1162            let url = check_oauth_url(
1163                "oauth.proxy.authorize_url",
1164                &proxy.authorize_url,
1165                allow_http,
1166            )?;
1167            if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1168                return Err(crate::error::RmcpServerKitError::Config(format!(
1169                    "oauth.proxy.authorize_url forbidden ({reason})"
1170                )));
1171            }
1172            let url = check_oauth_url("oauth.proxy.token_url", &proxy.token_url, allow_http)?;
1173            if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1174                return Err(crate::error::RmcpServerKitError::Config(format!(
1175                    "oauth.proxy.token_url forbidden ({reason})"
1176                )));
1177            }
1178            if let Some(url) = &proxy.introspection_url {
1179                let parsed = check_oauth_url("oauth.proxy.introspection_url", url, allow_http)?;
1180                if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1181                    return Err(crate::error::RmcpServerKitError::Config(format!(
1182                        "oauth.proxy.introspection_url forbidden ({reason})"
1183                    )));
1184                }
1185            }
1186            if let Some(url) = &proxy.revocation_url {
1187                let parsed = check_oauth_url("oauth.proxy.revocation_url", url, allow_http)?;
1188                if let Some(reason) = crate::ssrf::check_url_literal_ip(&parsed) {
1189                    return Err(crate::error::RmcpServerKitError::Config(format!(
1190                        "oauth.proxy.revocation_url forbidden ({reason})"
1191                    )));
1192                }
1193            }
1194            // M3: refuse to start with admin endpoints exposed but no
1195            // auth in front of them, unless the operator has explicitly
1196            // opted out via `allow_unauthenticated_admin_endpoints`. The
1197            // unauthenticated combination proxies arbitrary tokens to
1198            // the upstream IdP and is only safe behind an authenticated
1199            // reverse proxy / ingress.
1200            if proxy.expose_admin_endpoints
1201                && !proxy.require_auth_on_admin_endpoints
1202                && !proxy.allow_unauthenticated_admin_endpoints
1203            {
1204                return Err(crate::error::RmcpServerKitError::Config(
1205                    "oauth.proxy: expose_admin_endpoints = true requires \
1206                     require_auth_on_admin_endpoints = true (recommended) \
1207                     or allow_unauthenticated_admin_endpoints = true \
1208                     (explicit opt-out, only safe behind an authenticated \
1209                     reverse proxy)"
1210                        .into(),
1211                ));
1212            }
1213        }
1214        if let Some(tx) = &self.token_exchange {
1215            let url = check_oauth_url("oauth.token_exchange.token_url", &tx.token_url, allow_http)?;
1216            if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1217                return Err(crate::error::RmcpServerKitError::Config(format!(
1218                    "oauth.token_exchange.token_url forbidden ({reason})"
1219                )));
1220            }
1221            // M-H4: enforce RFC 8705 §2 mutual exclusion + feature gate
1222            // for token-exchange client authentication. See helper.
1223            validate_token_exchange_client_auth(tx)?;
1224            validate_token_exchange_optional_params(tx)?;
1225        }
1226        // Compile the operator allowlist (if any) at config-validate
1227        // time so misconfiguration is rejected up-front, before any
1228        // outbound HTTP client is ever built.
1229        if let Some(raw) = &self.ssrf_allowlist {
1230            let compiled = compile_oauth_ssrf_allowlist(raw).map_err(|e| {
1231                crate::error::RmcpServerKitError::Config(format!("oauth.ssrf_allowlist: {e}"))
1232            })?;
1233            if !compiled.is_empty() {
1234                tracing::warn!(
1235                    host_count = compiled.host_count(),
1236                    cidr_count = compiled.cidr_count(),
1237                    "oauth.ssrf_allowlist is configured: private/loopback OAuth/JWKS targets \
1238                     are now reachable. Cloud-metadata addresses remain blocked. \
1239                     See SECURITY.md \"Operator allowlist\"."
1240                );
1241            }
1242        }
1243        // Validate jwks_cache_ttl parses as a humantime duration so the
1244        // limiter constructor can rely on a non-fallback value (M5).
1245        humantime::parse_duration(&self.jwks_cache_ttl).map_err(|e| {
1246            crate::error::RmcpServerKitError::Config(format!(
1247                "oauth.jwks_cache_ttl {:?} is not a valid humantime duration (e.g. \"10m\", \"1h30m\"): {e}",
1248                self.jwks_cache_ttl
1249            ))
1250        })?;
1251        Ok(())
1252    }
1253
1254    /// Validate the URLs published by the discovery endpoints.
1255    ///
1256    /// SECURITY: `authorization_server_metadata_issuer` and
1257    /// `authorization_servers[]` are reflected verbatim by the unauthenticated
1258    /// `/.well-known/oauth-*` endpoints, so an unvalidated value is disclosed
1259    /// to any caller. They are held to the same policy as every other OAuth
1260    /// URL: parseable, no userinfo, scheme honouring `allow_http_oauth_urls`,
1261    /// and no literal-IP target.
1262    fn validate_discovery_metadata_urls(
1263        &self,
1264        allow_http: bool,
1265    ) -> Result<(), crate::error::RmcpServerKitError> {
1266        if let Some(ref issuer) = self.authorization_server_metadata_issuer {
1267            let url = check_oauth_url(
1268                "oauth.authorization_server_metadata_issuer",
1269                issuer,
1270                allow_http,
1271            )?;
1272            if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1273                return Err(crate::error::RmcpServerKitError::Config(format!(
1274                    "oauth.authorization_server_metadata_issuer forbidden ({reason})"
1275                )));
1276            }
1277        }
1278        // An empty vec is meaningful (it omits the claim entirely) and is
1279        // preserved here by iterating zero times.
1280        if let Some(ref servers) = self.authorization_servers {
1281            for (index, server) in servers.iter().enumerate() {
1282                let field = format!("oauth.authorization_servers[{index}]");
1283                let url = check_oauth_url(&field, server, allow_http)?;
1284                if let Some(reason) = crate::ssrf::check_url_literal_ip(&url) {
1285                    return Err(crate::error::RmcpServerKitError::Config(format!(
1286                        "{field} forbidden ({reason})"
1287                    )));
1288                }
1289            }
1290        }
1291        Ok(())
1292    }
1293}
1294
1295/// M-H4: enforce RFC 8705 §2 mutual exclusion (`client_secret` xor
1296/// `client_cert`) + cargo-feature gating for token-exchange client
1297/// authentication. Without this a `client_cert`-only config silently
1298/// disables client auth at the token endpoint (the runtime path
1299/// simply omits the Authorization header).
1300fn validate_token_exchange_client_auth(
1301    tx: &TokenExchangeConfig,
1302) -> Result<(), crate::error::RmcpServerKitError> {
1303    match (&tx.client_cert, tx.client_secret.is_some()) {
1304        (Some(_), true) => Err(crate::error::RmcpServerKitError::Config(
1305            "oauth.token_exchange: client_cert and client_secret are mutually \
1306             exclusive (RFC 8705 §2). Set exactly one."
1307                .into(),
1308        )),
1309        (None, false) => Err(crate::error::RmcpServerKitError::Config(
1310            "oauth.token_exchange: token exchange requires client authentication. \
1311             Set either client_secret (RFC 6749 §2.3.1) or client_cert (RFC 8705 §2)."
1312                .into(),
1313        )),
1314        (Some(cc), false) => validate_client_cert_config(cc),
1315        (None, true) => Ok(()),
1316    }
1317}
1318
1319/// Whether `c` is legal anywhere in an RFC 3986 URI.
1320///
1321/// A character-class gate, not a positional grammar check. It exists because
1322/// [`url::Url::parse`] implements the WHATWG URL Standard, not RFC 3986: it
1323/// silently trims surrounding spaces and C0 controls and percent-encodes
1324/// characters RFC 3986 forbids outright. Since `resource` is forwarded to the
1325/// authorization server verbatim, a value the RFC rejects must fail at startup
1326/// rather than be laundered into a different string.
1327fn is_rfc3986_uri_char(c: char) -> bool {
1328    matches!(
1329        c,
1330        'A'..='Z'
1331            | 'a'..='z'
1332            | '0'..='9'
1333            | '-' | '.' | '_' | '~'
1334            | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
1335            | ':' | '/' | '?' | '#' | '[' | ']' | '@'
1336            | '%'
1337    )
1338}
1339
1340/// Whether every `%` in `raw` begins a complete `%XX` triplet (RFC 3986 §2.1).
1341fn has_valid_pct_encoding(raw: &str) -> bool {
1342    let bytes = raw.as_bytes();
1343    let mut idx = 0;
1344    while let Some(byte) = bytes.get(idx) {
1345        if *byte == b'%' {
1346            let (Some(hi), Some(lo)) = (bytes.get(idx + 1), bytes.get(idx + 2)) else {
1347                return false;
1348            };
1349            if !hi.is_ascii_hexdigit() || !lo.is_ascii_hexdigit() {
1350                return false;
1351            }
1352            idx += 3;
1353        } else {
1354            idx += 1;
1355        }
1356    }
1357    true
1358}
1359
1360/// Validate the RFC 8693 §2.1 OPTIONAL token-exchange parameters.
1361///
1362/// An empty value is rejected because it is a malformed request parameter,
1363/// semantically distinct from omission — omission is expressed by `None` (or
1364/// [`RequestedTokenType::Omit`]) and is what RFC 8693 §2.1 actually permits.
1365/// Sending `audience=` would otherwise reach the authorization server.
1366///
1367/// `resource` is additionally held to RFC 8707 §2, which requires an absolute
1368/// URI with no fragment; both are uppercase MUSTs.
1369fn validate_token_exchange_optional_params(
1370    tx: &TokenExchangeConfig,
1371) -> Result<(), crate::error::RmcpServerKitError> {
1372    fn empty_field(field: &str) -> crate::error::RmcpServerKitError {
1373        crate::error::RmcpServerKitError::Config(format!(
1374            "oauth.token_exchange.{field} must not be empty; omit the key entirely \
1375             to leave the RFC 8693 §2.1 parameter out of the request"
1376        ))
1377    }
1378
1379    if tx.audience.as_deref().is_some_and(str::is_empty) {
1380        return Err(empty_field("audience"));
1381    }
1382    if tx.scope.as_deref().is_some_and(str::is_empty) {
1383        return Err(empty_field("scope"));
1384    }
1385    if let RequestedTokenType::Custom(ref uri) = tx.requested_token_type {
1386        if uri.is_empty() {
1387            return Err(empty_field("requested_token_type"));
1388        }
1389        // A custom token type must be a URI (RFC 8693 §3), which is what makes
1390        // a typo such as "acess_token" a config error rather than a bare word
1391        // silently forwarded to the authorization server. Unlike `resource`
1392        // below, a fragment is NOT rejected: the no-fragment rule is
1393        // RFC 8707 §2's constraint on resource indicators, not a property of
1394        // RFC 8693 token-type identifiers.
1395        if !uri.chars().all(is_rfc3986_uri_char) || !has_valid_pct_encoding(uri) {
1396            return Err(crate::error::RmcpServerKitError::Config(
1397                "oauth.token_exchange.requested_token_type custom value must be an RFC 3986 \
1398                 absolute URI using valid URI characters and percent-encoding (RFC 8693 §3)"
1399                    .into(),
1400            ));
1401        }
1402        url::Url::parse(uri).map_err(|e| {
1403            crate::error::RmcpServerKitError::Config(format!(
1404                "oauth.token_exchange.requested_token_type custom value must be an absolute \
1405                 URI (RFC 8693 §3): {e}"
1406            ))
1407        })?;
1408    }
1409    if let Some(resource) = tx.resource.as_deref() {
1410        if resource.is_empty() {
1411            return Err(empty_field("resource"));
1412        }
1413        if !resource.chars().all(is_rfc3986_uri_char) || !has_valid_pct_encoding(resource) {
1414            return Err(crate::error::RmcpServerKitError::Config(
1415                "oauth.token_exchange.resource must be an RFC 3986 absolute URI using valid \
1416                 URI characters and percent-encoding (RFC 8707 §2)"
1417                    .into(),
1418            ));
1419        }
1420        let parsed = url::Url::parse(resource).map_err(|e| {
1421            crate::error::RmcpServerKitError::Config(format!(
1422                "oauth.token_exchange.resource must be an absolute URI (RFC 8707 §2): {e}"
1423            ))
1424        })?;
1425        if parsed.fragment().is_some() {
1426            return Err(crate::error::RmcpServerKitError::Config(
1427                "oauth.token_exchange.resource must not include a fragment component \
1428                 (RFC 8707 §2)"
1429                    .into(),
1430            ));
1431        }
1432    }
1433    Ok(())
1434}
1435
1436/// Validate a [`ClientCertConfig`] for RFC 8705 §2 mTLS client auth.
1437///
1438/// Without the `oauth-mtls-client` cargo feature this fails closed with
1439/// a [`crate::error::RmcpServerKitError::Config`] (M-H4: a `client_cert`-only
1440/// config previously silently disabled client authentication). With the
1441/// feature on, this performs the same PEM read + parse the runtime path
1442/// would do, so missing files / malformed PEM / mismatched key&cert /
1443/// encrypted (passphrase-protected) keys all surface at validate time
1444/// rather than at first token-exchange request.
1445///
1446/// The returned error message includes the file path; the underlying
1447/// IO / parse error stays in a `tracing::warn!` log line.
1448fn validate_client_cert_config(
1449    cc: &ClientCertConfig,
1450) -> Result<(), crate::error::RmcpServerKitError> {
1451    #[cfg(not(feature = "oauth-mtls-client"))]
1452    {
1453        let _ = cc;
1454        Err(crate::error::RmcpServerKitError::Config(
1455            "oauth.token_exchange.client_cert requires the `oauth-mtls-client` cargo feature; \
1456             rebuild rmcp-server-kit with --features oauth-mtls-client (or have your \
1457             application crate enable it via `rmcp-server-kit/oauth-mtls-client`), or remove \
1458             the field"
1459                .into(),
1460        ))
1461    }
1462    #[cfg(feature = "oauth-mtls-client")]
1463    {
1464        let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1465            tracing::warn!(error = %e, path = %cc.cert_path.display(), "client cert read failed");
1466            crate::error::RmcpServerKitError::Config(format!(
1467                "oauth.token_exchange.client_cert.cert_path unreadable: {}",
1468                cc.cert_path.display()
1469            ))
1470        })?;
1471        let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1472            tracing::warn!(error = %e, path = %cc.key_path.display(), "client cert key read failed");
1473            crate::error::RmcpServerKitError::Config(format!(
1474                "oauth.token_exchange.client_cert.key_path unreadable: {}",
1475                cc.key_path.display()
1476            ))
1477        })?;
1478        let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1479        combined.extend_from_slice(&cert_bytes);
1480        if !cert_bytes.ends_with(b"\n") {
1481            combined.push(b'\n');
1482        }
1483        combined.extend_from_slice(&key_bytes);
1484        let _identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1485            tracing::warn!(
1486                error = %e,
1487                cert_path = %cc.cert_path.display(),
1488                key_path = %cc.key_path.display(),
1489                "client cert PEM parse failed"
1490            );
1491            crate::error::RmcpServerKitError::Config(format!(
1492                "oauth.token_exchange.client_cert: PEM parse failed (cert={}, key={})",
1493                cc.cert_path.display(),
1494                cc.key_path.display()
1495            ))
1496        })?;
1497        Ok(())
1498    }
1499}
1500
1501/// M-H4: build the `(cert_path, key_path) -> reqwest::Client` cache
1502/// consulted by [`OauthHttpClient::client_for`]. Each cert-bearing
1503/// client uses `redirect::Policy::none()` (RFC 8705 §2: never present
1504/// the client cert to a redirect target the operator did not approve)
1505/// and inherits the same `ca_cert_path`, connect/total timeouts as
1506/// the shared `inner` client. Returns an empty map when no
1507/// `token_exchange.client_cert` is configured.
1508#[cfg(feature = "oauth-mtls-client")]
1509fn build_mtls_clients(
1510    config: Option<&OAuthConfig>,
1511    allowlist: &Arc<crate::ssrf::CompiledSsrfAllowlist>,
1512    test_bypass: &crate::ssrf_resolver::TestLoopbackBypass,
1513) -> Result<Arc<HashMap<MtlsClientKey, reqwest::Client>>, crate::error::RmcpServerKitError> {
1514    let mut map: HashMap<MtlsClientKey, reqwest::Client> = HashMap::new();
1515    let Some(cfg) = config else {
1516        return Ok(Arc::new(map));
1517    };
1518    let Some(tx) = &cfg.token_exchange else {
1519        return Ok(Arc::new(map));
1520    };
1521    let Some(cc) = &tx.client_cert else {
1522        return Ok(Arc::new(map));
1523    };
1524
1525    let cert_bytes = std::fs::read(&cc.cert_path).map_err(|e| {
1526        crate::error::RmcpServerKitError::Startup(format!(
1527            "oauth http client mTLS: read cert_path {}: {e}",
1528            cc.cert_path.display()
1529        ))
1530    })?;
1531    let key_bytes = std::fs::read(&cc.key_path).map_err(|e| {
1532        crate::error::RmcpServerKitError::Startup(format!(
1533            "oauth http client mTLS: read key_path {}: {e}",
1534            cc.key_path.display()
1535        ))
1536    })?;
1537    let mut combined = Vec::with_capacity(cert_bytes.len() + 1 + key_bytes.len());
1538    combined.extend_from_slice(&cert_bytes);
1539    if !cert_bytes.ends_with(b"\n") {
1540        combined.push(b'\n');
1541    }
1542    combined.extend_from_slice(&key_bytes);
1543    let identity = reqwest::Identity::from_pem(&combined).map_err(|e| {
1544        crate::error::RmcpServerKitError::Startup(format!(
1545            "oauth http client mTLS: PEM parse (cert={}, key={}): {e}",
1546            cc.cert_path.display(),
1547            cc.key_path.display()
1548        ))
1549    })?;
1550
1551    let resolver: Arc<dyn reqwest::dns::Resolve> =
1552        Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
1553            Arc::clone(allowlist),
1554            // M-H2/B1: TestLoopbackBypass aliases to Arc<AtomicBool> in test
1555            // builds and to `()` in production. We need a value clone here
1556            // (not Arc::clone) because the type vanishes outside test cfg;
1557            // the allow is justified by the feature-gated type alias.
1558            #[allow(clippy::clone_on_ref_ptr, reason = "type alias varies per feature")]
1559            test_bypass.clone(),
1560        ));
1561
1562    let mut builder = reqwest::Client::builder()
1563        // M-H2/N1: same proxy + DNS hardening as the shared client.
1564        .no_proxy()
1565        .dns_resolver(Arc::clone(&resolver))
1566        .connect_timeout(Duration::from_secs(10))
1567        .timeout(Duration::from_secs(30))
1568        .redirect(reqwest::redirect::Policy::none())
1569        .identity(identity);
1570
1571    if let Some(ref ca_path) = cfg.ca_cert_path {
1572        let pem = std::fs::read(ca_path).map_err(|e| {
1573            crate::error::RmcpServerKitError::Startup(format!(
1574                "oauth http client mTLS: read ca_cert_path {}: {e}",
1575                ca_path.display()
1576            ))
1577        })?;
1578        let cert = reqwest::tls::Certificate::from_pem(&pem).map_err(|e| {
1579            crate::error::RmcpServerKitError::Startup(format!(
1580                "oauth http client mTLS: parse ca_cert_path {}: {e}",
1581                ca_path.display()
1582            ))
1583        })?;
1584        builder = builder.add_root_certificate(cert);
1585    }
1586
1587    let client = builder.build().map_err(|e| {
1588        crate::error::RmcpServerKitError::Startup(format!("oauth http client mTLS init: {e}"))
1589    })?;
1590    map.insert(
1591        MtlsClientKey {
1592            cert_path: cc.cert_path.clone(),
1593            key_path: cc.key_path.clone(),
1594        },
1595        client,
1596    );
1597    Ok(Arc::new(map))
1598}
1599
1600/// Parse `raw` as a URL and enforce the HTTPS-only policy.
1601///
1602/// Returns `Ok(())` for `https://...`, and also for `http://...` when
1603/// `allow_http` is `true`. All other schemes (and parse failures) are
1604/// rejected with a [`crate::error::RmcpServerKitError::Config`] referencing the
1605/// caller-supplied `field` name for diagnostics.
1606fn check_oauth_url(
1607    field: &str,
1608    raw: &str,
1609    allow_http: bool,
1610) -> Result<url::Url, crate::error::RmcpServerKitError> {
1611    let parsed = url::Url::parse(raw).map_err(|e| {
1612        crate::error::RmcpServerKitError::Config(format!(
1613            "{field}: invalid URL <unparseable-url>: {e}"
1614        ))
1615    })?;
1616    if !parsed.username().is_empty() || parsed.password().is_some() {
1617        return Err(crate::error::RmcpServerKitError::Config(format!(
1618            "{field} rejected: URL contains userinfo (credentials in URL are forbidden)"
1619        )));
1620    }
1621    match parsed.scheme() {
1622        "https" => Ok(parsed),
1623        "http" if allow_http => Ok(parsed),
1624        "http" => Err(crate::error::RmcpServerKitError::Config(format!(
1625            "{field}: must use https scheme (got http; set allow_http_oauth_urls=true \
1626             to override - strongly discouraged in production)"
1627        ))),
1628        other => Err(crate::error::RmcpServerKitError::Config(format!(
1629            "{field}: must use https scheme (got {other:?})"
1630        ))),
1631    }
1632}
1633
1634fn validate_oauth_capacity_knobs(
1635    config: &OAuthConfig,
1636) -> Result<(), crate::error::RmcpServerKitError> {
1637    (config.max_jwks_keys != 0).ok_or_else(|| {
1638        crate::error::RmcpServerKitError::Config("oauth.max_jwks_keys must be nonzero".into())
1639    })?;
1640    (config.jwks_max_response_bytes != 0).ok_or_else(|| {
1641        crate::error::RmcpServerKitError::Config(
1642            "oauth.jwks_max_response_bytes must be nonzero".into(),
1643        )
1644    })?;
1645    Ok(())
1646}
1647
1648/// Builder for [`OAuthConfig`].
1649///
1650/// Obtain via [`OAuthConfig::builder`]. All setters consume `self` and
1651/// return a new builder, so they compose fluently. Call
1652/// [`OAuthConfigBuilder::build`] to produce the final [`OAuthConfig`].
1653#[derive(Debug, Clone)]
1654#[must_use = "builders do nothing until `.build()` is called"]
1655pub struct OAuthConfigBuilder {
1656    inner: OAuthConfig,
1657}
1658
1659impl OAuthConfigBuilder {
1660    /// Restrict the accepted JWT signing algorithms.
1661    ///
1662    /// Must be a non-empty subset of the built-in set; validated by
1663    /// [`OAuthConfig::validate`]. See
1664    /// [`OAuthConfig::allowed_algorithms`].
1665    pub fn allowed_algorithms(
1666        mut self,
1667        algorithms: impl IntoIterator<Item = impl Into<String>>,
1668    ) -> Self {
1669        self.inner.allowed_algorithms =
1670            Some(algorithms.into_iter().map(Into::into).collect::<Vec<_>>());
1671        self
1672    }
1673
1674    /// Publish a specific `issuer` in the proxy's RFC 8414 Authorization
1675    /// Server Metadata document.
1676    ///
1677    /// The default is already RFC 8414 3.3 conformant (this server's public
1678    /// URL). Use this only to restore the pre-3.8 upstream value — see the
1679    /// RFC 9207 caveat on
1680    /// [`OAuthConfig::authorization_server_metadata_issuer`].
1681    pub fn authorization_server_metadata_issuer(mut self, issuer: impl Into<String>) -> Self {
1682        self.inner.authorization_server_metadata_issuer = Some(issuer.into());
1683        self
1684    }
1685
1686    /// Override the authorization servers advertised in Protected Resource
1687    /// Metadata.
1688    ///
1689    /// Needed when the application mounts its own OAuth endpoints via
1690    /// `with_extra_router` instead of using [`OAuthConfig::proxy`]. Pass an
1691    /// empty iterator to omit the field. See
1692    /// [`OAuthConfig::authorization_servers`].
1693    pub fn authorization_servers(
1694        mut self,
1695        servers: impl IntoIterator<Item = impl Into<String>>,
1696    ) -> Self {
1697        self.inner.authorization_servers =
1698            Some(servers.into_iter().map(Into::into).collect::<Vec<_>>());
1699        self
1700    }
1701
1702    /// Replace the scope-to-role mappings.
1703    pub fn scopes(mut self, scopes: Vec<ScopeMapping>) -> Self {
1704        self.inner.scopes = scopes;
1705        self
1706    }
1707
1708    /// Append a single scope-to-role mapping.
1709    pub fn scope(mut self, scope: impl Into<String>, role: impl Into<String>) -> Self {
1710        self.inner.scopes.push(ScopeMapping {
1711            scope: scope.into(),
1712            role: role.into(),
1713        });
1714        self
1715    }
1716
1717    /// Set the JWT claim path used to extract roles directly (without
1718    /// going through `scope` mappings).
1719    pub fn role_claim(mut self, claim: impl Into<String>) -> Self {
1720        self.inner.role_claim = Some(claim.into());
1721        self
1722    }
1723
1724    /// Replace the claim-value-to-role mappings.
1725    pub fn role_mappings(mut self, mappings: Vec<RoleMapping>) -> Self {
1726        self.inner.role_mappings = mappings;
1727        self
1728    }
1729
1730    /// Append a single claim-value-to-role mapping (used with
1731    /// [`Self::role_claim`]).
1732    pub fn role_mapping(mut self, claim_value: impl Into<String>, role: impl Into<String>) -> Self {
1733        self.inner.role_mappings.push(RoleMapping {
1734            claim_value: claim_value.into(),
1735            role: role.into(),
1736        });
1737        self
1738    }
1739
1740    /// Override the JWKS cache TTL (humantime string, e.g. `"5m"`).
1741    /// Defaults to `"10m"`.
1742    pub fn jwks_cache_ttl(mut self, ttl: impl Into<String>) -> Self {
1743        self.inner.jwks_cache_ttl = ttl.into();
1744        self
1745    }
1746
1747    /// Attach an OAuth proxy configuration. When set, the server
1748    /// exposes `/authorize`, `/token`, and `/register` endpoints.
1749    pub fn proxy(mut self, proxy: OAuthProxyConfig) -> Self {
1750        self.inner.proxy = Some(proxy);
1751        self
1752    }
1753
1754    /// Attach an RFC 8693 token exchange configuration.
1755    pub fn token_exchange(mut self, token_exchange: TokenExchangeConfig) -> Self {
1756        self.inner.token_exchange = Some(token_exchange);
1757        self
1758    }
1759
1760    /// Provide a PEM CA bundle path used for all OAuth-bound HTTPS traffic
1761    /// originated by this crate (JWKS fetches and the optional OAuth proxy
1762    /// `/authorize`, `/token`, `/register`, `/introspect`, `/revoke`,
1763    /// `/.well-known/oauth-authorization-server` upstream calls).
1764    pub fn ca_cert_path(mut self, path: impl Into<PathBuf>) -> Self {
1765        self.inner.ca_cert_path = Some(path.into());
1766        self
1767    }
1768
1769    /// Allow plain-HTTP (non-TLS) URLs for OAuth endpoints.
1770    ///
1771    /// **Default: `false`.** See the field-level documentation on
1772    /// [`OAuthConfig::allow_http_oauth_urls`] for the security caveats
1773    /// before enabling this.
1774    pub const fn allow_http_oauth_urls(mut self, allow: bool) -> Self {
1775        self.inner.allow_http_oauth_urls = allow;
1776        self
1777    }
1778
1779    /// Toggle strict audience validation so only the JWT `aud` claim is
1780    /// considered and the compatibility fallback to `azp` is disabled.
1781    ///
1782    /// **Deprecated since 1.7.0.** Prefer
1783    /// [`OAuthConfigBuilder::audience_validation_mode`] for explicit
1784    /// three-state policy. This method clears
1785    /// `audience_validation_mode` so the legacy bool resolution path
1786    /// applies.
1787    #[deprecated(since = "1.7.0", note = "use `audience_validation_mode` instead")]
1788    pub const fn strict_audience_validation(mut self, strict: bool) -> Self {
1789        #[allow(
1790            deprecated,
1791            reason = "intentional: deprecated builder forwards to deprecated field"
1792        )]
1793        {
1794            self.inner.strict_audience_validation = Some(strict);
1795        }
1796        self.inner.audience_validation_mode = None;
1797        self
1798    }
1799
1800    /// Set the audience-validation policy explicitly.
1801    ///
1802    /// Takes precedence over the deprecated
1803    /// [`OAuthConfigBuilder::strict_audience_validation`] flag. See
1804    /// [`AudienceValidationMode`] for variant semantics. Defaults to
1805    /// [`AudienceValidationMode::Strict`] when neither this method nor the
1806    /// legacy flag is set.
1807    pub const fn audience_validation_mode(mut self, mode: AudienceValidationMode) -> Self {
1808        self.inner.audience_validation_mode = Some(mode);
1809        self
1810    }
1811
1812    /// Require the JWT `sub` (subject) claim (opt-in; default `false`).
1813    ///
1814    /// When `true`, a token without `sub` is rejected. Leave `false` for
1815    /// OAuth client-credentials / machine-to-machine tokens, which
1816    /// legitimately carry no subject.
1817    pub const fn require_subject(mut self, require: bool) -> Self {
1818        self.inner.require_subject = require;
1819        self
1820    }
1821
1822    /// Override the maximum JWKS response body size in bytes.
1823    pub const fn jwks_max_response_bytes(mut self, bytes: u64) -> Self {
1824        self.inner.jwks_max_response_bytes = bytes;
1825        self
1826    }
1827
1828    /// Set the operator SSRF allowlist for OAuth/JWKS targets.
1829    ///
1830    /// **Operator-only.** Use only when an in-cluster IdP (e.g. Keycloak)
1831    /// resolves to private/loopback address space and must be reached.
1832    /// Cloud-metadata addresses (AWS/GCP/Alibaba IPv4 + IPv6) remain
1833    /// blocked regardless of allowlist contents -- see
1834    /// [`OAuthSsrfAllowlist`] and `SECURITY.md`  "Operator allowlist".
1835    pub fn ssrf_allowlist(mut self, allowlist: OAuthSsrfAllowlist) -> Self {
1836        self.inner.ssrf_allowlist = Some(allowlist);
1837        self
1838    }
1839
1840    /// Finalise the builder and return the [`OAuthConfig`].
1841    #[must_use]
1842    pub fn build(self) -> OAuthConfig {
1843        self.inner
1844    }
1845}
1846
1847/// Maps an OAuth scope string to an RBAC role name.
1848#[derive(Debug, Clone, Deserialize)]
1849#[serde(deny_unknown_fields)]
1850#[non_exhaustive]
1851pub struct ScopeMapping {
1852    /// OAuth scope string to match against the token's `scope` claim.
1853    pub scope: String,
1854    /// RBAC role granted when the scope is present.
1855    pub role: String,
1856}
1857
1858/// Maps a JWT claim value to an RBAC role name.
1859/// Used with `OAuthConfig::role_claim` for non-scope-based role extraction
1860/// (e.g. Keycloak `realm_access.roles`, Azure AD `roles`).
1861#[derive(Debug, Clone, Deserialize)]
1862#[serde(deny_unknown_fields)]
1863#[non_exhaustive]
1864pub struct RoleMapping {
1865    /// Expected value of the configured role claim (e.g. `admin`).
1866    pub claim_value: String,
1867    /// RBAC role granted when `claim_value` is present in the claim.
1868    pub role: String,
1869}
1870
1871const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
1872
1873/// RFC 8693 §2.1 `requested_token_type` — an OPTIONAL request parameter.
1874///
1875/// The RFC states that when the requested type is unspecified, "the issued
1876/// token type is at the discretion of the authorization server". [`Self::Omit`]
1877/// expresses that, which is otherwise unreachable.
1878///
1879/// Deserialised from a plain TOML string: `"access_token"` and `"omit"` map to
1880/// the corresponding variants, and any other string becomes [`Self::Custom`].
1881/// A misspelling such as `"acess_token"` is therefore accepted as a custom
1882/// token-type URI and sent verbatim rather than rejected — unavoidable, since
1883/// RFC 8693 §3 permits arbitrary URIs here.
1884#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
1885#[serde(from = "String")]
1886#[non_exhaustive]
1887pub enum RequestedTokenType {
1888    /// Send `urn:ietf:params:oauth:token-type:access_token`.
1889    ///
1890    /// Default, preserving the behaviour of every release before 3.8.0, which
1891    /// always sent this value.
1892    #[default]
1893    AccessToken,
1894    /// Omit `requested_token_type`, letting the authorization server choose.
1895    Omit,
1896    /// Send a specific token-type URI (RFC 8693 §3).
1897    Custom(String),
1898}
1899
1900impl From<String> for RequestedTokenType {
1901    fn from(value: String) -> Self {
1902        match value.as_str() {
1903            "access_token" => Self::AccessToken,
1904            "omit" => Self::Omit,
1905            _ => Self::Custom(value),
1906        }
1907    }
1908}
1909
1910impl RequestedTokenType {
1911    /// The wire value, or `None` when the parameter must be omitted.
1912    fn wire_value(&self) -> Option<&str> {
1913        match *self {
1914            Self::AccessToken => Some(TOKEN_TYPE_ACCESS_TOKEN),
1915            Self::Omit => None,
1916            Self::Custom(ref uri) => Some(uri.as_str()),
1917        }
1918    }
1919}
1920
1921/// Configuration for RFC 8693 token exchange.
1922///
1923/// The MCP server uses this to exchange an inbound user access token
1924/// (audience = MCP server) for a downstream access token (audience =
1925/// the upstream API the application calls) via the authorization
1926/// server's token endpoint.
1927#[derive(Debug, Clone, Deserialize)]
1928#[serde(deny_unknown_fields)]
1929#[non_exhaustive]
1930pub struct TokenExchangeConfig {
1931    /// Authorization server token endpoint used for the exchange
1932    /// (e.g. `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token`).
1933    pub token_url: String,
1934    /// OAuth `client_id` of the MCP server (the requester).
1935    pub client_id: String,
1936    /// OAuth `client_secret` for confidential-client authentication
1937    /// (RFC 6749 §2.3.1 HTTP Basic). Mutually exclusive with
1938    /// `client_cert` -- [`OAuthConfig::validate`] rejects configs
1939    /// that set both, or neither.
1940    pub client_secret: Option<secrecy::SecretString>,
1941    /// Client certificate for RFC 8705 §2 mTLS client authentication.
1942    /// When set, the exchange request authenticates by presenting the
1943    /// configured cert at TLS handshake (no Authorization header is
1944    /// sent). Requires the `oauth-mtls-client` cargo feature; without
1945    /// it, [`OAuthConfig::validate`] fails closed.
1946    ///
1947    /// **Scope**: implements RFC 8705 §2 only (PKI-bound client
1948    /// auth). RFC 8705 §3 self-signed client auth and the
1949    /// `cnf.x5t#S256` certificate-bound access-token confirmation
1950    /// claim are NOT enforced; the issued access token behaves like a
1951    /// bearer token once minted. In-place certificate rotation is
1952    /// not picked up without restart.
1953    pub client_cert: Option<ClientCertConfig>,
1954    /// RFC 8693 §2.1 `audience` — OPTIONAL. The logical name of the
1955    /// downstream API (e.g. `upstream-api`); the exchanged token carries
1956    /// it in the `aud` claim. `None` omits the parameter.
1957    ///
1958    /// Distinct from [`OAuthConfig::audience`], which is the `aud` claim
1959    /// this server *expects* on inbound tokens.
1960    #[serde(default)]
1961    pub audience: Option<String>,
1962    /// RFC 8693 §2.1 `resource` — OPTIONAL. An RFC 8707 resource
1963    /// indicator: an absolute URI, without a fragment, naming the target
1964    /// service. `None` omits the parameter.
1965    ///
1966    /// Unrelated to `oauth.proxy.strip_resource_param`, which governs the
1967    /// OAuth *proxy* endpoints, not token exchange.
1968    #[serde(default)]
1969    pub resource: Option<String>,
1970    /// RFC 8693 §2.1 `scope` — OPTIONAL. Space-delimited scopes requested
1971    /// for the exchanged token. `None` omits the parameter.
1972    #[serde(default)]
1973    pub scope: Option<String>,
1974    /// RFC 8693 §2.1 `requested_token_type` — OPTIONAL.
1975    ///
1976    /// `#[serde(default)]` is load-bearing: without it, every existing
1977    /// `[server.auth.oauth.token_exchange]` table — none of which contain
1978    /// this key — would fail to parse.
1979    #[serde(default)]
1980    pub requested_token_type: RequestedTokenType,
1981}
1982
1983impl TokenExchangeConfig {
1984    /// Create a new token exchange configuration.
1985    ///
1986    /// The RFC 8693 OPTIONAL parameters (`audience`, `resource`, `scope`,
1987    /// `requested_token_type`) default to omitted and are set with the
1988    /// `with_*` methods.
1989    #[must_use]
1990    pub fn new(
1991        token_url: impl Into<String>,
1992        client_id: impl Into<String>,
1993        client_secret: Option<secrecy::SecretString>,
1994        client_cert: Option<ClientCertConfig>,
1995    ) -> Self {
1996        Self {
1997            token_url: token_url.into(),
1998            client_id: client_id.into(),
1999            client_secret,
2000            client_cert,
2001            audience: None,
2002            resource: None,
2003            scope: None,
2004            requested_token_type: RequestedTokenType::default(),
2005        }
2006    }
2007
2008    /// Set the RFC 8693 `audience` parameter.
2009    #[must_use]
2010    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
2011        self.audience = Some(audience.into());
2012        self
2013    }
2014
2015    /// Set the RFC 8693 / RFC 8707 `resource` parameter.
2016    #[must_use]
2017    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
2018        self.resource = Some(resource.into());
2019        self
2020    }
2021
2022    /// Set the RFC 8693 `scope` parameter.
2023    #[must_use]
2024    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
2025        self.scope = Some(scope.into());
2026        self
2027    }
2028
2029    /// Set the RFC 8693 `requested_token_type` parameter.
2030    #[must_use]
2031    pub fn with_requested_token_type(mut self, requested_token_type: RequestedTokenType) -> Self {
2032        self.requested_token_type = requested_token_type;
2033        self
2034    }
2035}
2036
2037/// Client certificate paths for RFC 8705 §2 mTLS client
2038/// authentication at the token exchange endpoint. Requires the
2039/// `oauth-mtls-client` cargo feature.
2040#[derive(Debug, Clone, Deserialize)]
2041#[serde(deny_unknown_fields)]
2042#[non_exhaustive]
2043pub struct ClientCertConfig {
2044    /// Path to the PEM-encoded client certificate (X.509, single
2045    /// leaf or full chain). Read once at server startup.
2046    pub cert_path: PathBuf,
2047    /// Path to the PEM-encoded private key (PKCS#8 or RSA / EC).
2048    /// Encrypted (passphrase-protected) keys are NOT supported and
2049    /// fail closed at config validation.
2050    pub key_path: PathBuf,
2051}
2052
2053impl ClientCertConfig {
2054    /// Construct a `ClientCertConfig`. Required because the struct is
2055    /// `#[non_exhaustive]` and so cannot be built with a struct literal
2056    /// from outside the crate.
2057    #[must_use]
2058    pub fn new(cert_path: PathBuf, key_path: PathBuf) -> Self {
2059        Self {
2060            cert_path,
2061            key_path,
2062        }
2063    }
2064}
2065
2066/// Successful response from an RFC 8693 token exchange.
2067#[derive(Deserialize)]
2068#[non_exhaustive]
2069pub struct ExchangedToken {
2070    /// The newly issued access token.
2071    pub access_token: String,
2072    /// Token lifetime in seconds (if provided by the authorization server).
2073    pub expires_in: Option<u64>,
2074    /// Token type identifier (e.g.
2075    /// `urn:ietf:params:oauth:token-type:access_token`).
2076    pub issued_token_type: Option<String>,
2077}
2078
2079impl fmt::Debug for ExchangedToken {
2080    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2081        let Self {
2082            access_token,
2083            expires_in,
2084            issued_token_type,
2085        } = self;
2086        let access_token = if crate::diagnostics::plaintext_oauth_tokens() {
2087            access_token.as_str()
2088        } else {
2089            "[REDACTED]"
2090        };
2091        f.debug_struct("ExchangedToken")
2092            .field("access_token", &access_token)
2093            .field("expires_in", expires_in)
2094            .field("issued_token_type", issued_token_type)
2095            .finish()
2096    }
2097}
2098
2099/// Configuration for proxying OAuth 2.1 flows to an upstream identity provider.
2100///
2101/// When present, the MCP server exposes `/authorize`, `/token`, and
2102/// `/register` endpoints that proxy to the upstream identity provider
2103/// (e.g. Keycloak). MCP clients see this server as the authorization
2104/// server and perform a standard Authorization Code + PKCE flow.
2105#[derive(Debug, Clone, Deserialize, Default)]
2106#[serde(deny_unknown_fields)]
2107#[allow(
2108    clippy::struct_excessive_bools,
2109    reason = "flat TOML sub-table of independent operator toggles; collapsing them into an enum would break both the public API and the deserialized schema"
2110)]
2111#[non_exhaustive]
2112pub struct OAuthProxyConfig {
2113    /// Upstream authorization endpoint (e.g.
2114    /// `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth`).
2115    pub authorize_url: String,
2116    /// Upstream token endpoint (e.g.
2117    /// `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token`).
2118    pub token_url: String,
2119    /// OAuth `client_id` registered at the upstream identity provider.
2120    pub client_id: String,
2121    /// OAuth `client_secret` (for confidential clients). Omit for public clients.
2122    pub client_secret: Option<secrecy::SecretString>,
2123    /// Optional upstream RFC 7662 introspection endpoint. When set
2124    /// **and** [`Self::expose_admin_endpoints`] is `true`, the server
2125    /// exposes a local `/introspect` endpoint that proxies to it.
2126    #[serde(default)]
2127    pub introspection_url: Option<String>,
2128    /// Optional upstream RFC 7009 revocation endpoint. When set
2129    /// **and** [`Self::expose_admin_endpoints`] is `true`, the server
2130    /// exposes a local `/revoke` endpoint that proxies to it.
2131    #[serde(default)]
2132    pub revocation_url: Option<String>,
2133    /// Whether to expose the OAuth admin endpoints (`/introspect`,
2134    /// `/revoke`) and advertise them in the authorization-server
2135    /// metadata document.
2136    ///
2137    /// **Default: `false`.** These endpoints are unauthenticated at the
2138    /// transport layer (the OAuth proxy router is mounted outside the
2139    /// MCP auth middleware) and proxy directly to the upstream `IdP`. If
2140    /// enabled, you are responsible for restricting access at the
2141    /// network boundary (firewall, reverse proxy, mTLS) or by routing
2142    /// the entire rmcp-server-kit process behind an authenticated ingress. Leaving
2143    /// this `false` (the default) makes the endpoints return 404.
2144    #[serde(default)]
2145    pub expose_admin_endpoints: bool,
2146    /// Require the normal authentication middleware before the local
2147    /// `/introspect` and `/revoke` proxy endpoints are reached.
2148    ///
2149    /// **Default: `false` for backward compatibility.** New deployments
2150    /// should set this to `true` when exposing admin endpoints.
2151    #[serde(default)]
2152    pub require_auth_on_admin_endpoints: bool,
2153    /// Explicit operator opt-out for the M3 startup check that rejects
2154    /// `expose_admin_endpoints = true` combined with
2155    /// `require_auth_on_admin_endpoints = false`.
2156    ///
2157    /// **Default: `false`.** Setting this to `true` allows the unauth
2158    /// admin-endpoint combination to start, which is only safe when the
2159    /// rmcp-server-kit process sits behind an authenticated reverse
2160    /// proxy / ingress that screens `/introspect` and `/revoke` itself.
2161    /// Production deployments should leave this `false` and instead set
2162    /// `require_auth_on_admin_endpoints = true`.
2163    #[serde(default)]
2164    pub allow_unauthenticated_admin_endpoints: bool,
2165    /// Drop the RFC 8707 `resource` parameter from proxied `/authorize`
2166    /// and `/token` requests before forwarding them upstream.
2167    ///
2168    /// **Default: `false`**, which forwards the parameter unchanged and is
2169    /// the spec-preserving behaviour.
2170    ///
2171    /// Set this to `true` for Microsoft Entra ID (Azure AD) v2.0, which
2172    /// rejects a `resource` parameter carried alongside a differing
2173    /// `api://` scope with error `AADSTS9010010`. MCP clients send
2174    /// `resource` because the MCP specification requires it, so without
2175    /// this opt-out an Entra-backed proxy cannot complete an
2176    /// authorization-code flow.
2177    ///
2178    /// Only `resource` is ever dropped. Parameters that carry security
2179    /// meaning -- `state`, `code_challenge`, `code_challenge_method`,
2180    /// `code_verifier`, `redirect_uri`, `nonce`, `scope` -- are always
2181    /// forwarded, so enabling this cannot silently disable PKCE or CSRF
2182    /// protection. The upstream `/introspect` and `/revoke` proxy path is
2183    /// unaffected: `resource` is not a parameter of RFC 7662 or RFC 7009
2184    /// requests.
2185    #[serde(default)]
2186    pub strip_resource_param: bool,
2187}
2188
2189impl OAuthProxyConfig {
2190    /// Start building an [`OAuthProxyConfig`] with the three required
2191    /// upstream fields.
2192    ///
2193    /// Optional settings (`client_secret`, `introspection_url`,
2194    /// `revocation_url`, `expose_admin_endpoints`) default to their
2195    /// [`Default`] values and can be set via the corresponding builder
2196    /// methods.
2197    pub fn builder(
2198        authorize_url: impl Into<String>,
2199        token_url: impl Into<String>,
2200        client_id: impl Into<String>,
2201    ) -> OAuthProxyConfigBuilder {
2202        OAuthProxyConfigBuilder {
2203            inner: Self {
2204                authorize_url: authorize_url.into(),
2205                token_url: token_url.into(),
2206                client_id: client_id.into(),
2207                ..Self::default()
2208            },
2209        }
2210    }
2211}
2212
2213/// Builder for [`OAuthProxyConfig`].
2214///
2215/// Obtain via [`OAuthProxyConfig::builder`]. See the type-level docs on
2216/// [`OAuthProxyConfig`] and in particular the security caveats on
2217/// [`OAuthProxyConfig::expose_admin_endpoints`].
2218#[derive(Debug, Clone)]
2219#[must_use = "builders do nothing until `.build()` is called"]
2220pub struct OAuthProxyConfigBuilder {
2221    inner: OAuthProxyConfig,
2222}
2223
2224impl OAuthProxyConfigBuilder {
2225    /// Set the upstream OAuth client secret. Omit for public clients.
2226    pub fn client_secret(mut self, secret: secrecy::SecretString) -> Self {
2227        self.inner.client_secret = Some(secret);
2228        self
2229    }
2230
2231    /// Configure the upstream RFC 7662 introspection endpoint. Only
2232    /// advertised and reachable when
2233    /// [`Self::expose_admin_endpoints`] is also set to `true`.
2234    pub fn introspection_url(mut self, url: impl Into<String>) -> Self {
2235        self.inner.introspection_url = Some(url.into());
2236        self
2237    }
2238
2239    /// Configure the upstream RFC 7009 revocation endpoint. Only
2240    /// advertised and reachable when
2241    /// [`Self::expose_admin_endpoints`] is also set to `true`.
2242    pub fn revocation_url(mut self, url: impl Into<String>) -> Self {
2243        self.inner.revocation_url = Some(url.into());
2244        self
2245    }
2246
2247    /// Opt in to exposing the `/introspect` and `/revoke` admin
2248    /// endpoints and advertising them in the authorization-server
2249    /// metadata document.
2250    ///
2251    /// **Security:** see the field-level documentation on
2252    /// [`OAuthProxyConfig::expose_admin_endpoints`] for the caveats
2253    /// before enabling this.
2254    pub const fn expose_admin_endpoints(mut self, expose: bool) -> Self {
2255        self.inner.expose_admin_endpoints = expose;
2256        self
2257    }
2258
2259    /// Require the normal authentication middleware on `/introspect` and
2260    /// `/revoke`.
2261    pub const fn require_auth_on_admin_endpoints(mut self, require: bool) -> Self {
2262        self.inner.require_auth_on_admin_endpoints = require;
2263        self
2264    }
2265
2266    /// Explicit opt-out for the M3 startup check that rejects exposing
2267    /// `/introspect`/`/revoke` without authentication. See
2268    /// [`OAuthProxyConfig::allow_unauthenticated_admin_endpoints`].
2269    pub const fn allow_unauthenticated_admin_endpoints(mut self, allow: bool) -> Self {
2270        self.inner.allow_unauthenticated_admin_endpoints = allow;
2271        self
2272    }
2273
2274    /// Drop the RFC 8707 `resource` parameter when proxying `/authorize`
2275    /// and `/token` upstream. Required for Microsoft Entra v2.0
2276    /// (`AADSTS9010010`). See
2277    /// [`OAuthProxyConfig::strip_resource_param`].
2278    pub const fn strip_resource_param(mut self, strip: bool) -> Self {
2279        self.inner.strip_resource_param = strip;
2280        self
2281    }
2282
2283    /// Finalise the builder and return the [`OAuthProxyConfig`].
2284    #[must_use]
2285    pub fn build(self) -> OAuthProxyConfig {
2286        self.inner
2287    }
2288}
2289
2290// ---------------------------------------------------------------------------
2291// JWKS cache
2292// ---------------------------------------------------------------------------
2293
2294/// Key-type family used to decide which JWS algorithms an `alg`-less JWK may
2295/// verify.
2296///
2297/// RFC 7517 4.4 makes the JWK `alg` member OPTIONAL, and real issuers omit it
2298/// (Microsoft Entra v2.0 publishes every signing key without `alg`). When it is
2299/// absent the algorithm is inferred from the key material instead, so the key
2300/// stays usable without ever consulting the untrusted token header.
2301///
2302/// **`P-521`/`ES512` is deliberately absent.** `jsonwebtoken` 11's
2303/// `Algorithm` enum has no `ES512` variant at all -- it defines only `ES256`
2304/// and `ES384` for ECDSA -- so a `P-521` family could not name an algorithm to
2305/// map to. It is likewise absent from [`ACCEPTED_ALGS`]. Supporting P-521 would
2306/// require upstream `jsonwebtoken` support first.
2307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2308enum JwkKeyFamily {
2309    /// RSA key: any RSASSA-PKCS1-v1_5 or RSASSA-PSS algorithm.
2310    Rsa,
2311    /// NIST P-256 EC key: `ES256` only.
2312    EcP256,
2313    /// NIST P-384 EC key: `ES384` only.
2314    EcP384,
2315    /// Ed25519 octet key pair: `EdDSA` only.
2316    Ed25519,
2317}
2318
2319/// How a cached JWK constrains the JWS algorithm it may verify.
2320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2321enum JwkAlg {
2322    /// The JWK declared `alg`; exactly that algorithm is accepted.
2323    Explicit(Algorithm),
2324    /// The JWK omitted `alg`; the algorithms implied by its key type are
2325    /// accepted (see [`family_accepts`]).
2326    Family(JwkKeyFamily),
2327}
2328
2329impl JwkAlg {
2330    /// Whether this cached key may verify a token whose header declares `alg`.
2331    ///
2332    /// SECURITY: the candidate `alg` has already been screened against
2333    /// [`ACCEPTED_ALGS`] before key lookup, so `HS*` and `none` can never reach
2334    /// here. This is the second, key-bound half of that check: it prevents a
2335    /// token from selecting a key whose material cannot produce its algorithm.
2336    fn accepts(self, alg: Algorithm) -> bool {
2337        match self {
2338            Self::Explicit(declared) => declared == alg,
2339            Self::Family(family) => family_accepts(family, alg),
2340        }
2341    }
2342}
2343
2344/// Algorithms an `alg`-less JWK of the given family may verify.
2345///
2346/// INVARIANT: every algorithm returned here is a member of [`ACCEPTED_ALGS`];
2347/// `family_accepts_is_subset_of_accepted_algs` locks that down. Widening this
2348/// beyond [`ACCEPTED_ALGS`] would let an inferred key bypass the pre-lookup
2349/// algorithm screen.
2350const fn family_accepts(family: JwkKeyFamily, alg: Algorithm) -> bool {
2351    match family {
2352        JwkKeyFamily::Rsa => matches!(
2353            alg,
2354            Algorithm::RS256
2355                | Algorithm::RS384
2356                | Algorithm::RS512
2357                | Algorithm::PS256
2358                | Algorithm::PS384
2359                | Algorithm::PS512
2360        ),
2361        JwkKeyFamily::EcP256 => matches!(alg, Algorithm::ES256),
2362        JwkKeyFamily::EcP384 => matches!(alg, Algorithm::ES384),
2363        JwkKeyFamily::Ed25519 => matches!(alg, Algorithm::EdDSA),
2364    }
2365}
2366
2367/// `kid`-indexed map of (algorithm, decoding key) pairs plus a list of
2368/// unnamed keys. Produced by [`build_key_cache`] and consumed by
2369/// [`JwksCache::refresh_inner`].
2370type JwksKeyCache = (
2371    HashMap<String, (JwkAlg, DecodingKey)>,
2372    Vec<(JwkAlg, DecodingKey)>,
2373);
2374
2375struct CachedKeys {
2376    /// `kid` -> (`JwkAlg`, `DecodingKey`)
2377    keys: HashMap<String, (JwkAlg, DecodingKey)>,
2378    /// Keys without a kid, indexed by algorithm family.
2379    unnamed_keys: Vec<(JwkAlg, DecodingKey)>,
2380    fetched_at: Instant,
2381    ttl: Duration,
2382}
2383
2384const _JWKS_REFRESH_COOLDOWN_DOC_ANCHOR: &str = "JWKS_REFRESH_COOLDOWN";
2385
2386impl CachedKeys {
2387    fn is_expired(&self) -> bool {
2388        self.fetched_at.elapsed() >= self.ttl
2389    }
2390}
2391
2392/// Thread-safe JWKS key cache with automatic refresh.
2393///
2394/// Includes protections against denial-of-service via invalid JWTs:
2395/// - **Refresh cooldown**: At most one refresh per 10 seconds, regardless of
2396///   cache misses. This prevents attackers from flooding the upstream JWKS
2397///   endpoint by sending JWTs with fabricated `kid` values.
2398/// - **Concurrent deduplication**: Only one refresh in flight at a time;
2399///   concurrent waiters share the same fetch result.
2400#[allow(
2401    missing_debug_implementations,
2402    reason = "contains reqwest::Client and DecodingKey cache with no Debug impl"
2403)]
2404#[non_exhaustive]
2405pub struct JwksCache {
2406    jwks_uri: String,
2407    ttl: Duration,
2408    max_jwks_keys: usize,
2409    /// Algorithms this cache will verify with. Defaults to [`ACCEPTED_ALGS`];
2410    /// [`OAuthConfig::allowed_algorithms`] may narrow it but never widen it.
2411    allowed_algorithms: Vec<Algorithm>,
2412    max_response_bytes: u64,
2413    allow_http: bool,
2414    inner: RwLock<Option<CachedKeys>>,
2415    http: reqwest::Client,
2416    validation_template: Validation,
2417    /// Expected audience value from config; checked against `aud` and,
2418    /// per `audience_mode`, optionally `azp`.
2419    expected_audience: String,
2420    audience_mode: AudienceValidationMode,
2421    require_subject: bool,
2422    /// Set to `true` after the first `azp`-only audience match while in
2423    /// [`AudienceValidationMode::Warn`], so the deprecation warning logs
2424    /// at most once per process lifetime.
2425    azp_fallback_warned: AtomicBool,
2426    /// Separate from [Self::azp_fallback_warned] on purpose: sharing one
2427    /// flag would let whichever mode logged first suppress the other.
2428    azp_permissive_logged: AtomicBool,
2429    scopes: Vec<ScopeMapping>,
2430    role_claim: Option<String>,
2431    role_mappings: Vec<RoleMapping>,
2432    /// Tracks the last refresh attempt timestamp. Enforces a 10-second cooldown
2433    /// between refresh attempts to prevent abuse via fabricated JWTs with invalid kids.
2434    last_refresh_attempt: RwLock<Option<Instant>>,
2435    /// Serializes concurrent refresh attempts so only one fetch is in flight.
2436    refresh_lock: tokio::sync::Mutex<()>,
2437    /// Compiled operator SSRF allowlist (empty by default = original
2438    /// fail-closed behaviour). Wrapped in `Arc` so the redirect-policy
2439    /// closure can capture a cheap clone without inflating the cache size.
2440    allowlist: Arc<crate::ssrf::CompiledSsrfAllowlist>,
2441    /// M-H2/B1: shared loopback bypass; same Arc is captured by the
2442    /// SSRF resolver inside the cached `reqwest::Client`. See the
2443    /// matching field on `OauthHttpClient`.
2444    #[cfg(any(test, feature = "test-helpers"))]
2445    test_allow_loopback_ssrf: crate::ssrf_resolver::TestLoopbackBypass,
2446}
2447
2448const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(10);
2449
2450/// Upper bound on an upstream OAuth proxy response body (`/token`,
2451/// `/introspect`, `/revoke`, and RFC 8693 token exchange).
2452///
2453/// The upstream is the operator-configured, SSRF-screened authorization
2454/// server, so this is defense-in-depth rather than an attacker-facing
2455/// control — but it keeps the proxy paths symmetric with the bounded JWKS
2456/// fetch (`jwks_max_response_bytes`) so a misbehaving or compromised IdP
2457/// cannot make the server buffer an unbounded response. 1 MiB comfortably
2458/// covers token, introspection, and revocation JSON payloads.
2459const OAUTH_PROXY_MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
2460
2461/// Algorithms we accept from JWKS-served keys.
2462///
2463/// This is the crate-wide ceiling. `HS*` and `none` are deliberately absent:
2464/// a JWKS publishes public keys, so a symmetric secret must never become a
2465/// verification key, and RFC 9068 2.1 forbids `none` for access tokens.
2466/// [`OAuthConfig::allowed_algorithms`] may only NARROW this set, never widen
2467/// it.
2468const ACCEPTED_ALGS: &[Algorithm] = &[
2469    Algorithm::RS256,
2470    Algorithm::RS384,
2471    Algorithm::RS512,
2472    Algorithm::ES256,
2473    Algorithm::ES384,
2474    Algorithm::PS256,
2475    Algorithm::PS384,
2476    Algorithm::PS512,
2477    Algorithm::EdDSA,
2478];
2479
2480/// The JWA name of an accepted algorithm, or `None` if it is not accepted.
2481///
2482/// Single source of truth for the strings operators write in
2483/// [`OAuthConfig::allowed_algorithms`], so config parsing and error messages
2484/// can never drift from [`ACCEPTED_ALGS`]. `accepted_algorithm_names_cover_accepted_algs`
2485/// asserts the two stay in lockstep.
2486#[allow(
2487    clippy::wildcard_enum_match_arm,
2488    reason = "jsonwebtoken Algorithm is #[non_exhaustive], so an exhaustive match is impossible; HS*, `none`, and any future variant must fail closed to None"
2489)]
2490fn accepted_algorithm_name(alg: Algorithm) -> Option<&'static str> {
2491    match alg {
2492        Algorithm::RS256 => Some("RS256"),
2493        Algorithm::RS384 => Some("RS384"),
2494        Algorithm::RS512 => Some("RS512"),
2495        Algorithm::ES256 => Some("ES256"),
2496        Algorithm::ES384 => Some("ES384"),
2497        Algorithm::PS256 => Some("PS256"),
2498        Algorithm::PS384 => Some("PS384"),
2499        Algorithm::PS512 => Some("PS512"),
2500        Algorithm::EdDSA => Some("EdDSA"),
2501        _ => None,
2502    }
2503}
2504
2505/// Parse an operator-supplied algorithm name.
2506///
2507/// Case-insensitive so `rs256` and `RS256` both work. Returns `None` for any
2508/// name outside [`ACCEPTED_ALGS`] -- including `HS256` and `none` -- which is
2509/// what enforces the narrow-only rule at config-validation time.
2510fn accepted_algorithm_from_name(name: &str) -> Option<Algorithm> {
2511    ACCEPTED_ALGS
2512        .iter()
2513        .copied()
2514        .find(|alg| accepted_algorithm_name(*alg).is_some_and(|n| n.eq_ignore_ascii_case(name)))
2515}
2516
2517/// Comma-separated list of every accepted algorithm name, for error messages.
2518fn accepted_algorithm_names() -> String {
2519    ACCEPTED_ALGS
2520        .iter()
2521        .filter_map(|alg| accepted_algorithm_name(*alg))
2522        .collect::<Vec<_>>()
2523        .join(", ")
2524}
2525
2526/// Resolve the configured algorithm allowlist into concrete algorithms.
2527///
2528/// SECURITY (narrow-only): every name must resolve inside [`ACCEPTED_ALGS`].
2529/// `accepted_algorithm_from_name` returns `None` for `HS*` and `none`, so an
2530/// operator can never re-enable a symmetric or unsigned algorithm through
2531/// config. An empty list is rejected because it would silently reject every
2532/// token -- almost certainly an operator mistake rather than an intent to
2533/// disable OAuth.
2534pub(crate) fn resolve_allowed_algorithms(
2535    configured: Option<&Vec<String>>,
2536) -> Result<Vec<Algorithm>, crate::error::RmcpServerKitError> {
2537    let Some(names) = configured else {
2538        return Ok(ACCEPTED_ALGS.to_vec());
2539    };
2540    if names.is_empty() {
2541        return Err(crate::error::RmcpServerKitError::Config(
2542            "oauth.allowed_algorithms must not be empty; omit the field to accept the default set"
2543                .into(),
2544        ));
2545    }
2546    let mut resolved = Vec::with_capacity(names.len());
2547    for name in names {
2548        let Some(alg) = accepted_algorithm_from_name(name) else {
2549            return Err(crate::error::RmcpServerKitError::Config(format!(
2550                "oauth.allowed_algorithms contains unsupported algorithm {name:?}; \
2551                 permitted values are: {}",
2552                accepted_algorithm_names()
2553            )));
2554        };
2555        if !resolved.contains(&alg) {
2556            resolved.push(alg);
2557        }
2558    }
2559    Ok(resolved)
2560}
2561
2562/// Coarse JWT validation failure classification for auth diagnostics.
2563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2564#[non_exhaustive]
2565pub enum JwtValidationFailure {
2566    /// JWT was well-formed but expired per `exp` validation.
2567    Expired,
2568    /// JWT failed validation for all other reasons.
2569    Invalid,
2570}
2571
2572impl JwksCache {
2573    /// Build a new cache from OAuth configuration.
2574    ///
2575    /// # Errors
2576    ///
2577    /// Returns an error if the CA bundle cannot be read, the HTTP client
2578    /// cannot be built, or `config.jwks_cache_ttl` is not a valid
2579    /// humantime duration. [`OAuthConfig::validate`] (run automatically by
2580    /// the typed
2581    /// [`McpServerConfig::validate`](crate::transport::McpServerConfig::validate)
2582    /// pipeline) rejects invalid TTLs up front, so the TTL branch is
2583    /// unreachable for validated configs.
2584    pub fn new(config: &OAuthConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
2585        // Ensure crypto providers are installed (idempotent -- ok() ignores
2586        // the error if already installed by another call in the same process).
2587        rustls::crypto::ring::default_provider()
2588            .install_default()
2589            .ok();
2590        jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
2591            .install_default()
2592            .ok();
2593
2594        let ttl = humantime::parse_duration(&config.jwks_cache_ttl).map_err(|error| {
2595            format!(
2596                "invalid jwks_cache_ttl {:?}: {error}",
2597                config.jwks_cache_ttl
2598            )
2599        })?;
2600
2601        let mut validation = Validation::new(Algorithm::RS256);
2602        // Note: validation.algorithms is overridden per-decode to [header.alg]
2603        // because jsonwebtoken requires all listed algorithms to share
2604        // the same key family. The ACCEPTED_ALGS whitelist is checked
2605        // separately before looking up the key.
2606        //
2607        // Audience validation is done manually after decode: we accept the
2608        // token if `aud` contains `config.audience` OR `azp == config.audience`.
2609        // This is correct per RFC 9068 Sec.4 + OIDC Core Sec.2: `aud` lists
2610        // resource servers, `azp` identifies the authorized client. When the
2611        // MCP server is both the OAuth client and the resource server (as in
2612        // our proxy setup), the configured audience may appear in either claim.
2613        validation.validate_aud = false;
2614        validation.set_issuer(&[&config.issuer]);
2615        validation.set_required_spec_claims(&["exp", "iss"]);
2616        validation.validate_exp = true;
2617        validation.validate_nbf = true;
2618
2619        let allow_http = config.allow_http_oauth_urls;
2620
2621        // Compile operator allowlist up-front so misconfiguration is
2622        // surfaced at startup rather than on first JWKS fetch.
2623        let allowlist = match config.ssrf_allowlist.as_ref() {
2624            Some(raw) => Arc::new(compile_oauth_ssrf_allowlist(raw).map_err(|e| {
2625                Box::<dyn std::error::Error + Send + Sync>::from(format!(
2626                    "oauth.ssrf_allowlist: {e}"
2627                ))
2628            })?),
2629            None => Arc::new(crate::ssrf::CompiledSsrfAllowlist::default()),
2630        };
2631        let redirect_allowlist = Arc::clone(&allowlist);
2632
2633        // M-H2: see OauthHttpClient::build for rationale; same pattern.
2634        #[cfg(any(test, feature = "test-helpers"))]
2635        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass =
2636            Arc::new(AtomicBool::new(false));
2637        #[cfg(not(any(test, feature = "test-helpers")))]
2638        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = ();
2639
2640        #[allow(
2641            clippy::clone_on_ref_ptr,
2642            clippy::clone_on_copy,
2643            clippy::unit_arg,
2644            reason = "TestLoopbackBypass aliases to Arc<AtomicBool> under cfg(test)/test-helpers and to `()` otherwise; each cfg trips a different clone/arg lint"
2645        )]
2646        let resolver: Arc<dyn reqwest::dns::Resolve> =
2647            Arc::new(crate::ssrf_resolver::SsrfScreeningResolver::new(
2648                Arc::clone(&allowlist),
2649                test_bypass.clone(),
2650            ));
2651
2652        let mut http_builder = reqwest::Client::builder()
2653            // M-H2/N1: see OauthHttpClient::build.
2654            .no_proxy()
2655            .dns_resolver(Arc::clone(&resolver))
2656            .timeout(Duration::from_secs(10))
2657            .connect_timeout(Duration::from_secs(3))
2658            .redirect(reqwest::redirect::Policy::custom(move |attempt| {
2659                // SECURITY: a redirect from `https` to `http` is *always*
2660                // rejected, even when `allow_http_oauth_urls` is true.
2661                // The flag controls whether the *original* request URL
2662                // may be plain HTTP; it never authorises a downgrade
2663                // mid-flight. An `http -> http` redirect is permitted
2664                // only when the flag is true (dev-only). The full
2665                // policy lives in `evaluate_oauth_redirect` so the
2666                // OauthHttpClient and JwksCache closures stay
2667                // byte-for-byte identical.
2668                match evaluate_oauth_redirect(&attempt, allow_http, &redirect_allowlist) {
2669                    Ok(()) => attempt.follow(),
2670                    Err(reason) => {
2671                        // Sanitized target: the rejected URL may carry
2672                        // userinfo credentials (the rejection reason
2673                        // itself is URL-free).
2674                        tracing::warn!(
2675                            reason = %reason,
2676                            target = %crate::ssrf::sanitized_url_for_log(attempt.url()),
2677                            "oauth redirect rejected"
2678                        );
2679                        attempt.error(reason)
2680                    }
2681                }
2682            }));
2683
2684        if let Some(ref ca_path) = config.ca_cert_path {
2685            // Pre-startup blocking I/O — runs before the runtime begins
2686            // serving requests, so blocking the current thread here is
2687            // intentional. Do not wrap in `spawn_blocking`: the constructor
2688            // is synchronous by contract and is called from `serve()`'s
2689            // pre-startup phase.
2690            let pem = std::fs::read(ca_path)?;
2691            let cert = reqwest::tls::Certificate::from_pem(&pem)?;
2692            http_builder = http_builder.add_root_certificate(cert);
2693        }
2694
2695        let http = http_builder.build()?;
2696
2697        Ok(Self {
2698            jwks_uri: config.jwks_uri.clone(),
2699            ttl,
2700            max_jwks_keys: config.max_jwks_keys,
2701            allowed_algorithms: resolve_allowed_algorithms(config.allowed_algorithms.as_ref())?,
2702            max_response_bytes: config.jwks_max_response_bytes,
2703            allow_http,
2704            inner: RwLock::new(None),
2705            http,
2706            validation_template: validation,
2707            expected_audience: config.audience.clone(),
2708            audience_mode: config.effective_audience_validation_mode(),
2709            require_subject: config.require_subject,
2710            azp_fallback_warned: AtomicBool::new(false),
2711            azp_permissive_logged: AtomicBool::new(false),
2712            scopes: config.scopes.clone(),
2713            role_claim: config.role_claim.clone(),
2714            role_mappings: config.role_mappings.clone(),
2715            last_refresh_attempt: RwLock::new(None),
2716            refresh_lock: tokio::sync::Mutex::new(()),
2717            allowlist,
2718            #[cfg(any(test, feature = "test-helpers"))]
2719            test_allow_loopback_ssrf: test_bypass,
2720        })
2721    }
2722
2723    /// Test-only: disable initial-target SSRF screening for loopback-backed
2724    /// fixtures. This is unreachable from normal production builds and exists
2725    /// only so tests can fetch JWKS from local mock servers.
2726    ///
2727    /// # ⚠️ Security
2728    ///
2729    /// Disables the JWKS fetcher's SSRF guard loopback rejection, allowing
2730    /// loopback JWKS targets that production OAuth screening would reject.
2731    #[cfg(any(test, feature = "test-helpers"))]
2732    #[doc(hidden)]
2733    #[must_use]
2734    pub fn __test_allow_loopback_ssrf(self) -> Self {
2735        // M-H2/B1: flip the SHARED atomic so the resolver inside the
2736        // cached client and the pre-flight check both observe the bypass.
2737        self.test_allow_loopback_ssrf.store(true, Ordering::Relaxed);
2738        self
2739    }
2740
2741    /// Validate a JWT Bearer token. Returns `Some(AuthIdentity)` on success.
2742    pub async fn validate_token(&self, token: &str) -> Option<AuthIdentity> {
2743        self.validate_token_with_reason(token).await.ok()
2744    }
2745
2746    /// Validate a JWT Bearer token with failure classification.
2747    ///
2748    /// # Errors
2749    ///
2750    /// Returns [`JwtValidationFailure::Expired`] when the JWT is expired,
2751    /// or [`JwtValidationFailure::Invalid`] for all other validation failures.
2752    // cancel-safe: composed of cancel-safe `decode_claims` (spawn_blocking
2753    // decode, no shared state) plus pure, side-effect-free claim checks
2754    // (`check_audience`, `resolve_role`). No partial state on cancellation.
2755    pub async fn validate_token_with_reason(
2756        &self,
2757        token: &str,
2758    ) -> Result<AuthIdentity, JwtValidationFailure> {
2759        let claims = self.decode_claims(token).await?;
2760
2761        if self.require_subject && claims.sub.is_none() {
2762            core::hint::cold_path();
2763            tracing::debug!("JWT rejected: require_subject is set but the token has no `sub`");
2764            return Err(JwtValidationFailure::Invalid);
2765        }
2766        self.check_audience(&claims)?;
2767        let role = self.resolve_role(&claims)?;
2768
2769        // Identity: prefer human-readable `preferred_username` (Keycloak/OIDC),
2770        // then `sub`, then `azp` (authorized party), then `client_id`.
2771        let sub = claims.sub;
2772        let name = claims
2773            .extra
2774            .get("preferred_username")
2775            .and_then(|v| v.as_str())
2776            .map(String::from)
2777            .or_else(|| sub.clone())
2778            .or(claims.azp)
2779            .or(claims.client_id)
2780            .unwrap_or_else(|| "oauth-client".into());
2781
2782        Ok(AuthIdentity {
2783            name,
2784            role,
2785            method: AuthMethod::OAuthJwt,
2786            raw_token: None,
2787            sub,
2788        })
2789    }
2790
2791    /// Decode and fully verify a JWT, returning its claims.
2792    ///
2793    /// Performs header decode, algorithm allow-list check, JWKS key lookup
2794    /// (with on-demand refresh), signature verification, and standard
2795    /// claim validation (exp/nbf/iss) against the template.
2796    ///
2797    /// The CPU-bound `jsonwebtoken::decode` call (RSA / ECDSA signature
2798    /// verification) is offloaded to [`tokio::task::spawn_blocking`] so a
2799    /// burst of concurrent JWT validations never starves other tasks on
2800    /// the multi-threaded runtime's worker pool. The blocking pool absorbs
2801    /// the verification cost; the async path stays responsive.
2802    // cancel-safe: `select_jwks_key` (cancel-safe: read-only lookup + idempotent
2803    // refresh) then a `spawn_blocking` decode whose `JoinHandle`, if dropped on
2804    // cancellation, detaches the verification (it completes off-task). No shared
2805    // state is mutated on this path.
2806    async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2807        let (key, alg) = self.select_jwks_key(token).await?;
2808
2809        // Build a per-decode validation scoped to the header's algorithm.
2810        // jsonwebtoken requires ALL algorithms in the list to share the
2811        // same family as the key, so we restrict to [alg] only.
2812        let mut validation = self.validation_template.clone();
2813        validation.algorithms = vec![alg];
2814
2815        // Move the (cheap) clones into the blocking task so the verifier
2816        // does not hold a reference into the request's async scope.
2817        let token_owned = token.to_owned();
2818        let join =
2819            tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2820                .await;
2821
2822        let decode_result = match join {
2823            Ok(r) => r,
2824            Err(join_err) => {
2825                core::hint::cold_path();
2826                tracing::error!(
2827                    error = %join_err,
2828                    "JWT decode task panicked or was cancelled"
2829                );
2830                return Err(JwtValidationFailure::Invalid);
2831            }
2832        };
2833
2834        decode_result.map(|td| td.claims).map_err(|e| {
2835            core::hint::cold_path();
2836            let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2837                JwtValidationFailure::Expired
2838            } else {
2839                JwtValidationFailure::Invalid
2840            };
2841            tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2842            failure
2843        })
2844    }
2845
2846    /// Decode the JWT header, check the algorithm against the allow-list,
2847    /// and look up the matching JWKS key (refreshing on miss).
2848    //
2849    // Complexity: 28/25. Three structured early-returns each pair a
2850    // `cold_path()` hint with a distinct `tracing::debug!` site so the
2851    // failure is observable. Collapsing them into a combinator chain
2852    // would lose those structured-field log sites without reducing
2853    // real cognitive load.
2854    // NOT cancel-safe: on a cache miss this delegates to `find_key`, which can
2855    // enter `refresh_with_cooldown`. That commits `last_refresh_attempt` before
2856    // fetching, so a cancellation mid-refresh still consumes the cooldown slot
2857    // and the next caller may be refused a refresh for the cooldown window.
2858    #[allow(
2859        clippy::cognitive_complexity,
2860        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"
2861    )]
2862    async fn select_jwks_key(
2863        &self,
2864        token: &str,
2865    ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2866        let Ok(header) = decode_header(token) else {
2867            core::hint::cold_path();
2868            tracing::debug!("JWT header decode failed");
2869            return Err(JwtValidationFailure::Invalid);
2870        };
2871        let kid = header.kid.as_deref();
2872        tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2873
2874        if !self.allowed_algorithms.contains(&header.alg) {
2875            core::hint::cold_path();
2876            tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2877            return Err(JwtValidationFailure::Invalid);
2878        }
2879
2880        let Some(key) = self.find_key(kid, header.alg).await else {
2881            core::hint::cold_path();
2882            tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2883            return Err(JwtValidationFailure::Invalid);
2884        };
2885
2886        Ok((key, header.alg))
2887    }
2888
2889    /// Manual audience check.
2890    ///
2891    /// Resolves per [`AudienceValidationMode`]: `aud` matches always
2892    /// accept silently. `azp`-only matches accept silently in
2893    /// [`AudienceValidationMode::Permissive`], accept with a one-shot
2894    /// `tracing::warn!` per process in [`AudienceValidationMode::Warn`],
2895    /// and reject in [`AudienceValidationMode::Strict`]. No-claim-match
2896    /// always rejects.
2897    fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2898        if claims.aud.contains(&self.expected_audience) {
2899            return Ok(());
2900        }
2901        let azp_match = claims
2902            .azp
2903            .as_deref()
2904            .is_some_and(|azp| azp == self.expected_audience);
2905        if azp_match {
2906            match self.audience_mode {
2907                AudienceValidationMode::Permissive => {
2908                    if !self.azp_permissive_logged.swap(true, Ordering::Relaxed) {
2909                        tracing::info!(
2910                            expected = %self.expected_audience,
2911                            "JWT accepted via azp-only audience fallback because \
2912                             audience_validation_mode = \"permissive\". Acceptance is \
2913                             intentionally wider than the spec; set \"warn\" or \"strict\" \
2914                             to tighten it. This message logs once per process."
2915                        );
2916                    }
2917                    return Ok(());
2918                }
2919                AudienceValidationMode::Warn => {
2920                    if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2921                        tracing::warn!(
2922                            expected = %self.expected_audience,
2923                            azp = claims.azp.as_deref().unwrap_or("-"),
2924                            "JWT accepted via deprecated azp-only audience fallback. \
2925                             Configure your IdP to populate aud, or set \
2926                             audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2927                             To silence this warning without changing acceptance, \
2928                             set audience_validation_mode = \"permissive\". \
2929                             This warning logs once per process."
2930                        );
2931                    }
2932                    return Ok(());
2933                }
2934                AudienceValidationMode::Strict => {}
2935            }
2936        }
2937        core::hint::cold_path();
2938        self.log_audience_mismatch(claims);
2939        Err(JwtValidationFailure::Invalid)
2940    }
2941
2942    /// Log an audience-mismatch rejection.
2943    ///
2944    /// The token's own claim values (`aud`, `azp`) are gated behind the
2945    /// operator diagnostic switch, matching `log_exchanged_token`.
2946    /// `expected` and `mode` are local configuration rather than token
2947    /// material, so they stay visible for debuggability.
2948    fn log_audience_mismatch(&self, claims: &Claims) {
2949        let expose = crate::diagnostics::oauth_claim_values();
2950        let aud = if expose {
2951            claims.aud.log_display()
2952        } else {
2953            "[REDACTED]".to_owned()
2954        };
2955        let azp = if expose {
2956            claims.azp.as_deref().unwrap_or("-")
2957        } else {
2958            "[REDACTED]"
2959        };
2960        tracing::debug!(
2961            aud = %aud,
2962            azp = azp,
2963            expected = %self.expected_audience,
2964            mode = self.audience_mode.as_str(),
2965            "JWT rejected: audience mismatch"
2966        );
2967    }
2968
2969    /// Resolve the role for this token.
2970    ///
2971    /// When `role_claim` is set, extract values from the given claim path
2972    /// and match against `role_mappings`. Otherwise, match space-separated
2973    /// tokens in the `scope` claim against configured scope mappings.
2974    fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2975        if let Some(ref claim_path) = self.role_claim {
2976            let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2977            let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2978            values.extend(resolve_claim_path(&claims.extra, claim_path));
2979            return self
2980                .role_mappings
2981                .iter()
2982                .find(|m| values.contains(&m.claim_value.as_str()))
2983                .map(|m| m.role.clone())
2984                .ok_or(JwtValidationFailure::Invalid);
2985        }
2986
2987        let token_scopes: Vec<&str> = claims
2988            .scope
2989            .as_deref()
2990            .unwrap_or("")
2991            .split_whitespace()
2992            .collect();
2993
2994        self.scopes
2995            .iter()
2996            .find(|m| token_scopes.contains(&m.scope.as_str()))
2997            .map(|m| m.role.clone())
2998            .ok_or(JwtValidationFailure::Invalid)
2999    }
3000
3001    /// Look up a decoding key by kid + algorithm. Refreshes JWKS on miss,
3002    /// subject to cooldown and deduplication constraints.
3003    // cancel-safe: reads the key cache under a `tokio::sync::RwLock` and, on a
3004    // miss, delegates to the idempotent `refresh_with_cooldown`. Cancellation at
3005    // any await leaves the cache in its prior consistent state.
3006    async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3007        // Try cached keys first.
3008        {
3009            let guard = self.inner.read().await;
3010            if let Some(cached) = guard.as_ref()
3011                && !cached.is_expired()
3012                && let Some(key) = lookup_key(cached, kid, alg)
3013            {
3014                return Some(key);
3015            }
3016        }
3017
3018        // Cache miss or expired -- refresh (with cooldown/deduplication).
3019        self.refresh_with_cooldown().await;
3020
3021        // Fail closed (H2): a failed or cooled-down refresh leaves the previous
3022        // (now-expired) cache in place. Re-apply the freshness gate the first
3023        // lookup enforces so a rotated-out key is never served from a stale
3024        // cache -- otherwise an attacker who can stall the JWKS endpoint could
3025        // keep a revoked signing key valid past its TTL.
3026        let guard = self.inner.read().await;
3027        guard
3028            .as_ref()
3029            .filter(|cached| !cached.is_expired())
3030            .and_then(|cached| lookup_key(cached, kid, alg))
3031    }
3032
3033    /// Refresh JWKS with cooldown and concurrent deduplication.
3034    ///
3035    /// - Only one refresh in flight at a time (concurrent waiters share result).
3036    /// - At most one refresh per [`JWKS_REFRESH_COOLDOWN`] (10 seconds).
3037    ///
3038    /// # Cancellation
3039    ///
3040    /// **NOT cancel-safe by design.** `last_refresh_attempt` is committed
3041    /// *before* the fetch so that a burst of failing or cancelled refreshes
3042    /// cannot hammer the JWKS endpoint (the invalid-JWT → JWKS-refresh DoS
3043    /// class; see `AGENTS.md` pitfall #2). The consequence is a deliberate
3044    /// trade-off: if this future is cancelled between the timestamp write and
3045    /// cache publication, a genuinely-new `kid` may be rejected for up to
3046    /// [`JWKS_REFRESH_COOLDOWN`] (10s). Endpoint DoS protection is preferred
3047    /// over immediate post-cancellation retriability. Do **not** "fix" this by
3048    /// bypassing the cooldown on unknown-`kid` requests — that reopens the
3049    /// DoS-amplification vector the cooldown exists to close.
3050    // NOT cancel-safe: see the `# Cancellation` section above — cooldown is
3051    // committed before the fetch to throttle JWKS-endpoint abuse.
3052    async fn refresh_with_cooldown(&self) {
3053        // Acquire the mutex to serialize refresh attempts.
3054        let _guard = self.refresh_lock.lock().await;
3055
3056        // Check cooldown: skip if we refreshed recently.
3057        {
3058            let last = self.last_refresh_attempt.read().await;
3059            if let Some(ts) = *last
3060                && ts.elapsed() < JWKS_REFRESH_COOLDOWN
3061            {
3062                tracing::info!(
3063                    elapsed_ms = ts.elapsed().as_millis(),
3064                    cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
3065                    "JWKS refresh skipped (cooldown active)"
3066                );
3067                return;
3068            }
3069        }
3070
3071        // Update last refresh timestamp BEFORE the fetch attempt.
3072        // This ensures the cooldown applies even if the fetch fails.
3073        {
3074            let mut last = self.last_refresh_attempt.write().await;
3075            *last = Some(Instant::now());
3076        }
3077
3078        // Perform the actual fetch.
3079        let _ = self.refresh_inner().await;
3080    }
3081
3082    /// Fetch JWKS from the configured URI and update the cache.
3083    ///
3084    /// Internal implementation - callers should use [`Self::refresh_with_cooldown`]
3085    /// to respect rate limiting.
3086    // cancel-safe (cache integrity): the cache is published via a single
3087    // `*guard = Some(..)` assignment under the `tokio::sync::RwLock` write lock
3088    // at the end. Cancellation before that point leaves the prior cache intact;
3089    // it never observes a half-built cache.
3090    async fn refresh_inner(&self) -> Result<(), String> {
3091        let Some(jwks) = self.fetch_jwks().await else {
3092            return Ok(());
3093        };
3094        let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
3095            Ok(cache) => cache,
3096            Err(msg) => {
3097                tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
3098                return Err(msg);
3099            }
3100        };
3101
3102        tracing::debug!(
3103            named = keys.len(),
3104            unnamed = unnamed_keys.len(),
3105            "JWKS refreshed"
3106        );
3107
3108        let mut guard = self.inner.write().await;
3109        *guard = Some(CachedKeys {
3110            keys,
3111            unnamed_keys,
3112            fetched_at: Instant::now(),
3113            ttl: self.ttl,
3114        });
3115        drop(guard);
3116        Ok(())
3117    }
3118
3119    /// Fetch and parse the JWKS document. Returns `None` and logs on failure.
3120    #[allow(
3121        clippy::cognitive_complexity,
3122        reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
3123    )]
3124    // cancel-safe (cache integrity): screening, `send`, chunk reads, and JSON
3125    // parse build only a local body/JWK set; cache publication happens later
3126    // via one `refresh_inner` write-lock assignment, so old cache stays intact.
3127    async fn fetch_jwks(&self) -> Option<JwkSet> {
3128        #[cfg(any(test, feature = "test-helpers"))]
3129        let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
3130            screen_oauth_target_with_test_override(
3131                &self.jwks_uri,
3132                self.allow_http,
3133                &self.allowlist,
3134                true,
3135            )
3136            .await
3137        } else {
3138            screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
3139        };
3140        #[cfg(not(any(test, feature = "test-helpers")))]
3141        let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
3142
3143        if let Err(error) = screening {
3144            tracing::warn!(
3145                error = %error,
3146                uri = %oauth_request_target_for_log(&self.jwks_uri),
3147                "failed to screen JWKS target"
3148            );
3149            return None;
3150        }
3151
3152        let mut resp = match self.http.get(&self.jwks_uri).send().await {
3153            Ok(resp) => resp,
3154            Err(e) => {
3155                tracing::warn!(
3156                    error = %e.without_url(),
3157                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3158                    "failed to fetch JWKS"
3159                );
3160                return None;
3161            }
3162        };
3163
3164        let initial_capacity =
3165            usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3166        let mut body = Vec::with_capacity(initial_capacity);
3167        while let Some(chunk) = match resp.chunk().await {
3168            Ok(chunk) => chunk,
3169            Err(error) => {
3170                tracing::warn!(
3171                    error = %error.without_url(),
3172                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3173                    "failed to read JWKS response"
3174                );
3175                return None;
3176            }
3177        } {
3178            let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3179            let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3180            if body_len.saturating_add(chunk_len) > self.max_response_bytes {
3181                tracing::warn!(
3182                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3183                    max_bytes = self.max_response_bytes,
3184                    "JWKS response exceeded configured size cap"
3185                );
3186                return None;
3187            }
3188            body.extend_from_slice(&chunk);
3189        }
3190
3191        match serde_json::from_slice::<JwkSet>(&body) {
3192            Ok(jwks) => Some(jwks),
3193            Err(error) => {
3194                tracing::warn!(
3195                    error = %error,
3196                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3197                    "failed to parse JWKS"
3198                );
3199                None
3200            }
3201        }
3202    }
3203
3204    /// Test-only: drive `refresh_inner` now, surfacing the
3205    /// `build_key_cache` error string. Used by `tests/jwks_key_cap.rs`.
3206    ///
3207    /// # ⚠️ Security
3208    ///
3209    /// Bypasses `refresh_with_cooldown` and therefore `JWKS_REFRESH_COOLDOWN`,
3210    /// the DoS protection that prevents invalid-JWT floods from hammering the
3211    /// identity provider's JWKS endpoint.
3212    #[cfg(any(test, feature = "test-helpers"))]
3213    #[doc(hidden)]
3214    pub async fn __test_refresh_now(&self) -> Result<(), String> {
3215        let jwks = self
3216            .fetch_jwks()
3217            .await
3218            .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
3219        let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
3220        let mut guard = self.inner.write().await;
3221        *guard = Some(CachedKeys {
3222            keys,
3223            unnamed_keys,
3224            fetched_at: Instant::now(),
3225            ttl: self.ttl,
3226        });
3227        drop(guard);
3228        Ok(())
3229    }
3230
3231    /// Test-only: returns whether the cache currently contains the
3232    /// supplied kid. Read-only; takes the cache lock briefly.
3233    #[cfg(any(test, feature = "test-helpers"))]
3234    #[doc(hidden)]
3235    pub async fn __test_has_kid(&self, kid: &str) -> bool {
3236        let guard = self.inner.read().await;
3237        guard
3238            .as_ref()
3239            .is_some_and(|cache| cache.keys.contains_key(kid))
3240    }
3241}
3242
3243/// Partition a JWKS into a kid-indexed map plus a list of unnamed keys.
3244/// Longest `kid` prefix emitted to logs.
3245const MAX_LOGGED_KID_CHARS: usize = 64;
3246
3247/// Truncate an issuer-supplied `kid` to [`MAX_LOGGED_KID_CHARS`] before it
3248/// reaches a log line.
3249///
3250/// `kid` is remote-controlled text of unbounded length, so logging it raw
3251/// lets a hostile or misconfigured issuer inflate log volume. Truncation is
3252/// on a char boundary to keep the output valid UTF-8.
3253fn truncate_kid_for_log(kid: &str) -> (String, bool) {
3254    if kid.chars().count() <= MAX_LOGGED_KID_CHARS {
3255        return (kid.to_owned(), false);
3256    }
3257    let head: String = kid.chars().take(MAX_LOGGED_KID_CHARS).collect();
3258    (format!("{head}...(truncated)"), true)
3259}
3260
3261/// Render a JWK's `kid` for logging, bounded, with a placeholder when absent.
3262fn jwk_kid_for_log(jwk: &jsonwebtoken::jwk::Jwk) -> (String, bool) {
3263    jwk.common
3264        .key_id
3265        .as_deref()
3266        .map_or_else(|| ("<no-kid>".to_owned(), false), truncate_kid_for_log)
3267}
3268
3269/// Classify a single JWK into a cacheable (algorithm-constraint, key) pair.
3270///
3271/// Returns `None` for every fail-closed case: a key whose declared `use`/
3272/// `key_ops` forbid signature verification, a key `jsonwebtoken` cannot decode,
3273/// and a key whose algorithm can be neither read nor inferred.
3274fn classify_jwk(jwk: &jsonwebtoken::jwk::Jwk) -> Option<(JwkAlg, DecodingKey)> {
3275    if !jwk_permits_signature_verification(jwk) {
3276        let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3277        tracing::debug!(
3278            kid = %kid_log,
3279            kid_truncated,
3280            "skipping JWKS key not permitted for signature verification (use/key_ops)"
3281        );
3282        return None;
3283    }
3284    let decoding_key = DecodingKey::from_jwk(jwk).ok()?;
3285    let alg = jwk_algorithm(jwk)?;
3286    if let JwkAlg::Family(family) = alg {
3287        let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3288        tracing::debug!(
3289            kid = %kid_log,
3290            kid_truncated,
3291            family = ?family,
3292            "JWKS key omits `alg`; inferring permitted algorithms from key type (RFC 7517 4.4)"
3293        );
3294    }
3295    Some((alg, decoding_key))
3296}
3297
3298fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
3299    if jwks.keys.len() > max_keys {
3300        return Err(format!(
3301            "jwks_key_count_exceeds_cap: got {} keys, max is {}",
3302            jwks.keys.len(),
3303            max_keys
3304        ));
3305    }
3306    let mut keys = HashMap::new();
3307    let mut unnamed_keys = Vec::new();
3308    for jwk in &jwks.keys {
3309        let Some((alg, decoding_key)) = classify_jwk(jwk) else {
3310            continue;
3311        };
3312        if let Some(ref kid) = jwk.common.key_id {
3313            if keys.insert(kid.clone(), (alg, decoding_key)).is_some() {
3314                let (kid_log, kid_truncated) = truncate_kid_for_log(kid);
3315                tracing::warn!(
3316                    kid = %kid_log,
3317                    kid_truncated,
3318                    "duplicate kid in JWKS; later entry wins"
3319                );
3320            }
3321        } else {
3322            unnamed_keys.push((alg, decoding_key));
3323        }
3324    }
3325    Ok((keys, unnamed_keys))
3326}
3327
3328/// Look up a key from the cache by kid (if present) or by algorithm.
3329fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3330    if let Some(kid) = kid {
3331        // A token carrying a `kid` must match a NAMED JWKS key exactly; it
3332        // must NOT fall back to an unnamed key. Otherwise an attacker could
3333        // present an unknown `kid` and be validated against an unrelated
3334        // unnamed key of the same algorithm (L4, fail-closed key selection).
3335        if let Some((cached_alg, key)) = cached.keys.get(kid)
3336            && cached_alg.accepts(alg)
3337        {
3338            return Some(key.clone());
3339        }
3340        return None;
3341    }
3342    // No `kid`: fall back to any unnamed key that permits this algorithm.
3343    cached
3344        .unnamed_keys
3345        .iter()
3346        .find(|(a, _)| a.accepts(alg))
3347        .map(|(_, k)| k.clone())
3348}
3349
3350/// Whether a JWK is permitted to act as a JWT **signature verification**
3351/// key, per its declared intent.
3352///
3353/// SECURITY (key-use separation, RFC 7517 4.2/4.3): `DecodingKey::from_jwk`
3354/// does NOT enforce `use` or `key_ops`, so without this gate an issuer that
3355/// publishes signing and encryption keys in one JWKS would have its
3356/// encryption keys silently accepted as verification keys. Anyone holding
3357/// such a key's private half could then mint tokens this server trusts.
3358///
3359/// Both parameters are optional; absent means unconstrained and is accepted
3360/// (RFC 7517 says `use` is optional unless the application requires it).
3361/// When present they are enforced fail-closed.
3362fn jwk_permits_signature_verification(jwk: &jsonwebtoken::jwk::Jwk) -> bool {
3363    use jsonwebtoken::jwk::{KeyOperations, PublicKeyUse};
3364
3365    let use_ok = match jwk.common.public_key_use {
3366        None | Some(PublicKeyUse::Signature) => true,
3367        Some(PublicKeyUse::Encryption | PublicKeyUse::Other(_)) => false,
3368    };
3369    // RFC 7517 4.3: when key_ops is present it enumerates the permitted
3370    // operations exhaustively, so a key without "verify" must be refused.
3371    let ops_ok = jwk
3372        .common
3373        .key_operations
3374        .as_ref()
3375        .is_none_or(|ops| ops.contains(&KeyOperations::Verify));
3376
3377    use_ok && ops_ok
3378}
3379
3380/// Determine how a JWK constrains the algorithms it may verify.
3381///
3382/// An explicit `alg` pins exactly one algorithm (unchanged behaviour). When
3383/// `alg` is absent -- which RFC 7517 4.4 explicitly permits, and which Entra
3384/// v2.0 always does -- the key type implies the family instead. Returning
3385/// `None` drops the key, so unknown or symmetric key types stay fail-closed.
3386fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkAlg> {
3387    match jwk.common.key_algorithm {
3388        Some(declared) => explicit_jwk_algorithm(declared).map(JwkAlg::Explicit),
3389        None => infer_jwk_family(jwk).map(JwkAlg::Family),
3390    }
3391}
3392
3393/// Map a declared JWK `alg` onto a supported JWS algorithm.
3394#[allow(
3395    clippy::wildcard_enum_match_arm,
3396    reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
3397)]
3398fn explicit_jwk_algorithm(declared: jsonwebtoken::jwk::KeyAlgorithm) -> Option<Algorithm> {
3399    match declared {
3400        jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
3401        jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
3402        jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
3403        jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
3404        jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
3405        jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
3406        jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
3407        jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
3408        jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
3409        _ => None,
3410    }
3411}
3412
3413/// Infer the algorithm family of a JWK that omitted `alg`, from its key type.
3414///
3415/// SECURITY: inference reads only the JWK's own key material, never the token
3416/// header, so it cannot be steered by an attacker. `OctetKey` (symmetric) is
3417/// deliberately never inferred -- an `HS*` secret must not become a
3418/// verification key -- and `P-521` yields `None` because `jsonwebtoken` 11
3419/// defines no `ES512` variant (its own `EllipticCurve::P521` doc notes the
3420/// curve is unsupported by `ring`).
3421#[allow(
3422    clippy::wildcard_enum_match_arm,
3423    reason = "jsonwebtoken AlgorithmParameters and EllipticCurve are both #[non_exhaustive] external enums, so an exhaustive match is impossible; unmatched variants must fail closed to None"
3424)]
3425fn infer_jwk_family(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkKeyFamily> {
3426    use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve};
3427
3428    match jwk.algorithm {
3429        AlgorithmParameters::RSA(_) => Some(JwkKeyFamily::Rsa),
3430        AlgorithmParameters::EllipticCurve(ref ec) => match ec.curve {
3431            EllipticCurve::P256 => Some(JwkKeyFamily::EcP256),
3432            EllipticCurve::P384 => Some(JwkKeyFamily::EcP384),
3433            _ => None,
3434        },
3435        AlgorithmParameters::OctetKeyPair(ref okp) => match okp.curve {
3436            EllipticCurve::Ed25519 => Some(JwkKeyFamily::Ed25519),
3437            _ => None,
3438        },
3439        _ => None,
3440    }
3441}
3442
3443// ---------------------------------------------------------------------------
3444// Claim path resolution
3445// ---------------------------------------------------------------------------
3446
3447/// Resolve a `role_claim` path against the explicit [`Claims`] fields
3448/// (`sub`, `aud`, `azp`, `client_id`, `scope`).
3449///
3450/// Operators commonly configure `role_claim = "scope"` or `"sub"` /
3451/// `"client_id"` to map first-class JWT claims to roles. These claims are
3452/// captured by [`Claims`] as named fields, so they never appear in the
3453/// `extra` map that [`resolve_claim_path`] inspects. This helper bridges
3454/// that gap by returning owned `String`s for those first-class fields
3455/// when the claim path matches one of them; the caller layers the result
3456/// over [`resolve_claim_path`] so dot-paths into custom claims continue
3457/// to work.
3458///
3459/// `scope` is split on whitespace per the OAuth 2.0 convention so a token
3460/// like `scope = "read write"` matches `claim_value = "read"` or
3461/// `"write"`. `aud` returns every audience entry. Other fields return
3462/// their value as a single element when present.
3463fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
3464    match path {
3465        "sub" => claims.sub.iter().cloned().collect(),
3466        "azp" => claims.azp.iter().cloned().collect(),
3467        "client_id" => claims.client_id.iter().cloned().collect(),
3468        "aud" => claims.aud.0.clone(),
3469        "scope" => claims
3470            .scope
3471            .as_deref()
3472            .unwrap_or("")
3473            .split_whitespace()
3474            .map(str::to_owned)
3475            .collect(),
3476        _ => Vec::new(),
3477    }
3478}
3479
3480/// Resolve a dot-separated claim path to a list of string values.
3481///
3482/// Handles three shapes:
3483/// - **String**: split on whitespace (OAuth `scope` convention).
3484/// - **Array of strings**: each element becomes a value (Keycloak `realm_access.roles`).
3485/// - **Nested object**: traversed by dot-separated segments (e.g. `realm_access.roles`).
3486///
3487/// Returns an empty vec if the path does not exist or the leaf is not a
3488/// string/array.
3489fn resolve_claim_path<'a>(
3490    extra: &'a HashMap<String, serde_json::Value>,
3491    path: &str,
3492) -> Vec<&'a str> {
3493    let mut segments = path.split('.');
3494    let Some(first) = segments.next() else {
3495        return Vec::new();
3496    };
3497
3498    let mut current: Option<&serde_json::Value> = extra.get(first);
3499
3500    for segment in segments {
3501        current = current.and_then(|v| v.get(segment));
3502    }
3503
3504    match current {
3505        Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
3506        Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
3507        _ => Vec::new(),
3508    }
3509}
3510
3511// ---------------------------------------------------------------------------
3512// JWT claims
3513// ---------------------------------------------------------------------------
3514
3515/// Standard + common JWT claims we care about.
3516#[derive(Debug, Deserialize)]
3517struct Claims {
3518    /// Subject (user or service account).
3519    sub: Option<String>,
3520    /// Audience - resource servers the token is intended for.
3521    /// Can be a single string or an array of strings per RFC 7519 Sec.4.1.3.
3522    #[serde(default)]
3523    aud: OneOrMany,
3524    /// Authorized party (OIDC Core Sec.2) - the OAuth client that was issued the token.
3525    azp: Option<String>,
3526    /// Client ID (some providers use this instead of azp).
3527    client_id: Option<String>,
3528    /// Space-separated scope string (OAuth 2.0 convention).
3529    scope: Option<String>,
3530    /// All remaining claims, captured for `role_claim` dot-path resolution.
3531    #[serde(flatten)]
3532    extra: HashMap<String, serde_json::Value>,
3533}
3534
3535/// Deserializes a JWT claim that can be either a single string or an array of strings.
3536#[derive(Debug, Default)]
3537struct OneOrMany(Vec<String>);
3538
3539impl OneOrMany {
3540    fn contains(&self, value: &str) -> bool {
3541        self.0.iter().any(|v| v == value)
3542    }
3543
3544    /// Render the audience list as a single comma-separated string for
3545    /// structured logging (e.g. `aud="a, b"`), preserving every entry so
3546    /// no debugging signal is lost. An empty list renders as `"-"`.
3547    fn log_display(&self) -> String {
3548        if self.0.is_empty() {
3549            "-".to_owned()
3550        } else {
3551            self.0.join(", ")
3552        }
3553    }
3554}
3555
3556/// Format a JSON `aud` claim (string OR array of strings) for structured
3557/// logging without losing shape.
3558///
3559/// The `aud` claim is legitimately either a single string or an array
3560/// (RFC 7519 §4.1.3). Rendering via `serde_json::Value::as_str()` alone
3561/// would drop array audiences (returns `None` → `"-"`), hiding real
3562/// values in the log. This joins arrays with `", "`, passes strings
3563/// through, and falls back to `"-"` only when the claim is truly absent
3564/// or an unexpected JSON type.
3565fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
3566    match value {
3567        Some(serde_json::Value::String(s)) => s.clone(),
3568        Some(serde_json::Value::Array(items)) => {
3569            let joined = items
3570                .iter()
3571                .filter_map(serde_json::Value::as_str)
3572                .collect::<Vec<_>>()
3573                .join(", ");
3574            if joined.is_empty() {
3575                "-".to_owned()
3576            } else {
3577                joined
3578            }
3579        }
3580        Some(
3581            serde_json::Value::Null
3582            | serde_json::Value::Bool(_)
3583            | serde_json::Value::Number(_)
3584            | serde_json::Value::Object(_),
3585        )
3586        | None => "-".to_owned(),
3587    }
3588}
3589
3590/// Render an optional JSON claim as a plain string for logging, without the
3591/// `Debug` wrapper/escaping (e.g. `sub="alice"` not `sub=Some(String("alice"))`).
3592/// Non-string or absent claims render as `"-"`.
3593fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
3594    value.and_then(serde_json::Value::as_str).unwrap_or("-")
3595}
3596
3597impl<'de> Deserialize<'de> for OneOrMany {
3598    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
3599        use serde::de;
3600
3601        struct Visitor;
3602        impl<'de> de::Visitor<'de> for Visitor {
3603            type Value = OneOrMany;
3604            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3605                f.write_str("a string or array of strings")
3606            }
3607            fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
3608                Ok(OneOrMany(vec![v.to_owned()]))
3609            }
3610            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
3611                let mut v = Vec::new();
3612                while let Some(s) = seq.next_element::<String>()? {
3613                    v.push(s);
3614                }
3615                Ok(OneOrMany(v))
3616            }
3617        }
3618        deserializer.deserialize_any(Visitor)
3619    }
3620}
3621
3622// ---------------------------------------------------------------------------
3623// JWT detection heuristic
3624// ---------------------------------------------------------------------------
3625
3626/// Returns true if the token looks like a JWT (3 dot-separated segments
3627/// where the first segment decodes to JSON containing `"alg"`).
3628#[must_use]
3629pub fn looks_like_jwt(token: &str) -> bool {
3630    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3631
3632    let mut parts = token.splitn(4, '.');
3633    let Some(header_b64) = parts.next() else {
3634        return false;
3635    };
3636    // Must have exactly 3 segments.
3637    if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
3638        return false;
3639    }
3640    // Try to decode the header segment.
3641    let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
3642        return false;
3643    };
3644    // Check for "alg" key in the JSON.
3645    let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
3646        return false;
3647    };
3648    header.get("alg").is_some()
3649}
3650
3651// ---------------------------------------------------------------------------
3652// Protected Resource Metadata (RFC 9728)
3653// ---------------------------------------------------------------------------
3654
3655/// Resolve the `authorization_servers` list for Protected Resource Metadata.
3656///
3657/// RFC 9728 3.2: a zero-valued claim MUST be omitted, so an empty result means
3658/// "leave the field out" rather than "emit `[]`".
3659fn resolve_authorization_servers<'a>(server_url: &'a str, config: &'a OAuthConfig) -> Vec<&'a str> {
3660    if let Some(ref explicit) = config.authorization_servers {
3661        return explicit.iter().map(String::as_str).collect();
3662    }
3663    // Advertise this server only when it actually mounts the OAuth endpoints.
3664    // `install_oauth_proxy_routes` mounts `/authorize`, `/token`, and
3665    // `/.well-known/oauth-authorization-server` ONLY when `proxy` is set, while
3666    // Protected Resource Metadata is served unconditionally -- so without a
3667    // proxy the local URL resolves to a 404 and the upstream issuer is the only
3668    // truthful answer. An application that mounts its own facade through
3669    // `with_extra_router` must say so via `authorization_servers`.
3670    if config.proxy.is_some() {
3671        vec![server_url]
3672    } else {
3673        vec![config.issuer.as_str()]
3674    }
3675}
3676
3677/// Build the Protected Resource Metadata JSON response.
3678///
3679/// `authorization_servers` follows [`OAuthConfig::authorization_servers`]:
3680/// the upstream issuer for a plain resource server, this server's own URL
3681/// when the built-in proxy is mounted, or an explicit operator override.
3682#[must_use]
3683pub fn protected_resource_metadata(
3684    resource_url: &str,
3685    server_url: &str,
3686    config: &OAuthConfig,
3687) -> serde_json::Value {
3688    let mut meta = serde_json::json!({
3689        "resource": resource_url,
3690        "bearer_methods_supported": ["header"],
3691    });
3692    let Some(obj) = meta.as_object_mut() else {
3693        return meta;
3694    };
3695    // RFC 9728 3.2: omit zero-valued claims rather than emitting empty arrays.
3696    let auth_servers = resolve_authorization_servers(server_url, config);
3697    if !auth_servers.is_empty() {
3698        obj.insert(
3699            "authorization_servers".into(),
3700            serde_json::json!(auth_servers),
3701        );
3702    }
3703    let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3704    if !scopes.is_empty() {
3705        obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3706    }
3707    meta
3708}
3709
3710/// Build the Authorization Server Metadata JSON response (RFC 8414).
3711///
3712/// Returned at `GET /.well-known/oauth-authorization-server` so MCP
3713/// clients can discover the authorization and token endpoints.
3714///
3715/// `issuer` defaults to `server_url`, the origin this document is served from,
3716/// as RFC 8414 3.3 requires. The upstream [`OAuthConfig::issuer`] remains the
3717/// *token* issuer and is still what inbound JWT `iss` claims are validated
3718/// against — the two are deliberately different. See
3719/// [`OAuthConfig::authorization_server_metadata_issuer`] for the legacy
3720/// opt-out.
3721#[must_use]
3722pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
3723    let issuer = config
3724        .authorization_server_metadata_issuer
3725        .as_deref()
3726        .unwrap_or(server_url);
3727    let mut meta = serde_json::json!({
3728        "issuer": issuer,
3729        "authorization_endpoint": format!("{server_url}/authorize"),
3730        "token_endpoint": format!("{server_url}/token"),
3731        "registration_endpoint": format!("{server_url}/register"),
3732        "response_types_supported": ["code"],
3733        "grant_types_supported": ["authorization_code", "refresh_token"],
3734        "code_challenge_methods_supported": ["S256"],
3735        "token_endpoint_auth_methods_supported": ["none"],
3736    });
3737    // RFC 8414 3.2: omit zero-valued claims rather than emitting `[]`.
3738    let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3739    if !scopes.is_empty()
3740        && let Some(obj) = meta.as_object_mut()
3741    {
3742        obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3743    }
3744    if let Some(proxy) = &config.proxy
3745        && proxy.expose_admin_endpoints
3746        && let Some(obj) = meta.as_object_mut()
3747    {
3748        if proxy.introspection_url.is_some() {
3749            obj.insert(
3750                "introspection_endpoint".into(),
3751                serde_json::Value::String(format!("{server_url}/introspect")),
3752            );
3753        }
3754        if proxy.revocation_url.is_some() {
3755            obj.insert(
3756                "revocation_endpoint".into(),
3757                serde_json::Value::String(format!("{server_url}/revoke")),
3758            );
3759        }
3760        if proxy.require_auth_on_admin_endpoints {
3761            obj.insert(
3762                "introspection_endpoint_auth_methods_supported".into(),
3763                serde_json::json!(["bearer"]),
3764            );
3765            obj.insert(
3766                "revocation_endpoint_auth_methods_supported".into(),
3767                serde_json::json!(["bearer"]),
3768            );
3769        }
3770    }
3771    meta
3772}
3773
3774// ---------------------------------------------------------------------------
3775// OAuth 2.1 Proxy Handlers
3776// ---------------------------------------------------------------------------
3777
3778/// Handle `GET /authorize` - redirect to the upstream authorize URL.
3779///
3780/// Forwards all OAuth query parameters (`response_type`, `client_id`,
3781/// `redirect_uri`, `scope`, `state`, `code_challenge`,
3782/// `code_challenge_method`) to the upstream identity provider.
3783/// The upstream provider (e.g. Keycloak) presents the login UI and
3784/// redirects the user back to the MCP client's `redirect_uri` with an
3785/// authorization code.
3786#[must_use]
3787pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
3788    use axum::{
3789        http::{StatusCode, header},
3790        response::IntoResponse,
3791    };
3792
3793    // Replace the client_id in the query with the upstream client_id.
3794    let upstream_query =
3795        rewrite_client_auth_params(query, &proxy.client_id, proxy.strip_resource_param);
3796    let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
3797
3798    (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
3799}
3800
3801/// Handle `POST /token` - proxy the token request to the upstream provider.
3802///
3803/// Forwards the request body (authorization code exchange or refresh token
3804/// grant) to the upstream token endpoint, injecting client credentials
3805/// when configured (confidential client). Returns the upstream response as-is.
3806// NOT cancel-safe: once the upstream POST is in flight the authorization
3807// code may be consumed or a token minted upstream. Cancelling between send
3808// and response-forwarding loses the token while the grant is spent, so the
3809// client must retry with a fresh code rather than replay this one.
3810pub async fn handle_token(
3811    http: &OauthHttpClient,
3812    proxy: &OAuthProxyConfig,
3813    body: &str,
3814) -> axum::response::Response {
3815    use axum::{
3816        http::{StatusCode, header},
3817        response::IntoResponse,
3818    };
3819
3820    // Replace client_id in the form body with the upstream client_id.
3821    let mut upstream_body =
3822        rewrite_client_auth_params(body, &proxy.client_id, proxy.strip_resource_param);
3823
3824    // For confidential clients, inject the client_secret.
3825    if let Some(ref secret) = proxy.client_secret {
3826        use std::fmt::Write;
3827
3828        use secrecy::ExposeSecret;
3829        let _ = write!(
3830            upstream_body,
3831            "&client_secret={}",
3832            urlencoding::encode(secret.expose_secret())
3833        );
3834    }
3835
3836    let result = http
3837        .send_screened(
3838            &proxy.token_url,
3839            http.credential_client
3840                .post(&proxy.token_url)
3841                .header("Content-Type", "application/x-www-form-urlencoded")
3842                .body(upstream_body),
3843        )
3844        .await;
3845
3846    match result {
3847        Ok(resp) => {
3848            let status =
3849                StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3850            let Ok(body_bytes) =
3851                read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
3852            else {
3853                return oauth_error_response(
3854                    StatusCode::BAD_GATEWAY,
3855                    "server_error",
3856                    "upstream response too large or unreadable",
3857                );
3858            };
3859            (
3860                status,
3861                [(header::CONTENT_TYPE, "application/json")],
3862                body_bytes,
3863            )
3864                .into_response()
3865        }
3866        Err(e) => {
3867            tracing::error!(error = %e, "OAuth token proxy request failed");
3868            (
3869                StatusCode::BAD_GATEWAY,
3870                [(header::CONTENT_TYPE, "application/json")],
3871                "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
3872            )
3873                .into_response()
3874        }
3875    }
3876}
3877
3878/// Handle `POST /register` - return the pre-configured `client_id`.
3879///
3880/// MCP clients call this to discover which `client_id` to use in the
3881/// authorization flow.  We return the upstream `client_id` from config
3882/// and echo back any `redirect_uris` from the request body (required
3883/// by the MCP SDK's Zod validation).
3884#[must_use]
3885pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
3886    let mut resp = serde_json::json!({
3887        "client_id": proxy.client_id,
3888        "token_endpoint_auth_method": "none",
3889    });
3890    if let Some(uris) = body.get("redirect_uris")
3891        && let Some(obj) = resp.as_object_mut()
3892    {
3893        obj.insert("redirect_uris".into(), uris.clone());
3894    }
3895    if let Some(name) = body.get("client_name")
3896        && let Some(obj) = resp.as_object_mut()
3897    {
3898        obj.insert("client_name".into(), name.clone());
3899    }
3900    resp
3901}
3902
3903/// Handle `POST /introspect` - RFC 7662 token introspection proxy.
3904///
3905/// Forwards the request body to the upstream introspection endpoint,
3906/// injecting client credentials when configured. Returns the upstream
3907/// response as-is.  Requires `proxy.introspection_url` to be `Some`.
3908// cancel-safe: introspection is a read-only upstream query; cancelling only
3909// discards the answer and leaves no upstream state change.
3910pub async fn handle_introspect(
3911    http: &OauthHttpClient,
3912    proxy: &OAuthProxyConfig,
3913    body: &str,
3914) -> axum::response::Response {
3915    let Some(ref url) = proxy.introspection_url else {
3916        return oauth_error_response(
3917            axum::http::StatusCode::NOT_FOUND,
3918            "not_supported",
3919            "introspection endpoint is not configured",
3920        );
3921    };
3922    proxy_oauth_admin_request(http, proxy, url, body).await
3923}
3924
3925/// Handle `POST /revoke` - RFC 7009 token revocation proxy.
3926///
3927/// Forwards the request body to the upstream revocation endpoint,
3928/// injecting client credentials when configured. Returns the upstream
3929/// response as-is (per RFC 7009, typically 200 with empty body).
3930/// Requires `proxy.revocation_url` to be `Some`.
3931// cancel-safe for security purposes: cancellation cannot un-revoke a token.
3932// The caller may lose the confirmation response while the revocation still
3933// takes effect upstream, which fails in the safe direction.
3934pub async fn handle_revoke(
3935    http: &OauthHttpClient,
3936    proxy: &OAuthProxyConfig,
3937    body: &str,
3938) -> axum::response::Response {
3939    let Some(ref url) = proxy.revocation_url else {
3940        return oauth_error_response(
3941            axum::http::StatusCode::NOT_FOUND,
3942            "not_supported",
3943            "revocation endpoint is not configured",
3944        );
3945    };
3946    proxy_oauth_admin_request(http, proxy, url, body).await
3947}
3948
3949/// Shared proxy for introspection/revocation: injects `client_id` and
3950/// `client_secret` (when configured) and forwards the form-encoded body
3951/// upstream, returning the upstream status/body verbatim.
3952// cancel-safe for local state: credential rewriting is local, and
3953// `send_screened`/`read_response_capped` publish no server state. A repeated
3954// revocation cannot restore a token; introspection is read-only.
3955async fn proxy_oauth_admin_request(
3956    http: &OauthHttpClient,
3957    proxy: &OAuthProxyConfig,
3958    upstream_url: &str,
3959    body: &str,
3960) -> axum::response::Response {
3961    use axum::{
3962        http::{StatusCode, header},
3963        response::IntoResponse,
3964    };
3965
3966    // `false`: `resource` is not a parameter of RFC 7662 introspection or
3967    // RFC 7009 revocation requests, so the strip flag -- which exists purely
3968    // to satisfy Entra's authorization-code flow -- must not reach this path.
3969    let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id, false);
3970    if let Some(ref secret) = proxy.client_secret {
3971        use std::fmt::Write;
3972
3973        use secrecy::ExposeSecret;
3974        let _ = write!(
3975            upstream_body,
3976            "&client_secret={}",
3977            urlencoding::encode(secret.expose_secret())
3978        );
3979    }
3980
3981    let result = http
3982        .send_screened(
3983            upstream_url,
3984            http.credential_client
3985                .post(upstream_url)
3986                .header("Content-Type", "application/x-www-form-urlencoded")
3987                .body(upstream_body),
3988        )
3989        .await;
3990
3991    match result {
3992        Ok(resp) => {
3993            let status =
3994                StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3995            let content_type = resp
3996                .headers()
3997                .get(header::CONTENT_TYPE)
3998                .and_then(|v| v.to_str().ok())
3999                .unwrap_or("application/json")
4000                .to_owned();
4001            let Ok(body_bytes) =
4002                read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
4003            else {
4004                return oauth_error_response(
4005                    StatusCode::BAD_GATEWAY,
4006                    "server_error",
4007                    "upstream response too large or unreadable",
4008                );
4009            };
4010            (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
4011        }
4012        Err(e) => {
4013            tracing::error!(
4014                error = %e,
4015                url = %oauth_request_target_for_log(upstream_url),
4016                "OAuth admin proxy request failed"
4017            );
4018            oauth_error_response(
4019                StatusCode::BAD_GATEWAY,
4020                "server_error",
4021                "upstream endpoint unreachable",
4022            )
4023        }
4024    }
4025}
4026
4027/// Read an upstream response body, aborting if it exceeds `max_bytes`.
4028///
4029/// Mirrors the bounded-streaming read used for JWKS
4030/// ([`JwksCache::fetch_jwks`]) so OAuth proxy paths never buffer an
4031/// unbounded upstream response. Fails **closed**: on a transport error or
4032/// a body that grows past the cap it returns `Err(())` (the caller maps
4033/// this to a generic `502`); it never returns a truncated body that a
4034/// caller might forward as if complete. `context` is an authority-only
4035/// label for logs (never a full URL with credentials).
4036// cancel-safe: the response body is accumulated in a local `Vec` and returned
4037// only after EOF; cancellation during `resp.chunk()` drops the partial buffer
4038// and never forwards a truncated OAuth response.
4039async fn read_response_capped(
4040    mut resp: reqwest::Response,
4041    max_bytes: u64,
4042    context: &str,
4043) -> Result<Vec<u8>, ()> {
4044    let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
4045    let mut body = Vec::with_capacity(initial_capacity);
4046    loop {
4047        match resp.chunk().await {
4048            Ok(Some(chunk)) => {
4049                let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
4050                let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
4051                if body_len.saturating_add(chunk_len) > max_bytes {
4052                    tracing::warn!(
4053                        context = context,
4054                        max_bytes = max_bytes,
4055                        "upstream OAuth response exceeded size cap; failing closed"
4056                    );
4057                    return Err(());
4058                }
4059                body.extend_from_slice(&chunk);
4060            }
4061            Ok(None) => return Ok(body),
4062            Err(error) => {
4063                tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
4064                return Err(());
4065            }
4066        }
4067    }
4068}
4069
4070fn oauth_error_response(
4071    status: axum::http::StatusCode,
4072    error: &str,
4073    description: &str,
4074) -> axum::response::Response {
4075    use axum::{http::header, response::IntoResponse};
4076    let body = serde_json::json!({
4077        "error": error,
4078        "error_description": description,
4079    });
4080    (
4081        status,
4082        [(header::CONTENT_TYPE, "application/json")],
4083        body.to_string(),
4084    )
4085        .into_response()
4086}
4087
4088// ---------------------------------------------------------------------------
4089// RFC 8693 Token Exchange
4090// ---------------------------------------------------------------------------
4091
4092/// OAuth error response body from the authorization server.
4093#[derive(Debug, Deserialize)]
4094struct OAuthErrorResponse {
4095    error: String,
4096    error_description: Option<String>,
4097}
4098
4099/// Choose what to log for an upstream `error_description`.
4100///
4101/// SECURITY: `error_description` is free-form text chosen by the authorization
4102/// server and may echo request parameters back, so it is redacted unless an
4103/// operator explicitly enables `observability.log_upstream_error_bodies`. The
4104/// sibling `error` field is an enumerated RFC 6749 §5.2 / RFC 8693 code rather
4105/// than free text, and is logged unconditionally.
4106fn upstream_error_description_for_log(description: Option<&str>) -> &str {
4107    if crate::diagnostics::upstream_error_bodies() {
4108        description.unwrap_or("")
4109    } else {
4110        "[REDACTED]"
4111    }
4112}
4113
4114/// Map an upstream OAuth error code to an allowlisted short code suitable
4115/// for client exposure.
4116///
4117/// Returns one of the RFC 6749 §5.2 / RFC 8693 standard codes. Unknown or
4118/// non-standard codes collapse to `server_error` to avoid leaking
4119/// authorization-server implementation details to MCP clients.
4120fn sanitize_oauth_error_code(raw: &str) -> &'static str {
4121    match raw {
4122        "invalid_request" => "invalid_request",
4123        "invalid_client" => "invalid_client",
4124        "invalid_grant" => "invalid_grant",
4125        "unauthorized_client" => "unauthorized_client",
4126        "unsupported_grant_type" => "unsupported_grant_type",
4127        "invalid_scope" => "invalid_scope",
4128        "temporarily_unavailable" => "temporarily_unavailable",
4129        // RFC 8693 token-exchange specific.
4130        "invalid_target" => "invalid_target",
4131        // Anything else (including upstream-specific codes that may leak
4132        // implementation details) collapses to a generic short code.
4133        _ => "server_error",
4134    }
4135}
4136
4137/// Exchange an inbound access token for a downstream access token
4138/// via RFC 8693 token exchange.
4139///
4140/// The MCP server calls this to swap a user's MCP-scoped JWT
4141/// (`subject_token`) for a new JWT scoped to a downstream API
4142/// identified by [`TokenExchangeConfig::audience`].
4143///
4144/// # Errors
4145///
4146/// Returns an error if the HTTP request fails, the authorization
4147/// server rejects the exchange, or the response cannot be parsed.
4148// NOT cancel-safe, and NOT fixable at this layer: once `send_screened` puts the
4149// RFC 8693 POST on the wire, dropping this future cannot un-send it. The
4150// authorization server may mint a downstream token that never reaches the
4151// caller and that nothing here records. No local cache is torn, but retries may
4152// duplicate upstream issuance.
4153//
4154// Callers that can be cancelled should use `exchange_token_with_cancel`, which
4155// pre-checks the token, detaches the in-flight exchange rather than dropping it,
4156// and audits a token minted after the caller went away. That is a mitigation,
4157// not a guarantee -- see its docs for what remains unattainable.
4158pub async fn exchange_token(
4159    http: &OauthHttpClient,
4160    config: &TokenExchangeConfig,
4161    subject_token: &str,
4162) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4163    exchange_token_inner(http, config, subject_token, SuccessLogMode::Normal).await
4164}
4165
4166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4167enum SuccessLogMode {
4168    Normal,
4169    Suppress,
4170}
4171
4172async fn exchange_token_inner(
4173    http: &OauthHttpClient,
4174    config: &TokenExchangeConfig,
4175    subject_token: &str,
4176    success_log: SuccessLogMode,
4177) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4178    use secrecy::ExposeSecret;
4179
4180    let client = http.client_for(config);
4181    let mut req = client
4182        .post(&config.token_url)
4183        .header("Content-Type", "application/x-www-form-urlencoded")
4184        .header("Accept", "application/json");
4185
4186    // M-H4: client authentication strategy.
4187    //   * `client_secret` set -> RFC 6749 §2.3.1 HTTP Basic.
4188    //   * `client_cert`   set -> RFC 8705 §2 mTLS via the cert-bearing
4189    //     `reqwest::Client` selected by `client_for`. NO Authorization
4190    //     header is sent: presenting a TLS client certificate at
4191    //     handshake time *is* the client authentication.
4192    // `OAuthConfig::validate` enforces exactly-one-of so neither both
4193    // nor neither reach this code path.
4194    if config.client_cert.is_none()
4195        && let Some(ref secret) = config.client_secret
4196    {
4197        use base64::Engine;
4198        let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
4199            "{}:{}",
4200            urlencoding::encode(&config.client_id),
4201            urlencoding::encode(secret.expose_secret()),
4202        ));
4203        req = req.header("Authorization", format!("Basic {credentials}"));
4204    }
4205
4206    let form_body = build_exchange_form(config, subject_token);
4207
4208    let resp = http
4209        .send_screened(&config.token_url, req.body(form_body))
4210        .await
4211        .map_err(|e| {
4212            tracing::error!(error = %e, "token exchange request failed");
4213            // Do NOT leak upstream URL, reqwest internals, or DNS detail to clients.
4214            crate::error::RmcpServerKitError::Auth("server_error".into())
4215        })?;
4216
4217    let status = resp.status();
4218    let body_bytes =
4219        read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
4220            .await
4221            .map_err(|()| {
4222                // read_response_capped already logged the cause (oversize / transport).
4223                crate::error::RmcpServerKitError::Auth("server_error".into())
4224            })?;
4225
4226    if !status.is_success() {
4227        core::hint::cold_path();
4228        // Parse upstream error for logging only; client-visible payload is a
4229        // sanitized short code from the RFC 6749 §5.2 / RFC 8693 allowlist.
4230        let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
4231        let short_code = parsed
4232            .as_ref()
4233            .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
4234        if let Some(ref e) = parsed {
4235            let description = upstream_error_description_for_log(e.error_description.as_deref());
4236            tracing::warn!(
4237                status = %status,
4238                upstream_error = %e.error,
4239                upstream_error_description = description,
4240                client_code = %short_code,
4241                "token exchange rejected by authorization server",
4242            );
4243        } else {
4244            tracing::warn!(
4245                status = %status,
4246                client_code = %short_code,
4247                "token exchange rejected (unparseable upstream body)",
4248            );
4249        }
4250        return Err(crate::error::RmcpServerKitError::Auth(short_code.into()));
4251    }
4252
4253    let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
4254        tracing::error!(error = %e, "failed to parse token exchange response");
4255        // Avoid surfacing serde internals; map to sanitized short code so
4256        // RmcpServerKitError::into_response cannot leak parser detail to the client.
4257        crate::error::RmcpServerKitError::Auth("server_error".into())
4258    })?;
4259
4260    match success_log {
4261        SuccessLogMode::Normal => log_exchanged_token(&exchanged),
4262        SuccessLogMode::Suppress => {}
4263    }
4264
4265    Ok(exchanged)
4266}
4267
4268/// Exchange an inbound access token while preserving post-send observability
4269/// if the caller cancels or times out.
4270///
4271/// This wrapper does **not** make RFC 8693 token exchange strictly
4272/// cancel-safe. Once the POST reaches the authorization server, this process
4273/// cannot un-send it or prove whether the server minted a downstream token.
4274/// Instead it provides the three local guarantees that are achievable: work is
4275/// not started when `ct` is already cancelled, the in-flight exchange future is
4276/// not dropped while reading the response, and an abandoned successful exchange
4277/// emits a sanitized warning so the orphaned downstream credential is
4278/// observable.
4279///
4280/// On cancellation or timeout after the spawned exchange starts, the exchange
4281/// task is deliberately detached and allowed to finish under the existing
4282/// [`OauthHttpClient`] request budgets. The task is **not** aborted. If it later
4283/// receives a successful [`ExchangedToken`] after the caller has gone away, it
4284/// discards the token and logs only bounded metadata (`expires_in` and a
4285/// truncated `issued_token_type`); token material and endpoint details are never
4286/// logged.
4287///
4288/// # Resource caveat
4289///
4290/// Detaching is unbounded in *count* under a cancel storm: every detached task
4291/// is time-bounded by the HTTP client's connect/total timeouts, but this helper
4292/// does not cap how many detached exchanges can exist at once. Use it only
4293/// behind the crate's existing authentication, rate-limit, and concurrency
4294/// controls (or equivalent caller-side controls).
4295///
4296/// # Errors
4297///
4298/// The completed outcome carries the exact [`Result`] returned by
4299/// [`exchange_token`]. Cancellation and timeout are reported structurally via
4300/// [`crate::cancel::DetachOutcome`] and do not construct client-visible error
4301/// strings.
4302#[must_use = "DetachOutcome must be inspected to distinguish completion from cancel/timeout"]
4303pub async fn exchange_token_with_cancel(
4304    http: &OauthHttpClient,
4305    config: &TokenExchangeConfig,
4306    subject_token: &str,
4307    ct: &tokio_util::sync::CancellationToken,
4308    timeout: Option<Duration>,
4309) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4310    // Pre-cancel check FIRST: do not clone config, client, or subject token for
4311    // an already-abandoned request. In particular, cloning the subject token
4312    // would allocate and keep credential-adjacent material alive for work that
4313    // the caller has already told us not to start.
4314    if ct.is_cancelled() {
4315        return crate::cancel::DetachOutcome::Cancelled;
4316    }
4317
4318    let (tx, rx) = tokio::sync::oneshot::channel();
4319    let http = http.clone();
4320    let config = config.clone();
4321    let subject_token = subject_token.to_owned();
4322
4323    // This task is intentionally detached on caller cancel/timeout. A plain
4324    // `run_with_cancel_and_timeout(exchange_token(...))` would drop the
4325    // JoinHandle in those arms, but it would not keep a result sink. The
4326    // `oneshot::Sender` is the sink: if the receiver is gone, `send` returns
4327    // the result to this task so an abandoned success can be audited without
4328    // logging token material.
4329    tokio::spawn(
4330        async move {
4331            let result =
4332                exchange_token_inner(&http, &config, &subject_token, SuccessLogMode::Suppress)
4333                    .await;
4334            if let Err(result) = tx.send(result) {
4335                audit_abandoned_exchange_result(result);
4336            }
4337        }
4338        .instrument(tracing::Span::current()),
4339    );
4340
4341    receive_exchange_result_with_cancel(rx, ct, timeout).await
4342}
4343
4344async fn receive_exchange_result_with_cancel(
4345    rx: tokio::sync::oneshot::Receiver<Result<ExchangedToken, crate::error::RmcpServerKitError>>,
4346    ct: &tokio_util::sync::CancellationToken,
4347    timeout: Option<Duration>,
4348) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4349    // `biased;` is deliberate and matches `cancel::run_with_cancel_and_timeout`:
4350    // the receiver arm comes first so a ready completion wins over a
4351    // simultaneously-ready cancellation or timeout. Dropping the receiver on
4352    // the other arms is not a leak; it is the signal that tells the spawned task
4353    // to audit an eventual success via `Sender::send`'s returned value.
4354    if let Some(t) = timeout {
4355        tokio::select! {
4356            biased;
4357            received = rx => map_exchange_receiver(received),
4358            () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4359            () = tokio::time::sleep(t) => crate::cancel::DetachOutcome::TimedOut,
4360        }
4361    } else {
4362        tokio::select! {
4363            biased;
4364            received = rx => map_exchange_receiver(received),
4365            () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4366        }
4367    }
4368}
4369
4370fn map_exchange_receiver(
4371    received: Result<
4372        Result<ExchangedToken, crate::error::RmcpServerKitError>,
4373        tokio::sync::oneshot::error::RecvError,
4374    >,
4375) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4376    match received {
4377        Ok(result) => crate::cancel::DetachOutcome::Completed(result),
4378        Err(error) => {
4379            tracing::error!(error = %error, "token exchange task ended before returning a result");
4380            crate::cancel::DetachOutcome::Completed(Err(
4381                crate::error::RmcpServerKitError::Internal("server_error".into()),
4382            ))
4383        }
4384    }
4385}
4386
4387fn audit_abandoned_exchange_result(
4388    result: Result<ExchangedToken, crate::error::RmcpServerKitError>,
4389) {
4390    match result {
4391        Ok(token) => {
4392            let (issued_token_type, issued_token_type_truncated) = token
4393                .issued_token_type
4394                .as_deref()
4395                .map_or_else(|| ("-".to_owned(), false), truncate_kid_for_log);
4396            tracing::warn!(
4397                expires_in = token.expires_in,
4398                issued_token_type = %issued_token_type,
4399                issued_token_type_truncated,
4400                "token exchange minted downstream token after caller detached; discarded token material"
4401            );
4402        }
4403        Err(error) => {
4404            tracing::debug!(error = %error, "token exchange failed after caller detached");
4405        }
4406    }
4407}
4408
4409fn push_form_param(body: &mut String, name: &str, value: &str) {
4410    body.push('&');
4411    body.push_str(name);
4412    body.push('=');
4413    body.push_str(&urlencoding::encode(value));
4414}
4415
4416/// Build the RFC 8693 token-exchange form body.
4417///
4418/// Emits the three REQUIRED parameters (RFC 8693 §2.1) unconditionally, then
4419/// each OPTIONAL parameter only when configured. Parameter ORDER is fixed and
4420/// load-bearing: `resource` and `scope` are appended after `audience` and
4421/// before `client_id` so that a config predating 3.8.0 — where both are
4422/// necessarily `None` — produces a byte-identical body to earlier releases.
4423fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
4424    let mut body = format!(
4425        "grant_type={}&subject_token={}&subject_token_type={}",
4426        urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
4427        urlencoding::encode(subject_token),
4428        urlencoding::encode(TOKEN_TYPE_ACCESS_TOKEN),
4429    );
4430    if let Some(value) = config.requested_token_type.wire_value() {
4431        push_form_param(&mut body, "requested_token_type", value);
4432    }
4433    if let Some(audience) = config.audience.as_deref() {
4434        push_form_param(&mut body, "audience", audience);
4435    }
4436    if let Some(resource) = config.resource.as_deref() {
4437        push_form_param(&mut body, "resource", resource);
4438    }
4439    if let Some(scope) = config.scope.as_deref() {
4440        push_form_param(&mut body, "scope", scope);
4441    }
4442    if config.client_secret.is_none() {
4443        push_form_param(&mut body, "client_id", &config.client_id);
4444    }
4445    body
4446}
4447
4448/// Debug-log the exchanged token. For JWTs, decode and log claim summary;
4449/// for opaque tokens, log length + issued type.
4450fn log_exchanged_token(exchanged: &ExchangedToken) {
4451    use base64::Engine;
4452
4453    if !looks_like_jwt(&exchanged.access_token) {
4454        tracing::debug!(
4455            token_len = exchanged.access_token.len(),
4456            issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
4457            expires_in = exchanged.expires_in,
4458            "exchanged token (opaque)",
4459        );
4460        return;
4461    }
4462    let Some(payload) = exchanged.access_token.split('.').nth(1) else {
4463        return;
4464    };
4465    let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
4466        return;
4467    };
4468    let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
4469        return;
4470    };
4471    let expose_claims = crate::diagnostics::oauth_claim_values();
4472    let sub = gated_claim_str(claims.get("sub"), expose_claims);
4473    let aud = gated_claim_aud(claims.get("aud"), expose_claims);
4474    let azp = gated_claim_str(claims.get("azp"), expose_claims);
4475    let iss = gated_claim_str(claims.get("iss"), expose_claims);
4476    tracing::debug!(
4477        sub = sub,
4478        aud = %aud,
4479        azp = azp,
4480        iss = iss,
4481        expires_in = exchanged.expires_in,
4482        "exchanged token claims (JWT)",
4483    );
4484}
4485
4486fn gated_claim_str(value: Option<&serde_json::Value>, expose: bool) -> &str {
4487    if expose {
4488        fmt_json_str(value)
4489    } else {
4490        "[REDACTED]"
4491    }
4492}
4493
4494fn gated_claim_aud(value: Option<&serde_json::Value>, expose: bool) -> String {
4495    if expose {
4496        fmt_json_aud(value)
4497    } else {
4498        "[REDACTED]".to_owned()
4499    }
4500}
4501
4502/// Form/query parameters that carry OAuth client authentication.
4503///
4504/// Every one of these is proxy-owned: the upstream client identity and its
4505/// credentials are configured server-side and must never be influenced by the
4506/// downstream caller.
4507const CLIENT_AUTH_PARAMS: [&str; 4] = [
4508    "client_id",
4509    "client_secret",
4510    "client_assertion",
4511    "client_assertion_type",
4512];
4513
4514/// Re-serialize an `application/x-www-form-urlencoded` query or body with every
4515/// caller-supplied client-authentication parameter removed, then inject the
4516/// proxy's `client_id`.
4517///
4518/// This parses and re-serializes rather than rewriting the raw string. The
4519/// previous implementation split on `&` and dropped segments literally starting
4520/// with `client_id=`, which let a caller smuggle client credentials past the
4521/// proxy two ways:
4522///
4523/// - percent-encoded keys (`%63lient_id=...`, `client%5Fid=...`) do not match the
4524///   literal prefix but decode upstream to `client_id`; and
4525/// - `client_secret` was never filtered at all, so a caller-supplied secret
4526///   survived alongside the proxy's own injected one on credential-bearing POSTs.
4527///
4528/// Either way the upstream IdP received duplicate decoded parameters, and a
4529/// first-wins parser would honour the caller's value over the proxy's.
4530///
4531/// Decoded values and the relative order of non-client parameters are preserved
4532/// (OAuth permits repeated `scope` / `resource`). The raw byte encoding is *not*
4533/// preserved: `form_urlencoded` normalizes `+` and percent-escapes on
4534/// re-serialization, which is semantically equivalent for form data.
4535fn rewrite_client_auth_params(
4536    params: &str,
4537    upstream_client_id: &str,
4538    strip_resource: bool,
4539) -> String {
4540    let mut out = url::form_urlencoded::Serializer::new(String::new());
4541    for (key, value) in url::form_urlencoded::parse(params.as_bytes()) {
4542        if CLIENT_AUTH_PARAMS.contains(&key.as_ref()) {
4543            continue;
4544        }
4545        // SECURITY: `resource` is the ONLY caller parameter this flag drops.
4546        // Comparison happens post-decode, so `%72esource` cannot smuggle past
4547        // it -- the same property that protects CLIENT_AUTH_PARAMS above.
4548        if strip_resource && key.as_ref() == "resource" {
4549            continue;
4550        }
4551        out.append_pair(&key, &value);
4552    }
4553    out.append_pair("client_id", upstream_client_id);
4554    out.finish()
4555}
4556
4557#[cfg(test)]
4558mod tests {
4559    use std::{sync::Arc, time::Instant};
4560
4561    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
4562
4563    use super::*;
4564
4565    // -- F2 regression: client-auth parameter smuggling in the OAuth proxy --
4566    //
4567    // The previous `replace_client_id` split on `&` and dropped segments
4568    // literally starting with `client_id=`. Percent-encoded keys survived that
4569    // filter but decode upstream to `client_id`, and `client_secret` was never
4570    // filtered at all, so a caller could ship duplicate client credentials to
4571    // the IdP alongside the proxy's own. Every case below forwarded the
4572    // attacker value before the fix.
4573
4574    /// Decode a rewritten form back into `(key, value)` pairs. Assertions run
4575    /// on decoded pairs, never on raw bytes: `form_urlencoded` normalizes `+`
4576    /// and percent-escapes on re-serialization, so byte equality is not a
4577    /// meaningful contract here.
4578    fn decoded_pairs(form: &str) -> Vec<(String, String)> {
4579        url::form_urlencoded::parse(form.as_bytes())
4580            .map(|(k, v)| (k.into_owned(), v.into_owned()))
4581            .collect()
4582    }
4583
4584    #[test]
4585    fn rewrite_drops_percent_encoded_client_id_key() {
4586        let out = rewrite_client_auth_params("%63lient_id=attacker&scope=read", "proxy-id", false);
4587        let pairs = decoded_pairs(&out);
4588        let client_ids: Vec<&String> = pairs
4589            .iter()
4590            .filter(|(k, _)| k == "client_id")
4591            .map(|(_, v)| v)
4592            .collect();
4593        assert_eq!(client_ids, vec!["proxy-id"], "smuggled client_id survived");
4594    }
4595
4596    #[test]
4597    fn rewrite_drops_underscore_encoded_client_id_key() {
4598        let out = rewrite_client_auth_params("client%5Fid=attacker&scope=read", "proxy-id", false);
4599        let pairs = decoded_pairs(&out);
4600        assert!(
4601            !pairs.iter().any(|(_, v)| v == "attacker"),
4602            "smuggled client_id survived: {pairs:?}"
4603        );
4604    }
4605
4606    #[test]
4607    fn rewrite_drops_caller_supplied_client_secret() {
4608        let out = rewrite_client_auth_params(
4609            "client_secret=attacker-secret&scope=read",
4610            "proxy-id",
4611            false,
4612        );
4613        let pairs = decoded_pairs(&out);
4614        assert!(
4615            !pairs.iter().any(|(k, _)| k == "client_secret"),
4616            "caller client_secret survived: {pairs:?}"
4617        );
4618    }
4619
4620    #[test]
4621    fn rewrite_drops_caller_supplied_client_assertion() {
4622        let out = rewrite_client_auth_params(
4623            "client_assertion=ey.evil&client_assertion_type=urn:evil&scope=read",
4624            "proxy-id",
4625            false,
4626        );
4627        let pairs = decoded_pairs(&out);
4628        assert!(
4629            !pairs
4630                .iter()
4631                .any(|(k, _)| k == "client_assertion" || k == "client_assertion_type"),
4632            "caller client assertion survived: {pairs:?}"
4633        );
4634    }
4635
4636    #[test]
4637    fn rewrite_collapses_duplicate_client_id_to_proxy_value() {
4638        let out =
4639            rewrite_client_auth_params("client_id=a&client_id=b&scope=read", "proxy-id", false);
4640        let pairs = decoded_pairs(&out);
4641        let client_ids: Vec<&String> = pairs
4642            .iter()
4643            .filter(|(k, _)| k == "client_id")
4644            .map(|(_, v)| v)
4645            .collect();
4646        assert_eq!(client_ids, vec!["proxy-id"]);
4647    }
4648
4649    #[test]
4650    fn rewrite_preserves_non_client_params_in_order_with_duplicates() {
4651        let out = rewrite_client_auth_params(
4652            "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4653            "proxy-id",
4654            false,
4655        );
4656        let pairs = decoded_pairs(&out);
4657        let non_client: Vec<(String, String)> = pairs
4658            .into_iter()
4659            .filter(|(k, _)| k != "client_id")
4660            .collect();
4661        assert_eq!(
4662            non_client,
4663            vec![
4664                ("scope".to_owned(), "read".to_owned()),
4665                ("resource".to_owned(), "a".to_owned()),
4666                ("state".to_owned(), "xyz".to_owned()),
4667                ("resource".to_owned(), "b".to_owned()),
4668                ("code_verifier".to_owned(), "v".to_owned()),
4669            ]
4670        );
4671    }
4672
4673    #[test]
4674    fn rewrite_strips_every_resource_param_when_enabled() {
4675        // Issue #17: Entra rejects `resource` alongside a differing api://
4676        // scope (AADSTS9010010). All occurrences must go, and everything else
4677        // must survive in order.
4678        let out = rewrite_client_auth_params(
4679            "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4680            "proxy-id",
4681            true,
4682        );
4683        let non_client: Vec<(String, String)> = decoded_pairs(&out)
4684            .into_iter()
4685            .filter(|(k, _)| k != "client_id")
4686            .collect();
4687        assert_eq!(
4688            non_client,
4689            vec![
4690                ("scope".to_owned(), "read".to_owned()),
4691                ("state".to_owned(), "xyz".to_owned()),
4692                ("code_verifier".to_owned(), "v".to_owned()),
4693            ]
4694        );
4695    }
4696
4697    #[test]
4698    fn rewrite_strips_percent_encoded_resource_key() {
4699        // The strip filter compares post-decode, so an encoded key cannot
4700        // smuggle `resource` upstream -- same property that protects
4701        // CLIENT_AUTH_PARAMS.
4702        let out = rewrite_client_auth_params("%72esource=sneaky&scope=read", "proxy-id", true);
4703        let pairs = decoded_pairs(&out);
4704        assert!(
4705            !pairs.iter().any(|(k, _)| k == "resource"),
4706            "percent-encoded resource survived: {pairs:?}"
4707        );
4708        assert!(pairs.contains(&("scope".to_owned(), "read".to_owned())));
4709    }
4710
4711    #[test]
4712    fn rewrite_never_strips_security_params_when_resource_stripping_enabled() {
4713        // SECURITY: stripping must never reach PKCE, CSRF, or redirect
4714        // binding. If this ever fails, an operator enabling the Entra
4715        // workaround would silently lose those protections.
4716        let input = "response_type=code&redirect_uri=https%3A%2F%2Fapp%2Fcb&state=s1\
4717                     &code_challenge=cc&code_challenge_method=S256&nonce=n1&scope=read\
4718                     &code_verifier=cv&grant_type=authorization_code&code=abc\
4719                     &refresh_token=rt&resource=https%3A%2F%2Fapi";
4720        let pairs = decoded_pairs(&rewrite_client_auth_params(input, "proxy-id", true));
4721        for key in [
4722            "response_type",
4723            "redirect_uri",
4724            "state",
4725            "code_challenge",
4726            "code_challenge_method",
4727            "nonce",
4728            "scope",
4729            "code_verifier",
4730            "grant_type",
4731            "code",
4732            "refresh_token",
4733        ] {
4734            assert!(
4735                pairs.iter().any(|(k, _)| k == key),
4736                "{key} must never be stripped: {pairs:?}"
4737            );
4738        }
4739        assert!(!pairs.iter().any(|(k, _)| k == "resource"));
4740    }
4741
4742    #[test]
4743    fn rewrite_roundtrips_values_with_special_characters() {
4744        let input = url::form_urlencoded::Serializer::new(String::new())
4745            .append_pair("state", "a&b=c+d")
4746            .append_pair("scope", "réad ✓")
4747            .finish();
4748        let out = rewrite_client_auth_params(&input, "proxy-id", false);
4749        let pairs = decoded_pairs(&out);
4750        assert!(pairs.contains(&("state".to_owned(), "a&b=c+d".to_owned())));
4751        assert!(pairs.contains(&("scope".to_owned(), "réad ✓".to_owned())));
4752    }
4753
4754    #[test]
4755    fn rewrite_injects_client_id_when_absent() {
4756        let out = rewrite_client_auth_params("scope=read", "proxy-id", false);
4757        assert!(decoded_pairs(&out).contains(&("client_id".to_owned(), "proxy-id".to_owned())));
4758    }
4759
4760    #[test]
4761    fn looks_like_jwt_valid() {
4762        // Minimal valid JWT structure: base64({"alg":"RS256"}).base64({}).sig
4763        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
4764        let payload = URL_SAFE_NO_PAD.encode(b"{}");
4765        let token = format!("{header}.{payload}.signature");
4766        assert!(looks_like_jwt(&token));
4767    }
4768
4769    #[test]
4770    fn looks_like_jwt_rejects_opaque_token() {
4771        assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
4772    }
4773
4774    #[test]
4775    fn looks_like_jwt_rejects_two_segments() {
4776        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
4777        let token = format!("{header}.payload");
4778        assert!(!looks_like_jwt(&token));
4779    }
4780
4781    #[test]
4782    fn looks_like_jwt_rejects_four_segments() {
4783        assert!(!looks_like_jwt("a.b.c.d"));
4784    }
4785
4786    #[test]
4787    fn looks_like_jwt_rejects_no_alg() {
4788        let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
4789        let payload = URL_SAFE_NO_PAD.encode(b"{}");
4790        let token = format!("{header}.{payload}.sig");
4791        assert!(!looks_like_jwt(&token));
4792    }
4793
4794    #[test]
4795    fn protected_resource_metadata_shape() {
4796        let config = OAuthConfig {
4797            require_subject: false,
4798            issuer: "https://auth.example.com".into(),
4799            audience: "https://mcp.example.com/mcp".into(),
4800            jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4801            scopes: vec![
4802                ScopeMapping {
4803                    scope: "mcp:read".into(),
4804                    role: "viewer".into(),
4805                },
4806                ScopeMapping {
4807                    scope: "mcp:admin".into(),
4808                    role: "ops".into(),
4809                },
4810            ],
4811            role_claim: None,
4812            role_mappings: vec![],
4813            jwks_cache_ttl: "10m".into(),
4814            proxy: None,
4815            token_exchange: None,
4816            ca_cert_path: None,
4817            allow_http_oauth_urls: false,
4818            max_jwks_keys: default_max_jwks_keys(),
4819            allowed_algorithms: None,
4820            authorization_servers: None,
4821            authorization_server_metadata_issuer: None,
4822            #[allow(
4823                deprecated,
4824                reason = "test fixture: explicit value for the deprecated field"
4825            )]
4826            strict_audience_validation: None,
4827            audience_validation_mode: None,
4828            jwks_max_response_bytes: default_jwks_max_bytes(),
4829            ssrf_allowlist: None,
4830        };
4831        let meta = protected_resource_metadata(
4832            "https://mcp.example.com/mcp",
4833            "https://mcp.example.com",
4834            &config,
4835        );
4836        assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4837        // No proxy: this process mounts no authorization-server endpoints, so
4838        // advertising itself would point RFC 9728 discovery at a 404. The
4839        // upstream issuer is the only truthful answer.
4840        assert_eq!(meta["authorization_servers"][0], "https://auth.example.com");
4841        assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
4842        assert_eq!(meta["bearer_methods_supported"][0], "header");
4843    }
4844
4845    /// Build a PRM fixture with the given proxy / override topology.
4846    fn prm_for(
4847        proxy: Option<OAuthProxyConfig>,
4848        authorization_servers: Option<Vec<String>>,
4849        scopes: Vec<ScopeMapping>,
4850    ) -> serde_json::Value {
4851        let config = OAuthConfig {
4852            issuer: "https://auth.example.com".into(),
4853            audience: "https://mcp.example.com/mcp".into(),
4854            jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4855            scopes,
4856            proxy,
4857            authorization_servers,
4858            ..OAuthConfig::default()
4859        };
4860        protected_resource_metadata(
4861            "https://mcp.example.com/mcp",
4862            "https://mcp.example.com",
4863            &config,
4864        )
4865    }
4866
4867    fn demo_proxy() -> OAuthProxyConfig {
4868        OAuthProxyConfig::builder(
4869            "https://auth.example.com/authorize",
4870            "https://auth.example.com/token",
4871            "mcp",
4872        )
4873        .build()
4874    }
4875
4876    #[test]
4877    fn prm_advertises_local_server_only_when_proxy_mounts_the_endpoints() {
4878        // With the built-in proxy the local server really does serve
4879        // /authorize, /token, /register and the AS metadata document.
4880        let meta = prm_for(Some(demo_proxy()), None, vec![]);
4881        assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4882    }
4883
4884    #[test]
4885    fn prm_explicit_override_wins_over_topology() {
4886        // The extra_router case: the application mounts its own OAuth facade
4887        // without configuring `proxy`, so it must be able to say so.
4888        let meta = prm_for(
4889            None,
4890            Some(vec!["https://mcp.example.com".to_owned()]),
4891            vec![],
4892        );
4893        assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4894
4895        // An override also wins when a proxy IS configured.
4896        let meta = prm_for(
4897            Some(demo_proxy()),
4898            Some(vec!["https://elsewhere.example".to_owned()]),
4899            vec![],
4900        );
4901        assert_eq!(
4902            meta["authorization_servers"][0],
4903            "https://elsewhere.example"
4904        );
4905    }
4906
4907    #[test]
4908    fn prm_omits_zero_valued_claims() {
4909        // RFC 9728 3.2: claims with zero elements MUST be omitted, not
4910        // emitted as `[]`.
4911        let meta = prm_for(None, Some(vec![]), vec![]);
4912        assert!(
4913            meta.get("authorization_servers").is_none(),
4914            "empty override must omit the claim: {meta}"
4915        );
4916        assert!(
4917            meta.get("scopes_supported").is_none(),
4918            "no configured scopes must omit the claim: {meta}"
4919        );
4920        assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4921    }
4922
4923    fn proxy_as_metadata_config() -> OAuthConfig {
4924        OAuthConfig {
4925            issuer: "https://auth.example.com".into(),
4926            audience: "https://mcp.example.com/mcp".into(),
4927            jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4928            proxy: Some(demo_proxy()),
4929            ..OAuthConfig::default()
4930        }
4931    }
4932
4933    #[test]
4934    fn as_metadata_issuer_defaults_to_the_origin_it_is_served_from() {
4935        // RFC 8414 3.3: the published `issuer` MUST equal the identifier the
4936        // metadata URL was built from. RFC 8414 6.2 requires clients to reject
4937        // a mismatch, so publishing the upstream issuer here made the document
4938        // unusable to conformant clients.
4939        let config = proxy_as_metadata_config();
4940        let meta = authorization_server_metadata("https://mcp.example.com", &config);
4941        assert_eq!(meta["issuer"], "https://mcp.example.com");
4942        assert_eq!(
4943            meta["authorization_endpoint"],
4944            "https://mcp.example.com/authorize"
4945        );
4946        assert!(
4947            meta.get("scopes_supported").is_none(),
4948            "RFC 8414 3.2: omit zero-valued claims: {meta}"
4949        );
4950    }
4951
4952    #[test]
4953    fn as_metadata_issuer_legacy_opt_out_restores_upstream_value() {
4954        // Escape hatch for an upstream IdP that emits RFC 9207 `iss` to
4955        // clients that validate it; the proxy cannot reconcile that because
4956        // the callback bypasses this process entirely.
4957        let mut config = proxy_as_metadata_config();
4958        config.authorization_server_metadata_issuer = Some("https://auth.example.com".into());
4959        let meta = authorization_server_metadata("https://mcp.example.com", &config);
4960        assert_eq!(meta["issuer"], "https://auth.example.com");
4961    }
4962
4963    #[test]
4964    fn as_metadata_issuer_never_affects_token_validation() {
4965        // Whichever value is published, inbound JWT `iss` is validated against
4966        // `config.issuer`.
4967        let mut config = proxy_as_metadata_config();
4968        config.authorization_server_metadata_issuer = Some("https://mcp.example.com".into());
4969        assert_eq!(config.issuer, "https://auth.example.com");
4970    }
4971
4972    // -----------------------------------------------------------------------
4973    // F2: OAuth URL HTTPS-only validation (CVE-class: MITM JWKS / token URL)
4974    // -----------------------------------------------------------------------
4975
4976    fn validation_https_config() -> OAuthConfig {
4977        OAuthConfig::builder(
4978            "https://auth.example.com",
4979            "mcp",
4980            "https://auth.example.com/.well-known/jwks.json",
4981        )
4982        .build()
4983    }
4984
4985    #[test]
4986    fn validate_rejects_non_conformant_discovery_metadata_urls() {
4987        for bad in [
4988            "https://user:pw@as.example.com",
4989            "http://as.example.com",
4990            "https://10.0.0.1",
4991            "not-a-url",
4992        ] {
4993            let mut cfg = validation_https_config();
4994            cfg.authorization_server_metadata_issuer = Some(bad.to_owned());
4995            cfg.validate().unwrap_err();
4996
4997            let mut cfg = validation_https_config();
4998            cfg.authorization_servers = Some(vec![bad.to_owned()]);
4999            let err = cfg.validate().unwrap_err().to_string();
5000            assert!(
5001                err.contains("authorization_servers[0]"),
5002                "error must identify the offending index; got {err:?}"
5003            );
5004        }
5005    }
5006
5007    #[test]
5008    fn validate_accepts_discovery_metadata_urls_and_the_empty_override() {
5009        let mut cfg = validation_https_config();
5010        cfg.authorization_server_metadata_issuer = Some("https://as.example.com".to_owned());
5011        cfg.authorization_servers = Some(vec!["https://as.example.com".to_owned()]);
5012        cfg.validate()
5013            .expect("well-formed https metadata must validate");
5014
5015        let mut cfg = validation_https_config();
5016        cfg.authorization_servers = Some(vec![]);
5017        cfg.validate()
5018            .expect("an empty list is the documented way to omit the claim entirely");
5019    }
5020
5021    #[test]
5022    fn validate_accepts_all_https_urls() {
5023        let cfg = validation_https_config();
5024        cfg.validate().expect("all-HTTPS config must validate");
5025    }
5026
5027    #[test]
5028    fn validate_rejects_empty_audience() {
5029        let mut cfg = validation_https_config();
5030        cfg.audience = String::new();
5031        let err = cfg.validate().expect_err("empty audience must be rejected");
5032        assert!(
5033            err.to_string().contains("oauth.audience"),
5034            "error must reference oauth.audience; got {err}"
5035        );
5036    }
5037
5038    fn assert_config_nonzero_error(err: crate::error::RmcpServerKitError, field: &str) {
5039        let crate::error::RmcpServerKitError::Config(msg) = err else {
5040            panic!("expected Config error for {field}");
5041        };
5042        assert!(
5043            msg.contains(field) && msg.contains("must be nonzero"),
5044            "error must name {field} and say must be nonzero; got {msg:?}"
5045        );
5046    }
5047
5048    #[test]
5049    fn rejects_zero_max_jwks_keys() {
5050        let mut cfg = validation_https_config();
5051        cfg.max_jwks_keys = 0;
5052        let err = cfg
5053            .validate()
5054            .expect_err("zero max_jwks_keys must be rejected");
5055        assert_config_nonzero_error(err, "oauth.max_jwks_keys");
5056    }
5057
5058    #[test]
5059    fn rejects_zero_jwks_max_response_bytes() {
5060        let mut cfg = validation_https_config();
5061        cfg.jwks_max_response_bytes = 0;
5062        let err = cfg
5063            .validate()
5064            .expect_err("zero jwks_max_response_bytes must be rejected");
5065        assert_config_nonzero_error(err, "oauth.jwks_max_response_bytes");
5066    }
5067
5068    #[test]
5069    fn oauth_config_partial_table_deserializes_then_validate_rejects_empty_fields() {
5070        let toml_src = r#"
5071role_claim = "realm_access.roles"
5072
5073[[role_mappings]]
5074claim_value = "mcp-admin"
5075role = "admin"
5076"#;
5077        let cfg: OAuthConfig = toml::from_str(toml_src).expect(
5078            "partial [oauth] table without issuer/audience/jwks_uri must deserialize via serde(default)",
5079        );
5080        assert_eq!(cfg.issuer, "", "omitted issuer must default to empty");
5081        assert_eq!(cfg.audience, "", "omitted audience must default to empty");
5082        assert_eq!(cfg.jwks_uri, "", "omitted jwks_uri must default to empty");
5083        assert_eq!(cfg.role_claim.as_deref(), Some("realm_access.roles"));
5084        assert_eq!(cfg.role_mappings.len(), 1);
5085        cfg.validate().expect_err(
5086            "empty issuer/jwks_uri/audience must still fail validate() (parse-don't-validate)",
5087        );
5088    }
5089
5090    #[test]
5091    fn validate_rejects_unparseable_jwks_cache_ttl() {
5092        let mut cfg = validation_https_config();
5093        cfg.jwks_cache_ttl = "not-a-duration".into();
5094        let err = cfg
5095            .validate()
5096            .expect_err("malformed jwks_cache_ttl must be rejected");
5097        let msg = err.to_string();
5098        assert!(
5099            msg.contains("jwks_cache_ttl"),
5100            "error must reference offending field; got {msg:?}"
5101        );
5102    }
5103
5104    #[test]
5105    fn validate_rejects_http_jwks_uri() {
5106        let mut cfg = validation_https_config();
5107        cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
5108        let err = cfg.validate().expect_err("http jwks_uri must be rejected");
5109        let msg = err.to_string();
5110        assert!(
5111            msg.contains("oauth.jwks_uri") && msg.contains("https"),
5112            "error must reference offending field + scheme requirement; got {msg:?}"
5113        );
5114    }
5115
5116    #[test]
5117    fn validate_rejects_http_proxy_authorize_url() {
5118        let mut cfg = validation_https_config();
5119        cfg.proxy = Some(
5120            OAuthProxyConfig::builder(
5121                "http://idp.example.com/authorize", // <-- HTTP, must be rejected
5122                "https://idp.example.com/token",
5123                "client",
5124            )
5125            .build(),
5126        );
5127        let err = cfg
5128            .validate()
5129            .expect_err("http authorize_url must be rejected");
5130        assert!(
5131            err.to_string().contains("oauth.proxy.authorize_url"),
5132            "error must reference proxy.authorize_url; got {err}"
5133        );
5134    }
5135
5136    #[test]
5137    fn validate_rejects_http_proxy_token_url() {
5138        let mut cfg = validation_https_config();
5139        cfg.proxy = Some(
5140            OAuthProxyConfig::builder(
5141                "https://idp.example.com/authorize",
5142                "http://idp.example.com/token", // <-- HTTP, must be rejected
5143                "client",
5144            )
5145            .build(),
5146        );
5147        let err = cfg.validate().expect_err("http token_url must be rejected");
5148        assert!(
5149            err.to_string().contains("oauth.proxy.token_url"),
5150            "error must reference proxy.token_url; got {err}"
5151        );
5152    }
5153
5154    #[test]
5155    fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
5156        let mut cfg = validation_https_config();
5157        cfg.proxy = Some(
5158            OAuthProxyConfig::builder(
5159                "https://idp.example.com/authorize",
5160                "https://idp.example.com/token",
5161                "client",
5162            )
5163            .introspection_url("http://idp.example.com/introspect")
5164            .build(),
5165        );
5166        let err = cfg
5167            .validate()
5168            .expect_err("http introspection_url must be rejected");
5169        assert!(err.to_string().contains("oauth.proxy.introspection_url"));
5170
5171        let mut cfg = validation_https_config();
5172        cfg.proxy = Some(
5173            OAuthProxyConfig::builder(
5174                "https://idp.example.com/authorize",
5175                "https://idp.example.com/token",
5176                "client",
5177            )
5178            .revocation_url("http://idp.example.com/revoke")
5179            .build(),
5180        );
5181        let err = cfg
5182            .validate()
5183            .expect_err("http revocation_url must be rejected");
5184        assert!(err.to_string().contains("oauth.proxy.revocation_url"));
5185    }
5186
5187    // -- M3 regression: unauthenticated /introspect and /revoke must fail validate --
5188
5189    #[test]
5190    fn validate_rejects_exposed_admin_endpoints_without_auth() {
5191        let mut cfg = validation_https_config();
5192        cfg.proxy = Some(
5193            OAuthProxyConfig::builder(
5194                "https://idp.example.com/authorize",
5195                "https://idp.example.com/token",
5196                "client",
5197            )
5198            .introspection_url("https://idp.example.com/introspect")
5199            .expose_admin_endpoints(true)
5200            .build(),
5201        );
5202        let err = cfg
5203            .validate()
5204            .expect_err("expose_admin_endpoints without auth must fail");
5205        let msg = err.to_string();
5206        assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
5207        assert!(
5208            msg.contains("allow_unauthenticated_admin_endpoints"),
5209            "{msg}"
5210        );
5211    }
5212
5213    #[test]
5214    fn validate_accepts_exposed_admin_endpoints_with_auth() {
5215        let mut cfg = validation_https_config();
5216        cfg.proxy = Some(
5217            OAuthProxyConfig::builder(
5218                "https://idp.example.com/authorize",
5219                "https://idp.example.com/token",
5220                "client",
5221            )
5222            .introspection_url("https://idp.example.com/introspect")
5223            .expose_admin_endpoints(true)
5224            .require_auth_on_admin_endpoints(true)
5225            .build(),
5226        );
5227        cfg.validate()
5228            .expect("authed admin endpoints must validate");
5229    }
5230
5231    #[test]
5232    fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
5233        let mut cfg = validation_https_config();
5234        cfg.proxy = Some(
5235            OAuthProxyConfig::builder(
5236                "https://idp.example.com/authorize",
5237                "https://idp.example.com/token",
5238                "client",
5239            )
5240            .introspection_url("https://idp.example.com/introspect")
5241            .expose_admin_endpoints(true)
5242            .allow_unauthenticated_admin_endpoints(true)
5243            .build(),
5244        );
5245        cfg.validate()
5246            .expect("explicit unauth opt-out must validate");
5247    }
5248
5249    #[test]
5250    fn validate_accepts_unexposed_admin_endpoints_without_auth() {
5251        // The default safe shape: expose_admin_endpoints = false. The
5252        // M3 check must not fire because the routes are not mounted.
5253        let mut cfg = validation_https_config();
5254        cfg.proxy = Some(
5255            OAuthProxyConfig::builder(
5256                "https://idp.example.com/authorize",
5257                "https://idp.example.com/token",
5258                "client",
5259            )
5260            .introspection_url("https://idp.example.com/introspect")
5261            .build(),
5262        );
5263        cfg.validate()
5264            .expect("unexposed admin endpoints must validate");
5265    }
5266
5267    #[test]
5268    fn validate_rejects_http_token_exchange_url() {
5269        let mut cfg = validation_https_config();
5270        cfg.token_exchange = Some(
5271            TokenExchangeConfig::new(
5272                "http://idp.example.com/token", // <-- HTTP
5273                "client",
5274                None,
5275                None,
5276            )
5277            .with_audience("downstream"),
5278        );
5279        let err = cfg
5280            .validate()
5281            .expect_err("http token_exchange.token_url must be rejected");
5282        assert!(
5283            err.to_string().contains("oauth.token_exchange.token_url"),
5284            "error must reference token_exchange.token_url; got {err}"
5285        );
5286    }
5287
5288    #[test]
5289    fn validate_rejects_unparseable_url() {
5290        let mut cfg = validation_https_config();
5291        cfg.jwks_uri = "not a url".into();
5292        let err = cfg
5293            .validate()
5294            .expect_err("unparseable URL must be rejected");
5295        assert!(err.to_string().contains("invalid URL"));
5296    }
5297
5298    #[test]
5299    fn validate_rejects_non_http_scheme() {
5300        let mut cfg = validation_https_config();
5301        cfg.jwks_uri = "file:///etc/passwd".into();
5302        let err = cfg.validate().expect_err("file:// scheme must be rejected");
5303        let msg = err.to_string();
5304        assert!(
5305            msg.contains("must use https scheme") && msg.contains("file"),
5306            "error must reject non-http(s) schemes; got {msg:?}"
5307        );
5308    }
5309
5310    #[test]
5311    fn validate_accepts_http_with_escape_hatch() {
5312        // F2 escape-hatch: `allow_http_oauth_urls = true` permits HTTP for
5313        // dev/test against local IdPs without TLS. Document the security
5314        // tradeoff (see field doc) and verify all 6 URL fields are accepted
5315        // when the flag is set.
5316        let mut cfg = OAuthConfig::builder(
5317            "http://auth.local",
5318            "mcp",
5319            "http://auth.local/.well-known/jwks.json",
5320        )
5321        .allow_http_oauth_urls(true)
5322        .build();
5323        cfg.proxy = Some(
5324            OAuthProxyConfig::builder(
5325                "http://idp.local/authorize",
5326                "http://idp.local/token",
5327                "client",
5328            )
5329            .introspection_url("http://idp.local/introspect")
5330            .revocation_url("http://idp.local/revoke")
5331            .build(),
5332        );
5333        cfg.token_exchange = Some(
5334            TokenExchangeConfig::new(
5335                "http://idp.local/token",
5336                "client",
5337                Some(secrecy::SecretString::new("dev-secret".into())),
5338                None,
5339            )
5340            .with_audience("downstream"),
5341        );
5342        cfg.validate()
5343            .expect("escape hatch must permit http on all URL fields");
5344    }
5345
5346    #[test]
5347    fn validate_with_escape_hatch_still_rejects_unparseable() {
5348        // Even with the escape hatch, malformed URLs are rejected so
5349        // garbage configuration cannot silently degrade to no-op.
5350        let mut cfg = validation_https_config();
5351        cfg.allow_http_oauth_urls = true;
5352        cfg.jwks_uri = "::not-a-url::".into();
5353        cfg.validate()
5354            .expect_err("escape hatch must NOT bypass URL parsing");
5355    }
5356
5357    #[tokio::test]
5358    async fn jwks_cache_rejects_redirect_downgrade_to_http() {
5359        // F2.4 (Oracle modification A): even when the configured `jwks_uri`
5360        // is HTTPS, a `302 Location: http://...` from the JWKS host must
5361        // be refused by the reqwest redirect policy. Without this guard,
5362        // a network-positioned attacker who can spoof the upstream IdP
5363        // could redirect the JWKS fetch to plaintext and inject signing
5364        // keys, forging arbitrary JWTs.
5365        //
5366        // We assert at the reqwest-client level (rather than through
5367        // `validate_token`) so the assertion is precise: it pins the
5368        // policy to "reject scheme downgrade" rather than the broader
5369        // "JWKS fetch failed for any reason".
5370
5371        // Install the same rustls crypto provider JwksCache::new uses,
5372        // so the test client can build with TLS support.
5373        rustls::crypto::ring::default_provider()
5374            .install_default()
5375            .ok();
5376
5377        let policy = reqwest::redirect::Policy::custom(|attempt| {
5378            if attempt.url().scheme() != "https" {
5379                attempt.error("redirect to non-HTTPS URL refused")
5380            } else if attempt.previous().len() >= 2 {
5381                attempt.error("too many redirects (max 2)")
5382            } else {
5383                attempt.follow()
5384            }
5385        });
5386        // M-H2: even though this is a redirect-policy test harness
5387        // (not a production code path), wire the same resolver +
5388        // .no_proxy() so the audit-trail invariant "every reqwest
5389        // builder in this crate uses SsrfScreeningResolver" holds.
5390        // Loopback bypass is enabled so the wiremock fixture stays
5391        // reachable.
5392        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
5393        let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
5394        let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
5395            crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
5396        );
5397        let client = reqwest::Client::builder()
5398            .no_proxy()
5399            .dns_resolver(Arc::clone(&resolver))
5400            .timeout(Duration::from_secs(5))
5401            .connect_timeout(Duration::from_secs(3))
5402            .redirect(policy)
5403            .build()
5404            .expect("test client builds");
5405
5406        let mock = wiremock::MockServer::start().await;
5407        wiremock::Mock::given(wiremock::matchers::method("GET"))
5408            .and(wiremock::matchers::path("/jwks.json"))
5409            .respond_with(
5410                wiremock::ResponseTemplate::new(302)
5411                    .insert_header("location", "http://example.invalid/jwks.json"),
5412            )
5413            .mount(&mock)
5414            .await;
5415
5416        // Emulate an HTTPS jwks_uri that 302s to HTTP.  We can't easily
5417        // bring up an HTTPS wiremock, so we simulate the kernel of the
5418        // policy: the same client that JwksCache uses must refuse the
5419        // redirect target.  reqwest invokes the redirect policy
5420        // regardless of source scheme, so an HTTP -> HTTP redirect with
5421        // policy `custom(... if scheme != https then error ...)` still
5422        // yields the redirect-rejection error path.  That is sufficient
5423        // to lock in the policy semantics.
5424        let url = format!("{}/jwks.json", mock.uri());
5425        let err = client
5426            .get(&url)
5427            .send()
5428            .await
5429            .expect_err("redirect policy must reject scheme downgrade");
5430        let chain = format!("{err:#}");
5431        assert!(
5432            chain.contains("redirect to non-HTTPS URL refused")
5433                || chain.to_lowercase().contains("redirect"),
5434            "error must surface redirect-policy rejection; got {chain:?}"
5435        );
5436    }
5437
5438    // -----------------------------------------------------------------------
5439    // Integration tests with in-process RSA keypair + wiremock JWKS
5440    // -----------------------------------------------------------------------
5441
5442    use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
5443
5444    /// Generate an RSA-2048 keypair and return `(private_pem, jwks_json)`.
5445    fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
5446        let mut rng = rsa::rand_core::OsRng;
5447        let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
5448        let private_pem = private_key
5449            .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
5450            .expect("PKCS8 PEM export")
5451            .to_string();
5452
5453        let public_key = private_key.to_public_key();
5454        let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
5455        let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
5456
5457        let jwks = serde_json::json!({
5458            "keys": [{
5459                "kty": "RSA",
5460                "use": "sig",
5461                "alg": "RS256",
5462                "kid": kid,
5463                "n": n,
5464                "e": e
5465            }]
5466        });
5467
5468        (private_pem, jwks)
5469    }
5470
5471    /// Mint a signed JWT with the given claims.
5472    fn mint_token(
5473        private_pem: &str,
5474        kid: &str,
5475        issuer: &str,
5476        audience: &str,
5477        subject: &str,
5478        scope: &str,
5479    ) -> String {
5480        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5481            .expect("encoding key from PEM");
5482        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5483        header.kid = Some(kid.into());
5484
5485        let now = jsonwebtoken::get_current_timestamp();
5486        let claims = serde_json::json!({
5487            "iss": issuer,
5488            "aud": audience,
5489            "sub": subject,
5490            "scope": scope,
5491            "exp": now + 3600,
5492            "iat": now,
5493        });
5494
5495        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5496    }
5497
5498    /// Mint a signed JWT WITHOUT a `sub` claim (for `require_subject` tests).
5499    fn mint_token_without_sub(
5500        private_pem: &str,
5501        kid: &str,
5502        issuer: &str,
5503        audience: &str,
5504        scope: &str,
5505    ) -> String {
5506        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5507            .expect("encoding key from PEM");
5508        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5509        header.kid = Some(kid.into());
5510        let now = jsonwebtoken::get_current_timestamp();
5511        let claims = serde_json::json!({
5512            "iss": issuer,
5513            "aud": audience,
5514            "scope": scope,
5515            "exp": now + 3600,
5516            "iat": now,
5517        });
5518        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5519    }
5520
5521    fn test_config(jwks_uri: &str) -> OAuthConfig {
5522        OAuthConfig {
5523            require_subject: false,
5524            issuer: "https://auth.test.local".into(),
5525            audience: "https://mcp.test.local/mcp".into(),
5526            jwks_uri: jwks_uri.into(),
5527            scopes: vec![
5528                ScopeMapping {
5529                    scope: "mcp:read".into(),
5530                    role: "viewer".into(),
5531                },
5532                ScopeMapping {
5533                    scope: "mcp:admin".into(),
5534                    role: "ops".into(),
5535                },
5536            ],
5537            role_claim: None,
5538            role_mappings: vec![],
5539            jwks_cache_ttl: "5m".into(),
5540            proxy: None,
5541            token_exchange: None,
5542            ca_cert_path: None,
5543            allow_http_oauth_urls: true,
5544            max_jwks_keys: default_max_jwks_keys(),
5545            allowed_algorithms: None,
5546            authorization_servers: None,
5547            authorization_server_metadata_issuer: None,
5548            #[allow(
5549                deprecated,
5550                reason = "test fixture: explicit value for the deprecated field"
5551            )]
5552            strict_audience_validation: None,
5553            audience_validation_mode: None,
5554            jwks_max_response_bytes: default_jwks_max_bytes(),
5555            ssrf_allowlist: None,
5556        }
5557    }
5558
5559    fn test_cache(config: &OAuthConfig) -> JwksCache {
5560        JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
5561    }
5562
5563    // -- H2: expired JWKS cache must fail closed when refresh cannot succeed --
5564
5565    /// Prime a cache (with `ttl`) from a valid JWKS, confirm the kid landed,
5566    /// then repoint the endpoint at a 503 so any later refresh fails. Returns
5567    /// the cache, a matching-`aud` token for the primed kid, and the live mock
5568    /// server (kept alive by the caller).
5569    async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
5570        let kid = "test-h2-stale";
5571        let (pem, jwks) = generate_test_keypair(kid);
5572        let mock_server = wiremock::MockServer::start().await;
5573        wiremock::Mock::given(wiremock::matchers::method("GET"))
5574            .and(wiremock::matchers::path("/jwks.json"))
5575            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5576            .mount(&mock_server)
5577            .await;
5578        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5579        let mut config = test_config(&jwks_uri);
5580        config.jwks_cache_ttl = ttl.into();
5581        let cache = test_cache(&config);
5582        cache.__test_refresh_now().await.expect("prime JWKS cache");
5583        assert!(cache.__test_has_kid(kid).await, "kid must be primed");
5584
5585        mock_server.reset().await;
5586        wiremock::Mock::given(wiremock::matchers::method("GET"))
5587            .and(wiremock::matchers::path("/jwks.json"))
5588            .respond_with(wiremock::ResponseTemplate::new(503))
5589            .mount(&mock_server)
5590            .await;
5591
5592        let token = mint_token(
5593            &pem,
5594            kid,
5595            "https://auth.test.local",
5596            "https://mcp.test.local/mcp",
5597            "h2-client",
5598            "mcp:read",
5599        );
5600        (cache, token, mock_server)
5601    }
5602
5603    #[test]
5604    fn build_key_cache_last_duplicate_kid_wins() {
5605        let (_pem, jwks_json) = generate_test_keypair("dup-kid");
5606        let entry = jwks_json["keys"][0].clone();
5607        let merged = serde_json::json!({ "keys": [entry.clone(), entry] });
5608        let jwks: JwkSet = serde_json::from_value(merged).expect("merged jwks parses");
5609        assert_eq!(jwks.keys.len(), 2, "fixture must carry two colliding kids");
5610
5611        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5612        assert_eq!(keys.len(), 1, "colliding kids collapse to one entry");
5613        assert!(keys.contains_key("dup-kid"));
5614        assert!(unnamed.is_empty());
5615    }
5616
5617    #[test]
5618    fn build_key_cache_rejects_keys_not_marked_for_signature_verification() {
5619        // SECURITY (key-use separation, RFC 7517 4.2/4.3): DecodingKey::from_jwk
5620        // ignores `use`/`key_ops`, so an issuer publishing an encryption key in
5621        // the same JWKS must not have it accepted as a verification key.
5622        let (_pem, jwks_json) = generate_test_keypair("enc-only");
5623
5624        let mut enc = jwks_json["keys"][0].clone();
5625        enc["use"] = serde_json::json!("enc");
5626        let jwks: JwkSet =
5627            serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5628        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5629        assert!(
5630            keys.is_empty(),
5631            "use=enc key must not be a verification key"
5632        );
5633        assert!(unnamed.is_empty());
5634
5635        let mut wrap_only = jwks_json["keys"][0].clone();
5636        wrap_only["key_ops"] = serde_json::json!(["wrapKey"]);
5637        let jwks: JwkSet = serde_json::from_value(serde_json::json!({ "keys": [wrap_only] }))
5638            .expect("jwks parses");
5639        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5640        assert!(keys.is_empty(), "key_ops without verify must be rejected");
5641        assert!(unnamed.is_empty());
5642    }
5643
5644    #[test]
5645    fn build_key_cache_accepts_sig_and_unconstrained_keys() {
5646        let (_pem, jwks_json) = generate_test_keypair("sig-key");
5647
5648        // Absent `use`/`key_ops` stays accepted (RFC 7517: both are optional).
5649        let jwks: JwkSet = serde_json::from_value(jwks_json.clone()).expect("jwks parses");
5650        let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5651        assert!(keys.contains_key("sig-key"));
5652
5653        let mut sig = jwks_json["keys"][0].clone();
5654        sig["use"] = serde_json::json!("sig");
5655        sig["key_ops"] = serde_json::json!(["verify"]);
5656        let jwks: JwkSet =
5657            serde_json::from_value(serde_json::json!({ "keys": [sig] })).expect("jwks parses");
5658        let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5659        assert!(keys.contains_key("sig-key"));
5660    }
5661
5662    // -- Issue #17: JWKS keys that omit the OPTIONAL `alg` member (RFC 7517 4.4) --
5663    //
5664    // Microsoft Entra v2.0 publishes every signing key without `alg`
5665    // (verified against login.microsoftonline.com/common/discovery/v2.0/keys:
5666    // 9 keys, 0 with `alg`, all kty=RSA use=sig). Requiring `alg` dropped every
5667    // key and produced a silent, total authentication outage.
5668
5669    /// Strip the `alg` member from a generated fixture, reproducing Entra shape.
5670    fn jwks_without_alg(jwks: &serde_json::Value) -> JwkSet {
5671        let mut key = jwks["keys"][0].clone();
5672        if let Some(obj) = key.as_object_mut() {
5673            obj.remove("alg");
5674        }
5675        serde_json::from_value(serde_json::json!({ "keys": [key] })).expect("alg-less jwks parses")
5676    }
5677
5678    #[test]
5679    fn alg_less_rsa_key_is_cached_as_rsa_family() {
5680        let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5681        let jwks = jwks_without_alg(&jwks_json);
5682        assert!(
5683            jwks.keys[0].common.key_algorithm.is_none(),
5684            "fixture must omit `alg`"
5685        );
5686
5687        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5688        assert!(unnamed.is_empty());
5689        let (cached_alg, _) = keys.get("entra-kid").expect("alg-less key must be cached");
5690        assert_eq!(*cached_alg, JwkAlg::Family(JwkKeyFamily::Rsa));
5691    }
5692
5693    #[test]
5694    fn alg_less_rsa_key_accepts_rsa_family_and_rejects_others() {
5695        let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5696        let cached = CachedKeys {
5697            keys: build_key_cache(&jwks_without_alg(&jwks_json), 16)
5698                .expect("under key cap")
5699                .0,
5700            unnamed_keys: vec![],
5701            fetched_at: Instant::now(),
5702            ttl: Duration::from_secs(300),
5703        };
5704
5705        for alg in [
5706            Algorithm::RS256,
5707            Algorithm::RS384,
5708            Algorithm::RS512,
5709            Algorithm::PS256,
5710            Algorithm::PS384,
5711            Algorithm::PS512,
5712        ] {
5713            assert!(
5714                lookup_key(&cached, Some("entra-kid"), alg).is_some(),
5715                "{alg:?} is producible by an RSA key and must resolve"
5716            );
5717        }
5718        // An RSA key cannot produce an EC signature.
5719        assert!(lookup_key(&cached, Some("entra-kid"), Algorithm::ES256).is_none());
5720        // The kid-strict rule still holds for inferred keys.
5721        assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
5722    }
5723
5724    #[test]
5725    fn alg_less_key_never_accepts_hmac_algorithm_confusion() {
5726        // Regression guard: the classic attack is to present alg=HS256 and use
5727        // the issuer's PUBLIC RSA modulus as the HMAC secret. Family inference
5728        // must never widen an RSA key to a symmetric algorithm. (ACCEPTED_ALGS
5729        // also screens HS* before lookup; this asserts the key-bound layer.)
5730        assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS256));
5731        assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS384));
5732        assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS512));
5733        assert!(!family_accepts(JwkKeyFamily::EcP256, Algorithm::HS256));
5734        assert!(!family_accepts(JwkKeyFamily::Ed25519, Algorithm::HS256));
5735    }
5736
5737    #[test]
5738    fn family_accepts_is_subset_of_accepted_algs() {
5739        // INVARIANT: family inference must never admit an algorithm that the
5740        // pre-lookup `ACCEPTED_ALGS` screen would reject.
5741        let every_alg = [
5742            Algorithm::HS256,
5743            Algorithm::HS384,
5744            Algorithm::HS512,
5745            Algorithm::RS256,
5746            Algorithm::RS384,
5747            Algorithm::RS512,
5748            Algorithm::ES256,
5749            Algorithm::ES384,
5750            Algorithm::PS256,
5751            Algorithm::PS384,
5752            Algorithm::PS512,
5753            Algorithm::EdDSA,
5754        ];
5755        for family in [
5756            JwkKeyFamily::Rsa,
5757            JwkKeyFamily::EcP256,
5758            JwkKeyFamily::EcP384,
5759            JwkKeyFamily::Ed25519,
5760        ] {
5761            for alg in every_alg {
5762                if family_accepts(family, alg) {
5763                    assert!(
5764                        ACCEPTED_ALGS.contains(&alg),
5765                        "{family:?} admits {alg:?}, which is outside ACCEPTED_ALGS"
5766                    );
5767                }
5768            }
5769        }
5770    }
5771
5772    #[test]
5773    fn explicit_alg_still_pins_exactly_one_algorithm() {
5774        // The JWK declares RS256, so an RS384 token must NOT be accepted even
5775        // though both are producible by the same RSA key.
5776        let (_pem, jwks_json) = generate_test_keypair("pinned");
5777        let jwks: JwkSet = serde_json::from_value(jwks_json).expect("jwks parses");
5778        let cached = CachedKeys {
5779            keys: build_key_cache(&jwks, 16).expect("under key cap").0,
5780            unnamed_keys: vec![],
5781            fetched_at: Instant::now(),
5782            ttl: Duration::from_secs(300),
5783        };
5784        assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS256).is_some());
5785        assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS384).is_none());
5786    }
5787
5788    #[test]
5789    fn alg_less_key_still_subject_to_use_and_key_ops_gate() {
5790        // Ordering guard: `jwk_permits_signature_verification` runs BEFORE the
5791        // algorithm step, so inference must not resurrect a key excluded by
5792        // key-use separation. Covers both branches of that gate.
5793        let (_pem, jwks_json) = generate_test_keypair("gated");
5794
5795        let mut enc = jwks_json["keys"][0].clone();
5796        if let Some(obj) = enc.as_object_mut() {
5797            obj.remove("alg");
5798        }
5799        enc["use"] = serde_json::json!("enc");
5800        let jwks: JwkSet =
5801            serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5802        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5803        assert!(
5804            keys.is_empty() && unnamed.is_empty(),
5805            "use=enc must be dropped"
5806        );
5807
5808        let mut wrap = jwks_json["keys"][0].clone();
5809        if let Some(obj) = wrap.as_object_mut() {
5810            obj.remove("alg");
5811            obj.remove("use");
5812        }
5813        wrap["key_ops"] = serde_json::json!(["wrapKey"]);
5814        let jwks: JwkSet =
5815            serde_json::from_value(serde_json::json!({ "keys": [wrap] })).expect("jwks parses");
5816        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5817        assert!(
5818            keys.is_empty() && unnamed.is_empty(),
5819            "key_ops without verify must be dropped"
5820        );
5821    }
5822
5823    // -- allowed_algorithms: operator narrowing of the accepted algorithm set --
5824
5825    #[test]
5826    fn accepted_algorithm_names_cover_accepted_algs() {
5827        // Lockstep contract: every accepted algorithm must have a name an
5828        // operator can write, and every name must round-trip back.
5829        for alg in ACCEPTED_ALGS {
5830            let name = accepted_algorithm_name(*alg)
5831                .unwrap_or_else(|| panic!("{alg:?} is accepted but has no configurable name"));
5832            assert_eq!(accepted_algorithm_from_name(name), Some(*alg));
5833        }
5834        assert_eq!(
5835            accepted_algorithm_names().split(", ").count(),
5836            ACCEPTED_ALGS.len()
5837        );
5838    }
5839
5840    #[test]
5841    fn allowed_algorithms_cannot_widen_beyond_accepted_algs() {
5842        // SECURITY: the whole point of the narrow-only rule. An operator must
5843        // not be able to re-enable a symmetric or unsigned algorithm and open
5844        // an algorithm-confusion hole.
5845        for name in ["HS256", "HS384", "HS512", "none", "ES512", "RS1"] {
5846            assert!(
5847                accepted_algorithm_from_name(name).is_none(),
5848                "{name} must not be resolvable"
5849            );
5850            let err = resolve_allowed_algorithms(Some(&vec![name.to_owned()]))
5851                .expect_err("must reject non-accepted algorithm");
5852            assert!(err.to_string().contains("unsupported algorithm"));
5853        }
5854    }
5855
5856    #[test]
5857    fn allowed_algorithms_rejects_empty_list() {
5858        let err = resolve_allowed_algorithms(Some(&Vec::new()))
5859            .expect_err("empty list would reject every token");
5860        assert!(err.to_string().contains("must not be empty"));
5861    }
5862
5863    #[test]
5864    fn allowed_algorithms_defaults_to_full_accepted_set() {
5865        assert_eq!(
5866            resolve_allowed_algorithms(None).expect("default resolves"),
5867            ACCEPTED_ALGS.to_vec()
5868        );
5869    }
5870
5871    #[test]
5872    fn allowed_algorithms_narrows_and_dedups_case_insensitively() {
5873        let resolved = resolve_allowed_algorithms(Some(&vec![
5874            "rs256".to_owned(),
5875            "RS256".to_owned(),
5876            "ES384".to_owned(),
5877        ]))
5878        .expect("valid subset");
5879        assert_eq!(resolved, vec![Algorithm::RS256, Algorithm::ES384]);
5880    }
5881
5882    #[test]
5883    fn allowed_algorithms_surfaces_through_config_validate() {
5884        let mut cfg = test_config("https://idp.test.local/jwks.json");
5885        cfg.allowed_algorithms = Some(vec!["HS256".to_owned()]);
5886        let err = cfg.validate().expect_err("HS256 must fail validation");
5887        assert!(err.to_string().contains("unsupported algorithm"));
5888
5889        cfg.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5890        cfg.validate().expect("a valid subset must validate");
5891    }
5892
5893    #[tokio::test]
5894    async fn narrowed_allowed_algorithms_rejects_excluded_but_otherwise_valid_token() {
5895        // The token is signed RS256 by a key the JWKS serves, so it would
5896        // normally authenticate; narrowing to ES384 must reject it at the
5897        // pre-lookup algorithm gate.
5898        let kid = "narrowing-kid";
5899        let (pem, jwks) = generate_test_keypair(kid);
5900
5901        let mock_server = wiremock::MockServer::start().await;
5902        wiremock::Mock::given(wiremock::matchers::method("GET"))
5903            .and(wiremock::matchers::path("/jwks.json"))
5904            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5905            .mount(&mock_server)
5906            .await;
5907
5908        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5909        let token = mint_token(
5910            &pem,
5911            kid,
5912            "https://auth.test.local",
5913            "https://mcp.test.local/mcp",
5914            "narrow-user",
5915            "mcp:admin",
5916        );
5917
5918        let mut permissive = test_config(&jwks_uri);
5919        permissive.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5920        assert!(
5921            test_cache(&permissive)
5922                .validate_token(&token)
5923                .await
5924                .is_some(),
5925            "RS256 token must authenticate when RS256 is allowed"
5926        );
5927
5928        let mut narrowed = test_config(&jwks_uri);
5929        narrowed.allowed_algorithms = Some(vec!["ES384".to_owned()]);
5930        assert!(
5931            test_cache(&narrowed).validate_token(&token).await.is_none(),
5932            "RS256 token must be rejected when only ES384 is allowed"
5933        );
5934    }
5935
5936    #[test]
5937    fn truncate_kid_for_log_bounds_hostile_input() {
5938        let short = "kid-1";
5939        assert_eq!(truncate_kid_for_log(short), (short.to_owned(), false));
5940
5941        let long = "k".repeat(4096);
5942        let (truncated, was_truncated) = truncate_kid_for_log(&long);
5943        assert!(was_truncated);
5944        assert!(truncated.ends_with("...(truncated)"));
5945        assert_eq!(
5946            truncated.chars().count(),
5947            MAX_LOGGED_KID_CHARS + "...(truncated)".chars().count()
5948        );
5949    }
5950
5951    #[test]
5952    fn truncate_kid_for_log_splits_on_char_boundary() {
5953        let multibyte = "\u{1f512}".repeat(MAX_LOGGED_KID_CHARS + 10);
5954        let (truncated, was_truncated) = truncate_kid_for_log(&multibyte);
5955        assert!(was_truncated);
5956        assert!(truncated.starts_with('\u{1f512}'));
5957        assert!(truncated.ends_with("...(truncated)"));
5958    }
5959
5960    #[test]
5961    fn truncate_kid_for_log_flag_marks_exact_boundary_as_untruncated() {
5962        let exact = "k".repeat(MAX_LOGGED_KID_CHARS);
5963        let (out, was_truncated) = truncate_kid_for_log(&exact);
5964        assert!(!was_truncated, "a kid exactly at the cap is not truncated");
5965        assert_eq!(out, exact);
5966    }
5967
5968    #[tokio::test]
5969    async fn expired_jwks_fails_closed_when_refresh_fails() {
5970        let (cache, token, _mock) = h2_prime_then_break("80ms").await;
5971        tokio::time::sleep(Duration::from_millis(200)).await;
5972        let failure = cache
5973            .validate_token_with_reason(&token)
5974            .await
5975            .expect_err("an expired cache whose refresh fails must not serve the stale key");
5976        assert_eq!(failure, JwtValidationFailure::Invalid);
5977    }
5978
5979    #[tokio::test]
5980    async fn fresh_jwks_still_validates() {
5981        let kid = "test-h2-fresh";
5982        let (pem, jwks) = generate_test_keypair(kid);
5983        let mock_server = wiremock::MockServer::start().await;
5984        wiremock::Mock::given(wiremock::matchers::method("GET"))
5985            .and(wiremock::matchers::path("/jwks.json"))
5986            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5987            .mount(&mock_server)
5988            .await;
5989        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5990        let config = test_config(&jwks_uri); // 5m TTL, reachable JWKS
5991        let cache = test_cache(&config);
5992        let token = mint_token(
5993            &pem,
5994            kid,
5995            "https://auth.test.local",
5996            "https://mcp.test.local/mcp",
5997            "h2-fresh-client",
5998            "mcp:read",
5999        );
6000        cache
6001            .validate_token_with_reason(&token)
6002            .await
6003            .expect("a reachable JWKS must still validate a matching token");
6004    }
6005
6006    #[tokio::test]
6007    async fn cooldown_active_plus_expired_fails_closed() {
6008        let (cache, token, _mock) = h2_prime_then_break("80ms").await;
6009        tokio::time::sleep(Duration::from_millis(200)).await;
6010        // First attempt: no cooldown yet, so this triggers a (503) refresh that
6011        // records `last_refresh_attempt` and still fails closed.
6012        assert_eq!(
6013            cache
6014                .validate_token_with_reason(&token)
6015                .await
6016                .expect_err("first attempt must fail closed"),
6017            JwtValidationFailure::Invalid,
6018        );
6019        // Second attempt: the refresh cooldown is now active, so no refresh is
6020        // attempted -- the still-expired cache must not serve the stale key.
6021        let failure = cache
6022            .validate_token_with_reason(&token)
6023            .await
6024            .expect_err("cooldown-active + expired cache must still fail closed");
6025        assert_eq!(failure, JwtValidationFailure::Invalid);
6026    }
6027
6028    #[tokio::test]
6029    async fn valid_jwt_returns_identity() {
6030        let kid = "test-key-1";
6031        let (pem, jwks) = generate_test_keypair(kid);
6032
6033        let mock_server = wiremock::MockServer::start().await;
6034        wiremock::Mock::given(wiremock::matchers::method("GET"))
6035            .and(wiremock::matchers::path("/jwks.json"))
6036            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6037            .mount(&mock_server)
6038            .await;
6039
6040        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6041        let config = test_config(&jwks_uri);
6042        let cache = test_cache(&config);
6043
6044        let token = mint_token(
6045            &pem,
6046            kid,
6047            "https://auth.test.local",
6048            "https://mcp.test.local/mcp",
6049            "ci-bot",
6050            "mcp:read mcp:other",
6051        );
6052
6053        let identity = cache.validate_token(&token).await;
6054        assert!(identity.is_some(), "valid JWT should authenticate");
6055        let id = identity.unwrap();
6056        assert_eq!(id.name, "ci-bot");
6057        assert_eq!(id.role, "viewer"); // first matching scope
6058        assert_eq!(id.method, AuthMethod::OAuthJwt);
6059    }
6060
6061    // -- L4: kid-strict key lookup + require_subject --
6062
6063    #[test]
6064    fn unknown_kid_with_named_keys_rejected() {
6065        let mut keys = HashMap::new();
6066        keys.insert(
6067            "kid-1".to_owned(),
6068            (
6069                JwkAlg::Explicit(Algorithm::RS256),
6070                DecodingKey::from_secret(b"named"),
6071            ),
6072        );
6073        let cached = CachedKeys {
6074            keys,
6075            unnamed_keys: vec![(
6076                JwkAlg::Explicit(Algorithm::RS256),
6077                DecodingKey::from_secret(b"unnamed"),
6078            )],
6079            fetched_at: Instant::now(),
6080            ttl: Duration::from_secs(300),
6081        };
6082        // A matching kid + algorithm resolves to the named key.
6083        assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
6084        // An unknown kid must NOT fall back to the unnamed key (L4 fail-closed):
6085        // a token naming an absent key is rejected rather than silently verified
6086        // against a keyless JWKS entry.
6087        assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
6088        // A known kid paired with the wrong algorithm is rejected too.
6089        assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
6090    }
6091
6092    #[test]
6093    fn no_kid_token_matches_unnamed_key() {
6094        let mut keys = HashMap::new();
6095        keys.insert(
6096            "kid-1".to_owned(),
6097            (
6098                JwkAlg::Explicit(Algorithm::RS256),
6099                DecodingKey::from_secret(b"named"),
6100            ),
6101        );
6102        let cached = CachedKeys {
6103            keys,
6104            unnamed_keys: vec![(
6105                JwkAlg::Explicit(Algorithm::RS256),
6106                DecodingKey::from_secret(b"unnamed"),
6107            )],
6108            fetched_at: Instant::now(),
6109            ttl: Duration::from_secs(300),
6110        };
6111        // A token with no kid falls back to an unnamed key, supporting JWKS
6112        // entries that legitimately omit `kid`.
6113        assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
6114    }
6115
6116    #[tokio::test]
6117    async fn require_subject_rejects_subject_less() {
6118        let kid = "test-key-reqsub";
6119        let (pem, jwks) = generate_test_keypair(kid);
6120        let mock_server = wiremock::MockServer::start().await;
6121        wiremock::Mock::given(wiremock::matchers::method("GET"))
6122            .and(wiremock::matchers::path("/jwks.json"))
6123            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6124            .mount(&mock_server)
6125            .await;
6126        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6127        let mut config = test_config(&jwks_uri);
6128        config.require_subject = true;
6129        let cache = test_cache(&config);
6130
6131        let no_sub = mint_token_without_sub(
6132            &pem,
6133            kid,
6134            "https://auth.test.local",
6135            "https://mcp.test.local/mcp",
6136            "mcp:read",
6137        );
6138        assert!(
6139            cache.validate_token(&no_sub).await.is_none(),
6140            "require_subject must reject a token with no sub"
6141        );
6142
6143        let with_sub = mint_token(
6144            &pem,
6145            kid,
6146            "https://auth.test.local",
6147            "https://mcp.test.local/mcp",
6148            "svc",
6149            "mcp:read",
6150        );
6151        assert!(
6152            cache.validate_token(&with_sub).await.is_some(),
6153            "a token carrying sub must still be accepted"
6154        );
6155    }
6156
6157    #[tokio::test]
6158    async fn subject_less_token_accepted_by_default() {
6159        let kid = "test-key-nosub-default";
6160        let (pem, jwks) = generate_test_keypair(kid);
6161        let mock_server = wiremock::MockServer::start().await;
6162        wiremock::Mock::given(wiremock::matchers::method("GET"))
6163            .and(wiremock::matchers::path("/jwks.json"))
6164            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6165            .mount(&mock_server)
6166            .await;
6167        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6168        let config = test_config(&jwks_uri); // require_subject defaults to false
6169        let cache = test_cache(&config);
6170        let no_sub = mint_token_without_sub(
6171            &pem,
6172            kid,
6173            "https://auth.test.local",
6174            "https://mcp.test.local/mcp",
6175            "mcp:read",
6176        );
6177        assert!(
6178            cache.validate_token(&no_sub).await.is_some(),
6179            "the default policy must accept a sub-less (client-credentials) token"
6180        );
6181    }
6182
6183    #[tokio::test]
6184    async fn credential_post_does_not_follow_redirect() {
6185        // M7: a 307 from the token endpoint must NOT be followed, or the
6186        // client_secret-bearing body would be re-sent to the redirect host.
6187        let mock = wiremock::MockServer::start().await;
6188        wiremock::Mock::given(wiremock::matchers::method("POST"))
6189            .and(wiremock::matchers::path("/followed"))
6190            .respond_with(wiremock::ResponseTemplate::new(200))
6191            .expect(0) // verified on MockServer drop: must never be hit
6192            .mount(&mock)
6193            .await;
6194        wiremock::Mock::given(wiremock::matchers::method("POST"))
6195            .and(wiremock::matchers::path("/token"))
6196            .respond_with(
6197                wiremock::ResponseTemplate::new(307)
6198                    .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
6199            )
6200            .mount(&mock)
6201            .await;
6202
6203        let client = OauthHttpClient::build(None).expect("build oauth http client");
6204        let resp = client
6205            .credential_client
6206            .post(format!("{}/token", mock.uri()))
6207            .body("grant_type=client_credentials")
6208            .send()
6209            .await
6210            .expect("request sent");
6211        assert_eq!(
6212            resp.status().as_u16(),
6213            307,
6214            "credential client must surface the 307 rather than follow it"
6215        );
6216    }
6217
6218    fn test_token_exchange_config(token_url: String) -> TokenExchangeConfig {
6219        TokenExchangeConfig::new(
6220            token_url,
6221            "mcp-client",
6222            Some(secrecy::SecretString::new("test-client-secret".into())),
6223            None,
6224        )
6225        .with_audience("downstream-api")
6226    }
6227
6228    const ENC_GRANT: &str = "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange";
6229    const ENC_ACCESS: &str = "urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token";
6230
6231    #[test]
6232    fn build_exchange_form_is_byte_identical_to_pre_3_8_0_output() {
6233        let config = test_token_exchange_config("https://idp.example.com/token".into());
6234        let body = build_exchange_form(&config, "subj-token");
6235        assert_eq!(
6236            body,
6237            format!(
6238                "grant_type={ENC_GRANT}&subject_token=subj-token\
6239                 &subject_token_type={ENC_ACCESS}&requested_token_type={ENC_ACCESS}\
6240                 &audience=downstream-api"
6241            ),
6242            "a config predating 3.8.0 must produce an unchanged request body"
6243        );
6244    }
6245
6246    #[test]
6247    fn build_exchange_form_emits_only_required_params_when_all_optional_omitted() {
6248        let config =
6249            TokenExchangeConfig::new("https://idp.example.com/token", "public-client", None, None)
6250                .with_requested_token_type(RequestedTokenType::Omit);
6251        let body = build_exchange_form(&config, "subj");
6252        assert_eq!(
6253            body,
6254            format!(
6255                "grant_type={ENC_GRANT}&subject_token=subj\
6256                 &subject_token_type={ENC_ACCESS}&client_id=public-client"
6257            ),
6258            "only the three RFC 8693 §2.1 REQUIRED params plus the public-client id"
6259        );
6260    }
6261
6262    #[test]
6263    fn build_exchange_form_keeps_rfc_parameter_order() {
6264        let config = test_token_exchange_config("https://idp.example.com/token".into())
6265            .with_resource("https://api.example.com/v1")
6266            .with_scope("read write")
6267            .with_requested_token_type(RequestedTokenType::Custom("urn:example:token".into()));
6268        let body = build_exchange_form(&config, "subj");
6269        let keys: Vec<&str> = body
6270            .split('&')
6271            .filter_map(|kv| kv.split('=').next())
6272            .collect();
6273        assert_eq!(
6274            keys,
6275            vec![
6276                "grant_type",
6277                "subject_token",
6278                "subject_token_type",
6279                "requested_token_type",
6280                "audience",
6281                "resource",
6282                "scope",
6283            ]
6284        );
6285        assert!(
6286            body.contains("&requested_token_type=urn%3Aexample%3Atoken"),
6287            "custom token type must be sent verbatim: {body}"
6288        );
6289    }
6290
6291    #[test]
6292    fn token_exchange_toml_omitting_new_keys_still_deserializes() {
6293        let cfg: TokenExchangeConfig = toml::from_str(
6294            "token_url = \"https://idp.example.com/token\"\n\
6295             client_id = \"client\"\n\
6296             audience = \"downstream\"\n",
6297        )
6298        .expect("a token_exchange table predating 3.8.0 must still parse");
6299        assert_eq!(cfg.audience.as_deref(), Some("downstream"));
6300        assert_eq!(cfg.resource, None);
6301        assert_eq!(cfg.scope, None);
6302        assert_eq!(cfg.requested_token_type, RequestedTokenType::AccessToken);
6303    }
6304
6305    #[test]
6306    fn upstream_error_description_is_redacted_by_default() {
6307        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6308        crate::diagnostics::set_diagnostic_exposure(
6309            &crate::diagnostics::DiagnosticExposure::default(),
6310        );
6311
6312        assert_eq!(
6313            upstream_error_description_for_log(Some("subject_token=eyJhbGciOi...")),
6314            "[REDACTED]",
6315            "upstream free-form text must not reach logs unless opted in"
6316        );
6317        assert_eq!(upstream_error_description_for_log(None), "[REDACTED]");
6318    }
6319
6320    #[test]
6321    fn upstream_error_description_is_shown_when_opted_in() {
6322        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6323        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
6324            upstream_error_bodies: true,
6325            ..crate::diagnostics::DiagnosticExposure::default()
6326        });
6327
6328        assert_eq!(
6329            upstream_error_description_for_log(Some("audience not permitted")),
6330            "audience not permitted",
6331            "the debug switch must surface the upstream description verbatim"
6332        );
6333        assert_eq!(
6334            upstream_error_description_for_log(None),
6335            "",
6336            "an absent description renders empty, not the redaction marker"
6337        );
6338    }
6339
6340    #[test]
6341    fn requested_token_type_deserializes_from_plain_strings() {
6342        for (raw, expected) in [
6343            ("access_token", RequestedTokenType::AccessToken),
6344            ("omit", RequestedTokenType::Omit),
6345            (
6346                "urn:example:token",
6347                RequestedTokenType::Custom("urn:example:token".into()),
6348            ),
6349        ] {
6350            let cfg: TokenExchangeConfig = toml::from_str(&format!(
6351                "token_url = \"https://idp.example.com/token\"\n\
6352                 client_id = \"client\"\n\
6353                 requested_token_type = \"{raw}\"\n"
6354            ))
6355            .expect("requested_token_type must accept any string");
6356            assert_eq!(cfg.requested_token_type, expected, "input {raw}");
6357        }
6358    }
6359
6360    fn exchange_response(access_token: &str, issued_token_type: &str) -> serde_json::Value {
6361        serde_json::json!({
6362            "access_token": access_token,
6363            "expires_in": 3600_u64,
6364            "issued_token_type": issued_token_type,
6365        })
6366    }
6367
6368    fn unsigned_jwt_with_claims(claims: &serde_json::Value) -> String {
6369        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
6370        let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims json"));
6371        format!("{header}.{payload}.signature")
6372    }
6373
6374    fn test_exchange_client() -> OauthHttpClient {
6375        let config = OAuthConfig::builder(
6376            "http://auth.test.local",
6377            "mcp",
6378            "http://auth.test.local/jwks.json",
6379        )
6380        .allow_http_oauth_urls(true)
6381        .build();
6382        OauthHttpClient::build(Some(&config))
6383            .expect("build oauth http client")
6384            .__test_allow_loopback_ssrf()
6385    }
6386
6387    fn unavailable_loopback_token_url() -> String {
6388        "http://127.0.0.1:1/token?client_secret=super-secret".to_owned()
6389    }
6390
6391    async fn recorded_request_count(mock: &wiremock::MockServer) -> usize {
6392        mock.received_requests()
6393            .await
6394            .expect("wiremock request recording is enabled")
6395            .len()
6396    }
6397
6398    async fn wait_for_recorded_request(mock: &wiremock::MockServer) {
6399        // Liveness wait, not a latency bound: it returns as soon as the mock
6400        // records the request, so a generous ceiling costs nothing on success
6401        // and only makes a genuine hang fail slower.
6402        tokio::time::timeout(Duration::from_secs(15), async {
6403            loop {
6404                if recorded_request_count(mock).await > 0 {
6405                    return;
6406                }
6407                tokio::time::sleep(Duration::from_millis(10)).await;
6408            }
6409        })
6410        .await
6411        .expect("token endpoint must record the in-flight request before cancellation");
6412    }
6413
6414    async fn wait_for_log_contains(logs: &CapturedLogs, needle: &str) {
6415        // Must comfortably exceed the mock response delay: the detached task
6416        // cannot emit its audit line until the upstream exchange completes, so
6417        // this bound is `mock delay + slack`, not a latency expectation. It is
6418        // a bounded wait -- on success it returns as soon as the line appears.
6419        tokio::time::timeout(Duration::from_secs(15), async {
6420            loop {
6421                if logs.contents().contains(needle) {
6422                    return;
6423                }
6424                tokio::time::sleep(Duration::from_millis(10)).await;
6425            }
6426        })
6427        .await
6428        .expect("detached token exchange must eventually emit its audit log");
6429    }
6430
6431    #[tokio::test]
6432    async fn send_screened_request_failure_sanitizes_url_and_reqwest_error() {
6433        let client = test_exchange_client();
6434        let screened_url = unavailable_loopback_token_url();
6435        let request_url = screened_url.replacen("//", "//u:p@", 1);
6436
6437        let error = client
6438            .send_screened(
6439                &screened_url,
6440                client
6441                    .credential_client
6442                    .post(&request_url)
6443                    .body("grant_type=test"),
6444            )
6445            .await
6446            .expect_err("closed loopback port must fail the request");
6447
6448        let rendered = error.to_string();
6449        let sanitized = oauth_request_target_for_log(&screened_url);
6450        assert!(
6451            rendered.contains(&format!("oauth request {sanitized}")),
6452            "request failure must identify only the sanitized origin: {rendered}"
6453        );
6454        for leaked in ["u:p", "/token", "client_secret", "super-secret"] {
6455            assert!(
6456                !rendered.contains(leaked),
6457                "request failure must not echo raw URL component {leaked}: {rendered}"
6458            );
6459        }
6460    }
6461
6462    #[tokio::test]
6463    async fn exchange_token_request_failure_log_sanitizes_token_url() {
6464        let logs = CapturedLogs::default();
6465        let subscriber = tracing_subscriber::fmt()
6466            .with_max_level(tracing::Level::ERROR)
6467            .with_writer(logs.clone())
6468            .with_ansi(false)
6469            .without_time()
6470            .finish();
6471        let _guard = tracing::subscriber::set_default(subscriber);
6472
6473        let client = test_exchange_client();
6474        let token_url = unavailable_loopback_token_url();
6475        let config = test_token_exchange_config(token_url);
6476        let error = exchange_token(&client, &config, "subject-token")
6477            .await
6478            .expect_err("closed loopback port must fail exchange");
6479
6480        assert!(
6481            error.to_string().contains("server_error"),
6482            "client-visible exchange error must remain sanitized: {error}"
6483        );
6484        let contents = logs.contents();
6485        assert!(
6486            contents.contains("token exchange request failed"),
6487            "exchange failure must still be logged: {contents}"
6488        );
6489        assert!(
6490            contents.contains("oauth request http://127.0.0.1:1"),
6491            "exchange failure log must include only sanitized origin: {contents}"
6492        );
6493        for leaked in ["/token", "client_secret", "super-secret", "subject-token"] {
6494            assert!(
6495                !contents.contains(leaked),
6496                "exchange failure log must not echo raw URL/token component {leaked}: {contents}"
6497            );
6498        }
6499    }
6500
6501    #[tokio::test]
6502    async fn exchange_token_with_cancel_precancel_does_not_send() {
6503        let mock = wiremock::MockServer::start().await;
6504        wiremock::Mock::given(wiremock::matchers::method("POST"))
6505            .and(wiremock::matchers::path("/token"))
6506            .respond_with(
6507                wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6508                    "downstream-token",
6509                    "urn:ietf:params:oauth:token-type:access_token",
6510                )),
6511            )
6512            .mount(&mock)
6513            .await;
6514
6515        let client = test_exchange_client();
6516        let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6517        let ct = tokio_util::sync::CancellationToken::new();
6518        ct.cancel();
6519
6520        let outcome =
6521            exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6522
6523        assert!(
6524            matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6525            "pre-cancelled exchanges must not start work"
6526        );
6527        assert_eq!(
6528            recorded_request_count(&mock).await,
6529            0,
6530            "pre-cancel check must happen before cloning/spawning/sending"
6531        );
6532    }
6533
6534    #[tokio::test]
6535    async fn exchange_token_with_cancel_completes_normally() {
6536        let mock = wiremock::MockServer::start().await;
6537        wiremock::Mock::given(wiremock::matchers::method("POST"))
6538            .and(wiremock::matchers::path("/token"))
6539            .respond_with(
6540                wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6541                    "downstream-token",
6542                    "urn:ietf:params:oauth:token-type:access_token",
6543                )),
6544            )
6545            .expect(1)
6546            .mount(&mock)
6547            .await;
6548
6549        let client = test_exchange_client();
6550        let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6551        let ct = tokio_util::sync::CancellationToken::new();
6552
6553        let outcome =
6554            exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6555
6556        let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6557            panic!("uncancelled exchange must complete successfully")
6558        };
6559        assert_eq!(token.access_token, "downstream-token");
6560        mock.verify().await;
6561    }
6562
6563    #[tokio::test]
6564    async fn exchange_token_with_cancel_detaches_and_audits_abandoned_token() {
6565        let mock = wiremock::MockServer::start().await;
6566        let long_issued_token_type = format!(
6567            "urn:ietf:params:oauth:token-type:{}",
6568            "x".repeat(MAX_LOGGED_KID_CHARS + 32)
6569        );
6570        wiremock::Mock::given(wiremock::matchers::method("POST"))
6571            .and(wiremock::matchers::path("/token"))
6572            .respond_with(
6573                wiremock::ResponseTemplate::new(200)
6574                    // Long enough that the completion arm cannot plausibly win
6575                    // the `biased;` race before the caller cancels. A tight
6576                    // delay would make the outcome assertion depend on machine
6577                    // load rather than on the detach behaviour it proves. The
6578                    // test never waits this out -- returning without waiting is
6579                    // precisely the point.
6580                    .set_delay(Duration::from_secs(2))
6581                    .set_body_json(exchange_response(
6582                        "abandoned-downstream-token",
6583                        &long_issued_token_type,
6584                    )),
6585            )
6586            .expect(1)
6587            .mount(&mock)
6588            .await;
6589
6590        let token_url = format!("{}/token", mock.uri());
6591        let token_url_host = url::Url::parse(&token_url)
6592            .expect("mock token URL parses")
6593            .host_str()
6594            .expect("mock token URL has host")
6595            .to_owned();
6596        let logs = CapturedLogs::default();
6597        let subscriber = tracing_subscriber::fmt()
6598            .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6599            .with_writer(logs.clone())
6600            .with_ansi(false)
6601            .without_time()
6602            .finish();
6603        let _guard = tracing::subscriber::set_default(subscriber);
6604
6605        let client = test_exchange_client();
6606        let config = test_token_exchange_config(token_url);
6607        let ct = tokio_util::sync::CancellationToken::new();
6608        let task_ct = ct.clone();
6609        let handle = tokio::spawn(async move {
6610            exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6611        });
6612
6613        wait_for_recorded_request(&mock).await;
6614        let cancelled_at = Instant::now();
6615        ct.cancel();
6616        let outcome = handle.await.expect("wrapper task must not panic");
6617
6618        assert!(
6619            matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6620            "caller must get an immediate cancellation outcome"
6621        );
6622        assert!(
6623            cancelled_at.elapsed() < Duration::from_millis(100),
6624            "wrapper must detach instead of waiting for the delayed upstream response"
6625        );
6626
6627        wait_for_log_contains(
6628            &logs,
6629            "token exchange minted downstream token after caller detached",
6630        )
6631        .await;
6632        mock.verify().await;
6633        let contents = logs.contents();
6634        assert!(
6635            contents.contains("issued_token_type_truncated=true"),
6636            "audit log must mark issuer-controlled token type truncation: {contents}"
6637        );
6638        assert!(
6639            !contents.contains("abandoned-downstream-token"),
6640            "audit log must not include downstream token material: {contents}"
6641        );
6642        assert!(
6643            !contents.contains("token_len="),
6644            "DEBUG success log must be suppressed on abandoned exchanges: {contents}"
6645        );
6646        assert!(
6647            !contents.contains(&long_issued_token_type),
6648            "detached logs must not include unbounded issued token type: {contents}"
6649        );
6650        for field in ["sub=", "aud=", "azp=", "iss="] {
6651            assert!(
6652                !contents.contains(field),
6653                "detached logs must not include JWT claim field {field}: {contents}"
6654            );
6655        }
6656        assert!(
6657            !contents.contains(&token_url_host),
6658            "detached success logs must not include token endpoint host: {contents}"
6659        );
6660        assert!(
6661            !contents.contains("subject-token"),
6662            "audit log must not include subject token material: {contents}"
6663        );
6664        assert!(
6665            !contents.contains("test-client-secret"),
6666            "audit log must not include client secret material: {contents}"
6667        );
6668    }
6669
6670    #[tokio::test]
6671    async fn exchange_token_with_cancel_detached_jwt_success_does_not_log_claims() {
6672        let mock = wiremock::MockServer::start().await;
6673        let jwt = unsigned_jwt_with_claims(&serde_json::json!({
6674            "sub": "detached-subject",
6675            "aud": "detached-audience",
6676            "azp": "detached-client",
6677            "iss": "https://issuer.example.test/realm",
6678        }));
6679        wiremock::Mock::given(wiremock::matchers::method("POST"))
6680            .and(wiremock::matchers::path("/token"))
6681            .respond_with(
6682                wiremock::ResponseTemplate::new(200)
6683                    // See the opaque-token variant of this test: the delay is a
6684                    // race margin, not a wait. It keeps the completion arm from
6685                    // winning the `biased;` race under load.
6686                    .set_delay(Duration::from_secs(2))
6687                    .set_body_json(exchange_response(
6688                        &jwt,
6689                        "urn:ietf:params:oauth:token-type:access_token",
6690                    )),
6691            )
6692            .expect(1)
6693            .mount(&mock)
6694            .await;
6695
6696        let logs = CapturedLogs::default();
6697        let subscriber = tracing_subscriber::fmt()
6698            .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6699            .with_writer(logs.clone())
6700            .with_ansi(false)
6701            .without_time()
6702            .finish();
6703        let _guard = tracing::subscriber::set_default(subscriber);
6704
6705        let client = test_exchange_client();
6706        let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6707        let ct = tokio_util::sync::CancellationToken::new();
6708        let task_ct = ct.clone();
6709        let handle = tokio::spawn(async move {
6710            exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6711        });
6712
6713        wait_for_recorded_request(&mock).await;
6714        ct.cancel();
6715        let outcome = handle.await.expect("wrapper task must not panic");
6716        assert!(
6717            matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6718            "caller must get cancellation while spawned JWT exchange continues"
6719        );
6720
6721        wait_for_log_contains(
6722            &logs,
6723            "token exchange minted downstream token after caller detached",
6724        )
6725        .await;
6726        mock.verify().await;
6727        let contents = logs.contents();
6728        assert!(
6729            !contents.contains(&jwt),
6730            "detached JWT success must not log token material: {contents}"
6731        );
6732        for leaked in [
6733            "sub=",
6734            "aud=",
6735            "azp=",
6736            "iss=",
6737            "detached-subject",
6738            "detached-audience",
6739            "detached-client",
6740            "issuer.example.test",
6741        ] {
6742            assert!(
6743                !contents.contains(leaked),
6744                "detached JWT success must not log claim material {leaked}: {contents}"
6745            );
6746        }
6747    }
6748
6749    #[tokio::test]
6750    async fn exchange_token_with_cancel_completion_wins_tie() {
6751        let (tx, rx) = tokio::sync::oneshot::channel();
6752        tx.send(Ok(ExchangedToken {
6753            access_token: "tie-winner".into(),
6754            expires_in: Some(3600),
6755            issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".into()),
6756        }))
6757        .expect("test receiver is alive");
6758        let ct = tokio_util::sync::CancellationToken::new();
6759        ct.cancel();
6760
6761        let outcome = receive_exchange_result_with_cancel(rx, &ct, None).await;
6762
6763        let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6764            panic!("ready completion must win over ready cancellation under biased select")
6765        };
6766        assert_eq!(token.access_token, "tie-winner");
6767    }
6768
6769    #[tokio::test]
6770    async fn jwks_get_still_follows_screened_redirect() {
6771        // M7 regression: adding the no-redirect credential client must NOT
6772        // change the JWKS/discovery client, which still follows a redirect
6773        // whose every hop passes the SSRF screen. `allow_http` plus a loopback
6774        // allowlist entry let the http->http hop to the wiremock literal IP
6775        // clear `evaluate_oauth_redirect`'s scheme and per-hop SSRF checks.
6776        let mock = wiremock::MockServer::start().await;
6777        wiremock::Mock::given(wiremock::matchers::method("GET"))
6778            .and(wiremock::matchers::path("/jwks.json"))
6779            .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
6780                "location",
6781                format!("{}/jwks-final.json", mock.uri()).as_str(),
6782            ))
6783            .mount(&mock)
6784            .await;
6785        wiremock::Mock::given(wiremock::matchers::method("GET"))
6786            .and(wiremock::matchers::path("/jwks-final.json"))
6787            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
6788            .expect(1)
6789            .mount(&mock)
6790            .await;
6791
6792        let mut allowlist = OAuthSsrfAllowlist::default();
6793        allowlist.cidrs.push("127.0.0.0/8".into());
6794        allowlist.cidrs.push("::1/128".into());
6795        let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
6796        config.allow_http_oauth_urls = true;
6797        config.ssrf_allowlist = Some(allowlist);
6798
6799        let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
6800        let resp = client
6801            .inner
6802            .get(format!("{}/jwks.json", mock.uri()))
6803            .send()
6804            .await
6805            .expect("request sent");
6806        assert_eq!(
6807            resp.status().as_u16(),
6808            200,
6809            "JWKS client must follow the screened redirect to the final endpoint"
6810        );
6811        assert_eq!(resp.text().await.expect("response body"), "reached");
6812    }
6813
6814    #[tokio::test]
6815    async fn wrong_issuer_rejected() {
6816        let kid = "test-key-2";
6817        let (pem, jwks) = generate_test_keypair(kid);
6818
6819        let mock_server = wiremock::MockServer::start().await;
6820        wiremock::Mock::given(wiremock::matchers::method("GET"))
6821            .and(wiremock::matchers::path("/jwks.json"))
6822            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6823            .mount(&mock_server)
6824            .await;
6825
6826        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6827        let config = test_config(&jwks_uri);
6828        let cache = test_cache(&config);
6829
6830        let token = mint_token(
6831            &pem,
6832            kid,
6833            "https://wrong-issuer.example.com", // wrong
6834            "https://mcp.test.local/mcp",
6835            "attacker",
6836            "mcp:admin",
6837        );
6838
6839        assert!(cache.validate_token(&token).await.is_none());
6840    }
6841
6842    #[tokio::test]
6843    async fn wrong_audience_rejected() {
6844        let kid = "test-key-3";
6845        let (pem, jwks) = generate_test_keypair(kid);
6846
6847        let mock_server = wiremock::MockServer::start().await;
6848        wiremock::Mock::given(wiremock::matchers::method("GET"))
6849            .and(wiremock::matchers::path("/jwks.json"))
6850            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6851            .mount(&mock_server)
6852            .await;
6853
6854        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6855        let config = test_config(&jwks_uri);
6856        let cache = test_cache(&config);
6857
6858        let token = mint_token(
6859            &pem,
6860            kid,
6861            "https://auth.test.local",
6862            "https://wrong-audience.example.com", // wrong
6863            "attacker",
6864            "mcp:admin",
6865        );
6866
6867        assert!(cache.validate_token(&token).await.is_none());
6868    }
6869
6870    #[tokio::test]
6871    async fn expired_jwt_rejected() {
6872        let kid = "test-key-4";
6873        let (pem, jwks) = generate_test_keypair(kid);
6874
6875        let mock_server = wiremock::MockServer::start().await;
6876        wiremock::Mock::given(wiremock::matchers::method("GET"))
6877            .and(wiremock::matchers::path("/jwks.json"))
6878            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6879            .mount(&mock_server)
6880            .await;
6881
6882        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6883        let config = test_config(&jwks_uri);
6884        let cache = test_cache(&config);
6885
6886        // Create a token that expired 2 minutes ago (past the 60s leeway).
6887        let encoding_key =
6888            jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
6889        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
6890        header.kid = Some(kid.into());
6891        let now = jsonwebtoken::get_current_timestamp();
6892        let claims = serde_json::json!({
6893            "iss": "https://auth.test.local",
6894            "aud": "https://mcp.test.local/mcp",
6895            "sub": "expired-bot",
6896            "scope": "mcp:read",
6897            "exp": now - 120,
6898            "iat": now - 3720,
6899        });
6900        let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
6901
6902        assert!(cache.validate_token(&token).await.is_none());
6903    }
6904
6905    #[tokio::test]
6906    async fn no_matching_scope_rejected() {
6907        let kid = "test-key-5";
6908        let (pem, jwks) = generate_test_keypair(kid);
6909
6910        let mock_server = wiremock::MockServer::start().await;
6911        wiremock::Mock::given(wiremock::matchers::method("GET"))
6912            .and(wiremock::matchers::path("/jwks.json"))
6913            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6914            .mount(&mock_server)
6915            .await;
6916
6917        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6918        let config = test_config(&jwks_uri);
6919        let cache = test_cache(&config);
6920
6921        let token = mint_token(
6922            &pem,
6923            kid,
6924            "https://auth.test.local",
6925            "https://mcp.test.local/mcp",
6926            "limited-bot",
6927            "some:other:scope", // no matching scope
6928        );
6929
6930        assert!(cache.validate_token(&token).await.is_none());
6931    }
6932
6933    #[tokio::test]
6934    async fn wrong_signing_key_rejected() {
6935        let kid = "test-key-6";
6936        let (_pem, jwks) = generate_test_keypair(kid);
6937
6938        // Generate a DIFFERENT keypair for signing (attacker key).
6939        let (attacker_pem, _) = generate_test_keypair(kid);
6940
6941        let mock_server = wiremock::MockServer::start().await;
6942        wiremock::Mock::given(wiremock::matchers::method("GET"))
6943            .and(wiremock::matchers::path("/jwks.json"))
6944            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6945            .mount(&mock_server)
6946            .await;
6947
6948        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6949        let config = test_config(&jwks_uri);
6950        let cache = test_cache(&config);
6951
6952        // Sign with attacker key but JWKS has legitimate public key.
6953        let token = mint_token(
6954            &attacker_pem,
6955            kid,
6956            "https://auth.test.local",
6957            "https://mcp.test.local/mcp",
6958            "attacker",
6959            "mcp:admin",
6960        );
6961
6962        assert!(cache.validate_token(&token).await.is_none());
6963    }
6964
6965    #[tokio::test]
6966    async fn admin_scope_maps_to_ops_role() {
6967        let kid = "test-key-7";
6968        let (pem, jwks) = generate_test_keypair(kid);
6969
6970        let mock_server = wiremock::MockServer::start().await;
6971        wiremock::Mock::given(wiremock::matchers::method("GET"))
6972            .and(wiremock::matchers::path("/jwks.json"))
6973            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6974            .mount(&mock_server)
6975            .await;
6976
6977        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6978        let config = test_config(&jwks_uri);
6979        let cache = test_cache(&config);
6980
6981        let token = mint_token(
6982            &pem,
6983            kid,
6984            "https://auth.test.local",
6985            "https://mcp.test.local/mcp",
6986            "admin-bot",
6987            "mcp:admin",
6988        );
6989
6990        let id = cache
6991            .validate_token(&token)
6992            .await
6993            .expect("should authenticate");
6994        assert_eq!(id.role, "ops");
6995        assert_eq!(id.name, "admin-bot");
6996    }
6997
6998    #[tokio::test]
6999    async fn entra_shaped_alg_less_jwks_authenticates_end_to_end() {
7000        // Issue #17: the reported Entra failure, reproduced end-to-end. The
7001        // JWKS omits `alg` exactly as login.microsoftonline.com does; before
7002        // family inference the key was dropped and this returned None.
7003        let kid = "entra-e2e";
7004        let (pem, jwks) = generate_test_keypair(kid);
7005        let mut alg_less = jwks;
7006        if let Some(key) = alg_less["keys"][0].as_object_mut() {
7007            key.remove("alg");
7008        }
7009        assert!(
7010            alg_less["keys"][0].get("alg").is_none(),
7011            "fixture must reproduce Entra's alg-less shape"
7012        );
7013
7014        let mock_server = wiremock::MockServer::start().await;
7015        wiremock::Mock::given(wiremock::matchers::method("GET"))
7016            .and(wiremock::matchers::path("/jwks.json"))
7017            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&alg_less))
7018            .mount(&mock_server)
7019            .await;
7020
7021        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7022        let config = test_config(&jwks_uri);
7023        let cache = test_cache(&config);
7024
7025        let token = mint_token(
7026            &pem,
7027            kid,
7028            "https://auth.test.local",
7029            "https://mcp.test.local/mcp",
7030            "entra-user",
7031            "mcp:admin",
7032        );
7033
7034        let id = cache
7035            .validate_token(&token)
7036            .await
7037            .expect("an alg-less JWKS key must still authenticate (issue #17)");
7038        assert_eq!(id.name, "entra-user");
7039    }
7040
7041    #[tokio::test]
7042    async fn jwks_server_down_returns_none() {
7043        // Point to a non-existent server.
7044        let config = test_config("http://127.0.0.1:1/jwks.json");
7045        let cache = test_cache(&config);
7046
7047        let kid = "orphan-key";
7048        let (pem, _) = generate_test_keypair(kid);
7049        let token = mint_token(
7050            &pem,
7051            kid,
7052            "https://auth.test.local",
7053            "https://mcp.test.local/mcp",
7054            "bot",
7055            "mcp:read",
7056        );
7057
7058        assert!(cache.validate_token(&token).await.is_none());
7059    }
7060
7061    // -----------------------------------------------------------------------
7062    // resolve_claim_path tests
7063    // -----------------------------------------------------------------------
7064
7065    #[test]
7066    fn resolve_claim_path_flat_string() {
7067        let mut extra = HashMap::new();
7068        extra.insert(
7069            "scope".into(),
7070            serde_json::Value::String("mcp:read mcp:admin".into()),
7071        );
7072        let values = resolve_claim_path(&extra, "scope");
7073        assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
7074    }
7075
7076    #[test]
7077    fn resolve_claim_path_flat_array() {
7078        let mut extra = HashMap::new();
7079        extra.insert(
7080            "roles".into(),
7081            serde_json::json!(["mcp-admin", "mcp-viewer"]),
7082        );
7083        let values = resolve_claim_path(&extra, "roles");
7084        assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
7085    }
7086
7087    #[test]
7088    fn resolve_claim_path_nested_keycloak() {
7089        let mut extra = HashMap::new();
7090        extra.insert(
7091            "realm_access".into(),
7092            serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
7093        );
7094        let values = resolve_claim_path(&extra, "realm_access.roles");
7095        assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
7096    }
7097
7098    #[test]
7099    fn resolve_claim_path_missing_returns_empty() {
7100        let extra = HashMap::new();
7101        assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
7102    }
7103
7104    #[test]
7105    fn resolve_claim_path_numeric_leaf_returns_empty() {
7106        let mut extra = HashMap::new();
7107        extra.insert("count".into(), serde_json::json!(42));
7108        assert!(resolve_claim_path(&extra, "count").is_empty());
7109    }
7110
7111    fn make_claims(json: serde_json::Value) -> Claims {
7112        serde_json::from_value(json).expect("test claims must deserialize")
7113    }
7114
7115    #[test]
7116    fn first_class_scope_claim_splits_on_whitespace() {
7117        let claims = make_claims(serde_json::json!({
7118            "iss": "https://issuer.example.com",
7119            "exp": 9_999_999_999_u64,
7120            "scope": "read write admin",
7121        }));
7122        let values = first_class_claim_values(&claims, "scope");
7123        assert_eq!(values, vec!["read", "write", "admin"]);
7124    }
7125
7126    #[test]
7127    fn first_class_sub_claim_returns_single_value() {
7128        let claims = make_claims(serde_json::json!({
7129            "iss": "https://issuer.example.com",
7130            "exp": 9_999_999_999_u64,
7131            "sub": "service-account-orders",
7132        }));
7133        let values = first_class_claim_values(&claims, "sub");
7134        assert_eq!(values, vec!["service-account-orders"]);
7135    }
7136
7137    #[test]
7138    fn first_class_aud_claim_returns_every_audience() {
7139        let claims = make_claims(serde_json::json!({
7140            "iss": "https://issuer.example.com",
7141            "exp": 9_999_999_999_u64,
7142            "aud": ["api-a", "api-b"],
7143        }));
7144        let values = first_class_claim_values(&claims, "aud");
7145        assert_eq!(values, vec!["api-a", "api-b"]);
7146    }
7147
7148    #[test]
7149    fn first_class_unknown_path_returns_empty() {
7150        let claims = make_claims(serde_json::json!({
7151            "iss": "https://issuer.example.com",
7152            "exp": 9_999_999_999_u64,
7153        }));
7154        assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
7155    }
7156
7157    // -----------------------------------------------------------------------
7158    // role_claim integration tests (wiremock)
7159    // -----------------------------------------------------------------------
7160
7161    /// Mint a JWT with arbitrary custom claims (for `role_claim` testing).
7162    fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
7163        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
7164            .expect("encoding key from PEM");
7165        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
7166        header.kid = Some(kid.into());
7167        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
7168    }
7169
7170    fn test_config_with_role_claim(
7171        jwks_uri: &str,
7172        role_claim: &str,
7173        role_mappings: Vec<RoleMapping>,
7174    ) -> OAuthConfig {
7175        OAuthConfig {
7176            require_subject: false,
7177            issuer: "https://auth.test.local".into(),
7178            audience: "https://mcp.test.local/mcp".into(),
7179            jwks_uri: jwks_uri.into(),
7180            scopes: vec![],
7181            role_claim: Some(role_claim.into()),
7182            role_mappings,
7183            jwks_cache_ttl: "5m".into(),
7184            proxy: None,
7185            token_exchange: None,
7186            ca_cert_path: None,
7187            allow_http_oauth_urls: true,
7188            max_jwks_keys: default_max_jwks_keys(),
7189            allowed_algorithms: None,
7190            authorization_servers: None,
7191            authorization_server_metadata_issuer: None,
7192            #[allow(
7193                deprecated,
7194                reason = "test fixture: explicit value for the deprecated field"
7195            )]
7196            strict_audience_validation: None,
7197            audience_validation_mode: None,
7198            jwks_max_response_bytes: default_jwks_max_bytes(),
7199            ssrf_allowlist: None,
7200        }
7201    }
7202
7203    #[tokio::test]
7204    async fn screen_oauth_target_rejects_literal_ip() {
7205        let err = screen_oauth_target(
7206            "https://127.0.0.1/jwks.json",
7207            false,
7208            &crate::ssrf::CompiledSsrfAllowlist::default(),
7209        )
7210        .await
7211        .expect_err("literal IPs must be rejected");
7212        let msg = err.to_string();
7213        assert!(msg.contains("literal IPv4 addresses are forbidden"));
7214    }
7215
7216    #[tokio::test]
7217    async fn screen_oauth_target_rejects_private_dns_resolution() {
7218        let err = screen_oauth_target(
7219            "https://localhost/jwks.json",
7220            false,
7221            &crate::ssrf::CompiledSsrfAllowlist::default(),
7222        )
7223        .await
7224        .expect_err("localhost resolution must be rejected");
7225        let msg = err.to_string();
7226        assert!(
7227            msg.contains("blocked IP") && msg.contains("loopback"),
7228            "got {msg:?}"
7229        );
7230    }
7231
7232    #[tokio::test]
7233    async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
7234        let err = screen_oauth_target(
7235            "http://127.0.0.1/jwks.json",
7236            true,
7237            &crate::ssrf::CompiledSsrfAllowlist::default(),
7238        )
7239        .await
7240        .expect_err("literal IPs must still be rejected when http is allowed");
7241        let msg = err.to_string();
7242        assert!(msg.contains("literal IPv4 addresses are forbidden"));
7243    }
7244
7245    #[tokio::test]
7246    async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
7247        let err = screen_oauth_target(
7248            "http://localhost/jwks.json",
7249            true,
7250            &crate::ssrf::CompiledSsrfAllowlist::default(),
7251        )
7252        .await
7253        .expect_err("private DNS resolution must still be rejected when http is allowed");
7254        let msg = err.to_string();
7255        assert!(
7256            msg.contains("blocked IP") && msg.contains("loopback"),
7257            "got {msg:?}"
7258        );
7259    }
7260
7261    #[tokio::test]
7262    async fn screen_oauth_target_allows_public_hostname() {
7263        screen_oauth_target(
7264            "https://example.com/.well-known/jwks.json",
7265            false,
7266            &crate::ssrf::CompiledSsrfAllowlist::default(),
7267        )
7268        .await
7269        .expect("public hostname should pass screening");
7270    }
7271
7272    // -----------------------------------------------------------------------
7273    // Operator SSRF allowlist (1.4.0)
7274    // -----------------------------------------------------------------------
7275
7276    /// Helper: compile an allowlist from string literals.
7277    fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
7278        let raw = OAuthSsrfAllowlist {
7279            hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
7280            cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
7281        };
7282        compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
7283    }
7284
7285    #[test]
7286    fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
7287        let raw = OAuthSsrfAllowlist {
7288            hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
7289            cidrs: vec![],
7290        };
7291        let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
7292        assert_eq!(compiled.host_count(), 1);
7293        assert!(compiled.host_allowed("rhbk.ops.example.com"));
7294        assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
7295    }
7296
7297    #[test]
7298    fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
7299        let raw = OAuthSsrfAllowlist {
7300            hosts: vec!["10.0.0.1".into()],
7301            cidrs: vec![],
7302        };
7303        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
7304        assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
7305    }
7306
7307    #[test]
7308    fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
7309        let raw = OAuthSsrfAllowlist {
7310            hosts: vec!["rhbk.ops.example.com:8443".into()],
7311            cidrs: vec![],
7312        };
7313        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
7314        assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
7315    }
7316
7317    // -- L3: internal-hostname-suffix pre-DNS denylist --
7318
7319    #[test]
7320    fn internal_suffix_rejected_by_default() {
7321        let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7322        for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
7323            assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
7324        }
7325    }
7326
7327    #[test]
7328    fn exact_allowlisted_internal_permitted() {
7329        let allow = make_allowlist(&["idp.internal"], &[]);
7330        assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
7331        assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
7332    }
7333
7334    #[test]
7335    fn subdomain_of_allowlisted_internal_still_rejected() {
7336        let allow = make_allowlist(&["idp.internal"], &[]);
7337        assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
7338    }
7339
7340    #[test]
7341    fn cidr_allowlist_does_not_bypass_suffix_denylist() {
7342        let allow = make_allowlist(&[], &["10.0.0.0/8"]);
7343        assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
7344    }
7345
7346    #[test]
7347    fn public_hostname_not_blocked_by_suffix() {
7348        let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7349        assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
7350    }
7351
7352    #[test]
7353    fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
7354        let raw = OAuthSsrfAllowlist {
7355            hosts: vec![],
7356            cidrs: vec!["not-a-cidr".into()],
7357        };
7358        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
7359        assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
7360    }
7361
7362    #[test]
7363    fn validate_rejects_misconfigured_allowlist() {
7364        let mut cfg = OAuthConfig::builder(
7365            "https://auth.example.com/",
7366            "mcp",
7367            "https://auth.example.com/jwks.json",
7368        )
7369        .build();
7370        cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7371            hosts: vec!["10.0.0.1".into()],
7372            cidrs: vec![],
7373        });
7374        let err = cfg
7375            .validate()
7376            .expect_err("literal IP host must be rejected");
7377        assert!(
7378            err.to_string().contains("oauth.ssrf_allowlist"),
7379            "got {err}"
7380        );
7381    }
7382
7383    #[tokio::test]
7384    async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
7385        // localhost resolves to loopback; with a *non-empty* allowlist that
7386        // doesn't cover loopback, we expect the new verbose error referencing
7387        // the config field.
7388        let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
7389        let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
7390            .await
7391            .expect_err("loopback must still be blocked when not in allowlist");
7392        let msg = err.to_string();
7393        assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
7394        assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7395        assert!(msg.contains("SECURITY.md"), "got {msg:?}");
7396    }
7397
7398    #[tokio::test]
7399    async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
7400        // The default (empty) allowlist must continue to emit the
7401        // pre-1.4.0 wording so existing operator runbooks keep working.
7402        let err = screen_oauth_target(
7403            "https://localhost/jwks.json",
7404            false,
7405            &crate::ssrf::CompiledSsrfAllowlist::default(),
7406        )
7407        .await
7408        .expect_err("loopback rejection");
7409        let msg = err.to_string();
7410        assert!(msg.contains("blocked IP"), "got {msg:?}");
7411        assert!(msg.contains("loopback"), "got {msg:?}");
7412        // The legacy message must NOT advertise the new knob.
7413        assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7414    }
7415
7416    #[tokio::test]
7417    async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
7418        // localhost -> 127.0.0.1; allowlisting the hostname must let it through.
7419        let allow = make_allowlist(&["localhost"], &[]);
7420        screen_oauth_target("https://localhost/jwks.json", false, &allow)
7421            .await
7422            .expect("allowlisted host must pass");
7423    }
7424
7425    #[tokio::test]
7426    async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
7427        // localhost may resolve to 127.0.0.1 and/or ::1 depending on the OS;
7428        // allowlist both loopback ranges to make the test stable cross-platform.
7429        let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
7430        screen_oauth_target("https://localhost/jwks.json", false, &allow)
7431            .await
7432            .expect("allowlisted CIDR must pass");
7433    }
7434
7435    #[tokio::test]
7436    async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
7437        let mut cfg = OAuthConfig::builder(
7438            "https://auth.example.com/",
7439            "mcp",
7440            "https://auth.example.com/jwks.json",
7441        )
7442        .build();
7443        cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7444            hosts: vec![],
7445            cidrs: vec!["bad-cidr".into()],
7446        });
7447        let Err(err) = JwksCache::new(&cfg) else {
7448            panic!("invalid CIDR must fail JwksCache::new")
7449        };
7450        let msg = err.to_string();
7451        assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7452    }
7453
7454    #[tokio::test]
7455    async fn jwks_cache_new_invalid_ttl_is_err() {
7456        // An unvalidated config with a bogus TTL must surface as Err, not
7457        // as the formerly-documented panic.
7458        let cfg = OAuthConfig::builder(
7459            "https://auth.example.com/",
7460            "mcp",
7461            "https://auth.example.com/jwks.json",
7462        )
7463        .jwks_cache_ttl("not-a-duration")
7464        .build();
7465        let Err(err) = JwksCache::new(&cfg) else {
7466            panic!("invalid jwks_cache_ttl must fail JwksCache::new")
7467        };
7468        let msg = err.to_string();
7469        assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
7470    }
7471
7472    #[tokio::test]
7473    async fn audience_default_is_strict() {
7474        let kid = "test-audience-azp-default";
7475        let (pem, jwks) = generate_test_keypair(kid);
7476
7477        let mock_server = wiremock::MockServer::start().await;
7478        wiremock::Mock::given(wiremock::matchers::method("GET"))
7479            .and(wiremock::matchers::path("/jwks.json"))
7480            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7481            .mount(&mock_server)
7482            .await;
7483
7484        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7485        let config = test_config(&jwks_uri);
7486        let cache = test_cache(&config);
7487
7488        let now = jsonwebtoken::get_current_timestamp();
7489        let token = mint_token_with_claims(
7490            &pem,
7491            kid,
7492            &serde_json::json!({
7493                "iss": "https://auth.test.local",
7494                "aud": "https://some-other-resource.example.com",
7495                "azp": "https://mcp.test.local/mcp",
7496                "sub": "compat-client",
7497                "scope": "mcp:read",
7498                "exp": now + 3600,
7499                "iat": now,
7500            }),
7501        );
7502
7503        let failure = cache
7504            .validate_token_with_reason(&token)
7505            .await
7506            .expect_err("the default policy is Strict and must reject an azp-only match");
7507        assert_eq!(failure, JwtValidationFailure::Invalid);
7508    }
7509
7510    #[tokio::test]
7511    async fn audience_warn_still_accepts_azp() {
7512        let kid = "test-audience-warn-optin";
7513        let (pem, jwks) = generate_test_keypair(kid);
7514
7515        let mock_server = wiremock::MockServer::start().await;
7516        wiremock::Mock::given(wiremock::matchers::method("GET"))
7517            .and(wiremock::matchers::path("/jwks.json"))
7518            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7519            .mount(&mock_server)
7520            .await;
7521
7522        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7523        let mut config = test_config(&jwks_uri);
7524        config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7525        let cache = test_cache(&config);
7526
7527        let now = jsonwebtoken::get_current_timestamp();
7528        let token = mint_token_with_claims(
7529            &pem,
7530            kid,
7531            &serde_json::json!({
7532                "iss": "https://auth.test.local",
7533                "aud": "https://some-other-resource.example.com",
7534                "azp": "https://mcp.test.local/mcp",
7535                "sub": "warn-optin-client",
7536                "scope": "mcp:read",
7537                "exp": now + 3600,
7538                "iat": now,
7539            }),
7540        );
7541
7542        cache.validate_token_with_reason(&token).await.expect(
7543            "the audience_validation_mode=warn opt-out must still accept an azp-only match",
7544        );
7545    }
7546
7547    #[tokio::test]
7548    async fn legacy_strict_false_maps_to_warn() {
7549        let kid = "test-audience-legacy-false";
7550        let (pem, jwks) = generate_test_keypair(kid);
7551
7552        let mock_server = wiremock::MockServer::start().await;
7553        wiremock::Mock::given(wiremock::matchers::method("GET"))
7554            .and(wiremock::matchers::path("/jwks.json"))
7555            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7556            .mount(&mock_server)
7557            .await;
7558
7559        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7560        let mut config = test_config(&jwks_uri);
7561        // Legacy opt-out: the deprecated bool set to Some(false) with the enum
7562        // unset must resolve to Warn, preserving the pre-3.2 azp-accepting path.
7563        #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
7564        {
7565            config.strict_audience_validation = Some(false);
7566        }
7567        let cache = test_cache(&config);
7568
7569        let now = jsonwebtoken::get_current_timestamp();
7570        let token = mint_token_with_claims(
7571            &pem,
7572            kid,
7573            &serde_json::json!({
7574                "iss": "https://auth.test.local",
7575                "aud": "https://some-other-resource.example.com",
7576                "azp": "https://mcp.test.local/mcp",
7577                "sub": "legacy-false-client",
7578                "scope": "mcp:read",
7579                "exp": now + 3600,
7580                "iat": now,
7581            }),
7582        );
7583
7584        cache
7585            .validate_token_with_reason(&token)
7586            .await
7587            .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
7588    }
7589
7590    #[tokio::test]
7591    async fn aud_match_always_accepts() {
7592        let kid = "test-audience-aud-match";
7593        let (pem, jwks) = generate_test_keypair(kid);
7594
7595        let mock_server = wiremock::MockServer::start().await;
7596        wiremock::Mock::given(wiremock::matchers::method("GET"))
7597            .and(wiremock::matchers::path("/jwks.json"))
7598            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7599            .mount(&mock_server)
7600            .await;
7601
7602        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7603        let config = test_config(&jwks_uri); // Strict by default
7604        let cache = test_cache(&config);
7605
7606        let now = jsonwebtoken::get_current_timestamp();
7607        let token = mint_token_with_claims(
7608            &pem,
7609            kid,
7610            &serde_json::json!({
7611                "iss": "https://auth.test.local",
7612                "aud": "https://mcp.test.local/mcp",
7613                "sub": "aud-match-client",
7614                "scope": "mcp:read",
7615                "exp": now + 3600,
7616                "iat": now,
7617            }),
7618        );
7619
7620        cache
7621            .validate_token_with_reason(&token)
7622            .await
7623            .expect("a matching aud must be accepted even under the Strict default");
7624    }
7625
7626    #[tokio::test]
7627    async fn strict_audience_validation_rejects_azp_only_match() {
7628        let kid = "test-audience-azp-strict";
7629        let (pem, jwks) = generate_test_keypair(kid);
7630
7631        let mock_server = wiremock::MockServer::start().await;
7632        wiremock::Mock::given(wiremock::matchers::method("GET"))
7633            .and(wiremock::matchers::path("/jwks.json"))
7634            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7635            .mount(&mock_server)
7636            .await;
7637
7638        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7639        let mut config = test_config(&jwks_uri);
7640        #[allow(deprecated, reason = "covers the legacy bool resolution path")]
7641        {
7642            config.strict_audience_validation = Some(true);
7643        }
7644        let cache = test_cache(&config);
7645
7646        let now = jsonwebtoken::get_current_timestamp();
7647        let token = mint_token_with_claims(
7648            &pem,
7649            kid,
7650            &serde_json::json!({
7651                "iss": "https://auth.test.local",
7652                "aud": "https://some-other-resource.example.com",
7653                "azp": "https://mcp.test.local/mcp",
7654                "sub": "strict-client",
7655                "scope": "mcp:read",
7656                "exp": now + 3600,
7657                "iat": now,
7658            }),
7659        );
7660
7661        let failure = cache
7662            .validate_token_with_reason(&token)
7663            .await
7664            .expect_err("strict audience validation must ignore azp fallback");
7665        assert_eq!(failure, JwtValidationFailure::Invalid);
7666    }
7667
7668    #[tokio::test]
7669    async fn warn_mode_accepts_azp_only_match_and_warns_once() {
7670        let kid = "test-audience-warn-mode";
7671        let (pem, jwks) = generate_test_keypair(kid);
7672
7673        let mock_server = wiremock::MockServer::start().await;
7674        wiremock::Mock::given(wiremock::matchers::method("GET"))
7675            .and(wiremock::matchers::path("/jwks.json"))
7676            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7677            .mount(&mock_server)
7678            .await;
7679
7680        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7681        let mut config = test_config(&jwks_uri);
7682        config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7683        let cache = test_cache(&config);
7684
7685        let now = jsonwebtoken::get_current_timestamp();
7686        let claims = serde_json::json!({
7687            "iss": "https://auth.test.local",
7688            "aud": "https://some-other-resource.example.com",
7689            "azp": "https://mcp.test.local/mcp",
7690            "sub": "warn-client",
7691            "scope": "mcp:read",
7692            "exp": now + 3600,
7693            "iat": now,
7694        });
7695        let token = mint_token_with_claims(&pem, kid, &claims);
7696
7697        let identity = cache
7698            .validate_token_with_reason(&token)
7699            .await
7700            .expect("warn mode must accept azp-only match");
7701        assert_eq!(identity.role, "viewer");
7702        assert!(
7703            cache.azp_fallback_warned.load(Ordering::Relaxed),
7704            "warn-once flag should be set after first azp-only match"
7705        );
7706
7707        let token2 = mint_token_with_claims(&pem, kid, &claims);
7708        cache
7709            .validate_token_with_reason(&token2)
7710            .await
7711            .expect("warn mode must continue accepting subsequent matches");
7712        assert!(
7713            cache.azp_fallback_warned.load(Ordering::Relaxed),
7714            "warn-once flag must remain set; the assertion guards against accidental clearing"
7715        );
7716    }
7717
7718    #[tokio::test]
7719    async fn permissive_mode_accepts_azp_only_match_silently() {
7720        let kid = "test-audience-permissive-mode";
7721        let (pem, jwks) = generate_test_keypair(kid);
7722
7723        let mock_server = wiremock::MockServer::start().await;
7724        wiremock::Mock::given(wiremock::matchers::method("GET"))
7725            .and(wiremock::matchers::path("/jwks.json"))
7726            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7727            .mount(&mock_server)
7728            .await;
7729
7730        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7731        let mut config = test_config(&jwks_uri);
7732        config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7733        let cache = test_cache(&config);
7734
7735        let now = jsonwebtoken::get_current_timestamp();
7736        let token = mint_token_with_claims(
7737            &pem,
7738            kid,
7739            &serde_json::json!({
7740                "iss": "https://auth.test.local",
7741                "aud": "https://some-other-resource.example.com",
7742                "azp": "https://mcp.test.local/mcp",
7743                "sub": "permissive-client",
7744                "scope": "mcp:read",
7745                "exp": now + 3600,
7746                "iat": now,
7747            }),
7748        );
7749
7750        cache
7751            .validate_token_with_reason(&token)
7752            .await
7753            .expect("permissive mode must accept azp-only match");
7754        assert!(
7755            !cache.azp_fallback_warned.load(Ordering::Relaxed),
7756            "permissive mode must not flip the warn-once flag"
7757        );
7758        assert!(
7759            cache.azp_permissive_logged.load(Ordering::Relaxed),
7760            "permissive mode must record its own once-per-process log flag"
7761        );
7762    }
7763
7764    #[test]
7765    fn audience_validation_mode_overrides_legacy_bool() {
7766        let mut config = OAuthConfig::default();
7767        #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7768        {
7769            config.strict_audience_validation = Some(false);
7770        }
7771        config.audience_validation_mode = Some(AudienceValidationMode::Strict);
7772        assert_eq!(
7773            config.effective_audience_validation_mode(),
7774            AudienceValidationMode::Strict,
7775            "explicit mode must override legacy false"
7776        );
7777
7778        let mut config = OAuthConfig::default();
7779        #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7780        {
7781            config.strict_audience_validation = Some(true);
7782        }
7783        config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7784        assert_eq!(
7785            config.effective_audience_validation_mode(),
7786            AudienceValidationMode::Permissive,
7787            "explicit mode must override legacy true"
7788        );
7789    }
7790
7791    #[test]
7792    fn audience_validation_mode_default_is_strict_when_unset() {
7793        let config = OAuthConfig::default();
7794        assert_eq!(
7795            config.effective_audience_validation_mode(),
7796            AudienceValidationMode::Strict,
7797            "unset mode + unset bool must resolve to Strict (the secure default)"
7798        );
7799    }
7800
7801    #[test]
7802    fn audience_validation_legacy_bool_true_resolves_to_strict() {
7803        let mut config = OAuthConfig::default();
7804        #[allow(deprecated, reason = "covers the legacy bool resolution path")]
7805        {
7806            config.strict_audience_validation = Some(true);
7807        }
7808        assert_eq!(
7809            config.effective_audience_validation_mode(),
7810            AudienceValidationMode::Strict,
7811            "legacy bool=true must resolve to Strict for backward compat"
7812        );
7813    }
7814
7815    #[derive(Clone, Default)]
7816    struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
7817
7818    impl CapturedLogs {
7819        fn contents(&self) -> String {
7820            let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
7821            String::from_utf8(bytes).unwrap_or_default()
7822        }
7823    }
7824
7825    struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
7826
7827    impl std::io::Write for CapturedLogsWriter {
7828        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
7829            if let Ok(mut guard) = self.0.lock() {
7830                guard.extend_from_slice(buf);
7831            }
7832            Ok(buf.len())
7833        }
7834
7835        fn flush(&mut self) -> std::io::Result<()> {
7836            Ok(())
7837        }
7838    }
7839
7840    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
7841        type Writer = CapturedLogsWriter;
7842
7843        fn make_writer(&'a self) -> Self::Writer {
7844            CapturedLogsWriter(Arc::clone(&self.0))
7845        }
7846    }
7847
7848    fn exchanged_token_for_debug(secret: &str) -> ExchangedToken {
7849        ExchangedToken {
7850            access_token: secret.to_owned(),
7851            expires_in: Some(3600),
7852            issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".to_owned()),
7853        }
7854    }
7855
7856    fn exchanged_jwt_with_sensitive_claims() -> ExchangedToken {
7857        let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
7858        let payload = URL_SAFE_NO_PAD.encode(
7859            br#"{"sub":"subject-secret","aud":["aud-secret"],"azp":"azp-secret","iss":"issuer-secret"}"#,
7860        );
7861        exchanged_token_for_debug(&format!("{header}.{payload}.signature"))
7862    }
7863
7864    #[test]
7865    fn exchanged_token_debug_redacts_access_token_by_default() {
7866        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7867        crate::diagnostics::set_diagnostic_exposure(
7868            &crate::diagnostics::DiagnosticExposure::default(),
7869        );
7870        let secret = "oauth-access-token-secret";
7871
7872        let rendered = format!("{:?}", exchanged_token_for_debug(secret));
7873
7874        assert!(rendered.contains("[REDACTED]"));
7875        assert!(
7876            !rendered.contains(secret),
7877            "Debug output must not contain plaintext access token: {rendered}"
7878        );
7879        assert!(rendered.contains("expires_in"));
7880        assert!(rendered.contains("issued_token_type"));
7881    }
7882
7883    #[test]
7884    fn exchanged_token_debug_can_show_access_token_when_enabled() {
7885        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7886        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
7887            plaintext_oauth_tokens: true,
7888            ..crate::diagnostics::DiagnosticExposure::default()
7889        });
7890        let secret = "oauth-access-token-secret";
7891
7892        let rendered = format!("{:?}", exchanged_token_for_debug(secret));
7893
7894        assert!(rendered.contains(secret));
7895    }
7896
7897    #[test]
7898    fn exchanged_token_claim_log_redacts_claim_values_by_default() {
7899        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7900        crate::diagnostics::set_diagnostic_exposure(
7901            &crate::diagnostics::DiagnosticExposure::default(),
7902        );
7903        let logs = CapturedLogs::default();
7904        let subscriber = tracing_subscriber::fmt()
7905            .with_max_level(tracing::Level::DEBUG)
7906            .with_writer(logs.clone())
7907            .with_ansi(false)
7908            .without_time()
7909            .finish();
7910        let _subscriber_guard = tracing::subscriber::set_default(subscriber);
7911
7912        log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
7913
7914        let contents = logs.contents();
7915        assert!(contents.contains("[REDACTED]"));
7916        for secret in [
7917            "subject-secret",
7918            "aud-secret",
7919            "azp-secret",
7920            "issuer-secret",
7921        ] {
7922            assert!(
7923                !contents.contains(secret),
7924                "claim log must not contain {secret}: {contents}"
7925            );
7926        }
7927        assert!(contents.contains("expires_in"));
7928    }
7929
7930    #[test]
7931    fn exchanged_token_claim_log_can_show_claim_values_when_enabled() {
7932        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
7933        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
7934            oauth_claim_values: true,
7935            ..crate::diagnostics::DiagnosticExposure::default()
7936        });
7937        let logs = CapturedLogs::default();
7938        let subscriber = tracing_subscriber::fmt()
7939            .with_max_level(tracing::Level::DEBUG)
7940            .with_writer(logs.clone())
7941            .with_ansi(false)
7942            .without_time()
7943            .finish();
7944        let _subscriber_guard = tracing::subscriber::set_default(subscriber);
7945
7946        log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
7947
7948        let contents = logs.contents();
7949        for secret in [
7950            "subject-secret",
7951            "aud-secret",
7952            "azp-secret",
7953            "issuer-secret",
7954        ] {
7955            assert!(
7956                contents.contains(secret),
7957                "claim log must contain {secret} when enabled: {contents}"
7958            );
7959        }
7960    }
7961
7962    #[tokio::test]
7963    async fn jwks_response_size_cap_returns_none_and_logs_warning() {
7964        let kid = "oversized-jwks";
7965        let (_pem, jwks) = generate_test_keypair(kid);
7966        let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
7967        oversized_body.push_str(&" ".repeat(4096));
7968
7969        let mock_server = wiremock::MockServer::start().await;
7970        wiremock::Mock::given(wiremock::matchers::method("GET"))
7971            .and(wiremock::matchers::path("/jwks.json"))
7972            .respond_with(
7973                wiremock::ResponseTemplate::new(200)
7974                    .insert_header("content-type", "application/json")
7975                    .set_body_string(oversized_body),
7976            )
7977            .mount(&mock_server)
7978            .await;
7979
7980        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7981        let mut config = test_config(&jwks_uri);
7982        config.jwks_max_response_bytes = 256;
7983        let cache = test_cache(&config);
7984
7985        let logs = CapturedLogs::default();
7986        let subscriber = tracing_subscriber::fmt()
7987            .with_writer(logs.clone())
7988            .with_ansi(false)
7989            .without_time()
7990            .finish();
7991        let _guard = tracing::subscriber::set_default(subscriber);
7992
7993        let result = cache.fetch_jwks().await;
7994        assert!(result.is_none(), "oversized JWKS must be dropped");
7995        assert!(
7996            logs.contents()
7997                .contains("JWKS response exceeded configured size cap"),
7998            "expected cap-exceeded warning in logs"
7999        );
8000    }
8001
8002    /// A redirect to a userinfo-bearing target is rejected, and the
8003    /// rejection warn log must not echo the embedded credentials
8004    /// (sanitized to scheme+host+port only).
8005    #[tokio::test]
8006    async fn redirect_rejection_log_does_not_echo_credentials() {
8007        let mock_server = wiremock::MockServer::start().await;
8008        wiremock::Mock::given(wiremock::matchers::method("GET"))
8009            .and(wiremock::matchers::path("/jwks.json"))
8010            .respond_with(
8011                wiremock::ResponseTemplate::new(302)
8012                    .insert_header("location", "https://u:p@redirect-target.example/next"),
8013            )
8014            .mount(&mock_server)
8015            .await;
8016
8017        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8018        let config = test_config(&jwks_uri);
8019        let cache = test_cache(&config);
8020
8021        let logs = CapturedLogs::default();
8022        let subscriber = tracing_subscriber::fmt()
8023            .with_writer(logs.clone())
8024            .with_ansi(false)
8025            .without_time()
8026            .finish();
8027        let _guard = tracing::subscriber::set_default(subscriber);
8028
8029        let result = cache.fetch_jwks().await;
8030        assert!(result.is_none(), "rejected redirect must fail the fetch");
8031        let contents = logs.contents();
8032        assert!(
8033            contents.contains("oauth redirect rejected"),
8034            "expected redirect-rejection warning in logs: {contents}"
8035        );
8036        assert!(
8037            !contents.contains("u:p"),
8038            "rejection log must not echo userinfo credentials: {contents}"
8039        );
8040    }
8041
8042    #[tokio::test]
8043    async fn jwks_fetch_failure_log_sanitizes_url_and_reqwest_error() {
8044        let config = test_config("http://127.0.0.1:1/jwks.json?client_secret=super-secret");
8045        let cache = test_cache(&config);
8046
8047        let logs = CapturedLogs::default();
8048        let subscriber = tracing_subscriber::fmt()
8049            .with_max_level(tracing::Level::WARN)
8050            .with_writer(logs.clone())
8051            .with_ansi(false)
8052            .without_time()
8053            .finish();
8054        let _guard = tracing::subscriber::set_default(subscriber);
8055
8056        let result = cache.fetch_jwks().await;
8057        assert!(
8058            result.is_none(),
8059            "closed loopback port must fail JWKS fetch"
8060        );
8061        let contents = logs.contents();
8062        assert!(
8063            contents.contains("failed to fetch JWKS"),
8064            "JWKS failure must still be logged: {contents}"
8065        );
8066        assert!(
8067            contents.contains("uri=http://127.0.0.1:1"),
8068            "JWKS failure log must include only sanitized origin: {contents}"
8069        );
8070        for leaked in ["/jwks.json", "client_secret", "super-secret"] {
8071            assert!(
8072                !contents.contains(leaked),
8073                "JWKS failure log must not echo raw URL component {leaked}: {contents}"
8074            );
8075        }
8076    }
8077
8078    #[tokio::test]
8079    async fn role_claim_keycloak_nested_array() {
8080        let kid = "test-role-1";
8081        let (pem, jwks) = generate_test_keypair(kid);
8082
8083        let mock_server = wiremock::MockServer::start().await;
8084        wiremock::Mock::given(wiremock::matchers::method("GET"))
8085            .and(wiremock::matchers::path("/jwks.json"))
8086            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8087            .mount(&mock_server)
8088            .await;
8089
8090        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8091        let config = test_config_with_role_claim(
8092            &jwks_uri,
8093            "realm_access.roles",
8094            vec![
8095                RoleMapping {
8096                    claim_value: "mcp-admin".into(),
8097                    role: "ops".into(),
8098                },
8099                RoleMapping {
8100                    claim_value: "mcp-viewer".into(),
8101                    role: "viewer".into(),
8102                },
8103            ],
8104        );
8105        let cache = test_cache(&config);
8106
8107        let now = jsonwebtoken::get_current_timestamp();
8108        let token = mint_token_with_claims(
8109            &pem,
8110            kid,
8111            &serde_json::json!({
8112                "iss": "https://auth.test.local",
8113                "aud": "https://mcp.test.local/mcp",
8114                "sub": "keycloak-user",
8115                "exp": now + 3600,
8116                "iat": now,
8117                "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
8118            }),
8119        );
8120
8121        let id = cache
8122            .validate_token(&token)
8123            .await
8124            .expect("should authenticate");
8125        assert_eq!(id.name, "keycloak-user");
8126        assert_eq!(id.role, "ops");
8127    }
8128
8129    #[tokio::test]
8130    async fn role_claim_flat_roles_array() {
8131        let kid = "test-role-2";
8132        let (pem, jwks) = generate_test_keypair(kid);
8133
8134        let mock_server = wiremock::MockServer::start().await;
8135        wiremock::Mock::given(wiremock::matchers::method("GET"))
8136            .and(wiremock::matchers::path("/jwks.json"))
8137            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8138            .mount(&mock_server)
8139            .await;
8140
8141        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8142        let config = test_config_with_role_claim(
8143            &jwks_uri,
8144            "roles",
8145            vec![
8146                RoleMapping {
8147                    claim_value: "MCP.Admin".into(),
8148                    role: "ops".into(),
8149                },
8150                RoleMapping {
8151                    claim_value: "MCP.Reader".into(),
8152                    role: "viewer".into(),
8153                },
8154            ],
8155        );
8156        let cache = test_cache(&config);
8157
8158        let now = jsonwebtoken::get_current_timestamp();
8159        let token = mint_token_with_claims(
8160            &pem,
8161            kid,
8162            &serde_json::json!({
8163                "iss": "https://auth.test.local",
8164                "aud": "https://mcp.test.local/mcp",
8165                "sub": "azure-ad-user",
8166                "exp": now + 3600,
8167                "iat": now,
8168                "roles": ["MCP.Reader", "OtherApp.Admin"]
8169            }),
8170        );
8171
8172        let id = cache
8173            .validate_token(&token)
8174            .await
8175            .expect("should authenticate");
8176        assert_eq!(id.name, "azure-ad-user");
8177        assert_eq!(id.role, "viewer");
8178    }
8179
8180    #[tokio::test]
8181    async fn role_claim_no_matching_value_rejected() {
8182        let kid = "test-role-3";
8183        let (pem, jwks) = generate_test_keypair(kid);
8184
8185        let mock_server = wiremock::MockServer::start().await;
8186        wiremock::Mock::given(wiremock::matchers::method("GET"))
8187            .and(wiremock::matchers::path("/jwks.json"))
8188            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8189            .mount(&mock_server)
8190            .await;
8191
8192        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8193        let config = test_config_with_role_claim(
8194            &jwks_uri,
8195            "roles",
8196            vec![RoleMapping {
8197                claim_value: "mcp-admin".into(),
8198                role: "ops".into(),
8199            }],
8200        );
8201        let cache = test_cache(&config);
8202
8203        let now = jsonwebtoken::get_current_timestamp();
8204        let token = mint_token_with_claims(
8205            &pem,
8206            kid,
8207            &serde_json::json!({
8208                "iss": "https://auth.test.local",
8209                "aud": "https://mcp.test.local/mcp",
8210                "sub": "limited-user",
8211                "exp": now + 3600,
8212                "iat": now,
8213                "roles": ["some-other-role"]
8214            }),
8215        );
8216
8217        assert!(cache.validate_token(&token).await.is_none());
8218    }
8219
8220    #[tokio::test]
8221    async fn role_claim_space_separated_string() {
8222        let kid = "test-role-4";
8223        let (pem, jwks) = generate_test_keypair(kid);
8224
8225        let mock_server = wiremock::MockServer::start().await;
8226        wiremock::Mock::given(wiremock::matchers::method("GET"))
8227            .and(wiremock::matchers::path("/jwks.json"))
8228            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8229            .mount(&mock_server)
8230            .await;
8231
8232        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8233        let config = test_config_with_role_claim(
8234            &jwks_uri,
8235            "custom_scope",
8236            vec![
8237                RoleMapping {
8238                    claim_value: "write".into(),
8239                    role: "ops".into(),
8240                },
8241                RoleMapping {
8242                    claim_value: "read".into(),
8243                    role: "viewer".into(),
8244                },
8245            ],
8246        );
8247        let cache = test_cache(&config);
8248
8249        let now = jsonwebtoken::get_current_timestamp();
8250        let token = mint_token_with_claims(
8251            &pem,
8252            kid,
8253            &serde_json::json!({
8254                "iss": "https://auth.test.local",
8255                "aud": "https://mcp.test.local/mcp",
8256                "sub": "custom-client",
8257                "exp": now + 3600,
8258                "iat": now,
8259                "custom_scope": "read audit"
8260            }),
8261        );
8262
8263        let id = cache
8264            .validate_token(&token)
8265            .await
8266            .expect("should authenticate");
8267        assert_eq!(id.name, "custom-client");
8268        assert_eq!(id.role, "viewer");
8269    }
8270
8271    #[tokio::test]
8272    async fn scope_backward_compat_without_role_claim() {
8273        // Verify existing `scopes` behavior still works when role_claim is None.
8274        let kid = "test-compat-1";
8275        let (pem, jwks) = generate_test_keypair(kid);
8276
8277        let mock_server = wiremock::MockServer::start().await;
8278        wiremock::Mock::given(wiremock::matchers::method("GET"))
8279            .and(wiremock::matchers::path("/jwks.json"))
8280            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8281            .mount(&mock_server)
8282            .await;
8283
8284        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8285        let config = test_config(&jwks_uri); // role_claim: None, uses scopes
8286        let cache = test_cache(&config);
8287
8288        let token = mint_token(
8289            &pem,
8290            kid,
8291            "https://auth.test.local",
8292            "https://mcp.test.local/mcp",
8293            "legacy-bot",
8294            "mcp:admin other:scope",
8295        );
8296
8297        let id = cache
8298            .validate_token(&token)
8299            .await
8300            .expect("should authenticate");
8301        assert_eq!(id.name, "legacy-bot");
8302        assert_eq!(id.role, "ops"); // mcp:admin -> ops via scopes
8303    }
8304
8305    // -----------------------------------------------------------------------
8306    // JWKS refresh cooldown tests
8307    // -----------------------------------------------------------------------
8308
8309    #[tokio::test]
8310    async fn jwks_refresh_deduplication() {
8311        // Verify that concurrent requests with unknown kids result in exactly
8312        // one JWKS fetch, not one per request (deduplication via mutex).
8313        let kid = "test-dedup";
8314        let (pem, jwks) = generate_test_keypair(kid);
8315
8316        let mock_server = wiremock::MockServer::start().await;
8317        let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8318            .and(wiremock::matchers::path("/jwks.json"))
8319            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8320            .expect(1) // Should be called exactly once
8321            .mount(&mock_server)
8322            .await;
8323
8324        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8325        let config = test_config(&jwks_uri);
8326        let cache = Arc::new(test_cache(&config));
8327
8328        // Create 5 concurrent validation requests with the same valid token.
8329        let token = mint_token(
8330            &pem,
8331            kid,
8332            "https://auth.test.local",
8333            "https://mcp.test.local/mcp",
8334            "concurrent-bot",
8335            "mcp:read",
8336        );
8337
8338        let mut handles = Vec::new();
8339        for _ in 0..5 {
8340            let c = Arc::clone(&cache);
8341            let t = token.clone();
8342            handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
8343        }
8344
8345        for h in handles {
8346            let result = h.await.unwrap();
8347            assert!(result.is_some(), "all concurrent requests should succeed");
8348        }
8349
8350        // The expect(1) assertion on the mock verifies only one fetch occurred.
8351    }
8352
8353    #[tokio::test]
8354    async fn jwks_refresh_cooldown_blocks_rapid_requests() {
8355        // Verify that rapid sequential requests with unknown kids (cache misses)
8356        // only trigger one JWKS fetch due to cooldown.
8357        let kid = "test-cooldown";
8358        let (_pem, jwks) = generate_test_keypair(kid);
8359
8360        let mock_server = wiremock::MockServer::start().await;
8361        let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8362            .and(wiremock::matchers::path("/jwks.json"))
8363            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8364            .expect(1) // Should be called exactly once despite multiple misses
8365            .mount(&mock_server)
8366            .await;
8367
8368        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8369        let config = test_config(&jwks_uri);
8370        let cache = test_cache(&config);
8371
8372        // First request with unknown kid triggers a refresh.
8373        let fake_token1 =
8374            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
8375        let _ = cache.validate_token(fake_token1).await;
8376
8377        // Second request with a different unknown kid should NOT trigger refresh
8378        // because we're within the 10-second cooldown.
8379        let fake_token2 =
8380            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
8381        let _ = cache.validate_token(fake_token2).await;
8382
8383        // Third request with yet another unknown kid - still within cooldown.
8384        let fake_token3 =
8385            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
8386        let _ = cache.validate_token(fake_token3).await;
8387
8388        // The expect(1) assertion verifies only one fetch occurred.
8389    }
8390
8391    // -- introspection / revocation proxy --
8392
8393    fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
8394        OAuthProxyConfig {
8395            authorize_url: "https://example.invalid/auth".into(),
8396            token_url: token_url.into(),
8397            client_id: "mcp-client".into(),
8398            client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
8399            introspection_url: None,
8400            revocation_url: None,
8401            expose_admin_endpoints: false,
8402            require_auth_on_admin_endpoints: false,
8403            allow_unauthenticated_admin_endpoints: false,
8404            strip_resource_param: false,
8405        }
8406    }
8407
8408    /// Build an HTTP client for tests. Ensures a rustls crypto provider
8409    /// is installed (normally done inside `JwksCache::new`).
8410    fn test_http_client() -> OauthHttpClient {
8411        rustls::crypto::ring::default_provider()
8412            .install_default()
8413            .ok();
8414        let config = OAuthConfig::builder(
8415            "https://auth.test.local",
8416            "https://mcp.test.local/mcp",
8417            "https://auth.test.local/.well-known/jwks.json",
8418        )
8419        .allow_http_oauth_urls(true)
8420        .build();
8421        OauthHttpClient::with_config(&config)
8422            .expect("build test http client")
8423            .__test_allow_loopback_ssrf()
8424    }
8425
8426    #[tokio::test]
8427    async fn introspect_proxies_and_injects_client_credentials() {
8428        use wiremock::matchers::{body_string_contains, method, path};
8429
8430        let mock_server = wiremock::MockServer::start().await;
8431        wiremock::Mock::given(method("POST"))
8432            .and(path("/introspect"))
8433            .and(body_string_contains("client_id=mcp-client"))
8434            .and(body_string_contains("client_secret=shh"))
8435            .and(body_string_contains("token=abc"))
8436            .respond_with(
8437                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8438                    "active": true,
8439                    "scope": "read"
8440                })),
8441            )
8442            .expect(1)
8443            .mount(&mock_server)
8444            .await;
8445
8446        let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8447        proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
8448
8449        let http = test_http_client();
8450        let resp = handle_introspect(&http, &proxy, "token=abc").await;
8451        assert_eq!(resp.status(), 200);
8452    }
8453
8454    #[tokio::test]
8455    async fn token_proxy_fails_closed_on_oversized_upstream_response() {
8456        use http_body_util::BodyExt as _;
8457        use wiremock::matchers::{method, path};
8458
8459        // Upstream returns a body far larger than OAUTH_PROXY_MAX_RESPONSE_BYTES.
8460        let oversized = "x"
8461            .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
8462        let mock_server = wiremock::MockServer::start().await;
8463        wiremock::Mock::given(method("POST"))
8464            .and(path("/token"))
8465            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
8466            .expect(1)
8467            .mount(&mock_server)
8468            .await;
8469
8470        let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8471        let http = test_http_client();
8472        let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8473
8474        // Must fail closed with 502, and MUST NOT forward the oversized body.
8475        assert_eq!(
8476            resp.status(),
8477            502,
8478            "oversized upstream response must fail closed as 502"
8479        );
8480        let body = resp
8481            .into_body()
8482            .collect()
8483            .await
8484            .expect("collect body")
8485            .to_bytes();
8486        assert!(
8487            body.len() < 1024,
8488            "must return the small generic error body, not the oversized upstream body (got {} bytes)",
8489            body.len()
8490        );
8491        assert!(
8492            !body.windows(8).any(|w| w == b"xxxxxxxx"),
8493            "the oversized upstream payload must not be forwarded to the client"
8494        );
8495    }
8496
8497    #[tokio::test]
8498    async fn token_proxy_passes_through_normal_response() {
8499        use http_body_util::BodyExt as _;
8500        use wiremock::matchers::{method, path};
8501
8502        let mock_server = wiremock::MockServer::start().await;
8503        wiremock::Mock::given(method("POST"))
8504            .and(path("/token"))
8505            .respond_with(
8506                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8507                    "access_token": "at-123",
8508                    "token_type": "Bearer"
8509                })),
8510            )
8511            .expect(1)
8512            .mount(&mock_server)
8513            .await;
8514
8515        let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8516        let http = test_http_client();
8517        let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8518
8519        assert_eq!(
8520            resp.status(),
8521            200,
8522            "a normal-sized response must pass through"
8523        );
8524        let body = resp
8525            .into_body()
8526            .collect()
8527            .await
8528            .expect("collect body")
8529            .to_bytes();
8530        let json: serde_json::Value =
8531            serde_json::from_slice(&body).expect("upstream JSON preserved");
8532        assert_eq!(json["access_token"], "at-123");
8533    }
8534
8535    #[tokio::test]
8536    async fn introspect_returns_404_when_not_configured() {
8537        let proxy = proxy_cfg("https://example.invalid/token");
8538        let http = test_http_client();
8539        let resp = handle_introspect(&http, &proxy, "token=abc").await;
8540        assert_eq!(resp.status(), 404);
8541    }
8542
8543    #[tokio::test]
8544    async fn revoke_proxies_and_returns_upstream_status() {
8545        use wiremock::matchers::{method, path};
8546
8547        let mock_server = wiremock::MockServer::start().await;
8548        wiremock::Mock::given(method("POST"))
8549            .and(path("/revoke"))
8550            .respond_with(wiremock::ResponseTemplate::new(200))
8551            .expect(1)
8552            .mount(&mock_server)
8553            .await;
8554
8555        let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8556        proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
8557
8558        let http = test_http_client();
8559        let resp = handle_revoke(&http, &proxy, "token=abc").await;
8560        assert_eq!(resp.status(), 200);
8561    }
8562
8563    #[tokio::test]
8564    async fn revoke_returns_404_when_not_configured() {
8565        let proxy = proxy_cfg("https://example.invalid/token");
8566        let http = test_http_client();
8567        let resp = handle_revoke(&http, &proxy, "token=abc").await;
8568        assert_eq!(resp.status(), 404);
8569    }
8570
8571    #[test]
8572    fn metadata_advertises_endpoints_only_when_configured() {
8573        let mut cfg = test_config("https://auth.test.local/jwks.json");
8574        // Without proxy configured, no introspection/revocation advertised.
8575        let m = authorization_server_metadata("https://mcp.local", &cfg);
8576        assert!(m.get("introspection_endpoint").is_none());
8577        assert!(m.get("revocation_endpoint").is_none());
8578
8579        // With proxy + introspection_url but expose_admin_endpoints = false
8580        // (the secure default): endpoints MUST NOT be advertised.
8581        let mut proxy = proxy_cfg("https://upstream.local/token");
8582        proxy.introspection_url = Some("https://upstream.local/introspect".into());
8583        proxy.revocation_url = Some("https://upstream.local/revoke".into());
8584        cfg.proxy = Some(proxy);
8585        let m = authorization_server_metadata("https://mcp.local", &cfg);
8586        assert!(
8587            m.get("introspection_endpoint").is_none(),
8588            "introspection must not be advertised when expose_admin_endpoints=false"
8589        );
8590        assert!(
8591            m.get("revocation_endpoint").is_none(),
8592            "revocation must not be advertised when expose_admin_endpoints=false"
8593        );
8594
8595        // Opt in: expose_admin_endpoints = true + introspection_url only.
8596        if let Some(p) = cfg.proxy.as_mut() {
8597            p.expose_admin_endpoints = true;
8598            p.revocation_url = None;
8599        }
8600        let m = authorization_server_metadata("https://mcp.local", &cfg);
8601        assert_eq!(
8602            m["introspection_endpoint"],
8603            serde_json::Value::String("https://mcp.local/introspect".into())
8604        );
8605        assert!(m.get("revocation_endpoint").is_none());
8606
8607        // Add revocation_url.
8608        if let Some(p) = cfg.proxy.as_mut() {
8609            p.revocation_url = Some("https://upstream.local/revoke".into());
8610        }
8611        let m = authorization_server_metadata("https://mcp.local", &cfg);
8612        assert_eq!(
8613            m["revocation_endpoint"],
8614            serde_json::Value::String("https://mcp.local/revoke".into())
8615        );
8616    }
8617
8618    // ---------- M-H4: token-exchange client authentication ----------
8619
8620    fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
8621        let mut cfg = validation_https_config();
8622        cfg.token_exchange = Some(tx);
8623        cfg
8624    }
8625
8626    fn tx_with(
8627        client_secret: Option<&str>,
8628        client_cert: Option<ClientCertConfig>,
8629    ) -> TokenExchangeConfig {
8630        TokenExchangeConfig::new(
8631            "https://idp.example.com/token",
8632            "client",
8633            client_secret.map(|s| secrecy::SecretString::new(s.into())),
8634            client_cert,
8635        )
8636        .with_audience("downstream")
8637    }
8638
8639    #[test]
8640    fn validate_rejects_non_uri_custom_requested_token_type() {
8641        for bad in ["acess_token", "not a uri", "urn:bad%zz:token"] {
8642            let tx = tx_with(Some("s"), None)
8643                .with_requested_token_type(RequestedTokenType::Custom(bad.to_owned()));
8644            let err = https_cfg_with_tx(tx)
8645                .validate()
8646                .expect_err("a custom token type that is not a URI must be rejected")
8647                .to_string();
8648            assert!(
8649                err.contains("requested_token_type"),
8650                "error must name the offending field for {bad:?}; got {err:?}"
8651            );
8652        }
8653    }
8654
8655    #[test]
8656    fn validate_accepts_uri_custom_requested_token_type_including_fragments() {
8657        for good in [
8658            "urn:ietf:params:oauth:token-type:saml2",
8659            "https://vendor.example/token-type",
8660            "urn:example:token#v2",
8661        ] {
8662            let tx = tx_with(Some("s"), None)
8663                .with_requested_token_type(RequestedTokenType::Custom(good.to_owned()));
8664            https_cfg_with_tx(tx).validate().unwrap_or_else(|e| {
8665                panic!(
8666                    "RFC 8693 §3 only requires a URI; {good:?} must be accepted \
8667                     (the no-fragment rule is RFC 8707's, for `resource` only): {e}"
8668                )
8669            });
8670        }
8671    }
8672
8673    #[test]
8674    fn validate_rejects_empty_optional_token_exchange_params() {
8675        let base = || tx_with(Some("s"), None);
8676        let cases = [
8677            (base().with_audience(""), "audience"),
8678            (base().with_resource(""), "resource"),
8679            (base().with_scope(""), "scope"),
8680            (
8681                base().with_requested_token_type(RequestedTokenType::Custom(String::new())),
8682                "requested_token_type",
8683            ),
8684        ];
8685        for (tx, field) in cases {
8686            let cfg = https_cfg_with_tx(tx);
8687            let err = cfg
8688                .validate()
8689                .expect_err("an empty optional parameter must be rejected");
8690            let msg = err.to_string();
8691            assert!(
8692                msg.contains(field) && msg.contains("must not be empty"),
8693                "error must name {field} and explain emptiness; got {msg:?}"
8694            );
8695        }
8696    }
8697
8698    #[test]
8699    fn validate_rejects_non_conformant_resource_uri() {
8700        for (value, expected) in [
8701            ("not-an-absolute-uri", "absolute URI"),
8702            ("https://api.example.com/v1#frag", "fragment"),
8703            ("https://api.example.com/a b", "valid URI characters"),
8704            ("https://api.example.com/%zz", "valid URI characters"),
8705            ("https://api.example.com/\u{e9}", "valid URI characters"),
8706        ] {
8707            let cfg = https_cfg_with_tx(tx_with(Some("s"), None).with_resource(value));
8708            let err = cfg
8709                .validate()
8710                .expect_err("resource must satisfy RFC 8707 §2");
8711            let msg = err.to_string();
8712            assert!(
8713                msg.contains(expected),
8714                "error for {value:?} must mention {expected:?}; got {msg:?}"
8715            );
8716        }
8717    }
8718
8719    #[test]
8720    fn validate_accepts_token_exchange_with_all_optional_params_omitted() {
8721        let mut tx = tx_with(Some("s"), None);
8722        tx.audience = None;
8723        tx.requested_token_type = RequestedTokenType::Omit;
8724        https_cfg_with_tx(tx)
8725            .validate()
8726            .expect("omitting every RFC 8693 §2.1 OPTIONAL parameter must be valid");
8727    }
8728
8729    #[test]
8730    fn validate_rejects_token_exchange_without_client_auth() {
8731        let cfg = https_cfg_with_tx(tx_with(None, None));
8732        let err = cfg
8733            .validate()
8734            .expect_err("token_exchange without client auth must be rejected");
8735        let msg = err.to_string();
8736        assert!(
8737            msg.contains("requires client authentication"),
8738            "error must explain missing client auth; got {msg:?}"
8739        );
8740    }
8741
8742    #[test]
8743    fn validate_rejects_token_exchange_with_both_secret_and_cert() {
8744        let cc = ClientCertConfig {
8745            cert_path: PathBuf::from("/nonexistent/cert.pem"),
8746            key_path: PathBuf::from("/nonexistent/key.pem"),
8747        };
8748        let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
8749        let err = cfg
8750            .validate()
8751            .expect_err("client_secret + client_cert must be rejected");
8752        let msg = err.to_string();
8753        assert!(
8754            msg.contains("mutually") && msg.contains("exclusive"),
8755            "error must explain mutual exclusion; got {msg:?}"
8756        );
8757    }
8758
8759    #[cfg(not(feature = "oauth-mtls-client"))]
8760    #[test]
8761    fn validate_rejects_client_cert_without_feature() {
8762        let cc = ClientCertConfig {
8763            cert_path: PathBuf::from("/nonexistent/cert.pem"),
8764            key_path: PathBuf::from("/nonexistent/key.pem"),
8765        };
8766        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8767        let err = cfg
8768            .validate()
8769            .expect_err("client_cert without feature must be rejected");
8770        assert!(
8771            err.to_string().contains("oauth-mtls-client"),
8772            "error must reference the cargo feature; got {err}"
8773        );
8774    }
8775
8776    #[cfg(feature = "oauth-mtls-client")]
8777    #[test]
8778    fn validate_rejects_missing_client_cert_files() {
8779        let cc = ClientCertConfig {
8780            cert_path: PathBuf::from("/nonexistent/cert.pem"),
8781            key_path: PathBuf::from("/nonexistent/key.pem"),
8782        };
8783        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8784        let err = cfg
8785            .validate()
8786            .expect_err("missing cert file must be rejected");
8787        assert!(
8788            err.to_string().contains("unreadable"),
8789            "error must call out unreadable file; got {err}"
8790        );
8791    }
8792
8793    #[cfg(feature = "oauth-mtls-client")]
8794    #[test]
8795    fn validate_rejects_malformed_client_cert_pem() {
8796        let dir = std::env::temp_dir();
8797        let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
8798        let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
8799        std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
8800        std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
8801        let cc = ClientCertConfig {
8802            cert_path: cert.clone(),
8803            key_path: key.clone(),
8804        };
8805        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8806        let err = cfg.validate().expect_err("malformed PEM must be rejected");
8807        let _ = std::fs::remove_file(&cert);
8808        let _ = std::fs::remove_file(&key);
8809        assert!(
8810            err.to_string().contains("PEM parse failed"),
8811            "error must call out PEM parse failure; got {err}"
8812        );
8813    }
8814
8815    #[cfg(feature = "oauth-mtls-client")]
8816    fn write_self_signed_pem() -> (PathBuf, PathBuf) {
8817        let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
8818        let dir = std::env::temp_dir();
8819        let pid = std::process::id();
8820        let nonce: u64 = rand::random();
8821        let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
8822        let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
8823        std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
8824        std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
8825        (cert_path, key_path)
8826    }
8827
8828    #[cfg(feature = "oauth-mtls-client")]
8829    fn install_test_crypto_provider() {
8830        let _ = rustls::crypto::ring::default_provider().install_default();
8831    }
8832
8833    #[cfg(feature = "oauth-mtls-client")]
8834    #[test]
8835    fn validate_accepts_well_formed_client_cert() {
8836        install_test_crypto_provider();
8837        let (cert_path, key_path) = write_self_signed_pem();
8838        let cc = ClientCertConfig {
8839            cert_path: cert_path.clone(),
8840            key_path: key_path.clone(),
8841        };
8842        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8843        let res = cfg.validate();
8844        let _ = std::fs::remove_file(&cert_path);
8845        let _ = std::fs::remove_file(&key_path);
8846        res.expect("well-formed cert+key must validate");
8847    }
8848
8849    #[cfg(feature = "oauth-mtls-client")]
8850    #[test]
8851    fn client_for_returns_cached_mtls_client() {
8852        install_test_crypto_provider();
8853        let (cert_path, key_path) = write_self_signed_pem();
8854        let cc = ClientCertConfig {
8855            cert_path: cert_path.clone(),
8856            key_path: key_path.clone(),
8857        };
8858        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8859        let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
8860        let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
8861        let cert_client = http.client_for(tx_ref);
8862        let inner_client = http.client_for(&tx_with(Some("s"), None));
8863        let _ = std::fs::remove_file(&cert_path);
8864        let _ = std::fs::remove_file(&key_path);
8865        assert!(
8866            !std::ptr::eq(cert_client, inner_client),
8867            "client_for must return distinct clients for cert vs no-cert configs"
8868        );
8869    }
8870
8871    #[cfg(feature = "oauth-mtls-client")]
8872    #[test]
8873    fn client_for_falls_back_to_inner_when_cache_miss() {
8874        install_test_crypto_provider();
8875        let cfg = validation_https_config();
8876        let http = OauthHttpClient::with_config(&cfg).expect("build client");
8877        let unrelated_cc = ClientCertConfig {
8878            cert_path: PathBuf::from("/cache/miss/cert.pem"),
8879            key_path: PathBuf::from("/cache/miss/key.pem"),
8880        };
8881        let tx_unknown = tx_with(None, Some(unrelated_cc));
8882        let fallback = http.client_for(&tx_unknown);
8883        let inner = http.client_for(&tx_with(Some("s"), None));
8884        assert!(
8885            std::ptr::eq(fallback, inner),
8886            "cache miss must fall back to inner client"
8887        );
8888    }
8889}