1use 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#[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 pub cluster: ClusterConfig,
30 pub smtp: SmtpConfig,
32 #[serde(default)]
34 pub cache: CacheConfig,
35 #[serde(default)]
37 pub oauth: OAuthConfig,
38 #[serde(default)]
40 pub dpop: DpopConfig,
41 #[serde(default)]
43 pub crypto: CryptoSettings,
44 pub themes: ThemesConfig,
46 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 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 pub fn secure_cookies(&self) -> bool {
121 url::Url::parse(&self.issuer_url).is_ok_and(|u| u.scheme() == "https")
122 }
123
124 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
172pub 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257pub struct CorsConfig {
258 pub allowed_origins: Vec<String>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ClusterConfig {
270 pub enabled: bool,
272 pub node_id: Option<String>,
274 pub redis_nodes: Vec<String>,
277 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct CacheConfig {
322 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
335pub const AUTH_CODE_TTL_DEFAULT_SECS: u64 = 600;
338pub const AUTH_CODE_TTL_MIN_SECS: u64 = 10;
340pub const AUTH_CODE_TTL_MAX_SECS: u64 = 600;
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct OAuthConfig {
350 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 pub const REALM_AUTH_CODE_TTL_ATTRIBUTE: &'static str = "auth_code_ttl_secs";
377
378 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 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
404pub const DPOP_NONCE_LIFETIME_DEFAULT_SECS: u64 = 30;
407pub const DPOP_NONCE_LIFETIME_MIN_SECS: u64 = 5;
409pub const DPOP_NONCE_LIFETIME_MAX_SECS: u64 = 300;
412
413#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct DpopConfig {
419 #[serde(default)]
423 pub nonce: DpopNonceConfig,
424}
425
426impl DpopConfig {
427 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case")]
449pub enum DpopNonceMode {
450 #[default]
452 Disabled,
453 Supported,
458 Required,
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct DpopNonceConfig {
467 pub mode: DpopNonceMode,
469 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
488pub struct CryptoSettings {
489 pub key_encryption: Option<KeyEncryptionConfig>,
494}
495
496impl CryptoSettings {
497 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#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct KeyEncryptionConfig {
524 pub key_id: String,
528 pub key_base64: String,
531 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct PreviousKekConfig {
555 pub key_id: String,
558 pub key_base64: String,
560}
561
562fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct ThemesConfig {
588 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#[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 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 assert_eq!(merged.username.as_deref(), Some("global-user"));
733 assert!(merged.enabled);
734
735 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 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 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 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 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 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(), }],
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 #[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 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 assert_eq!(oauth.auth_code_ttl(&realm), std::time::Duration::from_secs(600));
1078 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 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 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 #[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 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 #[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}