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_deref())?;
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<&[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_deref())?,
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        // `require_subject` must also reject a *blank* sub: it is the OAuth
2762        // session-binding stable id, so a blank one collapses distinct
2763        // principals to one fingerprint (CWE-384).
2764        if self.require_subject && claims.sub.as_deref().is_none_or(|s| s.trim().is_empty()) {
2765            core::hint::cold_path();
2766            tracing::debug!(
2767                "JWT rejected: require_subject is set but the token has no non-blank `sub`"
2768            );
2769            return Err(JwtValidationFailure::Invalid);
2770        }
2771        self.check_audience(&claims)?;
2772        let role = self.resolve_role(&claims)?;
2773
2774        // Store a blank `sub` as `None` so `fingerprint` never keys on a blank
2775        // stable id.
2776        let sub = claims.sub.filter(|value| !value.trim().is_empty());
2777
2778        // Identity name: prefer `preferred_username`, then `sub`, then `azp`,
2779        // then `client_id`. Skip every blank candidate so a present-but-empty
2780        // claim cannot short-circuit the chain into a blank (colliding) name.
2781        let preferred_username = claims
2782            .extra
2783            .get("preferred_username")
2784            .and_then(|v| v.as_str())
2785            .filter(|s| !s.trim().is_empty())
2786            .map(String::from);
2787        let name = preferred_username
2788            .or_else(|| sub.clone())
2789            .or_else(|| claims.azp.filter(|s| !s.trim().is_empty()))
2790            .or_else(|| claims.client_id.filter(|s| !s.trim().is_empty()))
2791            .unwrap_or_else(|| "oauth-client".into());
2792
2793        Ok(AuthIdentity {
2794            name,
2795            role,
2796            method: AuthMethod::OAuthJwt,
2797            raw_token: None,
2798            sub,
2799        })
2800    }
2801
2802    /// Decode and fully verify a JWT, returning its claims.
2803    ///
2804    /// Performs header decode, algorithm allow-list check, JWKS key lookup
2805    /// (with on-demand refresh), signature verification, and standard
2806    /// claim validation (exp/nbf/iss) against the template.
2807    ///
2808    /// The CPU-bound `jsonwebtoken::decode` call (RSA / ECDSA signature
2809    /// verification) is offloaded to [`tokio::task::spawn_blocking`] so a
2810    /// burst of concurrent JWT validations never starves other tasks on
2811    /// the multi-threaded runtime's worker pool. The blocking pool absorbs
2812    /// the verification cost; the async path stays responsive.
2813    // cancel-safe: `select_jwks_key` (cancel-safe: read-only lookup + idempotent
2814    // refresh) then a `spawn_blocking` decode whose `JoinHandle`, if dropped on
2815    // cancellation, detaches the verification (it completes off-task). No shared
2816    // state is mutated on this path.
2817    async fn decode_claims(&self, token: &str) -> Result<Claims, JwtValidationFailure> {
2818        let (key, alg) = self.select_jwks_key(token).await?;
2819
2820        // Build a per-decode validation scoped to the header's algorithm.
2821        // jsonwebtoken requires ALL algorithms in the list to share the
2822        // same family as the key, so we restrict to [alg] only.
2823        let mut validation = self.validation_template.clone();
2824        validation.algorithms = vec![alg];
2825
2826        // Move the (cheap) clones into the blocking task so the verifier
2827        // does not hold a reference into the request's async scope.
2828        let token_owned = token.to_owned();
2829        let join =
2830            tokio::task::spawn_blocking(move || decode::<Claims>(&token_owned, &key, &validation))
2831                .await;
2832
2833        let decode_result = match join {
2834            Ok(r) => r,
2835            Err(join_err) => {
2836                core::hint::cold_path();
2837                tracing::error!(
2838                    error = %join_err,
2839                    "JWT decode task panicked or was cancelled"
2840                );
2841                return Err(JwtValidationFailure::Invalid);
2842            }
2843        };
2844
2845        decode_result.map(|td| td.claims).map_err(|e| {
2846            core::hint::cold_path();
2847            let failure = if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
2848                JwtValidationFailure::Expired
2849            } else {
2850                JwtValidationFailure::Invalid
2851            };
2852            tracing::debug!(error = %e, ?alg, ?failure, "JWT decode failed");
2853            failure
2854        })
2855    }
2856
2857    /// Decode the JWT header, check the algorithm against the allow-list,
2858    /// and look up the matching JWKS key (refreshing on miss).
2859    //
2860    // Complexity: 28/25. Three structured early-returns each pair a
2861    // `cold_path()` hint with a distinct `tracing::debug!` site so the
2862    // failure is observable. Collapsing them into a combinator chain
2863    // would lose those structured-field log sites without reducing
2864    // real cognitive load.
2865    // NOT cancel-safe: on a cache miss this delegates to `find_key`, which can
2866    // enter `refresh_with_cooldown`. That commits `last_refresh_attempt` before
2867    // fetching, so a cancellation mid-refresh still consumes the cooldown slot
2868    // and the next caller may be refused a refresh for the cooldown window.
2869    #[allow(
2870        clippy::cognitive_complexity,
2871        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"
2872    )]
2873    async fn select_jwks_key(
2874        &self,
2875        token: &str,
2876    ) -> Result<(DecodingKey, Algorithm), JwtValidationFailure> {
2877        let Ok(header) = decode_header(token) else {
2878            core::hint::cold_path();
2879            tracing::debug!("JWT header decode failed");
2880            return Err(JwtValidationFailure::Invalid);
2881        };
2882        let kid = header.kid.as_deref();
2883        tracing::debug!(alg = ?header.alg, kid = kid.unwrap_or("-"), "JWT header decoded");
2884
2885        if !self.allowed_algorithms.contains(&header.alg) {
2886            core::hint::cold_path();
2887            tracing::debug!(alg = ?header.alg, "JWT algorithm not accepted");
2888            return Err(JwtValidationFailure::Invalid);
2889        }
2890
2891        let Some(key) = self.find_key(kid, header.alg).await else {
2892            core::hint::cold_path();
2893            tracing::debug!(kid = kid.unwrap_or("-"), alg = ?header.alg, "no matching JWKS key found");
2894            return Err(JwtValidationFailure::Invalid);
2895        };
2896
2897        Ok((key, header.alg))
2898    }
2899
2900    /// Manual audience check.
2901    ///
2902    /// Resolves per [`AudienceValidationMode`]: `aud` matches always
2903    /// accept silently. `azp`-only matches accept silently in
2904    /// [`AudienceValidationMode::Permissive`], accept with a one-shot
2905    /// `tracing::warn!` per process in [`AudienceValidationMode::Warn`],
2906    /// and reject in [`AudienceValidationMode::Strict`]. No-claim-match
2907    /// always rejects.
2908    fn check_audience(&self, claims: &Claims) -> Result<(), JwtValidationFailure> {
2909        if claims.aud.contains(&self.expected_audience) {
2910            return Ok(());
2911        }
2912        let azp_match = claims
2913            .azp
2914            .as_deref()
2915            .is_some_and(|azp| azp == self.expected_audience);
2916        if azp_match {
2917            match self.audience_mode {
2918                AudienceValidationMode::Permissive => {
2919                    if !self.azp_permissive_logged.swap(true, Ordering::Relaxed) {
2920                        tracing::info!(
2921                            expected = %self.expected_audience,
2922                            "JWT accepted via azp-only audience fallback because \
2923                             audience_validation_mode = \"permissive\". Acceptance is \
2924                             intentionally wider than the spec; set \"warn\" or \"strict\" \
2925                             to tighten it. This message logs once per process."
2926                        );
2927                    }
2928                    return Ok(());
2929                }
2930                AudienceValidationMode::Warn => {
2931                    if !self.azp_fallback_warned.swap(true, Ordering::Relaxed) {
2932                        tracing::warn!(
2933                            expected = %self.expected_audience,
2934                            azp = claims.azp.as_deref().unwrap_or("-"),
2935                            "JWT accepted via deprecated azp-only audience fallback. \
2936                             Configure your IdP to populate aud, or set \
2937                             audience_validation_mode = \"strict\" once tokens carry aud correctly. \
2938                             To silence this warning without changing acceptance, \
2939                             set audience_validation_mode = \"permissive\". \
2940                             This warning logs once per process."
2941                        );
2942                    }
2943                    return Ok(());
2944                }
2945                AudienceValidationMode::Strict => {}
2946            }
2947        }
2948        core::hint::cold_path();
2949        self.log_audience_mismatch(claims);
2950        Err(JwtValidationFailure::Invalid)
2951    }
2952
2953    /// Log an audience-mismatch rejection.
2954    ///
2955    /// The token's own claim values (`aud`, `azp`) are gated behind the
2956    /// operator diagnostic switch, matching `log_exchanged_token`.
2957    /// `expected` and `mode` are local configuration rather than token
2958    /// material, so they stay visible for debuggability.
2959    fn log_audience_mismatch(&self, claims: &Claims) {
2960        let expose = crate::diagnostics::oauth_claim_values();
2961        let aud = if expose {
2962            claims.aud.log_display()
2963        } else {
2964            "[REDACTED]".to_owned()
2965        };
2966        let azp = if expose {
2967            claims.azp.as_deref().unwrap_or("-")
2968        } else {
2969            "[REDACTED]"
2970        };
2971        tracing::debug!(
2972            aud = %aud,
2973            azp = azp,
2974            expected = %self.expected_audience,
2975            mode = self.audience_mode.as_str(),
2976            "JWT rejected: audience mismatch"
2977        );
2978    }
2979
2980    /// Resolve the role for this token.
2981    ///
2982    /// When `role_claim` is set, extract values from the given claim path
2983    /// and match against `role_mappings`. Otherwise, match space-separated
2984    /// tokens in the `scope` claim against configured scope mappings.
2985    fn resolve_role(&self, claims: &Claims) -> Result<String, JwtValidationFailure> {
2986        if let Some(ref claim_path) = self.role_claim {
2987            let owned_first_class: Vec<String> = first_class_claim_values(claims, claim_path);
2988            let mut values: Vec<&str> = owned_first_class.iter().map(String::as_str).collect();
2989            values.extend(resolve_claim_path(&claims.extra, claim_path));
2990            return self
2991                .role_mappings
2992                .iter()
2993                .find(|m| values.contains(&m.claim_value.as_str()))
2994                .map(|m| m.role.clone())
2995                .ok_or(JwtValidationFailure::Invalid);
2996        }
2997
2998        let token_scopes: Vec<&str> = claims
2999            .scope
3000            .as_deref()
3001            .unwrap_or("")
3002            .split_whitespace()
3003            .collect();
3004
3005        self.scopes
3006            .iter()
3007            .find(|m| token_scopes.contains(&m.scope.as_str()))
3008            .map(|m| m.role.clone())
3009            .ok_or(JwtValidationFailure::Invalid)
3010    }
3011
3012    /// Look up a decoding key by kid + algorithm. Refreshes JWKS on miss,
3013    /// subject to cooldown and deduplication constraints.
3014    // cancel-safe: reads the key cache under a `tokio::sync::RwLock` and, on a
3015    // miss, delegates to the idempotent `refresh_with_cooldown`. Cancellation at
3016    // any await leaves the cache in its prior consistent state.
3017    async fn find_key(&self, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3018        // Try cached keys first.
3019        {
3020            let guard = self.inner.read().await;
3021            if let Some(cached) = guard.as_ref()
3022                && !cached.is_expired()
3023                && let Some(key) = lookup_key(cached, kid, alg)
3024            {
3025                return Some(key);
3026            }
3027        }
3028
3029        // Cache miss or expired -- refresh (with cooldown/deduplication).
3030        self.refresh_with_cooldown().await;
3031
3032        // Fail closed (H2): a failed or cooled-down refresh leaves the previous
3033        // (now-expired) cache in place. Re-apply the freshness gate the first
3034        // lookup enforces so a rotated-out key is never served from a stale
3035        // cache -- otherwise an attacker who can stall the JWKS endpoint could
3036        // keep a revoked signing key valid past its TTL.
3037        let guard = self.inner.read().await;
3038        guard
3039            .as_ref()
3040            .filter(|cached| !cached.is_expired())
3041            .and_then(|cached| lookup_key(cached, kid, alg))
3042    }
3043
3044    /// Refresh JWKS with cooldown and concurrent deduplication.
3045    ///
3046    /// - Only one refresh in flight at a time (concurrent waiters share result).
3047    /// - At most one refresh per [`JWKS_REFRESH_COOLDOWN`] (10 seconds).
3048    ///
3049    /// # Cancellation
3050    ///
3051    /// **NOT cancel-safe by design.** `last_refresh_attempt` is committed
3052    /// *before* the fetch so that a burst of failing or cancelled refreshes
3053    /// cannot hammer the JWKS endpoint (the invalid-JWT → JWKS-refresh DoS
3054    /// class; see `AGENTS.md` pitfall #2). The consequence is a deliberate
3055    /// trade-off: if this future is cancelled between the timestamp write and
3056    /// cache publication, a genuinely-new `kid` may be rejected for up to
3057    /// [`JWKS_REFRESH_COOLDOWN`] (10s). Endpoint DoS protection is preferred
3058    /// over immediate post-cancellation retriability. Do **not** "fix" this by
3059    /// bypassing the cooldown on unknown-`kid` requests - that reopens the
3060    /// DoS-amplification vector the cooldown exists to close.
3061    // NOT cancel-safe: see the `# Cancellation` section above - cooldown is
3062    // committed before the fetch to throttle JWKS-endpoint abuse.
3063    async fn refresh_with_cooldown(&self) {
3064        // Acquire the mutex to serialize refresh attempts.
3065        let _guard = self.refresh_lock.lock().await;
3066
3067        // Check cooldown: skip if we refreshed recently.
3068        {
3069            let last = self.last_refresh_attempt.read().await;
3070            if let Some(ts) = *last
3071                && ts.elapsed() < JWKS_REFRESH_COOLDOWN
3072            {
3073                tracing::info!(
3074                    elapsed_ms = ts.elapsed().as_millis(),
3075                    cooldown_ms = JWKS_REFRESH_COOLDOWN.as_millis(),
3076                    "JWKS refresh skipped (cooldown active)"
3077                );
3078                return;
3079            }
3080        }
3081
3082        // Update last refresh timestamp BEFORE the fetch attempt.
3083        // This ensures the cooldown applies even if the fetch fails.
3084        {
3085            let mut last = self.last_refresh_attempt.write().await;
3086            *last = Some(Instant::now());
3087        }
3088
3089        // Perform the actual fetch.
3090        let _ = self.refresh_inner().await;
3091    }
3092
3093    /// Fetch JWKS from the configured URI and update the cache.
3094    ///
3095    /// Internal implementation - callers should use [`Self::refresh_with_cooldown`]
3096    /// to respect rate limiting.
3097    // cancel-safe (cache integrity): the cache is published via a single
3098    // `*guard = Some(..)` assignment under the `tokio::sync::RwLock` write lock
3099    // at the end. Cancellation before that point leaves the prior cache intact;
3100    // it never observes a half-built cache.
3101    async fn refresh_inner(&self) -> Result<(), String> {
3102        let Some(jwks) = self.fetch_jwks().await else {
3103            return Ok(());
3104        };
3105        let (keys, unnamed_keys) = match build_key_cache(&jwks, self.max_jwks_keys) {
3106            Ok(cache) => cache,
3107            Err(msg) => {
3108                tracing::warn!(reason = %msg, "JWKS key cap exceeded; refusing to populate cache");
3109                return Err(msg);
3110            }
3111        };
3112
3113        tracing::debug!(
3114            named = keys.len(),
3115            unnamed = unnamed_keys.len(),
3116            "JWKS refreshed"
3117        );
3118
3119        let mut guard = self.inner.write().await;
3120        *guard = Some(CachedKeys {
3121            keys,
3122            unnamed_keys,
3123            fetched_at: Instant::now(),
3124            ttl: self.ttl,
3125        });
3126        drop(guard);
3127        Ok(())
3128    }
3129
3130    /// Fetch and parse the JWKS document. Returns `None` and logs on failure.
3131    #[allow(
3132        clippy::cognitive_complexity,
3133        reason = "screening, bounded streaming, and parse logging are intentionally kept in one fetch path"
3134    )]
3135    // cancel-safe (cache integrity): screening, `send`, chunk reads, and JSON
3136    // parse build only a local body/JWK set; cache publication happens later
3137    // via one `refresh_inner` write-lock assignment, so old cache stays intact.
3138    async fn fetch_jwks(&self) -> Option<JwkSet> {
3139        #[cfg(any(test, feature = "test-helpers"))]
3140        let screening = if self.test_allow_loopback_ssrf.load(Ordering::Relaxed) {
3141            screen_oauth_target_with_test_override(
3142                &self.jwks_uri,
3143                self.allow_http,
3144                &self.allowlist,
3145                true,
3146            )
3147            .await
3148        } else {
3149            screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await
3150        };
3151        #[cfg(not(any(test, feature = "test-helpers")))]
3152        let screening = screen_oauth_target(&self.jwks_uri, self.allow_http, &self.allowlist).await;
3153
3154        if let Err(error) = screening {
3155            tracing::warn!(
3156                error = %error,
3157                uri = %oauth_request_target_for_log(&self.jwks_uri),
3158                "failed to screen JWKS target"
3159            );
3160            return None;
3161        }
3162
3163        let mut resp = match self.http.get(&self.jwks_uri).send().await {
3164            Ok(resp) => resp,
3165            Err(e) => {
3166                tracing::warn!(
3167                    error = %e.without_url(),
3168                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3169                    "failed to fetch JWKS"
3170                );
3171                return None;
3172            }
3173        };
3174
3175        let initial_capacity =
3176            usize::try_from(self.max_response_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
3177        let mut body = Vec::with_capacity(initial_capacity);
3178        while let Some(chunk) = match resp.chunk().await {
3179            Ok(chunk) => chunk,
3180            Err(error) => {
3181                tracing::warn!(
3182                    error = %error.without_url(),
3183                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3184                    "failed to read JWKS response"
3185                );
3186                return None;
3187            }
3188        } {
3189            let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
3190            let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
3191            if body_len.saturating_add(chunk_len) > self.max_response_bytes {
3192                tracing::warn!(
3193                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3194                    max_bytes = self.max_response_bytes,
3195                    "JWKS response exceeded configured size cap"
3196                );
3197                return None;
3198            }
3199            body.extend_from_slice(&chunk);
3200        }
3201
3202        match serde_json::from_slice::<JwkSet>(&body) {
3203            Ok(jwks) => Some(jwks),
3204            Err(error) => {
3205                tracing::warn!(
3206                    error = %error,
3207                    uri = %oauth_request_target_for_log(&self.jwks_uri),
3208                    "failed to parse JWKS"
3209                );
3210                None
3211            }
3212        }
3213    }
3214
3215    /// Test-only: drive `refresh_inner` now, surfacing the
3216    /// `build_key_cache` error string. Used by `tests/jwks_key_cap.rs`.
3217    ///
3218    /// # ⚠️ Security
3219    ///
3220    /// Bypasses `refresh_with_cooldown` and therefore `JWKS_REFRESH_COOLDOWN`,
3221    /// the DoS protection that prevents invalid-JWT floods from hammering the
3222    /// identity provider's JWKS endpoint.
3223    #[cfg(any(test, feature = "test-helpers"))]
3224    #[doc(hidden)]
3225    pub async fn __test_refresh_now(&self) -> Result<(), String> {
3226        let jwks = self
3227            .fetch_jwks()
3228            .await
3229            .ok_or_else(|| "failed to fetch or parse JWKS".to_owned())?;
3230        let (keys, unnamed_keys) = build_key_cache(&jwks, self.max_jwks_keys)?;
3231        let mut guard = self.inner.write().await;
3232        *guard = Some(CachedKeys {
3233            keys,
3234            unnamed_keys,
3235            fetched_at: Instant::now(),
3236            ttl: self.ttl,
3237        });
3238        drop(guard);
3239        Ok(())
3240    }
3241
3242    /// Test-only: returns whether the cache currently contains the
3243    /// supplied kid. Read-only; takes the cache lock briefly.
3244    #[cfg(any(test, feature = "test-helpers"))]
3245    #[doc(hidden)]
3246    pub async fn __test_has_kid(&self, kid: &str) -> bool {
3247        let guard = self.inner.read().await;
3248        guard
3249            .as_ref()
3250            .is_some_and(|cache| cache.keys.contains_key(kid))
3251    }
3252}
3253
3254/// Partition a JWKS into a kid-indexed map plus a list of unnamed keys.
3255/// Longest `kid` prefix emitted to logs.
3256const MAX_LOGGED_KID_CHARS: usize = 64;
3257
3258/// Truncate an issuer-supplied `kid` to [`MAX_LOGGED_KID_CHARS`] before it
3259/// reaches a log line.
3260///
3261/// `kid` is remote-controlled text of unbounded length, so logging it raw
3262/// lets a hostile or misconfigured issuer inflate log volume. Truncation is
3263/// on a char boundary to keep the output valid UTF-8.
3264fn truncate_kid_for_log(kid: &str) -> (String, bool) {
3265    if kid.chars().count() <= MAX_LOGGED_KID_CHARS {
3266        return (kid.to_owned(), false);
3267    }
3268    let head: String = kid.chars().take(MAX_LOGGED_KID_CHARS).collect();
3269    (format!("{head}...(truncated)"), true)
3270}
3271
3272/// Render a JWK's `kid` for logging, bounded, with a placeholder when absent.
3273fn jwk_kid_for_log(jwk: &jsonwebtoken::jwk::Jwk) -> (String, bool) {
3274    jwk.common
3275        .key_id
3276        .as_deref()
3277        .map_or_else(|| ("<no-kid>".to_owned(), false), truncate_kid_for_log)
3278}
3279
3280/// Classify a single JWK into a cacheable (algorithm-constraint, key) pair.
3281///
3282/// Returns `None` for every fail-closed case: a key whose declared `use`/
3283/// `key_ops` forbid signature verification, a key `jsonwebtoken` cannot decode,
3284/// and a key whose algorithm can be neither read nor inferred.
3285fn classify_jwk(jwk: &jsonwebtoken::jwk::Jwk) -> Option<(JwkAlg, DecodingKey)> {
3286    if !jwk_permits_signature_verification(jwk) {
3287        let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3288        tracing::debug!(
3289            kid = %kid_log,
3290            kid_truncated,
3291            "skipping JWKS key not permitted for signature verification (use/key_ops)"
3292        );
3293        return None;
3294    }
3295    let decoding_key = DecodingKey::from_jwk(jwk).ok()?;
3296    let alg = jwk_algorithm(jwk)?;
3297    if let JwkAlg::Family(family) = alg {
3298        let (kid_log, kid_truncated) = jwk_kid_for_log(jwk);
3299        tracing::debug!(
3300            kid = %kid_log,
3301            kid_truncated,
3302            family = ?family,
3303            "JWKS key omits `alg`; inferring permitted algorithms from key type (RFC 7517 4.4)"
3304        );
3305    }
3306    Some((alg, decoding_key))
3307}
3308
3309fn build_key_cache(jwks: &JwkSet, max_keys: usize) -> Result<JwksKeyCache, String> {
3310    if jwks.keys.len() > max_keys {
3311        return Err(format!(
3312            "jwks_key_count_exceeds_cap: got {} keys, max is {}",
3313            jwks.keys.len(),
3314            max_keys
3315        ));
3316    }
3317    let mut keys = HashMap::new();
3318    let mut unnamed_keys = Vec::new();
3319    for jwk in &jwks.keys {
3320        let Some((alg, decoding_key)) = classify_jwk(jwk) else {
3321            continue;
3322        };
3323        if let Some(ref kid) = jwk.common.key_id {
3324            if keys.insert(kid.clone(), (alg, decoding_key)).is_some() {
3325                let (kid_log, kid_truncated) = truncate_kid_for_log(kid);
3326                tracing::warn!(
3327                    kid = %kid_log,
3328                    kid_truncated,
3329                    "duplicate kid in JWKS; later entry wins"
3330                );
3331            }
3332        } else {
3333            unnamed_keys.push((alg, decoding_key));
3334        }
3335    }
3336    Ok((keys, unnamed_keys))
3337}
3338
3339/// Look up a key from the cache by kid (if present) or by algorithm.
3340fn lookup_key(cached: &CachedKeys, kid: Option<&str>, alg: Algorithm) -> Option<DecodingKey> {
3341    if let Some(kid) = kid {
3342        // A token carrying a `kid` must match a NAMED JWKS key exactly; it
3343        // must NOT fall back to an unnamed key. Otherwise an attacker could
3344        // present an unknown `kid` and be validated against an unrelated
3345        // unnamed key of the same algorithm (L4, fail-closed key selection).
3346        if let Some((cached_alg, key)) = cached.keys.get(kid)
3347            && cached_alg.accepts(alg)
3348        {
3349            return Some(key.clone());
3350        }
3351        return None;
3352    }
3353    // No `kid`: fall back to any unnamed key that permits this algorithm.
3354    cached
3355        .unnamed_keys
3356        .iter()
3357        .find(|(a, _)| a.accepts(alg))
3358        .map(|(_, k)| k.clone())
3359}
3360
3361/// Whether a JWK is permitted to act as a JWT **signature verification**
3362/// key, per its declared intent.
3363///
3364/// SECURITY (key-use separation, RFC 7517 4.2/4.3): `DecodingKey::from_jwk`
3365/// does NOT enforce `use` or `key_ops`, so without this gate an issuer that
3366/// publishes signing and encryption keys in one JWKS would have its
3367/// encryption keys silently accepted as verification keys. Anyone holding
3368/// such a key's private half could then mint tokens this server trusts.
3369///
3370/// Both parameters are optional; absent means unconstrained and is accepted
3371/// (RFC 7517 says `use` is optional unless the application requires it).
3372/// When present they are enforced fail-closed.
3373fn jwk_permits_signature_verification(jwk: &jsonwebtoken::jwk::Jwk) -> bool {
3374    use jsonwebtoken::jwk::{KeyOperations, PublicKeyUse};
3375
3376    let use_ok = match jwk.common.public_key_use {
3377        None | Some(PublicKeyUse::Signature) => true,
3378        Some(PublicKeyUse::Encryption | PublicKeyUse::Other(_)) => false,
3379    };
3380    // RFC 7517 4.3: when key_ops is present it enumerates the permitted
3381    // operations exhaustively, so a key without "verify" must be refused.
3382    let ops_ok = jwk
3383        .common
3384        .key_operations
3385        .as_ref()
3386        .is_none_or(|ops| ops.contains(&KeyOperations::Verify));
3387
3388    use_ok && ops_ok
3389}
3390
3391/// Determine how a JWK constrains the algorithms it may verify.
3392///
3393/// An explicit `alg` pins exactly one algorithm (unchanged behaviour). When
3394/// `alg` is absent -- which RFC 7517 4.4 explicitly permits, and which Entra
3395/// v2.0 always does -- the key type implies the family instead. Returning
3396/// `None` drops the key, so unknown or symmetric key types stay fail-closed.
3397fn jwk_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkAlg> {
3398    match jwk.common.key_algorithm {
3399        Some(declared) => explicit_jwk_algorithm(declared).map(JwkAlg::Explicit),
3400        None => infer_jwk_family(jwk).map(JwkAlg::Family),
3401    }
3402}
3403
3404/// Map a declared JWK `alg` onto a supported JWS algorithm.
3405#[allow(
3406    clippy::wildcard_enum_match_arm,
3407    reason = "jsonwebtoken KeyAlgorithm is a large external enum; only the JWT-signing variants are mappable to `Algorithm`"
3408)]
3409fn explicit_jwk_algorithm(declared: jsonwebtoken::jwk::KeyAlgorithm) -> Option<Algorithm> {
3410    match declared {
3411        jsonwebtoken::jwk::KeyAlgorithm::RS256 => Some(Algorithm::RS256),
3412        jsonwebtoken::jwk::KeyAlgorithm::RS384 => Some(Algorithm::RS384),
3413        jsonwebtoken::jwk::KeyAlgorithm::RS512 => Some(Algorithm::RS512),
3414        jsonwebtoken::jwk::KeyAlgorithm::ES256 => Some(Algorithm::ES256),
3415        jsonwebtoken::jwk::KeyAlgorithm::ES384 => Some(Algorithm::ES384),
3416        jsonwebtoken::jwk::KeyAlgorithm::PS256 => Some(Algorithm::PS256),
3417        jsonwebtoken::jwk::KeyAlgorithm::PS384 => Some(Algorithm::PS384),
3418        jsonwebtoken::jwk::KeyAlgorithm::PS512 => Some(Algorithm::PS512),
3419        jsonwebtoken::jwk::KeyAlgorithm::EdDSA => Some(Algorithm::EdDSA),
3420        _ => None,
3421    }
3422}
3423
3424/// Infer the algorithm family of a JWK that omitted `alg`, from its key type.
3425///
3426/// SECURITY: inference reads only the JWK's own key material, never the token
3427/// header, so it cannot be steered by an attacker. `OctetKey` (symmetric) is
3428/// deliberately never inferred -- an `HS*` secret must not become a
3429/// verification key -- and `P-521` yields `None` because `jsonwebtoken` 11
3430/// defines no `ES512` variant (its own `EllipticCurve::P521` doc notes the
3431/// curve is unsupported by `ring`).
3432#[allow(
3433    clippy::wildcard_enum_match_arm,
3434    reason = "jsonwebtoken AlgorithmParameters and EllipticCurve are both #[non_exhaustive] external enums, so an exhaustive match is impossible; unmatched variants must fail closed to None"
3435)]
3436fn infer_jwk_family(jwk: &jsonwebtoken::jwk::Jwk) -> Option<JwkKeyFamily> {
3437    use jsonwebtoken::jwk::{AlgorithmParameters, EllipticCurve};
3438
3439    match jwk.algorithm {
3440        AlgorithmParameters::RSA(_) => Some(JwkKeyFamily::Rsa),
3441        AlgorithmParameters::EllipticCurve(ref ec) => match ec.curve {
3442            EllipticCurve::P256 => Some(JwkKeyFamily::EcP256),
3443            EllipticCurve::P384 => Some(JwkKeyFamily::EcP384),
3444            _ => None,
3445        },
3446        AlgorithmParameters::OctetKeyPair(ref okp) => match okp.curve {
3447            EllipticCurve::Ed25519 => Some(JwkKeyFamily::Ed25519),
3448            _ => None,
3449        },
3450        _ => None,
3451    }
3452}
3453
3454// ---------------------------------------------------------------------------
3455// Claim path resolution
3456// ---------------------------------------------------------------------------
3457
3458/// Resolve a `role_claim` path against the explicit [`Claims`] fields
3459/// (`sub`, `aud`, `azp`, `client_id`, `scope`).
3460///
3461/// Operators commonly configure `role_claim = "scope"` or `"sub"` /
3462/// `"client_id"` to map first-class JWT claims to roles. These claims are
3463/// captured by [`Claims`] as named fields, so they never appear in the
3464/// `extra` map that [`resolve_claim_path`] inspects. This helper bridges
3465/// that gap by returning owned `String`s for those first-class fields
3466/// when the claim path matches one of them; the caller layers the result
3467/// over [`resolve_claim_path`] so dot-paths into custom claims continue
3468/// to work.
3469///
3470/// `scope` is split on whitespace per the OAuth 2.0 convention so a token
3471/// like `scope = "read write"` matches `claim_value = "read"` or
3472/// `"write"`. `aud` returns every audience entry. Other fields return
3473/// their value as a single element when present.
3474fn first_class_claim_values(claims: &Claims, path: &str) -> Vec<String> {
3475    match path {
3476        "sub" => claims.sub.iter().cloned().collect(),
3477        "azp" => claims.azp.iter().cloned().collect(),
3478        "client_id" => claims.client_id.iter().cloned().collect(),
3479        "aud" => claims.aud.0.clone(),
3480        "scope" => claims
3481            .scope
3482            .as_deref()
3483            .unwrap_or("")
3484            .split_whitespace()
3485            .map(str::to_owned)
3486            .collect(),
3487        _ => Vec::new(),
3488    }
3489}
3490
3491/// Resolve a dot-separated claim path to a list of string values.
3492///
3493/// Handles three shapes:
3494/// - **String**: split on whitespace (OAuth `scope` convention).
3495/// - **Array of strings**: each element becomes a value (Keycloak `realm_access.roles`).
3496/// - **Nested object**: traversed by dot-separated segments (e.g. `realm_access.roles`).
3497///
3498/// Returns an empty vec if the path does not exist or the leaf is not a
3499/// string/array.
3500fn resolve_claim_path<'a>(
3501    extra: &'a HashMap<String, serde_json::Value>,
3502    path: &str,
3503) -> Vec<&'a str> {
3504    let mut segments = path.split('.');
3505    let Some(first) = segments.next() else {
3506        return Vec::new();
3507    };
3508
3509    let mut current: Option<&serde_json::Value> = extra.get(first);
3510
3511    for segment in segments {
3512        current = current.and_then(|v| v.get(segment));
3513    }
3514
3515    match current {
3516        Some(serde_json::Value::String(s)) => s.split_whitespace().collect(),
3517        Some(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).collect(),
3518        _ => Vec::new(),
3519    }
3520}
3521
3522// ---------------------------------------------------------------------------
3523// JWT claims
3524// ---------------------------------------------------------------------------
3525
3526/// Standard + common JWT claims we care about.
3527#[derive(Debug, Deserialize)]
3528struct Claims {
3529    /// Subject (user or service account).
3530    sub: Option<String>,
3531    /// Audience - resource servers the token is intended for.
3532    /// Can be a single string or an array of strings per RFC 7519 Sec.4.1.3.
3533    #[serde(default)]
3534    aud: OneOrMany,
3535    /// Authorized party (OIDC Core Sec.2) - the OAuth client that was issued the token.
3536    azp: Option<String>,
3537    /// Client ID (some providers use this instead of azp).
3538    client_id: Option<String>,
3539    /// Space-separated scope string (OAuth 2.0 convention).
3540    scope: Option<String>,
3541    /// All remaining claims, captured for `role_claim` dot-path resolution.
3542    #[serde(flatten)]
3543    extra: HashMap<String, serde_json::Value>,
3544}
3545
3546/// Deserializes a JWT claim that can be either a single string or an array of strings.
3547#[derive(Debug, Default)]
3548struct OneOrMany(Vec<String>);
3549
3550impl OneOrMany {
3551    fn contains(&self, value: &str) -> bool {
3552        self.0.iter().any(|v| v == value)
3553    }
3554
3555    /// Render the audience list as a single comma-separated string for
3556    /// structured logging (e.g. `aud="a, b"`), preserving every entry so
3557    /// no debugging signal is lost. An empty list renders as `"-"`.
3558    fn log_display(&self) -> String {
3559        if self.0.is_empty() {
3560            "-".to_owned()
3561        } else {
3562            self.0.join(", ")
3563        }
3564    }
3565}
3566
3567/// Format a JSON `aud` claim (string OR array of strings) for structured
3568/// logging without losing shape.
3569///
3570/// The `aud` claim is legitimately either a single string or an array
3571/// (RFC 7519 §4.1.3). Rendering via `serde_json::Value::as_str()` alone
3572/// would drop array audiences (returns `None` → `"-"`), hiding real
3573/// values in the log. This joins arrays with `", "`, passes strings
3574/// through, and falls back to `"-"` only when the claim is truly absent
3575/// or an unexpected JSON type.
3576fn fmt_json_aud(value: Option<&serde_json::Value>) -> String {
3577    match value {
3578        Some(serde_json::Value::String(s)) => s.clone(),
3579        Some(serde_json::Value::Array(items)) => {
3580            let joined = items
3581                .iter()
3582                .filter_map(serde_json::Value::as_str)
3583                .collect::<Vec<_>>()
3584                .join(", ");
3585            if joined.is_empty() {
3586                "-".to_owned()
3587            } else {
3588                joined
3589            }
3590        }
3591        Some(
3592            serde_json::Value::Null
3593            | serde_json::Value::Bool(_)
3594            | serde_json::Value::Number(_)
3595            | serde_json::Value::Object(_),
3596        )
3597        | None => "-".to_owned(),
3598    }
3599}
3600
3601/// Render an optional JSON claim as a plain string for logging, without the
3602/// `Debug` wrapper/escaping (e.g. `sub="alice"` not `sub=Some(String("alice"))`).
3603/// Non-string or absent claims render as `"-"`.
3604fn fmt_json_str(value: Option<&serde_json::Value>) -> &str {
3605    value.and_then(serde_json::Value::as_str).unwrap_or("-")
3606}
3607
3608impl<'de> Deserialize<'de> for OneOrMany {
3609    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
3610        use serde::de;
3611
3612        struct Visitor;
3613        impl<'de> de::Visitor<'de> for Visitor {
3614            type Value = OneOrMany;
3615            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3616                f.write_str("a string or array of strings")
3617            }
3618            fn visit_str<E: de::Error>(self, v: &str) -> Result<OneOrMany, E> {
3619                Ok(OneOrMany(vec![v.to_owned()]))
3620            }
3621            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<OneOrMany, A::Error> {
3622                let mut v = Vec::new();
3623                while let Some(s) = seq.next_element::<String>()? {
3624                    v.push(s);
3625                }
3626                Ok(OneOrMany(v))
3627            }
3628        }
3629        deserializer.deserialize_any(Visitor)
3630    }
3631}
3632
3633// ---------------------------------------------------------------------------
3634// JWT detection heuristic
3635// ---------------------------------------------------------------------------
3636
3637/// Returns true if the token looks like a JWT (3 dot-separated segments
3638/// where the first segment decodes to JSON containing `"alg"`).
3639#[must_use]
3640pub fn looks_like_jwt(token: &str) -> bool {
3641    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3642
3643    let mut parts = token.splitn(4, '.');
3644    let Some(header_b64) = parts.next() else {
3645        return false;
3646    };
3647    // Must have exactly 3 segments.
3648    if parts.next().is_none() || parts.next().is_none() || parts.next().is_some() {
3649        return false;
3650    }
3651    // Try to decode the header segment.
3652    let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64) else {
3653        return false;
3654    };
3655    // Check for "alg" key in the JSON.
3656    let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes) else {
3657        return false;
3658    };
3659    header.get("alg").is_some()
3660}
3661
3662// ---------------------------------------------------------------------------
3663// Protected Resource Metadata (RFC 9728)
3664// ---------------------------------------------------------------------------
3665
3666/// Resolve the `authorization_servers` list for Protected Resource Metadata.
3667///
3668/// RFC 9728 3.2: a zero-valued claim MUST be omitted, so an empty result means
3669/// "leave the field out" rather than "emit `[]`".
3670fn resolve_authorization_servers<'a>(server_url: &'a str, config: &'a OAuthConfig) -> Vec<&'a str> {
3671    if let Some(ref explicit) = config.authorization_servers {
3672        return explicit.iter().map(String::as_str).collect();
3673    }
3674    // Advertise this server only when it actually mounts the OAuth endpoints.
3675    // `install_oauth_proxy_routes` mounts `/authorize`, `/token`, and
3676    // `/.well-known/oauth-authorization-server` ONLY when `proxy` is set, while
3677    // Protected Resource Metadata is served unconditionally -- so without a
3678    // proxy the local URL resolves to a 404 and the upstream issuer is the only
3679    // truthful answer. An application that mounts its own facade through
3680    // `with_extra_router` must say so via `authorization_servers`.
3681    if config.proxy.is_some() {
3682        vec![server_url]
3683    } else {
3684        vec![config.issuer.as_str()]
3685    }
3686}
3687
3688/// Build the Protected Resource Metadata JSON response.
3689///
3690/// `authorization_servers` follows [`OAuthConfig::authorization_servers`]:
3691/// the upstream issuer for a plain resource server, this server's own URL
3692/// when the built-in proxy is mounted, or an explicit operator override.
3693#[must_use]
3694pub fn protected_resource_metadata(
3695    resource_url: &str,
3696    server_url: &str,
3697    config: &OAuthConfig,
3698) -> serde_json::Value {
3699    let mut meta = serde_json::json!({
3700        "resource": resource_url,
3701        "bearer_methods_supported": ["header"],
3702    });
3703    let Some(obj) = meta.as_object_mut() else {
3704        return meta;
3705    };
3706    // RFC 9728 3.2: omit zero-valued claims rather than emitting empty arrays.
3707    let auth_servers = resolve_authorization_servers(server_url, config);
3708    if !auth_servers.is_empty() {
3709        obj.insert(
3710            "authorization_servers".into(),
3711            serde_json::json!(auth_servers),
3712        );
3713    }
3714    let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3715    if !scopes.is_empty() {
3716        obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3717    }
3718    meta
3719}
3720
3721/// Build the Authorization Server Metadata JSON response (RFC 8414).
3722///
3723/// Returned at `GET /.well-known/oauth-authorization-server` so MCP
3724/// clients can discover the authorization and token endpoints.
3725///
3726/// `issuer` defaults to `server_url`, the origin this document is served from,
3727/// as RFC 8414 3.3 requires. The upstream [`OAuthConfig::issuer`] remains the
3728/// *token* issuer and is still what inbound JWT `iss` claims are validated
3729/// against - the two are deliberately different. See
3730/// [`OAuthConfig::authorization_server_metadata_issuer`] for the legacy
3731/// opt-out.
3732#[must_use]
3733pub fn authorization_server_metadata(server_url: &str, config: &OAuthConfig) -> serde_json::Value {
3734    let issuer = config
3735        .authorization_server_metadata_issuer
3736        .as_deref()
3737        .unwrap_or(server_url);
3738    let mut meta = serde_json::json!({
3739        "issuer": issuer,
3740        "authorization_endpoint": format!("{server_url}/authorize"),
3741        "token_endpoint": format!("{server_url}/token"),
3742        "registration_endpoint": format!("{server_url}/register"),
3743        "response_types_supported": ["code"],
3744        "grant_types_supported": ["authorization_code", "refresh_token"],
3745        "code_challenge_methods_supported": ["S256"],
3746        "token_endpoint_auth_methods_supported": ["none"],
3747    });
3748    // RFC 8414 3.2: omit zero-valued claims rather than emitting `[]`.
3749    let scopes: Vec<&str> = config.scopes.iter().map(|s| s.scope.as_str()).collect();
3750    if !scopes.is_empty()
3751        && let Some(obj) = meta.as_object_mut()
3752    {
3753        obj.insert("scopes_supported".into(), serde_json::json!(scopes));
3754    }
3755    if let Some(proxy) = &config.proxy
3756        && proxy.expose_admin_endpoints
3757        && let Some(obj) = meta.as_object_mut()
3758    {
3759        if proxy.introspection_url.is_some() {
3760            obj.insert(
3761                "introspection_endpoint".into(),
3762                serde_json::Value::String(format!("{server_url}/introspect")),
3763            );
3764        }
3765        if proxy.revocation_url.is_some() {
3766            obj.insert(
3767                "revocation_endpoint".into(),
3768                serde_json::Value::String(format!("{server_url}/revoke")),
3769            );
3770        }
3771        if proxy.require_auth_on_admin_endpoints {
3772            obj.insert(
3773                "introspection_endpoint_auth_methods_supported".into(),
3774                serde_json::json!(["bearer"]),
3775            );
3776            obj.insert(
3777                "revocation_endpoint_auth_methods_supported".into(),
3778                serde_json::json!(["bearer"]),
3779            );
3780        }
3781    }
3782    meta
3783}
3784
3785// ---------------------------------------------------------------------------
3786// OAuth 2.1 Proxy Handlers
3787// ---------------------------------------------------------------------------
3788
3789/// Handle `GET /authorize` - redirect to the upstream authorize URL.
3790///
3791/// Forwards all OAuth query parameters (`response_type`, `client_id`,
3792/// `redirect_uri`, `scope`, `state`, `code_challenge`,
3793/// `code_challenge_method`) to the upstream identity provider.
3794/// The upstream provider (e.g. Keycloak) presents the login UI and
3795/// redirects the user back to the MCP client's `redirect_uri` with an
3796/// authorization code.
3797#[must_use]
3798pub fn handle_authorize(proxy: &OAuthProxyConfig, query: &str) -> axum::response::Response {
3799    use axum::{
3800        http::{StatusCode, header},
3801        response::IntoResponse,
3802    };
3803
3804    // Replace the client_id in the query with the upstream client_id.
3805    let upstream_query =
3806        rewrite_client_auth_params(query, &proxy.client_id, proxy.strip_resource_param);
3807    let redirect_url = format!("{}?{upstream_query}", proxy.authorize_url);
3808
3809    (StatusCode::FOUND, [(header::LOCATION, redirect_url)]).into_response()
3810}
3811
3812/// Handle `POST /token` - proxy the token request to the upstream provider.
3813///
3814/// Forwards the request body (authorization code exchange or refresh token
3815/// grant) to the upstream token endpoint, injecting client credentials
3816/// when configured (confidential client). Returns the upstream response as-is.
3817// NOT cancel-safe: once the upstream POST is in flight the authorization
3818// code may be consumed or a token minted upstream. Cancelling between send
3819// and response-forwarding loses the token while the grant is spent, so the
3820// client must retry with a fresh code rather than replay this one.
3821pub async fn handle_token(
3822    http: &OauthHttpClient,
3823    proxy: &OAuthProxyConfig,
3824    body: &str,
3825) -> axum::response::Response {
3826    use axum::{
3827        http::{StatusCode, header},
3828        response::IntoResponse,
3829    };
3830
3831    // Replace client_id in the form body with the upstream client_id.
3832    let mut upstream_body =
3833        rewrite_client_auth_params(body, &proxy.client_id, proxy.strip_resource_param);
3834
3835    // For confidential clients, inject the client_secret.
3836    if let Some(ref secret) = proxy.client_secret {
3837        use std::fmt::Write;
3838
3839        use secrecy::ExposeSecret;
3840        let _ = write!(
3841            upstream_body,
3842            "&client_secret={}",
3843            urlencoding::encode(secret.expose_secret())
3844        );
3845    }
3846
3847    let result = http
3848        .send_screened(
3849            &proxy.token_url,
3850            http.credential_client
3851                .post(&proxy.token_url)
3852                .header("Content-Type", "application/x-www-form-urlencoded")
3853                .body(upstream_body),
3854        )
3855        .await;
3856
3857    match result {
3858        Ok(resp) => {
3859            let status =
3860                StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
3861            let Ok(body_bytes) =
3862                read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token").await
3863            else {
3864                return oauth_error_response(
3865                    StatusCode::BAD_GATEWAY,
3866                    "server_error",
3867                    "upstream response too large or unreadable",
3868                );
3869            };
3870            (
3871                status,
3872                [(header::CONTENT_TYPE, "application/json")],
3873                body_bytes,
3874            )
3875                .into_response()
3876        }
3877        Err(e) => {
3878            tracing::error!(error = %e, "OAuth token proxy request failed");
3879            (
3880                StatusCode::BAD_GATEWAY,
3881                [(header::CONTENT_TYPE, "application/json")],
3882                "{\"error\":\"server_error\",\"error_description\":\"token endpoint unreachable\"}",
3883            )
3884                .into_response()
3885        }
3886    }
3887}
3888
3889/// Handle `POST /register` - return the pre-configured `client_id`.
3890///
3891/// MCP clients call this to discover which `client_id` to use in the
3892/// authorization flow.  We return the upstream `client_id` from config
3893/// and echo back any `redirect_uris` from the request body (required
3894/// by the MCP SDK's Zod validation).
3895#[must_use]
3896pub fn handle_register(proxy: &OAuthProxyConfig, body: &serde_json::Value) -> serde_json::Value {
3897    let mut resp = serde_json::json!({
3898        "client_id": proxy.client_id,
3899        "token_endpoint_auth_method": "none",
3900    });
3901    if let Some(uris) = body.get("redirect_uris")
3902        && let Some(obj) = resp.as_object_mut()
3903    {
3904        obj.insert("redirect_uris".into(), uris.clone());
3905    }
3906    if let Some(name) = body.get("client_name")
3907        && let Some(obj) = resp.as_object_mut()
3908    {
3909        obj.insert("client_name".into(), name.clone());
3910    }
3911    resp
3912}
3913
3914/// Handle `POST /introspect` - RFC 7662 token introspection proxy.
3915///
3916/// Forwards the request body to the upstream introspection endpoint,
3917/// injecting client credentials when configured. Returns the upstream
3918/// response as-is.  Requires `proxy.introspection_url` to be `Some`.
3919// cancel-safe: introspection is a read-only upstream query; cancelling only
3920// discards the answer and leaves no upstream state change.
3921pub async fn handle_introspect(
3922    http: &OauthHttpClient,
3923    proxy: &OAuthProxyConfig,
3924    body: &str,
3925) -> axum::response::Response {
3926    let Some(ref url) = proxy.introspection_url else {
3927        return oauth_error_response(
3928            axum::http::StatusCode::NOT_FOUND,
3929            "not_supported",
3930            "introspection endpoint is not configured",
3931        );
3932    };
3933    proxy_oauth_admin_request(http, proxy, url, body).await
3934}
3935
3936/// Handle `POST /revoke` - RFC 7009 token revocation proxy.
3937///
3938/// Forwards the request body to the upstream revocation endpoint,
3939/// injecting client credentials when configured. Returns the upstream
3940/// response as-is (per RFC 7009, typically 200 with empty body).
3941/// Requires `proxy.revocation_url` to be `Some`.
3942// cancel-safe for security purposes: cancellation cannot un-revoke a token.
3943// The caller may lose the confirmation response while the revocation still
3944// takes effect upstream, which fails in the safe direction.
3945pub async fn handle_revoke(
3946    http: &OauthHttpClient,
3947    proxy: &OAuthProxyConfig,
3948    body: &str,
3949) -> axum::response::Response {
3950    let Some(ref url) = proxy.revocation_url else {
3951        return oauth_error_response(
3952            axum::http::StatusCode::NOT_FOUND,
3953            "not_supported",
3954            "revocation endpoint is not configured",
3955        );
3956    };
3957    proxy_oauth_admin_request(http, proxy, url, body).await
3958}
3959
3960/// Shared proxy for introspection/revocation: injects `client_id` and
3961/// `client_secret` (when configured) and forwards the form-encoded body
3962/// upstream, returning the upstream status/body verbatim.
3963// cancel-safe for local state: credential rewriting is local, and
3964// `send_screened`/`read_response_capped` publish no server state. A repeated
3965// revocation cannot restore a token; introspection is read-only.
3966async fn proxy_oauth_admin_request(
3967    http: &OauthHttpClient,
3968    proxy: &OAuthProxyConfig,
3969    upstream_url: &str,
3970    body: &str,
3971) -> axum::response::Response {
3972    use axum::{
3973        http::{StatusCode, header},
3974        response::IntoResponse,
3975    };
3976
3977    // `false`: `resource` is not a parameter of RFC 7662 introspection or
3978    // RFC 7009 revocation requests, so the strip flag -- which exists purely
3979    // to satisfy Entra's authorization-code flow -- must not reach this path.
3980    let mut upstream_body = rewrite_client_auth_params(body, &proxy.client_id, false);
3981    if let Some(ref secret) = proxy.client_secret {
3982        use std::fmt::Write;
3983
3984        use secrecy::ExposeSecret;
3985        let _ = write!(
3986            upstream_body,
3987            "&client_secret={}",
3988            urlencoding::encode(secret.expose_secret())
3989        );
3990    }
3991
3992    let result = http
3993        .send_screened(
3994            upstream_url,
3995            http.credential_client
3996                .post(upstream_url)
3997                .header("Content-Type", "application/x-www-form-urlencoded")
3998                .body(upstream_body),
3999        )
4000        .await;
4001
4002    match result {
4003        Ok(resp) => {
4004            let status =
4005                StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
4006            let content_type = resp
4007                .headers()
4008                .get(header::CONTENT_TYPE)
4009                .and_then(|v| v.to_str().ok())
4010                .unwrap_or("application/json")
4011                .to_owned();
4012            let Ok(body_bytes) =
4013                read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/admin").await
4014            else {
4015                return oauth_error_response(
4016                    StatusCode::BAD_GATEWAY,
4017                    "server_error",
4018                    "upstream response too large or unreadable",
4019                );
4020            };
4021            (status, [(header::CONTENT_TYPE, content_type)], body_bytes).into_response()
4022        }
4023        Err(e) => {
4024            tracing::error!(
4025                error = %e,
4026                url = %oauth_request_target_for_log(upstream_url),
4027                "OAuth admin proxy request failed"
4028            );
4029            oauth_error_response(
4030                StatusCode::BAD_GATEWAY,
4031                "server_error",
4032                "upstream endpoint unreachable",
4033            )
4034        }
4035    }
4036}
4037
4038/// Read an upstream response body, aborting if it exceeds `max_bytes`.
4039///
4040/// Mirrors the bounded-streaming read used for JWKS
4041/// ([`JwksCache::fetch_jwks`]) so OAuth proxy paths never buffer an
4042/// unbounded upstream response. Fails **closed**: on a transport error or
4043/// a body that grows past the cap it returns `Err(())` (the caller maps
4044/// this to a generic `502`); it never returns a truncated body that a
4045/// caller might forward as if complete. `context` is an authority-only
4046/// label for logs (never a full URL with credentials).
4047// cancel-safe: the response body is accumulated in a local `Vec` and returned
4048// only after EOF; cancellation during `resp.chunk()` drops the partial buffer
4049// and never forwards a truncated OAuth response.
4050async fn read_response_capped(
4051    mut resp: reqwest::Response,
4052    max_bytes: u64,
4053    context: &str,
4054) -> Result<Vec<u8>, ()> {
4055    let initial_capacity = usize::try_from(max_bytes.min(64 * 1024)).unwrap_or(64 * 1024);
4056    let mut body = Vec::with_capacity(initial_capacity);
4057    loop {
4058        match resp.chunk().await {
4059            Ok(Some(chunk)) => {
4060                let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
4061                let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
4062                if body_len.saturating_add(chunk_len) > max_bytes {
4063                    tracing::warn!(
4064                        context = context,
4065                        max_bytes = max_bytes,
4066                        "upstream OAuth response exceeded size cap; failing closed"
4067                    );
4068                    return Err(());
4069                }
4070                body.extend_from_slice(&chunk);
4071            }
4072            Ok(None) => return Ok(body),
4073            Err(error) => {
4074                tracing::warn!(context = context, error = %error, "failed to read upstream OAuth response");
4075                return Err(());
4076            }
4077        }
4078    }
4079}
4080
4081fn oauth_error_response(
4082    status: axum::http::StatusCode,
4083    error: &str,
4084    description: &str,
4085) -> axum::response::Response {
4086    use axum::{http::header, response::IntoResponse};
4087    let body = serde_json::json!({
4088        "error": error,
4089        "error_description": description,
4090    });
4091    (
4092        status,
4093        [(header::CONTENT_TYPE, "application/json")],
4094        body.to_string(),
4095    )
4096        .into_response()
4097}
4098
4099// ---------------------------------------------------------------------------
4100// RFC 8693 Token Exchange
4101// ---------------------------------------------------------------------------
4102
4103/// OAuth error response body from the authorization server.
4104#[derive(Debug, Deserialize)]
4105struct OAuthErrorResponse {
4106    error: String,
4107    error_description: Option<String>,
4108}
4109
4110/// Choose what to log for an upstream `error_description`.
4111///
4112/// SECURITY: `error_description` is free-form text chosen by the authorization
4113/// server and may echo request parameters back, so it is redacted unless an
4114/// operator explicitly enables `observability.log_upstream_error_bodies`. The
4115/// sibling `error` field is an enumerated RFC 6749 §5.2 / RFC 8693 code rather
4116/// than free text, and is logged unconditionally.
4117fn upstream_error_description_for_log(description: Option<&str>) -> &str {
4118    if crate::diagnostics::upstream_error_bodies() {
4119        description.unwrap_or("")
4120    } else {
4121        "[REDACTED]"
4122    }
4123}
4124
4125/// Map an upstream OAuth error code to an allowlisted short code suitable
4126/// for client exposure.
4127///
4128/// Returns one of the RFC 6749 §5.2 / RFC 8693 standard codes. Unknown or
4129/// non-standard codes collapse to `server_error` to avoid leaking
4130/// authorization-server implementation details to MCP clients.
4131fn sanitize_oauth_error_code(raw: &str) -> &'static str {
4132    match raw {
4133        "invalid_request" => "invalid_request",
4134        "invalid_client" => "invalid_client",
4135        "invalid_grant" => "invalid_grant",
4136        "unauthorized_client" => "unauthorized_client",
4137        "unsupported_grant_type" => "unsupported_grant_type",
4138        "invalid_scope" => "invalid_scope",
4139        "temporarily_unavailable" => "temporarily_unavailable",
4140        // RFC 8693 token-exchange specific.
4141        "invalid_target" => "invalid_target",
4142        // Anything else (including upstream-specific codes that may leak
4143        // implementation details) collapses to a generic short code.
4144        _ => "server_error",
4145    }
4146}
4147
4148/// Exchange an inbound access token for a downstream access token
4149/// via RFC 8693 token exchange.
4150///
4151/// The MCP server calls this to swap a user's MCP-scoped JWT
4152/// (`subject_token`) for a new JWT scoped to a downstream API
4153/// identified by [`TokenExchangeConfig::audience`].
4154///
4155/// # Errors
4156///
4157/// Returns an error if the HTTP request fails, the authorization
4158/// server rejects the exchange, or the response cannot be parsed.
4159// NOT cancel-safe, and NOT fixable at this layer: once `send_screened` puts the
4160// RFC 8693 POST on the wire, dropping this future cannot un-send it. The
4161// authorization server may mint a downstream token that never reaches the
4162// caller and that nothing here records. No local cache is torn, but retries may
4163// duplicate upstream issuance.
4164//
4165// Callers that can be cancelled should use `exchange_token_with_cancel`, which
4166// pre-checks the token, detaches the in-flight exchange rather than dropping it,
4167// and audits a token minted after the caller went away. That is a mitigation,
4168// not a guarantee -- see its docs for what remains unattainable.
4169pub async fn exchange_token(
4170    http: &OauthHttpClient,
4171    config: &TokenExchangeConfig,
4172    subject_token: &str,
4173) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4174    exchange_token_inner(http, config, subject_token, SuccessLogMode::Normal).await
4175}
4176
4177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4178enum SuccessLogMode {
4179    Normal,
4180    Suppress,
4181}
4182
4183async fn exchange_token_inner(
4184    http: &OauthHttpClient,
4185    config: &TokenExchangeConfig,
4186    subject_token: &str,
4187    success_log: SuccessLogMode,
4188) -> Result<ExchangedToken, crate::error::RmcpServerKitError> {
4189    use secrecy::ExposeSecret;
4190
4191    let client = http.client_for(config);
4192    let mut req = client
4193        .post(&config.token_url)
4194        .header("Content-Type", "application/x-www-form-urlencoded")
4195        .header("Accept", "application/json");
4196
4197    // M-H4: client authentication strategy.
4198    //   * `client_secret` set -> RFC 6749 §2.3.1 HTTP Basic.
4199    //   * `client_cert`   set -> RFC 8705 §2 mTLS via the cert-bearing
4200    //     `reqwest::Client` selected by `client_for`. NO Authorization
4201    //     header is sent: presenting a TLS client certificate at
4202    //     handshake time *is* the client authentication.
4203    // `OAuthConfig::validate` enforces exactly-one-of so neither both
4204    // nor neither reach this code path.
4205    if config.client_cert.is_none()
4206        && let Some(ref secret) = config.client_secret
4207    {
4208        use base64::Engine;
4209        let credentials = base64::engine::general_purpose::STANDARD.encode(format!(
4210            "{}:{}",
4211            urlencoding::encode(&config.client_id),
4212            urlencoding::encode(secret.expose_secret()),
4213        ));
4214        req = req.header("Authorization", format!("Basic {credentials}"));
4215    }
4216
4217    let form_body = build_exchange_form(config, subject_token);
4218
4219    let resp = http
4220        .send_screened(&config.token_url, req.body(form_body))
4221        .await
4222        .map_err(|e| {
4223            tracing::error!(error = %e, "token exchange request failed");
4224            // Do NOT leak upstream URL, reqwest internals, or DNS detail to clients.
4225            crate::error::RmcpServerKitError::Auth("server_error".into())
4226        })?;
4227
4228    let status = resp.status();
4229    let body_bytes =
4230        read_response_capped(resp, OAUTH_PROXY_MAX_RESPONSE_BYTES, "oauth/token-exchange")
4231            .await
4232            .map_err(|()| {
4233                // read_response_capped already logged the cause (oversize / transport).
4234                crate::error::RmcpServerKitError::Auth("server_error".into())
4235            })?;
4236
4237    if !status.is_success() {
4238        core::hint::cold_path();
4239        // Parse upstream error for logging only; client-visible payload is a
4240        // sanitized short code from the RFC 6749 §5.2 / RFC 8693 allowlist.
4241        let parsed = serde_json::from_slice::<OAuthErrorResponse>(&body_bytes).ok();
4242        let short_code = parsed
4243            .as_ref()
4244            .map_or("server_error", |e| sanitize_oauth_error_code(&e.error));
4245        if let Some(ref e) = parsed {
4246            let description = upstream_error_description_for_log(e.error_description.as_deref());
4247            tracing::warn!(
4248                status = %status,
4249                upstream_error = %e.error,
4250                upstream_error_description = description,
4251                client_code = %short_code,
4252                "token exchange rejected by authorization server",
4253            );
4254        } else {
4255            tracing::warn!(
4256                status = %status,
4257                client_code = %short_code,
4258                "token exchange rejected (unparseable upstream body)",
4259            );
4260        }
4261        return Err(crate::error::RmcpServerKitError::Auth(short_code.into()));
4262    }
4263
4264    let exchanged = serde_json::from_slice::<ExchangedToken>(&body_bytes).map_err(|e| {
4265        tracing::error!(error = %e, "failed to parse token exchange response");
4266        // Avoid surfacing serde internals; map to sanitized short code so
4267        // RmcpServerKitError::into_response cannot leak parser detail to the client.
4268        crate::error::RmcpServerKitError::Auth("server_error".into())
4269    })?;
4270
4271    match success_log {
4272        SuccessLogMode::Normal => log_exchanged_token(&exchanged),
4273        SuccessLogMode::Suppress => {}
4274    }
4275
4276    Ok(exchanged)
4277}
4278
4279/// Exchange an inbound access token while preserving post-send observability
4280/// if the caller cancels or times out.
4281///
4282/// This wrapper does **not** make RFC 8693 token exchange strictly
4283/// cancel-safe. Once the POST reaches the authorization server, this process
4284/// cannot un-send it or prove whether the server minted a downstream token.
4285/// Instead it provides the three local guarantees that are achievable: work is
4286/// not started when `ct` is already cancelled, the in-flight exchange future is
4287/// not dropped while reading the response, and an abandoned successful exchange
4288/// emits a sanitized warning so the orphaned downstream credential is
4289/// observable.
4290///
4291/// On cancellation or timeout after the spawned exchange starts, the exchange
4292/// task is deliberately detached and allowed to finish under the existing
4293/// [`OauthHttpClient`] request budgets. The task is **not** aborted. If it later
4294/// receives a successful [`ExchangedToken`] after the caller has gone away, it
4295/// discards the token and logs only bounded metadata (`expires_in` and a
4296/// truncated `issued_token_type`); token material and endpoint details are never
4297/// logged.
4298///
4299/// # Resource caveat
4300///
4301/// Detaching is unbounded in *count* under a cancel storm: every detached task
4302/// is time-bounded by the HTTP client's connect/total timeouts, but this helper
4303/// does not cap how many detached exchanges can exist at once. Use it only
4304/// behind the crate's existing authentication, rate-limit, and concurrency
4305/// controls (or equivalent caller-side controls).
4306///
4307/// # Errors
4308///
4309/// The completed outcome carries the exact [`Result`] returned by
4310/// [`exchange_token`]. Cancellation and timeout are reported structurally via
4311/// [`crate::cancel::DetachOutcome`] and do not construct client-visible error
4312/// strings.
4313#[must_use = "DetachOutcome must be inspected to distinguish completion from cancel/timeout"]
4314pub async fn exchange_token_with_cancel(
4315    http: &OauthHttpClient,
4316    config: &TokenExchangeConfig,
4317    subject_token: &str,
4318    ct: &tokio_util::sync::CancellationToken,
4319    timeout: Option<Duration>,
4320) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4321    // Pre-cancel check FIRST: do not clone config, client, or subject token for
4322    // an already-abandoned request. In particular, cloning the subject token
4323    // would allocate and keep credential-adjacent material alive for work that
4324    // the caller has already told us not to start.
4325    if ct.is_cancelled() {
4326        return crate::cancel::DetachOutcome::Cancelled;
4327    }
4328
4329    let (tx, rx) = tokio::sync::oneshot::channel();
4330    let http = http.clone();
4331    let config = config.clone();
4332    let subject_token = subject_token.to_owned();
4333
4334    // This task is intentionally detached on caller cancel/timeout. A plain
4335    // `run_with_cancel_and_timeout(exchange_token(...))` would drop the
4336    // JoinHandle in those arms, but it would not keep a result sink. The
4337    // `oneshot::Sender` is the sink: if the receiver is gone, `send` returns
4338    // the result to this task so an abandoned success can be audited without
4339    // logging token material.
4340    tokio::spawn(
4341        async move {
4342            let result =
4343                exchange_token_inner(&http, &config, &subject_token, SuccessLogMode::Suppress)
4344                    .await;
4345            if let Err(result) = tx.send(result) {
4346                audit_abandoned_exchange_result(result);
4347            }
4348        }
4349        .instrument(tracing::Span::current()),
4350    );
4351
4352    receive_exchange_result_with_cancel(rx, ct, timeout).await
4353}
4354
4355async fn receive_exchange_result_with_cancel(
4356    rx: tokio::sync::oneshot::Receiver<Result<ExchangedToken, crate::error::RmcpServerKitError>>,
4357    ct: &tokio_util::sync::CancellationToken,
4358    timeout: Option<Duration>,
4359) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4360    // `biased;` is deliberate and matches `cancel::run_with_cancel_and_timeout`:
4361    // the receiver arm comes first so a ready completion wins over a
4362    // simultaneously-ready cancellation or timeout. Dropping the receiver on
4363    // the other arms is not a leak; it is the signal that tells the spawned task
4364    // to audit an eventual success via `Sender::send`'s returned value.
4365    if let Some(t) = timeout {
4366        tokio::select! {
4367            biased;
4368            received = rx => map_exchange_receiver(received),
4369            () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4370            () = tokio::time::sleep(t) => crate::cancel::DetachOutcome::TimedOut,
4371        }
4372    } else {
4373        tokio::select! {
4374            biased;
4375            received = rx => map_exchange_receiver(received),
4376            () = ct.cancelled() => crate::cancel::DetachOutcome::Cancelled,
4377        }
4378    }
4379}
4380
4381fn map_exchange_receiver(
4382    received: Result<
4383        Result<ExchangedToken, crate::error::RmcpServerKitError>,
4384        tokio::sync::oneshot::error::RecvError,
4385    >,
4386) -> crate::cancel::DetachOutcome<Result<ExchangedToken, crate::error::RmcpServerKitError>> {
4387    match received {
4388        Ok(result) => crate::cancel::DetachOutcome::Completed(result),
4389        Err(error) => {
4390            tracing::error!(error = %error, "token exchange task ended before returning a result");
4391            crate::cancel::DetachOutcome::Completed(Err(
4392                crate::error::RmcpServerKitError::Internal("server_error".into()),
4393            ))
4394        }
4395    }
4396}
4397
4398fn audit_abandoned_exchange_result(
4399    result: Result<ExchangedToken, crate::error::RmcpServerKitError>,
4400) {
4401    match result {
4402        Ok(token) => {
4403            let (issued_token_type, issued_token_type_truncated) = token
4404                .issued_token_type
4405                .as_deref()
4406                .map_or_else(|| ("-".to_owned(), false), truncate_kid_for_log);
4407            tracing::warn!(
4408                expires_in = token.expires_in,
4409                issued_token_type = %issued_token_type,
4410                issued_token_type_truncated,
4411                "token exchange minted downstream token after caller detached; discarded token material"
4412            );
4413        }
4414        Err(error) => {
4415            tracing::debug!(error = %error, "token exchange failed after caller detached");
4416        }
4417    }
4418}
4419
4420fn push_form_param(body: &mut String, name: &str, value: &str) {
4421    body.push('&');
4422    body.push_str(name);
4423    body.push('=');
4424    body.push_str(&urlencoding::encode(value));
4425}
4426
4427/// Build the RFC 8693 token-exchange form body.
4428///
4429/// Emits the three REQUIRED parameters (RFC 8693 §2.1) unconditionally, then
4430/// each OPTIONAL parameter only when configured. Parameter ORDER is fixed and
4431/// load-bearing: `resource` and `scope` are appended after `audience` and
4432/// before `client_id` so that a config predating 3.8.0 - where both are
4433/// necessarily `None` - produces a byte-identical body to earlier releases.
4434fn build_exchange_form(config: &TokenExchangeConfig, subject_token: &str) -> String {
4435    let mut body = format!(
4436        "grant_type={}&subject_token={}&subject_token_type={}",
4437        urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
4438        urlencoding::encode(subject_token),
4439        urlencoding::encode(TOKEN_TYPE_ACCESS_TOKEN),
4440    );
4441    if let Some(value) = config.requested_token_type.wire_value() {
4442        push_form_param(&mut body, "requested_token_type", value);
4443    }
4444    if let Some(audience) = config.audience.as_deref() {
4445        push_form_param(&mut body, "audience", audience);
4446    }
4447    if let Some(resource) = config.resource.as_deref() {
4448        push_form_param(&mut body, "resource", resource);
4449    }
4450    if let Some(scope) = config.scope.as_deref() {
4451        push_form_param(&mut body, "scope", scope);
4452    }
4453    if config.client_secret.is_none() {
4454        push_form_param(&mut body, "client_id", &config.client_id);
4455    }
4456    body
4457}
4458
4459/// Debug-log the exchanged token. For JWTs, decode and log claim summary;
4460/// for opaque tokens, log length + issued type.
4461fn log_exchanged_token(exchanged: &ExchangedToken) {
4462    use base64::Engine;
4463
4464    if !looks_like_jwt(&exchanged.access_token) {
4465        tracing::debug!(
4466            token_len = exchanged.access_token.len(),
4467            issued_token_type = exchanged.issued_token_type.as_deref().unwrap_or("-"),
4468            expires_in = exchanged.expires_in,
4469            "exchanged token (opaque)",
4470        );
4471        return;
4472    }
4473    let Some(payload) = exchanged.access_token.split('.').nth(1) else {
4474        return;
4475    };
4476    let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
4477        return;
4478    };
4479    let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
4480        return;
4481    };
4482    let expose_claims = crate::diagnostics::oauth_claim_values();
4483    let sub = gated_claim_str(claims.get("sub"), expose_claims);
4484    let aud = gated_claim_aud(claims.get("aud"), expose_claims);
4485    let azp = gated_claim_str(claims.get("azp"), expose_claims);
4486    let iss = gated_claim_str(claims.get("iss"), expose_claims);
4487    tracing::debug!(
4488        sub = sub,
4489        aud = %aud,
4490        azp = azp,
4491        iss = iss,
4492        expires_in = exchanged.expires_in,
4493        "exchanged token claims (JWT)",
4494    );
4495}
4496
4497fn gated_claim_str(value: Option<&serde_json::Value>, expose: bool) -> &str {
4498    if expose {
4499        fmt_json_str(value)
4500    } else {
4501        "[REDACTED]"
4502    }
4503}
4504
4505fn gated_claim_aud(value: Option<&serde_json::Value>, expose: bool) -> String {
4506    if expose {
4507        fmt_json_aud(value)
4508    } else {
4509        "[REDACTED]".to_owned()
4510    }
4511}
4512
4513/// Form/query parameters that carry OAuth client authentication.
4514///
4515/// Every one of these is proxy-owned: the upstream client identity and its
4516/// credentials are configured server-side and must never be influenced by the
4517/// downstream caller.
4518const CLIENT_AUTH_PARAMS: [&str; 4] = [
4519    "client_id",
4520    "client_secret",
4521    "client_assertion",
4522    "client_assertion_type",
4523];
4524
4525/// Re-serialize an `application/x-www-form-urlencoded` query or body with every
4526/// caller-supplied client-authentication parameter removed, then inject the
4527/// proxy's `client_id`.
4528///
4529/// This parses and re-serializes rather than rewriting the raw string. The
4530/// previous implementation split on `&` and dropped segments literally starting
4531/// with `client_id=`, which let a caller smuggle client credentials past the
4532/// proxy two ways:
4533///
4534/// - percent-encoded keys (`%63lient_id=...`, `client%5Fid=...`) do not match the
4535///   literal prefix but decode upstream to `client_id`; and
4536/// - `client_secret` was never filtered at all, so a caller-supplied secret
4537///   survived alongside the proxy's own injected one on credential-bearing POSTs.
4538///
4539/// Either way the upstream IdP received duplicate decoded parameters, and a
4540/// first-wins parser would honour the caller's value over the proxy's.
4541///
4542/// Decoded values and the relative order of non-client parameters are preserved
4543/// (OAuth permits repeated `scope` / `resource`). The raw byte encoding is *not*
4544/// preserved: `form_urlencoded` normalizes `+` and percent-escapes on
4545/// re-serialization, which is semantically equivalent for form data.
4546fn rewrite_client_auth_params(
4547    params: &str,
4548    upstream_client_id: &str,
4549    strip_resource: bool,
4550) -> String {
4551    let mut out = url::form_urlencoded::Serializer::new(String::new());
4552    for (key, value) in url::form_urlencoded::parse(params.as_bytes()) {
4553        if CLIENT_AUTH_PARAMS.contains(&key.as_ref()) {
4554            continue;
4555        }
4556        // SECURITY: `resource` is the ONLY caller parameter this flag drops.
4557        // Comparison happens post-decode, so `%72esource` cannot smuggle past
4558        // it -- the same property that protects CLIENT_AUTH_PARAMS above.
4559        if strip_resource && key.as_ref() == "resource" {
4560            continue;
4561        }
4562        out.append_pair(&key, &value);
4563    }
4564    out.append_pair("client_id", upstream_client_id);
4565    out.finish()
4566}
4567
4568#[cfg(test)]
4569mod tests {
4570    use std::{sync::Arc, time::Instant};
4571
4572    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
4573
4574    use super::*;
4575
4576    // -- F2 regression: client-auth parameter smuggling in the OAuth proxy --
4577    //
4578    // The previous `replace_client_id` split on `&` and dropped segments
4579    // literally starting with `client_id=`. Percent-encoded keys survived that
4580    // filter but decode upstream to `client_id`, and `client_secret` was never
4581    // filtered at all, so a caller could ship duplicate client credentials to
4582    // the IdP alongside the proxy's own. Every case below forwarded the
4583    // attacker value before the fix.
4584
4585    /// Decode a rewritten form back into `(key, value)` pairs. Assertions run
4586    /// on decoded pairs, never on raw bytes: `form_urlencoded` normalizes `+`
4587    /// and percent-escapes on re-serialization, so byte equality is not a
4588    /// meaningful contract here.
4589    fn decoded_pairs(form: &str) -> Vec<(String, String)> {
4590        url::form_urlencoded::parse(form.as_bytes())
4591            .map(|(k, v)| (k.into_owned(), v.into_owned()))
4592            .collect()
4593    }
4594
4595    #[test]
4596    fn rewrite_drops_percent_encoded_client_id_key() {
4597        let out = rewrite_client_auth_params("%63lient_id=attacker&scope=read", "proxy-id", false);
4598        let pairs = decoded_pairs(&out);
4599        let client_ids: Vec<&String> = pairs
4600            .iter()
4601            .filter(|(k, _)| k == "client_id")
4602            .map(|(_, v)| v)
4603            .collect();
4604        assert_eq!(client_ids, vec!["proxy-id"], "smuggled client_id survived");
4605    }
4606
4607    #[test]
4608    fn rewrite_drops_underscore_encoded_client_id_key() {
4609        let out = rewrite_client_auth_params("client%5Fid=attacker&scope=read", "proxy-id", false);
4610        let pairs = decoded_pairs(&out);
4611        assert!(
4612            !pairs.iter().any(|(_, v)| v == "attacker"),
4613            "smuggled client_id survived: {pairs:?}"
4614        );
4615    }
4616
4617    #[test]
4618    fn rewrite_drops_caller_supplied_client_secret() {
4619        let out = rewrite_client_auth_params(
4620            "client_secret=attacker-secret&scope=read",
4621            "proxy-id",
4622            false,
4623        );
4624        let pairs = decoded_pairs(&out);
4625        assert!(
4626            !pairs.iter().any(|(k, _)| k == "client_secret"),
4627            "caller client_secret survived: {pairs:?}"
4628        );
4629    }
4630
4631    #[test]
4632    fn rewrite_drops_caller_supplied_client_assertion() {
4633        let out = rewrite_client_auth_params(
4634            "client_assertion=ey.evil&client_assertion_type=urn:evil&scope=read",
4635            "proxy-id",
4636            false,
4637        );
4638        let pairs = decoded_pairs(&out);
4639        assert!(
4640            !pairs
4641                .iter()
4642                .any(|(k, _)| k == "client_assertion" || k == "client_assertion_type"),
4643            "caller client assertion survived: {pairs:?}"
4644        );
4645    }
4646
4647    #[test]
4648    fn rewrite_collapses_duplicate_client_id_to_proxy_value() {
4649        let out =
4650            rewrite_client_auth_params("client_id=a&client_id=b&scope=read", "proxy-id", false);
4651        let pairs = decoded_pairs(&out);
4652        let client_ids: Vec<&String> = pairs
4653            .iter()
4654            .filter(|(k, _)| k == "client_id")
4655            .map(|(_, v)| v)
4656            .collect();
4657        assert_eq!(client_ids, vec!["proxy-id"]);
4658    }
4659
4660    #[test]
4661    fn rewrite_preserves_non_client_params_in_order_with_duplicates() {
4662        let out = rewrite_client_auth_params(
4663            "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4664            "proxy-id",
4665            false,
4666        );
4667        let pairs = decoded_pairs(&out);
4668        let non_client: Vec<(String, String)> = pairs
4669            .into_iter()
4670            .filter(|(k, _)| k != "client_id")
4671            .collect();
4672        assert_eq!(
4673            non_client,
4674            vec![
4675                ("scope".to_owned(), "read".to_owned()),
4676                ("resource".to_owned(), "a".to_owned()),
4677                ("state".to_owned(), "xyz".to_owned()),
4678                ("resource".to_owned(), "b".to_owned()),
4679                ("code_verifier".to_owned(), "v".to_owned()),
4680            ]
4681        );
4682    }
4683
4684    #[test]
4685    fn rewrite_strips_every_resource_param_when_enabled() {
4686        // Issue #17: Entra rejects `resource` alongside a differing api://
4687        // scope (AADSTS9010010). All occurrences must go, and everything else
4688        // must survive in order.
4689        let out = rewrite_client_auth_params(
4690            "scope=read&resource=a&state=xyz&resource=b&code_verifier=v",
4691            "proxy-id",
4692            true,
4693        );
4694        let non_client: Vec<(String, String)> = decoded_pairs(&out)
4695            .into_iter()
4696            .filter(|(k, _)| k != "client_id")
4697            .collect();
4698        assert_eq!(
4699            non_client,
4700            vec![
4701                ("scope".to_owned(), "read".to_owned()),
4702                ("state".to_owned(), "xyz".to_owned()),
4703                ("code_verifier".to_owned(), "v".to_owned()),
4704            ]
4705        );
4706    }
4707
4708    #[test]
4709    fn rewrite_strips_percent_encoded_resource_key() {
4710        // The strip filter compares post-decode, so an encoded key cannot
4711        // smuggle `resource` upstream -- same property that protects
4712        // CLIENT_AUTH_PARAMS.
4713        let out = rewrite_client_auth_params("%72esource=sneaky&scope=read", "proxy-id", true);
4714        let pairs = decoded_pairs(&out);
4715        assert!(
4716            !pairs.iter().any(|(k, _)| k == "resource"),
4717            "percent-encoded resource survived: {pairs:?}"
4718        );
4719        assert!(pairs.contains(&("scope".to_owned(), "read".to_owned())));
4720    }
4721
4722    #[test]
4723    fn rewrite_never_strips_security_params_when_resource_stripping_enabled() {
4724        // SECURITY: stripping must never reach PKCE, CSRF, or redirect
4725        // binding. If this ever fails, an operator enabling the Entra
4726        // workaround would silently lose those protections.
4727        let input = "response_type=code&redirect_uri=https%3A%2F%2Fapp%2Fcb&state=s1\
4728                     &code_challenge=cc&code_challenge_method=S256&nonce=n1&scope=read\
4729                     &code_verifier=cv&grant_type=authorization_code&code=abc\
4730                     &refresh_token=rt&resource=https%3A%2F%2Fapi";
4731        let pairs = decoded_pairs(&rewrite_client_auth_params(input, "proxy-id", true));
4732        for key in [
4733            "response_type",
4734            "redirect_uri",
4735            "state",
4736            "code_challenge",
4737            "code_challenge_method",
4738            "nonce",
4739            "scope",
4740            "code_verifier",
4741            "grant_type",
4742            "code",
4743            "refresh_token",
4744        ] {
4745            assert!(
4746                pairs.iter().any(|(k, _)| k == key),
4747                "{key} must never be stripped: {pairs:?}"
4748            );
4749        }
4750        assert!(!pairs.iter().any(|(k, _)| k == "resource"));
4751    }
4752
4753    #[test]
4754    fn rewrite_roundtrips_values_with_special_characters() {
4755        let input = url::form_urlencoded::Serializer::new(String::new())
4756            .append_pair("state", "a&b=c+d")
4757            .append_pair("scope", "réad ✓")
4758            .finish();
4759        let out = rewrite_client_auth_params(&input, "proxy-id", false);
4760        let pairs = decoded_pairs(&out);
4761        assert!(pairs.contains(&("state".to_owned(), "a&b=c+d".to_owned())));
4762        assert!(pairs.contains(&("scope".to_owned(), "réad ✓".to_owned())));
4763    }
4764
4765    #[test]
4766    fn rewrite_injects_client_id_when_absent() {
4767        let out = rewrite_client_auth_params("scope=read", "proxy-id", false);
4768        assert!(decoded_pairs(&out).contains(&("client_id".to_owned(), "proxy-id".to_owned())));
4769    }
4770
4771    #[test]
4772    fn looks_like_jwt_valid() {
4773        // Minimal valid JWT structure: base64({"alg":"RS256"}).base64({}).sig
4774        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
4775        let payload = URL_SAFE_NO_PAD.encode(b"{}");
4776        let token = format!("{header}.{payload}.signature");
4777        assert!(looks_like_jwt(&token));
4778    }
4779
4780    #[test]
4781    fn looks_like_jwt_rejects_opaque_token() {
4782        assert!(!looks_like_jwt("dGhpcyBpcyBhbiBvcGFxdWUgdG9rZW4"));
4783    }
4784
4785    #[test]
4786    fn looks_like_jwt_rejects_two_segments() {
4787        let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"RS256\"}");
4788        let token = format!("{header}.payload");
4789        assert!(!looks_like_jwt(&token));
4790    }
4791
4792    #[test]
4793    fn looks_like_jwt_rejects_four_segments() {
4794        assert!(!looks_like_jwt("a.b.c.d"));
4795    }
4796
4797    #[test]
4798    fn looks_like_jwt_rejects_no_alg() {
4799        let header = URL_SAFE_NO_PAD.encode(b"{\"typ\":\"JWT\"}");
4800        let payload = URL_SAFE_NO_PAD.encode(b"{}");
4801        let token = format!("{header}.{payload}.sig");
4802        assert!(!looks_like_jwt(&token));
4803    }
4804
4805    #[test]
4806    fn protected_resource_metadata_shape() {
4807        let config = OAuthConfig {
4808            require_subject: false,
4809            issuer: "https://auth.example.com".into(),
4810            audience: "https://mcp.example.com/mcp".into(),
4811            jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4812            scopes: vec![
4813                ScopeMapping {
4814                    scope: "mcp:read".into(),
4815                    role: "viewer".into(),
4816                },
4817                ScopeMapping {
4818                    scope: "mcp:admin".into(),
4819                    role: "ops".into(),
4820                },
4821            ],
4822            role_claim: None,
4823            role_mappings: vec![],
4824            jwks_cache_ttl: "10m".into(),
4825            proxy: None,
4826            token_exchange: None,
4827            ca_cert_path: None,
4828            allow_http_oauth_urls: false,
4829            max_jwks_keys: default_max_jwks_keys(),
4830            allowed_algorithms: None,
4831            authorization_servers: None,
4832            authorization_server_metadata_issuer: None,
4833            #[allow(
4834                deprecated,
4835                reason = "test fixture: explicit value for the deprecated field"
4836            )]
4837            strict_audience_validation: None,
4838            audience_validation_mode: None,
4839            jwks_max_response_bytes: default_jwks_max_bytes(),
4840            ssrf_allowlist: None,
4841        };
4842        let meta = protected_resource_metadata(
4843            "https://mcp.example.com/mcp",
4844            "https://mcp.example.com",
4845            &config,
4846        );
4847        assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4848        // No proxy: this process mounts no authorization-server endpoints, so
4849        // advertising itself would point RFC 9728 discovery at a 404. The
4850        // upstream issuer is the only truthful answer.
4851        assert_eq!(meta["authorization_servers"][0], "https://auth.example.com");
4852        assert_eq!(meta["scopes_supported"].as_array().unwrap().len(), 2);
4853        assert_eq!(meta["bearer_methods_supported"][0], "header");
4854    }
4855
4856    /// Build a PRM fixture with the given proxy / override topology.
4857    fn prm_for(
4858        proxy: Option<OAuthProxyConfig>,
4859        authorization_servers: Option<Vec<String>>,
4860        scopes: Vec<ScopeMapping>,
4861    ) -> serde_json::Value {
4862        let config = OAuthConfig {
4863            issuer: "https://auth.example.com".into(),
4864            audience: "https://mcp.example.com/mcp".into(),
4865            jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4866            scopes,
4867            proxy,
4868            authorization_servers,
4869            ..OAuthConfig::default()
4870        };
4871        protected_resource_metadata(
4872            "https://mcp.example.com/mcp",
4873            "https://mcp.example.com",
4874            &config,
4875        )
4876    }
4877
4878    fn demo_proxy() -> OAuthProxyConfig {
4879        OAuthProxyConfig::builder(
4880            "https://auth.example.com/authorize",
4881            "https://auth.example.com/token",
4882            "mcp",
4883        )
4884        .build()
4885    }
4886
4887    #[test]
4888    fn prm_advertises_local_server_only_when_proxy_mounts_the_endpoints() {
4889        // With the built-in proxy the local server really does serve
4890        // /authorize, /token, /register and the AS metadata document.
4891        let meta = prm_for(Some(demo_proxy()), None, vec![]);
4892        assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4893    }
4894
4895    #[test]
4896    fn prm_explicit_override_wins_over_topology() {
4897        // The extra_router case: the application mounts its own OAuth facade
4898        // without configuring `proxy`, so it must be able to say so.
4899        let meta = prm_for(
4900            None,
4901            Some(vec!["https://mcp.example.com".to_owned()]),
4902            vec![],
4903        );
4904        assert_eq!(meta["authorization_servers"][0], "https://mcp.example.com");
4905
4906        // An override also wins when a proxy IS configured.
4907        let meta = prm_for(
4908            Some(demo_proxy()),
4909            Some(vec!["https://elsewhere.example".to_owned()]),
4910            vec![],
4911        );
4912        assert_eq!(
4913            meta["authorization_servers"][0],
4914            "https://elsewhere.example"
4915        );
4916    }
4917
4918    #[test]
4919    fn prm_omits_zero_valued_claims() {
4920        // RFC 9728 3.2: claims with zero elements MUST be omitted, not
4921        // emitted as `[]`.
4922        let meta = prm_for(None, Some(vec![]), vec![]);
4923        assert!(
4924            meta.get("authorization_servers").is_none(),
4925            "empty override must omit the claim: {meta}"
4926        );
4927        assert!(
4928            meta.get("scopes_supported").is_none(),
4929            "no configured scopes must omit the claim: {meta}"
4930        );
4931        assert_eq!(meta["resource"], "https://mcp.example.com/mcp");
4932    }
4933
4934    fn proxy_as_metadata_config() -> OAuthConfig {
4935        OAuthConfig {
4936            issuer: "https://auth.example.com".into(),
4937            audience: "https://mcp.example.com/mcp".into(),
4938            jwks_uri: "https://auth.example.com/.well-known/jwks.json".into(),
4939            proxy: Some(demo_proxy()),
4940            ..OAuthConfig::default()
4941        }
4942    }
4943
4944    #[test]
4945    fn as_metadata_issuer_defaults_to_the_origin_it_is_served_from() {
4946        // RFC 8414 3.3: the published `issuer` MUST equal the identifier the
4947        // metadata URL was built from. RFC 8414 6.2 requires clients to reject
4948        // a mismatch, so publishing the upstream issuer here made the document
4949        // unusable to conformant clients.
4950        let config = proxy_as_metadata_config();
4951        let meta = authorization_server_metadata("https://mcp.example.com", &config);
4952        assert_eq!(meta["issuer"], "https://mcp.example.com");
4953        assert_eq!(
4954            meta["authorization_endpoint"],
4955            "https://mcp.example.com/authorize"
4956        );
4957        assert!(
4958            meta.get("scopes_supported").is_none(),
4959            "RFC 8414 3.2: omit zero-valued claims: {meta}"
4960        );
4961    }
4962
4963    #[test]
4964    fn as_metadata_issuer_legacy_opt_out_restores_upstream_value() {
4965        // Escape hatch for an upstream IdP that emits RFC 9207 `iss` to
4966        // clients that validate it; the proxy cannot reconcile that because
4967        // the callback bypasses this process entirely.
4968        let mut config = proxy_as_metadata_config();
4969        config.authorization_server_metadata_issuer = Some("https://auth.example.com".into());
4970        let meta = authorization_server_metadata("https://mcp.example.com", &config);
4971        assert_eq!(meta["issuer"], "https://auth.example.com");
4972    }
4973
4974    #[test]
4975    fn as_metadata_issuer_never_affects_token_validation() {
4976        // Whichever value is published, inbound JWT `iss` is validated against
4977        // `config.issuer`.
4978        let mut config = proxy_as_metadata_config();
4979        config.authorization_server_metadata_issuer = Some("https://mcp.example.com".into());
4980        assert_eq!(config.issuer, "https://auth.example.com");
4981    }
4982
4983    // -----------------------------------------------------------------------
4984    // F2: OAuth URL HTTPS-only validation (CVE-class: MITM JWKS / token URL)
4985    // -----------------------------------------------------------------------
4986
4987    fn validation_https_config() -> OAuthConfig {
4988        OAuthConfig::builder(
4989            "https://auth.example.com",
4990            "mcp",
4991            "https://auth.example.com/.well-known/jwks.json",
4992        )
4993        .build()
4994    }
4995
4996    #[test]
4997    fn validate_rejects_non_conformant_discovery_metadata_urls() {
4998        for bad in [
4999            "https://user:pw@as.example.com",
5000            "http://as.example.com",
5001            "https://10.0.0.1",
5002            "not-a-url",
5003        ] {
5004            let mut cfg = validation_https_config();
5005            cfg.authorization_server_metadata_issuer = Some(bad.to_owned());
5006            cfg.validate().unwrap_err();
5007
5008            let mut cfg = validation_https_config();
5009            cfg.authorization_servers = Some(vec![bad.to_owned()]);
5010            let err = cfg.validate().unwrap_err().to_string();
5011            assert!(
5012                err.contains("authorization_servers[0]"),
5013                "error must identify the offending index; got {err:?}"
5014            );
5015        }
5016    }
5017
5018    #[test]
5019    fn validate_accepts_discovery_metadata_urls_and_the_empty_override() {
5020        let mut cfg = validation_https_config();
5021        cfg.authorization_server_metadata_issuer = Some("https://as.example.com".to_owned());
5022        cfg.authorization_servers = Some(vec!["https://as.example.com".to_owned()]);
5023        cfg.validate()
5024            .expect("well-formed https metadata must validate");
5025
5026        let mut cfg = validation_https_config();
5027        cfg.authorization_servers = Some(vec![]);
5028        cfg.validate()
5029            .expect("an empty list is the documented way to omit the claim entirely");
5030    }
5031
5032    #[test]
5033    fn validate_accepts_all_https_urls() {
5034        let cfg = validation_https_config();
5035        cfg.validate().expect("all-HTTPS config must validate");
5036    }
5037
5038    #[test]
5039    fn validate_rejects_empty_audience() {
5040        let mut cfg = validation_https_config();
5041        cfg.audience = String::new();
5042        let err = cfg.validate().expect_err("empty audience must be rejected");
5043        assert!(
5044            err.to_string().contains("oauth.audience"),
5045            "error must reference oauth.audience; got {err}"
5046        );
5047    }
5048
5049    fn assert_config_nonzero_error(err: crate::error::RmcpServerKitError, field: &str) {
5050        let crate::error::RmcpServerKitError::Config(msg) = err else {
5051            panic!("expected Config error for {field}");
5052        };
5053        assert!(
5054            msg.contains(field) && msg.contains("must be nonzero"),
5055            "error must name {field} and say must be nonzero; got {msg:?}"
5056        );
5057    }
5058
5059    #[test]
5060    fn rejects_zero_max_jwks_keys() {
5061        let mut cfg = validation_https_config();
5062        cfg.max_jwks_keys = 0;
5063        let err = cfg
5064            .validate()
5065            .expect_err("zero max_jwks_keys must be rejected");
5066        assert_config_nonzero_error(err, "oauth.max_jwks_keys");
5067    }
5068
5069    #[test]
5070    fn rejects_zero_jwks_max_response_bytes() {
5071        let mut cfg = validation_https_config();
5072        cfg.jwks_max_response_bytes = 0;
5073        let err = cfg
5074            .validate()
5075            .expect_err("zero jwks_max_response_bytes must be rejected");
5076        assert_config_nonzero_error(err, "oauth.jwks_max_response_bytes");
5077    }
5078
5079    #[test]
5080    fn oauth_config_partial_table_deserializes_then_validate_rejects_empty_fields() {
5081        let toml_src = r#"
5082role_claim = "realm_access.roles"
5083
5084[[role_mappings]]
5085claim_value = "mcp-admin"
5086role = "admin"
5087"#;
5088        let cfg: OAuthConfig = toml::from_str(toml_src).expect(
5089            "partial [oauth] table without issuer/audience/jwks_uri must deserialize via serde(default)",
5090        );
5091        assert_eq!(cfg.issuer, "", "omitted issuer must default to empty");
5092        assert_eq!(cfg.audience, "", "omitted audience must default to empty");
5093        assert_eq!(cfg.jwks_uri, "", "omitted jwks_uri must default to empty");
5094        assert_eq!(cfg.role_claim.as_deref(), Some("realm_access.roles"));
5095        assert_eq!(cfg.role_mappings.len(), 1);
5096        cfg.validate().expect_err(
5097            "empty issuer/jwks_uri/audience must still fail validate() (parse-don't-validate)",
5098        );
5099    }
5100
5101    #[test]
5102    fn validate_rejects_unparseable_jwks_cache_ttl() {
5103        let mut cfg = validation_https_config();
5104        cfg.jwks_cache_ttl = "not-a-duration".into();
5105        let err = cfg
5106            .validate()
5107            .expect_err("malformed jwks_cache_ttl must be rejected");
5108        let msg = err.to_string();
5109        assert!(
5110            msg.contains("jwks_cache_ttl"),
5111            "error must reference offending field; got {msg:?}"
5112        );
5113    }
5114
5115    #[test]
5116    fn validate_rejects_http_jwks_uri() {
5117        let mut cfg = validation_https_config();
5118        cfg.jwks_uri = "http://auth.example.com/.well-known/jwks.json".into();
5119        let err = cfg.validate().expect_err("http jwks_uri must be rejected");
5120        let msg = err.to_string();
5121        assert!(
5122            msg.contains("oauth.jwks_uri") && msg.contains("https"),
5123            "error must reference offending field + scheme requirement; got {msg:?}"
5124        );
5125    }
5126
5127    #[test]
5128    fn validate_rejects_http_proxy_authorize_url() {
5129        let mut cfg = validation_https_config();
5130        cfg.proxy = Some(
5131            OAuthProxyConfig::builder(
5132                "http://idp.example.com/authorize", // <-- HTTP, must be rejected
5133                "https://idp.example.com/token",
5134                "client",
5135            )
5136            .build(),
5137        );
5138        let err = cfg
5139            .validate()
5140            .expect_err("http authorize_url must be rejected");
5141        assert!(
5142            err.to_string().contains("oauth.proxy.authorize_url"),
5143            "error must reference proxy.authorize_url; got {err}"
5144        );
5145    }
5146
5147    #[test]
5148    fn validate_rejects_http_proxy_token_url() {
5149        let mut cfg = validation_https_config();
5150        cfg.proxy = Some(
5151            OAuthProxyConfig::builder(
5152                "https://idp.example.com/authorize",
5153                "http://idp.example.com/token", // <-- HTTP, must be rejected
5154                "client",
5155            )
5156            .build(),
5157        );
5158        let err = cfg.validate().expect_err("http token_url must be rejected");
5159        assert!(
5160            err.to_string().contains("oauth.proxy.token_url"),
5161            "error must reference proxy.token_url; got {err}"
5162        );
5163    }
5164
5165    #[test]
5166    fn validate_rejects_http_proxy_introspection_and_revocation_urls() {
5167        let mut cfg = validation_https_config();
5168        cfg.proxy = Some(
5169            OAuthProxyConfig::builder(
5170                "https://idp.example.com/authorize",
5171                "https://idp.example.com/token",
5172                "client",
5173            )
5174            .introspection_url("http://idp.example.com/introspect")
5175            .build(),
5176        );
5177        let err = cfg
5178            .validate()
5179            .expect_err("http introspection_url must be rejected");
5180        assert!(err.to_string().contains("oauth.proxy.introspection_url"));
5181
5182        let mut cfg = validation_https_config();
5183        cfg.proxy = Some(
5184            OAuthProxyConfig::builder(
5185                "https://idp.example.com/authorize",
5186                "https://idp.example.com/token",
5187                "client",
5188            )
5189            .revocation_url("http://idp.example.com/revoke")
5190            .build(),
5191        );
5192        let err = cfg
5193            .validate()
5194            .expect_err("http revocation_url must be rejected");
5195        assert!(err.to_string().contains("oauth.proxy.revocation_url"));
5196    }
5197
5198    // -- M3 regression: unauthenticated /introspect and /revoke must fail validate --
5199
5200    #[test]
5201    fn validate_rejects_exposed_admin_endpoints_without_auth() {
5202        let mut cfg = validation_https_config();
5203        cfg.proxy = Some(
5204            OAuthProxyConfig::builder(
5205                "https://idp.example.com/authorize",
5206                "https://idp.example.com/token",
5207                "client",
5208            )
5209            .introspection_url("https://idp.example.com/introspect")
5210            .expose_admin_endpoints(true)
5211            .build(),
5212        );
5213        let err = cfg
5214            .validate()
5215            .expect_err("expose_admin_endpoints without auth must fail");
5216        let msg = err.to_string();
5217        assert!(msg.contains("require_auth_on_admin_endpoints"), "{msg}");
5218        assert!(
5219            msg.contains("allow_unauthenticated_admin_endpoints"),
5220            "{msg}"
5221        );
5222    }
5223
5224    #[test]
5225    fn validate_accepts_exposed_admin_endpoints_with_auth() {
5226        let mut cfg = validation_https_config();
5227        cfg.proxy = Some(
5228            OAuthProxyConfig::builder(
5229                "https://idp.example.com/authorize",
5230                "https://idp.example.com/token",
5231                "client",
5232            )
5233            .introspection_url("https://idp.example.com/introspect")
5234            .expose_admin_endpoints(true)
5235            .require_auth_on_admin_endpoints(true)
5236            .build(),
5237        );
5238        cfg.validate()
5239            .expect("authed admin endpoints must validate");
5240    }
5241
5242    #[test]
5243    fn validate_accepts_exposed_admin_endpoints_with_explicit_unauth_optout() {
5244        let mut cfg = validation_https_config();
5245        cfg.proxy = Some(
5246            OAuthProxyConfig::builder(
5247                "https://idp.example.com/authorize",
5248                "https://idp.example.com/token",
5249                "client",
5250            )
5251            .introspection_url("https://idp.example.com/introspect")
5252            .expose_admin_endpoints(true)
5253            .allow_unauthenticated_admin_endpoints(true)
5254            .build(),
5255        );
5256        cfg.validate()
5257            .expect("explicit unauth opt-out must validate");
5258    }
5259
5260    #[test]
5261    fn validate_accepts_unexposed_admin_endpoints_without_auth() {
5262        // The default safe shape: expose_admin_endpoints = false. The
5263        // M3 check must not fire because the routes are not mounted.
5264        let mut cfg = validation_https_config();
5265        cfg.proxy = Some(
5266            OAuthProxyConfig::builder(
5267                "https://idp.example.com/authorize",
5268                "https://idp.example.com/token",
5269                "client",
5270            )
5271            .introspection_url("https://idp.example.com/introspect")
5272            .build(),
5273        );
5274        cfg.validate()
5275            .expect("unexposed admin endpoints must validate");
5276    }
5277
5278    #[test]
5279    fn validate_rejects_http_token_exchange_url() {
5280        let mut cfg = validation_https_config();
5281        cfg.token_exchange = Some(
5282            TokenExchangeConfig::new(
5283                "http://idp.example.com/token", // <-- HTTP
5284                "client",
5285                None,
5286                None,
5287            )
5288            .with_audience("downstream"),
5289        );
5290        let err = cfg
5291            .validate()
5292            .expect_err("http token_exchange.token_url must be rejected");
5293        assert!(
5294            err.to_string().contains("oauth.token_exchange.token_url"),
5295            "error must reference token_exchange.token_url; got {err}"
5296        );
5297    }
5298
5299    #[test]
5300    fn validate_rejects_unparseable_url() {
5301        let mut cfg = validation_https_config();
5302        cfg.jwks_uri = "not a url".into();
5303        let err = cfg
5304            .validate()
5305            .expect_err("unparseable URL must be rejected");
5306        assert!(err.to_string().contains("invalid URL"));
5307    }
5308
5309    #[test]
5310    fn validate_rejects_non_http_scheme() {
5311        let mut cfg = validation_https_config();
5312        cfg.jwks_uri = "file:///etc/passwd".into();
5313        let err = cfg.validate().expect_err("file:// scheme must be rejected");
5314        let msg = err.to_string();
5315        assert!(
5316            msg.contains("must use https scheme") && msg.contains("file"),
5317            "error must reject non-http(s) schemes; got {msg:?}"
5318        );
5319    }
5320
5321    #[test]
5322    fn validate_accepts_http_with_escape_hatch() {
5323        // F2 escape-hatch: `allow_http_oauth_urls = true` permits HTTP for
5324        // dev/test against local IdPs without TLS. Document the security
5325        // tradeoff (see field doc) and verify all 6 URL fields are accepted
5326        // when the flag is set.
5327        let mut cfg = OAuthConfig::builder(
5328            "http://auth.local",
5329            "mcp",
5330            "http://auth.local/.well-known/jwks.json",
5331        )
5332        .allow_http_oauth_urls(true)
5333        .build();
5334        cfg.proxy = Some(
5335            OAuthProxyConfig::builder(
5336                "http://idp.local/authorize",
5337                "http://idp.local/token",
5338                "client",
5339            )
5340            .introspection_url("http://idp.local/introspect")
5341            .revocation_url("http://idp.local/revoke")
5342            .build(),
5343        );
5344        cfg.token_exchange = Some(
5345            TokenExchangeConfig::new(
5346                "http://idp.local/token",
5347                "client",
5348                Some(secrecy::SecretString::new("dev-secret".into())),
5349                None,
5350            )
5351            .with_audience("downstream"),
5352        );
5353        cfg.validate()
5354            .expect("escape hatch must permit http on all URL fields");
5355    }
5356
5357    #[test]
5358    fn validate_with_escape_hatch_still_rejects_unparseable() {
5359        // Even with the escape hatch, malformed URLs are rejected so
5360        // garbage configuration cannot silently degrade to no-op.
5361        let mut cfg = validation_https_config();
5362        cfg.allow_http_oauth_urls = true;
5363        cfg.jwks_uri = "::not-a-url::".into();
5364        cfg.validate()
5365            .expect_err("escape hatch must NOT bypass URL parsing");
5366    }
5367
5368    #[tokio::test]
5369    async fn jwks_cache_rejects_redirect_downgrade_to_http() {
5370        // F2.4 (Oracle modification A): even when the configured `jwks_uri`
5371        // is HTTPS, a `302 Location: http://...` from the JWKS host must
5372        // be refused by the reqwest redirect policy. Without this guard,
5373        // a network-positioned attacker who can spoof the upstream IdP
5374        // could redirect the JWKS fetch to plaintext and inject signing
5375        // keys, forging arbitrary JWTs.
5376        //
5377        // We assert at the reqwest-client level (rather than through
5378        // `validate_token`) so the assertion is precise: it pins the
5379        // policy to "reject scheme downgrade" rather than the broader
5380        // "JWKS fetch failed for any reason".
5381
5382        // Install the same rustls crypto provider JwksCache::new uses,
5383        // so the test client can build with TLS support.
5384        rustls::crypto::ring::default_provider()
5385            .install_default()
5386            .ok();
5387
5388        let policy = reqwest::redirect::Policy::custom(|attempt| {
5389            if attempt.url().scheme() != "https" {
5390                attempt.error("redirect to non-HTTPS URL refused")
5391            } else if attempt.previous().len() >= 2 {
5392                attempt.error("too many redirects (max 2)")
5393            } else {
5394                attempt.follow()
5395            }
5396        });
5397        // M-H2: even though this is a redirect-policy test harness
5398        // (not a production code path), wire the same resolver +
5399        // .no_proxy() so the audit-trail invariant "every reqwest
5400        // builder in this crate uses SsrfScreeningResolver" holds.
5401        // Loopback bypass is enabled so the wiremock fixture stays
5402        // reachable.
5403        let test_bypass: crate::ssrf_resolver::TestLoopbackBypass = Arc::new(AtomicBool::new(true));
5404        let allowlist = Arc::new(crate::ssrf::CompiledSsrfAllowlist::default());
5405        let resolver: Arc<dyn reqwest::dns::Resolve> = Arc::new(
5406            crate::ssrf_resolver::SsrfScreeningResolver::new(Arc::clone(&allowlist), test_bypass),
5407        );
5408        let client = reqwest::Client::builder()
5409            .no_proxy()
5410            .dns_resolver(Arc::clone(&resolver))
5411            .timeout(Duration::from_secs(5))
5412            .connect_timeout(Duration::from_secs(3))
5413            .redirect(policy)
5414            .build()
5415            .expect("test client builds");
5416
5417        let mock = wiremock::MockServer::start().await;
5418        wiremock::Mock::given(wiremock::matchers::method("GET"))
5419            .and(wiremock::matchers::path("/jwks.json"))
5420            .respond_with(
5421                wiremock::ResponseTemplate::new(302)
5422                    .insert_header("location", "http://example.invalid/jwks.json"),
5423            )
5424            .mount(&mock)
5425            .await;
5426
5427        // Emulate an HTTPS jwks_uri that 302s to HTTP.  We can't easily
5428        // bring up an HTTPS wiremock, so we simulate the kernel of the
5429        // policy: the same client that JwksCache uses must refuse the
5430        // redirect target.  reqwest invokes the redirect policy
5431        // regardless of source scheme, so an HTTP -> HTTP redirect with
5432        // policy `custom(... if scheme != https then error ...)` still
5433        // yields the redirect-rejection error path.  That is sufficient
5434        // to lock in the policy semantics.
5435        let url = format!("{}/jwks.json", mock.uri());
5436        let err = client
5437            .get(&url)
5438            .send()
5439            .await
5440            .expect_err("redirect policy must reject scheme downgrade");
5441        let chain = format!("{err:#}");
5442        assert!(
5443            chain.contains("redirect to non-HTTPS URL refused")
5444                || chain.to_lowercase().contains("redirect"),
5445            "error must surface redirect-policy rejection; got {chain:?}"
5446        );
5447    }
5448
5449    // -----------------------------------------------------------------------
5450    // Integration tests with in-process RSA keypair + wiremock JWKS
5451    // -----------------------------------------------------------------------
5452
5453    use rsa::{pkcs8::EncodePrivateKey, traits::PublicKeyParts};
5454
5455    /// Generate an RSA-2048 keypair and return `(private_pem, jwks_json)`.
5456    fn generate_test_keypair(kid: &str) -> (String, serde_json::Value) {
5457        let mut rng = rsa::rand_core::OsRng;
5458        let private_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("keypair generation");
5459        let private_pem = private_key
5460            .to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
5461            .expect("PKCS8 PEM export")
5462            .to_string();
5463
5464        let public_key = private_key.to_public_key();
5465        let n = URL_SAFE_NO_PAD.encode(public_key.n().to_bytes_be());
5466        let e = URL_SAFE_NO_PAD.encode(public_key.e().to_bytes_be());
5467
5468        let jwks = serde_json::json!({
5469            "keys": [{
5470                "kty": "RSA",
5471                "use": "sig",
5472                "alg": "RS256",
5473                "kid": kid,
5474                "n": n,
5475                "e": e
5476            }]
5477        });
5478
5479        (private_pem, jwks)
5480    }
5481
5482    /// Mint a signed JWT with the given claims.
5483    fn mint_token(
5484        private_pem: &str,
5485        kid: &str,
5486        issuer: &str,
5487        audience: &str,
5488        subject: &str,
5489        scope: &str,
5490    ) -> String {
5491        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5492            .expect("encoding key from PEM");
5493        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5494        header.kid = Some(kid.into());
5495
5496        let now = jsonwebtoken::get_current_timestamp();
5497        let claims = serde_json::json!({
5498            "iss": issuer,
5499            "aud": audience,
5500            "sub": subject,
5501            "scope": scope,
5502            "exp": now + 3600,
5503            "iat": now,
5504        });
5505
5506        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5507    }
5508
5509    /// Mint a signed JWT WITHOUT a `sub` claim (for `require_subject` tests).
5510    fn mint_token_without_sub(
5511        private_pem: &str,
5512        kid: &str,
5513        issuer: &str,
5514        audience: &str,
5515        scope: &str,
5516    ) -> String {
5517        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
5518            .expect("encoding key from PEM");
5519        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
5520        header.kid = Some(kid.into());
5521        let now = jsonwebtoken::get_current_timestamp();
5522        let claims = serde_json::json!({
5523            "iss": issuer,
5524            "aud": audience,
5525            "scope": scope,
5526            "exp": now + 3600,
5527            "iat": now,
5528        });
5529        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
5530    }
5531
5532    fn test_config(jwks_uri: &str) -> OAuthConfig {
5533        OAuthConfig {
5534            require_subject: false,
5535            issuer: "https://auth.test.local".into(),
5536            audience: "https://mcp.test.local/mcp".into(),
5537            jwks_uri: jwks_uri.into(),
5538            scopes: vec![
5539                ScopeMapping {
5540                    scope: "mcp:read".into(),
5541                    role: "viewer".into(),
5542                },
5543                ScopeMapping {
5544                    scope: "mcp:admin".into(),
5545                    role: "ops".into(),
5546                },
5547            ],
5548            role_claim: None,
5549            role_mappings: vec![],
5550            jwks_cache_ttl: "5m".into(),
5551            proxy: None,
5552            token_exchange: None,
5553            ca_cert_path: None,
5554            allow_http_oauth_urls: true,
5555            max_jwks_keys: default_max_jwks_keys(),
5556            allowed_algorithms: None,
5557            authorization_servers: None,
5558            authorization_server_metadata_issuer: None,
5559            #[allow(
5560                deprecated,
5561                reason = "test fixture: explicit value for the deprecated field"
5562            )]
5563            strict_audience_validation: None,
5564            audience_validation_mode: None,
5565            jwks_max_response_bytes: default_jwks_max_bytes(),
5566            ssrf_allowlist: None,
5567        }
5568    }
5569
5570    fn test_cache(config: &OAuthConfig) -> JwksCache {
5571        JwksCache::new(config).unwrap().__test_allow_loopback_ssrf()
5572    }
5573
5574    // -- H2: expired JWKS cache must fail closed when refresh cannot succeed --
5575
5576    /// Prime a cache (with `ttl`) from a valid JWKS, confirm the kid landed,
5577    /// then repoint the endpoint at a 503 so any later refresh fails. Returns
5578    /// the cache, a matching-`aud` token for the primed kid, and the live mock
5579    /// server (kept alive by the caller).
5580    async fn h2_prime_then_break(ttl: &str) -> (JwksCache, String, wiremock::MockServer) {
5581        let kid = "test-h2-stale";
5582        let (pem, jwks) = generate_test_keypair(kid);
5583        let mock_server = wiremock::MockServer::start().await;
5584        wiremock::Mock::given(wiremock::matchers::method("GET"))
5585            .and(wiremock::matchers::path("/jwks.json"))
5586            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5587            .mount(&mock_server)
5588            .await;
5589        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5590        let mut config = test_config(&jwks_uri);
5591        config.jwks_cache_ttl = ttl.into();
5592        let cache = test_cache(&config);
5593        cache.__test_refresh_now().await.expect("prime JWKS cache");
5594        assert!(cache.__test_has_kid(kid).await, "kid must be primed");
5595
5596        mock_server.reset().await;
5597        wiremock::Mock::given(wiremock::matchers::method("GET"))
5598            .and(wiremock::matchers::path("/jwks.json"))
5599            .respond_with(wiremock::ResponseTemplate::new(503))
5600            .mount(&mock_server)
5601            .await;
5602
5603        let token = mint_token(
5604            &pem,
5605            kid,
5606            "https://auth.test.local",
5607            "https://mcp.test.local/mcp",
5608            "h2-client",
5609            "mcp:read",
5610        );
5611        (cache, token, mock_server)
5612    }
5613
5614    #[test]
5615    fn build_key_cache_last_duplicate_kid_wins() {
5616        let (_pem, jwks_json) = generate_test_keypair("dup-kid");
5617        let entry = jwks_json["keys"][0].clone();
5618        let merged = serde_json::json!({ "keys": [entry.clone(), entry] });
5619        let jwks: JwkSet = serde_json::from_value(merged).expect("merged jwks parses");
5620        assert_eq!(jwks.keys.len(), 2, "fixture must carry two colliding kids");
5621
5622        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5623        assert_eq!(keys.len(), 1, "colliding kids collapse to one entry");
5624        assert!(keys.contains_key("dup-kid"));
5625        assert!(unnamed.is_empty());
5626    }
5627
5628    #[test]
5629    fn build_key_cache_rejects_keys_not_marked_for_signature_verification() {
5630        // SECURITY (key-use separation, RFC 7517 4.2/4.3): DecodingKey::from_jwk
5631        // ignores `use`/`key_ops`, so an issuer publishing an encryption key in
5632        // the same JWKS must not have it accepted as a verification key.
5633        let (_pem, jwks_json) = generate_test_keypair("enc-only");
5634
5635        let mut enc = jwks_json["keys"][0].clone();
5636        enc["use"] = serde_json::json!("enc");
5637        let jwks: JwkSet =
5638            serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5639        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5640        assert!(
5641            keys.is_empty(),
5642            "use=enc key must not be a verification key"
5643        );
5644        assert!(unnamed.is_empty());
5645
5646        let mut wrap_only = jwks_json["keys"][0].clone();
5647        wrap_only["key_ops"] = serde_json::json!(["wrapKey"]);
5648        let jwks: JwkSet = serde_json::from_value(serde_json::json!({ "keys": [wrap_only] }))
5649            .expect("jwks parses");
5650        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5651        assert!(keys.is_empty(), "key_ops without verify must be rejected");
5652        assert!(unnamed.is_empty());
5653    }
5654
5655    #[test]
5656    fn build_key_cache_accepts_sig_and_unconstrained_keys() {
5657        let (_pem, jwks_json) = generate_test_keypair("sig-key");
5658
5659        // Absent `use`/`key_ops` stays accepted (RFC 7517: both are optional).
5660        let jwks: JwkSet = serde_json::from_value(jwks_json.clone()).expect("jwks parses");
5661        let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5662        assert!(keys.contains_key("sig-key"));
5663
5664        let mut sig = jwks_json["keys"][0].clone();
5665        sig["use"] = serde_json::json!("sig");
5666        sig["key_ops"] = serde_json::json!(["verify"]);
5667        let jwks: JwkSet =
5668            serde_json::from_value(serde_json::json!({ "keys": [sig] })).expect("jwks parses");
5669        let (keys, _) = build_key_cache(&jwks, 16).expect("under key cap");
5670        assert!(keys.contains_key("sig-key"));
5671    }
5672
5673    // -- Issue #17: JWKS keys that omit the OPTIONAL `alg` member (RFC 7517 4.4) --
5674    //
5675    // Microsoft Entra v2.0 publishes every signing key without `alg`
5676    // (verified against login.microsoftonline.com/common/discovery/v2.0/keys:
5677    // 9 keys, 0 with `alg`, all kty=RSA use=sig). Requiring `alg` dropped every
5678    // key and produced a silent, total authentication outage.
5679
5680    /// Strip the `alg` member from a generated fixture, reproducing Entra shape.
5681    fn jwks_without_alg(jwks: &serde_json::Value) -> JwkSet {
5682        let mut key = jwks["keys"][0].clone();
5683        if let Some(obj) = key.as_object_mut() {
5684            obj.remove("alg");
5685        }
5686        serde_json::from_value(serde_json::json!({ "keys": [key] })).expect("alg-less jwks parses")
5687    }
5688
5689    #[test]
5690    fn alg_less_rsa_key_is_cached_as_rsa_family() {
5691        let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5692        let jwks = jwks_without_alg(&jwks_json);
5693        assert!(
5694            jwks.keys[0].common.key_algorithm.is_none(),
5695            "fixture must omit `alg`"
5696        );
5697
5698        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5699        assert!(unnamed.is_empty());
5700        let (cached_alg, _) = keys.get("entra-kid").expect("alg-less key must be cached");
5701        assert_eq!(*cached_alg, JwkAlg::Family(JwkKeyFamily::Rsa));
5702    }
5703
5704    #[test]
5705    fn alg_less_rsa_key_accepts_rsa_family_and_rejects_others() {
5706        let (_pem, jwks_json) = generate_test_keypair("entra-kid");
5707        let cached = CachedKeys {
5708            keys: build_key_cache(&jwks_without_alg(&jwks_json), 16)
5709                .expect("under key cap")
5710                .0,
5711            unnamed_keys: vec![],
5712            fetched_at: Instant::now(),
5713            ttl: Duration::from_secs(300),
5714        };
5715
5716        for alg in [
5717            Algorithm::RS256,
5718            Algorithm::RS384,
5719            Algorithm::RS512,
5720            Algorithm::PS256,
5721            Algorithm::PS384,
5722            Algorithm::PS512,
5723        ] {
5724            assert!(
5725                lookup_key(&cached, Some("entra-kid"), alg).is_some(),
5726                "{alg:?} is producible by an RSA key and must resolve"
5727            );
5728        }
5729        // An RSA key cannot produce an EC signature.
5730        assert!(lookup_key(&cached, Some("entra-kid"), Algorithm::ES256).is_none());
5731        // The kid-strict rule still holds for inferred keys.
5732        assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
5733    }
5734
5735    #[test]
5736    fn alg_less_key_never_accepts_hmac_algorithm_confusion() {
5737        // Regression guard: the classic attack is to present alg=HS256 and use
5738        // the issuer's PUBLIC RSA modulus as the HMAC secret. Family inference
5739        // must never widen an RSA key to a symmetric algorithm. (ACCEPTED_ALGS
5740        // also screens HS* before lookup; this asserts the key-bound layer.)
5741        assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS256));
5742        assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS384));
5743        assert!(!family_accepts(JwkKeyFamily::Rsa, Algorithm::HS512));
5744        assert!(!family_accepts(JwkKeyFamily::EcP256, Algorithm::HS256));
5745        assert!(!family_accepts(JwkKeyFamily::Ed25519, Algorithm::HS256));
5746    }
5747
5748    #[test]
5749    fn family_accepts_is_subset_of_accepted_algs() {
5750        // INVARIANT: family inference must never admit an algorithm that the
5751        // pre-lookup `ACCEPTED_ALGS` screen would reject.
5752        let every_alg = [
5753            Algorithm::HS256,
5754            Algorithm::HS384,
5755            Algorithm::HS512,
5756            Algorithm::RS256,
5757            Algorithm::RS384,
5758            Algorithm::RS512,
5759            Algorithm::ES256,
5760            Algorithm::ES384,
5761            Algorithm::PS256,
5762            Algorithm::PS384,
5763            Algorithm::PS512,
5764            Algorithm::EdDSA,
5765        ];
5766        for family in [
5767            JwkKeyFamily::Rsa,
5768            JwkKeyFamily::EcP256,
5769            JwkKeyFamily::EcP384,
5770            JwkKeyFamily::Ed25519,
5771        ] {
5772            for alg in every_alg {
5773                if family_accepts(family, alg) {
5774                    assert!(
5775                        ACCEPTED_ALGS.contains(&alg),
5776                        "{family:?} admits {alg:?}, which is outside ACCEPTED_ALGS"
5777                    );
5778                }
5779            }
5780        }
5781    }
5782
5783    #[test]
5784    fn explicit_alg_still_pins_exactly_one_algorithm() {
5785        // The JWK declares RS256, so an RS384 token must NOT be accepted even
5786        // though both are producible by the same RSA key.
5787        let (_pem, jwks_json) = generate_test_keypair("pinned");
5788        let jwks: JwkSet = serde_json::from_value(jwks_json).expect("jwks parses");
5789        let cached = CachedKeys {
5790            keys: build_key_cache(&jwks, 16).expect("under key cap").0,
5791            unnamed_keys: vec![],
5792            fetched_at: Instant::now(),
5793            ttl: Duration::from_secs(300),
5794        };
5795        assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS256).is_some());
5796        assert!(lookup_key(&cached, Some("pinned"), Algorithm::RS384).is_none());
5797    }
5798
5799    #[test]
5800    fn alg_less_key_still_subject_to_use_and_key_ops_gate() {
5801        // Ordering guard: `jwk_permits_signature_verification` runs BEFORE the
5802        // algorithm step, so inference must not resurrect a key excluded by
5803        // key-use separation. Covers both branches of that gate.
5804        let (_pem, jwks_json) = generate_test_keypair("gated");
5805
5806        let mut enc = jwks_json["keys"][0].clone();
5807        if let Some(obj) = enc.as_object_mut() {
5808            obj.remove("alg");
5809        }
5810        enc["use"] = serde_json::json!("enc");
5811        let jwks: JwkSet =
5812            serde_json::from_value(serde_json::json!({ "keys": [enc] })).expect("jwks parses");
5813        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5814        assert!(
5815            keys.is_empty() && unnamed.is_empty(),
5816            "use=enc must be dropped"
5817        );
5818
5819        let mut wrap = jwks_json["keys"][0].clone();
5820        if let Some(obj) = wrap.as_object_mut() {
5821            obj.remove("alg");
5822            obj.remove("use");
5823        }
5824        wrap["key_ops"] = serde_json::json!(["wrapKey"]);
5825        let jwks: JwkSet =
5826            serde_json::from_value(serde_json::json!({ "keys": [wrap] })).expect("jwks parses");
5827        let (keys, unnamed) = build_key_cache(&jwks, 16).expect("under key cap");
5828        assert!(
5829            keys.is_empty() && unnamed.is_empty(),
5830            "key_ops without verify must be dropped"
5831        );
5832    }
5833
5834    // -- allowed_algorithms: operator narrowing of the accepted algorithm set --
5835
5836    #[test]
5837    fn accepted_algorithm_names_cover_accepted_algs() {
5838        // Lockstep contract: every accepted algorithm must have a name an
5839        // operator can write, and every name must round-trip back.
5840        for alg in ACCEPTED_ALGS {
5841            let name = accepted_algorithm_name(*alg)
5842                .unwrap_or_else(|| panic!("{alg:?} is accepted but has no configurable name"));
5843            assert_eq!(accepted_algorithm_from_name(name), Some(*alg));
5844        }
5845        assert_eq!(
5846            accepted_algorithm_names().split(", ").count(),
5847            ACCEPTED_ALGS.len()
5848        );
5849    }
5850
5851    #[test]
5852    fn allowed_algorithms_cannot_widen_beyond_accepted_algs() {
5853        // SECURITY: the whole point of the narrow-only rule. An operator must
5854        // not be able to re-enable a symmetric or unsigned algorithm and open
5855        // an algorithm-confusion hole.
5856        for name in ["HS256", "HS384", "HS512", "none", "ES512", "RS1"] {
5857            assert!(
5858                accepted_algorithm_from_name(name).is_none(),
5859                "{name} must not be resolvable"
5860            );
5861            let err = resolve_allowed_algorithms(Some(&[name.to_owned()]))
5862                .expect_err("must reject non-accepted algorithm");
5863            assert!(err.to_string().contains("unsupported algorithm"));
5864        }
5865    }
5866
5867    #[test]
5868    fn allowed_algorithms_rejects_empty_list() {
5869        let err =
5870            resolve_allowed_algorithms(Some(&[])).expect_err("empty list would reject every token");
5871        assert!(err.to_string().contains("must not be empty"));
5872    }
5873
5874    #[test]
5875    fn allowed_algorithms_defaults_to_full_accepted_set() {
5876        assert_eq!(
5877            resolve_allowed_algorithms(None).expect("default resolves"),
5878            ACCEPTED_ALGS.to_vec()
5879        );
5880    }
5881
5882    #[test]
5883    fn allowed_algorithms_narrows_and_dedups_case_insensitively() {
5884        let resolved = resolve_allowed_algorithms(Some(&[
5885            "rs256".to_owned(),
5886            "RS256".to_owned(),
5887            "ES384".to_owned(),
5888        ]))
5889        .expect("valid subset");
5890        assert_eq!(resolved, vec![Algorithm::RS256, Algorithm::ES384]);
5891    }
5892
5893    #[test]
5894    fn allowed_algorithms_surfaces_through_config_validate() {
5895        let mut cfg = test_config("https://idp.test.local/jwks.json");
5896        cfg.allowed_algorithms = Some(vec!["HS256".to_owned()]);
5897        let err = cfg.validate().expect_err("HS256 must fail validation");
5898        assert!(err.to_string().contains("unsupported algorithm"));
5899
5900        cfg.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5901        cfg.validate().expect("a valid subset must validate");
5902    }
5903
5904    #[tokio::test]
5905    async fn narrowed_allowed_algorithms_rejects_excluded_but_otherwise_valid_token() {
5906        // The token is signed RS256 by a key the JWKS serves, so it would
5907        // normally authenticate; narrowing to ES384 must reject it at the
5908        // pre-lookup algorithm gate.
5909        let kid = "narrowing-kid";
5910        let (pem, jwks) = generate_test_keypair(kid);
5911
5912        let mock_server = wiremock::MockServer::start().await;
5913        wiremock::Mock::given(wiremock::matchers::method("GET"))
5914            .and(wiremock::matchers::path("/jwks.json"))
5915            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5916            .mount(&mock_server)
5917            .await;
5918
5919        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
5920        let token = mint_token(
5921            &pem,
5922            kid,
5923            "https://auth.test.local",
5924            "https://mcp.test.local/mcp",
5925            "narrow-user",
5926            "mcp:admin",
5927        );
5928
5929        let mut permissive = test_config(&jwks_uri);
5930        permissive.allowed_algorithms = Some(vec!["RS256".to_owned()]);
5931        assert!(
5932            test_cache(&permissive)
5933                .validate_token(&token)
5934                .await
5935                .is_some(),
5936            "RS256 token must authenticate when RS256 is allowed"
5937        );
5938
5939        let mut narrowed = test_config(&jwks_uri);
5940        narrowed.allowed_algorithms = Some(vec!["ES384".to_owned()]);
5941        assert!(
5942            test_cache(&narrowed).validate_token(&token).await.is_none(),
5943            "RS256 token must be rejected when only ES384 is allowed"
5944        );
5945    }
5946
5947    #[test]
5948    fn truncate_kid_for_log_bounds_hostile_input() {
5949        let short = "kid-1";
5950        assert_eq!(truncate_kid_for_log(short), (short.to_owned(), false));
5951
5952        let long = "k".repeat(4096);
5953        let (truncated, was_truncated) = truncate_kid_for_log(&long);
5954        assert!(was_truncated);
5955        assert!(truncated.ends_with("...(truncated)"));
5956        assert_eq!(
5957            truncated.chars().count(),
5958            MAX_LOGGED_KID_CHARS + "...(truncated)".chars().count()
5959        );
5960    }
5961
5962    #[test]
5963    fn truncate_kid_for_log_splits_on_char_boundary() {
5964        let multibyte = "\u{1f512}".repeat(MAX_LOGGED_KID_CHARS + 10);
5965        let (truncated, was_truncated) = truncate_kid_for_log(&multibyte);
5966        assert!(was_truncated);
5967        assert!(truncated.starts_with('\u{1f512}'));
5968        assert!(truncated.ends_with("...(truncated)"));
5969    }
5970
5971    #[test]
5972    fn truncate_kid_for_log_flag_marks_exact_boundary_as_untruncated() {
5973        let exact = "k".repeat(MAX_LOGGED_KID_CHARS);
5974        let (out, was_truncated) = truncate_kid_for_log(&exact);
5975        assert!(!was_truncated, "a kid exactly at the cap is not truncated");
5976        assert_eq!(out, exact);
5977    }
5978
5979    #[tokio::test]
5980    async fn expired_jwks_fails_closed_when_refresh_fails() {
5981        let (cache, token, _mock) = h2_prime_then_break("80ms").await;
5982        tokio::time::sleep(Duration::from_millis(200)).await;
5983        let failure = cache
5984            .validate_token_with_reason(&token)
5985            .await
5986            .expect_err("an expired cache whose refresh fails must not serve the stale key");
5987        assert_eq!(failure, JwtValidationFailure::Invalid);
5988    }
5989
5990    #[tokio::test]
5991    async fn fresh_jwks_still_validates() {
5992        let kid = "test-h2-fresh";
5993        let (pem, jwks) = generate_test_keypair(kid);
5994        let mock_server = wiremock::MockServer::start().await;
5995        wiremock::Mock::given(wiremock::matchers::method("GET"))
5996            .and(wiremock::matchers::path("/jwks.json"))
5997            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
5998            .mount(&mock_server)
5999            .await;
6000        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6001        let config = test_config(&jwks_uri); // 5m TTL, reachable JWKS
6002        let cache = test_cache(&config);
6003        let token = mint_token(
6004            &pem,
6005            kid,
6006            "https://auth.test.local",
6007            "https://mcp.test.local/mcp",
6008            "h2-fresh-client",
6009            "mcp:read",
6010        );
6011        cache
6012            .validate_token_with_reason(&token)
6013            .await
6014            .expect("a reachable JWKS must still validate a matching token");
6015    }
6016
6017    #[tokio::test]
6018    async fn cooldown_active_plus_expired_fails_closed() {
6019        let (cache, token, _mock) = h2_prime_then_break("80ms").await;
6020        tokio::time::sleep(Duration::from_millis(200)).await;
6021        // First attempt: no cooldown yet, so this triggers a (503) refresh that
6022        // records `last_refresh_attempt` and still fails closed.
6023        assert_eq!(
6024            cache
6025                .validate_token_with_reason(&token)
6026                .await
6027                .expect_err("first attempt must fail closed"),
6028            JwtValidationFailure::Invalid,
6029        );
6030        // Second attempt: the refresh cooldown is now active, so no refresh is
6031        // attempted -- the still-expired cache must not serve the stale key.
6032        let failure = cache
6033            .validate_token_with_reason(&token)
6034            .await
6035            .expect_err("cooldown-active + expired cache must still fail closed");
6036        assert_eq!(failure, JwtValidationFailure::Invalid);
6037    }
6038
6039    #[tokio::test]
6040    async fn valid_jwt_returns_identity() {
6041        let kid = "test-key-1";
6042        let (pem, jwks) = generate_test_keypair(kid);
6043
6044        let mock_server = wiremock::MockServer::start().await;
6045        wiremock::Mock::given(wiremock::matchers::method("GET"))
6046            .and(wiremock::matchers::path("/jwks.json"))
6047            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6048            .mount(&mock_server)
6049            .await;
6050
6051        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6052        let config = test_config(&jwks_uri);
6053        let cache = test_cache(&config);
6054
6055        let token = mint_token(
6056            &pem,
6057            kid,
6058            "https://auth.test.local",
6059            "https://mcp.test.local/mcp",
6060            "ci-bot",
6061            "mcp:read mcp:other",
6062        );
6063
6064        let identity = cache.validate_token(&token).await;
6065        assert!(identity.is_some(), "valid JWT should authenticate");
6066        let id = identity.unwrap();
6067        assert_eq!(id.name, "ci-bot");
6068        assert_eq!(id.role, "viewer"); // first matching scope
6069        assert_eq!(id.method, AuthMethod::OAuthJwt);
6070        // Session binding fingerprints prefer `sub` over `name` precisely
6071        // because `name` falls back through preferred_username -> sub -> azp
6072        // -> client_id and is unstable across token refresh. If `sub` stopped
6073        // being carried onto the identity, that fallback would engage silently
6074        // and OAuth sessions would break across replicas on refresh, with
6075        // every other assertion here still passing.
6076        assert_eq!(id.sub.as_deref(), Some("ci-bot"));
6077    }
6078
6079    // -- L4: kid-strict key lookup + require_subject --
6080
6081    #[test]
6082    fn unknown_kid_with_named_keys_rejected() {
6083        let mut keys = HashMap::new();
6084        keys.insert(
6085            "kid-1".to_owned(),
6086            (
6087                JwkAlg::Explicit(Algorithm::RS256),
6088                DecodingKey::from_secret(b"named"),
6089            ),
6090        );
6091        let cached = CachedKeys {
6092            keys,
6093            unnamed_keys: vec![(
6094                JwkAlg::Explicit(Algorithm::RS256),
6095                DecodingKey::from_secret(b"unnamed"),
6096            )],
6097            fetched_at: Instant::now(),
6098            ttl: Duration::from_secs(300),
6099        };
6100        // A matching kid + algorithm resolves to the named key.
6101        assert!(lookup_key(&cached, Some("kid-1"), Algorithm::RS256).is_some());
6102        // An unknown kid must NOT fall back to the unnamed key (L4 fail-closed):
6103        // a token naming an absent key is rejected rather than silently verified
6104        // against a keyless JWKS entry.
6105        assert!(lookup_key(&cached, Some("unknown"), Algorithm::RS256).is_none());
6106        // A known kid paired with the wrong algorithm is rejected too.
6107        assert!(lookup_key(&cached, Some("kid-1"), Algorithm::ES256).is_none());
6108    }
6109
6110    #[test]
6111    fn no_kid_token_matches_unnamed_key() {
6112        let mut keys = HashMap::new();
6113        keys.insert(
6114            "kid-1".to_owned(),
6115            (
6116                JwkAlg::Explicit(Algorithm::RS256),
6117                DecodingKey::from_secret(b"named"),
6118            ),
6119        );
6120        let cached = CachedKeys {
6121            keys,
6122            unnamed_keys: vec![(
6123                JwkAlg::Explicit(Algorithm::RS256),
6124                DecodingKey::from_secret(b"unnamed"),
6125            )],
6126            fetched_at: Instant::now(),
6127            ttl: Duration::from_secs(300),
6128        };
6129        // A token with no kid falls back to an unnamed key, supporting JWKS
6130        // entries that legitimately omit `kid`.
6131        assert!(lookup_key(&cached, None, Algorithm::RS256).is_some());
6132    }
6133
6134    #[tokio::test]
6135    async fn require_subject_rejects_subject_less() {
6136        let kid = "test-key-reqsub";
6137        let (pem, jwks) = generate_test_keypair(kid);
6138        let mock_server = wiremock::MockServer::start().await;
6139        wiremock::Mock::given(wiremock::matchers::method("GET"))
6140            .and(wiremock::matchers::path("/jwks.json"))
6141            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6142            .mount(&mock_server)
6143            .await;
6144        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6145        let mut config = test_config(&jwks_uri);
6146        config.require_subject = true;
6147        let cache = test_cache(&config);
6148
6149        let no_sub = mint_token_without_sub(
6150            &pem,
6151            kid,
6152            "https://auth.test.local",
6153            "https://mcp.test.local/mcp",
6154            "mcp:read",
6155        );
6156        assert!(
6157            cache.validate_token(&no_sub).await.is_none(),
6158            "require_subject must reject a token with no sub"
6159        );
6160
6161        let with_sub = mint_token(
6162            &pem,
6163            kid,
6164            "https://auth.test.local",
6165            "https://mcp.test.local/mcp",
6166            "svc",
6167            "mcp:read",
6168        );
6169        assert!(
6170            cache.validate_token(&with_sub).await.is_some(),
6171            "a token carrying sub must still be accepted"
6172        );
6173    }
6174
6175    #[tokio::test]
6176    async fn subject_less_token_accepted_by_default() {
6177        let kid = "test-key-nosub-default";
6178        let (pem, jwks) = generate_test_keypair(kid);
6179        let mock_server = wiremock::MockServer::start().await;
6180        wiremock::Mock::given(wiremock::matchers::method("GET"))
6181            .and(wiremock::matchers::path("/jwks.json"))
6182            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6183            .mount(&mock_server)
6184            .await;
6185        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6186        let config = test_config(&jwks_uri); // require_subject defaults to false
6187        let cache = test_cache(&config);
6188        let no_sub = mint_token_without_sub(
6189            &pem,
6190            kid,
6191            "https://auth.test.local",
6192            "https://mcp.test.local/mcp",
6193            "mcp:read",
6194        );
6195        let identity = cache.validate_token(&no_sub).await;
6196        assert!(
6197            identity.is_some(),
6198            "the default policy must accept a sub-less (client-credentials) token"
6199        );
6200        // Documents, rather than guards, the subjectless case: with no `sub`
6201        // the session-binding fingerprint falls back to `name`, which is why
6202        // `require_subject = true` is recommended for OAuth deployments using
6203        // an external session store.
6204        assert!(
6205            identity.and_then(|id| id.sub).is_none(),
6206            "a sub-less token must not synthesise a subject"
6207        );
6208    }
6209
6210    fn mint_token_with_extra(
6211        private_pem: &str,
6212        kid: &str,
6213        issuer: &str,
6214        audience: &str,
6215        extra: &serde_json::Value,
6216    ) -> String {
6217        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
6218            .expect("encoding key from PEM");
6219        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
6220        header.kid = Some(kid.into());
6221        let now = jsonwebtoken::get_current_timestamp();
6222        let mut claims = serde_json::json!({
6223            "iss": issuer,
6224            "aud": audience,
6225            "scope": "mcp:read",
6226            "exp": now + 3600,
6227            "iat": now,
6228        });
6229        if let (Some(base), Some(extra)) = (claims.as_object_mut(), extra.as_object()) {
6230            for (key, value) in extra {
6231                base.insert(key.clone(), value.clone());
6232            }
6233        }
6234        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
6235    }
6236
6237    async fn blank_claim_cache(require_subject: bool) -> (JwksCache, String, wiremock::MockServer) {
6238        let kid = "blank-claim-kid";
6239        let (pem, jwks) = generate_test_keypair(kid);
6240        let mock_server = wiremock::MockServer::start().await;
6241        wiremock::Mock::given(wiremock::matchers::method("GET"))
6242            .and(wiremock::matchers::path("/jwks.json"))
6243            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
6244            .mount(&mock_server)
6245            .await;
6246        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
6247        let mut config = test_config(&jwks_uri);
6248        config.require_subject = require_subject;
6249        let cache = test_cache(&config);
6250        (cache, pem, mock_server)
6251    }
6252
6253    #[tokio::test]
6254    async fn oauth_blank_preferred_username_falls_through_to_sub() {
6255        let (cache, pem, _server) = blank_claim_cache(false).await;
6256        let token = mint_token_with_extra(
6257            &pem,
6258            "blank-claim-kid",
6259            "https://auth.test.local",
6260            "https://mcp.test.local/mcp",
6261            &serde_json::json!({ "sub": "real-sub", "preferred_username": "" }),
6262        );
6263        let id = cache
6264            .validate_token(&token)
6265            .await
6266            .expect("a token with a usable sub must authenticate");
6267        assert_eq!(
6268            id.name, "real-sub",
6269            "blank preferred_username must be skipped"
6270        );
6271        assert_eq!(id.sub.as_deref(), Some("real-sub"));
6272    }
6273
6274    #[tokio::test]
6275    async fn oauth_all_blank_claims_yield_non_blank_name_and_fingerprint() {
6276        let (cache, pem, _server) = blank_claim_cache(false).await;
6277        let token = mint_token_with_extra(
6278            &pem,
6279            "blank-claim-kid",
6280            "https://auth.test.local",
6281            "https://mcp.test.local/mcp",
6282            &serde_json::json!({
6283                "sub": "",
6284                "preferred_username": "  ",
6285                "azp": "",
6286                "client_id": "   ",
6287            }),
6288        );
6289        let id = cache
6290            .validate_token(&token)
6291            .await
6292            .expect("all-blank identity claims still authenticate on a valid token");
6293        assert_eq!(
6294            id.name, "oauth-client",
6295            "all-blank claims must fall to the sentinel"
6296        );
6297        assert!(id.sub.is_none(), "a blank sub must be stored as None");
6298        // Exercises the fingerprint debug_assert: a blank stable id would panic.
6299        let _fingerprint = crate::session_binding::fingerprint(&id);
6300    }
6301
6302    #[tokio::test]
6303    async fn oauth_blank_sub_rejected_when_require_subject() {
6304        let (cache, pem, _server) = blank_claim_cache(true).await;
6305        let token = mint_token_with_extra(
6306            &pem,
6307            "blank-claim-kid",
6308            "https://auth.test.local",
6309            "https://mcp.test.local/mcp",
6310            &serde_json::json!({ "sub": "   " }),
6311        );
6312        assert!(
6313            cache.validate_token(&token).await.is_none(),
6314            "require_subject must reject a blank sub"
6315        );
6316    }
6317
6318    #[tokio::test]
6319    async fn oauth_blank_sub_stored_as_none() {
6320        let (cache, pem, _server) = blank_claim_cache(false).await;
6321        let token = mint_token_with_extra(
6322            &pem,
6323            "blank-claim-kid",
6324            "https://auth.test.local",
6325            "https://mcp.test.local/mcp",
6326            &serde_json::json!({ "sub": "" }),
6327        );
6328        let id = cache
6329            .validate_token(&token)
6330            .await
6331            .expect("a blank sub is accepted by default (require_subject off)");
6332        assert!(id.sub.is_none(), "a blank sub must be stored as None");
6333        assert_eq!(id.name, "oauth-client");
6334    }
6335
6336    #[tokio::test]
6337    async fn oauth_blank_preferred_and_sub_fall_through_to_azp() {
6338        let (cache, pem, _server) = blank_claim_cache(false).await;
6339        let token = mint_token_with_extra(
6340            &pem,
6341            "blank-claim-kid",
6342            "https://auth.test.local",
6343            "https://mcp.test.local/mcp",
6344            &serde_json::json!({ "sub": "", "preferred_username": "  ", "azp": "svc-account" }),
6345        );
6346        let id = cache
6347            .validate_token(&token)
6348            .await
6349            .expect("a usable azp must authenticate");
6350        assert_eq!(
6351            id.name, "svc-account",
6352            "must fall through to a non-blank azp"
6353        );
6354        assert!(id.sub.is_none(), "a blank sub must be stored as None");
6355    }
6356
6357    #[tokio::test]
6358    async fn oauth_blank_azp_falls_through_to_client_id() {
6359        let (cache, pem, _server) = blank_claim_cache(false).await;
6360        let token = mint_token_with_extra(
6361            &pem,
6362            "blank-claim-kid",
6363            "https://auth.test.local",
6364            "https://mcp.test.local/mcp",
6365            &serde_json::json!({
6366                "sub": "",
6367                "preferred_username": "",
6368                "azp": "  ",
6369                "client_id": "svc-client",
6370            }),
6371        );
6372        let id = cache
6373            .validate_token(&token)
6374            .await
6375            .expect("a usable client_id must authenticate");
6376        assert_eq!(
6377            id.name, "svc-client",
6378            "must fall through past a blank azp to a non-blank client_id"
6379        );
6380    }
6381
6382    #[tokio::test]
6383    async fn credential_post_does_not_follow_redirect() {
6384        // M7: a 307 from the token endpoint must NOT be followed, or the
6385        // client_secret-bearing body would be re-sent to the redirect host.
6386        let mock = wiremock::MockServer::start().await;
6387        wiremock::Mock::given(wiremock::matchers::method("POST"))
6388            .and(wiremock::matchers::path("/followed"))
6389            .respond_with(wiremock::ResponseTemplate::new(200))
6390            .expect(0) // verified on MockServer drop: must never be hit
6391            .mount(&mock)
6392            .await;
6393        wiremock::Mock::given(wiremock::matchers::method("POST"))
6394            .and(wiremock::matchers::path("/token"))
6395            .respond_with(
6396                wiremock::ResponseTemplate::new(307)
6397                    .insert_header("location", format!("{}/followed", mock.uri()).as_str()),
6398            )
6399            .mount(&mock)
6400            .await;
6401
6402        let client = OauthHttpClient::build(None).expect("build oauth http client");
6403        let resp = client
6404            .credential_client
6405            .post(format!("{}/token", mock.uri()))
6406            .body("grant_type=client_credentials")
6407            .send()
6408            .await
6409            .expect("request sent");
6410        assert_eq!(
6411            resp.status().as_u16(),
6412            307,
6413            "credential client must surface the 307 rather than follow it"
6414        );
6415    }
6416
6417    fn test_token_exchange_config(token_url: String) -> TokenExchangeConfig {
6418        TokenExchangeConfig::new(
6419            token_url,
6420            "mcp-client",
6421            Some(secrecy::SecretString::new("test-client-secret".into())),
6422            None,
6423        )
6424        .with_audience("downstream-api")
6425    }
6426
6427    const ENC_GRANT: &str = "urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange";
6428    const ENC_ACCESS: &str = "urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token";
6429
6430    #[test]
6431    fn build_exchange_form_is_byte_identical_to_pre_3_8_0_output() {
6432        let config = test_token_exchange_config("https://idp.example.com/token".into());
6433        let body = build_exchange_form(&config, "subj-token");
6434        assert_eq!(
6435            body,
6436            format!(
6437                "grant_type={ENC_GRANT}&subject_token=subj-token\
6438                 &subject_token_type={ENC_ACCESS}&requested_token_type={ENC_ACCESS}\
6439                 &audience=downstream-api"
6440            ),
6441            "a config predating 3.8.0 must produce an unchanged request body"
6442        );
6443    }
6444
6445    #[test]
6446    fn build_exchange_form_emits_only_required_params_when_all_optional_omitted() {
6447        let config =
6448            TokenExchangeConfig::new("https://idp.example.com/token", "public-client", None, None)
6449                .with_requested_token_type(RequestedTokenType::Omit);
6450        let body = build_exchange_form(&config, "subj");
6451        assert_eq!(
6452            body,
6453            format!(
6454                "grant_type={ENC_GRANT}&subject_token=subj\
6455                 &subject_token_type={ENC_ACCESS}&client_id=public-client"
6456            ),
6457            "only the three RFC 8693 §2.1 REQUIRED params plus the public-client id"
6458        );
6459    }
6460
6461    #[test]
6462    fn build_exchange_form_keeps_rfc_parameter_order() {
6463        let config = test_token_exchange_config("https://idp.example.com/token".into())
6464            .with_resource("https://api.example.com/v1")
6465            .with_scope("read write")
6466            .with_requested_token_type(RequestedTokenType::Custom("urn:example:token".into()));
6467        let body = build_exchange_form(&config, "subj");
6468        let keys: Vec<&str> = body
6469            .split('&')
6470            .filter_map(|kv| kv.split('=').next())
6471            .collect();
6472        assert_eq!(
6473            keys,
6474            vec![
6475                "grant_type",
6476                "subject_token",
6477                "subject_token_type",
6478                "requested_token_type",
6479                "audience",
6480                "resource",
6481                "scope",
6482            ]
6483        );
6484        assert!(
6485            body.contains("&requested_token_type=urn%3Aexample%3Atoken"),
6486            "custom token type must be sent verbatim: {body}"
6487        );
6488    }
6489
6490    #[test]
6491    fn token_exchange_toml_omitting_new_keys_still_deserializes() {
6492        let cfg: TokenExchangeConfig = toml::from_str(
6493            "token_url = \"https://idp.example.com/token\"\n\
6494             client_id = \"client\"\n\
6495             audience = \"downstream\"\n",
6496        )
6497        .expect("a token_exchange table predating 3.8.0 must still parse");
6498        assert_eq!(cfg.audience.as_deref(), Some("downstream"));
6499        assert_eq!(cfg.resource, None);
6500        assert_eq!(cfg.scope, None);
6501        assert_eq!(cfg.requested_token_type, RequestedTokenType::AccessToken);
6502    }
6503
6504    #[test]
6505    fn upstream_error_description_is_redacted_by_default() {
6506        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6507        crate::diagnostics::set_diagnostic_exposure(
6508            &crate::diagnostics::DiagnosticExposure::default(),
6509        );
6510
6511        assert_eq!(
6512            upstream_error_description_for_log(Some("subject_token=eyJhbGciOi...")),
6513            "[REDACTED]",
6514            "upstream free-form text must not reach logs unless opted in"
6515        );
6516        assert_eq!(upstream_error_description_for_log(None), "[REDACTED]");
6517    }
6518
6519    #[test]
6520    fn upstream_error_description_is_shown_when_opted_in() {
6521        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
6522        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
6523            upstream_error_bodies: true,
6524            ..crate::diagnostics::DiagnosticExposure::default()
6525        });
6526
6527        assert_eq!(
6528            upstream_error_description_for_log(Some("audience not permitted")),
6529            "audience not permitted",
6530            "the debug switch must surface the upstream description verbatim"
6531        );
6532        assert_eq!(
6533            upstream_error_description_for_log(None),
6534            "",
6535            "an absent description renders empty, not the redaction marker"
6536        );
6537    }
6538
6539    #[test]
6540    fn requested_token_type_deserializes_from_plain_strings() {
6541        for (raw, expected) in [
6542            ("access_token", RequestedTokenType::AccessToken),
6543            ("omit", RequestedTokenType::Omit),
6544            (
6545                "urn:example:token",
6546                RequestedTokenType::Custom("urn:example:token".into()),
6547            ),
6548        ] {
6549            let cfg: TokenExchangeConfig = toml::from_str(&format!(
6550                "token_url = \"https://idp.example.com/token\"\n\
6551                 client_id = \"client\"\n\
6552                 requested_token_type = \"{raw}\"\n"
6553            ))
6554            .expect("requested_token_type must accept any string");
6555            assert_eq!(cfg.requested_token_type, expected, "input {raw}");
6556        }
6557    }
6558
6559    fn exchange_response(access_token: &str, issued_token_type: &str) -> serde_json::Value {
6560        serde_json::json!({
6561            "access_token": access_token,
6562            "expires_in": 3600_u64,
6563            "issued_token_type": issued_token_type,
6564        })
6565    }
6566
6567    fn unsigned_jwt_with_claims(claims: &serde_json::Value) -> String {
6568        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
6569        let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims json"));
6570        format!("{header}.{payload}.signature")
6571    }
6572
6573    fn test_exchange_client() -> OauthHttpClient {
6574        let config = OAuthConfig::builder(
6575            "http://auth.test.local",
6576            "mcp",
6577            "http://auth.test.local/jwks.json",
6578        )
6579        .allow_http_oauth_urls(true)
6580        .build();
6581        OauthHttpClient::build(Some(&config))
6582            .expect("build oauth http client")
6583            .__test_allow_loopback_ssrf()
6584    }
6585
6586    fn unavailable_loopback_token_url() -> String {
6587        "http://127.0.0.1:1/token?client_secret=super-secret".to_owned()
6588    }
6589
6590    async fn recorded_request_count(mock: &wiremock::MockServer) -> usize {
6591        mock.received_requests()
6592            .await
6593            .expect("wiremock request recording is enabled")
6594            .len()
6595    }
6596
6597    async fn wait_for_recorded_request(mock: &wiremock::MockServer) {
6598        // Liveness wait, not a latency bound: it returns as soon as the mock
6599        // records the request, so a generous ceiling costs nothing on success
6600        // and only makes a genuine hang fail slower.
6601        tokio::time::timeout(Duration::from_secs(15), async {
6602            loop {
6603                if recorded_request_count(mock).await > 0 {
6604                    return;
6605                }
6606                tokio::time::sleep(Duration::from_millis(10)).await;
6607            }
6608        })
6609        .await
6610        .expect("token endpoint must record the in-flight request before cancellation");
6611    }
6612
6613    async fn wait_for_log_contains(logs: &CapturedLogs, needle: &str) {
6614        // Must comfortably exceed the mock response delay: the detached task
6615        // cannot emit its audit line until the upstream exchange completes, so
6616        // this bound is `mock delay + slack`, not a latency expectation. It is
6617        // a bounded wait -- on success it returns as soon as the line appears.
6618        tokio::time::timeout(Duration::from_secs(15), async {
6619            loop {
6620                if logs.contents().contains(needle) {
6621                    return;
6622                }
6623                tokio::time::sleep(Duration::from_millis(10)).await;
6624            }
6625        })
6626        .await
6627        .expect("detached token exchange must eventually emit its audit log");
6628    }
6629
6630    #[tokio::test]
6631    async fn send_screened_request_failure_sanitizes_url_and_reqwest_error() {
6632        let client = test_exchange_client();
6633        let screened_url = unavailable_loopback_token_url();
6634        let request_url = screened_url.replacen("//", "//u:p@", 1);
6635
6636        let error = client
6637            .send_screened(
6638                &screened_url,
6639                client
6640                    .credential_client
6641                    .post(&request_url)
6642                    .body("grant_type=test"),
6643            )
6644            .await
6645            .expect_err("closed loopback port must fail the request");
6646
6647        let rendered = error.to_string();
6648        let sanitized = oauth_request_target_for_log(&screened_url);
6649        assert!(
6650            rendered.contains(&format!("oauth request {sanitized}")),
6651            "request failure must identify only the sanitized origin: {rendered}"
6652        );
6653        for leaked in ["u:p", "/token", "client_secret", "super-secret"] {
6654            assert!(
6655                !rendered.contains(leaked),
6656                "request failure must not echo raw URL component {leaked}: {rendered}"
6657            );
6658        }
6659    }
6660
6661    #[tokio::test]
6662    async fn exchange_token_request_failure_log_sanitizes_token_url() {
6663        let logs = CapturedLogs::default();
6664        let subscriber = tracing_subscriber::fmt()
6665            .with_max_level(tracing::Level::ERROR)
6666            .with_writer(logs.clone())
6667            .with_ansi(false)
6668            .without_time()
6669            .finish();
6670        let _guard = tracing::subscriber::set_default(subscriber);
6671
6672        let client = test_exchange_client();
6673        let token_url = unavailable_loopback_token_url();
6674        let config = test_token_exchange_config(token_url);
6675        let error = exchange_token(&client, &config, "subject-token")
6676            .await
6677            .expect_err("closed loopback port must fail exchange");
6678
6679        assert!(
6680            error.to_string().contains("server_error"),
6681            "client-visible exchange error must remain sanitized: {error}"
6682        );
6683        let contents = logs.contents();
6684        assert!(
6685            contents.contains("token exchange request failed"),
6686            "exchange failure must still be logged: {contents}"
6687        );
6688        assert!(
6689            contents.contains("oauth request http://127.0.0.1:1"),
6690            "exchange failure log must include only sanitized origin: {contents}"
6691        );
6692        for leaked in ["/token", "client_secret", "super-secret", "subject-token"] {
6693            assert!(
6694                !contents.contains(leaked),
6695                "exchange failure log must not echo raw URL/token component {leaked}: {contents}"
6696            );
6697        }
6698    }
6699
6700    #[tokio::test]
6701    async fn exchange_token_with_cancel_precancel_does_not_send() {
6702        let mock = wiremock::MockServer::start().await;
6703        wiremock::Mock::given(wiremock::matchers::method("POST"))
6704            .and(wiremock::matchers::path("/token"))
6705            .respond_with(
6706                wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6707                    "downstream-token",
6708                    "urn:ietf:params:oauth:token-type:access_token",
6709                )),
6710            )
6711            .mount(&mock)
6712            .await;
6713
6714        let client = test_exchange_client();
6715        let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6716        let ct = tokio_util::sync::CancellationToken::new();
6717        ct.cancel();
6718
6719        let outcome =
6720            exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6721
6722        assert!(
6723            matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6724            "pre-cancelled exchanges must not start work"
6725        );
6726        assert_eq!(
6727            recorded_request_count(&mock).await,
6728            0,
6729            "pre-cancel check must happen before cloning/spawning/sending"
6730        );
6731    }
6732
6733    #[tokio::test]
6734    async fn exchange_token_with_cancel_completes_normally() {
6735        let mock = wiremock::MockServer::start().await;
6736        wiremock::Mock::given(wiremock::matchers::method("POST"))
6737            .and(wiremock::matchers::path("/token"))
6738            .respond_with(
6739                wiremock::ResponseTemplate::new(200).set_body_json(exchange_response(
6740                    "downstream-token",
6741                    "urn:ietf:params:oauth:token-type:access_token",
6742                )),
6743            )
6744            .expect(1)
6745            .mount(&mock)
6746            .await;
6747
6748        let client = test_exchange_client();
6749        let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6750        let ct = tokio_util::sync::CancellationToken::new();
6751
6752        let outcome =
6753            exchange_token_with_cancel(&client, &config, "subject-token", &ct, None).await;
6754
6755        let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6756            panic!("uncancelled exchange must complete successfully")
6757        };
6758        assert_eq!(token.access_token, "downstream-token");
6759        mock.verify().await;
6760    }
6761
6762    #[tokio::test]
6763    async fn exchange_token_with_cancel_detaches_and_audits_abandoned_token() {
6764        let mock = wiremock::MockServer::start().await;
6765        let long_issued_token_type = format!(
6766            "urn:ietf:params:oauth:token-type:{}",
6767            "x".repeat(MAX_LOGGED_KID_CHARS + 32)
6768        );
6769        wiremock::Mock::given(wiremock::matchers::method("POST"))
6770            .and(wiremock::matchers::path("/token"))
6771            .respond_with(
6772                wiremock::ResponseTemplate::new(200)
6773                    // Long enough that the completion arm cannot plausibly win
6774                    // the `biased;` race before the caller cancels. A tight
6775                    // delay would make the outcome assertion depend on machine
6776                    // load rather than on the detach behaviour it proves. The
6777                    // test never waits this out -- returning without waiting is
6778                    // precisely the point.
6779                    .set_delay(Duration::from_secs(2))
6780                    .set_body_json(exchange_response(
6781                        "abandoned-downstream-token",
6782                        &long_issued_token_type,
6783                    )),
6784            )
6785            .expect(1)
6786            .mount(&mock)
6787            .await;
6788
6789        let token_url = format!("{}/token", mock.uri());
6790        let token_url_host = url::Url::parse(&token_url)
6791            .expect("mock token URL parses")
6792            .host_str()
6793            .expect("mock token URL has host")
6794            .to_owned();
6795        let logs = CapturedLogs::default();
6796        let subscriber = tracing_subscriber::fmt()
6797            .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6798            .with_writer(logs.clone())
6799            .with_ansi(false)
6800            .without_time()
6801            .finish();
6802        let _guard = tracing::subscriber::set_default(subscriber);
6803
6804        let client = test_exchange_client();
6805        let config = test_token_exchange_config(token_url);
6806        let ct = tokio_util::sync::CancellationToken::new();
6807        let task_ct = ct.clone();
6808        let handle = tokio::spawn(async move {
6809            exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6810        });
6811
6812        wait_for_recorded_request(&mock).await;
6813        let cancelled_at = Instant::now();
6814        ct.cancel();
6815        let outcome = handle.await.expect("wrapper task must not panic");
6816
6817        assert!(
6818            matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6819            "caller must get an immediate cancellation outcome"
6820        );
6821        assert!(
6822            cancelled_at.elapsed() < Duration::from_millis(100),
6823            "wrapper must detach instead of waiting for the delayed upstream response"
6824        );
6825
6826        wait_for_log_contains(
6827            &logs,
6828            "token exchange minted downstream token after caller detached",
6829        )
6830        .await;
6831        mock.verify().await;
6832        let contents = logs.contents();
6833        assert!(
6834            contents.contains("issued_token_type_truncated=true"),
6835            "audit log must mark issuer-controlled token type truncation: {contents}"
6836        );
6837        assert!(
6838            !contents.contains("abandoned-downstream-token"),
6839            "audit log must not include downstream token material: {contents}"
6840        );
6841        assert!(
6842            !contents.contains("token_len="),
6843            "DEBUG success log must be suppressed on abandoned exchanges: {contents}"
6844        );
6845        assert!(
6846            !contents.contains(&long_issued_token_type),
6847            "detached logs must not include unbounded issued token type: {contents}"
6848        );
6849        for field in ["sub=", "aud=", "azp=", "iss="] {
6850            assert!(
6851                !contents.contains(field),
6852                "detached logs must not include JWT claim field {field}: {contents}"
6853            );
6854        }
6855        assert!(
6856            !contents.contains(&token_url_host),
6857            "detached success logs must not include token endpoint host: {contents}"
6858        );
6859        assert!(
6860            !contents.contains("subject-token"),
6861            "audit log must not include subject token material: {contents}"
6862        );
6863        assert!(
6864            !contents.contains("test-client-secret"),
6865            "audit log must not include client secret material: {contents}"
6866        );
6867    }
6868
6869    #[tokio::test]
6870    async fn exchange_token_with_cancel_detached_jwt_success_does_not_log_claims() {
6871        let mock = wiremock::MockServer::start().await;
6872        let jwt = unsigned_jwt_with_claims(&serde_json::json!({
6873            "sub": "detached-subject",
6874            "aud": "detached-audience",
6875            "azp": "detached-client",
6876            "iss": "https://issuer.example.test/realm",
6877        }));
6878        wiremock::Mock::given(wiremock::matchers::method("POST"))
6879            .and(wiremock::matchers::path("/token"))
6880            .respond_with(
6881                wiremock::ResponseTemplate::new(200)
6882                    // See the opaque-token variant of this test: the delay is a
6883                    // race margin, not a wait. It keeps the completion arm from
6884                    // winning the `biased;` race under load.
6885                    .set_delay(Duration::from_secs(2))
6886                    .set_body_json(exchange_response(
6887                        &jwt,
6888                        "urn:ietf:params:oauth:token-type:access_token",
6889                    )),
6890            )
6891            .expect(1)
6892            .mount(&mock)
6893            .await;
6894
6895        let logs = CapturedLogs::default();
6896        let subscriber = tracing_subscriber::fmt()
6897            .with_env_filter(tracing_subscriber::EnvFilter::new("rmcp_server_kit=debug"))
6898            .with_writer(logs.clone())
6899            .with_ansi(false)
6900            .without_time()
6901            .finish();
6902        let _guard = tracing::subscriber::set_default(subscriber);
6903
6904        let client = test_exchange_client();
6905        let config = test_token_exchange_config(format!("{}/token", mock.uri()));
6906        let ct = tokio_util::sync::CancellationToken::new();
6907        let task_ct = ct.clone();
6908        let handle = tokio::spawn(async move {
6909            exchange_token_with_cancel(&client, &config, "subject-token", &task_ct, None).await
6910        });
6911
6912        wait_for_recorded_request(&mock).await;
6913        ct.cancel();
6914        let outcome = handle.await.expect("wrapper task must not panic");
6915        assert!(
6916            matches!(outcome, crate::cancel::DetachOutcome::Cancelled),
6917            "caller must get cancellation while spawned JWT exchange continues"
6918        );
6919
6920        wait_for_log_contains(
6921            &logs,
6922            "token exchange minted downstream token after caller detached",
6923        )
6924        .await;
6925        mock.verify().await;
6926        let contents = logs.contents();
6927        assert!(
6928            !contents.contains(&jwt),
6929            "detached JWT success must not log token material: {contents}"
6930        );
6931        for leaked in [
6932            "sub=",
6933            "aud=",
6934            "azp=",
6935            "iss=",
6936            "detached-subject",
6937            "detached-audience",
6938            "detached-client",
6939            "issuer.example.test",
6940        ] {
6941            assert!(
6942                !contents.contains(leaked),
6943                "detached JWT success must not log claim material {leaked}: {contents}"
6944            );
6945        }
6946    }
6947
6948    #[tokio::test]
6949    async fn exchange_token_with_cancel_completion_wins_tie() {
6950        let (tx, rx) = tokio::sync::oneshot::channel();
6951        tx.send(Ok(ExchangedToken {
6952            access_token: "tie-winner".into(),
6953            expires_in: Some(3600),
6954            issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".into()),
6955        }))
6956        .expect("test receiver is alive");
6957        let ct = tokio_util::sync::CancellationToken::new();
6958        ct.cancel();
6959
6960        let outcome = receive_exchange_result_with_cancel(rx, &ct, None).await;
6961
6962        let crate::cancel::DetachOutcome::Completed(Ok(token)) = outcome else {
6963            panic!("ready completion must win over ready cancellation under biased select")
6964        };
6965        assert_eq!(token.access_token, "tie-winner");
6966    }
6967
6968    #[tokio::test]
6969    async fn jwks_get_still_follows_screened_redirect() {
6970        // M7 regression: adding the no-redirect credential client must NOT
6971        // change the JWKS/discovery client, which still follows a redirect
6972        // whose every hop passes the SSRF screen. `allow_http` plus a loopback
6973        // allowlist entry let the http->http hop to the wiremock literal IP
6974        // clear `evaluate_oauth_redirect`'s scheme and per-hop SSRF checks.
6975        let mock = wiremock::MockServer::start().await;
6976        wiremock::Mock::given(wiremock::matchers::method("GET"))
6977            .and(wiremock::matchers::path("/jwks.json"))
6978            .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
6979                "location",
6980                format!("{}/jwks-final.json", mock.uri()).as_str(),
6981            ))
6982            .mount(&mock)
6983            .await;
6984        wiremock::Mock::given(wiremock::matchers::method("GET"))
6985            .and(wiremock::matchers::path("/jwks-final.json"))
6986            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("reached"))
6987            .expect(1)
6988            .mount(&mock)
6989            .await;
6990
6991        let mut allowlist = OAuthSsrfAllowlist::default();
6992        allowlist.cidrs.push("127.0.0.0/8".into());
6993        allowlist.cidrs.push("::1/128".into());
6994        let mut config = test_config(&format!("{}/jwks.json", mock.uri()));
6995        config.allow_http_oauth_urls = true;
6996        config.ssrf_allowlist = Some(allowlist);
6997
6998        let client = OauthHttpClient::build(Some(&config)).expect("build oauth http client");
6999        let resp = client
7000            .inner
7001            .get(format!("{}/jwks.json", mock.uri()))
7002            .send()
7003            .await
7004            .expect("request sent");
7005        assert_eq!(
7006            resp.status().as_u16(),
7007            200,
7008            "JWKS client must follow the screened redirect to the final endpoint"
7009        );
7010        assert_eq!(resp.text().await.expect("response body"), "reached");
7011    }
7012
7013    #[tokio::test]
7014    async fn wrong_issuer_rejected() {
7015        let kid = "test-key-2";
7016        let (pem, jwks) = generate_test_keypair(kid);
7017
7018        let mock_server = wiremock::MockServer::start().await;
7019        wiremock::Mock::given(wiremock::matchers::method("GET"))
7020            .and(wiremock::matchers::path("/jwks.json"))
7021            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7022            .mount(&mock_server)
7023            .await;
7024
7025        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7026        let config = test_config(&jwks_uri);
7027        let cache = test_cache(&config);
7028
7029        let token = mint_token(
7030            &pem,
7031            kid,
7032            "https://wrong-issuer.example.com", // wrong
7033            "https://mcp.test.local/mcp",
7034            "attacker",
7035            "mcp:admin",
7036        );
7037
7038        assert!(cache.validate_token(&token).await.is_none());
7039    }
7040
7041    #[tokio::test]
7042    async fn wrong_audience_rejected() {
7043        let kid = "test-key-3";
7044        let (pem, jwks) = generate_test_keypair(kid);
7045
7046        let mock_server = wiremock::MockServer::start().await;
7047        wiremock::Mock::given(wiremock::matchers::method("GET"))
7048            .and(wiremock::matchers::path("/jwks.json"))
7049            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7050            .mount(&mock_server)
7051            .await;
7052
7053        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7054        let config = test_config(&jwks_uri);
7055        let cache = test_cache(&config);
7056
7057        let token = mint_token(
7058            &pem,
7059            kid,
7060            "https://auth.test.local",
7061            "https://wrong-audience.example.com", // wrong
7062            "attacker",
7063            "mcp:admin",
7064        );
7065
7066        assert!(cache.validate_token(&token).await.is_none());
7067    }
7068
7069    #[tokio::test]
7070    async fn expired_jwt_rejected() {
7071        let kid = "test-key-4";
7072        let (pem, jwks) = generate_test_keypair(kid);
7073
7074        let mock_server = wiremock::MockServer::start().await;
7075        wiremock::Mock::given(wiremock::matchers::method("GET"))
7076            .and(wiremock::matchers::path("/jwks.json"))
7077            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7078            .mount(&mock_server)
7079            .await;
7080
7081        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7082        let config = test_config(&jwks_uri);
7083        let cache = test_cache(&config);
7084
7085        // Create a token that expired 2 minutes ago (past the 60s leeway).
7086        let encoding_key =
7087            jsonwebtoken::EncodingKey::from_rsa_pem(pem.as_bytes()).expect("encoding key");
7088        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
7089        header.kid = Some(kid.into());
7090        let now = jsonwebtoken::get_current_timestamp();
7091        let claims = serde_json::json!({
7092            "iss": "https://auth.test.local",
7093            "aud": "https://mcp.test.local/mcp",
7094            "sub": "expired-bot",
7095            "scope": "mcp:read",
7096            "exp": now - 120,
7097            "iat": now - 3720,
7098        });
7099        let token = jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding");
7100
7101        assert!(cache.validate_token(&token).await.is_none());
7102    }
7103
7104    #[tokio::test]
7105    async fn no_matching_scope_rejected() {
7106        let kid = "test-key-5";
7107        let (pem, jwks) = generate_test_keypair(kid);
7108
7109        let mock_server = wiremock::MockServer::start().await;
7110        wiremock::Mock::given(wiremock::matchers::method("GET"))
7111            .and(wiremock::matchers::path("/jwks.json"))
7112            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7113            .mount(&mock_server)
7114            .await;
7115
7116        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7117        let config = test_config(&jwks_uri);
7118        let cache = test_cache(&config);
7119
7120        let token = mint_token(
7121            &pem,
7122            kid,
7123            "https://auth.test.local",
7124            "https://mcp.test.local/mcp",
7125            "limited-bot",
7126            "some:other:scope", // no matching scope
7127        );
7128
7129        assert!(cache.validate_token(&token).await.is_none());
7130    }
7131
7132    #[tokio::test]
7133    async fn wrong_signing_key_rejected() {
7134        let kid = "test-key-6";
7135        let (_pem, jwks) = generate_test_keypair(kid);
7136
7137        // Generate a DIFFERENT keypair for signing (attacker key).
7138        let (attacker_pem, _) = generate_test_keypair(kid);
7139
7140        let mock_server = wiremock::MockServer::start().await;
7141        wiremock::Mock::given(wiremock::matchers::method("GET"))
7142            .and(wiremock::matchers::path("/jwks.json"))
7143            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7144            .mount(&mock_server)
7145            .await;
7146
7147        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7148        let config = test_config(&jwks_uri);
7149        let cache = test_cache(&config);
7150
7151        // Sign with attacker key but JWKS has legitimate public key.
7152        let token = mint_token(
7153            &attacker_pem,
7154            kid,
7155            "https://auth.test.local",
7156            "https://mcp.test.local/mcp",
7157            "attacker",
7158            "mcp:admin",
7159        );
7160
7161        assert!(cache.validate_token(&token).await.is_none());
7162    }
7163
7164    #[tokio::test]
7165    async fn admin_scope_maps_to_ops_role() {
7166        let kid = "test-key-7";
7167        let (pem, jwks) = generate_test_keypair(kid);
7168
7169        let mock_server = wiremock::MockServer::start().await;
7170        wiremock::Mock::given(wiremock::matchers::method("GET"))
7171            .and(wiremock::matchers::path("/jwks.json"))
7172            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7173            .mount(&mock_server)
7174            .await;
7175
7176        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7177        let config = test_config(&jwks_uri);
7178        let cache = test_cache(&config);
7179
7180        let token = mint_token(
7181            &pem,
7182            kid,
7183            "https://auth.test.local",
7184            "https://mcp.test.local/mcp",
7185            "admin-bot",
7186            "mcp:admin",
7187        );
7188
7189        let id = cache
7190            .validate_token(&token)
7191            .await
7192            .expect("should authenticate");
7193        assert_eq!(id.role, "ops");
7194        assert_eq!(id.name, "admin-bot");
7195    }
7196
7197    #[tokio::test]
7198    async fn entra_shaped_alg_less_jwks_authenticates_end_to_end() {
7199        // Issue #17: the reported Entra failure, reproduced end-to-end. The
7200        // JWKS omits `alg` exactly as login.microsoftonline.com does; before
7201        // family inference the key was dropped and this returned None.
7202        let kid = "entra-e2e";
7203        let (pem, jwks) = generate_test_keypair(kid);
7204        let mut alg_less = jwks;
7205        if let Some(key) = alg_less["keys"][0].as_object_mut() {
7206            key.remove("alg");
7207        }
7208        assert!(
7209            alg_less["keys"][0].get("alg").is_none(),
7210            "fixture must reproduce Entra's alg-less shape"
7211        );
7212
7213        let mock_server = wiremock::MockServer::start().await;
7214        wiremock::Mock::given(wiremock::matchers::method("GET"))
7215            .and(wiremock::matchers::path("/jwks.json"))
7216            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&alg_less))
7217            .mount(&mock_server)
7218            .await;
7219
7220        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7221        let config = test_config(&jwks_uri);
7222        let cache = test_cache(&config);
7223
7224        let token = mint_token(
7225            &pem,
7226            kid,
7227            "https://auth.test.local",
7228            "https://mcp.test.local/mcp",
7229            "entra-user",
7230            "mcp:admin",
7231        );
7232
7233        let id = cache
7234            .validate_token(&token)
7235            .await
7236            .expect("an alg-less JWKS key must still authenticate (issue #17)");
7237        assert_eq!(id.name, "entra-user");
7238    }
7239
7240    #[tokio::test]
7241    async fn jwks_server_down_returns_none() {
7242        // Point to a non-existent server.
7243        let config = test_config("http://127.0.0.1:1/jwks.json");
7244        let cache = test_cache(&config);
7245
7246        let kid = "orphan-key";
7247        let (pem, _) = generate_test_keypair(kid);
7248        let token = mint_token(
7249            &pem,
7250            kid,
7251            "https://auth.test.local",
7252            "https://mcp.test.local/mcp",
7253            "bot",
7254            "mcp:read",
7255        );
7256
7257        assert!(cache.validate_token(&token).await.is_none());
7258    }
7259
7260    // -----------------------------------------------------------------------
7261    // resolve_claim_path tests
7262    // -----------------------------------------------------------------------
7263
7264    #[test]
7265    fn resolve_claim_path_flat_string() {
7266        let mut extra = HashMap::new();
7267        extra.insert(
7268            "scope".into(),
7269            serde_json::Value::String("mcp:read mcp:admin".into()),
7270        );
7271        let values = resolve_claim_path(&extra, "scope");
7272        assert_eq!(values, vec!["mcp:read", "mcp:admin"]);
7273    }
7274
7275    #[test]
7276    fn resolve_claim_path_flat_array() {
7277        let mut extra = HashMap::new();
7278        extra.insert(
7279            "roles".into(),
7280            serde_json::json!(["mcp-admin", "mcp-viewer"]),
7281        );
7282        let values = resolve_claim_path(&extra, "roles");
7283        assert_eq!(values, vec!["mcp-admin", "mcp-viewer"]);
7284    }
7285
7286    #[test]
7287    fn resolve_claim_path_nested_keycloak() {
7288        let mut extra = HashMap::new();
7289        extra.insert(
7290            "realm_access".into(),
7291            serde_json::json!({"roles": ["uma_authorization", "mcp-admin"]}),
7292        );
7293        let values = resolve_claim_path(&extra, "realm_access.roles");
7294        assert_eq!(values, vec!["uma_authorization", "mcp-admin"]);
7295    }
7296
7297    #[test]
7298    fn resolve_claim_path_missing_returns_empty() {
7299        let extra = HashMap::new();
7300        assert!(resolve_claim_path(&extra, "nonexistent.path").is_empty());
7301    }
7302
7303    #[test]
7304    fn resolve_claim_path_numeric_leaf_returns_empty() {
7305        let mut extra = HashMap::new();
7306        extra.insert("count".into(), serde_json::json!(42));
7307        assert!(resolve_claim_path(&extra, "count").is_empty());
7308    }
7309
7310    fn make_claims(json: serde_json::Value) -> Claims {
7311        serde_json::from_value(json).expect("test claims must deserialize")
7312    }
7313
7314    #[test]
7315    fn first_class_scope_claim_splits_on_whitespace() {
7316        let claims = make_claims(serde_json::json!({
7317            "iss": "https://issuer.example.com",
7318            "exp": 9_999_999_999_u64,
7319            "scope": "read write admin",
7320        }));
7321        let values = first_class_claim_values(&claims, "scope");
7322        assert_eq!(values, vec!["read", "write", "admin"]);
7323    }
7324
7325    #[test]
7326    fn first_class_sub_claim_returns_single_value() {
7327        let claims = make_claims(serde_json::json!({
7328            "iss": "https://issuer.example.com",
7329            "exp": 9_999_999_999_u64,
7330            "sub": "service-account-orders",
7331        }));
7332        let values = first_class_claim_values(&claims, "sub");
7333        assert_eq!(values, vec!["service-account-orders"]);
7334    }
7335
7336    #[test]
7337    fn first_class_aud_claim_returns_every_audience() {
7338        let claims = make_claims(serde_json::json!({
7339            "iss": "https://issuer.example.com",
7340            "exp": 9_999_999_999_u64,
7341            "aud": ["api-a", "api-b"],
7342        }));
7343        let values = first_class_claim_values(&claims, "aud");
7344        assert_eq!(values, vec!["api-a", "api-b"]);
7345    }
7346
7347    #[test]
7348    fn first_class_unknown_path_returns_empty() {
7349        let claims = make_claims(serde_json::json!({
7350            "iss": "https://issuer.example.com",
7351            "exp": 9_999_999_999_u64,
7352        }));
7353        assert!(first_class_claim_values(&claims, "realm_access.roles").is_empty());
7354    }
7355
7356    // -----------------------------------------------------------------------
7357    // role_claim integration tests (wiremock)
7358    // -----------------------------------------------------------------------
7359
7360    /// Mint a JWT with arbitrary custom claims (for `role_claim` testing).
7361    fn mint_token_with_claims(private_pem: &str, kid: &str, claims: &serde_json::Value) -> String {
7362        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
7363            .expect("encoding key from PEM");
7364        let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
7365        header.kid = Some(kid.into());
7366        jsonwebtoken::encode(&header, &claims, &encoding_key).expect("JWT encoding")
7367    }
7368
7369    fn test_config_with_role_claim(
7370        jwks_uri: &str,
7371        role_claim: &str,
7372        role_mappings: Vec<RoleMapping>,
7373    ) -> OAuthConfig {
7374        OAuthConfig {
7375            require_subject: false,
7376            issuer: "https://auth.test.local".into(),
7377            audience: "https://mcp.test.local/mcp".into(),
7378            jwks_uri: jwks_uri.into(),
7379            scopes: vec![],
7380            role_claim: Some(role_claim.into()),
7381            role_mappings,
7382            jwks_cache_ttl: "5m".into(),
7383            proxy: None,
7384            token_exchange: None,
7385            ca_cert_path: None,
7386            allow_http_oauth_urls: true,
7387            max_jwks_keys: default_max_jwks_keys(),
7388            allowed_algorithms: None,
7389            authorization_servers: None,
7390            authorization_server_metadata_issuer: None,
7391            #[allow(
7392                deprecated,
7393                reason = "test fixture: explicit value for the deprecated field"
7394            )]
7395            strict_audience_validation: None,
7396            audience_validation_mode: None,
7397            jwks_max_response_bytes: default_jwks_max_bytes(),
7398            ssrf_allowlist: None,
7399        }
7400    }
7401
7402    #[tokio::test]
7403    async fn screen_oauth_target_rejects_literal_ip() {
7404        let err = screen_oauth_target(
7405            "https://127.0.0.1/jwks.json",
7406            false,
7407            &crate::ssrf::CompiledSsrfAllowlist::default(),
7408        )
7409        .await
7410        .expect_err("literal IPs must be rejected");
7411        let msg = err.to_string();
7412        assert!(msg.contains("literal IPv4 addresses are forbidden"));
7413    }
7414
7415    #[tokio::test]
7416    async fn screen_oauth_target_rejects_private_dns_resolution() {
7417        let err = screen_oauth_target(
7418            "https://localhost/jwks.json",
7419            false,
7420            &crate::ssrf::CompiledSsrfAllowlist::default(),
7421        )
7422        .await
7423        .expect_err("localhost resolution must be rejected");
7424        let msg = err.to_string();
7425        assert!(
7426            msg.contains("blocked IP") && msg.contains("loopback"),
7427            "got {msg:?}"
7428        );
7429    }
7430
7431    #[tokio::test]
7432    async fn screen_oauth_target_rejects_literal_ip_even_with_allow_http() {
7433        let err = screen_oauth_target(
7434            "http://127.0.0.1/jwks.json",
7435            true,
7436            &crate::ssrf::CompiledSsrfAllowlist::default(),
7437        )
7438        .await
7439        .expect_err("literal IPs must still be rejected when http is allowed");
7440        let msg = err.to_string();
7441        assert!(msg.contains("literal IPv4 addresses are forbidden"));
7442    }
7443
7444    #[tokio::test]
7445    async fn screen_oauth_target_rejects_private_dns_even_with_allow_http() {
7446        let err = screen_oauth_target(
7447            "http://localhost/jwks.json",
7448            true,
7449            &crate::ssrf::CompiledSsrfAllowlist::default(),
7450        )
7451        .await
7452        .expect_err("private DNS resolution must still be rejected when http is allowed");
7453        let msg = err.to_string();
7454        assert!(
7455            msg.contains("blocked IP") && msg.contains("loopback"),
7456            "got {msg:?}"
7457        );
7458    }
7459
7460    #[tokio::test]
7461    async fn screen_oauth_target_allows_public_hostname() {
7462        screen_oauth_target(
7463            "https://example.com/.well-known/jwks.json",
7464            false,
7465            &crate::ssrf::CompiledSsrfAllowlist::default(),
7466        )
7467        .await
7468        .expect("public hostname should pass screening");
7469    }
7470
7471    // -----------------------------------------------------------------------
7472    // Operator SSRF allowlist (1.4.0)
7473    // -----------------------------------------------------------------------
7474
7475    /// Helper: compile an allowlist from string literals.
7476    fn make_allowlist(hosts: &[&str], cidrs: &[&str]) -> crate::ssrf::CompiledSsrfAllowlist {
7477        let raw = OAuthSsrfAllowlist {
7478            hosts: hosts.iter().map(|s| (*s).to_owned()).collect(),
7479            cidrs: cidrs.iter().map(|s| (*s).to_owned()).collect(),
7480        };
7481        compile_oauth_ssrf_allowlist(&raw).expect("test allowlist compiles")
7482    }
7483
7484    #[test]
7485    fn compile_oauth_ssrf_allowlist_lowercases_and_dedupes_hosts() {
7486        let raw = OAuthSsrfAllowlist {
7487            hosts: vec!["RHBK.ops.example.com".into(), "rhbk.ops.example.com".into()],
7488            cidrs: vec![],
7489        };
7490        let compiled = compile_oauth_ssrf_allowlist(&raw).expect("compiles");
7491        assert_eq!(compiled.host_count(), 1);
7492        assert!(compiled.host_allowed("rhbk.ops.example.com"));
7493        assert!(compiled.host_allowed("RHBK.OPS.EXAMPLE.COM"));
7494    }
7495
7496    #[test]
7497    fn compile_oauth_ssrf_allowlist_rejects_literal_ip_in_hosts() {
7498        let raw = OAuthSsrfAllowlist {
7499            hosts: vec!["10.0.0.1".into()],
7500            cidrs: vec![],
7501        };
7502        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("literal IP in hosts");
7503        assert!(err.contains("literal IPs are forbidden"), "got {err:?}");
7504    }
7505
7506    #[test]
7507    fn compile_oauth_ssrf_allowlist_rejects_host_with_port() {
7508        let raw = OAuthSsrfAllowlist {
7509            hosts: vec!["rhbk.ops.example.com:8443".into()],
7510            cidrs: vec![],
7511        };
7512        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("host:port");
7513        assert!(err.contains("must be a bare DNS hostname"), "got {err:?}");
7514    }
7515
7516    // -- L3: internal-hostname-suffix pre-DNS denylist --
7517
7518    #[test]
7519    fn internal_suffix_rejected_by_default() {
7520        let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7521        for h in ["idp.internal", "svc.local", "x.localhost", "idp.internal."] {
7522            assert!(oauth_internal_suffix_blocked(h, &allow), "{h}");
7523        }
7524    }
7525
7526    #[test]
7527    fn exact_allowlisted_internal_permitted() {
7528        let allow = make_allowlist(&["idp.internal"], &[]);
7529        assert!(!oauth_internal_suffix_blocked("idp.internal", &allow));
7530        assert!(!oauth_internal_suffix_blocked("idp.internal.", &allow));
7531    }
7532
7533    #[test]
7534    fn subdomain_of_allowlisted_internal_still_rejected() {
7535        let allow = make_allowlist(&["idp.internal"], &[]);
7536        assert!(oauth_internal_suffix_blocked("sub.idp.internal", &allow));
7537    }
7538
7539    #[test]
7540    fn cidr_allowlist_does_not_bypass_suffix_denylist() {
7541        let allow = make_allowlist(&[], &["10.0.0.0/8"]);
7542        assert!(oauth_internal_suffix_blocked("idp.internal", &allow));
7543    }
7544
7545    #[test]
7546    fn public_hostname_not_blocked_by_suffix() {
7547        let allow = crate::ssrf::CompiledSsrfAllowlist::default();
7548        assert!(!oauth_internal_suffix_blocked("idp.example.com", &allow));
7549    }
7550
7551    #[test]
7552    fn compile_oauth_ssrf_allowlist_rejects_invalid_cidr() {
7553        let raw = OAuthSsrfAllowlist {
7554            hosts: vec![],
7555            cidrs: vec!["not-a-cidr".into()],
7556        };
7557        let err = compile_oauth_ssrf_allowlist(&raw).expect_err("invalid CIDR");
7558        assert!(err.contains("oauth.ssrf_allowlist.cidrs[0]"), "got {err:?}");
7559    }
7560
7561    #[test]
7562    fn validate_rejects_misconfigured_allowlist() {
7563        let mut cfg = OAuthConfig::builder(
7564            "https://auth.example.com/",
7565            "mcp",
7566            "https://auth.example.com/jwks.json",
7567        )
7568        .build();
7569        cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7570            hosts: vec!["10.0.0.1".into()],
7571            cidrs: vec![],
7572        });
7573        let err = cfg
7574            .validate()
7575            .expect_err("literal IP host must be rejected");
7576        assert!(
7577            err.to_string().contains("oauth.ssrf_allowlist"),
7578            "got {err}"
7579        );
7580    }
7581
7582    #[tokio::test]
7583    async fn screen_oauth_target_with_allowlist_emits_helpful_error() {
7584        // localhost resolves to loopback; with a *non-empty* allowlist that
7585        // doesn't cover loopback, we expect the new verbose error referencing
7586        // the config field.
7587        let allow = make_allowlist(&["other.example.com"], &["10.0.0.0/8"]);
7588        let err = screen_oauth_target("https://localhost/jwks.json", false, &allow)
7589            .await
7590            .expect_err("loopback must still be blocked when not in allowlist");
7591        let msg = err.to_string();
7592        assert!(msg.contains("OAuth target blocked"), "got {msg:?}");
7593        assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7594        assert!(msg.contains("SECURITY.md"), "got {msg:?}");
7595    }
7596
7597    #[tokio::test]
7598    async fn screen_oauth_target_empty_allowlist_uses_legacy_message() {
7599        // The default (empty) allowlist must continue to emit the
7600        // pre-1.4.0 wording so existing operator runbooks keep working.
7601        let err = screen_oauth_target(
7602            "https://localhost/jwks.json",
7603            false,
7604            &crate::ssrf::CompiledSsrfAllowlist::default(),
7605        )
7606        .await
7607        .expect_err("loopback rejection");
7608        let msg = err.to_string();
7609        assert!(msg.contains("blocked IP"), "got {msg:?}");
7610        assert!(msg.contains("loopback"), "got {msg:?}");
7611        // The legacy message must NOT advertise the new knob.
7612        assert!(!msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7613    }
7614
7615    #[tokio::test]
7616    async fn screen_oauth_target_allows_loopback_when_host_allowlisted() {
7617        // localhost -> 127.0.0.1; allowlisting the hostname must let it through.
7618        let allow = make_allowlist(&["localhost"], &[]);
7619        screen_oauth_target("https://localhost/jwks.json", false, &allow)
7620            .await
7621            .expect("allowlisted host must pass");
7622    }
7623
7624    #[tokio::test]
7625    async fn screen_oauth_target_allows_loopback_when_cidr_allowlisted() {
7626        // localhost may resolve to 127.0.0.1 and/or ::1 depending on the OS;
7627        // allowlist both loopback ranges to make the test stable cross-platform.
7628        let allow = make_allowlist(&[], &["127.0.0.0/8", "::1/128"]);
7629        screen_oauth_target("https://localhost/jwks.json", false, &allow)
7630            .await
7631            .expect("allowlisted CIDR must pass");
7632    }
7633
7634    #[tokio::test]
7635    async fn jwks_cache_rejects_misconfigured_allowlist_at_startup() {
7636        let mut cfg = OAuthConfig::builder(
7637            "https://auth.example.com/",
7638            "mcp",
7639            "https://auth.example.com/jwks.json",
7640        )
7641        .build();
7642        cfg.ssrf_allowlist = Some(OAuthSsrfAllowlist {
7643            hosts: vec![],
7644            cidrs: vec!["bad-cidr".into()],
7645        });
7646        let Err(err) = JwksCache::new(&cfg) else {
7647            panic!("invalid CIDR must fail JwksCache::new")
7648        };
7649        let msg = err.to_string();
7650        assert!(msg.contains("oauth.ssrf_allowlist"), "got {msg:?}");
7651    }
7652
7653    #[tokio::test]
7654    async fn jwks_cache_new_invalid_ttl_is_err() {
7655        // An unvalidated config with a bogus TTL must surface as Err, not
7656        // as the formerly-documented panic.
7657        let cfg = OAuthConfig::builder(
7658            "https://auth.example.com/",
7659            "mcp",
7660            "https://auth.example.com/jwks.json",
7661        )
7662        .jwks_cache_ttl("not-a-duration")
7663        .build();
7664        let Err(err) = JwksCache::new(&cfg) else {
7665            panic!("invalid jwks_cache_ttl must fail JwksCache::new")
7666        };
7667        let msg = err.to_string();
7668        assert!(msg.contains("jwks_cache_ttl"), "got {msg:?}");
7669    }
7670
7671    #[tokio::test]
7672    async fn audience_default_is_strict() {
7673        let kid = "test-audience-azp-default";
7674        let (pem, jwks) = generate_test_keypair(kid);
7675
7676        let mock_server = wiremock::MockServer::start().await;
7677        wiremock::Mock::given(wiremock::matchers::method("GET"))
7678            .and(wiremock::matchers::path("/jwks.json"))
7679            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7680            .mount(&mock_server)
7681            .await;
7682
7683        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7684        let config = test_config(&jwks_uri);
7685        let cache = test_cache(&config);
7686
7687        let now = jsonwebtoken::get_current_timestamp();
7688        let token = mint_token_with_claims(
7689            &pem,
7690            kid,
7691            &serde_json::json!({
7692                "iss": "https://auth.test.local",
7693                "aud": "https://some-other-resource.example.com",
7694                "azp": "https://mcp.test.local/mcp",
7695                "sub": "compat-client",
7696                "scope": "mcp:read",
7697                "exp": now + 3600,
7698                "iat": now,
7699            }),
7700        );
7701
7702        let failure = cache
7703            .validate_token_with_reason(&token)
7704            .await
7705            .expect_err("the default policy is Strict and must reject an azp-only match");
7706        assert_eq!(failure, JwtValidationFailure::Invalid);
7707    }
7708
7709    #[tokio::test]
7710    async fn audience_warn_still_accepts_azp() {
7711        let kid = "test-audience-warn-optin";
7712        let (pem, jwks) = generate_test_keypair(kid);
7713
7714        let mock_server = wiremock::MockServer::start().await;
7715        wiremock::Mock::given(wiremock::matchers::method("GET"))
7716            .and(wiremock::matchers::path("/jwks.json"))
7717            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7718            .mount(&mock_server)
7719            .await;
7720
7721        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7722        let mut config = test_config(&jwks_uri);
7723        config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7724        let cache = test_cache(&config);
7725
7726        let now = jsonwebtoken::get_current_timestamp();
7727        let token = mint_token_with_claims(
7728            &pem,
7729            kid,
7730            &serde_json::json!({
7731                "iss": "https://auth.test.local",
7732                "aud": "https://some-other-resource.example.com",
7733                "azp": "https://mcp.test.local/mcp",
7734                "sub": "warn-optin-client",
7735                "scope": "mcp:read",
7736                "exp": now + 3600,
7737                "iat": now,
7738            }),
7739        );
7740
7741        cache.validate_token_with_reason(&token).await.expect(
7742            "the audience_validation_mode=warn opt-out must still accept an azp-only match",
7743        );
7744    }
7745
7746    #[tokio::test]
7747    async fn legacy_strict_false_maps_to_warn() {
7748        let kid = "test-audience-legacy-false";
7749        let (pem, jwks) = generate_test_keypair(kid);
7750
7751        let mock_server = wiremock::MockServer::start().await;
7752        wiremock::Mock::given(wiremock::matchers::method("GET"))
7753            .and(wiremock::matchers::path("/jwks.json"))
7754            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7755            .mount(&mock_server)
7756            .await;
7757
7758        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7759        let mut config = test_config(&jwks_uri);
7760        // Legacy opt-out: the deprecated bool set to Some(false) with the enum
7761        // unset must resolve to Warn, preserving the pre-3.2 azp-accepting path.
7762        #[allow(deprecated, reason = "covers the legacy bool compat mapping")]
7763        {
7764            config.strict_audience_validation = Some(false);
7765        }
7766        let cache = test_cache(&config);
7767
7768        let now = jsonwebtoken::get_current_timestamp();
7769        let token = mint_token_with_claims(
7770            &pem,
7771            kid,
7772            &serde_json::json!({
7773                "iss": "https://auth.test.local",
7774                "aud": "https://some-other-resource.example.com",
7775                "azp": "https://mcp.test.local/mcp",
7776                "sub": "legacy-false-client",
7777                "scope": "mcp:read",
7778                "exp": now + 3600,
7779                "iat": now,
7780            }),
7781        );
7782
7783        cache
7784            .validate_token_with_reason(&token)
7785            .await
7786            .expect("strict_audience_validation=Some(false) must map to Warn and accept azp");
7787    }
7788
7789    #[tokio::test]
7790    async fn aud_match_always_accepts() {
7791        let kid = "test-audience-aud-match";
7792        let (pem, jwks) = generate_test_keypair(kid);
7793
7794        let mock_server = wiremock::MockServer::start().await;
7795        wiremock::Mock::given(wiremock::matchers::method("GET"))
7796            .and(wiremock::matchers::path("/jwks.json"))
7797            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7798            .mount(&mock_server)
7799            .await;
7800
7801        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7802        let config = test_config(&jwks_uri); // Strict by default
7803        let cache = test_cache(&config);
7804
7805        let now = jsonwebtoken::get_current_timestamp();
7806        let token = mint_token_with_claims(
7807            &pem,
7808            kid,
7809            &serde_json::json!({
7810                "iss": "https://auth.test.local",
7811                "aud": "https://mcp.test.local/mcp",
7812                "sub": "aud-match-client",
7813                "scope": "mcp:read",
7814                "exp": now + 3600,
7815                "iat": now,
7816            }),
7817        );
7818
7819        cache
7820            .validate_token_with_reason(&token)
7821            .await
7822            .expect("a matching aud must be accepted even under the Strict default");
7823    }
7824
7825    #[tokio::test]
7826    async fn strict_audience_validation_rejects_azp_only_match() {
7827        let kid = "test-audience-azp-strict";
7828        let (pem, jwks) = generate_test_keypair(kid);
7829
7830        let mock_server = wiremock::MockServer::start().await;
7831        wiremock::Mock::given(wiremock::matchers::method("GET"))
7832            .and(wiremock::matchers::path("/jwks.json"))
7833            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7834            .mount(&mock_server)
7835            .await;
7836
7837        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7838        let mut config = test_config(&jwks_uri);
7839        #[allow(deprecated, reason = "covers the legacy bool resolution path")]
7840        {
7841            config.strict_audience_validation = Some(true);
7842        }
7843        let cache = test_cache(&config);
7844
7845        let now = jsonwebtoken::get_current_timestamp();
7846        let token = mint_token_with_claims(
7847            &pem,
7848            kid,
7849            &serde_json::json!({
7850                "iss": "https://auth.test.local",
7851                "aud": "https://some-other-resource.example.com",
7852                "azp": "https://mcp.test.local/mcp",
7853                "sub": "strict-client",
7854                "scope": "mcp:read",
7855                "exp": now + 3600,
7856                "iat": now,
7857            }),
7858        );
7859
7860        let failure = cache
7861            .validate_token_with_reason(&token)
7862            .await
7863            .expect_err("strict audience validation must ignore azp fallback");
7864        assert_eq!(failure, JwtValidationFailure::Invalid);
7865    }
7866
7867    #[tokio::test]
7868    async fn warn_mode_accepts_azp_only_match_and_warns_once() {
7869        let kid = "test-audience-warn-mode";
7870        let (pem, jwks) = generate_test_keypair(kid);
7871
7872        let mock_server = wiremock::MockServer::start().await;
7873        wiremock::Mock::given(wiremock::matchers::method("GET"))
7874            .and(wiremock::matchers::path("/jwks.json"))
7875            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7876            .mount(&mock_server)
7877            .await;
7878
7879        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7880        let mut config = test_config(&jwks_uri);
7881        config.audience_validation_mode = Some(AudienceValidationMode::Warn);
7882        let cache = test_cache(&config);
7883
7884        let now = jsonwebtoken::get_current_timestamp();
7885        let claims = serde_json::json!({
7886            "iss": "https://auth.test.local",
7887            "aud": "https://some-other-resource.example.com",
7888            "azp": "https://mcp.test.local/mcp",
7889            "sub": "warn-client",
7890            "scope": "mcp:read",
7891            "exp": now + 3600,
7892            "iat": now,
7893        });
7894        let token = mint_token_with_claims(&pem, kid, &claims);
7895
7896        let identity = cache
7897            .validate_token_with_reason(&token)
7898            .await
7899            .expect("warn mode must accept azp-only match");
7900        assert_eq!(identity.role, "viewer");
7901        assert!(
7902            cache.azp_fallback_warned.load(Ordering::Relaxed),
7903            "warn-once flag should be set after first azp-only match"
7904        );
7905
7906        let token2 = mint_token_with_claims(&pem, kid, &claims);
7907        cache
7908            .validate_token_with_reason(&token2)
7909            .await
7910            .expect("warn mode must continue accepting subsequent matches");
7911        assert!(
7912            cache.azp_fallback_warned.load(Ordering::Relaxed),
7913            "warn-once flag must remain set; the assertion guards against accidental clearing"
7914        );
7915    }
7916
7917    #[tokio::test]
7918    async fn permissive_mode_accepts_azp_only_match_silently() {
7919        let kid = "test-audience-permissive-mode";
7920        let (pem, jwks) = generate_test_keypair(kid);
7921
7922        let mock_server = wiremock::MockServer::start().await;
7923        wiremock::Mock::given(wiremock::matchers::method("GET"))
7924            .and(wiremock::matchers::path("/jwks.json"))
7925            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
7926            .mount(&mock_server)
7927            .await;
7928
7929        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
7930        let mut config = test_config(&jwks_uri);
7931        config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7932        let cache = test_cache(&config);
7933
7934        let now = jsonwebtoken::get_current_timestamp();
7935        let token = mint_token_with_claims(
7936            &pem,
7937            kid,
7938            &serde_json::json!({
7939                "iss": "https://auth.test.local",
7940                "aud": "https://some-other-resource.example.com",
7941                "azp": "https://mcp.test.local/mcp",
7942                "sub": "permissive-client",
7943                "scope": "mcp:read",
7944                "exp": now + 3600,
7945                "iat": now,
7946            }),
7947        );
7948
7949        cache
7950            .validate_token_with_reason(&token)
7951            .await
7952            .expect("permissive mode must accept azp-only match");
7953        assert!(
7954            !cache.azp_fallback_warned.load(Ordering::Relaxed),
7955            "permissive mode must not flip the warn-once flag"
7956        );
7957        assert!(
7958            cache.azp_permissive_logged.load(Ordering::Relaxed),
7959            "permissive mode must record its own once-per-process log flag"
7960        );
7961    }
7962
7963    #[test]
7964    fn audience_validation_mode_overrides_legacy_bool() {
7965        let mut config = OAuthConfig::default();
7966        #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7967        {
7968            config.strict_audience_validation = Some(false);
7969        }
7970        config.audience_validation_mode = Some(AudienceValidationMode::Strict);
7971        assert_eq!(
7972            config.effective_audience_validation_mode(),
7973            AudienceValidationMode::Strict,
7974            "explicit mode must override legacy false"
7975        );
7976
7977        let mut config = OAuthConfig::default();
7978        #[allow(deprecated, reason = "covers the precedence rule for the legacy bool")]
7979        {
7980            config.strict_audience_validation = Some(true);
7981        }
7982        config.audience_validation_mode = Some(AudienceValidationMode::Permissive);
7983        assert_eq!(
7984            config.effective_audience_validation_mode(),
7985            AudienceValidationMode::Permissive,
7986            "explicit mode must override legacy true"
7987        );
7988    }
7989
7990    #[test]
7991    fn audience_validation_mode_default_is_strict_when_unset() {
7992        let config = OAuthConfig::default();
7993        assert_eq!(
7994            config.effective_audience_validation_mode(),
7995            AudienceValidationMode::Strict,
7996            "unset mode + unset bool must resolve to Strict (the secure default)"
7997        );
7998    }
7999
8000    #[test]
8001    fn audience_validation_legacy_bool_true_resolves_to_strict() {
8002        let mut config = OAuthConfig::default();
8003        #[allow(deprecated, reason = "covers the legacy bool resolution path")]
8004        {
8005            config.strict_audience_validation = Some(true);
8006        }
8007        assert_eq!(
8008            config.effective_audience_validation_mode(),
8009            AudienceValidationMode::Strict,
8010            "legacy bool=true must resolve to Strict for backward compat"
8011        );
8012    }
8013
8014    #[derive(Clone, Default)]
8015    struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
8016
8017    impl CapturedLogs {
8018        fn contents(&self) -> String {
8019            let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
8020            String::from_utf8(bytes).unwrap_or_default()
8021        }
8022    }
8023
8024    struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
8025
8026    impl std::io::Write for CapturedLogsWriter {
8027        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
8028            if let Ok(mut guard) = self.0.lock() {
8029                guard.extend_from_slice(buf);
8030            }
8031            Ok(buf.len())
8032        }
8033
8034        fn flush(&mut self) -> std::io::Result<()> {
8035            Ok(())
8036        }
8037    }
8038
8039    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
8040        type Writer = CapturedLogsWriter;
8041
8042        fn make_writer(&'a self) -> Self::Writer {
8043            CapturedLogsWriter(Arc::clone(&self.0))
8044        }
8045    }
8046
8047    fn exchanged_token_for_debug(secret: &str) -> ExchangedToken {
8048        ExchangedToken {
8049            access_token: secret.to_owned(),
8050            expires_in: Some(3600),
8051            issued_token_type: Some("urn:ietf:params:oauth:token-type:access_token".to_owned()),
8052        }
8053    }
8054
8055    fn exchanged_jwt_with_sensitive_claims() -> ExchangedToken {
8056        let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
8057        let payload = URL_SAFE_NO_PAD.encode(
8058            br#"{"sub":"subject-secret","aud":["aud-secret"],"azp":"azp-secret","iss":"issuer-secret"}"#,
8059        );
8060        exchanged_token_for_debug(&format!("{header}.{payload}.signature"))
8061    }
8062
8063    #[test]
8064    fn exchanged_token_debug_redacts_access_token_by_default() {
8065        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8066        crate::diagnostics::set_diagnostic_exposure(
8067            &crate::diagnostics::DiagnosticExposure::default(),
8068        );
8069        let secret = "oauth-access-token-secret";
8070
8071        let rendered = format!("{:?}", exchanged_token_for_debug(secret));
8072
8073        assert!(rendered.contains("[REDACTED]"));
8074        assert!(
8075            !rendered.contains(secret),
8076            "Debug output must not contain plaintext access token: {rendered}"
8077        );
8078        assert!(rendered.contains("expires_in"));
8079        assert!(rendered.contains("issued_token_type"));
8080    }
8081
8082    #[test]
8083    fn exchanged_token_debug_can_show_access_token_when_enabled() {
8084        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8085        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
8086            plaintext_oauth_tokens: true,
8087            ..crate::diagnostics::DiagnosticExposure::default()
8088        });
8089        let secret = "oauth-access-token-secret";
8090
8091        let rendered = format!("{:?}", exchanged_token_for_debug(secret));
8092
8093        assert!(rendered.contains(secret));
8094    }
8095
8096    #[test]
8097    fn exchanged_token_claim_log_redacts_claim_values_by_default() {
8098        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8099        crate::diagnostics::set_diagnostic_exposure(
8100            &crate::diagnostics::DiagnosticExposure::default(),
8101        );
8102        let logs = CapturedLogs::default();
8103        let subscriber = tracing_subscriber::fmt()
8104            .with_max_level(tracing::Level::DEBUG)
8105            .with_writer(logs.clone())
8106            .with_ansi(false)
8107            .without_time()
8108            .finish();
8109        let _subscriber_guard = tracing::subscriber::set_default(subscriber);
8110
8111        log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
8112
8113        let contents = logs.contents();
8114        assert!(contents.contains("[REDACTED]"));
8115        for secret in [
8116            "subject-secret",
8117            "aud-secret",
8118            "azp-secret",
8119            "issuer-secret",
8120        ] {
8121            assert!(
8122                !contents.contains(secret),
8123                "claim log must not contain {secret}: {contents}"
8124            );
8125        }
8126        assert!(contents.contains("expires_in"));
8127    }
8128
8129    #[test]
8130    fn exchanged_token_claim_log_can_show_claim_values_when_enabled() {
8131        let _guard = crate::diagnostics::ExposureTestGuard::acquire();
8132        crate::diagnostics::set_diagnostic_exposure(&crate::diagnostics::DiagnosticExposure {
8133            oauth_claim_values: true,
8134            ..crate::diagnostics::DiagnosticExposure::default()
8135        });
8136        let logs = CapturedLogs::default();
8137        let subscriber = tracing_subscriber::fmt()
8138            .with_max_level(tracing::Level::DEBUG)
8139            .with_writer(logs.clone())
8140            .with_ansi(false)
8141            .without_time()
8142            .finish();
8143        let _subscriber_guard = tracing::subscriber::set_default(subscriber);
8144
8145        log_exchanged_token(&exchanged_jwt_with_sensitive_claims());
8146
8147        let contents = logs.contents();
8148        for secret in [
8149            "subject-secret",
8150            "aud-secret",
8151            "azp-secret",
8152            "issuer-secret",
8153        ] {
8154            assert!(
8155                contents.contains(secret),
8156                "claim log must contain {secret} when enabled: {contents}"
8157            );
8158        }
8159    }
8160
8161    #[tokio::test]
8162    async fn jwks_response_size_cap_returns_none_and_logs_warning() {
8163        let kid = "oversized-jwks";
8164        let (_pem, jwks) = generate_test_keypair(kid);
8165        let mut oversized_body = serde_json::to_string(&jwks).expect("jwks json");
8166        oversized_body.push_str(&" ".repeat(4096));
8167
8168        let mock_server = wiremock::MockServer::start().await;
8169        wiremock::Mock::given(wiremock::matchers::method("GET"))
8170            .and(wiremock::matchers::path("/jwks.json"))
8171            .respond_with(
8172                wiremock::ResponseTemplate::new(200)
8173                    .insert_header("content-type", "application/json")
8174                    .set_body_string(oversized_body),
8175            )
8176            .mount(&mock_server)
8177            .await;
8178
8179        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8180        let mut config = test_config(&jwks_uri);
8181        config.jwks_max_response_bytes = 256;
8182        let cache = test_cache(&config);
8183
8184        let logs = CapturedLogs::default();
8185        let subscriber = tracing_subscriber::fmt()
8186            .with_writer(logs.clone())
8187            .with_ansi(false)
8188            .without_time()
8189            .finish();
8190        let _guard = tracing::subscriber::set_default(subscriber);
8191
8192        let result = cache.fetch_jwks().await;
8193        assert!(result.is_none(), "oversized JWKS must be dropped");
8194        assert!(
8195            logs.contents()
8196                .contains("JWKS response exceeded configured size cap"),
8197            "expected cap-exceeded warning in logs"
8198        );
8199    }
8200
8201    /// A redirect to a userinfo-bearing target is rejected, and the
8202    /// rejection warn log must not echo the embedded credentials
8203    /// (sanitized to scheme+host+port only).
8204    #[tokio::test]
8205    async fn redirect_rejection_log_does_not_echo_credentials() {
8206        let mock_server = wiremock::MockServer::start().await;
8207        wiremock::Mock::given(wiremock::matchers::method("GET"))
8208            .and(wiremock::matchers::path("/jwks.json"))
8209            .respond_with(
8210                wiremock::ResponseTemplate::new(302)
8211                    .insert_header("location", "https://u:p@redirect-target.example/next"),
8212            )
8213            .mount(&mock_server)
8214            .await;
8215
8216        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8217        let config = test_config(&jwks_uri);
8218        let cache = test_cache(&config);
8219
8220        let logs = CapturedLogs::default();
8221        let subscriber = tracing_subscriber::fmt()
8222            .with_writer(logs.clone())
8223            .with_ansi(false)
8224            .without_time()
8225            .finish();
8226        let _guard = tracing::subscriber::set_default(subscriber);
8227
8228        let result = cache.fetch_jwks().await;
8229        assert!(result.is_none(), "rejected redirect must fail the fetch");
8230        let contents = logs.contents();
8231        assert!(
8232            contents.contains("oauth redirect rejected"),
8233            "expected redirect-rejection warning in logs: {contents}"
8234        );
8235        assert!(
8236            !contents.contains("u:p"),
8237            "rejection log must not echo userinfo credentials: {contents}"
8238        );
8239    }
8240
8241    #[tokio::test]
8242    async fn jwks_fetch_failure_log_sanitizes_url_and_reqwest_error() {
8243        let config = test_config("http://127.0.0.1:1/jwks.json?client_secret=super-secret");
8244        let cache = test_cache(&config);
8245
8246        let logs = CapturedLogs::default();
8247        let subscriber = tracing_subscriber::fmt()
8248            .with_max_level(tracing::Level::WARN)
8249            .with_writer(logs.clone())
8250            .with_ansi(false)
8251            .without_time()
8252            .finish();
8253        let _guard = tracing::subscriber::set_default(subscriber);
8254
8255        let result = cache.fetch_jwks().await;
8256        assert!(
8257            result.is_none(),
8258            "closed loopback port must fail JWKS fetch"
8259        );
8260        let contents = logs.contents();
8261        assert!(
8262            contents.contains("failed to fetch JWKS"),
8263            "JWKS failure must still be logged: {contents}"
8264        );
8265        assert!(
8266            contents.contains("uri=http://127.0.0.1:1"),
8267            "JWKS failure log must include only sanitized origin: {contents}"
8268        );
8269        for leaked in ["/jwks.json", "client_secret", "super-secret"] {
8270            assert!(
8271                !contents.contains(leaked),
8272                "JWKS failure log must not echo raw URL component {leaked}: {contents}"
8273            );
8274        }
8275    }
8276
8277    #[tokio::test]
8278    async fn role_claim_keycloak_nested_array() {
8279        let kid = "test-role-1";
8280        let (pem, jwks) = generate_test_keypair(kid);
8281
8282        let mock_server = wiremock::MockServer::start().await;
8283        wiremock::Mock::given(wiremock::matchers::method("GET"))
8284            .and(wiremock::matchers::path("/jwks.json"))
8285            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8286            .mount(&mock_server)
8287            .await;
8288
8289        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8290        let config = test_config_with_role_claim(
8291            &jwks_uri,
8292            "realm_access.roles",
8293            vec![
8294                RoleMapping {
8295                    claim_value: "mcp-admin".into(),
8296                    role: "ops".into(),
8297                },
8298                RoleMapping {
8299                    claim_value: "mcp-viewer".into(),
8300                    role: "viewer".into(),
8301                },
8302            ],
8303        );
8304        let cache = test_cache(&config);
8305
8306        let now = jsonwebtoken::get_current_timestamp();
8307        let token = mint_token_with_claims(
8308            &pem,
8309            kid,
8310            &serde_json::json!({
8311                "iss": "https://auth.test.local",
8312                "aud": "https://mcp.test.local/mcp",
8313                "sub": "keycloak-user",
8314                "exp": now + 3600,
8315                "iat": now,
8316                "realm_access": { "roles": ["uma_authorization", "mcp-admin"] }
8317            }),
8318        );
8319
8320        let id = cache
8321            .validate_token(&token)
8322            .await
8323            .expect("should authenticate");
8324        assert_eq!(id.name, "keycloak-user");
8325        assert_eq!(id.role, "ops");
8326    }
8327
8328    #[tokio::test]
8329    async fn role_claim_flat_roles_array() {
8330        let kid = "test-role-2";
8331        let (pem, jwks) = generate_test_keypair(kid);
8332
8333        let mock_server = wiremock::MockServer::start().await;
8334        wiremock::Mock::given(wiremock::matchers::method("GET"))
8335            .and(wiremock::matchers::path("/jwks.json"))
8336            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8337            .mount(&mock_server)
8338            .await;
8339
8340        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8341        let config = test_config_with_role_claim(
8342            &jwks_uri,
8343            "roles",
8344            vec![
8345                RoleMapping {
8346                    claim_value: "MCP.Admin".into(),
8347                    role: "ops".into(),
8348                },
8349                RoleMapping {
8350                    claim_value: "MCP.Reader".into(),
8351                    role: "viewer".into(),
8352                },
8353            ],
8354        );
8355        let cache = test_cache(&config);
8356
8357        let now = jsonwebtoken::get_current_timestamp();
8358        let token = mint_token_with_claims(
8359            &pem,
8360            kid,
8361            &serde_json::json!({
8362                "iss": "https://auth.test.local",
8363                "aud": "https://mcp.test.local/mcp",
8364                "sub": "azure-ad-user",
8365                "exp": now + 3600,
8366                "iat": now,
8367                "roles": ["MCP.Reader", "OtherApp.Admin"]
8368            }),
8369        );
8370
8371        let id = cache
8372            .validate_token(&token)
8373            .await
8374            .expect("should authenticate");
8375        assert_eq!(id.name, "azure-ad-user");
8376        assert_eq!(id.role, "viewer");
8377    }
8378
8379    #[tokio::test]
8380    async fn role_claim_no_matching_value_rejected() {
8381        let kid = "test-role-3";
8382        let (pem, jwks) = generate_test_keypair(kid);
8383
8384        let mock_server = wiremock::MockServer::start().await;
8385        wiremock::Mock::given(wiremock::matchers::method("GET"))
8386            .and(wiremock::matchers::path("/jwks.json"))
8387            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8388            .mount(&mock_server)
8389            .await;
8390
8391        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8392        let config = test_config_with_role_claim(
8393            &jwks_uri,
8394            "roles",
8395            vec![RoleMapping {
8396                claim_value: "mcp-admin".into(),
8397                role: "ops".into(),
8398            }],
8399        );
8400        let cache = test_cache(&config);
8401
8402        let now = jsonwebtoken::get_current_timestamp();
8403        let token = mint_token_with_claims(
8404            &pem,
8405            kid,
8406            &serde_json::json!({
8407                "iss": "https://auth.test.local",
8408                "aud": "https://mcp.test.local/mcp",
8409                "sub": "limited-user",
8410                "exp": now + 3600,
8411                "iat": now,
8412                "roles": ["some-other-role"]
8413            }),
8414        );
8415
8416        assert!(cache.validate_token(&token).await.is_none());
8417    }
8418
8419    #[tokio::test]
8420    async fn role_claim_space_separated_string() {
8421        let kid = "test-role-4";
8422        let (pem, jwks) = generate_test_keypair(kid);
8423
8424        let mock_server = wiremock::MockServer::start().await;
8425        wiremock::Mock::given(wiremock::matchers::method("GET"))
8426            .and(wiremock::matchers::path("/jwks.json"))
8427            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8428            .mount(&mock_server)
8429            .await;
8430
8431        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8432        let config = test_config_with_role_claim(
8433            &jwks_uri,
8434            "custom_scope",
8435            vec![
8436                RoleMapping {
8437                    claim_value: "write".into(),
8438                    role: "ops".into(),
8439                },
8440                RoleMapping {
8441                    claim_value: "read".into(),
8442                    role: "viewer".into(),
8443                },
8444            ],
8445        );
8446        let cache = test_cache(&config);
8447
8448        let now = jsonwebtoken::get_current_timestamp();
8449        let token = mint_token_with_claims(
8450            &pem,
8451            kid,
8452            &serde_json::json!({
8453                "iss": "https://auth.test.local",
8454                "aud": "https://mcp.test.local/mcp",
8455                "sub": "custom-client",
8456                "exp": now + 3600,
8457                "iat": now,
8458                "custom_scope": "read audit"
8459            }),
8460        );
8461
8462        let id = cache
8463            .validate_token(&token)
8464            .await
8465            .expect("should authenticate");
8466        assert_eq!(id.name, "custom-client");
8467        assert_eq!(id.role, "viewer");
8468    }
8469
8470    #[tokio::test]
8471    async fn scope_backward_compat_without_role_claim() {
8472        // Verify existing `scopes` behavior still works when role_claim is None.
8473        let kid = "test-compat-1";
8474        let (pem, jwks) = generate_test_keypair(kid);
8475
8476        let mock_server = wiremock::MockServer::start().await;
8477        wiremock::Mock::given(wiremock::matchers::method("GET"))
8478            .and(wiremock::matchers::path("/jwks.json"))
8479            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8480            .mount(&mock_server)
8481            .await;
8482
8483        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8484        let config = test_config(&jwks_uri); // role_claim: None, uses scopes
8485        let cache = test_cache(&config);
8486
8487        let token = mint_token(
8488            &pem,
8489            kid,
8490            "https://auth.test.local",
8491            "https://mcp.test.local/mcp",
8492            "legacy-bot",
8493            "mcp:admin other:scope",
8494        );
8495
8496        let id = cache
8497            .validate_token(&token)
8498            .await
8499            .expect("should authenticate");
8500        assert_eq!(id.name, "legacy-bot");
8501        assert_eq!(id.role, "ops"); // mcp:admin -> ops via scopes
8502    }
8503
8504    // -----------------------------------------------------------------------
8505    // JWKS refresh cooldown tests
8506    // -----------------------------------------------------------------------
8507
8508    #[tokio::test]
8509    async fn jwks_refresh_deduplication() {
8510        // Verify that concurrent requests with unknown kids result in exactly
8511        // one JWKS fetch, not one per request (deduplication via mutex).
8512        let kid = "test-dedup";
8513        let (pem, jwks) = generate_test_keypair(kid);
8514
8515        let mock_server = wiremock::MockServer::start().await;
8516        let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8517            .and(wiremock::matchers::path("/jwks.json"))
8518            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8519            .expect(1) // Should be called exactly once
8520            .mount(&mock_server)
8521            .await;
8522
8523        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8524        let config = test_config(&jwks_uri);
8525        let cache = Arc::new(test_cache(&config));
8526
8527        // Create 5 concurrent validation requests with the same valid token.
8528        let token = mint_token(
8529            &pem,
8530            kid,
8531            "https://auth.test.local",
8532            "https://mcp.test.local/mcp",
8533            "concurrent-bot",
8534            "mcp:read",
8535        );
8536
8537        let mut handles = Vec::new();
8538        for _ in 0..5 {
8539            let c = Arc::clone(&cache);
8540            let t = token.clone();
8541            handles.push(tokio::spawn(async move { c.validate_token(&t).await }));
8542        }
8543
8544        for h in handles {
8545            let result = h.await.unwrap();
8546            assert!(result.is_some(), "all concurrent requests should succeed");
8547        }
8548
8549        // The expect(1) assertion on the mock verifies only one fetch occurred.
8550    }
8551
8552    #[tokio::test]
8553    async fn jwks_refresh_cooldown_blocks_rapid_requests() {
8554        // Verify that rapid sequential requests with unknown kids (cache misses)
8555        // only trigger one JWKS fetch due to cooldown.
8556        let kid = "test-cooldown";
8557        let (_pem, jwks) = generate_test_keypair(kid);
8558
8559        let mock_server = wiremock::MockServer::start().await;
8560        let _mock = wiremock::Mock::given(wiremock::matchers::method("GET"))
8561            .and(wiremock::matchers::path("/jwks.json"))
8562            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&jwks))
8563            .expect(1) // Should be called exactly once despite multiple misses
8564            .mount(&mock_server)
8565            .await;
8566
8567        let jwks_uri = format!("{}/jwks.json", mock_server.uri());
8568        let config = test_config(&jwks_uri);
8569        let cache = test_cache(&config);
8570
8571        // First request with unknown kid triggers a refresh.
8572        let fake_token1 =
8573            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTEifQ.e30.sig";
8574        let _ = cache.validate_token(fake_token1).await;
8575
8576        // Second request with a different unknown kid should NOT trigger refresh
8577        // because we're within the 10-second cooldown.
8578        let fake_token2 =
8579            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTIifQ.e30.sig";
8580        let _ = cache.validate_token(fake_token2).await;
8581
8582        // Third request with yet another unknown kid - still within cooldown.
8583        let fake_token3 =
8584            "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6InVua25vd24ta2lkLTMifQ.e30.sig";
8585        let _ = cache.validate_token(fake_token3).await;
8586
8587        // The expect(1) assertion verifies only one fetch occurred.
8588    }
8589
8590    // -- introspection / revocation proxy --
8591
8592    fn proxy_cfg(token_url: &str) -> OAuthProxyConfig {
8593        OAuthProxyConfig {
8594            authorize_url: "https://example.invalid/auth".into(),
8595            token_url: token_url.into(),
8596            client_id: "mcp-client".into(),
8597            client_secret: Some(secrecy::SecretString::from("shh".to_owned())),
8598            introspection_url: None,
8599            revocation_url: None,
8600            expose_admin_endpoints: false,
8601            require_auth_on_admin_endpoints: false,
8602            allow_unauthenticated_admin_endpoints: false,
8603            strip_resource_param: false,
8604        }
8605    }
8606
8607    /// Build an HTTP client for tests. Ensures a rustls crypto provider
8608    /// is installed (normally done inside `JwksCache::new`).
8609    fn test_http_client() -> OauthHttpClient {
8610        rustls::crypto::ring::default_provider()
8611            .install_default()
8612            .ok();
8613        let config = OAuthConfig::builder(
8614            "https://auth.test.local",
8615            "https://mcp.test.local/mcp",
8616            "https://auth.test.local/.well-known/jwks.json",
8617        )
8618        .allow_http_oauth_urls(true)
8619        .build();
8620        OauthHttpClient::with_config(&config)
8621            .expect("build test http client")
8622            .__test_allow_loopback_ssrf()
8623    }
8624
8625    #[tokio::test]
8626    async fn introspect_proxies_and_injects_client_credentials() {
8627        use wiremock::matchers::{body_string_contains, method, path};
8628
8629        let mock_server = wiremock::MockServer::start().await;
8630        wiremock::Mock::given(method("POST"))
8631            .and(path("/introspect"))
8632            .and(body_string_contains("client_id=mcp-client"))
8633            .and(body_string_contains("client_secret=shh"))
8634            .and(body_string_contains("token=abc"))
8635            .respond_with(
8636                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8637                    "active": true,
8638                    "scope": "read"
8639                })),
8640            )
8641            .expect(1)
8642            .mount(&mock_server)
8643            .await;
8644
8645        let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8646        proxy.introspection_url = Some(format!("{}/introspect", mock_server.uri()));
8647
8648        let http = test_http_client();
8649        let resp = handle_introspect(&http, &proxy, "token=abc").await;
8650        assert_eq!(resp.status(), 200);
8651    }
8652
8653    #[tokio::test]
8654    async fn token_proxy_fails_closed_on_oversized_upstream_response() {
8655        use http_body_util::BodyExt as _;
8656        use wiremock::matchers::{method, path};
8657
8658        // Upstream returns a body far larger than OAUTH_PROXY_MAX_RESPONSE_BYTES.
8659        let oversized = "x"
8660            .repeat(usize::try_from(OAUTH_PROXY_MAX_RESPONSE_BYTES).unwrap_or(usize::MAX) + 4096);
8661        let mock_server = wiremock::MockServer::start().await;
8662        wiremock::Mock::given(method("POST"))
8663            .and(path("/token"))
8664            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(oversized.clone()))
8665            .expect(1)
8666            .mount(&mock_server)
8667            .await;
8668
8669        let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8670        let http = test_http_client();
8671        let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8672
8673        // Must fail closed with 502, and MUST NOT forward the oversized body.
8674        assert_eq!(
8675            resp.status(),
8676            502,
8677            "oversized upstream response must fail closed as 502"
8678        );
8679        let body = resp
8680            .into_body()
8681            .collect()
8682            .await
8683            .expect("collect body")
8684            .to_bytes();
8685        assert!(
8686            body.len() < 1024,
8687            "must return the small generic error body, not the oversized upstream body (got {} bytes)",
8688            body.len()
8689        );
8690        assert!(
8691            !body.windows(8).any(|w| w == b"xxxxxxxx"),
8692            "the oversized upstream payload must not be forwarded to the client"
8693        );
8694    }
8695
8696    #[tokio::test]
8697    async fn token_proxy_passes_through_normal_response() {
8698        use http_body_util::BodyExt as _;
8699        use wiremock::matchers::{method, path};
8700
8701        let mock_server = wiremock::MockServer::start().await;
8702        wiremock::Mock::given(method("POST"))
8703            .and(path("/token"))
8704            .respond_with(
8705                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8706                    "access_token": "at-123",
8707                    "token_type": "Bearer"
8708                })),
8709            )
8710            .expect(1)
8711            .mount(&mock_server)
8712            .await;
8713
8714        let proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8715        let http = test_http_client();
8716        let resp = handle_token(&http, &proxy, "grant_type=authorization_code&code=abc").await;
8717
8718        assert_eq!(
8719            resp.status(),
8720            200,
8721            "a normal-sized response must pass through"
8722        );
8723        let body = resp
8724            .into_body()
8725            .collect()
8726            .await
8727            .expect("collect body")
8728            .to_bytes();
8729        let json: serde_json::Value =
8730            serde_json::from_slice(&body).expect("upstream JSON preserved");
8731        assert_eq!(json["access_token"], "at-123");
8732    }
8733
8734    #[tokio::test]
8735    async fn introspect_returns_404_when_not_configured() {
8736        let proxy = proxy_cfg("https://example.invalid/token");
8737        let http = test_http_client();
8738        let resp = handle_introspect(&http, &proxy, "token=abc").await;
8739        assert_eq!(resp.status(), 404);
8740    }
8741
8742    #[tokio::test]
8743    async fn revoke_proxies_and_returns_upstream_status() {
8744        use wiremock::matchers::{method, path};
8745
8746        let mock_server = wiremock::MockServer::start().await;
8747        wiremock::Mock::given(method("POST"))
8748            .and(path("/revoke"))
8749            .respond_with(wiremock::ResponseTemplate::new(200))
8750            .expect(1)
8751            .mount(&mock_server)
8752            .await;
8753
8754        let mut proxy = proxy_cfg(&format!("{}/token", mock_server.uri()));
8755        proxy.revocation_url = Some(format!("{}/revoke", mock_server.uri()));
8756
8757        let http = test_http_client();
8758        let resp = handle_revoke(&http, &proxy, "token=abc").await;
8759        assert_eq!(resp.status(), 200);
8760    }
8761
8762    #[tokio::test]
8763    async fn revoke_returns_404_when_not_configured() {
8764        let proxy = proxy_cfg("https://example.invalid/token");
8765        let http = test_http_client();
8766        let resp = handle_revoke(&http, &proxy, "token=abc").await;
8767        assert_eq!(resp.status(), 404);
8768    }
8769
8770    #[test]
8771    fn metadata_advertises_endpoints_only_when_configured() {
8772        let mut cfg = test_config("https://auth.test.local/jwks.json");
8773        // Without proxy configured, no introspection/revocation advertised.
8774        let m = authorization_server_metadata("https://mcp.local", &cfg);
8775        assert!(m.get("introspection_endpoint").is_none());
8776        assert!(m.get("revocation_endpoint").is_none());
8777
8778        // With proxy + introspection_url but expose_admin_endpoints = false
8779        // (the secure default): endpoints MUST NOT be advertised.
8780        let mut proxy = proxy_cfg("https://upstream.local/token");
8781        proxy.introspection_url = Some("https://upstream.local/introspect".into());
8782        proxy.revocation_url = Some("https://upstream.local/revoke".into());
8783        cfg.proxy = Some(proxy);
8784        let m = authorization_server_metadata("https://mcp.local", &cfg);
8785        assert!(
8786            m.get("introspection_endpoint").is_none(),
8787            "introspection must not be advertised when expose_admin_endpoints=false"
8788        );
8789        assert!(
8790            m.get("revocation_endpoint").is_none(),
8791            "revocation must not be advertised when expose_admin_endpoints=false"
8792        );
8793
8794        // Opt in: expose_admin_endpoints = true + introspection_url only.
8795        if let Some(p) = cfg.proxy.as_mut() {
8796            p.expose_admin_endpoints = true;
8797            p.revocation_url = None;
8798        }
8799        let m = authorization_server_metadata("https://mcp.local", &cfg);
8800        assert_eq!(
8801            m["introspection_endpoint"],
8802            serde_json::Value::String("https://mcp.local/introspect".into())
8803        );
8804        assert!(m.get("revocation_endpoint").is_none());
8805
8806        // Add revocation_url.
8807        if let Some(p) = cfg.proxy.as_mut() {
8808            p.revocation_url = Some("https://upstream.local/revoke".into());
8809        }
8810        let m = authorization_server_metadata("https://mcp.local", &cfg);
8811        assert_eq!(
8812            m["revocation_endpoint"],
8813            serde_json::Value::String("https://mcp.local/revoke".into())
8814        );
8815    }
8816
8817    // ---------- M-H4: token-exchange client authentication ----------
8818
8819    fn https_cfg_with_tx(tx: TokenExchangeConfig) -> OAuthConfig {
8820        let mut cfg = validation_https_config();
8821        cfg.token_exchange = Some(tx);
8822        cfg
8823    }
8824
8825    fn tx_with(
8826        client_secret: Option<&str>,
8827        client_cert: Option<ClientCertConfig>,
8828    ) -> TokenExchangeConfig {
8829        TokenExchangeConfig::new(
8830            "https://idp.example.com/token",
8831            "client",
8832            client_secret.map(|s| secrecy::SecretString::new(s.into())),
8833            client_cert,
8834        )
8835        .with_audience("downstream")
8836    }
8837
8838    #[test]
8839    fn validate_rejects_non_uri_custom_requested_token_type() {
8840        for bad in ["acess_token", "not a uri", "urn:bad%zz:token"] {
8841            let tx = tx_with(Some("s"), None)
8842                .with_requested_token_type(RequestedTokenType::Custom(bad.to_owned()));
8843            let err = https_cfg_with_tx(tx)
8844                .validate()
8845                .expect_err("a custom token type that is not a URI must be rejected")
8846                .to_string();
8847            assert!(
8848                err.contains("requested_token_type"),
8849                "error must name the offending field for {bad:?}; got {err:?}"
8850            );
8851        }
8852    }
8853
8854    #[test]
8855    fn validate_accepts_uri_custom_requested_token_type_including_fragments() {
8856        for good in [
8857            "urn:ietf:params:oauth:token-type:saml2",
8858            "https://vendor.example/token-type",
8859            "urn:example:token#v2",
8860        ] {
8861            let tx = tx_with(Some("s"), None)
8862                .with_requested_token_type(RequestedTokenType::Custom(good.to_owned()));
8863            https_cfg_with_tx(tx).validate().unwrap_or_else(|e| {
8864                panic!(
8865                    "RFC 8693 §3 only requires a URI; {good:?} must be accepted \
8866                     (the no-fragment rule is RFC 8707's, for `resource` only): {e}"
8867                )
8868            });
8869        }
8870    }
8871
8872    #[test]
8873    fn validate_rejects_empty_optional_token_exchange_params() {
8874        let base = || tx_with(Some("s"), None);
8875        let cases = [
8876            (base().with_audience(""), "audience"),
8877            (base().with_resource(""), "resource"),
8878            (base().with_scope(""), "scope"),
8879            (
8880                base().with_requested_token_type(RequestedTokenType::Custom(String::new())),
8881                "requested_token_type",
8882            ),
8883        ];
8884        for (tx, field) in cases {
8885            let cfg = https_cfg_with_tx(tx);
8886            let err = cfg
8887                .validate()
8888                .expect_err("an empty optional parameter must be rejected");
8889            let msg = err.to_string();
8890            assert!(
8891                msg.contains(field) && msg.contains("must not be empty"),
8892                "error must name {field} and explain emptiness; got {msg:?}"
8893            );
8894        }
8895    }
8896
8897    #[test]
8898    fn validate_rejects_non_conformant_resource_uri() {
8899        for (value, expected) in [
8900            ("not-an-absolute-uri", "absolute URI"),
8901            ("https://api.example.com/v1#frag", "fragment"),
8902            ("https://api.example.com/a b", "valid URI characters"),
8903            ("https://api.example.com/%zz", "valid URI characters"),
8904            ("https://api.example.com/\u{e9}", "valid URI characters"),
8905        ] {
8906            let cfg = https_cfg_with_tx(tx_with(Some("s"), None).with_resource(value));
8907            let err = cfg
8908                .validate()
8909                .expect_err("resource must satisfy RFC 8707 §2");
8910            let msg = err.to_string();
8911            assert!(
8912                msg.contains(expected),
8913                "error for {value:?} must mention {expected:?}; got {msg:?}"
8914            );
8915        }
8916    }
8917
8918    #[test]
8919    fn validate_accepts_token_exchange_with_all_optional_params_omitted() {
8920        let mut tx = tx_with(Some("s"), None);
8921        tx.audience = None;
8922        tx.requested_token_type = RequestedTokenType::Omit;
8923        https_cfg_with_tx(tx)
8924            .validate()
8925            .expect("omitting every RFC 8693 §2.1 OPTIONAL parameter must be valid");
8926    }
8927
8928    #[test]
8929    fn validate_rejects_token_exchange_without_client_auth() {
8930        let cfg = https_cfg_with_tx(tx_with(None, None));
8931        let err = cfg
8932            .validate()
8933            .expect_err("token_exchange without client auth must be rejected");
8934        let msg = err.to_string();
8935        assert!(
8936            msg.contains("requires client authentication"),
8937            "error must explain missing client auth; got {msg:?}"
8938        );
8939    }
8940
8941    #[test]
8942    fn validate_rejects_token_exchange_with_both_secret_and_cert() {
8943        let cc = ClientCertConfig {
8944            cert_path: PathBuf::from("/nonexistent/cert.pem"),
8945            key_path: PathBuf::from("/nonexistent/key.pem"),
8946        };
8947        let cfg = https_cfg_with_tx(tx_with(Some("s"), Some(cc)));
8948        let err = cfg
8949            .validate()
8950            .expect_err("client_secret + client_cert must be rejected");
8951        let msg = err.to_string();
8952        assert!(
8953            msg.contains("mutually") && msg.contains("exclusive"),
8954            "error must explain mutual exclusion; got {msg:?}"
8955        );
8956    }
8957
8958    #[cfg(not(feature = "oauth-mtls-client"))]
8959    #[test]
8960    fn validate_rejects_client_cert_without_feature() {
8961        let cc = ClientCertConfig {
8962            cert_path: PathBuf::from("/nonexistent/cert.pem"),
8963            key_path: PathBuf::from("/nonexistent/key.pem"),
8964        };
8965        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8966        let err = cfg
8967            .validate()
8968            .expect_err("client_cert without feature must be rejected");
8969        assert!(
8970            err.to_string().contains("oauth-mtls-client"),
8971            "error must reference the cargo feature; got {err}"
8972        );
8973    }
8974
8975    #[cfg(feature = "oauth-mtls-client")]
8976    #[test]
8977    fn validate_rejects_missing_client_cert_files() {
8978        let cc = ClientCertConfig {
8979            cert_path: PathBuf::from("/nonexistent/cert.pem"),
8980            key_path: PathBuf::from("/nonexistent/key.pem"),
8981        };
8982        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
8983        let err = cfg
8984            .validate()
8985            .expect_err("missing cert file must be rejected");
8986        assert!(
8987            err.to_string().contains("unreadable"),
8988            "error must call out unreadable file; got {err}"
8989        );
8990    }
8991
8992    #[cfg(feature = "oauth-mtls-client")]
8993    #[test]
8994    fn validate_rejects_malformed_client_cert_pem() {
8995        let dir = std::env::temp_dir();
8996        let cert = dir.join(format!("rmcp-mtls-bad-cert-{}.pem", std::process::id()));
8997        let key = dir.join(format!("rmcp-mtls-bad-key-{}.pem", std::process::id()));
8998        std::fs::write(&cert, b"not a real PEM").expect("write tmp cert");
8999        std::fs::write(&key, b"not a real PEM either").expect("write tmp key");
9000        let cc = ClientCertConfig {
9001            cert_path: cert.clone(),
9002            key_path: key.clone(),
9003        };
9004        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
9005        let err = cfg.validate().expect_err("malformed PEM must be rejected");
9006        let _ = std::fs::remove_file(&cert);
9007        let _ = std::fs::remove_file(&key);
9008        assert!(
9009            err.to_string().contains("PEM parse failed"),
9010            "error must call out PEM parse failure; got {err}"
9011        );
9012    }
9013
9014    #[cfg(feature = "oauth-mtls-client")]
9015    fn write_self_signed_pem() -> (PathBuf, PathBuf) {
9016        let cert = rcgen::generate_simple_self_signed(vec!["client.test".into()]).expect("rcgen");
9017        let dir = std::env::temp_dir();
9018        let pid = std::process::id();
9019        let nonce: u64 = rand::random();
9020        let cert_path = dir.join(format!("rmcp-mtls-cert-{pid}-{nonce}.pem"));
9021        let key_path = dir.join(format!("rmcp-mtls-key-{pid}-{nonce}.pem"));
9022        std::fs::write(&cert_path, cert.cert.pem()).expect("write cert");
9023        std::fs::write(&key_path, cert.signing_key.serialize_pem()).expect("write key");
9024        (cert_path, key_path)
9025    }
9026
9027    #[cfg(feature = "oauth-mtls-client")]
9028    fn install_test_crypto_provider() {
9029        let _ = rustls::crypto::ring::default_provider().install_default();
9030    }
9031
9032    #[cfg(feature = "oauth-mtls-client")]
9033    #[test]
9034    fn validate_accepts_well_formed_client_cert() {
9035        install_test_crypto_provider();
9036        let (cert_path, key_path) = write_self_signed_pem();
9037        let cc = ClientCertConfig {
9038            cert_path: cert_path.clone(),
9039            key_path: key_path.clone(),
9040        };
9041        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
9042        let res = cfg.validate();
9043        let _ = std::fs::remove_file(&cert_path);
9044        let _ = std::fs::remove_file(&key_path);
9045        res.expect("well-formed cert+key must validate");
9046    }
9047
9048    #[cfg(feature = "oauth-mtls-client")]
9049    #[test]
9050    fn client_for_returns_cached_mtls_client() {
9051        install_test_crypto_provider();
9052        let (cert_path, key_path) = write_self_signed_pem();
9053        let cc = ClientCertConfig {
9054            cert_path: cert_path.clone(),
9055            key_path: key_path.clone(),
9056        };
9057        let cfg = https_cfg_with_tx(tx_with(None, Some(cc)));
9058        let http = OauthHttpClient::with_config(&cfg).expect("build mtls client");
9059        let tx_ref = cfg.token_exchange.as_ref().expect("tx set");
9060        let cert_client = http.client_for(tx_ref);
9061        let inner_client = http.client_for(&tx_with(Some("s"), None));
9062        let _ = std::fs::remove_file(&cert_path);
9063        let _ = std::fs::remove_file(&key_path);
9064        assert!(
9065            !std::ptr::eq(cert_client, inner_client),
9066            "client_for must return distinct clients for cert vs no-cert configs"
9067        );
9068    }
9069
9070    #[cfg(feature = "oauth-mtls-client")]
9071    #[test]
9072    fn client_for_falls_back_to_inner_when_cache_miss() {
9073        install_test_crypto_provider();
9074        let cfg = validation_https_config();
9075        let http = OauthHttpClient::with_config(&cfg).expect("build client");
9076        let unrelated_cc = ClientCertConfig {
9077            cert_path: PathBuf::from("/cache/miss/cert.pem"),
9078            key_path: PathBuf::from("/cache/miss/key.pem"),
9079        };
9080        let tx_unknown = tx_with(None, Some(unrelated_cc));
9081        let fallback = http.client_for(&tx_unknown);
9082        let inner = http.client_for(&tx_with(Some("s"), None));
9083        assert!(
9084            std::ptr::eq(fallback, inner),
9085            "cache miss must fall back to inner client"
9086        );
9087    }
9088}