pub(super) const MAX_DISCOVERY_RESPONSE_BYTES: usize = 64 * 1024;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
use parking_lot::RwLock;
use crate::security::{
auth_middleware::{
AuthenticatedUser,
types::{extract_claim_string, extract_name_string},
},
errors::{Result, SecurityError},
oidc::{
audience::JwtClaims,
jwks::CachedJwks,
providers::{MAX_CLOCK_SKEW_SECS, OidcConfig},
replay_cache::ReplayCache,
},
};
pub struct OidcValidator {
pub(super) config: OidcConfig,
pub(super) http_client: reqwest::Client,
pub(super) jwks_cache: Arc<RwLock<Option<CachedJwks>>>,
pub(super) jwks_uri: String,
pub(super) replay_cache: Option<Arc<ReplayCache>>,
}
impl OidcValidator {
pub async fn new(config: OidcConfig) -> Result<Self> {
use std::time::Duration;
use crate::security::oidc::jwks::OidcDiscoveryDocument;
config.validate()?;
let http_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| SecurityError::SecurityConfigError(format!("HTTP client error: {e}")))?;
let jwks_uri = if let Some(ref uri) = config.jwks_uri {
uri.clone()
} else {
let discovery_url =
format!("{}/.well-known/openid-configuration", config.issuer.trim_end_matches('/'));
tracing::debug!(url = %discovery_url, "Performing OIDC discovery");
let response = http_client.get(&discovery_url).send().await.map_err(|e| {
SecurityError::SecurityConfigError(format!("OIDC discovery failed: {e}"))
})?;
if !response.status().is_success() {
return Err(SecurityError::SecurityConfigError(format!(
"OIDC discovery failed with status: {}",
response.status()
)));
}
let body_bytes = response.bytes().await.map_err(|e| {
SecurityError::SecurityConfigError(format!(
"Failed to read OIDC discovery response: {e}"
))
})?;
if body_bytes.len() > MAX_DISCOVERY_RESPONSE_BYTES {
return Err(SecurityError::SecurityConfigError(format!(
"OIDC discovery response too large ({} bytes, max {MAX_DISCOVERY_RESPONSE_BYTES})",
body_bytes.len()
)));
}
let discovery: OidcDiscoveryDocument =
serde_json::from_slice(&body_bytes).map_err(|e| {
SecurityError::SecurityConfigError(format!(
"Invalid OIDC discovery response: {e}"
))
})?;
tracing::info!(
issuer = %discovery.issuer,
jwks_uri = %discovery.jwks_uri,
"OIDC discovery successful"
);
discovery.jwks_uri
};
let _ = reqwest::Url::parse(&jwks_uri).map_err(|e| {
SecurityError::SecurityConfigError(format!(
"OIDC jwks_uri is not a valid URL '{jwks_uri}': {e}"
))
})?;
Ok(Self {
config,
http_client,
jwks_cache: Arc::new(RwLock::new(None)),
jwks_uri,
replay_cache: None,
})
}
#[must_use]
pub fn with_jwks_uri(config: OidcConfig, jwks_uri: String) -> Self {
let http_client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("TLS backend should always be available for reqwest HTTP client");
Self {
config,
http_client,
jwks_cache: Arc::new(RwLock::new(None)),
jwks_uri,
replay_cache: None,
}
}
#[must_use]
pub fn with_replay_cache(mut self, cache: Arc<ReplayCache>) -> Self {
self.replay_cache = Some(cache);
self
}
pub async fn validate_token(&self, token: &str) -> Result<AuthenticatedUser> {
let header = decode_header(token).map_err(|e| {
tracing::debug!(error = %e, "Failed to decode JWT header");
SecurityError::InvalidToken
})?;
let kid = header.kid.as_ref().ok_or_else(|| {
tracing::debug!("JWT missing kid (key ID) in header");
SecurityError::InvalidToken
})?;
let decoding_key = self.get_decoding_key(kid).await?;
let mut validation = Validation::new(self.get_algorithm(&header)?);
validation.set_issuer(&[&self.config.issuer]);
if let Some(ref aud) = self.config.audience {
let mut audiences = vec![aud.clone()];
audiences.extend(self.config.additional_audiences.clone());
validation.set_audience(&audiences);
} else if !self.config.additional_audiences.is_empty() {
validation.set_audience(&self.config.additional_audiences);
} else {
validation.validate_aud = true;
}
validation.leeway = self.config.clock_skew_secs.min(MAX_CLOCK_SKEW_SECS);
let token_data = decode::<JwtClaims>(token, &decoding_key, &validation).map_err(|e| {
tracing::debug!(error = %e, "JWT validation failed");
match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => SecurityError::TokenExpired {
expired_at: Utc::now(), },
_ => SecurityError::InvalidToken,
}
})?;
let claims = token_data.claims;
if self.config.require_jti && claims.jti.is_none() {
tracing::debug!("JWT missing required jti (JWT ID) claim");
return Err(SecurityError::TokenMissingClaim {
claim: "jti".to_string(),
});
}
if let (Some(replay_cache), Some(ref jti)) = (&self.replay_cache, &claims.jti) {
use std::time::Duration;
let ttl = claims
.exp
.and_then(|exp| {
let remaining = exp - chrono::Utc::now().timestamp();
if remaining > 0 {
Some(Duration::from_secs(remaining.cast_unsigned()))
} else {
None
}
})
.unwrap_or(Duration::from_secs(900));
replay_cache.check_and_record(jti, ttl).await.map_err(|e| {
use crate::security::oidc::replay_cache::ReplayCacheError;
match e {
ReplayCacheError::Replayed => {
tracing::warn!(jti = %jti, "JWT replay detected");
SecurityError::TokenReplayed
},
ReplayCacheError::Backend(_) => {
tracing::warn!(jti = %jti, error = %e, "Replay cache backend error");
SecurityError::InvalidToken
},
}
})?;
}
let scopes = self.extract_scopes(&claims);
let user_id_str = claims.sub.ok_or(SecurityError::TokenMissingClaim {
claim: "sub".to_string(),
})?;
let user_id = crate::types::UserId::new(user_id_str);
let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
claim: "exp".to_string(),
})?;
let expires_at =
DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
tracing::debug!(
user_id = %user_id,
scopes = ?scopes,
expires_at = %expires_at,
"Token validated successfully"
);
let email = claims
.email
.as_ref()
.and_then(extract_claim_string)
.or_else(|| claims.extra.get("email").and_then(extract_claim_string));
let display_name = claims
.name
.as_ref()
.and_then(extract_name_string)
.or_else(|| claims.extra.get("name").and_then(extract_name_string));
Ok(AuthenticatedUser {
user_id,
scopes,
expires_at,
email,
display_name,
extra_claims: claims.extra,
})
}
pub(super) fn get_algorithm(&self, header: &jsonwebtoken::Header) -> Result<Algorithm> {
let alg_str = format!("{:?}", header.alg);
if !self.config.allowed_algorithms.contains(&alg_str) {
return Err(SecurityError::InvalidTokenAlgorithm { algorithm: alg_str });
}
Ok(header.alg)
}
fn extract_scopes(&self, claims: &JwtClaims) -> Vec<String> {
if self.config.scope_claim == "scope" {
if let Some(ref scope) = claims.scope {
return scope.split_whitespace().map(String::from).collect();
}
}
if let Some(ref scp) = claims.scp {
return scp.clone();
}
if let Some(ref perms) = claims.permissions {
return perms.clone();
}
if let Some(ref scope) = claims.scope {
return scope.split_whitespace().map(String::from).collect();
}
Vec::new()
}
#[must_use]
pub const fn is_required(&self) -> bool {
self.config.required
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.config.issuer
}
pub fn clear_cache(&self) {
let mut cache = self.jwks_cache.write();
*cache = None;
}
}