#[cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
use crate::error::{Error, Result};
use crate::jwks::{KeySet, KeyStore};
use url::Url;
#[cfg(not(target_arch = "wasm32"))]
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
pub struct HttpKeyStore {
url: Url,
client: reqwest::Client,
}
fn require_https(url: &Url) -> Result<()> {
if url.scheme() != "https" {
return Err(Error::InvalidUrlScheme(
"URL scheme must be 'https'; use new_insecure() or new_with_client_insecure() to allow HTTP for local development or testing",
));
}
Ok(())
}
impl HttpKeyStore {
pub fn new(url: impl AsRef<str>) -> Result<Self> {
let builder = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder.timeout(DEFAULT_TIMEOUT);
let client = builder.build()?;
Self::new_with_client(url, client)
}
pub fn new_with_client(url: impl AsRef<str>, client: reqwest::Client) -> Result<Self> {
let url = Url::parse(url.as_ref()).map_err(Error::InvalidUrl)?;
require_https(&url)?;
Ok(Self { url, client })
}
pub fn new_insecure(url: impl AsRef<str>) -> Result<Self> {
let builder = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder.timeout(DEFAULT_TIMEOUT);
let client = builder.build()?;
Self::new_with_client_insecure(url, client)
}
pub fn new_with_client_insecure(url: impl AsRef<str>, client: reqwest::Client) -> Result<Self> {
let url = Url::parse(url.as_ref()).map_err(Error::InvalidUrl)?;
Ok(Self { url, client })
}
async fn fetch(&self) -> Result<KeySet> {
let response = self
.client
.get(self.url.as_str())
.send()
.await?
.error_for_status()?;
let bytes = response.bytes().await?;
Ok(serde_json::from_slice::<KeySet>(&bytes)?)
}
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl KeyStore for HttpKeyStore {
async fn get_keyset(&self) -> Result<KeySet> {
self.fetch().await
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(test)]
mod tests {
use super::*;
use reqwest::StatusCode;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::{Duration as TokioDuration, sleep};
async fn spawn_single_response_server(response: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = vec![0_u8; 4096];
let _ = stream.read(&mut buf).await;
stream.write_all(response.as_bytes()).await.unwrap();
let _ = stream.shutdown().await;
});
format!("http://{}", addr)
}
#[tokio::test]
async fn test_http_keystore_fetch_success() {
let body = r#"{"keys":[{"kty":"oct","kid":"k1","k":"AQAB"}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let url = spawn_single_response_server(response).await;
let store = HttpKeyStore::new_insecure(url).unwrap();
let keyset = store.get_keyset().await.unwrap();
assert_eq!(keyset.len(), 1);
assert!(keyset.get_by_kid("k1").is_some());
}
#[tokio::test]
async fn test_http_keystore_non_2xx_propagates_error() {
let body = "not found";
let response = format!(
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let url = spawn_single_response_server(response).await;
let store = HttpKeyStore::new_insecure(url).unwrap();
let err = store.get_keyset().await.unwrap_err();
match err {
Error::Http(e) => {
assert_eq!(e.status(), Some(StatusCode::NOT_FOUND));
}
other => panic!("expected HTTP status error, got: {}", other),
}
}
#[tokio::test]
async fn test_http_keystore_invalid_json_error() {
let body = "not json";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let url = spawn_single_response_server(response).await;
let store = HttpKeyStore::new_insecure(url).unwrap();
let err = store.get_keyset().await.unwrap_err();
assert!(matches!(err, Error::Json(_)));
}
#[tokio::test]
async fn test_http_keystore_network_failure() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let store = HttpKeyStore::new_insecure(format!("http://{}", addr)).unwrap();
let err = store.get_keyset().await.unwrap_err();
match err {
Error::Http(e) => {
assert!(e.is_connect(), "expected connection error, got: {e}");
}
other => panic!("expected transport error, got: {}", other),
}
}
#[tokio::test]
async fn test_http_keystore_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = vec![0_u8; 4096];
let _ = stream.read(&mut buf).await;
sleep(TokioDuration::from_millis(200)).await;
let body = r#"{"keys":[]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
});
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(50))
.build()
.unwrap();
let store =
HttpKeyStore::new_with_client_insecure(format!("http://{}", addr), client).unwrap();
let err = store.get_keyset().await.unwrap_err();
match err {
Error::Http(e) => {
assert!(e.is_timeout(), "expected timeout error, got: {e}");
}
other => panic!("expected timeout transport error, got: {}", other),
}
}
#[test]
fn test_http_keystore_new_rejects_invalid_url() {
let err = HttpKeyStore::new("not a valid url").unwrap_err();
assert!(matches!(err, Error::InvalidUrl(_)));
}
#[test]
fn test_http_keystore_new_with_client_rejects_invalid_url() {
let client = reqwest::Client::new();
let err = HttpKeyStore::new_with_client("not a valid url", client).unwrap_err();
assert!(matches!(err, Error::InvalidUrl(_)));
}
#[test]
fn test_http_keystore_new_rejects_http_url() {
let err = HttpKeyStore::new("http://example.com/.well-known/jwks.json").unwrap_err();
assert!(matches!(err, Error::InvalidUrlScheme(_)));
}
#[test]
fn test_http_keystore_new_with_client_rejects_http_url() {
let client = reqwest::Client::new();
let err = HttpKeyStore::new_with_client("http://example.com/.well-known/jwks.json", client)
.unwrap_err();
assert!(matches!(err, Error::InvalidUrlScheme(_)));
}
#[test]
fn test_http_keystore_new_accepts_https_url() {
assert!(HttpKeyStore::new("https://example.com/.well-known/jwks.json").is_ok());
}
#[test]
fn test_http_keystore_new_with_client_accepts_https_url() {
let client = reqwest::Client::new();
assert!(
HttpKeyStore::new_with_client("https://example.com/.well-known/jwks.json", client)
.is_ok()
);
}
#[test]
fn test_http_keystore_new_insecure_accepts_http_url() {
assert!(HttpKeyStore::new_insecure("http://example.com/.well-known/jwks.json").is_ok());
}
#[test]
fn test_http_keystore_new_with_client_insecure_accepts_http_url() {
let client = reqwest::Client::new();
assert!(
HttpKeyStore::new_with_client_insecure(
"http://example.com/.well-known/jwks.json",
client
)
.is_ok()
);
}
}