Skip to main content

auth_password/
config.rs

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/// Token issuance strategy for auth-password.
37///
38/// - `Session`: create a database-backed session token (default).
39/// - `Jwt`: issue a stateless JWT instead of a session — no row in `auth.sessions`.
40#[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    /// Returns a fully resolved [`JwtConfig`] when `token_strategy` is [`TokenStrategy::Jwt`].
120    ///
121    /// Returns an error if JWT is selected but `jwt_secret` is missing.
122    /// Falls back to defaults for optional fields: issuer `"lenso"`, audience `"lenso"`,
123    /// TTL 1 hour.
124    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(&registry, "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(&registry, "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}