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        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    /// Returns a fully resolved [`JwtConfig`] when `token_strategy` is [`TokenStrategy::Jwt`].
109    ///
110    /// Returns an error if JWT is selected but `jwt_secret` is missing.
111    /// Falls back to defaults for optional fields: issuer `"lenso"`, audience `"lenso"`,
112    /// TTL 1 hour.
113    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(&registry, "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(&registry, "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}