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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct MeEndpointConfig {
27    /// Enable the `GET /auth/me` endpoint.  Default: `false` (opt-in).
28    #[serde(default)]
29    pub enabled: bool,
30
31    /// Raw JWT claim names to include in the response body, beyond the
32    /// always-present `sub`, `user_id`, and `expires_at`.
33    ///
34    /// Names must match the raw claim key in the token (e.g. `"email"`,
35    /// `"https://myapp.com/role"`).  Claims absent from the token are silently
36    /// omitted.  The `user_id` alias for `sub` is always included and must
37    /// **not** be listed here — listing `"user_id"` would silently return
38    /// nothing because the JWT only carries `sub`.
39    #[serde(default)]
40    pub expose_claims: Vec<String>,
41}
42
43/// OIDC authentication configuration.
44///
45/// Configure this with your identity provider's issuer URL.
46/// The validator will automatically discover JWKS endpoint.
47///
48/// **SECURITY CRITICAL**: You MUST configure the `audience` field to prevent
49/// token confusion attacks. See the `audience` field documentation for details.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct OidcConfig {
52    /// Issuer URL (e.g., `https://your-tenant.auth0.com/`)
53    ///
54    /// Must match the `iss` claim in tokens exactly.
55    /// Should include trailing slash if provider expects it.
56    pub issuer: String,
57
58    /// Expected audience claim (REQUIRED for security).
59    ///
60    /// **SECURITY CRITICAL**: This field is mandatory. Tokens must have this value in their `aud`
61    /// claim. This prevents token confusion attacks where tokens intended for service A
62    /// can be used for service B.
63    ///
64    /// For Auth0, this is typically your API identifier (e.g., `https://api.example.com`).
65    /// For other providers, use a unique identifier that represents your application.
66    ///
67    /// Set at least one of:
68    /// - `audience` (primary audience)
69    /// - `additional_audiences` (secondary audiences)
70    #[serde(default)]
71    pub audience: Option<String>,
72
73    /// Additional allowed audiences (optional).
74    ///
75    /// Some tokens may have multiple audiences. Add extras here.
76    #[serde(default)]
77    pub additional_audiences: Vec<String>,
78
79    /// JWKS cache TTL in seconds.
80    ///
81    /// How long to cache the JWKS before refetching. This is the MAXIMUM
82    /// stolen-key replay window once a key rotation has propagated upstream:
83    /// after an `IdP` rotates keys, FraiseQL keeps accepting tokens signed by a
84    /// cached-but-rotated-out key until the cached entry expires or is flushed.
85    /// To close the window immediately on a known compromise, call
86    /// `OidcValidator::invalidate_jwks_cache` (e.g. via `POST
87    /// /admin/v1/auth/refresh-jwks`).
88    /// Default: 300 (5 minutes) — short to bound the replay window.
89    #[serde(default = "default_jwks_cache_ttl")]
90    pub jwks_cache_ttl_secs: u64,
91
92    /// Allowed token algorithms.
93    ///
94    /// Default: RS256 (most common for OIDC providers)
95    #[serde(default = "default_algorithms")]
96    pub allowed_algorithms: Vec<String>,
97
98    /// Clock skew tolerance in seconds.
99    ///
100    /// Allow this many seconds of clock difference when
101    /// validating exp/nbf/iat claims.
102    /// Default: 60 seconds
103    #[serde(default = "default_clock_skew")]
104    pub clock_skew_secs: u64,
105
106    /// Custom JWKS URI (optional).
107    ///
108    /// If set, skip OIDC discovery and use this URI directly.
109    /// Useful for providers that don't support standard discovery.
110    #[serde(default)]
111    pub jwks_uri: Option<String>,
112
113    /// Require authentication for all requests.
114    ///
115    /// If false, requests without tokens are allowed (anonymous access).
116    /// Default: true
117    #[serde(default = "default_required")]
118    pub required: bool,
119
120    /// Scope claim name.
121    ///
122    /// The claim containing user scopes/permissions.
123    /// Default: "scope" (space-separated string)
124    /// Some providers use "scp" or "permissions" (array)
125    #[serde(default = "default_scope_claim")]
126    pub scope_claim: String,
127
128    /// Require the `jti` (JWT ID) claim on every validated token.
129    ///
130    /// When `true`, tokens without a `jti` are rejected with a validation error.
131    /// When `false` (default), a missing `jti` is accepted but the token cannot
132    /// be replay-checked.
133    ///
134    /// Set to `true` when you have a [`ReplayCache`] configured, so that every
135    /// token is guaranteed to be uniquely identifiable.
136    ///
137    /// [`ReplayCache`]: crate::security::oidc::replay_cache::ReplayCache
138    #[serde(default)]
139    pub require_jti: bool,
140
141    /// Configuration for the `GET /auth/me` session-identity endpoint.
142    ///
143    /// When present and `enabled = true`, mounts `GET /auth/me` behind
144    /// OIDC authentication.  The endpoint reflects a configurable subset of
145    /// the current session's JWT claims to the frontend.
146    ///
147    /// Default: `None` (endpoint not mounted).
148    #[serde(default)]
149    pub me: Option<MeEndpointConfig>,
150}
151
152pub(super) const fn default_jwks_cache_ttl() -> u64 {
153    // SECURITY: Reduced from 3600s (1 hour) to 300s (5 minutes)
154    // Prevents token cache poisoning by limiting revoked token window
155    300
156}
157
158pub(super) fn default_algorithms() -> Vec<String> {
159    vec!["RS256".to_string()]
160}
161
162/// Maximum clock skew tolerance enforced regardless of configuration.
163/// Prevents accepting arbitrarily old expired tokens due to misconfiguration.
164pub(super) const MAX_CLOCK_SKEW_SECS: u64 = 300;
165
166pub(super) const fn default_clock_skew() -> u64 {
167    60
168}
169
170pub(super) const fn default_required() -> bool {
171    true
172}
173
174pub(super) fn default_scope_claim() -> String {
175    "scope".to_string()
176}
177
178impl Default for OidcConfig {
179    fn default() -> Self {
180        Self {
181            issuer:               String::new(),
182            audience:             None,
183            additional_audiences: Vec::new(),
184            jwks_cache_ttl_secs:  default_jwks_cache_ttl(),
185            allowed_algorithms:   default_algorithms(),
186            clock_skew_secs:      default_clock_skew(),
187            jwks_uri:             None,
188            required:             default_required(),
189            scope_claim:          default_scope_claim(),
190            require_jti:          false,
191            me:                   None,
192        }
193    }
194}
195
196impl OidcConfig {
197    /// Create config for Auth0.
198    ///
199    /// # Arguments
200    ///
201    /// * `domain` - Your Auth0 domain (e.g., "your-tenant.auth0.com")
202    /// * `audience` - Your API identifier
203    #[must_use]
204    pub fn auth0(domain: &str, audience: &str) -> Self {
205        Self {
206            issuer: format!("https://{domain}/"),
207            audience: Some(audience.to_string()),
208            ..Default::default()
209        }
210    }
211
212    /// Create config for Keycloak.
213    ///
214    /// # Arguments
215    ///
216    /// * `base_url` - Keycloak server URL (e.g., `https://keycloak.example.com`)
217    /// * `realm` - Realm name
218    /// * `client_id` - Client ID (used as audience)
219    #[must_use]
220    pub fn keycloak(base_url: &str, realm: &str, client_id: &str) -> Self {
221        Self {
222            issuer: format!("{base_url}/realms/{realm}"),
223            audience: Some(client_id.to_string()),
224            ..Default::default()
225        }
226    }
227
228    /// Create config for Okta.
229    ///
230    /// # Arguments
231    ///
232    /// * `domain` - Your Okta domain (e.g., "your-org.okta.com")
233    /// * `audience` - Your API audience (often "<api://default>")
234    #[must_use]
235    pub fn okta(domain: &str, audience: &str) -> Self {
236        Self {
237            issuer: format!("https://{domain}"),
238            audience: Some(audience.to_string()),
239            ..Default::default()
240        }
241    }
242
243    /// Create config for AWS Cognito.
244    ///
245    /// # Arguments
246    ///
247    /// * `region` - AWS region (e.g., "us-east-1")
248    /// * `user_pool_id` - Cognito User Pool ID
249    /// * `client_id` - App client ID (used as audience)
250    #[must_use]
251    pub fn cognito(region: &str, user_pool_id: &str, client_id: &str) -> Self {
252        Self {
253            issuer: format!("https://cognito-idp.{region}.amazonaws.com/{user_pool_id}"),
254            audience: Some(client_id.to_string()),
255            ..Default::default()
256        }
257    }
258
259    /// Create config for Microsoft Entra ID (Azure AD).
260    ///
261    /// # Arguments
262    ///
263    /// * `tenant_id` - Azure AD tenant ID
264    /// * `client_id` - Application (client) ID
265    #[must_use]
266    pub fn azure_ad(tenant_id: &str, client_id: &str) -> Self {
267        Self {
268            issuer: format!("https://login.microsoftonline.com/{tenant_id}/v2.0"),
269            audience: Some(client_id.to_string()),
270            ..Default::default()
271        }
272    }
273
274    /// Create config for Google Identity.
275    ///
276    /// # Arguments
277    ///
278    /// * `client_id` - Google OAuth client ID
279    #[must_use]
280    pub fn google(client_id: &str) -> Self {
281        Self {
282            issuer: "https://accounts.google.com".to_string(),
283            audience: Some(client_id.to_string()),
284            ..Default::default()
285        }
286    }
287
288    /// Validate the configuration.
289    ///
290    /// # Errors
291    ///
292    /// Returns `SecurityError::SecurityConfigError` if:
293    /// - Issuer is empty
294    /// - Issuer does not use HTTPS (except localhost/127.0.0.1)
295    /// - Neither `audience` nor `additional_audiences` are configured
296    /// - No algorithms are allowed
297    pub fn validate(&self) -> Result<()> {
298        if self.issuer.is_empty() {
299            return Err(SecurityError::SecurityConfigError(
300                "OIDC issuer URL is required".to_string(),
301            ));
302        }
303
304        if !self.issuer.starts_with("https://")
305            && !self.issuer.starts_with("http://localhost")
306            && !self.issuer.starts_with("http://127.0.0.1")
307        {
308            return Err(SecurityError::SecurityConfigError(
309                "OIDC issuer must use HTTPS (except localhost/127.0.0.1 for development)"
310                    .to_string(),
311            ));
312        }
313
314        // CRITICAL SECURITY FIX: Audience validation is now mandatory
315        // This prevents token confusion attacks where tokens intended for service A
316        // can be used for service B.
317        if self.audience.is_none() && self.additional_audiences.is_empty() {
318            return Err(SecurityError::SecurityConfigError(
319                "OIDC audience is REQUIRED for security. Set 'audience' in auth config to your API identifier. \
320                 This prevents token confusion attacks where tokens from one service can be used in another. \
321                 Example: audience = \"https://api.example.com\" or audience = \"my-api-id\"".to_string(),
322            ));
323        }
324
325        if self.allowed_algorithms.is_empty() {
326            return Err(SecurityError::SecurityConfigError(
327                "At least one algorithm must be allowed".to_string(),
328            ));
329        }
330
331        Ok(())
332    }
333}