use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use arc_swap::ArcSwapOption;
use p256::ecdsa::VerifyingKey;
use tokio::{sync::Mutex, task::JoinHandle};
use crate::token::{BoxFuture, IssuerKeys, JwksError, KeysUnavailable, parse_jwks};
#[derive(Debug, Clone, Copy)]
pub struct RemoteJwksConfig {
pub fetch_timeout: Duration,
pub refresh_interval: Duration,
pub min_refresh_interval: Duration,
pub max_staleness: Duration,
pub max_document_bytes: usize,
}
impl Default for RemoteJwksConfig {
fn default() -> Self {
Self {
fetch_timeout: Duration::from_secs(3),
refresh_interval: Duration::from_mins(5),
min_refresh_interval: Duration::from_secs(30),
max_staleness: Duration::from_hours(6),
max_document_bytes: 64 * 1024,
}
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum JwksFetchError {
#[error("JWKS request failed")]
Request(#[from] Arc<reqwest::Error>),
#[error("JWKS endpoint answered {0}")]
Status(reqwest::StatusCode),
#[error("JWKS document is too large")]
TooLarge,
#[error(transparent)]
Invalid(#[from] Arc<JwksError>),
#[error("JWKS document has no usable key")]
Empty,
#[error("JWKS fetch task failed")]
Task(#[from] Arc<tokio::task::JoinError>),
}
#[derive(Clone)]
pub struct RemoteJwks {
inner: Arc<Inner>,
}
struct Inner {
url: String,
client: reqwest::Client,
config: RemoteJwksConfig,
cache: ArcSwapOption<Snapshot>,
last_attempt: Arc<Mutex<Option<RefreshAttempt>>>,
}
struct Snapshot {
keys: HashMap<String, VerifyingKey>,
fetched_at: Instant,
}
struct RefreshAttempt {
finished_at: Instant,
result: Result<(), JwksFetchError>,
}
enum Cached {
Key(VerifyingKey),
UnknownKid,
Unusable,
}
impl RemoteJwks {
#[must_use]
pub fn new(url: impl Into<String>, client: reqwest::Client, config: RemoteJwksConfig) -> Self {
Self {
inner: Arc::new(Inner {
url: url.into(),
client,
config,
cache: ArcSwapOption::empty(),
last_attempt: Arc::new(Mutex::new(None)),
}),
}
}
pub async fn refresh(&self) -> Result<(), JwksFetchError> {
self.refresh_shared(Duration::ZERO).await
}
#[must_use]
pub fn age(&self) -> Option<Duration> {
self.inner
.cache
.load()
.as_ref()
.map(|snapshot| snapshot.fetched_at.elapsed())
}
#[must_use]
pub fn is_usable(&self) -> bool {
self.age()
.is_some_and(|age| age <= self.inner.config.max_staleness)
}
#[must_use]
pub fn spawn_refresh_task(&self) -> JoinHandle<()> {
let jwks = self.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(jitter(jwks.inner.config.refresh_interval)).await;
if let Err(error) = jwks.refresh().await {
tracing::warn!(
url = %jwks.inner.url,
age_secs = jwks.age().map(|age| age.as_secs()),
error = %error,
"JWKS refresh failed; serving cached keys",
);
}
}
})
}
async fn refresh_shared(&self, cooldown: Duration) -> Result<(), JwksFetchError> {
let started = Instant::now();
let mut last_attempt = self.inner.last_attempt.clone().lock_owned().await;
if let Some(attempt) = last_attempt.as_ref()
&& (attempt.finished_at >= started || attempt.finished_at.elapsed() < cooldown)
{
return attempt.result.clone();
}
let jwks = self.clone();
tokio::spawn(async move {
let result = jwks.fetch().await;
*last_attempt = Some(RefreshAttempt {
finished_at: Instant::now(),
result: result.clone(),
});
result
})
.await
.map_err(Arc::new)?
}
async fn fetch(&self) -> Result<(), JwksFetchError> {
let config = &self.inner.config;
let mut response = self
.inner
.client
.get(&self.inner.url)
.timeout(config.fetch_timeout)
.send()
.await
.map_err(Arc::new)?;
if !response.status().is_success() {
return Err(JwksFetchError::Status(response.status()));
}
let mut document = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(Arc::new)? {
if document.len() + chunk.len() > config.max_document_bytes {
return Err(JwksFetchError::TooLarge);
}
document.extend_from_slice(&chunk);
}
let keys = parse_jwks(&document).map_err(Arc::new)?;
if keys.is_empty() {
return Err(JwksFetchError::Empty);
}
self.inner.cache.store(Some(Arc::new(Snapshot {
keys,
fetched_at: Instant::now(),
})));
Ok(())
}
fn cached(&self, kid: &str) -> Cached {
let cache = self.inner.cache.load();
match cache.as_ref() {
Some(snapshot) if snapshot.fetched_at.elapsed() <= self.inner.config.max_staleness => {
snapshot
.keys
.get(kid)
.map_or(Cached::UnknownKid, |key| Cached::Key(*key))
}
_ => Cached::Unusable,
}
}
}
impl IssuerKeys for RemoteJwks {
fn key<'a>(
&'a self,
kid: &'a str,
) -> BoxFuture<'a, Result<Option<VerifyingKey>, KeysUnavailable>> {
Box::pin(async move {
if let Cached::Key(key) = self.cached(kid) {
return Ok(Some(key));
}
self.refresh_shared(self.inner.config.min_refresh_interval)
.await
.map_err(|error| KeysUnavailable(Box::new(error)))?;
match self.cached(kid) {
Cached::Key(key) => Ok(Some(key)),
Cached::UnknownKid => Ok(None),
Cached::Unusable => Err(KeysUnavailable("no usable JWKS is cached".into())),
}
})
}
}
fn jitter(interval: Duration) -> Duration {
let mut byte = [0u8; 1];
let fraction = getrandom::fill(&mut byte).map_or(0.0, |()| f64::from(byte[0]) / 255.0);
interval.mul_f64(1.0 - 0.1 * fraction)
}