1use crate::errors::{AuthError, Result};
13use config::{Config, Environment, File, FileFormat};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::Path;
17
18#[derive(Debug, Clone)]
20pub struct ConfigManager {
21 config: Config,
23 sources: Vec<String>,
25 env_prefix: String,
27}
28
29#[derive(Debug, Clone)]
31pub struct ConfigBuilder {
32 sources: Vec<ConfigSource>,
34 env_prefix: String,
36 include_defaults: bool,
38 search_paths: Vec<String>,
40}
41
42#[derive(Debug, Clone)]
44pub enum ConfigSource {
45 File {
47 path: String,
48 format: FileFormat,
49 required: bool,
50 },
51 Environment { prefix: String },
53 Values(HashMap<String, config::Value>),
55 IncludeDir { path: String, pattern: String },
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61pub struct AuthFrameworkSettings {
62 #[serde(flatten)]
64 pub auth: super::AuthConfig,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub threat_intelligence: Option<crate::threat_intelligence::ThreatIntelConfig>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub session: Option<SessionSettings>,
73
74 #[serde(flatten)]
76 pub custom: HashMap<String, serde_json::Value>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SessionSettings {
82 pub max_concurrent_sessions: Option<u32>,
84
85 pub cleanup_interval: Option<u64>,
87
88 pub enable_device_tracking: Option<bool>,
90
91 pub cookie: Option<SessionCookieSettings>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SessionCookieSettings {
98 pub name: Option<String>,
100
101 pub domain: Option<String>,
103
104 pub path: Option<String>,
106
107 pub max_age: Option<u64>,
109
110 pub http_only: Option<bool>,
112}
113
114impl Default for ConfigBuilder {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl ConfigBuilder {
121 pub fn new() -> Self {
123 Self {
124 sources: Vec::new(),
125 env_prefix: "AUTH_FRAMEWORK".to_string(),
126 include_defaults: true,
127 search_paths: vec![
128 ".".to_string(),
129 "./config".to_string(),
130 "/etc/auth-framework".to_string(),
131 dirs::config_dir()
132 .map(|d| d.join("auth-framework").to_string_lossy().to_string())
133 .unwrap_or_else(|| "./config".to_string()),
134 ],
135 }
136 }
137
138 pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
140 self.env_prefix = prefix.into();
141 self
142 }
143
144 pub fn without_defaults(mut self) -> Self {
146 self.include_defaults = false;
147 self
148 }
149
150 pub fn add_file<P: AsRef<Path>>(mut self, path: P, required: bool) -> Self {
152 let path_str = path.as_ref().to_string_lossy().to_string();
153 let format = Self::detect_format(&path_str);
154
155 self.sources.push(ConfigSource::File {
156 path: path_str,
157 format,
158 required,
159 });
160 self
161 }
162
163 pub fn add_file_with_format<P: AsRef<Path>>(
165 mut self,
166 path: P,
167 format: FileFormat,
168 required: bool,
169 ) -> Self {
170 self.sources.push(ConfigSource::File {
171 path: path.as_ref().to_string_lossy().to_string(),
172 format,
173 required,
174 });
175 self
176 }
177
178 pub fn add_env_source(mut self, prefix: impl Into<String>) -> Self {
180 self.sources.push(ConfigSource::Environment {
181 prefix: prefix.into(),
182 });
183 self
184 }
185
186 pub fn add_values(mut self, values: HashMap<String, config::Value>) -> Self {
188 self.sources.push(ConfigSource::Values(values));
189 self
190 }
191
192 pub fn add_include_dir(mut self, path: impl Into<String>, pattern: impl Into<String>) -> Self {
194 self.sources.push(ConfigSource::IncludeDir {
195 path: path.into(),
196 pattern: pattern.into(),
197 });
198 self
199 }
200
201 pub fn add_search_path(mut self, path: impl Into<String>) -> Self {
203 self.search_paths.push(path.into());
204 self
205 }
206
207 pub fn build(self) -> Result<ConfigManager> {
209 let mut config = Config::builder();
210 let mut sources = Vec::new();
211
212 if self.include_defaults {
214 for search_path in &self.search_paths {
216 for filename in &[
217 "auth-framework.toml",
218 "auth-framework.yaml",
219 "auth-framework.yml",
220 "auth-framework.json",
221 "auth.toml",
222 "auth.yaml",
223 "auth.yml",
224 "auth.json",
225 ] {
226 let path = Path::new(search_path).join(filename);
227 if path.exists() {
228 let format = Self::detect_format(&path.to_string_lossy());
229 config = config
230 .add_source(File::from(path.clone()).format(format).required(false));
231 sources.push(path.to_string_lossy().to_string());
232 }
233 }
234 }
235 }
236
237 for source in self.sources {
239 match source {
240 ConfigSource::File {
241 path,
242 format,
243 required,
244 } => {
245 config = config.add_source(File::new(&path, format).required(required));
246 sources.push(path);
247 }
248 ConfigSource::Environment { prefix } => {
249 config = config.add_source(
250 Environment::with_prefix(&prefix)
251 .prefix_separator("_")
252 .separator("__"),
253 );
254 sources.push(format!("env:{}", prefix));
255 }
256 ConfigSource::Values(values) => {
257 for (key, value) in values {
258 config = config.set_override(&key, value).map_err(|e| {
259 AuthError::config(format!("Failed to set override: {e}"))
260 })?;
261 }
262 sources.push("values:override".to_string());
263 }
264 ConfigSource::IncludeDir { path, pattern } => {
265 if let Ok(entries) = std::fs::read_dir(&path) {
267 let mut files: Vec<_> = entries
268 .filter_map(|entry| entry.ok())
269 .filter(|entry| entry.file_name().to_string_lossy().contains(&pattern))
270 .collect();
271
272 files.sort_by_key(|e| e.file_name());
274
275 for entry in files {
276 let file_path = entry.path();
277 let format = Self::detect_format(&file_path.to_string_lossy());
278 config = config.add_source(
279 File::from(file_path.clone()).format(format).required(false),
280 );
281 sources.push(file_path.to_string_lossy().to_string());
282 }
283 }
284 }
285 }
286 }
287
288 config = config.add_source(
290 Environment::with_prefix(&self.env_prefix)
291 .prefix_separator("_")
292 .separator("__"),
293 );
294 sources.push(format!("env:{}", self.env_prefix));
295
296 let built_config = config
297 .build()
298 .map_err(|e| AuthError::config(format!("Failed to build configuration: {e}")))?;
299
300 Ok(ConfigManager {
301 config: built_config,
302 sources,
303 env_prefix: self.env_prefix,
304 })
305 }
306
307 fn detect_format(path: &str) -> FileFormat {
309 let path = Path::new(path);
310 match path.extension().and_then(|s| s.to_str()) {
311 Some("toml") => FileFormat::Toml,
312 Some("yaml") | Some("yml") => FileFormat::Yaml,
313 Some("json") => FileFormat::Json,
314 Some("ron") => FileFormat::Ron,
315 Some("ini") => FileFormat::Ini,
316 _ => FileFormat::Toml, }
318 }
319}
320
321impl ConfigManager {
322 pub fn new() -> Result<Self> {
324 ConfigBuilder::new().build()
325 }
326
327 pub fn for_application(app_name: &str) -> Result<Self> {
329 ConfigBuilder::new()
330 .with_env_prefix(format!("{}_AUTH_FRAMEWORK", app_name.to_uppercase()))
331 .add_file(format!("{}.toml", app_name), false)
332 .add_file(format!("config/{}.toml", app_name), false)
333 .build()
334 }
335
336 pub fn get_auth_settings(&self) -> Result<AuthFrameworkSettings> {
338 self.config
339 .clone()
340 .try_deserialize::<AuthFrameworkSettings>()
341 .map_err(|e| AuthError::config(format!("Failed to deserialize auth settings: {e}")))
342 }
343
344 pub fn get_section<T>(&self, section: &str) -> Result<T>
346 where
347 T: for<'de> Deserialize<'de>,
348 {
349 self.config
350 .get::<T>(section)
351 .map_err(|e| AuthError::config(format!("Failed to get section '{}': {e}", section)))
352 }
353
354 pub fn get<T>(&self, key: &str) -> Result<T>
356 where
357 T: for<'de> Deserialize<'de>,
358 {
359 self.config
360 .get::<T>(key)
361 .map_err(|e| AuthError::config(format!("Failed to get key '{}': {e}", key)))
362 }
363
364 pub fn get_or_default<T>(&self, key: &str, default: T) -> T
366 where
367 T: for<'de> Deserialize<'de>,
368 {
369 self.config.get::<T>(key).unwrap_or(default)
370 }
371
372 pub fn has_key(&self, key: &str) -> bool {
374 self.config.get::<config::Value>(key).is_ok()
375 }
376
377 pub fn get_keys_with_prefix(&self, prefix: &str) -> Vec<String> {
379 if let Ok(table) = self.config.get_table(prefix) {
381 table.keys().map(|k| format!("{}.{}", prefix, k)).collect()
382 } else {
383 Vec::new()
384 }
385 }
386
387 pub fn sources(&self) -> &[String] {
389 &self.sources
390 }
391
392 pub fn env_prefix(&self) -> &str {
394 &self.env_prefix
395 }
396
397 pub fn validate(&self) -> Result<()> {
399 let auth_config = self.get_auth_settings()?;
400 auth_config.auth.validate()
401 }
402
403 pub fn section(&self, section: &str) -> Result<ConfigManager> {
405 let section_config = self
406 .config
407 .get::<HashMap<String, config::Value>>(section)
408 .map_err(|e| AuthError::config(format!("Failed to get section '{}': {e}", section)))?;
409
410 let mut config_builder = Config::builder();
411 for (key, value) in section_config {
412 config_builder = config_builder
413 .set_override(&key, value)
414 .map_err(|e| AuthError::config(format!("Failed to set override: {e}")))?;
415 }
416
417 let built_config = config_builder
418 .build()
419 .map_err(|e| AuthError::config(format!("Failed to build section config: {e}")))?;
420
421 Ok(ConfigManager {
422 config: built_config,
423 sources: vec![format!("section:{}", section)],
424 env_prefix: format!("{}_{}", self.env_prefix, section.to_uppercase()),
425 })
426 }
427
428 pub fn merge(self, other: ConfigManager) -> Result<ConfigManager> {
430 let mut sources = self.sources;
431 sources.extend(other.sources);
432
433 Ok(ConfigManager {
436 config: other.config,
437 sources,
438 env_prefix: other.env_prefix,
439 })
440 }
441
442 pub fn export_to_string(&self, format: FileFormat) -> Result<String> {
444 let settings = self.get_auth_settings()?;
445
446 match format {
447 FileFormat::Toml => toml::to_string_pretty(&settings)
448 .map_err(|e| AuthError::config(format!("Failed to serialize to TOML: {e}"))),
449 FileFormat::Yaml => serde_yaml::to_string(&settings)
450 .map_err(|e| AuthError::config(format!("Failed to serialize to YAML: {e}"))),
451 FileFormat::Json => serde_json::to_string_pretty(&settings)
452 .map_err(|e| AuthError::config(format!("Failed to serialize to JSON: {e}"))),
453 _ => Err(AuthError::config("Unsupported export format")),
454 }
455 }
456}
457
458impl Default for ConfigManager {
459 fn default() -> Self {
466 Self::new().expect(
467 "ConfigManager::default(): failed to build default configuration. \
468 Use ConfigManager::new() for Result-based error handling.",
469 )
470 }
471}
472
473impl Default for SessionSettings {
474 fn default() -> Self {
475 Self {
476 max_concurrent_sessions: Some(5),
477 cleanup_interval: Some(3600), enable_device_tracking: Some(true),
479 cookie: Some(SessionCookieSettings::default()),
480 }
481 }
482}
483
484impl Default for SessionCookieSettings {
485 fn default() -> Self {
486 Self {
487 name: Some("auth_session".to_string()),
488 domain: None,
489 path: Some("/".to_string()),
490 max_age: Some(86400), http_only: Some(true),
492 }
493 }
494}
495
496pub trait ConfigIntegration {
498 fn auth_framework(&self) -> Option<&AuthFrameworkSettings>;
500
501 fn auth_framework_mut(&mut self) -> Option<&mut AuthFrameworkSettings>;
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn test_config_builder_basic() {
511 let config = ConfigBuilder::new()
512 .with_env_prefix("TEST")
513 .build()
514 .expect("Failed to build config");
515
516 assert_eq!(config.env_prefix(), "TEST");
517 }
518
519 #[test]
520 fn test_config_manager_default() {
521 let settings = AuthFrameworkSettings::default();
524
525 assert!(!settings.auth.enable_multi_factor);
527 assert_eq!(settings.auth.token_lifetime.as_secs(), 3600); assert_eq!(settings.auth.issuer, "auth-framework");
529 }
530
531 #[test]
532 fn test_application_specific_config() {
533 let config = ConfigManager::for_application("myapp").expect("Failed to create app config");
534
535 assert_eq!(config.env_prefix(), "MYAPP_AUTH_FRAMEWORK");
536 }
537
538 #[test]
539 fn test_config_sources() {
540 let config = ConfigBuilder::new()
541 .add_file("nonexistent.toml", false)
542 .add_env_source("TEST")
543 .build()
544 .expect("Failed to build config");
545
546 let sources = config.sources();
547 assert!(!sources.is_empty());
548 }
549}