Skip to main content

doido_auth/
config.rs

1//! `auth:` section of `config/<env>.yml` → [`AuthConfig`].
2
3use crate::error::AuthError;
4use doido_core::Environment;
5use serde::Deserialize;
6use std::collections::HashMap;
7
8/// Which auth strategies are enabled (consulted in order by extractors/layer).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
10#[serde(rename_all = "snake_case")]
11pub enum StrategyKind {
12    #[default]
13    Cookie,
14    Jwt,
15}
16
17/// Devise-style auth modules, declared under `auth.modules` in `config/<env>.yml`.
18///
19/// A module toggles a coherent feature — its routes (via the generated
20/// `auth_routes!` `only:` list), its migration columns, and its runtime behavior.
21/// `strategies` (cookie/jwt) are orthogonal: they decide *how* a request is
22/// authenticated, while modules decide *which* Devise features are active.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum AuthModule {
26    /// Password authentication (email + `password_digest`). Effectively required.
27    DatabaseAuthenticatable,
28    /// Sign-up / account registration (`registrations` routes).
29    Registerable,
30    /// Password reset via emailed token (`passwords` routes).
31    Recoverable,
32    /// "Remember me" persistent cookie (`remember_created_at`).
33    Rememberable,
34    /// Sign-in tracking (count, timestamps, IPs).
35    Trackable,
36    /// Idle session expiry after `auth.timeout` seconds.
37    Timeoutable,
38    /// Email/password format + length validation on registration.
39    Validatable,
40    /// Email confirmation before sign-in (`confirmation` routes).
41    Confirmable,
42    /// Lock an account after repeated failed sign-ins (`unlock` routes).
43    Lockable,
44    /// OAuth / social sign-in (`oauth` routes).
45    Omniauthable,
46    /// TOTP two-factor authentication (requires the `auth-2fa` feature).
47    TwoFactorAuthenticatable,
48}
49
50impl AuthModule {
51    /// All modules, in declaration order.
52    pub const ALL: [AuthModule; 11] = [
53        AuthModule::DatabaseAuthenticatable,
54        AuthModule::Registerable,
55        AuthModule::Recoverable,
56        AuthModule::Rememberable,
57        AuthModule::Trackable,
58        AuthModule::Timeoutable,
59        AuthModule::Validatable,
60        AuthModule::Confirmable,
61        AuthModule::Lockable,
62        AuthModule::Omniauthable,
63        AuthModule::TwoFactorAuthenticatable,
64    ];
65
66    /// The snake_case name used in config and generator flags.
67    pub fn as_str(self) -> &'static str {
68        match self {
69            AuthModule::DatabaseAuthenticatable => "database_authenticatable",
70            AuthModule::Registerable => "registerable",
71            AuthModule::Recoverable => "recoverable",
72            AuthModule::Rememberable => "rememberable",
73            AuthModule::Trackable => "trackable",
74            AuthModule::Timeoutable => "timeoutable",
75            AuthModule::Validatable => "validatable",
76            AuthModule::Confirmable => "confirmable",
77            AuthModule::Lockable => "lockable",
78            AuthModule::Omniauthable => "omniauthable",
79            AuthModule::TwoFactorAuthenticatable => "two_factor_authenticatable",
80        }
81    }
82
83    /// Parse a module from its snake_case name.
84    pub fn from_name(s: &str) -> Option<AuthModule> {
85        AuthModule::ALL.into_iter().find(|m| m.as_str() == s)
86    }
87
88    /// The `auth_routes!` route-group name this module mounts, if any.
89    /// Behavior-only modules (trackable, timeoutable, rememberable, validatable,
90    /// database_authenticatable) return `None` — they add no dedicated routes.
91    pub fn route_group(self) -> Option<&'static str> {
92        match self {
93            AuthModule::Registerable => Some("registrations"),
94            AuthModule::Recoverable => Some("passwords"),
95            AuthModule::Confirmable => Some("confirmation"),
96            AuthModule::Lockable => Some("unlock"),
97            AuthModule::Omniauthable => Some("oauth"),
98            AuthModule::TwoFactorAuthenticatable => Some("two_factor"),
99            _ => None,
100        }
101    }
102}
103
104/// JWT bearer settings from the `auth.jwt` section.
105#[derive(Debug, Clone, Deserialize)]
106pub struct JwtConfig {
107    pub secret: String,
108    #[serde(default = "default_access_ttl")]
109    pub access_ttl: u64,
110    #[serde(default = "default_refresh_ttl")]
111    pub refresh_ttl: u64,
112    #[serde(default)]
113    pub issuer: Option<String>,
114}
115
116fn default_access_ttl() -> u64 {
117    900
118}
119
120fn default_refresh_ttl() -> u64 {
121    604_800
122}
123
124impl JwtConfig {
125    pub fn validate(&self) -> Result<(), AuthError> {
126        if self.secret.trim().is_empty() {
127            return Err(AuthError::Config(
128                "auth.jwt.secret must not be empty".into(),
129            ));
130        }
131        Ok(())
132    }
133}
134
135/// OAuth provider type.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum OAuthProviderType {
139    Oauth1,
140    Oauth2,
141}
142
143/// One OAuth/OAuth2 provider entry under `auth.oauth`.
144#[derive(Debug, Clone, Deserialize)]
145pub struct OAuthProviderConfig {
146    #[serde(rename = "type")]
147    pub provider_type: OAuthProviderType,
148    #[serde(default)]
149    pub client_id: Option<String>,
150    #[serde(default)]
151    pub client_secret: Option<String>,
152    #[serde(default)]
153    pub consumer_key: Option<String>,
154    #[serde(default)]
155    pub consumer_secret: Option<String>,
156    #[serde(default)]
157    pub redirect_uri: Option<String>,
158    #[serde(default)]
159    pub scopes: Vec<String>,
160    #[serde(default)]
161    pub authorize_url: Option<String>,
162    #[serde(default)]
163    pub token_url: Option<String>,
164}
165
166/// Two-factor settings from `auth.two_factor`.
167#[derive(Debug, Clone, Default, Deserialize)]
168pub struct TwoFactorConfig {
169    #[serde(default)]
170    pub enabled: bool,
171    #[serde(default)]
172    pub issuer: Option<String>,
173}
174
175/// Devise-style route prefix and path segments.
176#[derive(Debug, Clone, Deserialize)]
177pub struct AuthRoutesConfig {
178    #[serde(default = "default_prefix")]
179    pub prefix: String,
180    #[serde(default = "default_sign_in")]
181    pub sign_in: String,
182    #[serde(default = "default_sign_out")]
183    pub sign_out: String,
184    #[serde(default = "default_sign_up")]
185    pub sign_up: String,
186    #[serde(default = "default_password_reset")]
187    pub password_reset: String,
188}
189
190fn default_prefix() -> String {
191    "/users".into()
192}
193
194fn default_sign_in() -> String {
195    "sign_in".into()
196}
197
198fn default_sign_out() -> String {
199    "sign_out".into()
200}
201
202fn default_sign_up() -> String {
203    "sign_up".into()
204}
205
206fn default_password_reset() -> String {
207    "password".into()
208}
209
210impl Default for AuthRoutesConfig {
211    fn default() -> Self {
212        Self {
213            prefix: default_prefix(),
214            sign_in: default_sign_in(),
215            sign_out: default_sign_out(),
216            sign_up: default_sign_up(),
217            password_reset: default_password_reset(),
218        }
219    }
220}
221
222impl AuthRoutesConfig {
223    pub fn sign_in_path(&self) -> String {
224        format!("{}/{}", self.prefix.trim_end_matches('/'), self.sign_in)
225    }
226
227    pub fn sign_out_path(&self) -> String {
228        format!("{}/{}", self.prefix.trim_end_matches('/'), self.sign_out)
229    }
230
231    pub fn sign_up_path(&self) -> String {
232        format!("{}/{}", self.prefix.trim_end_matches('/'), self.sign_up)
233    }
234
235    pub fn password_path(&self) -> String {
236        format!(
237            "{}/{}",
238            self.prefix.trim_end_matches('/'),
239            self.password_reset
240        )
241    }
242}
243
244/// Full auth configuration deserialized from the `auth` section.
245#[derive(Debug, Clone, Deserialize)]
246pub struct AuthConfig {
247    #[serde(default)]
248    pub user_model: Option<String>,
249    #[serde(default = "default_modules")]
250    pub modules: Vec<AuthModule>,
251    #[serde(default = "default_strategies")]
252    pub strategies: Vec<String>,
253    #[serde(default)]
254    pub jwt: Option<JwtConfig>,
255    #[serde(default)]
256    pub oauth: HashMap<String, OAuthProviderConfig>,
257    #[serde(default)]
258    pub two_factor: TwoFactorConfig,
259    /// Idle-session timeout in seconds for the `timeoutable` module.
260    #[serde(default = "default_timeout")]
261    pub timeout: u64,
262    /// Minimum password length enforced by the `validatable` module.
263    #[serde(default = "default_password_length")]
264    pub password_length: usize,
265    /// Failed sign-in attempts before `lockable` locks an account.
266    #[serde(default = "default_maximum_attempts")]
267    pub maximum_attempts: u32,
268    /// Seconds a `lockable` account stays locked before auto-unlocking.
269    #[serde(default = "default_unlock_in")]
270    pub unlock_in: i64,
271    /// Seconds a `recoverable` password-reset token stays valid.
272    #[serde(default = "default_reset_within")]
273    pub reset_password_within: i64,
274    /// Seconds a `rememberable` "remember me" cookie persists.
275    #[serde(default = "default_remember_for")]
276    pub remember_for: i64,
277    #[serde(default)]
278    pub routes: AuthRoutesConfig,
279}
280
281fn default_strategies() -> Vec<String> {
282    vec!["cookie".into()]
283}
284
285/// Devise's default module set for a generated model.
286fn default_modules() -> Vec<AuthModule> {
287    vec![
288        AuthModule::DatabaseAuthenticatable,
289        AuthModule::Registerable,
290        AuthModule::Recoverable,
291        AuthModule::Rememberable,
292        AuthModule::Validatable,
293    ]
294}
295
296fn default_timeout() -> u64 {
297    1_800
298}
299
300fn default_password_length() -> usize {
301    6
302}
303
304fn default_maximum_attempts() -> u32 {
305    20
306}
307
308fn default_unlock_in() -> i64 {
309    3_600
310}
311
312fn default_reset_within() -> i64 {
313    21_600
314}
315
316fn default_remember_for() -> i64 {
317    1_209_600
318}
319
320impl Default for AuthConfig {
321    fn default() -> Self {
322        Self {
323            user_model: None,
324            modules: default_modules(),
325            strategies: default_strategies(),
326            jwt: None,
327            oauth: HashMap::new(),
328            two_factor: TwoFactorConfig::default(),
329            timeout: default_timeout(),
330            password_length: default_password_length(),
331            maximum_attempts: default_maximum_attempts(),
332            unlock_in: default_unlock_in(),
333            reset_password_within: default_reset_within(),
334            remember_for: default_remember_for(),
335            routes: AuthRoutesConfig::default(),
336        }
337    }
338}
339
340impl AuthConfig {
341    /// Parse from a YAML string containing an `auth:` section (other sections ignored).
342    pub fn from_yaml(yaml: &str) -> Result<Self, std::io::Error> {
343        YamlConfig::from_yaml(yaml).map(|c| c.auth)
344    }
345
346    /// Returns whether `module` is enabled.
347    pub fn has_module(&self, module: AuthModule) -> bool {
348        self.modules.contains(&module)
349    }
350
351    /// The `auth_routes!` route-group names for the enabled modules, in a stable
352    /// order (`sessions` is always present for `database_authenticatable`). Used
353    /// to generate the `only:` list and to gate the runtime route mounter.
354    pub fn enabled_route_groups(&self) -> Vec<&'static str> {
355        let mut groups = vec!["sessions"];
356        for module in AuthModule::ALL {
357            if self.has_module(module) {
358                if let Some(group) = module.route_group() {
359                    groups.push(group);
360                }
361            }
362        }
363        groups
364    }
365
366    /// Validate required secrets, strategy-specific settings, and module coherence.
367    pub fn validate(&self) -> Result<(), AuthError> {
368        for name in &self.strategies {
369            match name.as_str() {
370                "cookie" => {}
371                "jwt" => {
372                    let jwt = self.jwt.as_ref().ok_or_else(|| {
373                        AuthError::Config(
374                            "auth.jwt section required when jwt strategy is enabled".into(),
375                        )
376                    })?;
377                    jwt.validate()?;
378                }
379                other => {
380                    if !crate::registry::has_strategy(other) {
381                        return Err(AuthError::UnknownStrategy(other.to_string()));
382                    }
383                }
384            }
385        }
386
387        if !self.has_module(AuthModule::DatabaseAuthenticatable) {
388            return Err(AuthError::Config(
389                "auth.modules must include database_authenticatable".into(),
390            ));
391        }
392        if self.has_module(AuthModule::TwoFactorAuthenticatable) && !cfg!(feature = "auth-2fa") {
393            return Err(AuthError::Config(
394                "auth.modules includes two_factor_authenticatable but the `auth-2fa` feature is not enabled".into(),
395            ));
396        }
397
398        Ok(())
399    }
400
401    pub fn strategy_kinds(&self) -> Vec<StrategyKind> {
402        self.strategies
403            .iter()
404            .filter_map(|s| match s.as_str() {
405                "cookie" => Some(StrategyKind::Cookie),
406                "jwt" => Some(StrategyKind::Jwt),
407                _ => None,
408            })
409            .collect()
410    }
411}
412
413/// File-based config wrapper — only the `auth` section is read.
414#[derive(Debug, Clone, Default, Deserialize)]
415pub struct YamlConfig {
416    #[serde(default)]
417    pub auth: AuthConfig,
418}
419
420impl YamlConfig {
421    pub fn load() -> std::io::Result<Self> {
422        Self::load_env(Environment::get_env())
423    }
424
425    pub fn load_env(env: Environment) -> std::io::Result<Self> {
426        let path = format!("config/{}.yml", env.as_str());
427        let contents = std::fs::read_to_string(&path)?;
428        Self::from_yaml(&contents)
429    }
430
431    pub fn from_yaml(yaml: &str) -> std::io::Result<Self> {
432        serde_norway::from_str(yaml)
433            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
434    }
435}
436
437/// Loads the current environment's [`AuthConfig`], defaulting when missing.
438pub fn load() -> AuthConfig {
439    YamlConfig::load().map(|c| c.auth).unwrap_or_default()
440}