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