use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use jsonwebtoken::jwk::{Jwk, JwkSet};
use jsonwebtoken::{Algorithm, DecodingKey};
use parking_lot::RwLock;
use serde::Deserialize;
use tokio::sync::Mutex as AsyncMutex;
use crate::auth::error::AuthError;
const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Deserialize)]
struct OidcDiscoveryDocument {
jwks_uri: String,
issuer: String,
}
fn tls_config() -> Result<rustls::ClientConfig, rustls::Error> {
use rustls_platform_verifier::BuilderVerifierExt;
let provider = rustls::crypto::CryptoProvider::get_default()
.cloned()
.unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
Ok(rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()?
.with_platform_verifier()?
.with_no_client_auth())
}
fn http_client() -> Result<reqwest::Client, AuthError> {
let tls = tls_config()
.map_err(|e| AuthError::Config(format!("failed to build TLS configuration: {e}")))?;
reqwest::Client::builder()
.timeout(HTTP_TIMEOUT)
.use_preconfigured_tls(tls)
.build()
.map_err(|e| AuthError::Config(format!("failed to build HTTP client: {e}")))
}
pub async fn discover_jwks_url(issuer: &str) -> Result<String, AuthError> {
let url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let client = http_client()?;
let resp =
client.get(&url).send().await.map_err(|e| {
AuthError::Config(format!("OIDC discovery request failed for {url}: {e}"))
})?;
if !resp.status().is_success() {
return Err(AuthError::Config(format!(
"OIDC discovery returned HTTP {}",
resp.status()
)));
}
let doc: OidcDiscoveryDocument = resp
.json()
.await
.map_err(|e| AuthError::Config(format!("failed to parse OIDC discovery document: {e}")))?;
let expected = issuer.trim_end_matches('/');
let actual = doc.issuer.trim_end_matches('/');
if expected != actual {
return Err(AuthError::Config(format!(
"OIDC issuer mismatch: expected {expected}, document reports {actual}"
)));
}
Ok(doc.jwks_uri)
}
pub async fn fetch_jwks(url: &str) -> Result<JwkSet, AuthError> {
let client = http_client()?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| AuthError::Config(format!("JWKS fetch failed for {url}: {e}")))?;
if !resp.status().is_success() {
return Err(AuthError::Config(format!(
"JWKS endpoint returned HTTP {}",
resp.status()
)));
}
resp.json::<JwkSet>()
.await
.map_err(|e| AuthError::Config(format!("failed to parse JWKS: {e}")))
}
fn decoding_key_from_jwk(jwk: &Jwk) -> Result<DecodingKey, AuthError> {
DecodingKey::from_jwk(jwk)
.map_err(|e| AuthError::Config(format!("failed to create decoding key from JWK: {e}")))
}
fn algorithm_for_jwk(jwk: &Jwk) -> Algorithm {
jwk.common
.key_algorithm
.map_or(Algorithm::RS256, |ka| match ka {
jsonwebtoken::jwk::KeyAlgorithm::RS256 => Algorithm::RS256,
jsonwebtoken::jwk::KeyAlgorithm::RS384 => Algorithm::RS384,
jsonwebtoken::jwk::KeyAlgorithm::RS512 => Algorithm::RS512,
jsonwebtoken::jwk::KeyAlgorithm::ES256 => Algorithm::ES256,
jsonwebtoken::jwk::KeyAlgorithm::ES384 => Algorithm::ES384,
_ => Algorithm::RS256,
})
}
#[derive(Clone)]
pub struct CachedKey {
pub key: DecodingKey,
pub algorithm: Algorithm,
}
impl std::fmt::Debug for CachedKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedKey")
.field("algorithm", &self.algorithm)
.finish_non_exhaustive()
}
}
struct CacheInner {
keys: HashMap<String, CachedKey>,
fetched_at: Option<Instant>,
}
pub struct JwksCache {
jwks_url: String,
ttl: Duration,
max_stale: Duration,
inner: Arc<RwLock<CacheInner>>,
refresh_mutex: Arc<AsyncMutex<()>>,
}
impl std::fmt::Debug for JwksCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwksCache")
.field("jwks_url", &self.jwks_url)
.field("ttl", &self.ttl)
.field("cached_keys", &self.inner.read().keys.len())
.finish()
}
}
impl JwksCache {
#[must_use]
pub fn new(jwks_url: String, ttl: Duration) -> Self {
Self {
jwks_url,
ttl,
max_stale: Duration::from_secs(24 * 3600), inner: Arc::new(RwLock::new(CacheInner {
keys: HashMap::new(),
fetched_at: None,
})),
refresh_mutex: Arc::new(AsyncMutex::new(())),
}
}
pub async fn from_oidc_issuer(issuer: &str, ttl: Duration) -> Result<Self, AuthError> {
let jwks_url = discover_jwks_url(issuer).await?;
let cache = Self::new(jwks_url, ttl);
cache.refresh().await?;
Ok(cache)
}
#[must_use]
pub fn from_keys(keys: HashMap<String, CachedKey>) -> Self {
Self {
jwks_url: String::new(),
ttl: Duration::from_secs(3600),
max_stale: Duration::from_secs(24 * 3600),
inner: Arc::new(RwLock::new(CacheInner {
keys,
fetched_at: Some(Instant::now()),
})),
refresh_mutex: Arc::new(AsyncMutex::new(())),
}
}
#[must_use]
pub fn is_expired(&self) -> bool {
let inner = self.inner.read();
match inner.fetched_at {
None => true,
Some(t) => t.elapsed() > self.ttl,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().keys.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.inner.read().keys.is_empty()
}
pub async fn refresh(&self) -> Result<(), AuthError> {
if self.jwks_url.is_empty() {
return Err(AuthError::Config(
"cannot refresh: no JWKS URL configured (cache was created via from_keys)".into(),
));
}
let jwk_set = match fetch_jwks(&self.jwks_url).await {
Ok(set) => set,
Err(e) => {
let inner = self.inner.read();
if !inner.keys.is_empty() {
let too_old = inner
.fetched_at
.is_none_or(|t| t.elapsed() > self.max_stale);
if too_old {
tracing::error!(
url = %self.jwks_url,
error = %e,
max_stale_secs = self.max_stale.as_secs(),
"JWKS refresh failed and stale keys exceeded max_stale โ rejecting"
);
return Err(e);
}
tracing::warn!(
url = %self.jwks_url,
error = %e,
"JWKS refresh failed, serving stale keys"
);
return Ok(());
}
return Err(e);
}
};
let mut new_keys = HashMap::new();
for jwk in &jwk_set.keys {
let kid = jwk
.common
.key_id
.clone()
.unwrap_or_else(|| "default".to_string());
let dk = decoding_key_from_jwk(jwk)?;
let alg = algorithm_for_jwk(jwk);
new_keys.insert(
kid,
CachedKey {
key: dk,
algorithm: alg,
},
);
}
let mut inner = self.inner.write();
inner.keys = new_keys;
inner.fetched_at = Some(Instant::now());
tracing::debug!(
keys = inner.keys.len(),
url = %self.jwks_url,
"JWKS cache refreshed"
);
Ok(())
}
async fn ensure_fresh(&self) -> Result<(), AuthError> {
if !self.is_expired() {
return Ok(());
}
let _guard = self.refresh_mutex.lock().await;
if !self.is_expired() {
return Ok(());
}
self.refresh().await
}
pub async fn get_key(&self, kid: &str) -> Result<CachedKey, AuthError> {
self.ensure_fresh().await?;
let inner = self.inner.read();
inner
.keys
.get(kid)
.cloned()
.ok_or_else(|| AuthError::Config(format!("no JWKS key with kid={kid}")))
}
pub async fn get_all_keys(&self) -> Result<Vec<CachedKey>, AuthError> {
self.ensure_fresh().await?;
let inner = self.inner.read();
Ok(inner.keys.values().cloned().collect())
}
#[must_use]
pub fn jwks_url(&self) -> &str {
&self.jwks_url
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_from_keys_not_expired() {
let mut keys = HashMap::new();
keys.insert(
"kid-1".to_string(),
CachedKey {
key: DecodingKey::from_secret(b"test"),
algorithm: Algorithm::HS256,
},
);
let cache = JwksCache::from_keys(keys);
assert!(!cache.is_expired());
assert_eq!(cache.len(), 1);
assert!(!cache.is_empty());
}
#[test]
fn empty_cache_is_expired() {
let cache = JwksCache::new("https://example.com/jwks".into(), Duration::from_secs(60));
assert!(cache.is_expired());
assert!(cache.is_empty());
}
#[test]
fn cache_expires_after_ttl() {
let cache = JwksCache {
jwks_url: String::new(),
ttl: Duration::from_millis(0), max_stale: Duration::from_secs(24 * 3600),
inner: Arc::new(RwLock::new(CacheInner {
keys: HashMap::new(),
fetched_at: Some(Instant::now().checked_sub(Duration::from_secs(1)).unwrap()),
})),
refresh_mutex: Arc::new(AsyncMutex::new(())),
};
assert!(cache.is_expired());
}
#[tokio::test]
async fn get_key_from_prepopulated_cache() {
let mut keys = HashMap::new();
keys.insert(
"my-kid".to_string(),
CachedKey {
key: DecodingKey::from_secret(b"secret"),
algorithm: Algorithm::HS256,
},
);
let cache = JwksCache::from_keys(keys);
let k = cache.get_key("my-kid").await.unwrap();
assert_eq!(k.algorithm, Algorithm::HS256);
}
#[tokio::test]
async fn get_key_missing_kid_returns_error() {
let cache = JwksCache::from_keys(HashMap::new());
let err = cache.get_key("nonexistent").await.unwrap_err();
assert!(matches!(err, AuthError::Config(_)));
}
#[tokio::test]
async fn get_all_keys_from_prepopulated_cache() {
let mut keys = HashMap::new();
keys.insert(
"kid-a".to_string(),
CachedKey {
key: DecodingKey::from_secret(b"a"),
algorithm: Algorithm::RS256,
},
);
keys.insert(
"kid-b".to_string(),
CachedKey {
key: DecodingKey::from_secret(b"b"),
algorithm: Algorithm::RS256,
},
);
let cache = JwksCache::from_keys(keys);
let all = cache.get_all_keys().await.unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn discover_url_construction() {
let issuer = "https://accounts.google.com";
let url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
assert_eq!(
url,
"https://accounts.google.com/.well-known/openid-configuration"
);
let issuer = "https://example.com/";
let url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
assert_eq!(url, "https://example.com/.well-known/openid-configuration");
}
#[test]
fn algorithm_for_jwk_defaults_to_rs256() {
let jwk: Jwk = serde_json::from_value(serde_json::json!({
"kty": "RSA",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB",
"kid": "test-kid"
}))
.unwrap();
let alg = algorithm_for_jwk(&jwk);
assert_eq!(alg, Algorithm::RS256);
}
#[test]
fn decoding_key_from_valid_jwk() {
let jwk: Jwk = serde_json::from_value(serde_json::json!({
"kty": "RSA",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB",
"kid": "test-kid"
}))
.unwrap();
let result = decoding_key_from_jwk(&jwk);
assert!(result.is_ok());
}
}