Skip to main content

issuerd_server/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (C) 2026 Dmitry Andreev. <da@issuerd.org>
3//
4// Server configuration types with figment-based layered loading (TOML/YAML/JSON + env).
5
6use figment::{
7    providers::{Env, Format, Json, Serialized, Toml, Yaml},
8    Figment,
9};
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13use issuerd_core::IssuerdError;
14
15/// Server configuration with figment-based loading.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ServerConfig {
18    pub bind: String,
19    pub port: u16,
20    pub issuer_url: String,
21    pub tls: Option<TlsConfig>,
22    pub storage: StorageConfig,
23    pub redis: Option<String>,
24    pub logging: LoggingConfig,
25    pub web_ui: WebUiConfig,
26    pub proxy: ProxyConfig,
27    pub cors: CorsConfig,
28    /// Multi-node clustering settings.
29    pub cluster: ClusterConfig,
30    /// SMTP/email settings (`[smtp]` section). Disabled by default.
31    pub smtp: SmtpConfig,
32    /// Read-model cache settings (`[cache]` section).
33    #[serde(default)]
34    pub cache: CacheConfig,
35    /// OAuth/OIDC protocol tuning (`[oauth]` section).
36    #[serde(default)]
37    pub oauth: OAuthConfig,
38    /// DPoP (RFC 9449) settings (`[dpop]` section).
39    #[serde(default)]
40    pub dpop: DpopConfig,
41    /// Crypto settings (`[crypto]` section).
42    #[serde(default)]
43    pub crypto: CryptoSettings,
44    /// Login-theme asset directory (`[themes]` section).
45    pub themes: ThemesConfig,
46    /// Optional path to a provision config file (YAML/TOML/JSON).
47    /// Applied exactly once at first startup; ignored on restarts.
48    pub provision: Option<PathBuf>,
49}
50
51impl Default for ServerConfig {
52    fn default() -> Self {
53        Self {
54            bind: "0.0.0.0".to_string(),
55            port: 8080,
56            issuer_url: "http://localhost:8080".to_string(),
57            tls: None,
58            storage: StorageConfig::InMemory,
59            redis: None,
60            logging: LoggingConfig::default(),
61            web_ui: WebUiConfig::default(),
62            proxy: ProxyConfig::default(),
63            cors: CorsConfig::default(),
64            cluster: ClusterConfig::default(),
65            smtp: SmtpConfig::default(),
66            cache: CacheConfig::default(),
67            oauth: OAuthConfig::default(),
68            dpop: DpopConfig::default(),
69            crypto: CryptoSettings::default(),
70            themes: ThemesConfig::default(),
71            provision: None,
72        }
73    }
74}
75
76impl ServerConfig {
77    /// Load configuration from file (TOML/YAML/JSON) and environment overrides.
78    ///
79    /// Environment variables prefixed with `ISSUERD_` are merged on top of
80    /// the file values.  If `path` is `None`, only defaults + environment are used.
81    pub fn load(path: Option<PathBuf>) -> Result<Self, IssuerdError> {
82        let mut figment = Figment::new().merge(Serialized::defaults(Self::default()));
83
84        if let Some(p) = path {
85            if p.exists() {
86                let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("toml").to_lowercase();
87                match ext.as_str() {
88                    "yaml" | "yml" => {
89                        figment = figment.merge(Yaml::file(p));
90                    }
91                    "json" => {
92                        figment = figment.merge(Json::file(p));
93                    }
94                    _ => {
95                        figment = figment.merge(Toml::file(p));
96                    }
97                }
98            }
99        }
100
101        figment = figment.merge(
102            Env::prefixed("ISSUERD_")
103                .split("__")
104                .map(|k| k.as_str().to_ascii_lowercase().into()),
105        );
106
107        figment
108            .extract::<Self>()
109            .map_err(|e| IssuerdError::ServerError(format!("config load failed: {e}")))
110    }
111
112    /// Whether server-set browser cookies carry the `Secure` attribute.
113    ///
114    /// Derived from the configured `issuer_url` — never from the request:
115    /// `true` exactly when the public scheme is `https`. Every TLS deployment
116    /// (direct, or behind a TLS-terminating proxy where `issuer_url` keeps
117    /// the public `https` scheme) gets `Secure` on all authentication
118    /// cookies — SSO session, remember-me, flow correlation, and the logout
119    /// clears — while plain-HTTP development rigs keep working.
120    pub fn secure_cookies(&self) -> bool {
121        url::Url::parse(&self.issuer_url).is_ok_and(|u| u.scheme() == "https")
122    }
123
124    /// Generate a fully-populated example config for documentation / CLI usage.
125    pub fn generate_example() -> Self {
126        Self {
127            bind: "0.0.0.0".to_string(),
128            port: 8080,
129            issuer_url: "http://localhost:8080".to_string(),
130            tls: Some(TlsConfig {
131                cert_path: "certs/issuerd.test.internal.crt".to_string(),
132                key_path: "certs/issuerd.test.internal.key".to_string(),
133            }),
134            storage: StorageConfig::Postgres {
135                url: "postgres://issuerd:issuerd_secret@localhost:5433/issuerd".to_string(),
136            },
137            redis: Some("redis://localhost:6379".to_string()),
138            logging: LoggingConfig {
139                format: "pretty".to_string(),
140                level: "info".to_string(),
141            },
142            web_ui: WebUiConfig { enabled: true },
143            proxy: ProxyConfig {
144                trusted_proxies: vec![],
145                trust_x_forwarded_for: true,
146                trust_x_real_ip: true,
147            },
148            cors: CorsConfig::default(),
149            themes: ThemesConfig::default(),
150            cluster: ClusterConfig::default(),
151            smtp: SmtpConfig {
152                enabled: false,
153                host: "127.0.0.1".to_string(),
154                port: 1025,
155                from: "issuerd@test.internal".to_string(),
156                from_display: Some("Issuerd".to_string()),
157                reply_to: None,
158                starttls: false,
159                ssl: false,
160                username: None,
161                password: None,
162            },
163            cache: CacheConfig::default(),
164            oauth: OAuthConfig::default(),
165            dpop: DpopConfig::default(),
166            crypto: CryptoSettings::default(),
167            provision: Some(PathBuf::from("provision.yaml")),
168        }
169    }
170}
171
172/// Serialize `value` to `path` inferring format from the file extension.
173///
174/// Supported extensions: `.toml` (default), `.yaml`/`.yml`, `.json`.
175pub fn write_example<T: serde::Serialize>(
176    value: &T,
177    path: &std::path::Path,
178) -> Result<(), IssuerdError> {
179    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("toml").to_lowercase();
180    let content = match ext.as_str() {
181        "yaml" | "yml" => serde_yaml::to_string(value)
182            .map_err(|e| IssuerdError::ServerError(format!("yaml serialization failed: {e}")))?,
183        "json" => serde_json::to_string_pretty(value)
184            .map_err(|e| IssuerdError::ServerError(format!("json serialization failed: {e}")))?,
185        _ => toml::to_string_pretty(value)
186            .map_err(|e| IssuerdError::ServerError(format!("toml serialization failed: {e}")))?,
187    };
188    std::fs::write(path, content)
189        .map_err(|e| IssuerdError::ServerError(format!("write failed: {e}")))?;
190    Ok(())
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum StorageConfig {
196    InMemory,
197    Postgres { url: String },
198    JsonFile { path: PathBuf },
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct TlsConfig {
203    pub cert_path: String,
204    pub key_path: String,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct LoggingConfig {
209    pub format: String,
210    pub level: String,
211}
212
213impl Default for LoggingConfig {
214    fn default() -> Self {
215        Self {
216            format: "pretty".to_string(),
217            level: "info".to_string(),
218        }
219    }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct WebUiConfig {
224    pub enabled: bool,
225}
226
227impl Default for WebUiConfig {
228    fn default() -> Self {
229        Self { enabled: true }
230    }
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ProxyConfig {
235    pub trusted_proxies: Vec<String>,
236    pub trust_x_forwarded_for: bool,
237    pub trust_x_real_ip: bool,
238}
239
240impl Default for ProxyConfig {
241    fn default() -> Self {
242        Self {
243            trusted_proxies: vec![],
244            trust_x_forwarded_for: true,
245            trust_x_real_ip: true,
246        }
247    }
248}
249
250/// CORS configuration.
251///
252/// Empty by default: no cross-origin browser requests are allowed. The embedded
253/// admin SPA is served same-origin and the dev server proxies API calls, so
254/// neither needs CORS. Add origins (scheme + host + port) only for browser
255/// clients hosted on other origins, e.g. `allowed_origins = ["https://app.example.com"]`.
256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257pub struct CorsConfig {
258    pub allowed_origins: Vec<String>,
259}
260
261/// Multi-node clustering configuration.
262///
263/// Clustering is opt-in. When `enabled` is true the server enforces the
264/// multi-node contract at boot: PostgreSQL storage and a Redis cache are
265/// required (boot fails otherwise), signing keys are loaded from shared
266/// storage, and a background task refreshes the JWKS snapshot from storage so
267/// keys added or rotated by peer nodes propagate.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ClusterConfig {
270    /// Enforce the multi-node deployment contract (Postgres + Redis required).
271    pub enabled: bool,
272    /// Stable identity of this node (used in logs). Defaults to the hostname.
273    pub node_id: Option<String>,
274    /// Redis Cluster node URLs (e.g. `["redis://r1:6379", "redis://r2:6379"]`).
275    /// When non-empty this overrides the single-node `redis` URL.
276    pub redis_nodes: Vec<String>,
277    /// How often (seconds) the node re-reads the shared signing-key set from
278    /// storage and refreshes its JWKS snapshot.
279    pub jwks_refresh_interval_secs: u64,
280}
281
282impl Default for ClusterConfig {
283    fn default() -> Self {
284        Self {
285            enabled: false,
286            node_id: None,
287            redis_nodes: Vec::new(),
288            jwks_refresh_interval_secs: 30,
289        }
290    }
291}
292
293impl ClusterConfig {
294    /// Resolve the node identity: configured value, else hostname, else a
295    /// random id. Used for log correlation only.
296    pub fn resolved_node_id(&self) -> String {
297        if let Some(id) = &self.node_id {
298            return id.clone();
299        }
300        if let Ok(host) = std::env::var("HOSTNAME") {
301            if !host.is_empty() {
302                return host;
303            }
304        }
305        issuerd_core::utils::generate_id()
306    }
307}
308
309/// Read-model cache configuration (`[cache]` section).
310///
311/// One knob governs every read-model cache on the token hot paths: session
312/// validity snapshots (`userinfo`/`introspect` session checks), the
313/// realm-by-name resolution cache, and the claims read-model (per-user
314/// claims bundles, the realm role/scope catalog, client bundles). Precise
315/// invalidation (delete on single-entity mutation) and epoch bumps
316/// (realm-wide definition changes) make committed writes visible
317/// immediately; a write that bypasses both stays hidden for at most
318/// `read_cache_ttl_secs` — the consciously accepted bounded-staleness
319/// window, same class as Keycloak's Infinispan propagation.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct CacheConfig {
322    /// TTL in seconds of every read-model cache entry. `0` disables all
323    /// read-model caches (every lookup hits storage — pre-cache behavior).
324    pub read_cache_ttl_secs: u64,
325}
326
327impl Default for CacheConfig {
328    fn default() -> Self {
329        Self {
330            read_cache_ttl_secs: 60,
331        }
332    }
333}
334
335/// Default authorization-code lifetime: 10 minutes — the common interoperable
336/// value (Keycloak uses the same).
337pub const AUTH_CODE_TTL_DEFAULT_SECS: u64 = 600;
338/// Shortest accepted authorization-code lifetime.
339pub const AUTH_CODE_TTL_MIN_SECS: u64 = 10;
340/// Longest accepted authorization-code lifetime. Codes are bearer grants
341/// delivered through the browser — keep the redemption window short.
342pub const AUTH_CODE_TTL_MAX_SECS: u64 = 600;
343
344/// OAuth/OIDC protocol tuning (`[oauth]` section).
345///
346/// The whole section is optional and every key has a default, so existing
347/// configuration files keep booting unchanged.
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct OAuthConfig {
350    /// Lifetime of an authorization code in seconds: how long the client has
351    /// to redeem it at the token endpoint
352    /// (`[oauth] auth_code_ttl_secs`, env
353    /// `ISSUERD_OAUTH__AUTH_CODE_TTL_SECS`). Default 600, accepted range
354    /// 10–600; out-of-range values abort the boot. A FAPI 2.0 high-assurance
355    /// profile wants ≤ 60 s — set 60 here or per realm (see
356    /// [`OAuthConfig::REALM_AUTH_CODE_TTL_ATTRIBUTE`]) when building such a
357    /// profile. (Full FAPI 2.0 message signing is out of scope; this is only
358    /// the TTL knob a profile needs.)
359    pub auth_code_ttl_secs: u64,
360}
361
362impl Default for OAuthConfig {
363    fn default() -> Self {
364        Self {
365            auth_code_ttl_secs: AUTH_CODE_TTL_DEFAULT_SECS,
366        }
367    }
368}
369
370impl OAuthConfig {
371    /// Realm attribute that overrides [`OAuthConfig::auth_code_ttl_secs`]
372    /// for one realm (set via the realm representation's `attributes` map or
373    /// the provision YAML `attributes` block). Must parse as an integer
374    /// within the same 10–600 bounds; an absent, malformed, or out-of-range
375    /// value falls back to the server-wide setting.
376    pub const REALM_AUTH_CODE_TTL_ATTRIBUTE: &'static str = "auth_code_ttl_secs";
377
378    /// Boot-time validation: reject out-of-range values with a clear error
379    /// instead of silently clamping them.
380    pub fn validate(&self) -> Result<(), IssuerdError> {
381        if !(AUTH_CODE_TTL_MIN_SECS..=AUTH_CODE_TTL_MAX_SECS).contains(&self.auth_code_ttl_secs) {
382            return Err(IssuerdError::InvalidRequest(format!(
383                "oauth.auth_code_ttl_secs must be within \
384                 {AUTH_CODE_TTL_MIN_SECS}..={AUTH_CODE_TTL_MAX_SECS} seconds, got {}",
385                self.auth_code_ttl_secs
386            )));
387        }
388        Ok(())
389    }
390
391    /// Effective authorization-code TTL for a realm: the realm attribute when
392    /// present and in bounds, else the server-wide value.
393    pub fn auth_code_ttl(&self, realm: &issuerd_core::Realm) -> std::time::Duration {
394        let secs = realm
395            .attributes
396            .get(Self::REALM_AUTH_CODE_TTL_ATTRIBUTE)
397            .and_then(|v| v.parse::<u64>().ok())
398            .filter(|v| (AUTH_CODE_TTL_MIN_SECS..=AUTH_CODE_TTL_MAX_SECS).contains(v))
399            .unwrap_or(self.auth_code_ttl_secs);
400        std::time::Duration::from_secs(secs)
401    }
402}
403
404/// Default server-nonce lifetime: 30 seconds (the value Keycloak documents
405/// for its own DPoP nonce).
406pub const DPOP_NONCE_LIFETIME_DEFAULT_SECS: u64 = 30;
407/// Shortest accepted server-nonce lifetime.
408pub const DPOP_NONCE_LIFETIME_MIN_SECS: u64 = 5;
409/// Longest accepted server-nonce lifetime. The nonce exists to bound proof
410/// freshness below the proof acceptance window (300 s) — keep it there.
411pub const DPOP_NONCE_LIFETIME_MAX_SECS: u64 = 300;
412
413/// DPoP (RFC 9449) settings (`[dpop]` section).
414///
415/// The whole section is optional and every key has a default, so existing
416/// configuration files keep booting unchanged.
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct DpopConfig {
419    /// Server-provided nonces (`[dpop.nonce]`, RFC 9449 §8/§9). Disabled by
420    /// default — replay protection then rides on single-use `jti` plus the
421    /// proof acceptance window, as before.
422    #[serde(default)]
423    pub nonce: DpopNonceConfig,
424}
425
426impl DpopConfig {
427    /// Boot-time validation: reject out-of-range values with a clear error
428    /// instead of silently clamping them.
429    pub fn validate(&self) -> Result<(), IssuerdError> {
430        let lifetime = self.nonce.lifetime_secs;
431        if !(DPOP_NONCE_LIFETIME_MIN_SECS..=DPOP_NONCE_LIFETIME_MAX_SECS).contains(&lifetime) {
432            return Err(IssuerdError::InvalidRequest(format!(
433                "dpop.nonce.lifetime_secs must be within \
434                 {DPOP_NONCE_LIFETIME_MIN_SECS}..={DPOP_NONCE_LIFETIME_MAX_SECS} seconds, \
435                 got {lifetime}"
436            )));
437        }
438        Ok(())
439    }
440}
441
442/// How the server treats the DPoP `nonce` claim (`[dpop.nonce] mode`).
443///
444/// Nonces are unguessable random values the server issues via the
445/// `DPoP-Nonce` response header and the client echoes in the proof's `nonce`
446/// claim; each nonce is single-use and expires after `lifetime_secs`.
447#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case")]
449pub enum DpopNonceMode {
450    /// No nonces issued or verified (default; pre-feature behavior).
451    #[default]
452    Disabled,
453    /// A fresh nonce rides every response to a proof-carrying request. A
454    /// proof without a `nonce` claim is accepted; a proof carrying an
455    /// unknown/stale nonce is challenged with `use_dpop_nonce` (the
456    /// RFC 9449 §8/§9 retry path — compliant clients recover transparently).
457    Supported,
458    /// Every proof MUST carry a live server-issued nonce; absence or an
459    /// unknown/stale value is rejected with `use_dpop_nonce` plus a fresh
460    /// nonce. Requests without a `DPoP` proof header are unaffected.
461    Required,
462}
463
464/// Server-provided DPoP nonces (`[dpop.nonce]`).
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct DpopNonceConfig {
467    /// Issuance/verification mode (`"disabled" | "supported" | "required"`).
468    pub mode: DpopNonceMode,
469    /// Nonce lifetime in seconds — the cache TTL. Default 30, accepted range
470    /// 5–300; out-of-range values abort the boot.
471    pub lifetime_secs: u64,
472}
473
474impl Default for DpopNonceConfig {
475    fn default() -> Self {
476        Self {
477            mode: DpopNonceMode::Disabled,
478            lifetime_secs: DPOP_NONCE_LIFETIME_DEFAULT_SECS,
479        }
480    }
481}
482
483/// Crypto settings (`[crypto]` section).
484///
485/// Currently only envelope encryption of signing keys at rest. The whole
486/// section is optional and defaults to "not configured".
487#[derive(Debug, Clone, Default, Serialize, Deserialize)]
488pub struct CryptoSettings {
489    /// Envelope encryption of signing keys at rest (`[crypto.key_encryption]`,
490    /// PostgreSQL storage only). Absent keeps the pre-encryption behavior —
491    /// signing keys are stored in plaintext — and the daemon logs a startup
492    /// WARN on PostgreSQL deployments.
493    pub key_encryption: Option<KeyEncryptionConfig>,
494}
495
496impl CryptoSettings {
497    /// Validate `[crypto.key_encryption]` (when present) and build the KEK
498    /// provider. `Ok(None)` means the section is absent (plaintext mode).
499    /// Invalid configuration — malformed base64, a key that is not exactly
500    /// 32 bytes, empty/overlong/duplicate key ids — is a boot-fatal error.
501    pub fn build_kek_provider(
502        &self,
503    ) -> Result<Option<std::sync::Arc<dyn issuerd_core::KeyEncryptionKeyProvider>>, IssuerdError>
504    {
505        self.key_encryption
506            .as_ref()
507            .map(KeyEncryptionConfig::build_provider)
508            .transpose()
509    }
510}
511
512/// Envelope encryption of signing keys at rest (`[crypto.key_encryption]`).
513///
514/// The Key Encryption Key (KEK) encrypts the cluster-wide JWT signing keys
515/// before they are written to PostgreSQL, so a database dump yields only
516/// ciphertext. The KEK itself must never be stored in the database; source it
517/// from the environment (`ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_BASE64`) or a
518/// permission-protected config file. Generate one with `openssl rand -base64 32`.
519///
520/// PostgreSQL-only: with any other storage backend the section is ignored
521/// (boot WARN) — protect JSON snapshots at the filesystem level instead.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct KeyEncryptionConfig {
524    /// Identifier of the active KEK, stored on every encrypted row so a later
525    /// rotation knows which KEK must decrypt it. Non-empty, at most 64 chars,
526    /// unique across `previous_keys`.
527    pub key_id: String,
528    /// Base64-encoded 32-byte KEK (AES-256). Prefer the
529    /// `ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_BASE64` env var over the file.
530    pub key_base64: String,
531    /// Previous KEKs, accepted for decryption only: the KEK-rotation window.
532    /// File-only setting (env vars cannot index into arrays).
533    #[serde(default)]
534    pub previous_keys: Vec<PreviousKekConfig>,
535}
536
537impl KeyEncryptionConfig {
538    fn build_provider(
539        &self,
540    ) -> Result<std::sync::Arc<dyn issuerd_core::KeyEncryptionKeyProvider>, IssuerdError> {
541        let active = decode_kek(&self.key_base64)?;
542        let mut previous = Vec::with_capacity(self.previous_keys.len());
543        for prev in &self.previous_keys {
544            previous.push((prev.key_id.clone(), decode_kek(&prev.key_base64)?));
545        }
546        let provider =
547            issuerd_storage::Aes256GcmKekProvider::new(self.key_id.clone(), active, previous)?;
548        Ok(std::sync::Arc::new(provider))
549    }
550}
551
552/// A retired KEK kept for decryption during a KEK-rotation window.
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct PreviousKekConfig {
555    /// The `key_id` the KEK had while it was active (matches the `kek_kid`
556    /// stored on rows it encrypted).
557    pub key_id: String,
558    /// Base64-encoded 32-byte KEK.
559    pub key_base64: String,
560}
561
562/// Decode a configured KEK: standard base64 of exactly 32 bytes (AES-256).
563fn decode_kek(key_base64: &str) -> Result<[u8; 32], IssuerdError> {
564    use base64::Engine;
565    let bytes =
566        base64::engine::general_purpose::STANDARD
567            .decode(key_base64.trim())
568            .map_err(|e| {
569                IssuerdError::KeyEncryption(format!("KEK key_base64 is not valid base64: {e}"))
570            })?;
571    <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| {
572        IssuerdError::KeyEncryption(format!(
573            "KEK must decode to exactly 32 bytes (AES-256), got {}",
574            bytes.len()
575        ))
576    })
577}
578
579/// Login-theme asset configuration (`[themes]` section).
580///
581/// Themes are directories of static assets under `dir`: the realm's
582/// `login_theme` selects the directory served at `/realms/{realm}/theme/...`,
583/// with a per-file fallback to the built-in `issuerd` theme. A theme may
584/// also carry `messages_{locale}.json` message-bundle overrides that merge
585/// over the built-in bundles (see `crate::i18n`).
586#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct ThemesConfig {
588    /// Root directory containing one subdirectory per theme. Relative paths
589    /// resolve against the daemon's working directory.
590    pub dir: PathBuf,
591}
592
593impl Default for ThemesConfig {
594    fn default() -> Self {
595        Self {
596            dir: PathBuf::from("themes"),
597        }
598    }
599}
600
601/// SMTP/email configuration (`[smtp]` section).
602///
603/// Email sending is **disabled by default**: the server wires a no-op sender
604/// until `enabled = true`, and verification emails then fail loudly instead
605/// of being silently dropped.
606///
607/// Realm-level overrides follow the Keycloak model: realm attributes under
608/// `smtpServer.*` (e.g. `smtpServer.host`, `smtpServer.from`,
609/// `smtpServer.fromDisplayName`, `smtpServer.user`, `smtpServer.password`,
610/// `smtpServer.starttls`, `smtpServer.ssl`, `smtpServer.port`,
611/// `smtpServer.replyTo`) override the global values for that realm only;
612/// `enabled` remains a global gate.
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct SmtpConfig {
615    pub enabled: bool,
616    pub host: String,
617    pub port: u16,
618    pub from: String,
619    pub from_display: Option<String>,
620    pub reply_to: Option<String>,
621    pub starttls: bool,
622    pub ssl: bool,
623    pub username: Option<String>,
624    pub password: Option<String>,
625}
626
627impl Default for SmtpConfig {
628    fn default() -> Self {
629        Self {
630            enabled: false,
631            host: "127.0.0.1".to_string(),
632            port: 25,
633            from: "issuerd@localhost".to_string(),
634            from_display: None,
635            reply_to: None,
636            starttls: false,
637            ssl: false,
638            username: None,
639            password: None,
640        }
641    }
642}
643
644impl SmtpConfig {
645    /// Resolve the effective SMTP settings for a realm by merging
646    /// `smtpServer.*` realm attributes over this global configuration.
647    pub fn for_realm(&self, realm: &issuerd_core::Realm) -> Self {
648        let attr = |key: &str| realm.attributes.get(&format!("smtpServer.{key}"));
649        let flag = |key: &str, default: bool| attr(key).map(|v| v == "true").unwrap_or(default);
650        Self {
651            enabled: self.enabled,
652            host: attr("host").cloned().unwrap_or_else(|| self.host.clone()),
653            port: attr("port").and_then(|v| v.parse().ok()).unwrap_or(self.port),
654            from: attr("from").cloned().unwrap_or_else(|| self.from.clone()),
655            from_display: attr("fromDisplayName").cloned().or_else(|| self.from_display.clone()),
656            reply_to: attr("replyTo").cloned().or_else(|| self.reply_to.clone()),
657            starttls: flag("starttls", self.starttls),
658            ssl: flag("ssl", self.ssl),
659            username: attr("user").cloned().or_else(|| self.username.clone()),
660            password: attr("password").cloned().or_else(|| self.password.clone()),
661        }
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use std::fs;
669
670    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
671
672    fn temp_path(name: &str, ext: &str) -> (std::path::PathBuf, std::path::PathBuf) {
673        let dir = std::env::temp_dir().join(format!("issuerd_server_config_test_{}", name));
674        let _ = fs::create_dir_all(&dir);
675        let path = dir.join(format!("config.{}", ext));
676        (path, dir)
677    }
678
679    #[test]
680    fn default_config_values() {
681        let cfg = ServerConfig::default();
682        assert_eq!(cfg.bind, "0.0.0.0");
683        assert_eq!(cfg.port, 8080);
684        assert_eq!(cfg.issuer_url, "http://localhost:8080");
685        assert!(cfg.tls.is_none());
686        assert!(matches!(cfg.storage, StorageConfig::InMemory));
687        assert!(cfg.redis.is_none());
688        assert_eq!(cfg.logging.format, "pretty");
689        assert_eq!(cfg.logging.level, "info");
690        assert!(cfg.web_ui.enabled);
691        assert!(cfg.proxy.trusted_proxies.is_empty());
692        assert!(cfg.proxy.trust_x_forwarded_for);
693        assert!(cfg.proxy.trust_x_real_ip);
694        assert!(cfg.cors.allowed_origins.is_empty());
695        assert!(!cfg.cluster.enabled);
696        assert!(cfg.cluster.node_id.is_none());
697        assert!(cfg.cluster.redis_nodes.is_empty());
698        assert_eq!(cfg.cluster.jwks_refresh_interval_secs, 30);
699        assert!(!cfg.smtp.enabled);
700        assert_eq!(cfg.smtp.host, "127.0.0.1");
701        assert_eq!(cfg.smtp.port, 25);
702        assert_eq!(cfg.smtp.from, "issuerd@localhost");
703        assert!(!cfg.smtp.starttls);
704        assert!(!cfg.smtp.ssl);
705        assert_eq!(cfg.cache.read_cache_ttl_secs, 60);
706        assert_eq!(cfg.oauth.auth_code_ttl_secs, 600);
707    }
708
709    #[test]
710    fn smtp_for_realm_merges_overrides() {
711        let base = SmtpConfig {
712            enabled: true,
713            username: Some("global-user".to_string()),
714            ..SmtpConfig::default()
715        };
716        let mut realm = issuerd_core::Realm::default();
717        realm
718            .attributes
719            .insert("smtpServer.host".to_string(), "mail.realm.example".to_string());
720        realm.attributes.insert("smtpServer.port".to_string(), "2525".to_string());
721        realm
722            .attributes
723            .insert("smtpServer.from".to_string(), "realm@example.com".to_string());
724        realm.attributes.insert("smtpServer.starttls".to_string(), "true".to_string());
725
726        let merged = base.for_realm(&realm);
727        assert_eq!(merged.host, "mail.realm.example");
728        assert_eq!(merged.port, 2525);
729        assert_eq!(merged.from, "realm@example.com");
730        assert!(merged.starttls);
731        // Untouched keys fall back to the global config.
732        assert_eq!(merged.username.as_deref(), Some("global-user"));
733        assert!(merged.enabled);
734
735        // No attributes -> identical to global.
736        let plain = issuerd_core::Realm::default();
737        let merged = base.for_realm(&plain);
738        assert_eq!(merged.host, base.host);
739        assert_eq!(merged.port, base.port);
740    }
741
742    #[test]
743    fn load_cluster_section_from_toml() {
744        let _guard = ENV_LOCK.lock().unwrap();
745        let (path, dir) = temp_path("cluster_toml", "toml");
746        fs::write(
747            &path,
748            r#"
749[cluster]
750enabled = true
751node_id = "node-1"
752redis_nodes = ["redis://r1:6379", "redis://r2:6379"]
753jwks_refresh_interval_secs = 15
754"#,
755        )
756        .unwrap();
757        let cfg = ServerConfig::load(Some(path)).unwrap();
758        assert!(cfg.cluster.enabled);
759        assert_eq!(cfg.cluster.node_id.as_deref(), Some("node-1"));
760        assert_eq!(cfg.cluster.redis_nodes.len(), 2);
761        assert_eq!(cfg.cluster.jwks_refresh_interval_secs, 15);
762        let _ = fs::remove_dir_all(&dir);
763    }
764
765    #[test]
766    fn load_cluster_env_override() {
767        let _guard = ENV_LOCK.lock().unwrap();
768        std::env::set_var("ISSUERD_CLUSTER__ENABLED", "true");
769        std::env::set_var("ISSUERD_CLUSTER__JWKS_REFRESH_INTERVAL_SECS", "7");
770        let cfg = ServerConfig::load(None).unwrap();
771        assert!(cfg.cluster.enabled);
772        assert_eq!(cfg.cluster.jwks_refresh_interval_secs, 7);
773        std::env::remove_var("ISSUERD_CLUSTER__ENABLED");
774        std::env::remove_var("ISSUERD_CLUSTER__JWKS_REFRESH_INTERVAL_SECS");
775    }
776
777    #[test]
778    fn cluster_node_id_resolution() {
779        let cluster = ClusterConfig {
780            node_id: Some("explicit".to_string()),
781            ..Default::default()
782        };
783        assert_eq!(cluster.resolved_node_id(), "explicit");
784        let fallback = ClusterConfig::default().resolved_node_id();
785        assert!(!fallback.is_empty());
786    }
787
788    #[test]
789    fn load_from_toml_file() {
790        let _guard = ENV_LOCK.lock().unwrap();
791        let (path, dir) = temp_path("toml_ok", "toml");
792        fs::write(
793            &path,
794            r#"
795bind = "127.0.0.1"
796port = 9090
797issuer_url = "http://example.com"
798"#,
799        )
800        .unwrap();
801        let cfg = ServerConfig::load(Some(path)).unwrap();
802        assert_eq!(cfg.bind, "127.0.0.1");
803        assert_eq!(cfg.port, 9090);
804        assert_eq!(cfg.issuer_url, "http://example.com");
805        // Sections absent from an existing config file keep their defaults
806        // (backward compatibility).
807        assert_eq!(cfg.cache.read_cache_ttl_secs, 60);
808        let _ = fs::remove_dir_all(&dir);
809    }
810
811    #[test]
812    fn load_from_yaml_file() {
813        let _guard = ENV_LOCK.lock().unwrap();
814        let (path, dir) = temp_path("yaml_ok", "yaml");
815        fs::write(&path, "bind: 127.0.0.1\nport: 9091\n").unwrap();
816        let cfg = ServerConfig::load(Some(path)).unwrap();
817        assert_eq!(cfg.bind, "127.0.0.1");
818        assert_eq!(cfg.port, 9091);
819        let _ = fs::remove_dir_all(&dir);
820    }
821
822    #[test]
823    fn load_from_json_file() {
824        let _guard = ENV_LOCK.lock().unwrap();
825        let (path, dir) = temp_path("json_ok", "json");
826        fs::write(&path, r#"{"bind":"127.0.0.1","port":9092}"#).unwrap();
827        let cfg = ServerConfig::load(Some(path)).unwrap();
828        assert_eq!(cfg.bind, "127.0.0.1");
829        assert_eq!(cfg.port, 9092);
830        let _ = fs::remove_dir_all(&dir);
831    }
832
833    #[test]
834    fn load_from_yml_file() {
835        let _guard = ENV_LOCK.lock().unwrap();
836        let (path, dir) = temp_path("yml_ok", "yml");
837        fs::write(&path, "bind: 127.0.0.1\nport: 9093\n").unwrap();
838        let cfg = ServerConfig::load(Some(path)).unwrap();
839        assert_eq!(cfg.bind, "127.0.0.1");
840        assert_eq!(cfg.port, 9093);
841        let _ = fs::remove_dir_all(&dir);
842    }
843
844    #[test]
845    fn load_nonexistent_path_uses_defaults() {
846        let _guard = ENV_LOCK.lock().unwrap();
847        // Defensively remove any leaked env var so other tests don't pollute us.
848        std::env::remove_var("ISSUERD_BIND");
849        std::env::remove_var("ISSUERD_PORT");
850        std::env::remove_var("ISSUERD_ISSUER_URL");
851        let cfg =
852            ServerConfig::load(Some(std::path::PathBuf::from("/nonexistent/path.toml"))).unwrap();
853        assert_eq!(cfg.bind, "0.0.0.0");
854        assert_eq!(cfg.port, 8080);
855    }
856
857    #[test]
858    fn load_invalid_config_returns_error() {
859        let _guard = ENV_LOCK.lock().unwrap();
860        let (path, dir) = temp_path("toml_bad", "toml");
861        fs::write(&path, "port = \"not_a_number\"").unwrap();
862        let result = ServerConfig::load(Some(path));
863        assert!(result.is_err());
864        let _ = fs::remove_dir_all(&dir);
865    }
866
867    #[test]
868    fn load_env_override() {
869        let _guard = ENV_LOCK.lock().unwrap();
870        // Set an environment variable that should override the default issuer_url.
871        // Use issuer_url because no other test asserts on it.
872        std::env::set_var("ISSUERD_ISSUER_URL", "http://env-override:9999");
873        let cfg = ServerConfig::load(None).unwrap();
874        assert_eq!(cfg.issuer_url, "http://env-override:9999");
875        std::env::remove_var("ISSUERD_ISSUER_URL");
876    }
877
878    // ------------------------------------------------------------------
879    // [crypto.key_encryption]
880    // ------------------------------------------------------------------
881
882    fn test_kek_base64(byte: u8) -> String {
883        use base64::Engine;
884        base64::engine::general_purpose::STANDARD.encode([byte; 32])
885    }
886
887    #[test]
888    fn key_encryption_absent_by_default() {
889        let cfg = ServerConfig::default();
890        assert!(cfg.crypto.key_encryption.is_none());
891        assert!(cfg.crypto.build_kek_provider().unwrap().is_none());
892        assert!(ServerConfig::generate_example().crypto.key_encryption.is_none());
893    }
894
895    #[test]
896    fn key_encryption_loads_from_toml() {
897        let _guard = ENV_LOCK.lock().unwrap();
898        let (path, dir) = temp_path("crypto_toml", "toml");
899        fs::write(
900            &path,
901            format!(
902                r#"
903[crypto.key_encryption]
904key_id = "kek-2026-01"
905key_base64 = "{}"
906
907[[crypto.key_encryption.previous_keys]]
908key_id = "kek-2025-01"
909key_base64 = "{}"
910"#,
911                test_kek_base64(7),
912                test_kek_base64(9)
913            ),
914        )
915        .unwrap();
916        let cfg = ServerConfig::load(Some(path)).unwrap();
917        let kec = cfg.crypto.key_encryption.as_ref().expect("section parsed");
918        assert_eq!(kec.key_id, "kek-2026-01");
919        assert_eq!(kec.previous_keys.len(), 1);
920        assert_eq!(kec.previous_keys[0].key_id, "kek-2025-01");
921
922        // The section builds a working provider (round-trip through both KEKs).
923        let kek = cfg.crypto.build_kek_provider().unwrap().expect("provider");
924        assert_eq!(kek.active_key_id(), "kek-2026-01");
925        let blob = kek.encrypt(b"der").unwrap();
926        assert_eq!(kek.decrypt("kek-2026-01", &blob).unwrap(), b"der");
927        let _ = fs::remove_dir_all(&dir);
928    }
929
930    #[test]
931    fn key_encryption_env_override() {
932        let _guard = ENV_LOCK.lock().unwrap();
933        std::env::set_var("ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_ID", "env-kek");
934        std::env::set_var("ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_BASE64", test_kek_base64(3));
935        let cfg = ServerConfig::load(None).unwrap();
936        std::env::remove_var("ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_ID");
937        std::env::remove_var("ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_BASE64");
938        let kec = cfg.crypto.key_encryption.as_ref().expect("env section parsed");
939        assert_eq!(kec.key_id, "env-kek");
940        assert!(cfg.crypto.build_kek_provider().unwrap().is_some());
941    }
942
943    #[test]
944    fn key_encryption_invalid_configs_fail_boot_validation() {
945        let valid = test_kek_base64(1);
946        for (label, kec) in [
947            (
948                "bad base64",
949                KeyEncryptionConfig {
950                    key_id: "k".to_string(),
951                    key_base64: "!!! not base64 !!!".to_string(),
952                    previous_keys: vec![],
953                },
954            ),
955            (
956                "31-byte key",
957                KeyEncryptionConfig {
958                    key_id: "k".to_string(),
959                    key_base64: {
960                        use base64::Engine;
961                        base64::engine::general_purpose::STANDARD.encode([1u8; 31])
962                    },
963                    previous_keys: vec![],
964                },
965            ),
966            (
967                "33-byte key",
968                KeyEncryptionConfig {
969                    key_id: "k".to_string(),
970                    key_base64: {
971                        use base64::Engine;
972                        base64::engine::general_purpose::STANDARD.encode([1u8; 33])
973                    },
974                    previous_keys: vec![],
975                },
976            ),
977            (
978                "empty key_id",
979                KeyEncryptionConfig {
980                    key_id: String::new(),
981                    key_base64: valid.clone(),
982                    previous_keys: vec![],
983                },
984            ),
985            (
986                "duplicate key_id",
987                KeyEncryptionConfig {
988                    key_id: "dup".to_string(),
989                    key_base64: valid.clone(),
990                    previous_keys: vec![PreviousKekConfig {
991                        key_id: "dup".to_string(),
992                        key_base64: test_kek_base64(2),
993                    }],
994                },
995            ),
996            (
997                "bad previous key",
998                KeyEncryptionConfig {
999                    key_id: "k".to_string(),
1000                    key_base64: valid.clone(),
1001                    previous_keys: vec![PreviousKekConfig {
1002                        key_id: "old".to_string(),
1003                        key_base64: "c2hvcnQ=".to_string(), // 5 bytes
1004                    }],
1005                },
1006            ),
1007        ] {
1008            let settings = CryptoSettings {
1009                key_encryption: Some(kec),
1010            };
1011            assert!(settings.build_kek_provider().is_err(), "{label} must fail validation");
1012        }
1013    }
1014
1015    // ------------------------------------------------------------------
1016    // [oauth]
1017    // ------------------------------------------------------------------
1018
1019    #[test]
1020    fn oauth_default_auth_code_ttl_unchanged() {
1021        let cfg = ServerConfig::default();
1022        assert_eq!(cfg.oauth.auth_code_ttl_secs, AUTH_CODE_TTL_DEFAULT_SECS);
1023        assert_eq!(cfg.oauth.auth_code_ttl_secs, 600);
1024        cfg.oauth.validate().unwrap();
1025        // The generated example carries the same default.
1026        assert_eq!(ServerConfig::generate_example().oauth.auth_code_ttl_secs, 600);
1027    }
1028
1029    #[test]
1030    fn oauth_loads_from_toml() {
1031        let _guard = ENV_LOCK.lock().unwrap();
1032        let (path, dir) = temp_path("oauth_toml", "toml");
1033        fs::write(&path, "[oauth]\nauth_code_ttl_secs = 60\n").unwrap();
1034        let cfg = ServerConfig::load(Some(path)).unwrap();
1035        assert_eq!(cfg.oauth.auth_code_ttl_secs, 60);
1036        cfg.oauth.validate().unwrap();
1037        let _ = fs::remove_dir_all(&dir);
1038    }
1039
1040    #[test]
1041    fn oauth_env_override() {
1042        let _guard = ENV_LOCK.lock().unwrap();
1043        std::env::set_var("ISSUERD_OAUTH__AUTH_CODE_TTL_SECS", "45");
1044        let cfg = ServerConfig::load(None).unwrap();
1045        std::env::remove_var("ISSUERD_OAUTH__AUTH_CODE_TTL_SECS");
1046        assert_eq!(cfg.oauth.auth_code_ttl_secs, 45);
1047    }
1048
1049    #[test]
1050    fn oauth_validate_enforces_bounds() {
1051        for ok in [AUTH_CODE_TTL_MIN_SECS, 60, AUTH_CODE_TTL_MAX_SECS] {
1052            OAuthConfig {
1053                auth_code_ttl_secs: ok,
1054            }
1055            .validate()
1056            .unwrap_or_else(|e| panic!("{ok}s must pass validation: {e}"));
1057        }
1058        for bad in [0, 9, 601, 6000] {
1059            assert!(
1060                OAuthConfig {
1061                    auth_code_ttl_secs: bad
1062                }
1063                .validate()
1064                .is_err(),
1065                "{bad}s must fail validation"
1066            );
1067        }
1068    }
1069
1070    #[test]
1071    fn auth_code_ttl_realm_override() {
1072        let oauth = OAuthConfig {
1073            auth_code_ttl_secs: 600,
1074        };
1075        let mut realm = issuerd_core::Realm::default();
1076        // Absent attribute -> server default.
1077        assert_eq!(oauth.auth_code_ttl(&realm), std::time::Duration::from_secs(600));
1078        // A valid override is honored (the FAPI 2.0 profile value).
1079        realm
1080            .attributes
1081            .insert(OAuthConfig::REALM_AUTH_CODE_TTL_ATTRIBUTE.to_string(), "60".to_string());
1082        assert_eq!(oauth.auth_code_ttl(&realm), std::time::Duration::from_secs(60));
1083        // Malformed or out-of-range attributes fall back to the server value.
1084        for bad in ["abc", "", "5", "601", "-10", "60.5"] {
1085            realm
1086                .attributes
1087                .insert(OAuthConfig::REALM_AUTH_CODE_TTL_ATTRIBUTE.to_string(), bad.to_string());
1088            assert_eq!(
1089                oauth.auth_code_ttl(&realm),
1090                std::time::Duration::from_secs(600),
1091                "attribute {bad:?} must fall back to the server default"
1092            );
1093        }
1094        // The fallback base is the configured server value, not a hardcoded one.
1095        let strict = OAuthConfig {
1096            auth_code_ttl_secs: 30,
1097        };
1098        realm.attributes.clear();
1099        assert_eq!(strict.auth_code_ttl(&realm), std::time::Duration::from_secs(30));
1100    }
1101
1102    // ------------------------------------------------------------------
1103    // [dpop]
1104    // ------------------------------------------------------------------
1105
1106    #[test]
1107    fn dpop_nonce_disabled_by_default() {
1108        let cfg = ServerConfig::default();
1109        assert_eq!(cfg.dpop.nonce.mode, DpopNonceMode::Disabled);
1110        assert_eq!(cfg.dpop.nonce.lifetime_secs, DPOP_NONCE_LIFETIME_DEFAULT_SECS);
1111        cfg.dpop.validate().unwrap();
1112        // The generated example carries the same defaults.
1113        let example = ServerConfig::generate_example();
1114        assert_eq!(example.dpop.nonce.mode, DpopNonceMode::Disabled);
1115        assert_eq!(example.dpop.nonce.lifetime_secs, 30);
1116    }
1117
1118    #[test]
1119    fn dpop_loads_from_toml() {
1120        let _guard = ENV_LOCK.lock().unwrap();
1121        let (path, dir) = temp_path("dpop_toml", "toml");
1122        fs::write(&path, "[dpop.nonce]\nmode = \"required\"\nlifetime_secs = 15\n").unwrap();
1123        let cfg = ServerConfig::load(Some(path)).unwrap();
1124        assert_eq!(cfg.dpop.nonce.mode, DpopNonceMode::Required);
1125        assert_eq!(cfg.dpop.nonce.lifetime_secs, 15);
1126        cfg.dpop.validate().unwrap();
1127        let _ = fs::remove_dir_all(&dir);
1128    }
1129
1130    #[test]
1131    fn dpop_mode_snake_case_values() {
1132        assert_eq!(
1133            serde_json::from_value::<DpopNonceMode>(serde_json::json!("disabled")).unwrap(),
1134            DpopNonceMode::Disabled
1135        );
1136        assert_eq!(
1137            serde_json::from_value::<DpopNonceMode>(serde_json::json!("supported")).unwrap(),
1138            DpopNonceMode::Supported
1139        );
1140        assert_eq!(
1141            serde_json::from_value::<DpopNonceMode>(serde_json::json!("required")).unwrap(),
1142            DpopNonceMode::Required
1143        );
1144        assert!(serde_json::from_value::<DpopNonceMode>(serde_json::json!("Required")).is_err());
1145    }
1146
1147    #[test]
1148    fn dpop_env_override() {
1149        let _guard = ENV_LOCK.lock().unwrap();
1150        std::env::set_var("ISSUERD_DPOP__NONCE__MODE", "supported");
1151        std::env::set_var("ISSUERD_DPOP__NONCE__LIFETIME_SECS", "45");
1152        let cfg = ServerConfig::load(None).unwrap();
1153        std::env::remove_var("ISSUERD_DPOP__NONCE__MODE");
1154        std::env::remove_var("ISSUERD_DPOP__NONCE__LIFETIME_SECS");
1155        assert_eq!(cfg.dpop.nonce.mode, DpopNonceMode::Supported);
1156        assert_eq!(cfg.dpop.nonce.lifetime_secs, 45);
1157    }
1158
1159    #[test]
1160    fn dpop_validate_enforces_bounds() {
1161        let nonce = |lifetime_secs| DpopConfig {
1162            nonce: DpopNonceConfig {
1163                mode: DpopNonceMode::Supported,
1164                lifetime_secs,
1165            },
1166        };
1167        for ok in [
1168            DPOP_NONCE_LIFETIME_MIN_SECS,
1169            30,
1170            DPOP_NONCE_LIFETIME_MAX_SECS,
1171        ] {
1172            nonce(ok)
1173                .validate()
1174                .unwrap_or_else(|e| panic!("{ok}s must pass validation: {e}"));
1175        }
1176        for bad in [0, 4, 301, 6000] {
1177            assert!(nonce(bad).validate().is_err(), "{bad}s must fail validation");
1178        }
1179    }
1180
1181    #[test]
1182    fn secure_cookies_follows_issuer_scheme() {
1183        let cfg = ServerConfig::default();
1184        assert!(!cfg.secure_cookies(), "http issuer -> no Secure flag");
1185        let https = ServerConfig {
1186            issuer_url: "https://idp.example.com".to_string(),
1187            ..Default::default()
1188        };
1189        assert!(https.secure_cookies(), "https issuer -> Secure flag");
1190        let upper = ServerConfig {
1191            issuer_url: "HTTPS://idp.example.com:8443".to_string(),
1192            ..Default::default()
1193        };
1194        assert!(upper.secure_cookies(), "URL schemes are case-insensitive");
1195        let garbage = ServerConfig {
1196            issuer_url: "not a url".to_string(),
1197            ..Default::default()
1198        };
1199        assert!(
1200            !garbage.secure_cookies(),
1201            "unparseable issuer -> no Secure flag (boot rejects such configs anyway)"
1202        );
1203    }
1204
1205    #[test]
1206    fn example_roundtrip_toml() {
1207        let _guard = ENV_LOCK.lock().unwrap();
1208        let original = ServerConfig::generate_example();
1209        let (path, dir) = temp_path("example_toml", "toml");
1210        write_example(&original, &path).unwrap();
1211        let loaded = ServerConfig::load(Some(path)).unwrap();
1212        assert_eq!(loaded.bind, original.bind);
1213        assert_eq!(loaded.port, original.port);
1214        assert_eq!(loaded.issuer_url, original.issuer_url);
1215        assert!(loaded.tls.is_some());
1216        assert!(matches!(loaded.storage, StorageConfig::Postgres { .. }));
1217        assert_eq!(loaded.redis, original.redis);
1218        assert_eq!(loaded.logging.format, original.logging.format);
1219        assert_eq!(loaded.web_ui.enabled, original.web_ui.enabled);
1220        assert_eq!(loaded.provision, original.provision);
1221        let _ = fs::remove_dir_all(&dir);
1222    }
1223
1224    #[test]
1225    fn example_roundtrip_yaml() {
1226        let _guard = ENV_LOCK.lock().unwrap();
1227        let original = ServerConfig::generate_example();
1228        let (path, dir) = temp_path("example_yaml", "yaml");
1229        write_example(&original, &path).unwrap();
1230        let loaded = ServerConfig::load(Some(path)).unwrap();
1231        assert_eq!(loaded.bind, original.bind);
1232        assert_eq!(loaded.port, original.port);
1233        let _ = fs::remove_dir_all(&dir);
1234    }
1235
1236    #[test]
1237    fn example_roundtrip_json() {
1238        let _guard = ENV_LOCK.lock().unwrap();
1239        let original = ServerConfig::generate_example();
1240        let (path, dir) = temp_path("example_json", "json");
1241        write_example(&original, &path).unwrap();
1242        let loaded = ServerConfig::load(Some(path)).unwrap();
1243        assert_eq!(loaded.bind, original.bind);
1244        assert_eq!(loaded.port, original.port);
1245        let _ = fs::remove_dir_all(&dir);
1246    }
1247
1248    /// Verify the committed `examples/issuerd.example.toml` is valid and matches the
1249    /// generated example struct. If this fails, regenerate with:
1250    ///   cargo run --bin issuerd -- example server-config -o examples/issuerd.example.toml
1251    #[test]
1252    fn committed_example_file_is_valid() {
1253        let _guard = ENV_LOCK.lock().unwrap();
1254        let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1255        let example_path = manifest
1256            .parent()
1257            .unwrap()
1258            .parent()
1259            .unwrap()
1260            .join("examples/issuerd.example.toml");
1261        let loaded = ServerConfig::load(Some(example_path)).unwrap();
1262        let generated = ServerConfig::generate_example();
1263        assert_eq!(loaded.bind, generated.bind);
1264        assert_eq!(loaded.port, generated.port);
1265        assert_eq!(loaded.issuer_url, generated.issuer_url);
1266        assert!(loaded.tls.is_some());
1267        assert!(matches!(loaded.storage, StorageConfig::Postgres { .. }));
1268        assert_eq!(loaded.redis, generated.redis);
1269        assert_eq!(loaded.logging.format, generated.logging.format);
1270        assert_eq!(loaded.web_ui.enabled, generated.web_ui.enabled);
1271        assert_eq!(loaded.provision, generated.provision);
1272    }
1273}