jwk_simple/jwks/store/
http.rs1#[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#[cfg(not(target_arch = "wasm32"))]
16pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17
18#[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 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 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 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 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 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 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 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}