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