fraiseql_cli/config/toml_schema/security.rs
1//! Security configuration types for `[security.*]` and `[auth]` TOML sections.
2
3use std::fmt;
4
5use fraiseql_core::security::oidc::MeEndpointConfig;
6use serde::{Deserialize, Serialize};
7
8/// Security configuration
9#[derive(Debug, Clone, Deserialize, Serialize)]
10#[serde(default, deny_unknown_fields)]
11pub struct SecuritySettings {
12 /// Default policy to apply if none specified
13 pub default_policy: Option<String>,
14 /// Custom authorization rules
15 pub rules: Vec<AuthorizationRule>,
16 /// Authorization policies
17 pub policies: Vec<AuthorizationPolicy>,
18 /// Field-level authorization rules
19 pub field_auth: Vec<FieldAuthRule>,
20 /// Enterprise security configuration (legacy flags)
21 pub enterprise: EnterpriseSecurityConfig,
22 /// Error sanitization — controls what detail clients see in error responses
23 pub error_sanitization: Option<ErrorSanitizationTomlConfig>,
24 /// Rate limiting — per-endpoint request caps
25 pub rate_limiting: Option<RateLimitingSecurityConfig>,
26 /// State encryption — AEAD encryption for OAuth state and PKCE blobs
27 pub state_encryption: Option<StateEncryptionConfig>,
28 /// PKCE — Proof Key for Code Exchange for OAuth Authorization Code flows
29 pub pkce: Option<PkceConfig>,
30 /// API key authentication — static or database-backed key-based auth
31 pub api_keys: Option<ApiKeySecurityConfig>,
32 /// Token revocation — reject JWTs by `jti` after revocation
33 pub token_revocation: Option<TokenRevocationSecurityConfig>,
34 /// Trusted documents — query allowlist (strict or permissive mode)
35 pub trusted_documents: Option<TrustedDocumentsConfig>,
36 /// Force persisted-operations-only enforcement (#379).
37 ///
38 /// When `true`, the runtime forces the trusted-document store into `strict` mode
39 /// (reject any non-persisted operation) regardless of `[security.trusted_documents].mode`.
40 /// Requires a configured trusted-documents manifest to have any effect.
41 pub persisted_queries_only: bool,
42}
43
44impl Default for SecuritySettings {
45 fn default() -> Self {
46 Self {
47 default_policy: Some("authenticated".to_string()),
48 rules: vec![],
49 policies: vec![],
50 field_auth: vec![],
51 enterprise: EnterpriseSecurityConfig::default(),
52 error_sanitization: None,
53 rate_limiting: None,
54 state_encryption: None,
55 pkce: None,
56 api_keys: None,
57 token_revocation: None,
58 trusted_documents: None,
59 persisted_queries_only: false,
60 }
61 }
62}
63
64/// Authorization rule (custom expressions)
65#[derive(Debug, Clone, Deserialize, Serialize)]
66#[serde(deny_unknown_fields)]
67pub struct AuthorizationRule {
68 /// Rule name
69 pub name: String,
70 /// Rule expression or condition
71 pub rule: String,
72 /// Rule description
73 pub description: Option<String>,
74 /// Whether rule result can be cached
75 #[serde(default)]
76 pub cacheable: bool,
77 /// Cache time-to-live in seconds
78 pub cache_ttl_seconds: Option<u32>,
79}
80
81/// Authorization policy (RBAC/ABAC)
82#[derive(Debug, Clone, Deserialize, Serialize)]
83#[serde(deny_unknown_fields)]
84pub struct AuthorizationPolicy {
85 /// Policy name
86 pub name: String,
87 /// Policy type (RBAC, ABAC, CUSTOM, HYBRID)
88 #[serde(rename = "type")]
89 pub policy_type: String,
90 /// Optional rule expression
91 pub rule: Option<String>,
92 /// Roles this policy applies to
93 pub roles: Vec<String>,
94 /// Combination strategy (ANY, ALL, EXACTLY)
95 pub strategy: Option<String>,
96 /// Attributes for attribute-based access control
97 #[serde(default)]
98 pub attributes: Vec<String>,
99 /// Policy description
100 pub description: Option<String>,
101 /// Cache time-to-live in seconds
102 pub cache_ttl_seconds: Option<u32>,
103}
104
105/// Field-level authorization rule
106#[derive(Debug, Clone, Deserialize, Serialize)]
107#[serde(deny_unknown_fields)]
108pub struct FieldAuthRule {
109 /// Type name this rule applies to
110 pub type_name: String,
111 /// Field name this rule applies to
112 pub field_name: String,
113 /// Policy to enforce
114 pub policy: String,
115}
116
117/// Enterprise security configuration
118#[derive(Debug, Clone, Deserialize, Serialize)]
119#[serde(default, deny_unknown_fields)]
120pub struct EnterpriseSecurityConfig {
121 /// Enable rate limiting
122 pub rate_limiting_enabled: bool,
123 /// Max requests per auth endpoint
124 pub auth_endpoint_max_requests: u32,
125 /// Rate limit window in seconds
126 pub auth_endpoint_window_seconds: u64,
127 /// Enable audit logging
128 pub audit_logging_enabled: bool,
129 /// Audit log backend service
130 pub audit_log_backend: String,
131 /// Audit log retention in days
132 pub audit_retention_days: u32,
133 /// Enable error sanitization
134 pub error_sanitization: bool,
135 /// Hide implementation details in errors
136 pub hide_implementation_details: bool,
137 /// Enable constant-time token comparison
138 pub constant_time_comparison: bool,
139 /// Enable PKCE for OAuth flows
140 pub pkce_enabled: bool,
141}
142
143impl Default for EnterpriseSecurityConfig {
144 fn default() -> Self {
145 Self {
146 rate_limiting_enabled: true,
147 auth_endpoint_max_requests: 100,
148 auth_endpoint_window_seconds: 60,
149 audit_logging_enabled: true,
150 audit_log_backend: "postgresql".to_string(),
151 audit_retention_days: 365,
152 error_sanitization: true,
153 hide_implementation_details: true,
154 constant_time_comparison: true,
155 pkce_enabled: true,
156 }
157 }
158}
159
160/// Controls how much error detail is exposed to API clients.
161/// When enabled, internal error messages, SQL, and stack traces are stripped.
162///
163/// Note: named `ErrorSanitizationTomlConfig` to avoid collision with the identically-named
164/// struct in `config::security` which serves `FraiseQLConfig`.
165#[derive(Debug, Clone, Deserialize, Serialize)]
166#[serde(default, deny_unknown_fields)]
167pub struct ErrorSanitizationTomlConfig {
168 /// Enable error sanitization (default: false — opt-in)
169 pub enabled: bool,
170 /// Strip stack traces, SQL fragments, file paths (default: true)
171 #[serde(default = "default_true")]
172 pub hide_implementation_details: bool,
173 /// Replace raw database error messages with a generic message (default: true)
174 #[serde(default = "default_true")]
175 pub sanitize_database_errors: bool,
176 /// Replacement message shown to clients when an internal error is sanitized
177 pub custom_error_message: Option<String>,
178}
179
180impl Default for ErrorSanitizationTomlConfig {
181 fn default() -> Self {
182 Self {
183 enabled: false,
184 hide_implementation_details: true,
185 sanitize_database_errors: true,
186 custom_error_message: None,
187 }
188 }
189}
190
191/// Per-endpoint and global rate limiting configuration for `[security.rate_limiting]`.
192#[derive(Debug, Clone, Deserialize, Serialize)]
193#[serde(default, deny_unknown_fields)]
194pub struct RateLimitingSecurityConfig {
195 /// Enable rate limiting
196 pub enabled: bool,
197 /// Global request rate cap (requests per second, per IP)
198 pub requests_per_second: u32,
199 /// Burst allowance above the steady-state rate
200 pub burst_size: u32,
201 /// Auth initiation endpoint — max requests per window
202 pub auth_start_max_requests: u32,
203 /// Auth initiation window in seconds
204 pub auth_start_window_secs: u64,
205 /// OAuth callback endpoint — max requests per window
206 pub auth_callback_max_requests: u32,
207 /// OAuth callback window in seconds
208 pub auth_callback_window_secs: u64,
209 /// Token refresh endpoint — max requests per window
210 pub auth_refresh_max_requests: u32,
211 /// Token refresh window in seconds
212 pub auth_refresh_window_secs: u64,
213 /// Logout endpoint — max requests per window
214 pub auth_logout_max_requests: u32,
215 /// Logout window in seconds
216 pub auth_logout_window_secs: u64,
217 /// Failed login attempts before lockout
218 pub failed_login_max_attempts: u32,
219 /// Duration of failed-login lockout in seconds
220 pub failed_login_lockout_secs: u64,
221 /// Per-authenticated-user request rate in requests/second.
222 /// Defaults to 10× `requests_per_second` if not set.
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub requests_per_second_per_user: Option<u32>,
225 /// Redis URL for distributed rate limiting (optional — falls back to in-memory)
226 pub redis_url: Option<String>,
227 /// Trust `X-Real-IP` / `X-Forwarded-For` headers for client IP extraction.
228 ///
229 /// Set to `true` only when FraiseQL is deployed behind a trusted reverse proxy
230 /// (e.g. nginx, Cloudflare, AWS ALB) that sets these headers.
231 /// Enabling without a trusted proxy allows clients to spoof their IP address.
232 #[serde(default)]
233 pub trust_proxy_headers: bool,
234 /// CIDR ranges trusted as proxy IPs (e.g. `["10.0.0.0/8", "172.16.0.0/12"]`).
235 ///
236 /// When set and `trust_proxy_headers = true`, `X-Forwarded-For` is only honoured
237 /// when the direct connection IP falls within one of these ranges — requests from
238 /// outside them use the connection IP directly, preventing clients from spoofing
239 /// their address. Use `["0.0.0.0/0"]` to trust every proxy IP explicitly.
240 ///
241 /// When omitted with `trust_proxy_headers = true`, all proxy IPs are trusted
242 /// (less secure — the server emits a startup warning). CIDR strings are validated
243 /// at compile time.
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub trusted_proxy_cidrs: Option<Vec<String>>,
246}
247
248impl Default for RateLimitingSecurityConfig {
249 fn default() -> Self {
250 Self {
251 enabled: false,
252 requests_per_second: 100,
253 requests_per_second_per_user: None,
254 burst_size: 200,
255 auth_start_max_requests: 5,
256 auth_start_window_secs: 60,
257 auth_callback_max_requests: 10,
258 auth_callback_window_secs: 60,
259 auth_refresh_max_requests: 20,
260 auth_refresh_window_secs: 300,
261 auth_logout_max_requests: 30,
262 auth_logout_window_secs: 60,
263 failed_login_max_attempts: 10,
264 failed_login_lockout_secs: 900,
265 redis_url: None,
266 trust_proxy_headers: false,
267 trusted_proxy_cidrs: None,
268 }
269 }
270}
271
272/// AEAD algorithm for OAuth state and PKCE state blobs.
273#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
274#[non_exhaustive]
275pub enum EncryptionAlgorithm {
276 /// ChaCha20-Poly1305 (recommended — constant-time, software-friendly)
277 #[default]
278 #[serde(rename = "chacha20-poly1305")]
279 Chacha20Poly1305,
280 /// AES-256-GCM (hardware-accelerated on modern CPUs)
281 #[serde(rename = "aes-256-gcm")]
282 Aes256Gcm,
283}
284
285impl fmt::Display for EncryptionAlgorithm {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 match self {
288 Self::Chacha20Poly1305 => f.write_str("chacha20-poly1305"),
289 Self::Aes256Gcm => f.write_str("aes-256-gcm"),
290 }
291 }
292}
293
294/// Where the encryption key is sourced from.
295#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
296#[serde(rename_all = "lowercase")]
297#[non_exhaustive]
298pub enum KeySource {
299 /// Read key from an environment variable
300 #[default]
301 Env,
302}
303
304/// AEAD encryption for OAuth state parameter and PKCE code challenges.
305#[derive(Debug, Clone, Deserialize, Serialize)]
306#[serde(default, deny_unknown_fields)]
307pub struct StateEncryptionConfig {
308 /// Enable state encryption
309 pub enabled: bool,
310 /// AEAD algorithm to use
311 pub algorithm: EncryptionAlgorithm,
312 /// Where to source the encryption key
313 pub key_source: KeySource,
314 /// Environment variable holding the 32-byte hex-encoded key
315 pub key_env: Option<String>,
316}
317
318impl Default for StateEncryptionConfig {
319 fn default() -> Self {
320 Self {
321 enabled: false,
322 algorithm: EncryptionAlgorithm::default(),
323 key_source: KeySource::Env,
324 key_env: Some("STATE_ENCRYPTION_KEY".to_string()),
325 }
326 }
327}
328
329/// PKCE code challenge method.
330#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
331#[non_exhaustive]
332pub enum CodeChallengeMethod {
333 /// SHA-256 (required in production)
334 #[default]
335 #[serde(rename = "S256")]
336 S256,
337 /// Plain (spec-allowed but insecure — warns at runtime)
338 #[serde(rename = "plain")]
339 Plain,
340}
341
342/// PKCE (Proof Key for Code Exchange) configuration.
343/// Requires `state_encryption` to be enabled for secure state storage.
344#[derive(Debug, Clone, Deserialize, Serialize)]
345#[serde(default, deny_unknown_fields)]
346pub struct PkceConfig {
347 /// Enable PKCE for OAuth Authorization Code flows
348 pub enabled: bool,
349 /// Code challenge method (`S256` recommended)
350 pub code_challenge_method: CodeChallengeMethod,
351 /// How long the PKCE state is valid before the auth flow expires (seconds)
352 pub state_ttl_secs: u64,
353 /// Redis URL for distributed PKCE state storage across multiple replicas.
354 ///
355 /// Required for multi-replica deployments (Kubernetes, ECS, fly.io with
356 /// multiple instances). Without Redis, `/auth/start` and `/auth/callback`
357 /// must hit the same replica.
358 ///
359 /// Requires the `redis-pkce` Cargo feature to be compiled in.
360 /// Example: `"redis://localhost:6379"` or `"${REDIS_URL}"`.
361 #[serde(skip_serializing_if = "Option::is_none")]
362 pub redis_url: Option<String>,
363}
364
365impl Default for PkceConfig {
366 fn default() -> Self {
367 Self {
368 enabled: false,
369 code_challenge_method: CodeChallengeMethod::S256,
370 state_ttl_secs: 600,
371 redis_url: None,
372 }
373 }
374}
375
376/// API key authentication configuration.
377///
378/// ```toml
379/// [security.api_keys]
380/// enabled = true
381/// header = "X-API-Key"
382/// hash_algorithm = "sha256"
383/// storage = "env"
384///
385/// [[security.api_keys.static]]
386/// key_hash = "sha256:abc123..."
387/// scopes = ["read:*"]
388/// name = "ci-readonly"
389/// ```
390#[derive(Debug, Clone, Deserialize, Serialize)]
391#[serde(default, deny_unknown_fields)]
392pub struct ApiKeySecurityConfig {
393 /// Enable API key authentication
394 pub enabled: bool,
395 /// HTTP header name to read the API key from
396 pub header: String,
397 /// Hash algorithm for key verification (`sha256`)
398 pub hash_algorithm: String,
399 /// Storage backend: `"env"` for static keys, `"postgres"` for DB-backed
400 pub storage: String,
401 /// Static API key entries (only for `storage = "env"`)
402 #[serde(default, rename = "static")]
403 pub static_keys: Vec<StaticApiKeyEntry>,
404}
405
406impl Default for ApiKeySecurityConfig {
407 fn default() -> Self {
408 Self {
409 enabled: false,
410 header: "X-API-Key".to_string(),
411 hash_algorithm: "sha256".to_string(),
412 storage: "env".to_string(),
413 static_keys: vec![],
414 }
415 }
416}
417
418/// A single static API key entry.
419#[derive(Debug, Clone, Deserialize, Serialize)]
420#[serde(deny_unknown_fields)]
421pub struct StaticApiKeyEntry {
422 /// Hex-encoded hash, optionally prefixed with algorithm (e.g. `sha256:abc...`)
423 pub key_hash: String,
424 /// Scopes granted by this key
425 #[serde(default)]
426 pub scopes: Vec<String>,
427 /// Human-readable name for audit logging
428 pub name: String,
429}
430
431/// Trusted document mode.
432#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
433#[serde(rename_all = "lowercase")]
434#[non_exhaustive]
435pub enum TrustedDocumentMode {
436 /// Only documentId requests allowed; raw query strings rejected
437 Strict,
438 /// documentId requests use the manifest; raw queries fall through
439 #[default]
440 Permissive,
441}
442
443/// Trusted documents / query allowlist configuration.
444///
445/// ```toml
446/// [security.trusted_documents]
447/// enabled = true
448/// mode = "strict"
449/// manifest_path = "./trusted-documents.json"
450/// reload_interval_secs = 0
451/// ```
452#[derive(Debug, Clone, Deserialize, Serialize)]
453#[serde(default, deny_unknown_fields)]
454pub struct TrustedDocumentsConfig {
455 /// Enable trusted documents
456 pub enabled: bool,
457 /// Enforcement mode: "strict" or "permissive"
458 pub mode: TrustedDocumentMode,
459 /// Path to the trusted documents manifest JSON file
460 #[serde(skip_serializing_if = "Option::is_none")]
461 pub manifest_path: Option<String>,
462 /// URL to fetch the trusted documents manifest from at startup
463 #[serde(skip_serializing_if = "Option::is_none")]
464 pub manifest_url: Option<String>,
465 /// Poll interval in seconds for hot-reloading the manifest (0 = no reload)
466 #[serde(default)]
467 pub reload_interval_secs: u64,
468}
469
470impl Default for TrustedDocumentsConfig {
471 fn default() -> Self {
472 Self {
473 enabled: false,
474 mode: TrustedDocumentMode::Permissive,
475 manifest_path: None,
476 manifest_url: None,
477 reload_interval_secs: 0,
478 }
479 }
480}
481
482/// Token revocation configuration.
483///
484/// ```toml
485/// [security.token_revocation]
486/// enabled = true
487/// backend = "redis"
488/// require_jti = true
489/// fail_open = false
490/// ```
491#[derive(Debug, Clone, Deserialize, Serialize)]
492#[serde(default, deny_unknown_fields)]
493pub struct TokenRevocationSecurityConfig {
494 /// Enable token revocation
495 pub enabled: bool,
496 /// Backend: `"redis"`, `"postgres"`, or `"memory"`
497 pub backend: String,
498 /// Reject JWTs without a `jti` claim when revocation is enabled
499 #[serde(default = "default_true")]
500 pub require_jti: bool,
501 /// If revocation store is unreachable: `false` = reject (fail-closed), `true` = allow
502 /// (fail-open)
503 #[serde(default)]
504 pub fail_open: bool,
505 /// Redis URL for distributed revocation (optional — inherited from `[fraiseql.redis]` if
506 /// absent)
507 #[serde(skip_serializing_if = "Option::is_none")]
508 pub redis_url: Option<String>,
509 /// How long (seconds) a `revoke-all` epoch is retained.
510 ///
511 /// `revoke-all` records a per-user epoch rather than deleting individual tokens, so the
512 /// entry must outlive every token that could have been issued before the revocation.
513 /// Set this **above your maximum access-token lifetime**; once it expires a pre-revocation
514 /// token would resume working (until its own `exp`). Default: 86400 (24h). The server reads
515 /// this value from the compiled schema.
516 pub revoke_all_ttl_secs: u64,
517}
518
519impl Default for TokenRevocationSecurityConfig {
520 fn default() -> Self {
521 Self {
522 enabled: false,
523 backend: "memory".to_string(),
524 require_jti: true,
525 fail_open: false,
526 redis_url: None,
527 revoke_all_ttl_secs: 86_400,
528 }
529 }
530}
531
532/// OIDC configuration for `[auth]`.
533///
534/// This is the CLI's **compile-time view** of the `[auth]` block. The server
535/// reads the same `[auth]` block independently into its own
536/// [`OidcConfig`](fraiseql_core::security::OidcConfig) at runtime, so this
537/// schema exists to *accept and structurally validate* the same block the
538/// server consumes — not to lower it into the compiled schema (the compiled
539/// schema carries no auth). Every JWT-validation field here must therefore stay
540/// field-compatible with `OidcConfig`, or `deny_unknown_fields` would reject a
541/// config the server accepts.
542///
543/// Two independent groups may be configured, together or separately:
544///
545/// - **JWT validation** — `issuer` **or** `jwks_uri`, plus `audience` and the rest of
546/// `OidcConfig`'s JWT settings (`additional_audiences`, `allowed_algorithms`,
547/// `jwks_cache_ttl_secs`, `clock_skew_secs`, `required`, `scope_claim`, `require_jti`,
548/// `[auth.me]`). The server consumes these to validate incoming bearer tokens; the CLI accepts
549/// them so the same file parses under both. Accepted and functional. `issuer` may be omitted for
550/// identity providers whose access tokens carry no `iss` claim (e.g. self-hosted Hanko); in that
551/// **issuer-less** mode `jwks_uri` must be pinned, since discovery cannot locate the JWKS
552/// endpoint without an issuer.
553/// - **PKCE OAuth client** (server-side login) — `discovery_url`, `client_id`, `client_secret_env`,
554/// `server_redirect_uri`, configured all four together. **Not yet functional on the compiled path
555/// (tracked in #621):** the compiled schema carries no `auth`/`auth_endpoints` for the server to
556/// consume, so a complete client group is *rejected at compile time* rather than silently
557/// accepted.
558///
559/// At least one group must be present; an empty `[auth]` is a load error. The client
560/// secret itself must never appear here — `client_secret_env` names the environment
561/// variable that holds it.
562///
563/// ```toml
564/// [auth]
565/// issuer = "https://accounts.google.com"
566/// audience = "my-api"
567/// ```
568///
569/// Issuer-less (e.g. Hanko), pinning the JWKS endpoint:
570///
571/// ```toml
572/// [auth]
573/// jwks_uri = "https://hanko.example.com/.well-known/jwks.json"
574/// audience = "my-relying-party-id"
575/// ```
576#[derive(Debug, Clone, Default, Deserialize, Serialize)]
577#[serde(default, deny_unknown_fields)]
578pub struct OidcClientConfig {
579 /// OIDC issuer URL for JWT validation (e.g. `"https://accounts.google.com"`).
580 #[serde(skip_serializing_if = "Option::is_none")]
581 pub issuer: Option<String>,
582 /// Expected `aud` claim for JWT validation. Required at runtime by the
583 /// server's `OidcConfig` to prevent token-confusion attacks.
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub audience: Option<String>,
586 /// Pinned JWKS endpoint for JWT validation (skips OIDC discovery).
587 ///
588 /// Required for **issuer-less** identity providers — IdPs whose access
589 /// tokens omit the `iss` claim (e.g. self-hosted Hanko). With `issuer`
590 /// unset, discovery cannot locate the JWKS endpoint, so it must be pinned
591 /// here. May also be set alongside `issuer` to skip discovery.
592 #[serde(skip_serializing_if = "Option::is_none")]
593 pub jwks_uri: Option<String>,
594
595 // JWT-validation fields below mirror the server's `OidcConfig` so a single
596 // config file's `[auth]` block parses under both. They are accepted and
597 // structurally validated here but consumed by the server (which re-reads the
598 // same block); the CLI does not act on their values. `cli_auth_schema_...`
599 // in tests pins that this list stays complete as `OidcConfig` evolves.
600 /// Additional accepted `aud` values (union-compat with `OidcConfig`).
601 #[serde(default, skip_serializing_if = "Vec::is_empty")]
602 pub additional_audiences: Vec<String>,
603 /// Allowed JWT signing algorithms (union-compat; the server defaults to RS256).
604 #[serde(default, skip_serializing_if = "Vec::is_empty")]
605 pub allowed_algorithms: Vec<String>,
606 /// JWKS cache TTL in seconds (union-compat with `OidcConfig`).
607 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub jwks_cache_ttl_secs: Option<u64>,
609 /// Clock-skew tolerance in seconds (union-compat with `OidcConfig`).
610 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub clock_skew_secs: Option<u64>,
612 /// Require authentication for all requests (union-compat with `OidcConfig`).
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub required: Option<bool>,
615 /// JWT scope-claim name (union-compat with `OidcConfig`).
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub scope_claim: Option<String>,
618 /// Require the `jti` claim on every token (union-compat with `OidcConfig`).
619 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub require_jti: Option<bool>,
621 /// `[auth.me]` session-identity endpoint config (union-compat with `OidcConfig`).
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub me: Option<MeEndpointConfig>,
624
625 /// PKCE: OIDC provider discovery URL. **Not yet functional (#621).**
626 #[serde(skip_serializing_if = "Option::is_none")]
627 pub discovery_url: Option<String>,
628 /// PKCE: OAuth2 `client_id` registered with the provider. **Not yet functional (#621).**
629 #[serde(skip_serializing_if = "Option::is_none")]
630 pub client_id: Option<String>,
631 /// PKCE: name of the environment variable that holds the client secret.
632 /// The secret itself must never appear in TOML or the compiled schema.
633 /// **Not yet functional (#621).**
634 #[serde(skip_serializing_if = "Option::is_none")]
635 pub client_secret_env: Option<String>,
636 /// PKCE: the full URL of this server's `/auth/callback` endpoint.
637 /// **Not yet functional (#621).**
638 #[serde(skip_serializing_if = "Option::is_none")]
639 pub server_redirect_uri: Option<String>,
640}
641
642impl OidcClientConfig {
643 /// Validate the `[auth]` group structure (#612 item 9).
644 ///
645 /// The JWT group (`issuer` or a pinned `jwks_uri`, plus optional `audience`) is
646 /// accepted and functional. The PKCE client group is all-four-or-none, and a
647 /// *complete* client group is rejected — it is not yet functional on the compiled
648 /// path (#621), so it is refused rather than silently accepted. At least one group
649 /// must be present.
650 ///
651 /// # Errors
652 ///
653 /// Returns an error naming the specific problem: an incomplete client group (with the
654 /// missing fields), a complete-but-unsupported client group, `audience` without a JWT
655 /// group (neither `issuer` nor `jwks_uri`), or an empty `[auth]` block.
656 pub fn validate(&self) -> anyhow::Result<()> {
657 let client_fields = [
658 ("discovery_url", self.discovery_url.is_some()),
659 ("client_id", self.client_id.is_some()),
660 ("client_secret_env", self.client_secret_env.is_some()),
661 ("server_redirect_uri", self.server_redirect_uri.is_some()),
662 ];
663 let client_set = client_fields.iter().filter(|(_, set)| *set).count();
664 let has_client_group = client_set > 0;
665 // A JWT-validation group exists if either `issuer` (used for discovery
666 // and `iss` validation) or a pinned `jwks_uri` (issuer-less mode, for
667 // IdPs that omit `iss`) is set. Mirrors the server's `OidcConfig`.
668 let has_jwt_group = self.issuer.is_some() || self.jwks_uri.is_some();
669
670 if has_client_group && client_set < client_fields.len() {
671 let missing: Vec<&str> =
672 client_fields.iter().filter(|(_, set)| !*set).map(|(name, _)| *name).collect();
673 anyhow::bail!(
674 "[auth] PKCE OAuth-client config is incomplete: discovery_url, client_id, \
675 client_secret_env, and server_redirect_uri must be set together (missing: {}).",
676 missing.join(", ")
677 );
678 }
679
680 if has_client_group {
681 // client_set == 4 here (all-or-none enforced above).
682 anyhow::bail!(
683 "[auth] PKCE OAuth-client config (discovery_url, client_id, client_secret_env, \
684 server_redirect_uri) is recognized but not yet functional on the compiled path: \
685 the compiled schema carries no auth/auth_endpoints for the server to consume. \
686 Remove these fields and use [auth] issuer/audience for JWT validation, or track \
687 the wiring in #621."
688 );
689 }
690
691 if self.audience.is_some() && !has_jwt_group {
692 anyhow::bail!(
693 "[auth] audience is set but neither issuer nor jwks_uri is configured. JWT \
694 validation needs an issuer (used for discovery and `iss` validation) or a pinned \
695 jwks_uri (for IdPs whose tokens omit `iss`, e.g. Hanko) — add one, or remove \
696 audience."
697 );
698 }
699
700 if !has_jwt_group {
701 anyhow::bail!(
702 "[auth] is empty. Configure JWT validation with issuer, or with jwks_uri for IdPs \
703 whose tokens omit `iss` (e.g. Hanko). An empty [auth] block does nothing."
704 );
705 }
706
707 Ok(())
708 }
709}
710
711fn default_true() -> bool {
712 true
713}