fraiseql_core/security/oidc/token.rs
1//! OIDC token validation: signature verification and claim extraction.
2//!
3//! JWKS fetching, caching, and key-selection logic lives in [`super::jwks`].
4
5/// Maximum byte size for an OIDC discovery document.
6///
7/// A well-formed `.well-known/openid-configuration` response is a few `KiB`.
8/// 64 `KiB` is generous while blocking allocation-bomb responses from
9/// a malicious or misconfigured OIDC provider.
10pub(super) const MAX_DISCOVERY_RESPONSE_BYTES: usize = 64 * 1024; // 64 KiB
11
12use std::sync::Arc;
13
14use chrono::{DateTime, Utc};
15use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
16use parking_lot::RwLock;
17
18use crate::security::{
19 auth_middleware::{
20 AuthenticatedUser,
21 types::{extract_claim_string, extract_name_string},
22 },
23 errors::{Result, SecurityError},
24 oidc::{
25 audience::JwtClaims,
26 jwks::CachedJwks,
27 providers::{MAX_CLOCK_SKEW_SECS, OidcConfig},
28 replay_cache::ReplayCache,
29 },
30};
31
32// ============================================================================
33// OidcValidator struct and constructor
34// ============================================================================
35
36/// OIDC token validator with JWKS caching.
37///
38/// Validates JWT tokens against an OIDC provider's public keys.
39/// Automatically fetches and caches the JWKS for efficiency.
40///
41/// JWKS fetch/cache/key-selection helpers are in `impl OidcValidator` blocks
42/// defined in the `jwks` sub-module.
43pub struct OidcValidator {
44 pub(super) config: OidcConfig,
45 pub(super) http_client: reqwest::Client,
46 pub(super) jwks_cache: Arc<RwLock<Option<CachedJwks>>>,
47 pub(super) jwks_uri: String,
48 /// Optional JWT replay cache. When set, each validated token's `jti` is
49 /// checked against the cache and rejected if it has been seen before.
50 pub(super) replay_cache: Option<Arc<ReplayCache>>,
51}
52
53impl OidcValidator {
54 /// Create a new OIDC validator.
55 ///
56 /// This will perform OIDC discovery to find the JWKS URI
57 /// unless `jwks_uri` is explicitly set in config.
58 ///
59 /// # Errors
60 ///
61 /// Returns error if:
62 /// - Config validation fails
63 /// - OIDC discovery fails
64 /// - JWKS endpoint cannot be determined
65 pub async fn new(config: OidcConfig) -> Result<Self> {
66 use std::time::Duration;
67
68 use crate::security::oidc::jwks::OidcDiscoveryDocument;
69
70 config.validate()?;
71
72 // Redirects are disabled to prevent redirect-chain SSRF attacks.
73 // `https_only` is not set here because `OidcConfig::validate()` already
74 // requires HTTPS for the issuer URL (localhost HTTP is allowed for dev/test).
75 let http_client = reqwest::Client::builder()
76 .redirect(reqwest::redirect::Policy::none())
77 .timeout(Duration::from_secs(30))
78 .build()
79 .map_err(|e| SecurityError::SecurityConfigError(format!("HTTP client error: {e}")))?;
80
81 // Determine JWKS URI
82 let jwks_uri = if let Some(ref uri) = config.jwks_uri {
83 uri.clone()
84 } else {
85 // Perform OIDC discovery
86 let discovery_url =
87 format!("{}/.well-known/openid-configuration", config.issuer.trim_end_matches('/'));
88
89 tracing::debug!(url = %discovery_url, "Performing OIDC discovery");
90
91 let response = http_client.get(&discovery_url).send().await.map_err(|e| {
92 SecurityError::SecurityConfigError(format!("OIDC discovery failed: {e}"))
93 })?;
94
95 if !response.status().is_success() {
96 return Err(SecurityError::SecurityConfigError(format!(
97 "OIDC discovery failed with status: {}",
98 response.status()
99 )));
100 }
101
102 let body_bytes = response.bytes().await.map_err(|e| {
103 SecurityError::SecurityConfigError(format!(
104 "Failed to read OIDC discovery response: {e}"
105 ))
106 })?;
107 if body_bytes.len() > MAX_DISCOVERY_RESPONSE_BYTES {
108 return Err(SecurityError::SecurityConfigError(format!(
109 "OIDC discovery response too large ({} bytes, max {MAX_DISCOVERY_RESPONSE_BYTES})",
110 body_bytes.len()
111 )));
112 }
113 let discovery: OidcDiscoveryDocument =
114 serde_json::from_slice(&body_bytes).map_err(|e| {
115 SecurityError::SecurityConfigError(format!(
116 "Invalid OIDC discovery response: {e}"
117 ))
118 })?;
119
120 tracing::info!(
121 issuer = %discovery.issuer,
122 jwks_uri = %discovery.jwks_uri,
123 "OIDC discovery successful"
124 );
125
126 discovery.jwks_uri
127 };
128
129 // Validate the JWKS URI before storing it (SSRF prevention pattern).
130 let _ = reqwest::Url::parse(&jwks_uri).map_err(|e| {
131 SecurityError::SecurityConfigError(format!(
132 "OIDC jwks_uri is not a valid URL '{jwks_uri}': {e}"
133 ))
134 })?;
135
136 Ok(Self {
137 config,
138 http_client,
139 jwks_cache: Arc::new(RwLock::new(None)),
140 jwks_uri,
141 replay_cache: None,
142 })
143 }
144
145 /// Create a validator without performing discovery.
146 ///
147 /// Use this for testing or when you have the JWKS URI directly.
148 ///
149 /// # Panics
150 ///
151 /// Panics if the platform TLS backend is unavailable for the HTTP client.
152 /// This would indicate a broken system-level TLS installation.
153 #[must_use]
154 pub fn with_jwks_uri(config: OidcConfig, jwks_uri: String) -> Self {
155 // Use the same 30-second timeout as `new()` to prevent indefinitely
156 // blocked JWKS fetches when the endpoint is slow or hung.
157 // Redirects are disabled to prevent redirect-chain SSRF attacks.
158 // `https_only` is not set here because `OidcConfig::validate()` already
159 // requires HTTPS for the issuer URL (localhost HTTP is allowed for dev/test).
160 let http_client = reqwest::Client::builder()
161 .redirect(reqwest::redirect::Policy::none())
162 .timeout(std::time::Duration::from_secs(30))
163 .build()
164 // Reason: TLS backend absence is a catastrophic system misconfiguration;
165 // this constructor is #[must_use] and returns Self, not Result.
166 .expect("TLS backend should always be available for reqwest HTTP client");
167 Self {
168 config,
169 http_client,
170 jwks_cache: Arc::new(RwLock::new(None)),
171 jwks_uri,
172 replay_cache: None,
173 }
174 }
175
176 /// Attach a JWT replay cache to this validator.
177 ///
178 /// When set, every validated token's `jti` claim is checked against the
179 /// cache. A token whose `jti` has been seen before is rejected with
180 /// `SecurityError::TokenReplayed`, preventing stolen-token replay attacks.
181 ///
182 /// If `require_jti` is `true` in [`OidcConfig`], tokens without a `jti`
183 /// are also rejected before the replay check is reached.
184 #[must_use]
185 pub fn with_replay_cache(mut self, cache: Arc<ReplayCache>) -> Self {
186 self.replay_cache = Some(cache);
187 self
188 }
189
190 /// Validate a JWT token and extract user information.
191 ///
192 /// # Arguments
193 ///
194 /// * `token` - The JWT token string (without "Bearer " prefix)
195 ///
196 /// # Returns
197 ///
198 /// `AuthenticatedUser` if token is valid, error otherwise.
199 ///
200 /// # Errors
201 ///
202 /// Returns error if:
203 /// - Token is malformed
204 /// - Signature verification fails
205 /// - Required claims are missing
206 /// - Token is expired
207 /// - Issuer/audience don't match
208 pub async fn validate_token(&self, token: &str) -> Result<AuthenticatedUser> {
209 // Decode header to get kid
210 let header = decode_header(token).map_err(|e| {
211 tracing::debug!(error = %e, "Failed to decode JWT header");
212 SecurityError::InvalidToken
213 })?;
214
215 let kid = header.kid.as_ref().ok_or_else(|| {
216 tracing::debug!("JWT missing kid (key ID) in header");
217 SecurityError::InvalidToken
218 })?;
219
220 // Get the signing key (fetch/cache logic in jwks.rs)
221 let decoding_key = self.get_decoding_key(kid).await?;
222
223 // Build validation
224 let mut validation = Validation::new(self.get_algorithm(&header)?);
225 validation.set_issuer(&[&self.config.issuer]);
226
227 // Set audience validation — always enabled when any audience is configured.
228 // The validate() call earlier guarantees at least one audience is set,
229 // so this else branch (validate_aud = false) is never reached in practice.
230 // It is kept as a defensive fallback; the real protection is the mandatory
231 // audience check in OidcConfig::validate().
232 if let Some(ref aud) = self.config.audience {
233 let mut audiences = vec![aud.clone()];
234 audiences.extend(self.config.additional_audiences.clone());
235 validation.set_audience(&audiences);
236 } else if !self.config.additional_audiences.is_empty() {
237 // Only additional_audiences configured (no primary audience):
238 // still validate against those.
239 validation.set_audience(&self.config.additional_audiences);
240 } else {
241 // Should be unreachable after OidcConfig::validate() — fail-closed.
242 validation.validate_aud = true;
243 }
244
245 // Set clock skew tolerance — capped to prevent accepting arbitrarily
246 // old expired tokens due to misconfiguration.
247 validation.leeway = self.config.clock_skew_secs.min(MAX_CLOCK_SKEW_SECS);
248
249 // Decode and validate token
250 let token_data = decode::<JwtClaims>(token, &decoding_key, &validation).map_err(|e| {
251 tracing::debug!(error = %e, "JWT validation failed");
252 match e.kind() {
253 jsonwebtoken::errors::ErrorKind::ExpiredSignature => SecurityError::TokenExpired {
254 expired_at: Utc::now(), // Approximate
255 },
256 // Invalid issuer, audience, signature, and all other errors map to InvalidToken
257 _ => SecurityError::InvalidToken,
258 }
259 })?;
260
261 let claims = token_data.claims;
262
263 // Validate jti claim if required by configuration.
264 if self.config.require_jti && claims.jti.is_none() {
265 tracing::debug!("JWT missing required jti (JWT ID) claim");
266 return Err(SecurityError::TokenMissingClaim {
267 claim: "jti".to_string(),
268 });
269 }
270
271 // Replay check: if a replay cache is configured and the token carries a jti,
272 // verify the jti has not been seen before and record it for future checks.
273 if let (Some(replay_cache), Some(ref jti)) = (&self.replay_cache, &claims.jti) {
274 use std::time::Duration;
275 // Compute the token's remaining TTL for the cache entry.
276 let ttl = claims
277 .exp
278 .and_then(|exp| {
279 let remaining = exp - chrono::Utc::now().timestamp();
280 if remaining > 0 {
281 Some(Duration::from_secs(remaining.cast_unsigned()))
282 } else {
283 None
284 }
285 })
286 .unwrap_or(Duration::from_secs(900)); // Fallback: 15-minute TTL
287
288 replay_cache.check_and_record(jti, ttl).await.map_err(|e| {
289 use crate::security::oidc::replay_cache::ReplayCacheError;
290 match e {
291 ReplayCacheError::Replayed => {
292 tracing::warn!(jti = %jti, "JWT replay detected");
293 SecurityError::TokenReplayed
294 },
295 ReplayCacheError::Backend(_) => {
296 // Backend error with fail-open is already handled inside
297 // ReplayCache::check_and_record(); reaching here means
298 // fail-closed is configured.
299 tracing::warn!(jti = %jti, error = %e, "Replay cache backend error");
300 SecurityError::InvalidToken
301 },
302 }
303 })?;
304 }
305
306 // Extract scopes first (before moving claims.sub)
307 let scopes = self.extract_scopes(&claims);
308
309 // Extract user ID (required)
310 let user_id_str = claims.sub.ok_or(SecurityError::TokenMissingClaim {
311 claim: "sub".to_string(),
312 })?;
313 let user_id = crate::types::UserId::new(user_id_str);
314
315 // Extract expiration (required)
316 let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
317 claim: "exp".to_string(),
318 })?;
319
320 let expires_at =
321 DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
322
323 tracing::debug!(
324 user_id = %user_id,
325 scopes = ?scopes,
326 expires_at = %expires_at,
327 "Token validated successfully"
328 );
329
330 // Extract email and display name from dedicated fields or extra claims.
331 // Dedicated fields take priority; fall back to extra for providers that
332 // use non-standard claim names.
333 let email = claims
334 .email
335 .as_ref()
336 .and_then(extract_claim_string)
337 .or_else(|| claims.extra.get("email").and_then(extract_claim_string));
338 let display_name = claims
339 .name
340 .as_ref()
341 .and_then(extract_name_string)
342 .or_else(|| claims.extra.get("name").and_then(extract_name_string));
343
344 Ok(AuthenticatedUser {
345 user_id,
346 scopes,
347 expires_at,
348 email,
349 display_name,
350 extra_claims: claims.extra,
351 })
352 }
353
354 /// Get the algorithm from the JWT header.
355 ///
356 /// # Errors
357 ///
358 /// Returns `SecurityError::InvalidTokenAlgorithm` if the algorithm is not in the allow-list.
359 pub(super) fn get_algorithm(&self, header: &jsonwebtoken::Header) -> Result<Algorithm> {
360 let alg_str = format!("{:?}", header.alg);
361
362 // Check if algorithm is allowed
363 if !self.config.allowed_algorithms.contains(&alg_str) {
364 return Err(SecurityError::InvalidTokenAlgorithm { algorithm: alg_str });
365 }
366
367 Ok(header.alg)
368 }
369
370 /// Extract scopes from JWT claims.
371 ///
372 /// Handles multiple formats:
373 /// - `scope`: space-separated string (Auth0, Okta)
374 /// - `scp`: array of strings (some providers)
375 /// - `permissions`: array of strings (Auth0 RBAC)
376 fn extract_scopes(&self, claims: &JwtClaims) -> Vec<String> {
377 // Try the configured scope claim first (default: "scope")
378 if self.config.scope_claim == "scope" {
379 if let Some(ref scope) = claims.scope {
380 return scope.split_whitespace().map(String::from).collect();
381 }
382 }
383
384 // Try scp (array format)
385 if let Some(ref scp) = claims.scp {
386 return scp.clone();
387 }
388
389 // Try permissions (Auth0 RBAC)
390 if let Some(ref perms) = claims.permissions {
391 return perms.clone();
392 }
393
394 // Try scope as space-separated string
395 if let Some(ref scope) = claims.scope {
396 return scope.split_whitespace().map(String::from).collect();
397 }
398
399 Vec::new()
400 }
401
402 /// Check if authentication is required.
403 #[must_use]
404 pub const fn is_required(&self) -> bool {
405 self.config.required
406 }
407
408 /// Get the configured issuer.
409 #[must_use]
410 pub fn issuer(&self) -> &str {
411 &self.config.issuer
412 }
413
414 /// Clear the JWKS cache.
415 ///
416 /// Call this if you need to force a refresh of the signing keys.
417 pub fn clear_cache(&self) {
418 let mut cache = self.jwks_cache.write();
419 *cache = None;
420 }
421}