1use crate::jwt::JwtConfig;
2use platform_core::{
3 AppContext, AppError, AppResult, RuntimeConfigDescriptor, RuntimeConfigGeneratedValue,
4 RuntimeConfigGroupDescriptor, RuntimeConfigScope, RuntimeConfigSnapshot, RuntimeConfigType,
5 RuntimeConfigVisibilityCondition,
6};
7use serde::Deserialize;
8use serde_json::json;
9use std::sync::LazyLock;
10
11pub const CONFIG_PREFIX: &str = "auth-password";
12
13const DEFAULT_ARGON2_MEMORY_KIB: u32 = argon2::Params::DEFAULT_M_COST;
14const DEFAULT_ARGON2_TIME_COST: u32 = argon2::Params::DEFAULT_T_COST;
15const DEFAULT_ARGON2_PARALLELISM: u32 = argon2::Params::DEFAULT_P_COST;
16const MIN_ARGON2_MEMORY_KIB: i64 = 8 * 1024;
17const MAX_ARGON2_MEMORY_KIB: i64 = 1024 * 1024;
18const MAX_ARGON2_TIME_COST: i64 = 10;
19const MAX_ARGON2_PARALLELISM: i64 = 8;
20const DEFAULT_JWT_TTL_HOURS: u32 = 1;
21
22#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
23pub enum HashAlgorithm {
24 #[serde(rename = "argon2id")]
25 Argon2id,
26 #[serde(rename = "argon2i")]
27 Argon2i,
28}
29
30impl Default for HashAlgorithm {
31 fn default() -> Self {
32 Self::Argon2id
33 }
34}
35
36#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
41pub enum TokenStrategy {
42 #[serde(rename = "session")]
43 Session,
44 #[serde(rename = "jwt")]
45 Jwt,
46}
47
48impl Default for TokenStrategy {
49 fn default() -> Self {
50 Self::Session
51 }
52}
53
54#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
55pub struct AuthPasswordConfig {
56 #[serde(default)]
57 pub hash_algorithm: HashAlgorithm,
58 #[serde(default = "default_argon2_memory_kib")]
59 pub argon2_memory_kib: u32,
60 #[serde(default = "default_argon2_time_cost")]
61 pub argon2_time_cost: u32,
62 #[serde(default = "default_argon2_parallelism")]
63 pub argon2_parallelism: u32,
64 #[serde(default)]
65 pub token_strategy: TokenStrategy,
66 #[serde(default)]
67 pub jwt_secret: Option<String>,
68 #[serde(default)]
69 pub jwt_issuer: Option<String>,
70 #[serde(default)]
71 pub jwt_audience: Option<String>,
72 #[serde(default)]
73 pub jwt_ttl_hours: Option<u32>,
74}
75
76impl Default for AuthPasswordConfig {
77 fn default() -> Self {
78 Self {
79 hash_algorithm: HashAlgorithm::Argon2id,
80 argon2_memory_kib: DEFAULT_ARGON2_MEMORY_KIB,
81 argon2_time_cost: DEFAULT_ARGON2_TIME_COST,
82 argon2_parallelism: DEFAULT_ARGON2_PARALLELISM,
83 token_strategy: TokenStrategy::Session,
84 jwt_secret: None,
85 jwt_issuer: None,
86 jwt_audience: None,
87 jwt_ttl_hours: None,
88 }
89 }
90}
91
92impl AuthPasswordConfig {
93 pub fn from_context(ctx: &AppContext) -> AppResult<Self> {
94 let mut config = Self::from_snapshot(&ctx.runtime_config.snapshot())?;
95 let local_config = ctx.config.module_local_config(CONFIG_PREFIX)?;
96 config.apply_module_local_config(&local_config);
97 Ok(config)
98 }
99
100 pub fn from_snapshot(snapshot: &RuntimeConfigSnapshot) -> AppResult<Self> {
101 snapshot.get(CONFIG_PREFIX)
102 }
103
104 pub fn argon2_algorithm(&self) -> argon2::Algorithm {
105 match self.hash_algorithm {
106 HashAlgorithm::Argon2id => argon2::Algorithm::Argon2id,
107 HashAlgorithm::Argon2i => argon2::Algorithm::Argon2i,
108 }
109 }
110
111 fn apply_module_local_config(&mut self, local_config: &AuthPasswordLocalConfig) {
112 if let Some(secret) = local_config.jwt_secret.as_deref().map(str::trim)
113 && !secret.is_empty()
114 {
115 self.jwt_secret = Some(secret.to_owned());
116 }
117 }
118
119 pub fn jwt_config(&self) -> AppResult<Option<JwtConfig>> {
125 if self.token_strategy != TokenStrategy::Jwt {
126 return Ok(None);
127 }
128
129 let secret = self.jwt_secret.clone().ok_or_else(|| {
130 AppError::validation(
131 "Request validation failed",
132 vec![platform_core::error::ErrorDetail {
133 field: Some("jwt_secret".to_owned()),
134 reason: "jwt_secret is required when token_strategy is jwt".to_owned(),
135 }],
136 )
137 })?;
138
139 Ok(Some(JwtConfig {
140 secret,
141 issuer: self
142 .jwt_issuer
143 .clone()
144 .unwrap_or_else(|| "lenso".to_owned()),
145 audience: self
146 .jwt_audience
147 .clone()
148 .unwrap_or_else(|| "lenso".to_owned()),
149 ttl_hours: self.jwt_ttl_hours.unwrap_or(DEFAULT_JWT_TTL_HOURS),
150 }))
151 }
152}
153
154#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
155struct AuthPasswordLocalConfig {
156 #[serde(default)]
157 jwt_secret: Option<String>,
158}
159
160pub static RUNTIME_CONFIG_GROUPS: LazyLock<Vec<RuntimeConfigGroupDescriptor>> =
161 LazyLock::new(|| {
162 vec![
163 RuntimeConfigGroupDescriptor {
164 id: "auth-password.hashing",
165 label: "Password Hashing",
166 description: "Password hash algorithm and Argon2 parameters.",
167 order: 30,
168 },
169 RuntimeConfigGroupDescriptor {
170 id: "auth-password.tokens",
171 label: "Tokens",
172 description: "Token issuance strategy and JWT settings.",
173 order: 40,
174 },
175 ]
176 });
177
178pub static RUNTIME_CONFIG: LazyLock<Vec<RuntimeConfigDescriptor>> = LazyLock::new(|| {
179 vec![
180 RuntimeConfigDescriptor {
181 key: "auth-password.hash_algorithm".to_owned(),
182 scope: RuntimeConfigScope::Shared,
183 group: Some("auth-password.hashing"),
184 section: None,
185 order: 10,
186 visible_when: None,
187 generated: None,
188 value_type: RuntimeConfigType::Enum(&["argon2id", "argon2i"]),
189 default: json!("argon2id"),
190 editable: true,
191 restart_only: false,
192 description: "Password hash algorithm used for new password hashes.",
193 },
194 RuntimeConfigDescriptor {
195 key: "auth-password.argon2_memory_kib".to_owned(),
196 scope: RuntimeConfigScope::Shared,
197 group: Some("auth-password.hashing"),
198 section: None,
199 order: 20,
200 visible_when: None,
201 generated: None,
202 value_type: RuntimeConfigType::Int {
203 min: Some(MIN_ARGON2_MEMORY_KIB),
204 max: Some(MAX_ARGON2_MEMORY_KIB),
205 },
206 default: json!(DEFAULT_ARGON2_MEMORY_KIB),
207 editable: true,
208 restart_only: false,
209 description: "Argon2 memory cost in KiB for new password hashes.",
210 },
211 RuntimeConfigDescriptor {
212 key: "auth-password.argon2_time_cost".to_owned(),
213 scope: RuntimeConfigScope::Shared,
214 group: Some("auth-password.hashing"),
215 section: None,
216 order: 30,
217 visible_when: None,
218 generated: None,
219 value_type: RuntimeConfigType::Int {
220 min: Some(i64::from(argon2::Params::MIN_T_COST)),
221 max: Some(MAX_ARGON2_TIME_COST),
222 },
223 default: json!(DEFAULT_ARGON2_TIME_COST),
224 editable: true,
225 restart_only: false,
226 description: "Argon2 iteration count for new password hashes.",
227 },
228 RuntimeConfigDescriptor {
229 key: "auth-password.argon2_parallelism".to_owned(),
230 scope: RuntimeConfigScope::Shared,
231 group: Some("auth-password.hashing"),
232 section: None,
233 order: 40,
234 visible_when: None,
235 generated: None,
236 value_type: RuntimeConfigType::Int {
237 min: Some(i64::from(argon2::Params::MIN_P_COST)),
238 max: Some(MAX_ARGON2_PARALLELISM),
239 },
240 default: json!(DEFAULT_ARGON2_PARALLELISM),
241 editable: true,
242 restart_only: false,
243 description: "Argon2 parallelism for new password hashes.",
244 },
245 RuntimeConfigDescriptor {
246 key: "auth-password.token_strategy".to_owned(),
247 scope: RuntimeConfigScope::Shared,
248 group: Some("auth-password.tokens"),
249 section: Some("Issuance"),
250 order: 10,
251 visible_when: None,
252 generated: None,
253 value_type: RuntimeConfigType::Enum(&["session", "jwt"]),
254 default: json!("session"),
255 editable: true,
256 restart_only: true,
257 description: "Token issuance strategy: session (stateful, DB-backed) or jwt (stateless, self-contained).",
258 },
259 RuntimeConfigDescriptor {
260 key: "auth-password.jwt_secret".to_owned(),
261 scope: RuntimeConfigScope::Shared,
262 group: Some("auth-password.tokens"),
263 section: Some("JWT"),
264 order: 20,
265 visible_when: Some(jwt_visibility_condition()),
266 generated: Some(RuntimeConfigGeneratedValue::Secret {
267 bytes: 32,
268 when: jwt_visibility_condition(),
269 }),
270 value_type: RuntimeConfigType::String,
271 default: json!(null),
272 editable: true,
273 restart_only: true,
274 description: "HMAC-SHA256 secret for JWT signing. Used when no local module jwt_secret is configured.",
275 },
276 RuntimeConfigDescriptor {
277 key: "auth-password.jwt_issuer".to_owned(),
278 scope: RuntimeConfigScope::Shared,
279 group: Some("auth-password.tokens"),
280 section: Some("JWT"),
281 order: 30,
282 visible_when: Some(jwt_visibility_condition()),
283 generated: None,
284 value_type: RuntimeConfigType::String,
285 default: json!("lenso"),
286 editable: true,
287 restart_only: false,
288 description: "JWT issuer claim (iss).",
289 },
290 RuntimeConfigDescriptor {
291 key: "auth-password.jwt_audience".to_owned(),
292 scope: RuntimeConfigScope::Shared,
293 group: Some("auth-password.tokens"),
294 section: Some("JWT"),
295 order: 40,
296 visible_when: Some(jwt_visibility_condition()),
297 generated: None,
298 value_type: RuntimeConfigType::String,
299 default: json!("lenso"),
300 editable: true,
301 restart_only: false,
302 description: "JWT audience claim (aud).",
303 },
304 RuntimeConfigDescriptor {
305 key: "auth-password.jwt_ttl_hours".to_owned(),
306 scope: RuntimeConfigScope::Shared,
307 group: Some("auth-password.tokens"),
308 section: Some("JWT"),
309 order: 50,
310 visible_when: Some(jwt_visibility_condition()),
311 generated: None,
312 value_type: RuntimeConfigType::Int {
313 min: Some(1),
314 max: Some(168),
315 },
316 default: json!(DEFAULT_JWT_TTL_HOURS),
317 editable: true,
318 restart_only: false,
319 description: "JWT time-to-live in hours.",
320 },
321 ]
322});
323
324fn jwt_visibility_condition() -> RuntimeConfigVisibilityCondition {
325 RuntimeConfigVisibilityCondition::Equals {
326 service: "*",
327 key: "auth-password.token_strategy",
328 value: json!("jwt"),
329 }
330}
331
332fn default_argon2_memory_kib() -> u32 {
333 DEFAULT_ARGON2_MEMORY_KIB
334}
335
336fn default_argon2_time_cost() -> u32 {
337 DEFAULT_ARGON2_TIME_COST
338}
339
340fn default_argon2_parallelism() -> u32 {
341 DEFAULT_ARGON2_PARALLELISM
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use platform_core::{RuntimeConfigRegistry, RuntimeConfigSnapshot};
348 use std::collections::BTreeMap;
349
350 #[test]
351 fn reads_defaults_from_snapshot() {
352 let registry = RuntimeConfigRegistry::try_new(RUNTIME_CONFIG.clone()).unwrap();
353 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &BTreeMap::new());
354 let config = AuthPasswordConfig::from_snapshot(&snapshot).unwrap();
355
356 assert_eq!(
357 config,
358 AuthPasswordConfig {
359 hash_algorithm: HashAlgorithm::Argon2id,
360 argon2_memory_kib: DEFAULT_ARGON2_MEMORY_KIB,
361 argon2_time_cost: DEFAULT_ARGON2_TIME_COST,
362 argon2_parallelism: DEFAULT_ARGON2_PARALLELISM,
363 token_strategy: TokenStrategy::Session,
364 jwt_secret: None,
365 jwt_issuer: Some("lenso".to_owned()),
366 jwt_audience: Some("lenso".to_owned()),
367 jwt_ttl_hours: Some(DEFAULT_JWT_TTL_HOURS),
368 }
369 );
370 }
371
372 #[test]
373 fn reads_configured_hash_policy_from_snapshot() {
374 let registry = RuntimeConfigRegistry::try_new(RUNTIME_CONFIG.clone()).unwrap();
375 let mut stored = BTreeMap::new();
376 stored.insert(
377 ("*".to_owned(), "auth-password.hash_algorithm".to_owned()),
378 json!("argon2i"),
379 );
380 stored.insert(
381 ("*".to_owned(), "auth-password.argon2_memory_kib".to_owned()),
382 json!(16384),
383 );
384 stored.insert(
385 ("*".to_owned(), "auth-password.argon2_time_cost".to_owned()),
386 json!(3),
387 );
388 stored.insert(
389 (
390 "*".to_owned(),
391 "auth-password.argon2_parallelism".to_owned(),
392 ),
393 json!(2),
394 );
395 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
396 let config = AuthPasswordConfig::from_snapshot(&snapshot).unwrap();
397
398 assert_eq!(
399 config,
400 AuthPasswordConfig {
401 hash_algorithm: HashAlgorithm::Argon2i,
402 argon2_memory_kib: 16384,
403 argon2_time_cost: 3,
404 argon2_parallelism: 2,
405 token_strategy: TokenStrategy::Session,
406 jwt_secret: None,
407 jwt_issuer: Some("lenso".to_owned()),
408 jwt_audience: Some("lenso".to_owned()),
409 jwt_ttl_hours: Some(DEFAULT_JWT_TTL_HOURS),
410 }
411 );
412 }
413
414 #[test]
415 fn jwt_config_returns_none_when_session_strategy() {
416 let config = AuthPasswordConfig::default();
417 assert!(config.jwt_config().unwrap().is_none());
418 }
419
420 #[test]
421 fn jwt_config_returns_error_when_jwt_without_secret() {
422 let config = AuthPasswordConfig {
423 token_strategy: TokenStrategy::Jwt,
424 ..AuthPasswordConfig::default()
425 };
426 assert!(config.jwt_config().is_err());
427 }
428
429 #[test]
430 fn jwt_config_returns_config_when_jwt_with_secret() {
431 let config = AuthPasswordConfig {
432 token_strategy: TokenStrategy::Jwt,
433 jwt_secret: Some("my-secret".to_owned()),
434 jwt_issuer: Some("custom-issuer".to_owned()),
435 jwt_audience: Some("custom-audience".to_owned()),
436 jwt_ttl_hours: Some(24),
437 ..AuthPasswordConfig::default()
438 };
439 let jwt_config = config.jwt_config().unwrap().unwrap();
440 assert_eq!(jwt_config.secret, "my-secret");
441 assert_eq!(jwt_config.issuer, "custom-issuer");
442 assert_eq!(jwt_config.audience, "custom-audience");
443 assert_eq!(jwt_config.ttl_hours, 24);
444 }
445
446 #[test]
447 fn module_local_jwt_secret_overrides_runtime_config_secret() {
448 let mut config = AuthPasswordConfig {
449 jwt_secret: Some("runtime-secret".to_owned()),
450 ..AuthPasswordConfig::default()
451 };
452 config.apply_module_local_config(&AuthPasswordLocalConfig {
453 jwt_secret: Some("local-secret".to_owned()),
454 });
455
456 assert_eq!(config.jwt_secret.as_deref(), Some("local-secret"));
457 }
458
459 #[test]
460 fn jwt_config_uses_defaults_for_optional_fields() {
461 let config = AuthPasswordConfig {
462 token_strategy: TokenStrategy::Jwt,
463 jwt_secret: Some("secret".to_owned()),
464 ..AuthPasswordConfig::default()
465 };
466 let jwt_config = config.jwt_config().unwrap().unwrap();
467 assert_eq!(jwt_config.issuer, "lenso");
468 assert_eq!(jwt_config.audience, "lenso");
469 assert_eq!(jwt_config.ttl_hours, DEFAULT_JWT_TTL_HOURS);
470 }
471}