fraiseql-core 2.12.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! OIDC token validation: signature verification and claim extraction.
//!
//! JWKS fetching, caching, and key-selection logic lives in [`super::jwks`].

/// Maximum byte size for an OIDC discovery document.
///
/// A well-formed `.well-known/openid-configuration` response is a few `KiB`.
/// 64 `KiB` is generous while blocking allocation-bomb responses from
/// a malicious or misconfigured OIDC provider.
pub(super) const MAX_DISCOVERY_RESPONSE_BYTES: usize = 64 * 1024; // 64 KiB

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,
    },
};

// ============================================================================
// OidcValidator struct and constructor
// ============================================================================

/// OIDC token validator with JWKS caching.
///
/// Validates JWT tokens against an OIDC provider's public keys.
/// Automatically fetches and caches the JWKS for efficiency.
///
/// JWKS fetch/cache/key-selection helpers are in `impl OidcValidator` blocks
/// defined in the `jwks` sub-module.
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,
    /// Optional JWT replay cache. When set, each validated token's `jti` is
    /// checked against the cache and rejected if it has been seen before.
    pub(super) replay_cache: Option<Arc<ReplayCache>>,
}

impl OidcValidator {
    /// Create a new OIDC validator.
    ///
    /// This will perform OIDC discovery to find the JWKS URI
    /// unless `jwks_uri` is explicitly set in config.
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - Config validation fails
    /// - OIDC discovery fails
    /// - JWKS endpoint cannot be determined
    pub async fn new(config: OidcConfig) -> Result<Self> {
        use std::time::Duration;

        use crate::security::oidc::jwks::OidcDiscoveryDocument;

        config.validate()?;

        // Redirects are disabled to prevent redirect-chain SSRF attacks.
        // `https_only` is not set here because `OidcConfig::validate()` already
        // requires HTTPS for the issuer URL (localhost HTTP is allowed for dev/test).
        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}")))?;

        // Determine JWKS URI
        let jwks_uri = if let Some(ref uri) = config.jwks_uri {
            uri.clone()
        } else {
            // Perform OIDC discovery
            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
        };

        // Validate the JWKS URI before storing it (SSRF prevention pattern).
        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,
        })
    }

    /// Create a validator without performing discovery.
    ///
    /// Use this for testing or when you have the JWKS URI directly.
    ///
    /// # Panics
    ///
    /// Panics if the platform TLS backend is unavailable for the HTTP client.
    /// This would indicate a broken system-level TLS installation.
    #[must_use]
    pub fn with_jwks_uri(config: OidcConfig, jwks_uri: String) -> Self {
        // Use the same 30-second timeout as `new()` to prevent indefinitely
        // blocked JWKS fetches when the endpoint is slow or hung.
        // Redirects are disabled to prevent redirect-chain SSRF attacks.
        // `https_only` is not set here because `OidcConfig::validate()` already
        // requires HTTPS for the issuer URL (localhost HTTP is allowed for dev/test).
        let http_client = reqwest::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .timeout(std::time::Duration::from_secs(30))
            .build()
            // Reason: TLS backend absence is a catastrophic system misconfiguration;
            // this constructor is #[must_use] and returns Self, not Result.
            .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,
        }
    }

    /// Attach a JWT replay cache to this validator.
    ///
    /// When set, every validated token's `jti` claim is checked against the
    /// cache. A token whose `jti` has been seen before is rejected with
    /// `SecurityError::TokenReplayed`, preventing stolen-token replay attacks.
    ///
    /// If `require_jti` is `true` in [`OidcConfig`], tokens without a `jti`
    /// are also rejected before the replay check is reached.
    #[must_use]
    pub fn with_replay_cache(mut self, cache: Arc<ReplayCache>) -> Self {
        self.replay_cache = Some(cache);
        self
    }

    /// Validate a JWT token and extract user information.
    ///
    /// # Arguments
    ///
    /// * `token` - The JWT token string (without "Bearer " prefix)
    ///
    /// # Returns
    ///
    /// `AuthenticatedUser` if token is valid, error otherwise.
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - Token is malformed
    /// - Signature verification fails
    /// - Required claims are missing
    /// - Token is expired
    /// - Issuer/audience don't match
    pub async fn validate_token(&self, token: &str) -> Result<AuthenticatedUser> {
        // Decode header to get kid
        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
        })?;

        // Get the signing key (fetch/cache logic in jwks.rs)
        let decoding_key = self.get_decoding_key(kid).await?;

        // Build validation
        let mut validation = Validation::new(self.get_algorithm(&header)?);
        validation.set_issuer(&[&self.config.issuer]);

        // Set audience validation — always enabled when any audience is configured.
        // The validate() call earlier guarantees at least one audience is set,
        // so this else branch (validate_aud = false) is never reached in practice.
        // It is kept as a defensive fallback; the real protection is the mandatory
        // audience check in OidcConfig::validate().
        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() {
            // Only additional_audiences configured (no primary audience):
            // still validate against those.
            validation.set_audience(&self.config.additional_audiences);
        } else {
            // Should be unreachable after OidcConfig::validate() — fail-closed.
            validation.validate_aud = true;
        }

        // Set clock skew tolerance — capped to prevent accepting arbitrarily
        // old expired tokens due to misconfiguration.
        validation.leeway = self.config.clock_skew_secs.min(MAX_CLOCK_SKEW_SECS);

        // Decode and validate token
        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(), // Approximate
                },
                // Invalid issuer, audience, signature, and all other errors map to InvalidToken
                _ => SecurityError::InvalidToken,
            }
        })?;

        let claims = token_data.claims;

        // Validate jti claim if required by configuration.
        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(),
            });
        }

        // Replay check: if a replay cache is configured and the token carries a jti,
        // verify the jti has not been seen before and record it for future checks.
        if let (Some(replay_cache), Some(ref jti)) = (&self.replay_cache, &claims.jti) {
            use std::time::Duration;
            // Compute the token's remaining TTL for the cache entry.
            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)); // Fallback: 15-minute TTL

            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(_) => {
                        // Backend error with fail-open is already handled inside
                        // ReplayCache::check_and_record(); reaching here means
                        // fail-closed is configured.
                        tracing::warn!(jti = %jti, error = %e, "Replay cache backend error");
                        SecurityError::InvalidToken
                    },
                }
            })?;
        }

        // Extract scopes first (before moving claims.sub)
        let scopes = self.extract_scopes(&claims);

        // Extract user ID (required)
        let user_id_str = claims.sub.ok_or(SecurityError::TokenMissingClaim {
            claim: "sub".to_string(),
        })?;
        let user_id = crate::types::UserId::new(user_id_str);

        // Extract expiration (required)
        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"
        );

        // Extract email and display name from dedicated fields or extra claims.
        // Dedicated fields take priority; fall back to extra for providers that
        // use non-standard claim names.
        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,
        })
    }

    /// Get the algorithm from the JWT header.
    ///
    /// # Errors
    ///
    /// Returns `SecurityError::InvalidTokenAlgorithm` if the algorithm is not in the allow-list.
    pub(super) fn get_algorithm(&self, header: &jsonwebtoken::Header) -> Result<Algorithm> {
        let alg_str = format!("{:?}", header.alg);

        // Check if algorithm is allowed
        if !self.config.allowed_algorithms.contains(&alg_str) {
            return Err(SecurityError::InvalidTokenAlgorithm { algorithm: alg_str });
        }

        Ok(header.alg)
    }

    /// Extract scopes from JWT claims.
    ///
    /// Handles multiple formats:
    /// - `scope`: space-separated string (Auth0, Okta)
    /// - `scp`: array of strings (some providers)
    /// - `permissions`: array of strings (Auth0 RBAC)
    fn extract_scopes(&self, claims: &JwtClaims) -> Vec<String> {
        // Try the configured scope claim first (default: "scope")
        if self.config.scope_claim == "scope" {
            if let Some(ref scope) = claims.scope {
                return scope.split_whitespace().map(String::from).collect();
            }
        }

        // Try scp (array format)
        if let Some(ref scp) = claims.scp {
            return scp.clone();
        }

        // Try permissions (Auth0 RBAC)
        if let Some(ref perms) = claims.permissions {
            return perms.clone();
        }

        // Try scope as space-separated string
        if let Some(ref scope) = claims.scope {
            return scope.split_whitespace().map(String::from).collect();
        }

        Vec::new()
    }

    /// Check if authentication is required.
    #[must_use]
    pub const fn is_required(&self) -> bool {
        self.config.required
    }

    /// Get the configured issuer.
    #[must_use]
    pub fn issuer(&self) -> &str {
        &self.config.issuer
    }

    /// Clear the JWKS cache.
    ///
    /// Call this if you need to force a refresh of the signing keys.
    pub fn clear_cache(&self) {
        let mut cache = self.jwks_cache.write();
        *cache = None;
    }
}