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/// JWT bearer settings from the `auth.jwt` section.
18#[derive(Debug, Clone, Deserialize)]
19pub struct JwtConfig {
20    pub secret: String,
21    #[serde(default = "default_access_ttl")]
22    pub access_ttl: u64,
23    #[serde(default = "default_refresh_ttl")]
24    pub refresh_ttl: u64,
25    #[serde(default)]
26    pub issuer: Option<String>,
27}
28
29fn default_access_ttl() -> u64 {
30    900
31}
32
33fn default_refresh_ttl() -> u64 {
34    604_800
35}
36
37impl JwtConfig {
38    pub fn validate(&self) -> Result<(), AuthError> {
39        if self.secret.trim().is_empty() {
40            return Err(AuthError::Config(
41                "auth.jwt.secret must not be empty".into(),
42            ));
43        }
44        Ok(())
45    }
46}
47
48/// OAuth provider type.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum OAuthProviderType {
52    Oauth1,
53    Oauth2,
54}
55
56/// One OAuth/OAuth2 provider entry under `auth.oauth`.
57#[derive(Debug, Clone, Deserialize)]
58pub struct OAuthProviderConfig {
59    #[serde(rename = "type")]
60    pub provider_type: OAuthProviderType,
61    #[serde(default)]
62    pub client_id: Option<String>,
63    #[serde(default)]
64    pub client_secret: Option<String>,
65    #[serde(default)]
66    pub consumer_key: Option<String>,
67    #[serde(default)]
68    pub consumer_secret: Option<String>,
69    #[serde(default)]
70    pub redirect_uri: Option<String>,
71    #[serde(default)]
72    pub scopes: Vec<String>,
73    #[serde(default)]
74    pub authorize_url: Option<String>,
75    #[serde(default)]
76    pub token_url: Option<String>,
77}
78
79/// Two-factor settings from `auth.two_factor`.
80#[derive(Debug, Clone, Default, Deserialize)]
81pub struct TwoFactorConfig {
82    #[serde(default)]
83    pub enabled: bool,
84    #[serde(default)]
85    pub issuer: Option<String>,
86}
87
88/// Devise-style route prefix and path segments.
89#[derive(Debug, Clone, Deserialize)]
90pub struct AuthRoutesConfig {
91    #[serde(default = "default_prefix")]
92    pub prefix: String,
93    #[serde(default = "default_sign_in")]
94    pub sign_in: String,
95    #[serde(default = "default_sign_out")]
96    pub sign_out: String,
97    #[serde(default = "default_sign_up")]
98    pub sign_up: String,
99    #[serde(default = "default_password_reset")]
100    pub password_reset: String,
101}
102
103fn default_prefix() -> String {
104    "/users".into()
105}
106
107fn default_sign_in() -> String {
108    "sign_in".into()
109}
110
111fn default_sign_out() -> String {
112    "sign_out".into()
113}
114
115fn default_sign_up() -> String {
116    "sign_up".into()
117}
118
119fn default_password_reset() -> String {
120    "password".into()
121}
122
123impl Default for AuthRoutesConfig {
124    fn default() -> Self {
125        Self {
126            prefix: default_prefix(),
127            sign_in: default_sign_in(),
128            sign_out: default_sign_out(),
129            sign_up: default_sign_up(),
130            password_reset: default_password_reset(),
131        }
132    }
133}
134
135impl AuthRoutesConfig {
136    pub fn sign_in_path(&self) -> String {
137        format!("{}/{}", self.prefix.trim_end_matches('/'), self.sign_in)
138    }
139
140    pub fn sign_out_path(&self) -> String {
141        format!("{}/{}", self.prefix.trim_end_matches('/'), self.sign_out)
142    }
143
144    pub fn sign_up_path(&self) -> String {
145        format!("{}/{}", self.prefix.trim_end_matches('/'), self.sign_up)
146    }
147
148    pub fn password_path(&self) -> String {
149        format!(
150            "{}/{}",
151            self.prefix.trim_end_matches('/'),
152            self.password_reset
153        )
154    }
155}
156
157/// Full auth configuration deserialized from the `auth` section.
158#[derive(Debug, Clone, Deserialize)]
159pub struct AuthConfig {
160    #[serde(default)]
161    pub user_model: Option<String>,
162    #[serde(default = "default_strategies")]
163    pub strategies: Vec<String>,
164    #[serde(default)]
165    pub jwt: Option<JwtConfig>,
166    #[serde(default)]
167    pub oauth: HashMap<String, OAuthProviderConfig>,
168    #[serde(default)]
169    pub two_factor: TwoFactorConfig,
170    #[serde(default)]
171    pub routes: AuthRoutesConfig,
172}
173
174fn default_strategies() -> Vec<String> {
175    vec!["cookie".into()]
176}
177
178impl Default for AuthConfig {
179    fn default() -> Self {
180        Self {
181            user_model: None,
182            strategies: default_strategies(),
183            jwt: None,
184            oauth: HashMap::new(),
185            two_factor: TwoFactorConfig::default(),
186            routes: AuthRoutesConfig::default(),
187        }
188    }
189}
190
191impl AuthConfig {
192    /// Parse from a YAML string containing an `auth:` section (other sections ignored).
193    pub fn from_yaml(yaml: &str) -> Result<Self, std::io::Error> {
194        YamlConfig::from_yaml(yaml).map(|c| c.auth)
195    }
196
197    /// Validate required secrets and strategy-specific settings.
198    pub fn validate(&self) -> Result<(), AuthError> {
199        for name in &self.strategies {
200            match name.as_str() {
201                "cookie" => {}
202                "jwt" => {
203                    let jwt = self.jwt.as_ref().ok_or_else(|| {
204                        AuthError::Config(
205                            "auth.jwt section required when jwt strategy is enabled".into(),
206                        )
207                    })?;
208                    jwt.validate()?;
209                }
210                other => {
211                    if !crate::registry::has_strategy(other) {
212                        return Err(AuthError::UnknownStrategy(other.to_string()));
213                    }
214                }
215            }
216        }
217        Ok(())
218    }
219
220    pub fn strategy_kinds(&self) -> Vec<StrategyKind> {
221        self.strategies
222            .iter()
223            .filter_map(|s| match s.as_str() {
224                "cookie" => Some(StrategyKind::Cookie),
225                "jwt" => Some(StrategyKind::Jwt),
226                _ => None,
227            })
228            .collect()
229    }
230}
231
232/// File-based config wrapper — only the `auth` section is read.
233#[derive(Debug, Clone, Default, Deserialize)]
234pub struct YamlConfig {
235    #[serde(default)]
236    pub auth: AuthConfig,
237}
238
239impl YamlConfig {
240    pub fn load() -> std::io::Result<Self> {
241        Self::load_env(Environment::get_env())
242    }
243
244    pub fn load_env(env: Environment) -> std::io::Result<Self> {
245        let path = format!("config/{}.yml", env.as_str());
246        let contents = std::fs::read_to_string(&path)?;
247        Self::from_yaml(&contents)
248    }
249
250    pub fn from_yaml(yaml: &str) -> std::io::Result<Self> {
251        serde_norway::from_str(yaml)
252            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
253    }
254}
255
256/// Loads the current environment's [`AuthConfig`], defaulting when missing.
257pub fn load() -> AuthConfig {
258    YamlConfig::load().map(|c| c.auth).unwrap_or_default()
259}