use core::fmt;
use std::collections::HashMap;
use std::sync::{Arc, PoisonError, RwLock};
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::Value;
use url::Url;
use super::{KeyReference, KeyResolutionError, KeyResolver, KeyRole};
use crate::jose::Jwk;
use crate::methods::web::{HttpClient, HttpError};
pub struct JwksResolver<C> {
client: C,
ttl: Duration,
capacity: usize,
cache: RwLock<HashMap<Url, Entry>>,
}
struct Entry {
keys: Arc<[Value]>,
expires_at: Option<Instant>,
}
impl Entry {
fn is_live(&self, now: Instant) -> bool {
self.expires_at.is_none_or(|expires_at| expires_at > now)
}
}
#[derive(Deserialize)]
struct Metadata {
issuer: String,
#[serde(default)]
jwks: Option<KeySet>,
#[serde(default)]
jwks_uri: Option<String>,
}
#[derive(Deserialize)]
struct KeySet {
keys: Vec<Value>,
}
impl<C> JwksResolver<C> {
pub const DEFAULT_TTL: Duration = Duration::from_mins(5);
pub const DEFAULT_CAPACITY: usize = 256;
pub fn new(client: C) -> Self {
Self {
client,
ttl: Self::DEFAULT_TTL,
capacity: Self::DEFAULT_CAPACITY,
cache: RwLock::new(HashMap::new()),
}
}
#[must_use]
pub fn with_ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
#[must_use]
pub fn with_capacity(mut self, capacity: usize) -> Self {
self.capacity = capacity.max(1);
self
}
pub fn ttl(&self) -> Duration {
self.ttl
}
pub fn client(&self) -> &C {
&self.client
}
fn cached(&self, issuer: &Url) -> Option<Arc<[Value]>> {
let cache = self.cache.read().unwrap_or_else(PoisonError::into_inner);
cache
.get(issuer)
.filter(|entry| entry.is_live(Instant::now()))
.map(|entry| Arc::clone(&entry.keys))
}
fn store(&self, issuer: &Url, keys: Arc<[Value]>) {
if self.ttl.is_zero() {
return;
}
let now = Instant::now();
let mut cache = self.cache.write().unwrap_or_else(PoisonError::into_inner);
if !cache.contains_key(issuer) && cache.len() >= self.capacity {
cache.retain(|_, entry| entry.is_live(now));
if cache.len() >= self.capacity
&& let Some(soonest) = cache
.iter()
.min_by_key(|(_, entry)| entry.expires_at)
.map(|(issuer, _)| issuer.clone())
{
cache.remove(&soonest);
}
}
cache.insert(
issuer.clone(),
Entry {
keys,
expires_at: now.checked_add(self.ttl),
},
);
}
}
impl<C: HttpClient> JwksResolver<C> {
async fn key_set(&self, issuer: &Url) -> Result<Arc<[Value]>, JwksError> {
if let Some(keys) = self.cached(issuer) {
return Ok(keys);
}
let url = metadata_url(issuer)?;
let metadata: Metadata = self.fetch_json(&url).await?;
if Url::parse(&metadata.issuer).ok().as_ref() != Some(issuer) {
return Err(JwksError::IssuerMismatch {
expected: issuer.clone(),
found: metadata.issuer,
});
}
let keys = match (metadata.jwks, metadata.jwks_uri) {
(Some(jwks), None) => jwks.keys,
(None, Some(jwks_uri)) => {
let jwks_uri = Url::parse(&jwks_uri).map_err(|e| JwksError::InvalidMetadata {
url: url.clone(),
reason: format!("`jwks_uri` {jwks_uri:?}: {e}"),
})?;
if jwks_uri.scheme() != "https" {
return Err(JwksError::NotHttps(jwks_uri));
}
self.fetch_json::<KeySet>(&jwks_uri).await?.keys
}
(Some(_), Some(_)) => {
return Err(JwksError::InvalidMetadata {
url,
reason: "both `jwks` and `jwks_uri` are present".to_owned(),
});
}
(None, None) => {
return Err(JwksError::InvalidMetadata {
url,
reason: "neither `jwks` nor `jwks_uri` is present".to_owned(),
});
}
};
let keys: Arc<[Value]> = keys.into();
self.store(issuer, Arc::clone(&keys));
Ok(keys)
}
async fn fetch_json<T: serde::de::DeserializeOwned>(&self, url: &Url) -> Result<T, JwksError> {
let response = self
.client
.get(url)
.await
.map_err(|source| JwksError::Http {
url: url.clone(),
source,
})?;
if !(200..300).contains(&response.status) {
return Err(JwksError::HttpStatus {
url: url.clone(),
status: response.status,
});
}
serde_json::from_slice(&response.body).map_err(|e| JwksError::InvalidMetadata {
url: url.clone(),
reason: e.to_string(),
})
}
}
fn metadata_url(issuer: &Url) -> Result<Url, JwksError> {
if issuer.scheme() != "https" {
return Err(JwksError::NotHttps(issuer.clone()));
}
if issuer.query().is_some()
|| issuer.fragment().is_some()
|| issuer.host().is_none()
|| !issuer.username().is_empty()
|| issuer.password().is_some()
{
return Err(JwksError::InvalidIssuer(issuer.clone()));
}
let path = issuer.path().trim_end_matches('/');
let mut url = issuer.clone();
url.set_path(&format!("/.well-known/jwt-vc-issuer{path}"));
Ok(url)
}
fn select(keys: &[Value], issuer: &Url, kid: &str) -> Result<Jwk, JwksError> {
let mut matching = keys
.iter()
.filter(|key| key.get("kid").and_then(Value::as_str) == Some(kid));
let key = matching.next().ok_or_else(|| JwksError::UnknownKid {
issuer: issuer.clone(),
kid: kid.to_owned(),
})?;
if matching.next().is_some() {
return Err(JwksError::DuplicateKid {
issuer: issuer.clone(),
kid: kid.to_owned(),
});
}
let jwk: Jwk = serde_json::from_value(key.clone()).map_err(|e| JwksError::InvalidKey {
kid: kid.to_owned(),
reason: e.to_string(),
})?;
if jwk.is_private() {
return Err(JwksError::InvalidKey {
kid: kid.to_owned(),
reason: "the key set publishes private material".to_owned(),
});
}
Ok(jwk)
}
impl<C> fmt::Debug for JwksResolver<C>
where
C: fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JwksResolver")
.field("client", &self.client)
.field("ttl", &self.ttl)
.field("capacity", &self.capacity)
.finish_non_exhaustive()
}
}
#[async_trait::async_trait]
impl<C: HttpClient> KeyResolver for JwksResolver<C> {
async fn resolve_key(&self, key: &KeyReference, _: KeyRole) -> Result<Jwk, KeyResolutionError> {
let KeyReference::Jwks { issuer, kid } = key else {
return Err(KeyResolutionError::unsupported(key));
};
let keys = self.key_set(issuer).await?;
Ok(select(&keys, issuer, kid)?)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum JwksError {
#[error("`{0}` is not an HTTPS URL")]
NotHttps(Url),
#[error(
"`{0}` is not an issuer identifier: it has a query, a fragment or credentials, or no host"
)]
InvalidIssuer(Url),
#[error("fetching `{url}` failed: {source}")]
Http {
url: Url,
#[source]
source: HttpError,
},
#[error("fetching `{url}` returned HTTP {status}")]
HttpStatus {
url: Url,
status: u16,
},
#[error("`{url}`: {reason}")]
InvalidMetadata {
url: Url,
reason: String,
},
#[error("the metadata of `{expected}` names the issuer {found:?}")]
IssuerMismatch {
expected: Url,
found: String,
},
#[error("issuer `{issuer}` has no key `{kid}`")]
UnknownKid {
issuer: Url,
kid: String,
},
#[error("issuer `{issuer}` has several keys `{kid}`")]
DuplicateKid {
issuer: Url,
kid: String,
},
#[error("key `{kid}`: {reason}")]
InvalidKey {
kid: String,
reason: String,
},
}
impl From<JwksError> for KeyResolutionError {
fn from(error: JwksError) -> Self {
Self::new(error)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_entry_expires_at_its_instant() {
let now = Instant::now();
let entry = Entry {
keys: Arc::from([]),
expires_at: Some(now),
};
assert!(entry.is_live(now.checked_sub(Duration::from_nanos(1)).unwrap()));
assert!(!entry.is_live(now));
}
}