Skip to main content

jwk_simple/jwks/store/
http.rs

1//! Remote JWKS fetching without caching.
2//!
3//! This module provides [`HttpKeyStore`], which fetches keys from an HTTP endpoint
4//! on every request. For production use, consider wrapping with
5//! [`CachedKeyStore`](crate::jwks::CachedKeyStore).
6
7#[cfg(not(target_arch = "wasm32"))]
8use std::time::Duration;
9
10use crate::error::{Error, Result};
11use crate::jwks::{KeySet, KeyStore};
12use url::Url;
13
14/// Default timeout for HTTP requests (30 seconds).
15#[cfg(not(target_arch = "wasm32"))]
16pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17
18/// A key store that fetches from an HTTP endpoint on every request.
19///
20/// This implementation does **not** cache keys. Every call to [`get_key`](KeyStore::get_key)
21/// or [`get_keyset`](KeyStore::get_keyset) will make an HTTP request.
22///
23/// For production use with high request volumes, wrap this in
24/// [`CachedKeyStore`](crate::jwks::CachedKeyStore) with [`MokaKeyCache`](crate::jwks::MokaKeyCache):
25///
26/// ```ignore
27/// use jwk_simple::jwks::{CachedKeyStore, HttpKeyStore, MokaKeyCache};
28/// use std::time::Duration;
29///
30/// let remote = HttpKeyStore::new("https://example.com/.well-known/jwks.json")?;
31/// let cache = MokaKeyCache::new(Duration::from_secs(300));
32/// let cached = CachedKeyStore::new(cache, remote);
33/// ```
34///
35/// # Examples
36///
37/// ```ignore
38/// use jwk_simple::jwks::{HttpKeyStore, KeyStore};
39///
40/// let store = HttpKeyStore::new("https://example.com/.well-known/jwks.json")?;
41/// let key = store.get_key("my-key-id").await?;
42/// ```
43///
44/// # Custom HTTP Client
45///
46/// You can provide a custom [`reqwest::Client`] for full control over HTTP behavior:
47///
48/// ```ignore
49/// use jwk_simple::jwks::HttpKeyStore;
50/// use std::time::Duration;
51///
52/// let client = reqwest::Client::builder()
53///     .timeout(Duration::from_secs(10))
54///     .user_agent("my-app/1.0")
55///     .build()
56///     .unwrap();
57///
58/// let store = HttpKeyStore::new_with_client(
59///     "https://example.com/.well-known/jwks.json",
60///     client,
61/// );
62/// ```
63#[derive(Debug, Clone)]
64pub struct HttpKeyStore {
65    url: Url,
66    client: reqwest::Client,
67}
68
69fn require_https(url: &Url) -> Result<()> {
70    if url.scheme() != "https" {
71        return Err(Error::InvalidUrlScheme(
72            "URL scheme must be 'https'; use new_insecure() or new_with_client_insecure() to allow HTTP for local development or testing",
73        ));
74    }
75    Ok(())
76}
77
78impl HttpKeyStore {
79    /// Creates a new `HttpKeyStore` from a URL.
80    ///
81    /// The URL must use the `https` scheme. To allow plain HTTP (e.g. in local development
82    /// or testing), use [`new_insecure`](Self::new_insecure).
83    ///
84    /// On native targets, uses a default HTTP client with a 30-second timeout.
85    /// On `wasm32`, reqwest uses the browser/Fetch backend where client-level
86    /// timeout configuration is not available.
87    /// To customize the client, use [`new_with_client`](Self::new_with_client).
88    pub fn new(url: impl AsRef<str>) -> Result<Self> {
89        let builder = reqwest::Client::builder();
90        #[cfg(not(target_arch = "wasm32"))]
91        let builder = builder.timeout(DEFAULT_TIMEOUT);
92        let client = builder.build()?;
93
94        Self::new_with_client(url, client)
95    }
96
97    /// Creates a new `HttpKeyStore` with a custom HTTP client.
98    ///
99    /// The URL must use the `https` scheme. To allow plain HTTP, use
100    /// [`new_with_client_insecure`](Self::new_with_client_insecure).
101    ///
102    /// Use this to configure custom headers, proxies, TLS settings, and (on native
103    /// targets) custom timeouts.
104    ///
105    /// On `wasm32`, reqwest uses the browser/Fetch backend where client-level
106    /// timeout configuration is not available.
107    ///
108    /// # Examples
109    ///
110    /// ```ignore
111    /// use jwk_simple::jwks::HttpKeyStore;
112    /// use std::time::Duration;
113    ///
114    /// let client = reqwest::Client::builder()
115    ///     .timeout(Duration::from_secs(10))
116    ///     .build()
117    ///     .unwrap();
118    ///
119    /// let store = HttpKeyStore::new_with_client(
120    ///     "https://example.com/.well-known/jwks.json",
121    ///     client,
122    /// )?;
123    /// ```
124    pub fn new_with_client(url: impl AsRef<str>, client: reqwest::Client) -> Result<Self> {
125        let url = Url::parse(url.as_ref()).map_err(Error::InvalidUrl)?;
126        require_https(&url)?;
127
128        Ok(Self { url, client })
129    }
130
131    /// Creates a new `HttpKeyStore` without enforcing HTTPS.
132    ///
133    /// # Warning
134    ///
135    /// This constructor skips the HTTPS scheme check and is intended **only** for local
136    /// development or testing where HTTPS is not available. Do **not** use this in
137    /// production — plain HTTP connections allow network attackers to tamper with
138    /// JWKS responses and inject attacker-controlled keys.
139    pub fn new_insecure(url: impl AsRef<str>) -> Result<Self> {
140        let builder = reqwest::Client::builder();
141        #[cfg(not(target_arch = "wasm32"))]
142        let builder = builder.timeout(DEFAULT_TIMEOUT);
143        let client = builder.build()?;
144
145        Self::new_with_client_insecure(url, client)
146    }
147
148    /// Creates a new `HttpKeyStore` with a custom HTTP client, without enforcing HTTPS.
149    ///
150    /// # Warning
151    ///
152    /// This constructor skips the HTTPS scheme check and is intended **only** for local
153    /// development or testing where HTTPS is not available. Do **not** use this in
154    /// production — plain HTTP connections allow network attackers to tamper with
155    /// JWKS responses and inject attacker-controlled keys.
156    pub fn new_with_client_insecure(url: impl AsRef<str>, client: reqwest::Client) -> Result<Self> {
157        let url = Url::parse(url.as_ref()).map_err(Error::InvalidUrl)?;
158
159        Ok(Self { url, client })
160    }
161
162    /// Fetches the JWKS from the remote endpoint.
163    async fn fetch(&self) -> Result<KeySet> {
164        let response = self
165            .client
166            .get(self.url.as_str())
167            .send()
168            .await?
169            .error_for_status()?;
170
171        let bytes = response.bytes().await?;
172
173        Ok(serde_json::from_slice::<KeySet>(&bytes)?)
174    }
175}
176
177#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
178#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
179impl KeyStore for HttpKeyStore {
180    async fn get_keyset(&self) -> Result<KeySet> {
181        self.fetch().await
182    }
183}
184
185#[cfg(not(target_arch = "wasm32"))]
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use reqwest::StatusCode;
190
191    use tokio::io::{AsyncReadExt, AsyncWriteExt};
192    use tokio::net::TcpListener;
193    use tokio::time::{Duration as TokioDuration, sleep};
194
195    async fn spawn_single_response_server(response: String) -> String {
196        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
197        let addr = listener.local_addr().unwrap();
198
199        tokio::spawn(async move {
200            let (mut stream, _) = listener.accept().await.unwrap();
201            let mut buf = vec![0_u8; 4096];
202            let _ = stream.read(&mut buf).await;
203            stream.write_all(response.as_bytes()).await.unwrap();
204            let _ = stream.shutdown().await;
205        });
206
207        format!("http://{}", addr)
208    }
209
210    #[tokio::test]
211    async fn test_http_keystore_fetch_success() {
212        let body = r#"{"keys":[{"kty":"oct","kid":"k1","k":"AQAB"}]}"#;
213        let response = format!(
214            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
215            body.len(),
216            body
217        );
218        let url = spawn_single_response_server(response).await;
219
220        let store = HttpKeyStore::new_insecure(url).unwrap();
221        let keyset = store.get_keyset().await.unwrap();
222        assert_eq!(keyset.len(), 1);
223        assert!(keyset.get_by_kid("k1").is_some());
224    }
225
226    #[tokio::test]
227    async fn test_http_keystore_non_2xx_propagates_error() {
228        let body = "not found";
229        let response = format!(
230            "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}",
231            body.len(),
232            body
233        );
234        let url = spawn_single_response_server(response).await;
235
236        let store = HttpKeyStore::new_insecure(url).unwrap();
237        let err = store.get_keyset().await.unwrap_err();
238        match err {
239            Error::Http(e) => {
240                assert_eq!(e.status(), Some(StatusCode::NOT_FOUND));
241            }
242            other => panic!("expected HTTP status error, got: {}", other),
243        }
244    }
245
246    #[tokio::test]
247    async fn test_http_keystore_invalid_json_error() {
248        let body = "not json";
249        let response = format!(
250            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
251            body.len(),
252            body
253        );
254        let url = spawn_single_response_server(response).await;
255
256        let store = HttpKeyStore::new_insecure(url).unwrap();
257        let err = store.get_keyset().await.unwrap_err();
258        assert!(matches!(err, Error::Json(_)));
259    }
260
261    #[tokio::test]
262    async fn test_http_keystore_network_failure() {
263        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
264        let addr = listener.local_addr().unwrap();
265        drop(listener);
266
267        let store = HttpKeyStore::new_insecure(format!("http://{}", addr)).unwrap();
268        let err = store.get_keyset().await.unwrap_err();
269        match err {
270            Error::Http(e) => {
271                assert!(e.is_connect(), "expected connection error, got: {e}");
272            }
273            other => panic!("expected transport error, got: {}", other),
274        }
275    }
276
277    #[tokio::test]
278    async fn test_http_keystore_timeout() {
279        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
280        let addr = listener.local_addr().unwrap();
281
282        tokio::spawn(async move {
283            let (mut stream, _) = listener.accept().await.unwrap();
284            let mut buf = vec![0_u8; 4096];
285            let _ = stream.read(&mut buf).await;
286            sleep(TokioDuration::from_millis(200)).await;
287            let body = r#"{"keys":[]}"#;
288            let response = format!(
289                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
290                body.len(),
291                body
292            );
293            let _ = stream.write_all(response.as_bytes()).await;
294            let _ = stream.shutdown().await;
295        });
296
297        let client = reqwest::Client::builder()
298            .timeout(Duration::from_millis(50))
299            .build()
300            .unwrap();
301        let store =
302            HttpKeyStore::new_with_client_insecure(format!("http://{}", addr), client).unwrap();
303        let err = store.get_keyset().await.unwrap_err();
304        match err {
305            Error::Http(e) => {
306                assert!(e.is_timeout(), "expected timeout error, got: {e}");
307            }
308            other => panic!("expected timeout transport error, got: {}", other),
309        }
310    }
311
312    #[test]
313    fn test_http_keystore_new_rejects_invalid_url() {
314        let err = HttpKeyStore::new("not a valid url").unwrap_err();
315        assert!(matches!(err, Error::InvalidUrl(_)));
316    }
317
318    #[test]
319    fn test_http_keystore_new_with_client_rejects_invalid_url() {
320        let client = reqwest::Client::new();
321        let err = HttpKeyStore::new_with_client("not a valid url", client).unwrap_err();
322        assert!(matches!(err, Error::InvalidUrl(_)));
323    }
324
325    #[test]
326    fn test_http_keystore_new_rejects_http_url() {
327        let err = HttpKeyStore::new("http://example.com/.well-known/jwks.json").unwrap_err();
328        assert!(matches!(err, Error::InvalidUrlScheme(_)));
329    }
330
331    #[test]
332    fn test_http_keystore_new_with_client_rejects_http_url() {
333        let client = reqwest::Client::new();
334        let err = HttpKeyStore::new_with_client("http://example.com/.well-known/jwks.json", client)
335            .unwrap_err();
336        assert!(matches!(err, Error::InvalidUrlScheme(_)));
337    }
338
339    #[test]
340    fn test_http_keystore_new_accepts_https_url() {
341        // Construction succeeds; no network call is made here.
342        assert!(HttpKeyStore::new("https://example.com/.well-known/jwks.json").is_ok());
343    }
344
345    #[test]
346    fn test_http_keystore_new_with_client_accepts_https_url() {
347        let client = reqwest::Client::new();
348        // Construction succeeds; no network call is made here.
349        assert!(
350            HttpKeyStore::new_with_client("https://example.com/.well-known/jwks.json", client)
351                .is_ok()
352        );
353    }
354
355    #[test]
356    fn test_http_keystore_new_insecure_accepts_http_url() {
357        assert!(HttpKeyStore::new_insecure("http://example.com/.well-known/jwks.json").is_ok());
358    }
359
360    #[test]
361    fn test_http_keystore_new_with_client_insecure_accepts_http_url() {
362        let client = reqwest::Client::new();
363        assert!(
364            HttpKeyStore::new_with_client_insecure(
365                "http://example.com/.well-known/jwks.json",
366                client
367            )
368            .is_ok()
369        );
370    }
371}