Skip to main content

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}