Skip to main content

authplane_sdk/cache/
document_fetcher.rs

1//! SSRF-protected JSON document fetcher used by the metadata and JWKS caches.
2
3use std::sync::Arc;
4
5use reqwest::Client;
6use serde_json::Value;
7
8use crate::cache::cache_headers::parse_expires_at;
9use crate::cache::document_cache::FetchResult;
10use crate::errors::transport_error;
11use crate::transport::{build_http_client, ssrf_safe_get, validate_fetch_url};
12use crate::{AuthplaneError, FetchSettings};
13
14/// Fetcher for JSON documents (AS metadata, JWKS, …).
15///
16/// Validates the URL through [`crate::transport::validate_fetch_url`], honours
17/// the configured timeout / SSRF policy, enforces a `max_size` body limit, and
18/// parses the response body as JSON.
19#[derive(Debug, Clone)]
20pub struct DocumentFetcher {
21    url: String,
22    document_type: String,
23    settings: FetchSettings,
24    max_size: u64,
25    http: Arc<Client>,
26}
27
28impl DocumentFetcher {
29    /// Default size cap for AS metadata documents (128 KB).
30    pub const DEFAULT_METADATA_MAX_BYTES: u64 = 131_072;
31    /// Default size cap for JWKS documents (64 KB).
32    pub const DEFAULT_JWKS_MAX_BYTES: u64 = 65_536;
33
34    /// Build a fetcher from a brand new HTTP client.
35    pub fn new(
36        url: impl Into<String>,
37        document_type: impl Into<String>,
38        settings: FetchSettings,
39        max_size: u64,
40    ) -> Result<Self, AuthplaneError> {
41        let http = Arc::new(build_http_client(&settings)?);
42        Ok(Self {
43            url: url.into(),
44            document_type: document_type.into(),
45            settings,
46            max_size,
47            http,
48        })
49    }
50
51    /// Build a fetcher reusing an existing HTTP client (e.g. shared with
52    /// `AuthplaneClient`).
53    pub fn with_client(
54        url: impl Into<String>,
55        document_type: impl Into<String>,
56        settings: FetchSettings,
57        max_size: u64,
58        http: Arc<Client>,
59    ) -> Self {
60        Self {
61            url: url.into(),
62            document_type: document_type.into(),
63            settings,
64            max_size,
65            http,
66        }
67    }
68
69    /// Target URL.
70    pub fn url(&self) -> &str {
71        &self.url
72    }
73
74    /// Document type label (used in errors / logs).
75    pub fn document_type(&self) -> &str {
76        &self.document_type
77    }
78
79    /// Perform a fetch, returning the parsed JSON body and the absolute
80    /// expiry timestamp derived from cache headers (if any).
81    pub async fn fetch(&self) -> Result<FetchResult, AuthplaneError> {
82        validate_fetch_url(
83            &self.url,
84            &self.settings,
85            &format!("{} URL", self.document_type),
86        )?;
87
88        if self.settings.ssrf_protection {
89            // DNS-pinned SSRF-safe fetch path.
90            let ssrf_response = ssrf_safe_get(&self.url, &self.settings, self.max_size).await?;
91
92            if !crate::transport::is_http_success(ssrf_response.status_code) {
93                return Err(transport_error(&format!(
94                    "{} fetch returned HTTP {}",
95                    self.document_type, ssrf_response.status_code
96                )));
97            }
98
99            let expires_at = parse_expires_at(
100                ssrf_response
101                    .headers
102                    .iter()
103                    .map(|(k, v)| (k.as_str(), v.as_str())),
104            );
105
106            Ok(FetchResult {
107                document: ssrf_response.body,
108                expires_at,
109            })
110        } else {
111            // Plain reqwest path (no SSRF protection).
112            let response = self.http.get(&self.url).send().await.map_err(|error| {
113                transport_error(&format!("{} fetch failed: {error}", self.document_type))
114            })?;
115
116            let status = response.status().as_u16();
117            if !crate::transport::is_http_success(status) {
118                return Err(transport_error(&format!(
119                    "{} fetch returned HTTP {status}",
120                    self.document_type
121                )));
122            }
123
124            // Header snapshot for cache parsing (consumes nothing).
125            let header_snapshot: Vec<(String, String)> = response
126                .headers()
127                .iter()
128                .filter_map(|(name, value)| {
129                    value
130                        .to_str()
131                        .ok()
132                        .map(|v| (name.to_string(), v.to_string()))
133                })
134                .collect();
135
136            let bytes = response.bytes().await.map_err(|error| {
137                transport_error(&format!("{} body read failed: {error}", self.document_type))
138            })?;
139
140            if bytes.len() as u64 > self.max_size {
141                return Err(transport_error(&format!(
142                    "{} document exceeds {} bytes",
143                    self.document_type, self.max_size
144                )));
145            }
146
147            let document: Value = serde_json::from_slice(&bytes).map_err(|error| {
148                transport_error(&format!(
149                    "{} body is not valid JSON: {error}",
150                    self.document_type
151                ))
152            })?;
153
154            let expires_at = parse_expires_at(
155                header_snapshot
156                    .iter()
157                    .map(|(k, v)| (k.as_str(), v.as_str())),
158            );
159
160            Ok(FetchResult {
161                document,
162                expires_at,
163            })
164        }
165    }
166}