Skip to main content

fraiseql_core/security/oidc/
providers.rs

1//! Per-provider OIDC configuration constructors (Auth0, Keycloak, Okta, etc.).
2
3use serde::{Deserialize, Serialize};
4
5use crate::security::errors::{Result, SecurityError};
6
7// ============================================================================
8// OIDC Configuration
9// ============================================================================
10
11/// Configuration for the `GET /auth/me` session-identity endpoint.
12///
13/// Exposed at `/auth/me` when enabled.  The endpoint reads the validated
14/// JWT already in the request context and returns a JSON object containing
15/// `sub`, `user_id` (alias for `sub`), `expires_at`, plus any extra claims
16/// listed in `expose_claims` that are present in the token.
17///
18/// # Example (TOML)
19///
20/// ```toml
21/// [auth.me]
22/// enabled = true
23/// expose_claims = ["email", "tenant_id", "https://myapp.com/role"]
24/// ```
25// #1337: nested inside `OidcConfig`, same surface, same reason.
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27#[serde(deny_unknown_fields)]
28pub struct MeEndpointConfig {
29    /// Enable the `GET /auth/me` endpoint.  Default: `false` (opt-in).
30    #[serde(default)]
31    pub enabled: bool,
32
33    /// Raw JWT claim names to include in the response body, beyond the
34    /// always-present `sub`, `user_id`, and `expires_at`.
35    ///
36    /// Names must match the raw claim key in the token (e.g. `"email"`,
37    /// `"https://myapp.com/role"`).  Claims absent from the token are silently
38    /// omitted.  The `user_id` alias for `sub` is always included and must
39    /// **not** be listed here — listing `"user_id"` would silently return
40    /// nothing because the JWT only carries `sub`.
41    #[serde(default)]
42    pub expose_claims: Vec<String>,
43}
44
45/// OIDC authentication configuration.
46///
47/// Configure this with your identity provider's issuer URL.
48/// The validator will automatically discover JWKS endpoint.
49///
50/// **SECURITY CRITICAL**: You MUST configure the `audience` field to prevent
51/// token confusion attacks. See the `audience` field documentation for details.
52// #1337: `[auth]` is the only place this is deserialized from — every other use
53// constructs it programmatically — so refusing an unknown key here closes the TOML
54// surface without affecting anything else. `require_jti` is a security switch that
55// used to sit at its default when misspelled, silently.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct OidcConfig {
59    /// Issuer URL (e.g., `https://your-tenant.auth0.com/`), optional.
60    ///
61    /// When set, it serves two purposes: OIDC discovery (building the
62    /// `.well-known/openid-configuration` URL when `jwks_uri` is not pinned) and
63    /// `iss`-claim validation — tokens must then carry a matching `iss`. Include
64    /// a trailing slash if the provider expects one.
65    ///
66    /// When unset (`None`), `iss` is **not** validated and no discovery is
67    /// performed, so `jwks_uri` must be pinned explicitly. This supports `IdPs`
68    /// whose access tokens omit the `iss` claim (e.g. self-hosted Hanko).
69    /// Signature verification (against the pinned JWKS) and `audience`
70    /// validation still apply, which is sufficient for a single-`IdP`,
71    /// direct-`jwks_uri` deployment.
72    #[serde(default)]
73    pub issuer: Option<String>,
74
75    /// Expected audience claim (REQUIRED for security).
76    ///
77    /// **SECURITY CRITICAL**: This field is mandatory. Tokens must have this value in their `aud`
78    /// claim. This prevents token confusion attacks where tokens intended for service A
79    /// can be used for service B.
80    ///
81    /// For Auth0, this is typically your API identifier (e.g., `https://api.example.com`).
82    /// For other providers, use a unique identifier that represents your application.
83    ///
84    /// Set at least one of:
85    /// - `audience` (primary audience)
86    /// - `additional_audiences` (secondary audiences)
87    #[serde(default)]
88    pub audience: Option<String>,
89
90    /// Additional allowed audiences (optional).
91    ///
92    /// Some tokens may have multiple audiences. Add extras here.
93    #[serde(default)]
94    pub additional_audiences: Vec<String>,
95
96    /// JWKS cache TTL in seconds.
97    ///
98    /// How long to cache the JWKS before refetching. This is the MAXIMUM
99    /// stolen-key replay window once a key rotation has propagated upstream:
100    /// after an `IdP` rotates keys, FraiseQL keeps accepting tokens signed by a
101    /// cached-but-rotated-out key until the cached entry expires or is flushed.
102    /// To close the window immediately on a known compromise, call
103    /// `OidcValidator::invalidate_jwks_cache` (e.g. via `POST
104    /// /admin/v1/auth/refresh-jwks`).
105    /// Default: 300 (5 minutes) — short to bound the replay window.
106    #[serde(default = "default_jwks_cache_ttl")]
107    pub jwks_cache_ttl_secs: u64,
108
109    /// Allowed token algorithms.
110    ///
111    /// Default: RS256 (most common for OIDC providers)
112    #[serde(default = "default_algorithms")]
113    pub allowed_algorithms: Vec<String>,
114
115    /// Clock skew tolerance in seconds.
116    ///
117    /// Allow this many seconds of clock difference when
118    /// validating exp/nbf/iat claims.
119    /// Default: 60 seconds
120    #[serde(default = "default_clock_skew")]
121    pub clock_skew_secs: u64,
122
123    /// Custom JWKS URI (optional).
124    ///
125    /// If set, skip OIDC discovery and use this URI directly.
126    /// Useful for providers that don't support standard discovery.
127    #[serde(default)]
128    pub jwks_uri: Option<String>,
129
130    /// Require authentication for all requests.
131    ///
132    /// If false, requests without tokens are allowed (anonymous access).
133    /// Default: true
134    #[serde(default = "default_required")]
135    pub required: bool,
136
137    /// Scope claim name.
138    ///
139    /// The claim containing user scopes/permissions.
140    /// Default: "scope" (space-separated string)
141    /// Some providers use "scp" or "permissions" (array)
142    #[serde(default = "default_scope_claim")]
143    pub scope_claim: String,
144
145    /// Require the `jti` (JWT ID) claim on every validated token.
146    ///
147    /// When `true`, tokens without a `jti` are rejected with a validation error.
148    /// When `false` (default), a missing `jti` is accepted but the token cannot
149    /// be replay-checked.
150    ///
151    /// Set to `true` when you have a [`ReplayCache`] configured, so that every
152    /// token is guaranteed to be uniquely identifiable.
153    ///
154    /// [`ReplayCache`]: crate::security::oidc::replay_cache::ReplayCache
155    #[serde(default)]
156    pub require_jti: bool,
157
158    /// Configuration for the `GET /auth/me` session-identity endpoint.
159    ///
160    /// When present and `enabled = true`, mounts `GET /auth/me` behind
161    /// OIDC authentication.  The endpoint reflects a configurable subset of
162    /// the current session's JWT claims to the frontend.
163    ///
164    /// Default: `None` (endpoint not mounted).
165    #[serde(default)]
166    pub me: Option<MeEndpointConfig>,
167
168    /// Further token issuers this server trusts beside [`Self::issuer`] (#1400) — for
169    /// example a first-party token-exchange service minting short-lived delegation
170    /// tokens next to the `IdP` users sign in with. Each is a complete issuer profile
171    /// with its own keys, audience and algorithms.
172    ///
173    /// A token is routed to exactly one profile by its `iss` claim, read before the
174    /// signature is checked, and verified with that profile's keys alone. A token whose
175    /// `iss` names no configured issuer is refused; it is never tried against every key
176    /// set, which would make `iss` meaningless. Configuring any requires [`Self::issuer`]
177    /// to be set too.
178    ///
179    /// Shared with the primary issuer: `required`, `jwks_cache_ttl_secs` and `me`.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub additional_issuers: Vec<TrustedIssuer>,
182}
183
184/// One additional trusted token issuer (`[[auth.additional_issuers]]`, #1400).
185///
186/// The issuer-level settings of [`OidcConfig`], for a second (third, …) issuer. `issuer`
187/// is mandatory: it is what routes a token here.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct TrustedIssuer {
191    /// The issuer URL, matched exactly against a token's `iss`.
192    pub issuer:               String,
193    /// Expected audience; see [`OidcConfig::audience`].
194    #[serde(default)]
195    pub audience:             Option<String>,
196    /// Further accepted audiences; see [`OidcConfig::additional_audiences`].
197    #[serde(default)]
198    pub additional_audiences: Vec<String>,
199    /// Allowed signing algorithms; see [`OidcConfig::allowed_algorithms`].
200    #[serde(default = "default_algorithms")]
201    pub allowed_algorithms:   Vec<String>,
202    /// Clock-skew tolerance; see [`OidcConfig::clock_skew_secs`].
203    #[serde(default = "default_clock_skew")]
204    pub clock_skew_secs:      u64,
205    /// Pinned JWKS endpoint; discovered from `issuer` when unset.
206    #[serde(default)]
207    pub jwks_uri:             Option<String>,
208    /// Claim scopes are read from; see [`OidcConfig::scope_claim`].
209    #[serde(default = "default_scope_claim")]
210    pub scope_claim:          String,
211    /// Require a `jti` claim; see [`OidcConfig::require_jti`].
212    #[serde(default)]
213    pub require_jti:          bool,
214}
215
216impl TrustedIssuer {
217    /// This issuer as a complete single-issuer [`OidcConfig`], taking the settings it
218    /// shares with the primary issuer from `shared`. The validator and
219    /// [`OidcConfig::validate`] then treat every issuer through one code path.
220    #[must_use]
221    pub fn as_config(&self, shared: &OidcConfig) -> OidcConfig {
222        OidcConfig {
223            issuer:               Some(self.issuer.clone()),
224            audience:             self.audience.clone(),
225            additional_audiences: self.additional_audiences.clone(),
226            jwks_cache_ttl_secs:  shared.jwks_cache_ttl_secs,
227            allowed_algorithms:   self.allowed_algorithms.clone(),
228            clock_skew_secs:      self.clock_skew_secs,
229            jwks_uri:             self.jwks_uri.clone(),
230            required:             shared.required,
231            scope_claim:          self.scope_claim.clone(),
232            require_jti:          self.require_jti,
233            me:                   None,
234            additional_issuers:   Vec::new(),
235        }
236    }
237}
238
239pub(super) const fn default_jwks_cache_ttl() -> u64 {
240    // SECURITY: Reduced from 3600s (1 hour) to 300s (5 minutes)
241    // Prevents token cache poisoning by limiting revoked token window
242    300
243}
244
245pub(super) fn default_algorithms() -> Vec<String> {
246    vec!["RS256".to_string()]
247}
248
249/// Maximum clock skew tolerance enforced regardless of configuration.
250/// Prevents accepting arbitrarily old expired tokens due to misconfiguration.
251pub(super) const MAX_CLOCK_SKEW_SECS: u64 = 300;
252
253pub(super) const fn default_clock_skew() -> u64 {
254    60
255}
256
257pub(super) const fn default_required() -> bool {
258    true
259}
260
261pub(super) fn default_scope_claim() -> String {
262    "scope".to_string()
263}
264
265impl Default for OidcConfig {
266    fn default() -> Self {
267        Self {
268            issuer:               None,
269            audience:             None,
270            additional_audiences: Vec::new(),
271            jwks_cache_ttl_secs:  default_jwks_cache_ttl(),
272            allowed_algorithms:   default_algorithms(),
273            clock_skew_secs:      default_clock_skew(),
274            jwks_uri:             None,
275            required:             default_required(),
276            scope_claim:          default_scope_claim(),
277            require_jti:          false,
278            me:                   None,
279            additional_issuers:   Vec::new(),
280        }
281    }
282}
283
284impl OidcConfig {
285    /// Create config for Auth0.
286    ///
287    /// # Arguments
288    ///
289    /// * `domain` - Your Auth0 domain (e.g., "your-tenant.auth0.com")
290    /// * `audience` - Your API identifier
291    #[must_use]
292    pub fn auth0(domain: &str, audience: &str) -> Self {
293        Self {
294            issuer: Some(format!("https://{domain}/")),
295            audience: Some(audience.to_string()),
296            ..Default::default()
297        }
298    }
299
300    /// Create config for Keycloak.
301    ///
302    /// # Arguments
303    ///
304    /// * `base_url` - Keycloak server URL (e.g., `https://keycloak.example.com`)
305    /// * `realm` - Realm name
306    /// * `client_id` - Client ID (used as audience)
307    #[must_use]
308    pub fn keycloak(base_url: &str, realm: &str, client_id: &str) -> Self {
309        Self {
310            issuer: Some(format!("{base_url}/realms/{realm}")),
311            audience: Some(client_id.to_string()),
312            ..Default::default()
313        }
314    }
315
316    /// Create config for Okta.
317    ///
318    /// # Arguments
319    ///
320    /// * `domain` - Your Okta domain (e.g., "your-org.okta.com")
321    /// * `audience` - Your API audience (often "<api://default>")
322    #[must_use]
323    pub fn okta(domain: &str, audience: &str) -> Self {
324        Self {
325            issuer: Some(format!("https://{domain}")),
326            audience: Some(audience.to_string()),
327            ..Default::default()
328        }
329    }
330
331    /// Create config for AWS Cognito.
332    ///
333    /// # Arguments
334    ///
335    /// * `region` - AWS region (e.g., "us-east-1")
336    /// * `user_pool_id` - Cognito User Pool ID
337    /// * `client_id` - App client ID (used as audience)
338    #[must_use]
339    pub fn cognito(region: &str, user_pool_id: &str, client_id: &str) -> Self {
340        Self {
341            issuer: Some(format!("https://cognito-idp.{region}.amazonaws.com/{user_pool_id}")),
342            audience: Some(client_id.to_string()),
343            ..Default::default()
344        }
345    }
346
347    /// Create config for Microsoft Entra ID (Azure AD).
348    ///
349    /// # Arguments
350    ///
351    /// * `tenant_id` - Azure AD tenant ID
352    /// * `client_id` - Application (client) ID
353    #[must_use]
354    pub fn azure_ad(tenant_id: &str, client_id: &str) -> Self {
355        Self {
356            issuer: Some(format!("https://login.microsoftonline.com/{tenant_id}/v2.0")),
357            audience: Some(client_id.to_string()),
358            ..Default::default()
359        }
360    }
361
362    /// Create config for Google Identity.
363    ///
364    /// # Arguments
365    ///
366    /// * `client_id` - Google OAuth client ID
367    #[must_use]
368    pub fn google(client_id: &str) -> Self {
369        Self {
370            issuer: Some("https://accounts.google.com".to_string()),
371            audience: Some(client_id.to_string()),
372            ..Default::default()
373        }
374    }
375
376    /// Validate the configuration.
377    ///
378    /// # Errors
379    ///
380    /// Returns `SecurityError::SecurityConfigError` if:
381    /// - `issuer` is set but does not use HTTPS (except localhost/127.0.0.1)
382    /// - `issuer` is unset and `jwks_uri` is not pinned (discovery is impossible without an issuer)
383    /// - Neither `audience` nor `additional_audiences` are configured
384    /// - No algorithms are allowed
385    pub fn validate(&self) -> Result<()> {
386        self.validate_issuer_profile()?;
387
388        if self.additional_issuers.is_empty() {
389            return Ok(());
390        }
391        // A token is routed by `iss`; an issuer-less primary would accept tokens no
392        // `iss` can be checked against, beside issuers selected by that very claim.
393        let Some(primary) = self.issuer.as_deref() else {
394            return Err(SecurityError::SecurityConfigError(
395                "`additional_issuers` requires `issuer` to be set: tokens are routed to an \
396                 issuer by their `iss` claim, so every trusted issuer must be named"
397                    .to_string(),
398            ));
399        };
400        let mut seen = std::collections::HashSet::from([primary]);
401        for extra in &self.additional_issuers {
402            if !seen.insert(extra.issuer.as_str()) {
403                return Err(SecurityError::SecurityConfigError(format!(
404                    "OIDC issuer {:?} is configured twice; each trusted issuer appears once, \
405                     or a token's `iss` would not name one set of keys",
406                    extra.issuer
407                )));
408            }
409            extra.as_config(self).validate_issuer_profile().map_err(|e| {
410                SecurityError::SecurityConfigError(format!(
411                    "additional issuer {:?}: {e}",
412                    extra.issuer
413                ))
414            })?;
415        }
416        Ok(())
417    }
418
419    /// The single-issuer rules, applied to the primary issuer and to each additional one.
420    fn validate_issuer_profile(&self) -> Result<()> {
421        match &self.issuer {
422            Some(issuer) => {
423                if !issuer.starts_with("https://")
424                    && !issuer.starts_with("http://localhost")
425                    && !issuer.starts_with("http://127.0.0.1")
426                {
427                    return Err(SecurityError::SecurityConfigError(
428                        "OIDC issuer must use HTTPS (except localhost/127.0.0.1 for development)"
429                            .to_string(),
430                    ));
431                }
432            },
433            None => {
434                // Issuer-less mode: `iss` is not validated and OIDC discovery is
435                // impossible (the discovery URL is built from the issuer), so the
436                // JWKS endpoint must be pinned. Signature + audience validation
437                // still apply — sufficient for a single-IdP deployment whose
438                // tokens omit `iss` (e.g. Hanko).
439                if self.jwks_uri.is_none() {
440                    return Err(SecurityError::SecurityConfigError(
441                        "OIDC issuer is unset, so `jwks_uri` must be pinned: without an issuer, \
442                         discovery cannot locate the JWKS endpoint. Set `issuer` to your IdP's \
443                         issuer URL, or set `jwks_uri` to its JWKS endpoint directly."
444                            .to_string(),
445                    ));
446                }
447            },
448        }
449
450        // CRITICAL SECURITY FIX: Audience validation is now mandatory
451        // This prevents token confusion attacks where tokens intended for service A
452        // can be used for service B.
453        if self.audience.is_none() && self.additional_audiences.is_empty() {
454            return Err(SecurityError::SecurityConfigError(
455                "OIDC audience is REQUIRED for security. Set 'audience' in auth config to your API identifier. \
456                 This prevents token confusion attacks where tokens from one service can be used in another. \
457                 Example: audience = \"https://api.example.com\" or audience = \"my-api-id\"".to_string(),
458            ));
459        }
460
461        if self.allowed_algorithms.is_empty() {
462            return Err(SecurityError::SecurityConfigError(
463                "At least one algorithm must be allowed".to_string(),
464            ));
465        }
466
467        Ok(())
468    }
469}