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.
82 /// Default: 300 (5 minutes) — short to prevent token cache poisoning.
83 #[serde(default = "default_jwks_cache_ttl")]
84 pub jwks_cache_ttl_secs: u64,
85
86 /// Allowed token algorithms.
87 ///
88 /// Default: RS256 (most common for OIDC providers)
89 #[serde(default = "default_algorithms")]
90 pub allowed_algorithms: Vec<String>,
91
92 /// Clock skew tolerance in seconds.
93 ///
94 /// Allow this many seconds of clock difference when
95 /// validating exp/nbf/iat claims.
96 /// Default: 60 seconds
97 #[serde(default = "default_clock_skew")]
98 pub clock_skew_secs: u64,
99
100 /// Custom JWKS URI (optional).
101 ///
102 /// If set, skip OIDC discovery and use this URI directly.
103 /// Useful for providers that don't support standard discovery.
104 #[serde(default)]
105 pub jwks_uri: Option<String>,
106
107 /// Require authentication for all requests.
108 ///
109 /// If false, requests without tokens are allowed (anonymous access).
110 /// Default: true
111 #[serde(default = "default_required")]
112 pub required: bool,
113
114 /// Scope claim name.
115 ///
116 /// The claim containing user scopes/permissions.
117 /// Default: "scope" (space-separated string)
118 /// Some providers use "scp" or "permissions" (array)
119 #[serde(default = "default_scope_claim")]
120 pub scope_claim: String,
121
122 /// Require the `jti` (JWT ID) claim on every validated token.
123 ///
124 /// When `true`, tokens without a `jti` are rejected with a validation error.
125 /// When `false` (default), a missing `jti` is accepted but the token cannot
126 /// be replay-checked.
127 ///
128 /// Set to `true` when you have a [`ReplayCache`] configured, so that every
129 /// token is guaranteed to be uniquely identifiable.
130 ///
131 /// [`ReplayCache`]: crate::security::oidc::replay_cache::ReplayCache
132 #[serde(default)]
133 pub require_jti: bool,
134
135 /// Configuration for the `GET /auth/me` session-identity endpoint.
136 ///
137 /// When present and `enabled = true`, mounts `GET /auth/me` behind
138 /// OIDC authentication. The endpoint reflects a configurable subset of
139 /// the current session's JWT claims to the frontend.
140 ///
141 /// Default: `None` (endpoint not mounted).
142 #[serde(default)]
143 pub me: Option<MeEndpointConfig>,
144}
145
146pub(super) const fn default_jwks_cache_ttl() -> u64 {
147 // SECURITY: Reduced from 3600s (1 hour) to 300s (5 minutes)
148 // Prevents token cache poisoning by limiting revoked token window
149 300
150}
151
152pub(super) fn default_algorithms() -> Vec<String> {
153 vec!["RS256".to_string()]
154}
155
156/// Maximum clock skew tolerance enforced regardless of configuration.
157/// Prevents accepting arbitrarily old expired tokens due to misconfiguration.
158pub(super) const MAX_CLOCK_SKEW_SECS: u64 = 300;
159
160pub(super) const fn default_clock_skew() -> u64 {
161 60
162}
163
164pub(super) const fn default_required() -> bool {
165 true
166}
167
168pub(super) fn default_scope_claim() -> String {
169 "scope".to_string()
170}
171
172impl Default for OidcConfig {
173 fn default() -> Self {
174 Self {
175 issuer: String::new(),
176 audience: None,
177 additional_audiences: Vec::new(),
178 jwks_cache_ttl_secs: default_jwks_cache_ttl(),
179 allowed_algorithms: default_algorithms(),
180 clock_skew_secs: default_clock_skew(),
181 jwks_uri: None,
182 required: default_required(),
183 scope_claim: default_scope_claim(),
184 require_jti: false,
185 me: None,
186 }
187 }
188}
189
190impl OidcConfig {
191 /// Create config for Auth0.
192 ///
193 /// # Arguments
194 ///
195 /// * `domain` - Your Auth0 domain (e.g., "your-tenant.auth0.com")
196 /// * `audience` - Your API identifier
197 #[must_use]
198 pub fn auth0(domain: &str, audience: &str) -> Self {
199 Self {
200 issuer: format!("https://{domain}/"),
201 audience: Some(audience.to_string()),
202 ..Default::default()
203 }
204 }
205
206 /// Create config for Keycloak.
207 ///
208 /// # Arguments
209 ///
210 /// * `base_url` - Keycloak server URL (e.g., `https://keycloak.example.com`)
211 /// * `realm` - Realm name
212 /// * `client_id` - Client ID (used as audience)
213 #[must_use]
214 pub fn keycloak(base_url: &str, realm: &str, client_id: &str) -> Self {
215 Self {
216 issuer: format!("{base_url}/realms/{realm}"),
217 audience: Some(client_id.to_string()),
218 ..Default::default()
219 }
220 }
221
222 /// Create config for Okta.
223 ///
224 /// # Arguments
225 ///
226 /// * `domain` - Your Okta domain (e.g., "your-org.okta.com")
227 /// * `audience` - Your API audience (often "<api://default>")
228 #[must_use]
229 pub fn okta(domain: &str, audience: &str) -> Self {
230 Self {
231 issuer: format!("https://{domain}"),
232 audience: Some(audience.to_string()),
233 ..Default::default()
234 }
235 }
236
237 /// Create config for AWS Cognito.
238 ///
239 /// # Arguments
240 ///
241 /// * `region` - AWS region (e.g., "us-east-1")
242 /// * `user_pool_id` - Cognito User Pool ID
243 /// * `client_id` - App client ID (used as audience)
244 #[must_use]
245 pub fn cognito(region: &str, user_pool_id: &str, client_id: &str) -> Self {
246 Self {
247 issuer: format!("https://cognito-idp.{region}.amazonaws.com/{user_pool_id}"),
248 audience: Some(client_id.to_string()),
249 ..Default::default()
250 }
251 }
252
253 /// Create config for Microsoft Entra ID (Azure AD).
254 ///
255 /// # Arguments
256 ///
257 /// * `tenant_id` - Azure AD tenant ID
258 /// * `client_id` - Application (client) ID
259 #[must_use]
260 pub fn azure_ad(tenant_id: &str, client_id: &str) -> Self {
261 Self {
262 issuer: format!("https://login.microsoftonline.com/{tenant_id}/v2.0"),
263 audience: Some(client_id.to_string()),
264 ..Default::default()
265 }
266 }
267
268 /// Create config for Google Identity.
269 ///
270 /// # Arguments
271 ///
272 /// * `client_id` - Google OAuth client ID
273 #[must_use]
274 pub fn google(client_id: &str) -> Self {
275 Self {
276 issuer: "https://accounts.google.com".to_string(),
277 audience: Some(client_id.to_string()),
278 ..Default::default()
279 }
280 }
281
282 /// Validate the configuration.
283 ///
284 /// # Errors
285 ///
286 /// Returns `SecurityError::SecurityConfigError` if:
287 /// - Issuer is empty
288 /// - Issuer does not use HTTPS (except localhost/127.0.0.1)
289 /// - Neither `audience` nor `additional_audiences` are configured
290 /// - No algorithms are allowed
291 pub fn validate(&self) -> Result<()> {
292 if self.issuer.is_empty() {
293 return Err(SecurityError::SecurityConfigError(
294 "OIDC issuer URL is required".to_string(),
295 ));
296 }
297
298 if !self.issuer.starts_with("https://")
299 && !self.issuer.starts_with("http://localhost")
300 && !self.issuer.starts_with("http://127.0.0.1")
301 {
302 return Err(SecurityError::SecurityConfigError(
303 "OIDC issuer must use HTTPS (except localhost/127.0.0.1 for development)"
304 .to_string(),
305 ));
306 }
307
308 // CRITICAL SECURITY FIX: Audience validation is now mandatory
309 // This prevents token confusion attacks where tokens intended for service A
310 // can be used for service B.
311 if self.audience.is_none() && self.additional_audiences.is_empty() {
312 return Err(SecurityError::SecurityConfigError(
313 "OIDC audience is REQUIRED for security. Set 'audience' in auth config to your API identifier. \
314 This prevents token confusion attacks where tokens from one service can be used in another. \
315 Example: audience = \"https://api.example.com\" or audience = \"my-api-id\"".to_string(),
316 ));
317 }
318
319 if self.allowed_algorithms.is_empty() {
320 return Err(SecurityError::SecurityConfigError(
321 "At least one algorithm must be allowed".to_string(),
322 ));
323 }
324
325 Ok(())
326 }
327}