Skip to main content

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}