use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use openidconnect::core::CoreJsonWebKeySet;
use openidconnect::{reqwest, JsonWebKeySetUrl};
use super::redirect::APPLE_JWKS_URI;
pub const DEFAULT_REFRESH_AFTER_SECONDS: i64 = 7 * 24 * 60 * 60;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum JwksError {
#[error("jwks http: {0}")]
Http(String),
#[error("jwks parse: {0}")]
Parse(String),
#[error("invalid jwks url: {0}")]
InvalidUrl(String),
}
#[async_trait]
pub trait JwksFetcher: Send + Sync {
async fn fetch(&self, http: &reqwest::Client) -> Result<CoreJsonWebKeySet, JwksError>;
}
#[derive(Debug, Clone)]
pub struct HttpJwksFetcher {
url: JsonWebKeySetUrl,
}
impl HttpJwksFetcher {
pub fn for_apple() -> Self {
Self {
url: JsonWebKeySetUrl::new(APPLE_JWKS_URI.to_owned())
.expect("APPLE_JWKS_URI is a const, must parse"),
}
}
pub fn with_url(url: impl Into<String>) -> Result<Self, JwksError> {
let url = url.into();
let url = JsonWebKeySetUrl::new(url.clone())
.map_err(|e| JwksError::InvalidUrl(format!("{url}: {e}")))?;
Ok(Self { url })
}
pub fn url(&self) -> &JsonWebKeySetUrl {
&self.url
}
}
#[async_trait]
impl JwksFetcher for HttpJwksFetcher {
async fn fetch(&self, http: &reqwest::Client) -> Result<CoreJsonWebKeySet, JwksError> {
let resp = http
.get(self.url.as_str())
.send()
.await
.map_err(|e| JwksError::Http(format!("{e}")))?
.error_for_status()
.map_err(|e| JwksError::Http(format!("{e}")))?;
let bytes = resp
.bytes()
.await
.map_err(|e| JwksError::Http(format!("{e}")))?;
serde_json::from_slice::<CoreJsonWebKeySet>(&bytes)
.map_err(|e| JwksError::Parse(format!("{e}")))
}
}
#[derive(Clone)]
struct CachedJwks {
jwks: Arc<CoreJsonWebKeySet>,
fetched_at: i64,
}
pub struct AppleJwksCache<F: JwksFetcher = HttpJwksFetcher> {
fetcher: F,
refresh_after_seconds: i64,
cache: Mutex<Option<CachedJwks>>,
}
impl AppleJwksCache<HttpJwksFetcher> {
pub fn for_apple() -> Self {
Self::new(HttpJwksFetcher::for_apple())
}
}
impl<F: JwksFetcher> AppleJwksCache<F> {
pub fn new(fetcher: F) -> Self {
Self {
fetcher,
refresh_after_seconds: DEFAULT_REFRESH_AFTER_SECONDS,
cache: Mutex::new(None),
}
}
pub fn with_refresh_after_seconds(mut self, seconds: i64) -> Self {
assert!(
seconds > 0,
"refresh_after_seconds must be positive, got {seconds}"
);
self.refresh_after_seconds = seconds;
self
}
pub fn refresh_after_seconds(&self) -> i64 {
self.refresh_after_seconds
}
pub fn fetcher(&self) -> &F {
&self.fetcher
}
pub async fn jwks(
&self,
now: i64,
http: &reqwest::Client,
) -> Result<Arc<CoreJsonWebKeySet>, JwksError> {
{
let guard = self.cache.lock().expect("jwks cache mutex");
if let Some(c) = guard.as_ref() {
if self.is_fresh(c.fetched_at, now) {
return Ok(Arc::clone(&c.jwks));
}
}
}
let fresh = self.fetcher.fetch(http).await?;
let arc = Arc::new(fresh);
let mut guard = self.cache.lock().expect("jwks cache mutex");
if let Some(c) = guard.as_ref() {
if self.is_fresh(c.fetched_at, now) {
return Ok(Arc::clone(&c.jwks));
}
}
*guard = Some(CachedJwks {
jwks: Arc::clone(&arc),
fetched_at: now,
});
Ok(arc)
}
pub fn invalidate(&self) {
*self.cache.lock().expect("jwks cache mutex") = None;
}
pub fn cached_fetched_at(&self) -> Option<i64> {
self.cache
.lock()
.expect("jwks cache mutex")
.as_ref()
.map(|c| c.fetched_at)
}
pub fn is_cached(&self) -> bool {
self.cache
.lock()
.expect("jwks cache mutex")
.is_some()
}
fn is_fresh(&self, fetched_at: i64, now: i64) -> bool {
now.saturating_sub(fetched_at) < self.refresh_after_seconds
}
}
impl<F: JwksFetcher> std::fmt::Debug for AppleJwksCache<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppleJwksCache")
.field("refresh_after_seconds", &self.refresh_after_seconds)
.field("cached_fetched_at", &self.cached_fetched_at())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use openidconnect::core::CoreJsonWebKey;
use openidconnect::JsonWebKey;
use std::sync::atomic::{AtomicU64, Ordering};
fn dummy_http() -> reqwest::Client {
reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("reqwest builds")
}
struct CountingFetcher {
calls: AtomicU64,
}
impl CountingFetcher {
fn new() -> Self {
Self {
calls: AtomicU64::new(0),
}
}
fn calls(&self) -> u64 {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl JwksFetcher for CountingFetcher {
async fn fetch(&self, _http: &reqwest::Client) -> Result<CoreJsonWebKeySet, JwksError> {
let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
let jwk_json = format!(
r#"{{
"kty": "RSA",
"kid": "test-key-{n}",
"use": "sig",
"alg": "RS256",
"n": "sRMj0YYjy7du6v1gWyKSTJx3YjBzZTG0XotRP0IaObw0k-6830dXadjL5jVhSWNdcg9OyMyTGWfdNqfdrS6ppBqlQNgjZJdloIqL9zOLBZrDm7G4-qN4KeZ4_5TyEilq2zOHHGFEzXpOq_UxqVnm3J4fhjqCNaS2nKd7HVVXGBQQ-4-FdVT-MyJXemw5maz2F_h324TQi6XoUPEwUddxBwLQFSOlzWnHYMc4_lcyZJ8MpTXCMPe_YJFNtb9CaikKUdf8x4mzwH7usSf8s2d6R4dQITzKrjrEJ0u3w3eGkBBapoMVFBGPjP3Haz5FsVtHc5VEN3FZVIDF6HrbJH1C4Q",
"e": "AQAB"
}}"#
);
let key: CoreJsonWebKey =
serde_json::from_str(&jwk_json).expect("fixture JWK parses");
Ok(CoreJsonWebKeySet::new(vec![key]))
}
}
fn kid_of(jwks: &CoreJsonWebKeySet) -> Option<String> {
jwks.keys()
.first()
.and_then(|k| k.key_id())
.map(|id| id.to_string())
}
#[tokio::test]
async fn first_call_fetches_and_caches() {
let cache = AppleJwksCache::new(CountingFetcher::new());
assert!(!cache.is_cached());
assert!(cache.cached_fetched_at().is_none());
let jwks = cache.jwks(1_000, &dummy_http()).await.unwrap();
assert_eq!(cache.fetcher().calls(), 1);
assert_eq!(kid_of(&jwks).as_deref(), Some("test-key-1"));
assert!(cache.is_cached());
assert_eq!(cache.cached_fetched_at(), Some(1_000));
}
#[tokio::test]
async fn second_call_inside_window_serves_cache() {
let cache = AppleJwksCache::new(CountingFetcher::new());
let _ = cache.jwks(1_000, &dummy_http()).await.unwrap();
let jwks = cache
.jwks(1_000 + DEFAULT_REFRESH_AFTER_SECONDS - 1, &dummy_http())
.await
.unwrap();
assert_eq!(cache.fetcher().calls(), 1, "cache hit, no refetch");
assert_eq!(kid_of(&jwks).as_deref(), Some("test-key-1"));
}
#[tokio::test]
async fn call_at_refresh_boundary_refetches() {
let cache = AppleJwksCache::new(CountingFetcher::new());
let _ = cache.jwks(1_000, &dummy_http()).await.unwrap();
let jwks = cache
.jwks(1_000 + DEFAULT_REFRESH_AFTER_SECONDS, &dummy_http())
.await
.unwrap();
assert_eq!(cache.fetcher().calls(), 2);
assert_eq!(kid_of(&jwks).as_deref(), Some("test-key-2"));
assert_eq!(
cache.cached_fetched_at(),
Some(1_000 + DEFAULT_REFRESH_AFTER_SECONDS)
);
}
#[tokio::test]
async fn invalidate_forces_refetch_next_call() {
let cache = AppleJwksCache::new(CountingFetcher::new());
let _ = cache.jwks(1_000, &dummy_http()).await.unwrap();
assert_eq!(cache.fetcher().calls(), 1);
cache.invalidate();
assert!(!cache.is_cached());
assert!(cache.cached_fetched_at().is_none());
let jwks = cache.jwks(1_001, &dummy_http()).await.unwrap();
assert_eq!(cache.fetcher().calls(), 2);
assert_eq!(kid_of(&jwks).as_deref(), Some("test-key-2"));
}
#[tokio::test]
async fn with_refresh_after_seconds_overrides_default() {
let cache = AppleJwksCache::new(CountingFetcher::new())
.with_refresh_after_seconds(60);
assert_eq!(cache.refresh_after_seconds(), 60);
let _ = cache.jwks(1_000, &dummy_http()).await.unwrap();
let _ = cache.jwks(1_059, &dummy_http()).await.unwrap();
assert_eq!(cache.fetcher().calls(), 1, "still fresh under 60s");
let _ = cache.jwks(1_060, &dummy_http()).await.unwrap();
assert_eq!(cache.fetcher().calls(), 2, "boundary refresh");
}
#[test]
#[should_panic(expected = "refresh_after_seconds must be positive")]
fn with_refresh_after_seconds_rejects_zero() {
let _ = AppleJwksCache::new(CountingFetcher::new()).with_refresh_after_seconds(0);
}
#[test]
#[should_panic(expected = "refresh_after_seconds must be positive")]
fn with_refresh_after_seconds_rejects_negative() {
let _ = AppleJwksCache::new(CountingFetcher::new()).with_refresh_after_seconds(-1);
}
#[test]
fn http_fetcher_for_apple_targets_published_uri() {
let f = HttpJwksFetcher::for_apple();
assert_eq!(f.url().as_str(), APPLE_JWKS_URI);
}
#[test]
fn http_fetcher_with_url_rejects_garbage() {
let err = HttpJwksFetcher::with_url("not a url").unwrap_err();
assert!(matches!(err, JwksError::InvalidUrl(_)));
}
#[test]
fn http_fetcher_with_url_accepts_self_hosted() {
let f = HttpJwksFetcher::with_url("https://idp.example.invalid/keys").unwrap();
assert_eq!(f.url().as_str(), "https://idp.example.invalid/keys");
}
struct FailingFetcher {
calls: AtomicU64,
}
impl FailingFetcher {
fn new() -> Self {
Self {
calls: AtomicU64::new(0),
}
}
fn calls(&self) -> u64 {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl JwksFetcher for FailingFetcher {
async fn fetch(&self, _http: &reqwest::Client) -> Result<CoreJsonWebKeySet, JwksError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Err(JwksError::Http("simulated network failure".into()))
}
}
#[tokio::test]
async fn fetch_failure_does_not_poison_cache() {
let cache = AppleJwksCache::new(FailingFetcher::new());
let err = cache.jwks(1_000, &dummy_http()).await.unwrap_err();
assert!(matches!(err, JwksError::Http(_)));
assert!(!cache.is_cached(), "failed fetch must not be stored");
let _ = cache.jwks(1_001, &dummy_http()).await.unwrap_err();
assert_eq!(cache.fetcher().calls(), 2);
}
#[tokio::test]
async fn cache_returns_shared_arc_to_same_jwks() {
let cache = AppleJwksCache::new(CountingFetcher::new());
let a = cache.jwks(1_000, &dummy_http()).await.unwrap();
let b = cache.jwks(1_001, &dummy_http()).await.unwrap();
assert!(
Arc::ptr_eq(&a, &b),
"cache hit should return the same Arc, not a clone"
);
}
#[test]
fn cache_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AppleJwksCache<HttpJwksFetcher>>();
assert_send_sync::<AppleJwksCache<CountingFetcher>>();
}
}