use crate::jwt::JwtConfig;
use platform_core::{
AppContext, AppError, AppResult, RuntimeConfigDescriptor, RuntimeConfigGeneratedValue,
RuntimeConfigGroupDescriptor, RuntimeConfigScope, RuntimeConfigSnapshot, RuntimeConfigType,
RuntimeConfigVisibilityCondition,
};
use serde::Deserialize;
use serde_json::json;
use std::sync::LazyLock;
pub const CONFIG_PREFIX: &str = "auth-password";
const DEFAULT_ARGON2_MEMORY_KIB: u32 = argon2::Params::DEFAULT_M_COST;
const DEFAULT_ARGON2_TIME_COST: u32 = argon2::Params::DEFAULT_T_COST;
const DEFAULT_ARGON2_PARALLELISM: u32 = argon2::Params::DEFAULT_P_COST;
const MIN_ARGON2_MEMORY_KIB: i64 = 8 * 1024;
const MAX_ARGON2_MEMORY_KIB: i64 = 1024 * 1024;
const MAX_ARGON2_TIME_COST: i64 = 10;
const MAX_ARGON2_PARALLELISM: i64 = 8;
const DEFAULT_JWT_TTL_HOURS: u32 = 1;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
pub enum HashAlgorithm {
#[serde(rename = "argon2id")]
Argon2id,
#[serde(rename = "argon2i")]
Argon2i,
}
impl Default for HashAlgorithm {
fn default() -> Self {
Self::Argon2id
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
pub enum TokenStrategy {
#[serde(rename = "session")]
Session,
#[serde(rename = "jwt")]
Jwt,
}
impl Default for TokenStrategy {
fn default() -> Self {
Self::Session
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct AuthPasswordConfig {
#[serde(default)]
pub hash_algorithm: HashAlgorithm,
#[serde(default = "default_argon2_memory_kib")]
pub argon2_memory_kib: u32,
#[serde(default = "default_argon2_time_cost")]
pub argon2_time_cost: u32,
#[serde(default = "default_argon2_parallelism")]
pub argon2_parallelism: u32,
#[serde(default)]
pub token_strategy: TokenStrategy,
#[serde(default)]
pub jwt_secret: Option<String>,
#[serde(default)]
pub jwt_issuer: Option<String>,
#[serde(default)]
pub jwt_audience: Option<String>,
#[serde(default)]
pub jwt_ttl_hours: Option<u32>,
}
impl Default for AuthPasswordConfig {
fn default() -> Self {
Self {
hash_algorithm: HashAlgorithm::Argon2id,
argon2_memory_kib: DEFAULT_ARGON2_MEMORY_KIB,
argon2_time_cost: DEFAULT_ARGON2_TIME_COST,
argon2_parallelism: DEFAULT_ARGON2_PARALLELISM,
token_strategy: TokenStrategy::Session,
jwt_secret: None,
jwt_issuer: None,
jwt_audience: None,
jwt_ttl_hours: None,
}
}
}
impl AuthPasswordConfig {
pub fn from_context(ctx: &AppContext) -> AppResult<Self> {
let mut config = Self::from_snapshot(&ctx.runtime_config.snapshot())?;
let local_config = ctx.config.module_local_config(CONFIG_PREFIX)?;
config.apply_module_local_config(&local_config);
Ok(config)
}
pub fn from_snapshot(snapshot: &RuntimeConfigSnapshot) -> AppResult<Self> {
snapshot.get(CONFIG_PREFIX)
}
pub fn argon2_algorithm(&self) -> argon2::Algorithm {
match self.hash_algorithm {
HashAlgorithm::Argon2id => argon2::Algorithm::Argon2id,
HashAlgorithm::Argon2i => argon2::Algorithm::Argon2i,
}
}
fn apply_module_local_config(&mut self, local_config: &AuthPasswordLocalConfig) {
if let Some(secret) = local_config.jwt_secret.as_deref().map(str::trim)
&& !secret.is_empty()
{
self.jwt_secret = Some(secret.to_owned());
}
}
pub fn jwt_config(&self) -> AppResult<Option<JwtConfig>> {
if self.token_strategy != TokenStrategy::Jwt {
return Ok(None);
}
let secret = self.jwt_secret.clone().ok_or_else(|| {
AppError::validation(
"Request validation failed",
vec![platform_core::error::ErrorDetail {
field: Some("jwt_secret".to_owned()),
reason: "jwt_secret is required when token_strategy is jwt".to_owned(),
}],
)
})?;
Ok(Some(JwtConfig {
secret,
issuer: self
.jwt_issuer
.clone()
.unwrap_or_else(|| "lenso".to_owned()),
audience: self
.jwt_audience
.clone()
.unwrap_or_else(|| "lenso".to_owned()),
ttl_hours: self.jwt_ttl_hours.unwrap_or(DEFAULT_JWT_TTL_HOURS),
}))
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
struct AuthPasswordLocalConfig {
#[serde(default)]
jwt_secret: Option<String>,
}
pub static RUNTIME_CONFIG_GROUPS: LazyLock<Vec<RuntimeConfigGroupDescriptor>> =
LazyLock::new(|| {
vec![
RuntimeConfigGroupDescriptor {
id: "auth-password.hashing",
label: "Password Hashing",
description: "Password hash algorithm and Argon2 parameters.",
order: 30,
},
RuntimeConfigGroupDescriptor {
id: "auth-password.tokens",
label: "Tokens",
description: "Token issuance strategy and JWT settings.",
order: 40,
},
]
});
pub static RUNTIME_CONFIG: LazyLock<Vec<RuntimeConfigDescriptor>> = LazyLock::new(|| {
vec![
RuntimeConfigDescriptor {
key: "auth-password.hash_algorithm".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.hashing"),
section: None,
order: 10,
visible_when: None,
generated: None,
value_type: RuntimeConfigType::Enum(&["argon2id", "argon2i"]),
default: json!("argon2id"),
editable: true,
restart_only: false,
description: "Password hash algorithm used for new password hashes.",
},
RuntimeConfigDescriptor {
key: "auth-password.argon2_memory_kib".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.hashing"),
section: None,
order: 20,
visible_when: None,
generated: None,
value_type: RuntimeConfigType::Int {
min: Some(MIN_ARGON2_MEMORY_KIB),
max: Some(MAX_ARGON2_MEMORY_KIB),
},
default: json!(DEFAULT_ARGON2_MEMORY_KIB),
editable: true,
restart_only: false,
description: "Argon2 memory cost in KiB for new password hashes.",
},
RuntimeConfigDescriptor {
key: "auth-password.argon2_time_cost".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.hashing"),
section: None,
order: 30,
visible_when: None,
generated: None,
value_type: RuntimeConfigType::Int {
min: Some(i64::from(argon2::Params::MIN_T_COST)),
max: Some(MAX_ARGON2_TIME_COST),
},
default: json!(DEFAULT_ARGON2_TIME_COST),
editable: true,
restart_only: false,
description: "Argon2 iteration count for new password hashes.",
},
RuntimeConfigDescriptor {
key: "auth-password.argon2_parallelism".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.hashing"),
section: None,
order: 40,
visible_when: None,
generated: None,
value_type: RuntimeConfigType::Int {
min: Some(i64::from(argon2::Params::MIN_P_COST)),
max: Some(MAX_ARGON2_PARALLELISM),
},
default: json!(DEFAULT_ARGON2_PARALLELISM),
editable: true,
restart_only: false,
description: "Argon2 parallelism for new password hashes.",
},
RuntimeConfigDescriptor {
key: "auth-password.token_strategy".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.tokens"),
section: Some("Issuance"),
order: 10,
visible_when: None,
generated: None,
value_type: RuntimeConfigType::Enum(&["session", "jwt"]),
default: json!("session"),
editable: true,
restart_only: true,
description: "Token issuance strategy: session (stateful, DB-backed) or jwt (stateless, self-contained).",
},
RuntimeConfigDescriptor {
key: "auth-password.jwt_secret".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.tokens"),
section: Some("JWT"),
order: 20,
visible_when: Some(jwt_visibility_condition()),
generated: Some(RuntimeConfigGeneratedValue::Secret {
bytes: 32,
when: jwt_visibility_condition(),
}),
value_type: RuntimeConfigType::String,
default: json!(null),
editable: true,
restart_only: true,
description: "HMAC-SHA256 secret for JWT signing. Used when no local module jwt_secret is configured.",
},
RuntimeConfigDescriptor {
key: "auth-password.jwt_issuer".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.tokens"),
section: Some("JWT"),
order: 30,
visible_when: Some(jwt_visibility_condition()),
generated: None,
value_type: RuntimeConfigType::String,
default: json!("lenso"),
editable: true,
restart_only: false,
description: "JWT issuer claim (iss).",
},
RuntimeConfigDescriptor {
key: "auth-password.jwt_audience".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.tokens"),
section: Some("JWT"),
order: 40,
visible_when: Some(jwt_visibility_condition()),
generated: None,
value_type: RuntimeConfigType::String,
default: json!("lenso"),
editable: true,
restart_only: false,
description: "JWT audience claim (aud).",
},
RuntimeConfigDescriptor {
key: "auth-password.jwt_ttl_hours".to_owned(),
scope: RuntimeConfigScope::Shared,
group: Some("auth-password.tokens"),
section: Some("JWT"),
order: 50,
visible_when: Some(jwt_visibility_condition()),
generated: None,
value_type: RuntimeConfigType::Int {
min: Some(1),
max: Some(168),
},
default: json!(DEFAULT_JWT_TTL_HOURS),
editable: true,
restart_only: false,
description: "JWT time-to-live in hours.",
},
]
});
fn jwt_visibility_condition() -> RuntimeConfigVisibilityCondition {
RuntimeConfigVisibilityCondition::Equals {
service: "*",
key: "auth-password.token_strategy",
value: json!("jwt"),
}
}
fn default_argon2_memory_kib() -> u32 {
DEFAULT_ARGON2_MEMORY_KIB
}
fn default_argon2_time_cost() -> u32 {
DEFAULT_ARGON2_TIME_COST
}
fn default_argon2_parallelism() -> u32 {
DEFAULT_ARGON2_PARALLELISM
}
#[cfg(test)]
mod tests {
use super::*;
use platform_core::{RuntimeConfigRegistry, RuntimeConfigSnapshot};
use std::collections::BTreeMap;
#[test]
fn reads_defaults_from_snapshot() {
let registry = RuntimeConfigRegistry::try_new(RUNTIME_CONFIG.clone()).unwrap();
let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &BTreeMap::new());
let config = AuthPasswordConfig::from_snapshot(&snapshot).unwrap();
assert_eq!(
config,
AuthPasswordConfig {
hash_algorithm: HashAlgorithm::Argon2id,
argon2_memory_kib: DEFAULT_ARGON2_MEMORY_KIB,
argon2_time_cost: DEFAULT_ARGON2_TIME_COST,
argon2_parallelism: DEFAULT_ARGON2_PARALLELISM,
token_strategy: TokenStrategy::Session,
jwt_secret: None,
jwt_issuer: Some("lenso".to_owned()),
jwt_audience: Some("lenso".to_owned()),
jwt_ttl_hours: Some(DEFAULT_JWT_TTL_HOURS),
}
);
}
#[test]
fn reads_configured_hash_policy_from_snapshot() {
let registry = RuntimeConfigRegistry::try_new(RUNTIME_CONFIG.clone()).unwrap();
let mut stored = BTreeMap::new();
stored.insert(
("*".to_owned(), "auth-password.hash_algorithm".to_owned()),
json!("argon2i"),
);
stored.insert(
("*".to_owned(), "auth-password.argon2_memory_kib".to_owned()),
json!(16384),
);
stored.insert(
("*".to_owned(), "auth-password.argon2_time_cost".to_owned()),
json!(3),
);
stored.insert(
(
"*".to_owned(),
"auth-password.argon2_parallelism".to_owned(),
),
json!(2),
);
let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
let config = AuthPasswordConfig::from_snapshot(&snapshot).unwrap();
assert_eq!(
config,
AuthPasswordConfig {
hash_algorithm: HashAlgorithm::Argon2i,
argon2_memory_kib: 16384,
argon2_time_cost: 3,
argon2_parallelism: 2,
token_strategy: TokenStrategy::Session,
jwt_secret: None,
jwt_issuer: Some("lenso".to_owned()),
jwt_audience: Some("lenso".to_owned()),
jwt_ttl_hours: Some(DEFAULT_JWT_TTL_HOURS),
}
);
}
#[test]
fn jwt_config_returns_none_when_session_strategy() {
let config = AuthPasswordConfig::default();
assert!(config.jwt_config().unwrap().is_none());
}
#[test]
fn jwt_config_returns_error_when_jwt_without_secret() {
let config = AuthPasswordConfig {
token_strategy: TokenStrategy::Jwt,
..AuthPasswordConfig::default()
};
assert!(config.jwt_config().is_err());
}
#[test]
fn jwt_config_returns_config_when_jwt_with_secret() {
let config = AuthPasswordConfig {
token_strategy: TokenStrategy::Jwt,
jwt_secret: Some("my-secret".to_owned()),
jwt_issuer: Some("custom-issuer".to_owned()),
jwt_audience: Some("custom-audience".to_owned()),
jwt_ttl_hours: Some(24),
..AuthPasswordConfig::default()
};
let jwt_config = config.jwt_config().unwrap().unwrap();
assert_eq!(jwt_config.secret, "my-secret");
assert_eq!(jwt_config.issuer, "custom-issuer");
assert_eq!(jwt_config.audience, "custom-audience");
assert_eq!(jwt_config.ttl_hours, 24);
}
#[test]
fn module_local_jwt_secret_overrides_runtime_config_secret() {
let mut config = AuthPasswordConfig {
jwt_secret: Some("runtime-secret".to_owned()),
..AuthPasswordConfig::default()
};
config.apply_module_local_config(&AuthPasswordLocalConfig {
jwt_secret: Some("local-secret".to_owned()),
});
assert_eq!(config.jwt_secret.as_deref(), Some("local-secret"));
}
#[test]
fn jwt_config_uses_defaults_for_optional_fields() {
let config = AuthPasswordConfig {
token_strategy: TokenStrategy::Jwt,
jwt_secret: Some("secret".to_owned()),
..AuthPasswordConfig::default()
};
let jwt_config = config.jwt_config().unwrap().unwrap();
assert_eq!(jwt_config.issuer, "lenso");
assert_eq!(jwt_config.audience, "lenso");
assert_eq!(jwt_config.ttl_hours, DEFAULT_JWT_TTL_HOURS);
}
}