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}