use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use jsonwebtoken::{Algorithm, DecodingKey};
use super::RejectReason;
const DEFAULT_TTL: Duration = Duration::from_secs(300);
const MIN_TTL: Duration = Duration::from_secs(60);
const MAX_TTL: Duration = Duration::from_secs(86_400);
const REFETCH_FLOOR: Duration = Duration::from_secs(30);
const MAX_JWKS_BYTES: usize = 262_144;
const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
struct Entry {
keys: Vec<(Option<String>, Option<Algorithm>, Arc<DecodingKey>)>,
fetched_at: Instant,
ttl: Duration,
last_forced: Option<Instant>,
}
pub struct JwksCache {
entries: tokio::sync::RwLock<HashMap<String, Arc<Entry>>>,
fetch_lock: tokio::sync::Mutex<()>,
client: reqwest::Client,
allow_private: bool,
}
impl JwksCache {
pub fn new(client: reqwest::Client, allow_private: bool) -> Self {
Self {
entries: tokio::sync::RwLock::new(HashMap::new()),
fetch_lock: tokio::sync::Mutex::new(()),
client,
allow_private,
}
}
pub async fn decoding_keys(
&self,
url: &str,
kid: Option<&str>,
alg: Algorithm,
) -> Result<Vec<Arc<DecodingKey>>, RejectReason> {
let entry = match self.fresh_entry(url, false).await {
Some(entry) => entry,
None => return Err(RejectReason::KeysUnavailable),
};
let matched = select(&entry, kid, alg);
if !matched.is_empty() {
return Ok(matched);
}
if kid.is_some()
&& let Some(entry) = self.fresh_entry(url, true).await
{
let matched = select(&entry, kid, alg);
if !matched.is_empty() {
return Ok(matched);
}
}
Err(RejectReason::UnknownKid)
}
async fn fresh_entry(&self, url: &str, force: bool) -> Option<Arc<Entry>> {
let existing = self.entries.read().await.get(url).cloned();
if !needs_fetch(existing.as_ref(), force) {
return existing;
}
let _flight = self.fetch_lock.lock().await;
let current = self.entries.read().await.get(url).cloned();
if !needs_fetch(current.as_ref(), force) {
return current;
}
match self.fetch(url).await {
Ok((keys, ttl)) => {
let entry = Arc::new(Entry {
keys,
fetched_at: Instant::now(),
ttl,
last_forced: force.then(Instant::now),
});
self.entries
.write()
.await
.insert(url.to_string(), Arc::clone(&entry));
Some(entry)
}
Err(e) => {
tracing::warn!(url = %url, error = %e, "JWKS refresh failed; serving cached keys");
if force && let Some(old) = current.clone() {
let entry = Arc::new(Entry {
keys: old.keys.clone(),
fetched_at: old.fetched_at,
ttl: old.ttl,
last_forced: Some(Instant::now()),
});
self.entries
.write()
.await
.insert(url.to_string(), Arc::clone(&entry));
return Some(entry);
}
current
}
}
}
async fn fetch(&self, url: &str) -> Result<(FetchedKeys, Duration), String> {
if !self.allow_private {
crate::validation::validate_url_not_private(url).await?;
}
let response = self
.client
.get(url)
.timeout(FETCH_TIMEOUT)
.send()
.await
.map_err(|e| format!("fetch failed: {e}"))?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
let ttl = ttl_from_cache_control(
response
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok()),
);
let body = response
.bytes()
.await
.map_err(|e| format!("read failed: {e}"))?;
if body.len() > MAX_JWKS_BYTES {
return Err(format!(
"document is {} bytes (cap {MAX_JWKS_BYTES})",
body.len()
));
}
let set: jsonwebtoken::jwk::JwkSet =
serde_json::from_slice(&body).map_err(|e| format!("not a JWK set: {e}"))?;
let mut keys: FetchedKeys = Vec::with_capacity(set.keys.len());
for jwk in &set.keys {
let Ok(decoded) = DecodingKey::from_jwk(jwk) else {
continue;
};
let alg = jwk
.common
.key_algorithm
.and_then(|a| super::parse_algorithm(a.to_string().as_str()).ok());
keys.push((jwk.common.key_id.clone(), alg, Arc::new(decoded)));
}
Ok((keys, ttl))
}
}
fn select(entry: &Entry, kid: Option<&str>, alg: Algorithm) -> Vec<Arc<DecodingKey>> {
entry
.keys
.iter()
.filter(|(entry_kid, entry_alg, _)| {
entry_alg.is_none_or(|a| a == alg)
&& match kid {
Some(kid) => entry_kid.as_deref() == Some(kid),
None => true,
}
})
.map(|(_, _, key)| Arc::clone(key))
.collect()
}
fn needs_fetch(entry: Option<&Arc<Entry>>, force: bool) -> bool {
match entry {
None => true,
Some(entry) => {
entry.fetched_at.elapsed() > entry.ttl
|| (force
&& entry
.last_forced
.is_none_or(|at| at.elapsed() > REFETCH_FLOOR))
}
}
}
type FetchedKeys = Vec<(Option<String>, Option<Algorithm>, Arc<DecodingKey>)>;
fn ttl_from_cache_control(header: Option<&str>) -> Duration {
let max_age = header.and_then(|value| {
value.split(',').find_map(|directive| {
directive
.trim()
.strip_prefix("max-age=")
.and_then(|secs| secs.trim().parse::<u64>().ok())
})
});
match max_age {
Some(secs) => Duration::from_secs(secs).clamp(MIN_TTL, MAX_TTL),
None => DEFAULT_TTL,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
async fn mock_jwks() -> (String, Arc<AtomicUsize>) {
use base64::Engine as _;
let k = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("a-symmetric-test-secret");
let hits = Arc::new(AtomicUsize::new(0));
let served = hits.clone();
let app = axum::Router::new().route(
"/jwks.json",
axum::routing::get(move || {
let hits = served.clone();
let k = k.clone();
async move {
hits.fetch_add(1, Ordering::SeqCst);
axum::Json(serde_json::json!({
"keys": [{"kty": "oct", "k": k, "kid": "one", "alg": "HS256"}]
}))
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("test listener");
let addr = listener.local_addr().expect("test addr");
tokio::spawn(async move { axum::serve(listener, app).await.expect("test serve") });
(format!("http://{addr}/jwks.json"), hits)
}
#[tokio::test]
async fn a_private_jwks_url_is_refused_before_the_request_is_made() {
let (url, hits) = mock_jwks().await;
let cache = JwksCache::new(reqwest::Client::new(), false);
let result = cache
.decoding_keys(&url, Some("one"), Algorithm::HS256)
.await;
assert_eq!(result.err(), Some(RejectReason::KeysUnavailable));
assert_eq!(hits.load(Ordering::SeqCst), 0, "the mock was contacted");
}
#[tokio::test]
async fn allow_private_lets_an_in_cluster_issuer_through() {
let (url, hits) = mock_jwks().await;
let cache = JwksCache::new(reqwest::Client::new(), true);
let keys = cache
.decoding_keys(&url, Some("one"), Algorithm::HS256)
.await
.expect("the key set is served");
assert_eq!(keys.len(), 1);
assert_eq!(hits.load(Ordering::SeqCst), 1);
}
}