use futures::TryStreamExt;
use url::Url;
use worker::kv::KvStore;
use crate::error::{Error, Result};
use crate::jwks::{KeyCache, KeySet, KeyStore};
pub const DEFAULT_KV_TTL_SECONDS: u64 = 300;
#[derive(Debug, Clone)]
pub struct FetchKeyStore {
url: Url,
}
fn require_https(url: &Url) -> Result<()> {
if url.scheme() != "https" {
return Err(Error::InvalidUrlScheme(
"URL scheme must be 'https'; use new_insecure() to allow HTTP for local development or testing",
));
}
Ok(())
}
impl FetchKeyStore {
pub fn new(url: impl AsRef<str>) -> Result<Self> {
let url = Url::parse(url.as_ref()).map_err(Error::InvalidUrl)?;
require_https(&url)?;
Ok(Self { url })
}
pub fn new_insecure(url: impl AsRef<str>) -> Result<Self> {
let url = Url::parse(url.as_ref()).map_err(Error::InvalidUrl)?;
Ok(Self { url })
}
async fn fetch(&self) -> Result<KeySet> {
let mut response = worker::Fetch::Url(self.url.clone())
.send()
.await
.map_err(|e| Error::Fetch(format!("fetch failed: {}", e)))?;
let status = response.status_code();
if !(200..300).contains(&status) {
return Err(Error::Fetch(format!(
"JWKS endpoint returned HTTP {}",
status
)));
}
let mut bytes = Vec::new();
let mut stream = response
.stream()
.map_err(|e| Error::Fetch(format!("failed to read response body: {}", e)))?;
while let Some(chunk) = stream
.try_next()
.await
.map_err(|e| Error::Fetch(format!("failed to read response body: {}", e)))?
{
bytes.extend_from_slice(&chunk);
}
Ok(serde_json::from_slice::<KeySet>(&bytes)?)
}
}
#[async_trait::async_trait(?Send)]
impl KeyStore for FetchKeyStore {
async fn get_keyset(&self) -> Result<KeySet> {
self.fetch().await
}
}
#[derive(Debug)]
pub struct KvKeyCache {
kv: KvStore,
key: String,
ttl_seconds: Option<u64>,
}
impl KvKeyCache {
pub fn new(kv: KvStore) -> Self {
Self {
kv,
key: "jwks".to_string(),
ttl_seconds: Some(DEFAULT_KV_TTL_SECONDS),
}
}
pub fn with_ttl(mut self, ttl: std::time::Duration) -> Self {
self.ttl_seconds = Some(ttl.as_secs());
self
}
pub fn without_ttl(mut self) -> Self {
self.ttl_seconds = None;
self
}
pub fn with_key(mut self, key: impl Into<String>) -> Self {
self.key = key.into();
self
}
pub fn ttl_seconds(&self) -> Option<u64> {
self.ttl_seconds
}
pub fn key(&self) -> &str {
&self.key
}
}
#[async_trait::async_trait(?Send)]
impl KeyCache for KvKeyCache {
async fn get(&self) -> Result<Option<KeySet>> {
let value = self
.kv
.get(&self.key)
.text()
.await
.map_err(|e| Error::Cache(format!("read failed: {}", e)))?;
match value {
Some(json) => serde_json::from_str(&json)
.map(Some)
.map_err(|e| Error::Cache(format!("deserialize failed: {}", e))),
None => Ok(None),
}
}
async fn set(&self, keyset: KeySet) -> Result<()> {
let json = serde_json::to_string(&keyset)
.map_err(|e| Error::Cache(format!("serialize failed: {}", e)))?;
let builder = self
.kv
.put(&self.key, json)
.map_err(|e| Error::Cache(format!("write setup failed: {}", e)))?;
let builder = if let Some(ttl) = self.ttl_seconds {
builder.expiration_ttl(ttl)
} else {
builder
};
builder
.execute()
.await
.map_err(|e| Error::Cache(format!("write failed: {}", e)))?;
Ok(())
}
async fn clear(&self) -> Result<()> {
self.kv
.delete(&self.key)
.await
.map_err(|e| Error::Cache(format!("delete failed: {}", e)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fetch_keystore_new_rejects_invalid_url() {
let err = FetchKeyStore::new("not a valid url").unwrap_err();
assert!(matches!(err, Error::InvalidUrl(_)));
}
#[test]
fn test_fetch_keystore_new_rejects_http_url() {
let err = FetchKeyStore::new("http://example.com/.well-known/jwks.json").unwrap_err();
assert!(matches!(err, Error::InvalidUrlScheme(_)));
}
#[test]
fn test_fetch_keystore_new_accepts_https_url() {
assert!(FetchKeyStore::new("https://example.com/.well-known/jwks.json").is_ok());
}
#[test]
fn test_fetch_keystore_new_insecure_accepts_http_url() {
assert!(FetchKeyStore::new_insecure("http://example.com/.well-known/jwks.json").is_ok());
}
}