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 Self::from_snapshot(&ctx.runtime_config.snapshot())
95 }
96
97 pub fn from_snapshot(snapshot: &RuntimeConfigSnapshot) -> AppResult<Self> {
98 snapshot.get(CONFIG_PREFIX)
99 }
100
101 pub fn argon2_algorithm(&self) -> argon2::Algorithm {
102 match self.hash_algorithm {
103 HashAlgorithm::Argon2id => argon2::Algorithm::Argon2id,
104 HashAlgorithm::Argon2i => argon2::Algorithm::Argon2i,
105 }
106 }
107
108 pub fn jwt_config(&self) -> AppResult<Option<JwtConfig>> {
114 if self.token_strategy != TokenStrategy::Jwt {
115 return Ok(None);
116 }
117
118 let secret = self.jwt_secret.clone().ok_or_else(|| {
119 AppError::validation(
120 "Request validation failed",
121 vec![platform_core::error::ErrorDetail {
122 field: Some("jwt_secret".to_owned()),
123 reason: "jwt_secret is required when token_strategy is jwt".to_owned(),
124 }],
125 )
126 })?;
127
128 Ok(Some(JwtConfig {
129 secret,
130 issuer: self
131 .jwt_issuer
132 .clone()
133 .unwrap_or_else(|| "lenso".to_owned()),
134 audience: self
135 .jwt_audience
136 .clone()
137 .unwrap_or_else(|| "lenso".to_owned()),
138 ttl_hours: self.jwt_ttl_hours.unwrap_or(DEFAULT_JWT_TTL_HOURS),
139 }))
140 }
141}
142
143pub static RUNTIME_CONFIG_GROUPS: LazyLock<Vec<RuntimeConfigGroupDescriptor>> =
144 LazyLock::new(|| {
145 vec![
146 RuntimeConfigGroupDescriptor {
147 id: "auth-password.hashing",
148 label: "Password Hashing",
149 description: "Password hash algorithm and Argon2 parameters.",
150 order: 30,
151 },
152 RuntimeConfigGroupDescriptor {
153 id: "auth-password.tokens",
154 label: "Tokens",
155 description: "Token issuance strategy and JWT settings.",
156 order: 40,
157 },
158 ]
159 });
160
161pub static RUNTIME_CONFIG: LazyLock<Vec<RuntimeConfigDescriptor>> = LazyLock::new(|| {
162 vec![
163 RuntimeConfigDescriptor {
164 key: "auth-password.hash_algorithm".to_owned(),
165 scope: RuntimeConfigScope::Shared,
166 group: Some("auth-password.hashing"),
167 section: None,
168 order: 10,
169 visible_when: None,
170 generated: None,
171 value_type: RuntimeConfigType::Enum(&["argon2id", "argon2i"]),
172 default: json!("argon2id"),
173 editable: true,
174 restart_only: false,
175 description: "Password hash algorithm used for new password hashes.",
176 },
177 RuntimeConfigDescriptor {
178 key: "auth-password.argon2_memory_kib".to_owned(),
179 scope: RuntimeConfigScope::Shared,
180 group: Some("auth-password.hashing"),
181 section: None,
182 order: 20,
183 visible_when: None,
184 generated: None,
185 value_type: RuntimeConfigType::Int {
186 min: Some(MIN_ARGON2_MEMORY_KIB),
187 max: Some(MAX_ARGON2_MEMORY_KIB),
188 },
189 default: json!(DEFAULT_ARGON2_MEMORY_KIB),
190 editable: true,
191 restart_only: false,
192 description: "Argon2 memory cost in KiB for new password hashes.",
193 },
194 RuntimeConfigDescriptor {
195 key: "auth-password.argon2_time_cost".to_owned(),
196 scope: RuntimeConfigScope::Shared,
197 group: Some("auth-password.hashing"),
198 section: None,
199 order: 30,
200 visible_when: None,
201 generated: None,
202 value_type: RuntimeConfigType::Int {
203 min: Some(i64::from(argon2::Params::MIN_T_COST)),
204 max: Some(MAX_ARGON2_TIME_COST),
205 },
206 default: json!(DEFAULT_ARGON2_TIME_COST),
207 editable: true,
208 restart_only: false,
209 description: "Argon2 iteration count for new password hashes.",
210 },
211 RuntimeConfigDescriptor {
212 key: "auth-password.argon2_parallelism".to_owned(),
213 scope: RuntimeConfigScope::Shared,
214 group: Some("auth-password.hashing"),
215 section: None,
216 order: 40,
217 visible_when: None,
218 generated: None,
219 value_type: RuntimeConfigType::Int {
220 min: Some(i64::from(argon2::Params::MIN_P_COST)),
221 max: Some(MAX_ARGON2_PARALLELISM),
222 },
223 default: json!(DEFAULT_ARGON2_PARALLELISM),
224 editable: true,
225 restart_only: false,
226 description: "Argon2 parallelism for new password hashes.",
227 },
228 RuntimeConfigDescriptor {
229 key: "auth-password.token_strategy".to_owned(),
230 scope: RuntimeConfigScope::Shared,
231 group: Some("auth-password.tokens"),
232 section: Some("Issuance"),
233 order: 10,
234 visible_when: None,
235 generated: None,
236 value_type: RuntimeConfigType::Enum(&["session", "jwt"]),
237 default: json!("session"),
238 editable: true,
239 restart_only: true,
240 description: "Token issuance strategy: session (stateful, DB-backed) or jwt (stateless, self-contained).",
241 },
242 RuntimeConfigDescriptor {
243 key: "auth-password.jwt_secret".to_owned(),
244 scope: RuntimeConfigScope::Shared,
245 group: Some("auth-password.tokens"),
246 section: Some("JWT"),
247 order: 20,
248 visible_when: Some(jwt_visibility_condition()),
249 generated: Some(RuntimeConfigGeneratedValue::Secret {
250 bytes: 32,
251 when: jwt_visibility_condition(),
252 }),
253 value_type: RuntimeConfigType::String,
254 default: json!(null),
255 editable: true,
256 restart_only: true,
257 description: "HMAC-SHA256 secret for JWT signing. Required when token_strategy is jwt.",
258 },
259 RuntimeConfigDescriptor {
260 key: "auth-password.jwt_issuer".to_owned(),
261 scope: RuntimeConfigScope::Shared,
262 group: Some("auth-password.tokens"),
263 section: Some("JWT"),
264 order: 30,
265 visible_when: Some(jwt_visibility_condition()),
266 generated: None,
267 value_type: RuntimeConfigType::String,
268 default: json!("lenso"),
269 editable: true,
270 restart_only: false,
271 description: "JWT issuer claim (iss).",
272 },
273 RuntimeConfigDescriptor {
274 key: "auth-password.jwt_audience".to_owned(),
275 scope: RuntimeConfigScope::Shared,
276 group: Some("auth-password.tokens"),
277 section: Some("JWT"),
278 order: 40,
279 visible_when: Some(jwt_visibility_condition()),
280 generated: None,
281 value_type: RuntimeConfigType::String,
282 default: json!("lenso"),
283 editable: true,
284 restart_only: false,
285 description: "JWT audience claim (aud).",
286 },
287 RuntimeConfigDescriptor {
288 key: "auth-password.jwt_ttl_hours".to_owned(),
289 scope: RuntimeConfigScope::Shared,
290 group: Some("auth-password.tokens"),
291 section: Some("JWT"),
292 order: 50,
293 visible_when: Some(jwt_visibility_condition()),
294 generated: None,
295 value_type: RuntimeConfigType::Int {
296 min: Some(1),
297 max: Some(168),
298 },
299 default: json!(DEFAULT_JWT_TTL_HOURS),
300 editable: true,
301 restart_only: false,
302 description: "JWT time-to-live in hours.",
303 },
304 ]
305});
306
307fn jwt_visibility_condition() -> RuntimeConfigVisibilityCondition {
308 RuntimeConfigVisibilityCondition::Equals {
309 service: "*",
310 key: "auth-password.token_strategy",
311 value: json!("jwt"),
312 }
313}
314
315fn default_argon2_memory_kib() -> u32 {
316 DEFAULT_ARGON2_MEMORY_KIB
317}
318
319fn default_argon2_time_cost() -> u32 {
320 DEFAULT_ARGON2_TIME_COST
321}
322
323fn default_argon2_parallelism() -> u32 {
324 DEFAULT_ARGON2_PARALLELISM
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use platform_core::{RuntimeConfigRegistry, RuntimeConfigSnapshot};
331 use std::collections::BTreeMap;
332
333 #[test]
334 fn reads_defaults_from_snapshot() {
335 let registry = RuntimeConfigRegistry::try_new(RUNTIME_CONFIG.clone()).unwrap();
336 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &BTreeMap::new());
337 let config = AuthPasswordConfig::from_snapshot(&snapshot).unwrap();
338
339 assert_eq!(
340 config,
341 AuthPasswordConfig {
342 hash_algorithm: HashAlgorithm::Argon2id,
343 argon2_memory_kib: DEFAULT_ARGON2_MEMORY_KIB,
344 argon2_time_cost: DEFAULT_ARGON2_TIME_COST,
345 argon2_parallelism: DEFAULT_ARGON2_PARALLELISM,
346 token_strategy: TokenStrategy::Session,
347 jwt_secret: None,
348 jwt_issuer: Some("lenso".to_owned()),
349 jwt_audience: Some("lenso".to_owned()),
350 jwt_ttl_hours: Some(DEFAULT_JWT_TTL_HOURS),
351 }
352 );
353 }
354
355 #[test]
356 fn reads_configured_hash_policy_from_snapshot() {
357 let registry = RuntimeConfigRegistry::try_new(RUNTIME_CONFIG.clone()).unwrap();
358 let mut stored = BTreeMap::new();
359 stored.insert(
360 ("*".to_owned(), "auth-password.hash_algorithm".to_owned()),
361 json!("argon2i"),
362 );
363 stored.insert(
364 ("*".to_owned(), "auth-password.argon2_memory_kib".to_owned()),
365 json!(16384),
366 );
367 stored.insert(
368 ("*".to_owned(), "auth-password.argon2_time_cost".to_owned()),
369 json!(3),
370 );
371 stored.insert(
372 (
373 "*".to_owned(),
374 "auth-password.argon2_parallelism".to_owned(),
375 ),
376 json!(2),
377 );
378 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
379 let config = AuthPasswordConfig::from_snapshot(&snapshot).unwrap();
380
381 assert_eq!(
382 config,
383 AuthPasswordConfig {
384 hash_algorithm: HashAlgorithm::Argon2i,
385 argon2_memory_kib: 16384,
386 argon2_time_cost: 3,
387 argon2_parallelism: 2,
388 token_strategy: TokenStrategy::Session,
389 jwt_secret: None,
390 jwt_issuer: Some("lenso".to_owned()),
391 jwt_audience: Some("lenso".to_owned()),
392 jwt_ttl_hours: Some(DEFAULT_JWT_TTL_HOURS),
393 }
394 );
395 }
396
397 #[test]
398 fn jwt_config_returns_none_when_session_strategy() {
399 let config = AuthPasswordConfig::default();
400 assert!(config.jwt_config().unwrap().is_none());
401 }
402
403 #[test]
404 fn jwt_config_returns_error_when_jwt_without_secret() {
405 let config = AuthPasswordConfig {
406 token_strategy: TokenStrategy::Jwt,
407 ..AuthPasswordConfig::default()
408 };
409 assert!(config.jwt_config().is_err());
410 }
411
412 #[test]
413 fn jwt_config_returns_config_when_jwt_with_secret() {
414 let config = AuthPasswordConfig {
415 token_strategy: TokenStrategy::Jwt,
416 jwt_secret: Some("my-secret".to_owned()),
417 jwt_issuer: Some("custom-issuer".to_owned()),
418 jwt_audience: Some("custom-audience".to_owned()),
419 jwt_ttl_hours: Some(24),
420 ..AuthPasswordConfig::default()
421 };
422 let jwt_config = config.jwt_config().unwrap().unwrap();
423 assert_eq!(jwt_config.secret, "my-secret");
424 assert_eq!(jwt_config.issuer, "custom-issuer");
425 assert_eq!(jwt_config.audience, "custom-audience");
426 assert_eq!(jwt_config.ttl_hours, 24);
427 }
428
429 #[test]
430 fn jwt_config_uses_defaults_for_optional_fields() {
431 let config = AuthPasswordConfig {
432 token_strategy: TokenStrategy::Jwt,
433 jwt_secret: Some("secret".to_owned()),
434 ..AuthPasswordConfig::default()
435 };
436 let jwt_config = config.jwt_config().unwrap().unwrap();
437 assert_eq!(jwt_config.issuer, "lenso");
438 assert_eq!(jwt_config.audience, "lenso");
439 assert_eq!(jwt_config.ttl_hours, DEFAULT_JWT_TTL_HOURS);
440 }
441}