Skip to main content

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}