arium/config.rs
1//! Configuration object the consumer hands to [`crate::install`].
2//!
3//! Built explicitly via the [`AuthConfig::builder`] entry point — env-var
4//! parsing only happens inside the optional convenience constructors that
5//! consumers can opt into (e.g. `Mailer::from_env`, `GithubProvider::from_env`).
6
7use chrono::Duration;
8
9use crate::pool::Pool;
10
11#[cfg(feature = "mail")]
12use crate::mail::Mailer;
13
14#[cfg(feature = "_oauth-core")]
15use crate::oauth::{OAuthProvider, OAuthRegistry};
16
17/// Rate-limit settings applied to the entire router. See [`crate::install`].
18#[cfg(feature = "ratelimit")]
19#[derive(Debug, Clone)]
20pub struct RateLimitConfig {
21 /// Number of requests allowed without delay before throttling kicks in.
22 pub burst: u32,
23 /// Sustained refill rate (requests per second per IP).
24 pub per_second: u64,
25}
26
27#[cfg(feature = "ratelimit")]
28impl Default for RateLimitConfig {
29 fn default() -> Self {
30 Self {
31 burst: 30,
32 per_second: 1,
33 }
34 }
35}
36
37/// Audit-log capture/retention settings. Wired into the audit emitter and
38/// the background prune task started by [`crate::install`].
39#[derive(Debug, Clone)]
40pub struct AuditConfig {
41 /// Persist client IP address with every event.
42 pub capture_ip: bool,
43 /// Persist client `User-Agent` header with every event.
44 pub capture_user_agent: bool,
45 /// Delete events older than this. Set to `0` to keep events forever
46 /// (the periodic prune task becomes a no-op).
47 pub retention_days: u64,
48}
49
50impl Default for AuditConfig {
51 fn default() -> Self {
52 Self {
53 capture_ip: true,
54 capture_user_agent: true,
55 retention_days: 90,
56 }
57 }
58}
59
60/// Everything [`crate::install`] needs to wire the auth engine onto an
61/// `axum::Router` — independent of any UI framework.
62#[derive(Clone)]
63pub struct AuthConfig {
64 pub(crate) pool: Pool,
65 #[cfg(feature = "mail")]
66 pub(crate) mailer: Mailer,
67 #[cfg(feature = "_oauth-core")]
68 pub(crate) oauth: OAuthRegistry,
69 pub(crate) session_lifetime: Duration,
70 pub(crate) session_max_lifetime: Duration,
71 pub(crate) cookie_max_age: Duration,
72 #[cfg(feature = "ratelimit")]
73 pub(crate) rate_limit: Option<RateLimitConfig>,
74 pub(crate) session_table_name: String,
75 pub(crate) audit: AuditConfig,
76 /// `Strict-Transport-Security` value, or `None` to omit the header.
77 /// Off by default — only meaningful over HTTPS and pins the domain to
78 /// HTTPS once a browser sees it, which is painful on plain-HTTP dev.
79 pub(crate) hsts: Option<String>,
80 /// `Content-Security-Policy` value, or `None` to omit the header. Off by
81 /// default because a wrong policy breaks Dioxus' wasm hydration; supply a
82 /// tuned value per app (see [`AuthConfigBuilder::content_security_policy`]).
83 pub(crate) csp: Option<String>,
84 /// Add `Secure` to the session cookie so browsers only send it over HTTPS.
85 /// `false` by default so plain-HTTP `localhost` dev still works; turn it on
86 /// in production (see [`AuthConfigBuilder::cookie_secure`]).
87 pub(crate) cookie_secure: bool,
88 /// App-registered resource-authority impl. When present, [`crate::install`]
89 /// layers it as the `Arc<dyn ResourceAuthority>` extension that
90 /// [`require_resource`](crate::authz::require_resource) and the adapters'
91 /// resource gates read. `None` leaves per-resource authz unwired.
92 pub(crate) resource_authority: Option<crate::authz::SharedResourceAuthority>,
93}
94
95/// A conservative `Strict-Transport-Security` value (2 years, subdomains,
96/// preload-eligible) to pass to [`AuthConfigBuilder::hsts`] in production.
97/// Only set this once you're certain every subdomain is HTTPS-only.
98pub const RECOMMENDED_HSTS: &str = "max-age=63072000; includeSubDomains; preload";
99
100impl AuthConfig {
101 /// Start a new builder. With the `mail` feature `pool` AND `mailer` are
102 /// required; without `mail` only `pool` is taken.
103 #[cfg(feature = "mail")]
104 pub fn builder(pool: Pool, mailer: Mailer) -> AuthConfigBuilder {
105 AuthConfigBuilder {
106 pool,
107 mailer,
108 #[cfg(feature = "_oauth-core")]
109 oauth: None,
110 session_lifetime: Duration::hours(2),
111 session_max_lifetime: Duration::days(30),
112 cookie_max_age: Duration::days(30),
113 #[cfg(feature = "ratelimit")]
114 rate_limit: Some(RateLimitConfig::default()),
115 session_table_name: "arium_sessions".to_string(),
116 audit: AuditConfig::default(),
117 hsts: None,
118 csp: None,
119 cookie_secure: false,
120 resource_authority: None,
121 }
122 }
123
124 /// Start a new builder without the `mail` feature compiled in.
125 #[cfg(not(feature = "mail"))]
126 pub fn builder(pool: Pool) -> AuthConfigBuilder {
127 AuthConfigBuilder {
128 pool,
129 #[cfg(feature = "_oauth-core")]
130 oauth: None,
131 session_lifetime: Duration::hours(2),
132 session_max_lifetime: Duration::days(30),
133 cookie_max_age: Duration::days(30),
134 #[cfg(feature = "ratelimit")]
135 rate_limit: Some(RateLimitConfig::default()),
136 session_table_name: "arium_sessions".to_string(),
137 audit: AuditConfig::default(),
138 hsts: None,
139 csp: None,
140 cookie_secure: false,
141 resource_authority: None,
142 }
143 }
144}
145
146/// Builder for [`AuthConfig`]. All methods consume + return `Self`.
147pub struct AuthConfigBuilder {
148 pool: Pool,
149 #[cfg(feature = "mail")]
150 mailer: Mailer,
151 #[cfg(feature = "_oauth-core")]
152 oauth: Option<OAuthRegistry>,
153 session_lifetime: Duration,
154 session_max_lifetime: Duration,
155 cookie_max_age: Duration,
156 #[cfg(feature = "ratelimit")]
157 rate_limit: Option<RateLimitConfig>,
158 session_table_name: String,
159 audit: AuditConfig,
160 hsts: Option<String>,
161 csp: Option<String>,
162 cookie_secure: bool,
163 resource_authority: Option<crate::authz::SharedResourceAuthority>,
164}
165
166impl AuthConfigBuilder {
167 /// Attach a fully-built OAuth registry (typically one constructed with
168 /// `OAuthRegistry::new(pool.clone())?.with_provider(GithubProvider::from_env()?.unwrap())`).
169 ///
170 /// Replaces any previously-set registry. For one-off provider registration
171 /// see [`Self::oauth_provider`].
172 #[cfg(feature = "_oauth-core")]
173 pub fn oauth(mut self, registry: OAuthRegistry) -> Self {
174 self.oauth = Some(registry);
175 self
176 }
177
178 /// Append a single provider, lazily initialising the registry on first
179 /// call. Convenient when registering one provider at a time:
180 ///
181 /// ```rust,no_run
182 /// # fn doc() -> anyhow::Result<()> {
183 /// # use arium::{AuthConfig, oauth::github::GithubProvider};
184 /// # let pool: arium::pool::Pool = unimplemented!();
185 /// # let mailer: arium::Mailer = unimplemented!();
186 /// let mut builder = AuthConfig::builder(pool, mailer);
187 /// if let Some(gh) = GithubProvider::from_env()? {
188 /// builder = builder.oauth_provider(gh)?;
189 /// }
190 /// # let _ = builder;
191 /// # Ok(()) }
192 /// ```
193 ///
194 /// Returns `Err` if lazy initialisation of the registry's HTTP client
195 /// fails (in practice only when the TLS backend can't initialise).
196 #[cfg(feature = "_oauth-core")]
197 pub fn oauth_provider<P: OAuthProvider>(mut self, provider: P) -> anyhow::Result<Self> {
198 let reg = match self.oauth.take() {
199 Some(r) => r,
200 None => OAuthRegistry::new(self.pool.clone())?,
201 };
202 self.oauth = Some(reg.with_provider(provider));
203 Ok(self)
204 }
205
206 /// Short-term session lifespan. Sessions created without "Remember me"
207 /// expire after this duration of inactivity. Default: 2 hours.
208 pub fn session_lifetime(mut self, d: Duration) -> Self {
209 self.session_lifetime = d;
210 self
211 }
212
213 /// Long-term session lifespan. Sessions created with "Remember me"
214 /// stretch to this duration. Default: 30 days.
215 pub fn session_max_lifetime(mut self, d: Duration) -> Self {
216 self.session_max_lifetime = d;
217 self
218 }
219
220 /// Cookie `Max-Age`. Should be `>=` the long-term lifespan or the cookie
221 /// will be GC'd by the browser before the server-side row expires.
222 /// Default: 30 days.
223 pub fn cookie_max_age(mut self, d: Duration) -> Self {
224 self.cookie_max_age = d;
225 self
226 }
227
228 /// Add the `Secure` attribute to the session cookie so browsers only send
229 /// it over HTTPS. `false` by default.
230 ///
231 /// Enable this in production (pair it with [`Self::hsts`]). Leave it off
232 /// for plain-HTTP `localhost` development — a `Secure` cookie is never
233 /// sent over HTTP, so turning it on locally silently logs everyone out.
234 ///
235 /// Note the session cookie stays `SameSite=Lax`, *not* `Strict`: the
236 /// OAuth provider's callback is a cross-site top-level redirect, and only
237 /// `Lax` lets the session cookie (which carries the CSRF `state` + PKCE
238 /// verifier) ride that navigation. `Strict` would break OAuth sign-in.
239 pub fn cookie_secure(mut self, secure: bool) -> Self {
240 self.cookie_secure = secure;
241 self
242 }
243
244 /// Replace the rate-limit settings. Pass `None` to disable rate limiting
245 /// entirely (the layer is still attached, just permissive).
246 #[cfg(feature = "ratelimit")]
247 pub fn rate_limit(mut self, rl: Option<RateLimitConfig>) -> Self {
248 self.rate_limit = rl;
249 self
250 }
251
252 /// Override the SQL table name used by `axum_session` for session
253 /// persistence. Default: `arium_sessions`. Existing deployments that were
254 /// running under the old `dx_auth_sessions` default should pin this to
255 /// `dx_auth_sessions` to keep their live sessions.
256 pub fn session_table_name(mut self, name: impl Into<String>) -> Self {
257 self.session_table_name = name.into();
258 self
259 }
260
261 /// Replace the audit-log capture/retention settings.
262 pub fn audit(mut self, audit: AuditConfig) -> Self {
263 self.audit = audit;
264 self
265 }
266
267 /// Register the app's [`ResourceAuthority`](crate::authz::ResourceAuthority)
268 /// implementation. [`crate::install`] layers it as the request extension
269 /// that [`require_resource`](crate::authz::require_resource) and the
270 /// adapters' resource gates read. Equivalent to layering
271 /// `axum::Extension(authority)` onto the router yourself.
272 ///
273 /// ```rust,no_run
274 /// # fn doc() -> anyhow::Result<()> {
275 /// # use arium::AuthConfig;
276 /// # let pool: arium::pool::Pool = unimplemented!();
277 /// # let mailer: arium::Mailer = unimplemented!();
278 /// // `SqlMembershipStore` is arium's bundled authority; substitute your own.
279 /// let authority: arium::authz::SharedResourceAuthority =
280 /// std::sync::Arc::new(arium::SqlMembershipStore);
281 /// let cfg = AuthConfig::builder(pool, mailer)
282 /// .resource_authority(authority)
283 /// .build()?;
284 /// # let _ = cfg;
285 /// # Ok(()) }
286 /// ```
287 pub fn resource_authority(mut self, authority: crate::authz::SharedResourceAuthority) -> Self {
288 self.resource_authority = Some(authority);
289 self
290 }
291
292 /// Enable the `Strict-Transport-Security` response header with the given
293 /// value (e.g. [`RECOMMENDED_HSTS`]). Off by default.
294 ///
295 /// Only set this in production behind HTTPS: once a browser sees HSTS it
296 /// refuses plain-HTTP for the domain for `max-age` seconds, so enabling it
297 /// on a `localhost` dev build can lock you out of HTTP until the directive
298 /// expires.
299 pub fn hsts(mut self, value: impl Into<String>) -> Self {
300 self.hsts = Some(value.into());
301 self
302 }
303
304 /// Enable the `Content-Security-Policy` response header with the given
305 /// value. Off by default.
306 ///
307 /// A Dioxus fullstack app hydrates from wasm and an inline bootstrap
308 /// script, so the policy must permit them. A workable starting point:
309 ///
310 /// ```text
311 /// default-src 'self'; \
312 /// script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'; \
313 /// style-src 'self' 'unsafe-inline'; \
314 /// img-src 'self' data: https:; \
315 /// connect-src 'self'
316 /// ```
317 ///
318 /// Tighten `script-src`/`style-src` with nonces or hashes once you've
319 /// confirmed hydration still works for your build.
320 pub fn content_security_policy(mut self, value: impl Into<String>) -> Self {
321 self.csp = Some(value.into());
322 self
323 }
324
325 /// Consume the builder and produce the [`AuthConfig`] ready to hand to
326 /// [`crate::install`].
327 ///
328 /// Returns `Err` only if lazy initialisation of the OAuth HTTP client
329 /// fails (in practice only when the TLS backend can't initialise).
330 pub fn build(self) -> anyhow::Result<AuthConfig> {
331 #[cfg(feature = "_oauth-core")]
332 let oauth = match self.oauth {
333 Some(reg) => reg,
334 None => OAuthRegistry::new(self.pool.clone())?,
335 };
336 Ok(AuthConfig {
337 pool: self.pool,
338 #[cfg(feature = "mail")]
339 mailer: self.mailer,
340 #[cfg(feature = "_oauth-core")]
341 oauth,
342 session_lifetime: self.session_lifetime,
343 session_max_lifetime: self.session_max_lifetime,
344 cookie_max_age: self.cookie_max_age,
345 #[cfg(feature = "ratelimit")]
346 rate_limit: self.rate_limit,
347 session_table_name: self.session_table_name,
348 audit: self.audit,
349 hsts: self.hsts,
350 csp: self.csp,
351 cookie_secure: self.cookie_secure,
352 resource_authority: self.resource_authority,
353 })
354 }
355}