use std::{collections::HashMap, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use jsonwebtoken::jwk::JwkSet;
use reqwest::Client;
use tokio::sync::RwLock;
use tracing;
use crate::error::{PepError, Result};
use super::types::{JwtClaims, OidcDiscoveryDocument, CachedJwks, CachedDiscoveryRaw, JwtValidationOptions};
#[derive(Clone)]
pub struct CachedUserInfo {
pub claims: serde_json::Map<String, serde_json::Value>,
pub cached_at: SystemTime,
pub ttl_secs: u64,
}
impl CachedUserInfo {
pub fn is_expired(&self) -> bool {
self.cached_at.elapsed().unwrap_or(Duration::from_secs(self.ttl_secs + 1)) >= Duration::from_secs(self.ttl_secs)
}
}
#[derive(Clone)]
pub struct UserInfoCache {
inner: Arc<RwLock<HashMap<String, CachedUserInfo>>>,
}
impl UserInfoCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn get(&self, key: &str) -> Option<serde_json::Map<String, serde_json::Value>> {
let cache = self.inner.read().await;
cache.get(key).and_then(|entry| {
if entry.is_expired() {
None
} else {
Some(entry.claims.clone())
}
})
}
pub async fn insert(
&self,
key: String,
claims: serde_json::Map<String, serde_json::Value>,
token_exp: i64,
) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::from_secs(0))
.as_secs() as i64;
let remaining = if token_exp > now {
(token_exp - now) as u64
} else {
0
};
let ttl_secs = remaining.saturating_sub(30).max(30);
let entry = CachedUserInfo {
claims,
cached_at: SystemTime::now(),
ttl_secs,
};
let mut cache = self.inner.write().await;
cache.insert(key, entry);
}
pub async fn purge_expired(&self) {
let mut cache = self.inner.write().await;
cache.retain(|_, entry| !entry.is_expired());
}
}
impl Default for UserInfoCache {
fn default() -> Self {
Self::new()
}
}
pub fn jwk_algorithm_to_algorithm(jwk: &jsonwebtoken::jwk::Jwk) -> Result<Algorithm> {
match &jwk.algorithm {
jsonwebtoken::jwk::AlgorithmParameters::RSA(_) => Ok(Algorithm::RS256),
jsonwebtoken::jwk::AlgorithmParameters::EllipticCurve(params) => match ¶ms.curve {
jsonwebtoken::jwk::EllipticCurve::P256 => Ok(Algorithm::ES256),
jsonwebtoken::jwk::EllipticCurve::P384 => Ok(Algorithm::ES384),
other => Err(PepError::BadRequest(format!("Unsupported elliptic curve for JWK: {:?}", other))),
},
jsonwebtoken::jwk::AlgorithmParameters::OctetKey(_) => Err(PepError::BadRequest("HMAC keys not supported for OIDC verification".to_string())),
jsonwebtoken::jwk::AlgorithmParameters::OctetKeyPair(_) => Ok(Algorithm::EdDSA),
}
}
#[derive(Clone)]
pub struct ResourceServerClient {
pub http_client: Client,
pub jwks_cache: Arc<RwLock<HashMap<String, CachedJwks>>>,
pub discovery_cache: Arc<RwLock<HashMap<String, (OidcDiscoveryDocument, SystemTime)>>>,
pub discovery_cache_raw: Arc<RwLock<HashMap<String, CachedDiscoveryRaw>>>,
pub userinfo_cache: Arc<UserInfoCache>,
}
impl ResourceServerClient {
pub fn new() -> Self {
Self {
http_client: Client::new(),
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
discovery_cache: Arc::new(RwLock::new(HashMap::new())),
discovery_cache_raw: Arc::new(RwLock::new(HashMap::new())),
userinfo_cache: Arc::new(UserInfoCache::new()),
}
}
pub async fn get_discovery_document(&self, issuer_url: &str) -> Result<OidcDiscoveryDocument> {
{
let cache = self.discovery_cache.read().await;
if let Some((doc, fetched_at)) = cache.get(issuer_url) {
if fetched_at.elapsed().unwrap_or(Duration::from_secs(3600)) < Duration::from_secs(3600) {
return Ok(doc.clone());
}
}
}
let discovery_url = format!("{}/.well-known/openid-configuration", issuer_url.trim_end_matches('/'));
tracing::debug!("Fetching OIDC discovery document from: {}", discovery_url);
let response = self.http_client
.get(&discovery_url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| PepError::OidcDiscovery(format!("Failed to fetch discovery document: {}", e)))?;
if !response.status().is_success() {
return Err(PepError::OidcDiscovery(format!("Discovery document fetch failed with status: {}", response.status())));
}
let discovery_doc: OidcDiscoveryDocument = response
.json()
.await
.map_err(|e| PepError::OidcDiscovery(format!("Failed to parse discovery document: {}", e)))?;
{
let mut cache = self.discovery_cache.write().await;
cache.insert(issuer_url.to_string(), (discovery_doc.clone(), SystemTime::now()));
}
Ok(discovery_doc)
}
pub async fn get_discovery_document_raw(&self, issuer_url: &str) -> Result<String> {
let cache_duration = Duration::from_secs(3600);
{
let cache = self.discovery_cache_raw.read().await;
if let Some(cached) = cache.get(issuer_url) {
if cached.fetched_at.elapsed().unwrap_or(cache_duration) < cache_duration {
return Ok(cached.raw_json.clone());
}
}
}
let discovery_url = format!("{}/.well-known/openid-configuration", issuer_url.trim_end_matches('/'));
tracing::debug!("Fetching raw OIDC discovery document from: {}", discovery_url);
let response = self.http_client
.get(&discovery_url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| PepError::OidcDiscovery(format!("Failed to fetch discovery document: {}", e)))?;
if !response.status().is_success() {
return Err(PepError::OidcDiscovery(format!("Discovery document fetch failed with status: {}", response.status())));
}
let raw_json = response
.text()
.await
.map_err(|e| PepError::OidcDiscovery(format!("Failed to read discovery document: {}", e)))?;
let cached = CachedDiscoveryRaw {
raw_json: raw_json.clone(),
fetched_at: SystemTime::now(),
cache_duration,
};
{
let mut cache = self.discovery_cache_raw.write().await;
cache.insert(issuer_url.to_string(), cached);
}
Ok(raw_json)
}
pub async fn get_jwks(&self, jwks_uri: &str) -> Result<HashMap<String, (DecodingKey, Algorithm)>> {
{
let cache = self.jwks_cache.read().await;
if let Some(cached) = cache.get(jwks_uri) {
if cached.fetched_at.elapsed().unwrap_or(cached.cache_duration) < cached.cache_duration {
return Ok(cached.keys.clone());
}
}
}
tracing::debug!("Fetching JWKS from: {}", jwks_uri);
let response = self.http_client
.get(jwks_uri)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| PepError::JwksFetch(format!("Failed to fetch JWKS: {}", e)))?;
if !response.status().is_success() {
return Err(PepError::JwksFetch(format!("JWKS fetch failed with status: {}", response.status())));
}
let jwks_text = response
.text()
.await
.map_err(|e| PepError::JwksFetch(format!("Failed to read JWKS response: {}", e)))?;
let jwk_set: JwkSet = serde_json::from_str(&jwks_text)
.map_err(|e| PepError::JwksFetch(format!("Failed to parse JWKS: {}", e)))?;
let mut keys = HashMap::new();
for jwk in jwk_set.keys {
if let Some(kid) = &jwk.common.key_id {
match DecodingKey::from_jwk(&jwk) {
Ok(decoding_key) => {
match jwk_algorithm_to_algorithm(&jwk) {
Ok(algorithm) => {
keys.insert(kid.clone(), (decoding_key, algorithm));
tracing::debug!("Successfully parsed key {}: algorithm={:?}", kid, algorithm);
}
Err(e) => {
tracing::warn!("Unsupported algorithm for kid {}: {}", kid, e);
continue;
}
}
}
Err(err) => {
tracing::warn!("Failed to create decoding key for kid {}: {}", kid, err);
}
}
} else {
tracing::warn!("JWK missing kid field, skipping");
}
}
let cached = CachedJwks {
keys: keys.clone(),
fetched_at: SystemTime::now(),
cache_duration: Duration::from_secs(3600), };
{
let mut cache = self.jwks_cache.write().await;
cache.insert(jwks_uri.to_string(), cached);
}
Ok(keys)
}
pub async fn validate_jwt_with_options(
&self,
token: &str,
issuer_url: &str,
client_id: &str,
options: &JwtValidationOptions,
) -> Result<JwtClaims> {
let header = decode_header(token)
.map_err(|e| PepError::JwtValidation(format!("Invalid JWT header: {}", e)))?;
let kid = header.kid
.ok_or_else(|| PepError::JwtValidation("JWT missing kid in header".to_string()))?;
let discovery_doc = self.get_discovery_document(issuer_url).await?;
let keys = self.get_jwks(&discovery_doc.jwks_uri).await?;
let (decoding_key, key_algorithm) = keys.get(&kid)
.ok_or_else(|| PepError::JwtValidation(format!("No key found for kid: {}", kid)))?;
let algorithm = {
let jwt_alg = header.alg;
if jwt_alg == *key_algorithm {
jwt_alg
} else {
tracing::warn!(
"JWT header algorithm ({:?}) doesn't match key algorithm ({:?}) for kid {}. Using key algorithm.",
jwt_alg, key_algorithm, kid
);
*key_algorithm
}
};
tracing::debug!("Validating JWT with kid: {}, algorithm: {:?}", kid, algorithm);
let mut validation = Validation::new(algorithm);
if options.skip_issuer_validation {
tracing::debug!("Skipping issuer validation as configured");
} else {
validation.set_issuer(&[issuer_url]);
}
if options.skip_audience_validation {
tracing::debug!("Skipping audience validation as configured");
validation.validate_aud = false;
} else {
let audience = options.expected_audience.as_deref().unwrap_or(client_id);
tracing::debug!("Validating audience against: {}", audience);
validation.set_audience(&[audience]);
}
let token_data = decode::<JwtClaims>(token, decoding_key, &validation)
.map_err(|e| PepError::JwtValidation(format!("JWT validation failed: {}", e)))?;
Ok(token_data.claims)
}
pub async fn validate_jwt(&self, token: &str, issuer_url: &str, client_id: &str) -> Result<JwtClaims> {
self.validate_jwt_with_options(token, issuer_url, client_id, &JwtValidationOptions::default()).await
}
pub async fn enrich_claims_with_userinfo(
&self,
claims: &mut JwtClaims,
token: &str,
issuer_url: &str,
userinfo_url_override: Option<&str>,
) -> Result<()> {
let has_groups = claims.extra.contains_key("groups");
if has_groups {
tracing::debug!("Claims already contain groups — skipping userinfo enrichment");
return Ok(());
}
tracing::debug!("Claims missing groups — attempting userinfo enrichment");
let cache_key = claims
.extra
.get("jti")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| claims.sub.clone());
if let Some(cached_claims) = self.userinfo_cache.get(&cache_key).await {
tracing::debug!(cache_key = %cache_key, "Using cached userinfo for claims enrichment");
merge_userinfo_into_claims(claims, &cached_claims);
return Ok(());
}
let userinfo_url = match userinfo_url_override {
Some(url) => url.to_string(),
None => {
match self.get_discovery_document(issuer_url).await {
Ok(doc) if doc.userinfo_endpoint.is_some() => {
doc.userinfo_endpoint.unwrap()
}
Ok(_) => {
format!("{}/userinfo", issuer_url.trim_end_matches('/'))
}
Err(e) => {
tracing::warn!("Failed to fetch discovery for userinfo URL: {}. Deriving from issuer.", e);
format!("{}/userinfo", issuer_url.trim_end_matches('/'))
}
}
}
};
tracing::debug!(userinfo_url = %userinfo_url, "Calling userinfo endpoint for claims enrichment");
let response = self.http_client
.get(&userinfo_url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/json")
.send()
.await
.map_err(|e| PepError::Userinfo(format!("Userinfo request failed: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(
userinfo_url = %userinfo_url, status = %status,
"Userinfo endpoint returned error: {}", body
);
return Ok(());
}
let userinfo: serde_json::Map<String, serde_json::Value> = response
.json()
.await
.map_err(|e| PepError::Userinfo(format!("Failed to parse userinfo response: {}", e)))?;
tracing::debug!(
userinfo_keys = ?userinfo.keys().collect::<Vec<_>>(),
"Userinfo response received"
);
self.userinfo_cache.insert(cache_key.clone(), userinfo.clone(), claims.exp).await;
merge_userinfo_into_claims(claims, &userinfo);
Ok(())
}
}
fn merge_userinfo_into_claims(
claims: &mut JwtClaims,
userinfo: &serde_json::Map<String, serde_json::Value>,
) {
let fields_to_merge = ["groups", "role"];
for field in &fields_to_merge {
if !claims.extra.contains_key(*field) {
if let Some(value) = userinfo.get(*field) {
claims.extra.insert(field.to_string(), value.clone());
tracing::debug!(
field,
value = %value,
"Merged field from userinfo into claims"
);
}
}
}
}
impl Default for ResourceServerClient {
fn default() -> Self {
Self::new()
}
}