Skip to main content

soma_auth/cimd/
document.rs

1//! Fetch, validate, and cache OAuth Client ID Metadata Documents (CIMD).
2//!
3//! Split into independently testable layers:
4//! 1. [`ssrf::validate_url_shape`] — static URL checks, no network (tested
5//!    in isolation in `cimd::ssrf`).
6//! 2. [`resolve_and_validate_address`] — real DNS resolution, bounded by a
7//!    timeout, rejecting the whole resolved-address set if any address is
8//!    private. Tested with literal loopback/private hostnames — no real
9//!    network access needed to prove a *rejection*; a real "successful
10//!    public resolution" is not unit-tested here (see the plan's Global
11//!    Constraints for why: no network access in CI).
12//! 3. `fetch_via_pinned_address` / `fetch_document_at` — given an
13//!    ALREADY resolved+validated address, builds a pinned/no-proxy/
14//!    no-redirect client and does the GET + peer-recheck + streaming-cap +
15//!    parse + validate. Tested against a local `wiremock` server by
16//!    pointing the pin directly at its real bound address — this
17//!    deliberately bypasses DNS resolution (same as production code does
18//!    once step 2 has already resolved+validated an address), so it needs
19//!    no network and no HTTPS certificate.
20//!
21//! [`fetch_and_validate_client_metadata`] composes all three for the real
22//! production path, with per-key single-flight coordination and a short
23//! negative-result cooldown for cached failures — the mechanism mirrors
24//! `crate::upstream::cache::OauthClientCache`'s `build_locks` pattern, but
25//! unlike that cache's `(upstream_name, subject)` key (bounded by operator
26//! config and authenticated sessions, not attacker-controlled), `client_id`
27//! here is an anonymous, attacker-controlled URL — see [`DocumentCache`]'s
28//! own doc for how that difference is handled.
29
30use std::net::{IpAddr, SocketAddr};
31use std::sync::Arc;
32use std::time::{Duration, Instant};
33
34use dashmap::DashMap;
35use serde::Deserialize;
36use tokio::sync::Mutex;
37use tracing::warn;
38
39use crate::cimd::ssrf;
40
41/// Maximum response body size accepted from a CIMD fetch, enforced via a
42/// running counter WHILE STREAMING (never buffer-then-check — a hostile
43/// server can otherwise force unbounded memory use regardless of this
44/// constant).
45const MAX_DOCUMENT_BYTES: usize = 64 * 1024;
46
47/// Fetch timeout for CIMD document requests, applied to the HTTP client
48/// AFTER DNS resolution has already completed (see `DNS_TIMEOUT` for the
49/// separate bound on resolution itself). Matches this crate's existing
50/// precedent for a request an interactive caller is actively waiting on —
51/// `google.rs::GOOGLE_JWKS_FETCH_TIMEOUT` also uses 5s, so that a slow
52/// upstream response can't stall the request past what a caller on
53/// `/authorize` will tolerate.
54const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
55
56/// Timeout for the DNS resolution step, bounded separately from
57/// `FETCH_TIMEOUT` because `tokio::net::lookup_host` has no timeout of its
58/// own — it delegates to the OS resolver, whose worst-case latency is
59/// governed by `/etc/resolv.conf`/systemd-resolved settings, not by
60/// anything in this code.
61const DNS_TIMEOUT: Duration = Duration::from_secs(3);
62
63/// Cache TTL for a successfully fetched and validated document.
64const CACHE_TTL: Duration = Duration::from_secs(300);
65
66/// Cache TTL for a *failed* fetch/validation attempt. Short — long enough
67/// to blunt a burst of retries against a hostile or broken `client_id`
68/// without permanently poisoning a transiently-unreachable legitimate one.
69const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(60);
70
71/// Hard cap on distinct cached URLs. `client_id` cardinality is
72/// attacker-controlled (any public HTTPS server counts), not
73/// traffic-volume-controlled, so this cannot be sized by "realistic
74/// legitimate usage" — it exists specifically to bound the memory an
75/// adversary can force this map to hold.
76const MAX_CACHE_ENTRIES: usize = 10_000;
77
78#[derive(Debug, Clone, Deserialize)]
79pub struct ClientMetadataDocument {
80    pub client_id: String,
81    pub client_name: String,
82    #[serde(default)]
83    pub redirect_uris: Vec<String>,
84}
85
86#[derive(Debug, Clone, thiserror::Error)]
87pub enum CimdError {
88    #[error(transparent)]
89    Ssrf(#[from] ssrf::SsrfError),
90    /// A genuine DNS lookup failure (NXDOMAIN, resolver timeout, network
91    /// unreachable) — an operational problem, NOT a security event. Kept
92    /// distinct from [`Self::DnsBlocked`] so logs/callers can tell a
93    /// mistyped hostname apart from an actual SSRF attempt.
94    #[error("dns resolution failed for `{0}`: {1}")]
95    DnsResolutionFailed(String, String),
96    /// DNS resolution succeeded but at least one resolved address was
97    /// private/loopback/link-local/etc — the whole result is rejected
98    /// rather than falling back to a public address in the same set,
99    /// since a hostname resolving to a mix of public and private
100    /// addresses is itself a signal worth treating as untrusted.
101    #[error(
102        "`{0}` resolved to at least one private/loopback/link-local address; blocked to prevent SSRF"
103    )]
104    DnsBlocked(String),
105    #[error("fetch failed: {0}")]
106    Fetch(String),
107    /// The actual TCP peer the response came from did not match the
108    /// address this fetch was pinned to. This is the post-connect
109    /// TOCTOU/proxy-interception backstop — see `fetch_document_at`.
110    #[error(
111        "peer address {actual} did not match the validated address {expected}; possible proxy interception or DNS-rebinding attempt"
112    )]
113    PeerMismatch {
114        expected: SocketAddr,
115        actual: SocketAddr,
116    },
117    #[error("invalid client metadata document: {0}")]
118    InvalidDocument(String),
119    #[error(
120        "client metadata document client_id `{document_client_id}` does not match the requested URL `{requested_url}`"
121    )]
122    ClientIdMismatch {
123        document_client_id: String,
124        requested_url: String,
125    },
126}
127
128impl CimdError {
129    /// Stable kind string for structured logging. Deliberately NOT surfaced
130    /// verbatim (via `Display`/`to_string()`) to the anonymous `/authorize`
131    /// caller — see `authorize::resolve_client_redirect_uris`, which logs
132    /// the full error server-side via this `kind()` plus `Display` but
133    /// returns only a generic message in the HTTP response.
134    /// A detailed message returned to an unauthenticated caller lets them
135    /// distinguish "resolves internally" from "doesn't exist" from
136    /// "resolves publicly but unreachable," which is a network-topology
137    /// mapping oracle.
138    #[must_use]
139    pub fn kind(&self) -> &'static str {
140        match self {
141            Self::Ssrf(e) => e.kind(),
142            Self::DnsResolutionFailed(..) => "dns_resolution_failed",
143            Self::DnsBlocked(_) => "ssrf_blocked",
144            Self::Fetch(_) => "cimd_fetch_failed",
145            Self::PeerMismatch { .. } => "ssrf_blocked",
146            Self::InvalidDocument(_) => "invalid_client_metadata",
147            Self::ClientIdMismatch { .. } => "invalid_client_metadata",
148        }
149    }
150}
151
152/// Cheap detection heuristic: a CIMD `client_id` is an `https://` URL.
153/// soma-auth's own DCR-issued `client_id`s are opaque base64url tokens
154/// (`random_token(18)` in `authorize::register_client`) and can never start
155/// with `https://`.
156#[must_use]
157pub fn is_cimd_client_id(client_id: &str) -> bool {
158    client_id.starts_with("https://")
159}
160
161/// Resolve `host:port` via DNS (bounded by `DNS_TIMEOUT`) and return the
162/// first resolved address, rejecting the *entire* result set if *any*
163/// resolved address is private/loopback/etc — a hostname resolving to a
164/// mix of public and private addresses is treated as untrusted outright
165/// rather than cherry-picking a public one, since DNS load-balancing could
166/// non-deterministically prefer the private one on a subsequent lookup
167/// even though this specific call pins one address.
168///
169/// # Errors
170/// Returns [`CimdError::DnsResolutionFailed`] on timeout/lookup failure or
171/// an empty result set, and [`CimdError::DnsBlocked`] if any resolved
172/// address is private.
173pub async fn resolve_and_validate_address(host: &str, port: u16) -> Result<SocketAddr, CimdError> {
174    let lookup = tokio::time::timeout(DNS_TIMEOUT, tokio::net::lookup_host((host, port)))
175        .await
176        .map_err(|_| {
177            CimdError::DnsResolutionFailed(
178                host.to_string(),
179                format!("timed out after {DNS_TIMEOUT:?}"),
180            )
181        })?
182        .map_err(|e| CimdError::DnsResolutionFailed(host.to_string(), e.to_string()))?;
183    let addrs: Vec<SocketAddr> = lookup.collect();
184    if addrs.is_empty() {
185        return Err(CimdError::DnsResolutionFailed(
186            host.to_string(),
187            "resolved to no addresses".to_string(),
188        ));
189    }
190    if addrs
191        .iter()
192        .any(|addr| ssrf::check_ip_not_private(addr.ip(), host).is_err())
193    {
194        return Err(CimdError::DnsBlocked(host.to_string()));
195    }
196    Ok(addrs[0])
197}
198
199/// Given an already resolved+validated `addr`, build a pinned, no-proxy,
200/// no-redirect `reqwest::Client` and run the guarded fetch. This is the
201/// test seam: tests call it directly with a local `wiremock` server's real
202/// bound address, entirely bypassing DNS resolution — exactly what
203/// production code does once [`resolve_and_validate_address`] (or the
204/// IP-literal branch in [`fetch_and_validate_client_metadata`]) has already
205/// produced a validated `addr`.
206///
207/// The pin host is derived from `url` itself (not taken as a separate
208/// parameter) so it can never diverge from the host `.resolve()` needs to
209/// intercept — a caller-supplied `host` that didn't match `url`'s real host
210/// would silently defeat the pin with no compiler or runtime signal.
211///
212/// # Errors
213/// Propagates [`CimdError`] from URL parsing, client construction, or
214/// [`fetch_document_at`].
215pub(crate) async fn fetch_via_pinned_address(
216    url: &str,
217    addr: SocketAddr,
218) -> Result<ClientMetadataDocument, CimdError> {
219    let parsed =
220        url::Url::parse(url).map_err(|e| CimdError::Fetch(format!("parse `{url}`: {e}")))?;
221    let host = parsed
222        .host_str()
223        .ok_or_else(|| CimdError::Fetch(format!("no host in `{url}`")))?;
224    let client = reqwest::Client::builder()
225        .resolve(host, addr)
226        // Without this, an ambient HTTPS_PROXY/ALL_PROXY env var makes
227        // reqwest connect to a proxy that resolves `host` ITSELF, silently
228        // discarding the `.resolve()` pin above and reopening the exact
229        // DNS-rebinding window this whole module exists to close.
230        .no_proxy()
231        // A redirect would fetch a URL other than `url`, which
232        // `fetch_document_at`'s exact-match check couldn't validate
233        // against `client_id` — treat any 3xx as a hard failure instead
234        // of following it.
235        .redirect(reqwest::redirect::Policy::none())
236        .timeout(FETCH_TIMEOUT)
237        .build()
238        .map_err(|e| CimdError::Fetch(format!("build pinned client for `{url}`: {e}")))?;
239    fetch_document_at(&client, url, addr).await
240}
241
242/// Fetch and validate a CIMD document at `url` using an already
243/// address-pinned `client`. Does NOT perform DNS resolution or SSRF
244/// filtering itself — that is [`resolve_and_validate_address`]'s job. Does,
245/// however, re-validate the actual TCP peer the response came from against
246/// `pinned_addr` — this closes the gap a bare `.resolve()` pin leaves open
247/// if a proxy intercepted the connection despite `.no_proxy()`, or if the
248/// pin's `host` key ever diverges from the authority host reqwest derives
249/// when re-parsing `url` internally.
250///
251/// # Errors
252/// Returns [`CimdError::Fetch`] on transport/HTTP failure or a non-success
253/// status, [`CimdError::PeerMismatch`] if the connected peer doesn't match
254/// `pinned_addr`, [`CimdError::InvalidDocument`] on an oversized body,
255/// malformed JSON, or missing/empty required fields, and
256/// [`CimdError::ClientIdMismatch`] when the document's `client_id` does not
257/// equal `url` exactly.
258pub(crate) async fn fetch_document_at(
259    client: &reqwest::Client,
260    url: &str,
261    pinned_addr: SocketAddr,
262) -> Result<ClientMetadataDocument, CimdError> {
263    let mut response = client
264        .get(url)
265        .send()
266        .await
267        .map_err(|e| CimdError::Fetch(format!("GET `{url}`: {e}")))?;
268
269    // No `check_ip_not_private` call on `peer` here: `pinned_addr` is
270    // guaranteed non-private by the caller before it ever reaches this
271    // function (either `resolve_and_validate_address`'s DNS-resolved
272    // result, or an IP-literal host that already passed
273    // `ssrf::validate_url_shape`'s own `check_ip_not_private` call). Once
274    // `peer == pinned_addr` holds, re-running the private-range check on
275    // `peer` would be redundant by construction — and would incorrectly
276    // reject every test that pins directly at a local `wiremock` server,
277    // which is the deliberate test seam this function's callers rely on.
278    //
279    // A missing `remote_addr()` fails CLOSED, not open: this peer-recheck is
280    // the load-bearing TOCTOU/DNS-rebinding backstop, so "peer unknowable"
281    // must never be treated as "peer trusted."
282    match response.remote_addr() {
283        Some(peer) if peer == pinned_addr => {}
284        Some(peer) => {
285            return Err(CimdError::PeerMismatch {
286                expected: pinned_addr,
287                actual: peer,
288            });
289        }
290        None => {
291            return Err(CimdError::Fetch(format!(
292                "no remote peer address available for `{url}`; refusing to trust an unverified connection"
293            )));
294        }
295    }
296
297    if !response.status().is_success() {
298        return Err(CimdError::Fetch(format!(
299            "GET `{url}` returned HTTP {}",
300            response.status()
301        )));
302    }
303
304    let mut buf: Vec<u8> = Vec::new();
305    while let Some(chunk) = response
306        .chunk()
307        .await
308        .map_err(|e| CimdError::Fetch(format!("read body from `{url}`: {e}")))?
309    {
310        buf.extend_from_slice(&chunk);
311        if buf.len() > MAX_DOCUMENT_BYTES {
312            return Err(CimdError::InvalidDocument(format!(
313                "document at `{url}` exceeds the {MAX_DOCUMENT_BYTES}-byte limit"
314            )));
315        }
316    }
317
318    let document: ClientMetadataDocument = serde_json::from_slice(&buf).map_err(|e| {
319        CimdError::InvalidDocument(format!("document at `{url}` is not valid JSON: {e}"))
320    })?;
321    if document.client_id.is_empty() || document.client_name.is_empty() {
322        return Err(CimdError::InvalidDocument(format!(
323            "document at `{url}` is missing required client_id or client_name"
324        )));
325    }
326    if document.redirect_uris.is_empty() {
327        return Err(CimdError::InvalidDocument(format!(
328            "document at `{url}` declares no redirect_uris"
329        )));
330    }
331    if document.client_id != url {
332        return Err(CimdError::ClientIdMismatch {
333            document_client_id: document.client_id,
334            requested_url: url.to_string(),
335        });
336    }
337    Ok(document)
338}
339
340struct CacheEntry {
341    /// The original `CimdError` is cached directly (it's already `Clone`) so
342    /// a cache hit reports the same `kind()` a cache miss would have — e.g. a
343    /// negatively-cached SSRF block must keep logging `kind="ssrf_blocked"`,
344    /// not a generic fetch-failure tag, for the entire `NEGATIVE_CACHE_TTL`
345    /// window an attacker's repeat requests spend being served from here.
346    result: Result<ClientMetadataDocument, CimdError>,
347    fetched_at: Instant,
348    ttl: Duration,
349}
350
351/// Single-flight, TTL-and-negative-cached store for fetched CIMD documents,
352/// keyed by the requested URL. Mirrors
353/// `crate::upstream::cache::OauthClientCache`'s `build_locks` pattern:
354/// concurrent callers for the same never-cached (or just-expired) URL
355/// serialize on a per-key lock so only one of them actually performs the
356/// DNS resolution + fetch; the rest wait for and reuse that result.
357///
358/// Unlike `OauthClientCache` (keyed by `(upstream_name, subject)`, a
359/// cardinality bounded by operator config and authenticated sessions),
360/// `build_locks` here is keyed by an anonymous, attacker-controlled `url`
361/// string — so it uses the same `MAX_CACHE_ENTRIES` threshold `entries`
362/// does, sweeping out locks nobody currently holds (`Arc::strong_count ==
363/// 1` means only this map references it) whenever a new key is requested
364/// at capacity. This is a softer guarantee than `entries`' hard cap: if
365/// every held lock is genuinely busy (an in-flight fetch), a new key still
366/// gets inserted, so sustained full-concurrency load across
367/// `MAX_CACHE_ENTRIES`+ distinct URLs can transiently exceed the
368/// threshold — self-correcting as those fetches complete and free their
369/// locks, unlike the original unbounded-forever growth this replaced.
370pub struct DocumentCache {
371    entries: DashMap<String, CacheEntry>,
372    build_locks: DashMap<String, Arc<Mutex<()>>>,
373}
374
375impl DocumentCache {
376    #[must_use]
377    pub fn new() -> Self {
378        Self {
379            entries: DashMap::new(),
380            build_locks: DashMap::new(),
381        }
382    }
383
384    fn get_fresh(&self, url: &str) -> Option<Result<ClientMetadataDocument, CimdError>> {
385        let entry = self.entries.get(url)?;
386        if entry.fetched_at.elapsed() >= entry.ttl {
387            return None;
388        }
389        Some(entry.result.clone())
390    }
391
392    /// Acquire (creating if absent) the per-URL single-flight lock, sweeping
393    /// out idle locks first if the map is at capacity. A lock is idle iff
394    /// this map is the only owner (`strong_count == 1`) — an in-flight
395    /// fetch holds a second clone for the duration of its lookup, so a swept
396    /// lock can never be one another task is actively waiting on.
397    fn lock_for(&self, url: String) -> Arc<Mutex<()>> {
398        if self.build_locks.len() >= MAX_CACHE_ENTRIES {
399            self.build_locks
400                .retain(|_, lock| Arc::strong_count(lock) > 1);
401        }
402        self.build_locks
403            .entry(url)
404            .or_insert_with(|| Arc::new(Mutex::new(())))
405            .clone()
406    }
407
408    fn insert(
409        &self,
410        url: String,
411        result: &Result<ClientMetadataDocument, CimdError>,
412        ttl: Duration,
413    ) {
414        if self.entries.len() >= MAX_CACHE_ENTRIES {
415            self.entries.retain(|_, e| e.fetched_at.elapsed() < e.ttl);
416            if self.entries.len() >= MAX_CACHE_ENTRIES {
417                // Every entry is still fresh — there's nothing expired to
418                // evict. Skip caching this result rather than growing past
419                // the cap: the fetch itself already succeeded/failed
420                // correctly, this only forgoes memoizing it.
421                warn!(
422                    cache_size = self.entries.len(),
423                    "CIMD document cache at capacity with no expired entries to evict; not caching this result"
424                );
425                return;
426            }
427        }
428        self.entries.insert(
429            url,
430            CacheEntry {
431                result: result.clone(),
432                fetched_at: Instant::now(),
433                ttl,
434            },
435        );
436    }
437}
438
439impl Default for DocumentCache {
440    fn default() -> Self {
441        Self::new()
442    }
443}
444
445/// Production entry point: single-flight-locked cache lookup (including a
446/// short negative-result cooldown for cached failures), else SSRF-validate
447/// the URL shape, resolve+validate DNS (for domain hosts) or use the
448/// already-validated IP literal directly, fetch via
449/// `fetch_via_pinned_address`, and cache the result either way.
450///
451/// # Errors
452/// Propagates [`CimdError`] from any of the composed validation/fetch
453/// steps.
454pub async fn fetch_and_validate_client_metadata(
455    cache: &DocumentCache,
456    url: &str,
457) -> Result<ClientMetadataDocument, CimdError> {
458    if let Some(cached) = cache.get_fresh(url) {
459        return cached;
460    }
461
462    // Validate shape BEFORE creating a build_locks entry: a malformed or
463    // non-https client_id is rejected for free, without ever occupying a
464    // permanent slot in the lock map — closing off the cheapest version of
465    // the unbounded-growth attack (garbage URLs that never even reach the
466    // network layer).
467    let parsed = ssrf::validate_url_shape(url)?;
468
469    let lock = cache.lock_for(url.to_string());
470    let _guard = lock.lock().await;
471
472    // Re-check after acquiring the lock: another caller may have finished
473    // fetching (successfully or not) while we were waiting.
474    if let Some(cached) = cache.get_fresh(url) {
475        return cached;
476    }
477
478    let result: Result<ClientMetadataDocument, CimdError> = async {
479        let host = parsed
480            .host_str()
481            .ok_or_else(|| CimdError::DnsResolutionFailed(url.to_string(), "no host".to_string()))?
482            .to_string();
483        let port = parsed.port_or_known_default().unwrap_or(443);
484
485        let addr = match parsed.host() {
486            Some(url::Host::Domain(_)) => resolve_and_validate_address(&host, port).await?,
487            // IP-literal hosts already passed check_ip_not_private inside
488            // validate_url_shape; no DNS step needed.
489            Some(url::Host::Ipv4(ip)) => SocketAddr::new(IpAddr::V4(ip), port),
490            Some(url::Host::Ipv6(ip)) => SocketAddr::new(IpAddr::V6(ip), port),
491            None => unreachable!("validate_url_shape guarantees a host"),
492        };
493
494        fetch_via_pinned_address(url, addr).await
495    }
496    .await;
497
498    let ttl = if result.is_ok() {
499        CACHE_TTL
500    } else {
501        NEGATIVE_CACHE_TTL
502    };
503    cache.insert(url.to_string(), &result, ttl);
504    result
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use wiremock::matchers::{method, path};
511    use wiremock::{Mock, MockServer, ResponseTemplate};
512
513    #[test]
514    fn is_cimd_client_id_detects_https_urls_only() {
515        assert!(is_cimd_client_id(
516            "https://app.example.com/oauth/client-metadata.json"
517        ));
518        assert!(!is_cimd_client_id("abcDEF123opaque-token"));
519        assert!(!is_cimd_client_id("http://app.example.com/client.json"));
520    }
521
522    #[tokio::test]
523    async fn resolve_and_validate_address_rejects_loopback_host() {
524        let err = resolve_and_validate_address("localhost", 443)
525            .await
526            .unwrap_err();
527        assert_eq!(err.kind(), "ssrf_blocked");
528    }
529
530    #[tokio::test]
531    async fn resolve_and_validate_address_reports_dns_failure_distinctly_from_ssrf_block() {
532        // A hostname under a reserved-for-documentation TLD that will not
533        // resolve is a genuine lookup failure, not an SSRF block -- the
534        // `kind()` must distinguish the two so operators aren't misled
535        // into thinking a typo is an attack.
536        let err = resolve_and_validate_address("definitely-does-not-exist.invalid", 443)
537            .await
538            .unwrap_err();
539        assert_eq!(err.kind(), "dns_resolution_failed");
540    }
541
542    #[tokio::test]
543    async fn fetch_via_pinned_address_succeeds_for_matching_client_id() {
544        let server = MockServer::start().await;
545        let addr = server.address();
546        let url = format!("{}/client.json", server.uri());
547        Mock::given(method("GET"))
548            .and(path("/client.json"))
549            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
550                "client_id": url,
551                "client_name": "Example MCP Client",
552                "redirect_uris": ["http://127.0.0.1:3000/callback"],
553            })))
554            .mount(&server)
555            .await;
556
557        let document = fetch_via_pinned_address(&url, *addr)
558            .await
559            .expect("fetch ok");
560        assert_eq!(document.client_id, url);
561        assert_eq!(document.client_name, "Example MCP Client");
562        assert_eq!(
563            document.redirect_uris,
564            vec!["http://127.0.0.1:3000/callback"]
565        );
566    }
567
568    #[tokio::test]
569    async fn fetch_document_at_rejects_peer_mismatch() {
570        // Simulates what would happen if the pin's target address ever
571        // diverged from the actual connected peer (proxy interception,
572        // resolve()-key mismatch): even though the client genuinely
573        // connects to the real mock server, passing a WRONG `pinned_addr`
574        // must be rejected rather than silently trusted.
575        let server = MockServer::start().await;
576        let real_addr = *server.address();
577        let url = format!("{}/client.json", server.uri());
578        Mock::given(method("GET"))
579            .and(path("/client.json"))
580            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
581                "client_id": url,
582                "client_name": "Example",
583                "redirect_uris": ["http://127.0.0.1:3000/callback"],
584            })))
585            .mount(&server)
586            .await;
587
588        let wrong_addr = SocketAddr::new(real_addr.ip(), real_addr.port().wrapping_add(1).max(1));
589        let client = reqwest::Client::builder()
590            .no_proxy()
591            .redirect(reqwest::redirect::Policy::none())
592            .build()
593            .unwrap();
594        let err = fetch_document_at(&client, &url, wrong_addr)
595            .await
596            .unwrap_err();
597        assert!(matches!(err, CimdError::PeerMismatch { .. }));
598        assert_eq!(err.kind(), "ssrf_blocked");
599    }
600
601    #[tokio::test]
602    async fn fetch_via_pinned_address_rejects_client_id_mismatch() {
603        let server = MockServer::start().await;
604        let addr = *server.address();
605        let url = format!("{}/client.json", server.uri());
606        Mock::given(method("GET"))
607            .and(path("/client.json"))
608            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
609                "client_id": "https://attacker.example/spoofed.json",
610                "client_name": "Spoofed Client",
611                "redirect_uris": ["http://127.0.0.1:9999/callback"],
612            })))
613            .mount(&server)
614            .await;
615
616        let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
617        assert!(matches!(err, CimdError::ClientIdMismatch { .. }));
618        assert_eq!(err.kind(), "invalid_client_metadata");
619    }
620
621    #[tokio::test]
622    async fn fetch_via_pinned_address_rejects_missing_required_fields() {
623        let server = MockServer::start().await;
624        let addr = *server.address();
625        let url = format!("{}/client.json", server.uri());
626        Mock::given(method("GET"))
627            .and(path("/client.json"))
628            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
629                "client_id": url,
630                "redirect_uris": ["http://127.0.0.1:3000/callback"],
631            })))
632            .mount(&server)
633            .await;
634
635        let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
636        assert!(matches!(err, CimdError::InvalidDocument(_)));
637        assert_eq!(err.kind(), "invalid_client_metadata");
638    }
639
640    #[tokio::test]
641    async fn fetch_via_pinned_address_rejects_malformed_json() {
642        let server = MockServer::start().await;
643        let addr = *server.address();
644        let url = format!("{}/client.json", server.uri());
645        Mock::given(method("GET"))
646            .and(path("/client.json"))
647            .respond_with(ResponseTemplate::new(200).set_body_string("{not valid json"))
648            .mount(&server)
649            .await;
650
651        let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
652        assert!(matches!(err, CimdError::InvalidDocument(_)));
653        assert_eq!(err.kind(), "invalid_client_metadata");
654    }
655
656    #[tokio::test]
657    async fn fetch_via_pinned_address_rejects_empty_redirect_uris() {
658        let server = MockServer::start().await;
659        let addr = *server.address();
660        let url = format!("{}/client.json", server.uri());
661        Mock::given(method("GET"))
662            .and(path("/client.json"))
663            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
664                "client_id": url,
665                "client_name": "Example",
666                "redirect_uris": [],
667            })))
668            .mount(&server)
669            .await;
670
671        let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
672        assert!(matches!(err, CimdError::InvalidDocument(_)));
673        assert_eq!(err.kind(), "invalid_client_metadata");
674    }
675
676    #[tokio::test]
677    async fn fetch_via_pinned_address_rejects_non_success_status() {
678        let server = MockServer::start().await;
679        let addr = *server.address();
680        let url = format!("{}/missing.json", server.uri());
681        Mock::given(method("GET"))
682            .and(path("/missing.json"))
683            .respond_with(ResponseTemplate::new(404))
684            .mount(&server)
685            .await;
686
687        let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
688        assert!(matches!(err, CimdError::Fetch(_)));
689        assert_eq!(err.kind(), "cimd_fetch_failed");
690    }
691
692    #[tokio::test]
693    async fn fetch_via_pinned_address_does_not_follow_redirects() {
694        let server = MockServer::start().await;
695        let addr = *server.address();
696        let url = format!("{}/redirecting.json", server.uri());
697        Mock::given(method("GET"))
698            .and(path("/redirecting.json"))
699            .respond_with(
700                ResponseTemplate::new(302)
701                    .insert_header("Location", "https://attacker.example/elsewhere.json"),
702            )
703            .mount(&server)
704            .await;
705
706        let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
707        assert!(matches!(err, CimdError::Fetch(_)));
708        assert_eq!(err.kind(), "cimd_fetch_failed");
709    }
710
711    #[tokio::test]
712    async fn fetch_via_pinned_address_rejects_oversized_body_without_hanging() {
713        let server = MockServer::start().await;
714        let addr = *server.address();
715        let url = format!("{}/big.json", server.uri());
716        let oversized = "x".repeat(MAX_DOCUMENT_BYTES + 1024);
717        Mock::given(method("GET"))
718            .and(path("/big.json"))
719            .respond_with(ResponseTemplate::new(200).set_body_string(oversized))
720            .mount(&server)
721            .await;
722
723        let err = fetch_via_pinned_address(&url, addr).await.unwrap_err();
724        assert!(matches!(err, CimdError::InvalidDocument(_)));
725        assert_eq!(err.kind(), "invalid_client_metadata");
726    }
727
728    #[test]
729    fn cache_returns_none_when_expired() {
730        let cache = DocumentCache::new();
731        let doc = ClientMetadataDocument {
732            client_id: "https://app.example.com/client.json".to_string(),
733            client_name: "Example".to_string(),
734            redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
735        };
736        cache.insert(
737            "https://app.example.com/client.json".to_string(),
738            &Ok(doc),
739            Duration::from_millis(1),
740        );
741        std::thread::sleep(Duration::from_millis(20));
742        assert!(
743            cache
744                .get_fresh("https://app.example.com/client.json")
745                .is_none()
746        );
747    }
748
749    #[test]
750    fn cache_returns_document_when_fresh() {
751        let cache = DocumentCache::new();
752        let doc = ClientMetadataDocument {
753            client_id: "https://app.example.com/client.json".to_string(),
754            client_name: "Example".to_string(),
755            redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
756        };
757        cache.insert(
758            "https://app.example.com/client.json".to_string(),
759            &Ok(doc),
760            CACHE_TTL,
761        );
762        assert!(
763            cache
764                .get_fresh("https://app.example.com/client.json")
765                .is_some()
766        );
767    }
768
769    #[test]
770    fn cache_caches_negative_results_too() {
771        let cache = DocumentCache::new();
772        let err = CimdError::DnsBlocked("app.example.com".to_string());
773        cache.insert(
774            "https://app.example.com/client.json".to_string(),
775            &Err(err),
776            NEGATIVE_CACHE_TTL,
777        );
778        let cached = cache
779            .get_fresh("https://app.example.com/client.json")
780            .expect("negative result should be cached");
781        // The specific variant (and therefore `kind()`) must survive the
782        // cache round-trip -- a security-relevant classification like
783        // "ssrf_blocked" must not be downgraded to a generic failure on a
784        // cache hit (see CacheEntry's doc comment for why).
785        assert!(matches!(cached, Err(CimdError::DnsBlocked(_))));
786        assert_eq!(cached.unwrap_err().kind(), "ssrf_blocked");
787    }
788
789    #[test]
790    fn entries_insert_skips_caching_once_at_capacity_with_nothing_expired() {
791        let cache = DocumentCache::new();
792        for i in 0..MAX_CACHE_ENTRIES {
793            let url = format!("https://app.example.com/{i}.json");
794            let doc = ClientMetadataDocument {
795                client_id: url.clone(),
796                client_name: "Example".to_string(),
797                redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
798            };
799            cache.insert(url, &Ok(doc), CACHE_TTL);
800        }
801        assert_eq!(cache.entries.len(), MAX_CACHE_ENTRIES);
802
803        let overflow_url = "https://app.example.com/overflow.json".to_string();
804        let doc = ClientMetadataDocument {
805            client_id: overflow_url.clone(),
806            client_name: "Example".to_string(),
807            redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
808        };
809        cache.insert(overflow_url.clone(), &Ok(doc), CACHE_TTL);
810
811        // At capacity with nothing expired (every entry shares CACHE_TTL and
812        // was just inserted): the overflow insert must be skipped rather
813        // than growing the map past MAX_CACHE_ENTRIES.
814        assert_eq!(cache.entries.len(), MAX_CACHE_ENTRIES);
815        assert!(cache.get_fresh(&overflow_url).is_none());
816    }
817
818    #[test]
819    fn lock_for_sweeps_idle_locks_through_the_real_entry_point_when_at_capacity() {
820        let cache = DocumentCache::new();
821        for i in 0..MAX_CACHE_ENTRIES {
822            cache.build_locks.insert(
823                format!("https://idle.example/{i}.json"),
824                Arc::new(Mutex::new(())),
825            );
826        }
827        assert_eq!(cache.build_locks.len(), MAX_CACHE_ENTRIES);
828
829        let _ = cache.lock_for("https://new.example/client.json".to_string());
830
831        // lock_for's own `len() >= MAX_CACHE_ENTRIES` branch (not just the
832        // retain predicate in isolation) must have triggered the sweep: all
833        // idle locks are gone, leaving only the newly-created one.
834        assert_eq!(cache.build_locks.len(), 1);
835        assert!(
836            cache
837                .build_locks
838                .get("https://new.example/client.json")
839                .is_some()
840        );
841    }
842
843    #[tokio::test]
844    async fn build_locks_coalesces_two_concurrent_misses_for_the_same_url_to_one_fetch() {
845        use std::sync::atomic::{AtomicUsize, Ordering};
846
847        // Exercises the exact single-flight primitives production code uses
848        // (`lock_for`, `get_fresh`, `insert`), with a fake "fetch" in place
849        // of the real network call -- proving two genuinely-concurrent
850        // cache MISSES for the same URL collapse to one fetch, which the
851        // deleted network-level test never actually demonstrated (it
852        // pre-seeded the cache, so both racers took the cache-hit path and
853        // the lock was never contended). A real network-level version of
854        // this test would need a public DNS-resolvable host, which this
855        // module's other tests also avoid (see the module doc above) since
856        // CI has no network access.
857        async fn simulate_fetch(
858            cache: &DocumentCache,
859            url: &str,
860            fetch_count: &AtomicUsize,
861        ) -> ClientMetadataDocument {
862            if let Some(Ok(doc)) = cache.get_fresh(url) {
863                return doc;
864            }
865            let lock = cache.lock_for(url.to_string());
866            let _guard = lock.lock().await;
867            if let Some(Ok(doc)) = cache.get_fresh(url) {
868                return doc;
869            }
870            fetch_count.fetch_add(1, Ordering::SeqCst);
871            // Simulate fetch latency so both callers are genuinely
872            // in-flight together rather than serializing by accident.
873            tokio::time::sleep(Duration::from_millis(30)).await;
874            let doc = ClientMetadataDocument {
875                client_id: url.to_string(),
876                client_name: "Example".to_string(),
877                redirect_uris: vec!["http://127.0.0.1:3000/callback".to_string()],
878            };
879            cache.insert(url.to_string(), &Ok(doc.clone()), CACHE_TTL);
880            doc
881        }
882
883        let cache = DocumentCache::new();
884        let url = "https://app.example.com/client.json";
885        let fetch_count = AtomicUsize::new(0);
886        let (a, b) = tokio::join!(
887            simulate_fetch(&cache, url, &fetch_count),
888            simulate_fetch(&cache, url, &fetch_count),
889        );
890        assert_eq!(a.client_id, url);
891        assert_eq!(b.client_id, url);
892        assert_eq!(
893            fetch_count.load(Ordering::SeqCst),
894            1,
895            "two concurrent misses for the same URL must coalesce to exactly one fetch"
896        );
897    }
898
899    #[test]
900    fn build_locks_retain_keeps_only_locks_with_an_active_holder() {
901        // `lock_for`'s capacity sweep relies on this exact predicate: a
902        // lock still referenced by an in-flight fetch (an extra Arc clone
903        // beyond the map's own) must survive eviction, while one nobody is
904        // using (strong_count == 1, held only by the map) must not.
905        let cache = DocumentCache::new();
906        let idle = Arc::new(Mutex::new(()));
907        let busy = Arc::new(Mutex::new(()));
908        let _busy_holder = busy.clone(); // simulates an in-flight fetch's lock clone
909        cache.build_locks.insert("idle.example".to_string(), idle);
910        cache.build_locks.insert("busy.example".to_string(), busy);
911        cache
912            .build_locks
913            .retain(|_, lock| Arc::strong_count(lock) > 1);
914        assert!(cache.build_locks.get("idle.example").is_none());
915        assert!(cache.build_locks.get("busy.example").is_some());
916    }
917}