use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
pub enable_authorize: bool,
pub failed_login_attempts: u32,
pub session_idle_timeout_secs: u64,
pub force_change_default_password: bool,
pub default_username: String,
#[serde(default = "default_bcrypt_cost")]
pub bcrypt_cost: u32,
#[serde(skip)]
pub bootstrap_password: String,
}
fn default_bcrypt_cost() -> u32 {
12
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enable_authorize: true,
failed_login_attempts: 5,
session_idle_timeout_secs: 3600,
force_change_default_password: true,
default_username: "root".to_string(),
bcrypt_cost: default_bcrypt_cost(),
bootstrap_password: String::new(),
}
}
}
impl fmt::Debug for AuthConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AuthConfig")
.field("enable_authorize", &self.enable_authorize)
.field("failed_login_attempts", &self.failed_login_attempts)
.field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
.field(
"force_change_default_password",
&self.force_change_default_password,
)
.field("default_username", &self.default_username)
.field("bcrypt_cost", &self.bcrypt_cost)
.field("bootstrap_password", &"***")
.finish()
}
}
impl AuthConfig {
pub fn validate(&self) -> Result<(), String> {
if self.default_username.is_empty() {
return Err("Default username cannot be empty".to_string());
}
if !(4..=12).contains(&self.bcrypt_cost) {
return Err("Bcrypt cost must be between 4 and 12".to_string());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_config_default() {
let config = AuthConfig::default();
assert!(config.enable_authorize);
assert_eq!(config.failed_login_attempts, 5);
assert_eq!(config.session_idle_timeout_secs, 3600);
assert!(config.force_change_default_password);
assert_eq!(config.default_username, "root");
assert_eq!(config.bcrypt_cost, 12);
assert!(config.bootstrap_password.is_empty());
}
#[test]
fn test_auth_config_debug_redacts_bootstrap_password() {
let config = AuthConfig {
bootstrap_password: "super-secret".to_string(),
..Default::default()
};
let rendered = format!("{:?}", config);
assert!(
!rendered.contains("super-secret"),
"debug output leaked the bootstrap password: {rendered}"
);
assert!(
rendered.contains("bootstrap_password: \"***\""),
"{rendered}"
);
}
#[test]
fn test_bootstrap_password_is_not_serialized() {
let config = AuthConfig {
bootstrap_password: "super-secret".to_string(),
..Default::default()
};
let toml_text = toml::to_string(&config).expect("serialize auth config");
assert!(
!toml_text.contains("bootstrap_password"),
"auth config must never persist the bootstrap password: {toml_text}"
);
let round_tripped: AuthConfig =
toml::from_str(&toml_text).expect("deserialize auth config");
assert!(round_tripped.bootstrap_password.is_empty());
}
#[test]
fn test_auth_config_validate() {
let config = AuthConfig::default();
assert!(config.validate().is_ok());
let invalid_config = AuthConfig {
default_username: String::new(),
..Default::default()
};
assert!(invalid_config.validate().is_err());
let invalid_cost = AuthConfig {
bcrypt_cost: 3,
..Default::default()
};
assert!(invalid_cost.validate().is_err());
}
}