Skip to main content

better_auth_core/
config.rs

1use crate::email::EmailProvider;
2use crate::error::AuthError;
3use chrono::Duration;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7/// Well-known core route paths.
8///
9/// These constants are the single source of truth for route paths used by both
10/// the core request dispatcher (`handle_core_request`) and framework-specific
11/// routers (e.g. Axum) so that path strings are never duplicated.
12pub mod core_paths {
13    pub const OK: &str = "/ok";
14    pub const ERROR: &str = "/error";
15    pub const HEALTH: &str = "/health";
16    pub const OPENAPI_SPEC: &str = "/reference/openapi.json";
17    pub const UPDATE_USER: &str = "/update-user";
18    pub const DELETE_USER: &str = "/delete-user";
19    pub const CHANGE_EMAIL: &str = "/change-email";
20    pub const DELETE_USER_CALLBACK: &str = "/delete-user/callback";
21}
22
23/// Main configuration for BetterAuth
24#[derive(Clone)]
25pub struct AuthConfig {
26    /// Secret key for signing tokens and sessions
27    pub secret: String,
28
29    /// Application name, used for cookie prefixes, email templates, etc.
30    ///
31    /// Defaults to `"Better Auth"`.
32    pub app_name: String,
33
34    /// Base URL for the authentication service (e.g. `"http://localhost:3000"`).
35    pub base_url: String,
36
37    /// Base path where the auth routes are mounted.
38    ///
39    /// All routes handled by BetterAuth will be prefixed with this path.
40    /// For example, with the default `"/api/auth"`, the sign-in route becomes
41    /// `"/api/auth/sign-in/email"`.
42    ///
43    /// Defaults to `"/api/auth"`.
44    pub base_path: String,
45
46    /// Origins that are trusted for CSRF and other cross-origin checks.
47    ///
48    /// Supports glob patterns (e.g. `"https://*.example.com"`).
49    /// These are shared across all middleware that needs origin validation
50    /// (CSRF, CORS, etc.).
51    pub trusted_origins: Vec<String>,
52
53    /// Paths that should be disabled (skipped) by the router.
54    ///
55    /// Any request whose path matches an entry in this list will receive
56    /// a 404 response, even if a handler is registered for it.
57    pub disabled_paths: Vec<String>,
58    /// Session configuration
59    pub session: SessionConfig,
60
61    /// JWT configuration
62    pub jwt: JwtConfig,
63
64    /// Password configuration
65    pub password: PasswordConfig,
66
67    /// Account configuration (linking, token encryption, etc.)
68    pub account: AccountConfig,
69
70    /// Email provider for sending emails (verification, password reset, etc.)
71    pub email_provider: Option<Arc<dyn EmailProvider>>,
72
73    /// Advanced configuration options
74    pub advanced: AdvancedConfig,
75}
76
77/// Account-level configuration: linking, token encryption, sign-in behavior.
78#[derive(Debug, Clone)]
79pub struct AccountConfig {
80    /// Update OAuth tokens on every sign-in (default: true)
81    pub update_account_on_sign_in: bool,
82    /// Account linking settings
83    pub account_linking: AccountLinkingConfig,
84    /// Encrypt OAuth tokens at rest (default: false)
85    pub encrypt_oauth_tokens: bool,
86}
87
88/// Settings that control how OAuth accounts are linked to existing users.
89#[derive(Debug, Clone)]
90pub struct AccountLinkingConfig {
91    /// Enable account linking (default: true)
92    pub enabled: bool,
93    /// Trusted providers that can auto-link (default: empty = all trusted)
94    pub trusted_providers: Vec<String>,
95    /// Allow linking accounts with different emails (default: false) - SECURITY WARNING
96    pub allow_different_emails: bool,
97    /// Allow unlinking all accounts (default: false)
98    pub allow_unlinking_all: bool,
99    /// Update user info when a new account is linked (default: false)
100    pub update_user_info_on_link: bool,
101}
102
103/// Session-specific configuration
104#[derive(Debug, Clone)]
105pub struct SessionConfig {
106    /// Session expiration duration
107    pub expires_in: Duration,
108
109    /// How often to refresh the session expiry (as a Duration).
110    ///
111    /// When set, session expiry is only updated if the session is older than
112    /// this duration since the last update. When `None`, every request
113    /// refreshes the session (equivalent to the old `update_age: true`).
114    pub update_age: Option<Duration>,
115
116    /// If `true`, sessions are never automatically refreshed on access.
117    pub disable_session_refresh: bool,
118
119    /// Session freshness window. A session younger than this is considered
120    /// "fresh" (useful for step-up auth or sensitive operations).
121    pub fresh_age: Option<Duration>,
122
123    /// Cookie name for session token
124    pub cookie_name: String,
125
126    /// Cookie settings
127    pub cookie_secure: bool,
128    pub cookie_http_only: bool,
129    pub cookie_same_site: SameSite,
130
131    /// Optional cookie-based session cache to avoid DB lookups.
132    ///
133    /// When enabled, session data is cached in a signed/encrypted cookie.
134    /// `SessionManager` checks the cookie cache before hitting the database.
135    pub cookie_cache: Option<CookieCacheConfig>,
136}
137
138/// JWT configuration
139#[derive(Debug, Clone)]
140pub struct JwtConfig {
141    /// JWT expiration duration
142    pub expires_in: Duration,
143
144    /// JWT algorithm
145    pub algorithm: String,
146
147    /// Issuer claim
148    pub issuer: Option<String>,
149
150    /// Audience claim
151    pub audience: Option<String>,
152}
153
154/// Password hashing configuration
155#[derive(Debug, Clone)]
156pub struct PasswordConfig {
157    /// Minimum password length
158    pub min_length: usize,
159
160    /// Require uppercase letters
161    pub require_uppercase: bool,
162
163    /// Require lowercase letters
164    pub require_lowercase: bool,
165
166    /// Require numbers
167    pub require_numbers: bool,
168
169    /// Require special characters
170    pub require_special: bool,
171
172    /// Argon2 configuration
173    pub argon2_config: Argon2Config,
174}
175
176/// Argon2 hashing configuration
177#[derive(Debug, Clone)]
178pub struct Argon2Config {
179    pub memory_cost: u32,
180    pub time_cost: u32,
181    pub parallelism: u32,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum SameSite {
186    Strict,
187    Lax,
188    None,
189}
190
191/// Configuration for cookie-based session caching.
192///
193/// When enabled, session data is stored in a signed or encrypted cookie so that
194/// subsequent requests can skip the database lookup.
195#[derive(Debug, Clone)]
196pub struct CookieCacheConfig {
197    /// Whether the cookie cache is active.
198    pub enabled: bool,
199
200    /// Maximum age of the cached cookie before a fresh DB lookup is required.
201    ///
202    /// Default: 5 minutes.
203    pub max_age: Duration,
204
205    /// Strategy used to protect the cached cookie value.
206    pub strategy: CookieCacheStrategy,
207}
208
209/// Strategy for signing / encrypting the cookie cache.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum CookieCacheStrategy {
212    /// Base64url-encoded payload + HMAC-SHA256 signature.
213    Compact,
214    /// Standard JWT with HMAC signing.
215    Jwt,
216    /// JWE with AES-256-GCM encryption.
217    Jwe,
218}
219
220impl Default for CookieCacheConfig {
221    fn default() -> Self {
222        Self {
223            enabled: false,
224            max_age: Duration::minutes(5),
225            strategy: CookieCacheStrategy::Compact,
226        }
227    }
228}
229
230impl Default for AccountConfig {
231    fn default() -> Self {
232        Self {
233            update_account_on_sign_in: true,
234            account_linking: AccountLinkingConfig::default(),
235            encrypt_oauth_tokens: false,
236        }
237    }
238}
239
240impl Default for AccountLinkingConfig {
241    fn default() -> Self {
242        Self {
243            enabled: true,
244            trusted_providers: Vec::new(),
245            allow_different_emails: false,
246            allow_unlinking_all: false,
247            update_user_info_on_link: false,
248        }
249    }
250}
251
252impl std::fmt::Display for SameSite {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        match self {
255            SameSite::Strict => f.write_str("Strict"),
256            SameSite::Lax => f.write_str("Lax"),
257            SameSite::None => f.write_str("None"),
258        }
259    }
260}
261
262// ── Advanced configuration ──────────────────────────────────────────────
263
264/// Advanced configuration options (mirrors TS `advanced` block).
265#[derive(Debug, Clone, Default)]
266pub struct AdvancedConfig {
267    /// IP address extraction configuration.
268    pub ip_address: IpAddressConfig,
269
270    /// If `true`, the CSRF-check middleware is disabled.
271    pub disable_csrf_check: bool,
272
273    /// If `true`, the Origin header check is skipped.
274    pub disable_origin_check: bool,
275
276    /// Cross-subdomain cookie sharing configuration.
277    pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,
278
279    /// Per-cookie-name overrides (name, attributes, prefix).
280    ///
281    /// Keys are the *logical* cookie names (e.g. `"session_token"`,
282    /// `"csrf_token"`). Values specify the attributes to override.
283    pub cookies: HashMap<String, CookieOverride>,
284
285    /// Default cookie attributes applied to *every* cookie the library sets
286    /// (individual overrides in `cookies` take precedence).
287    pub default_cookie_attributes: CookieAttributes,
288
289    /// Optional prefix prepended to every cookie name (e.g. `"myapp"` →
290    /// `"myapp.session_token"`).
291    pub cookie_prefix: Option<String>,
292
293    /// Database-related advanced options.
294    pub database: AdvancedDatabaseConfig,
295
296    /// List of header names the framework trusts for extracting the
297    /// client's real IP when behind a proxy (e.g. `X-Forwarded-For`).
298    pub trusted_proxy_headers: Vec<String>,
299}
300
301/// IP-address extraction configuration.
302#[derive(Debug, Clone)]
303pub struct IpAddressConfig {
304    /// Ordered list of headers to check for the client IP.
305    /// Defaults to `["x-forwarded-for", "x-real-ip"]`.
306    pub headers: Vec<String>,
307
308    /// If `true`, IP tracking is entirely disabled (no IP stored in sessions).
309    pub disable_ip_tracking: bool,
310}
311
312/// Configuration for sharing cookies across sub-domains.
313#[derive(Debug, Clone)]
314pub struct CrossSubDomainConfig {
315    /// The parent domain (e.g. `".example.com"`).
316    pub domain: String,
317}
318
319/// Overridable cookie attributes.
320#[derive(Debug, Clone, Default)]
321pub struct CookieAttributes {
322    /// Override `Secure` flag.
323    pub secure: Option<bool>,
324    /// Override `HttpOnly` flag.
325    pub http_only: Option<bool>,
326    /// Override `SameSite` policy.
327    pub same_site: Option<SameSite>,
328    /// Override `Path`.
329    pub path: Option<String>,
330    /// Override `Max-Age` (seconds).
331    pub max_age: Option<i64>,
332    /// Override cookie `Domain`.
333    pub domain: Option<String>,
334}
335
336/// Per-cookie override entry.
337#[derive(Debug, Clone, Default)]
338pub struct CookieOverride {
339    /// Custom name to use instead of the logical name.
340    pub name: Option<String>,
341    /// Attribute overrides for this cookie.
342    pub attributes: CookieAttributes,
343}
344
345/// Database-related advanced options.
346#[derive(Debug, Clone)]
347pub struct AdvancedDatabaseConfig {
348    /// Default `LIMIT` for "find many" queries.
349    pub default_find_many_limit: usize,
350
351    /// If `true`, auto-generated IDs will be numeric (auto-increment style)
352    /// rather than UUIDs.
353    pub use_number_id: bool,
354}
355impl Default for AuthConfig {
356    fn default() -> Self {
357        Self {
358            secret: String::new(),
359            app_name: "Better Auth".to_string(),
360            base_url: "http://localhost:3000".to_string(),
361            base_path: "/api/auth".to_string(),
362            trusted_origins: Vec::new(),
363            disabled_paths: Vec::new(),
364            session: SessionConfig::default(),
365            jwt: JwtConfig::default(),
366            password: PasswordConfig::default(),
367            account: AccountConfig::default(),
368            email_provider: None,
369            advanced: AdvancedConfig::default(),
370        }
371    }
372}
373
374impl Default for SessionConfig {
375    fn default() -> Self {
376        Self {
377            expires_in: Duration::hours(24 * 7),   // 7 days
378            update_age: Some(Duration::hours(24)), // refresh once per day
379            disable_session_refresh: false,
380            fresh_age: None,
381            cookie_name: "better-auth.session-token".to_string(),
382            cookie_secure: true,
383            cookie_http_only: true,
384            cookie_same_site: SameSite::Lax,
385            cookie_cache: None,
386        }
387    }
388}
389
390impl Default for IpAddressConfig {
391    fn default() -> Self {
392        Self {
393            headers: vec!["x-forwarded-for".to_string(), "x-real-ip".to_string()],
394            disable_ip_tracking: false,
395        }
396    }
397}
398
399impl Default for AdvancedDatabaseConfig {
400    fn default() -> Self {
401        Self {
402            default_find_many_limit: 100,
403            use_number_id: false,
404        }
405    }
406}
407
408impl Default for JwtConfig {
409    fn default() -> Self {
410        Self {
411            expires_in: Duration::hours(24), // 1 day
412            algorithm: "HS256".to_string(),
413            issuer: None,
414            audience: None,
415        }
416    }
417}
418
419impl Default for PasswordConfig {
420    fn default() -> Self {
421        Self {
422            min_length: 8,
423            require_uppercase: false,
424            require_lowercase: false,
425            require_numbers: false,
426            require_special: false,
427            argon2_config: Argon2Config::default(),
428        }
429    }
430}
431
432impl Default for Argon2Config {
433    fn default() -> Self {
434        Self {
435            memory_cost: 4096, // 4MB
436            time_cost: 3,      // 3 iterations
437            parallelism: 1,    // 1 thread
438        }
439    }
440}
441
442impl AuthConfig {
443    pub fn new(secret: impl Into<String>) -> Self {
444        Self {
445            secret: secret.into(),
446            ..Default::default()
447        }
448    }
449
450    /// Set the application name.
451    pub fn app_name(mut self, name: impl Into<String>) -> Self {
452        self.app_name = name.into();
453        self
454    }
455
456    /// Set the base URL (e.g. `"https://myapp.com"`).
457    pub fn base_url(mut self, url: impl Into<String>) -> Self {
458        self.base_url = url.into();
459        self
460    }
461
462    pub fn account(mut self, account: AccountConfig) -> Self {
463        self.account = account;
464        self
465    }
466
467    /// Set the base path where auth routes are mounted.
468    pub fn base_path(mut self, path: impl Into<String>) -> Self {
469        self.base_path = path.into();
470        self
471    }
472
473    /// Add a trusted origin. Supports glob patterns (e.g. `"https://*.example.com"`).
474    pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
475        self.trusted_origins.push(origin.into());
476        self
477    }
478
479    /// Set all trusted origins at once.
480    pub fn trusted_origins(mut self, origins: Vec<String>) -> Self {
481        self.trusted_origins = origins;
482        self
483    }
484
485    /// Add a path to the disabled paths list.
486    pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
487        self.disabled_paths.push(path.into());
488        self
489    }
490
491    /// Set all disabled paths at once.
492    pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
493        self.disabled_paths = paths;
494        self
495    }
496
497    /// Set the session expiration duration.
498    pub fn session_expires_in(mut self, duration: Duration) -> Self {
499        self.session.expires_in = duration;
500        self
501    }
502
503    pub fn session_update_age(mut self, duration: Duration) -> Self {
504        self.session.update_age = Some(duration);
505        self
506    }
507
508    pub fn disable_session_refresh(mut self, disabled: bool) -> Self {
509        self.session.disable_session_refresh = disabled;
510        self
511    }
512
513    pub fn session_fresh_age(mut self, duration: Duration) -> Self {
514        self.session.fresh_age = Some(duration);
515        self
516    }
517
518    /// Set the cookie cache configuration for sessions.
519    pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
520        self.session.cookie_cache = Some(config);
521        self
522    }
523
524    /// Set the JWT expiration duration.
525    pub fn jwt_expires_in(mut self, duration: Duration) -> Self {
526        self.jwt.expires_in = duration;
527        self
528    }
529
530    /// Set the minimum password length.
531    pub fn password_min_length(mut self, length: usize) -> Self {
532        self.password.min_length = length;
533        self
534    }
535
536    pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
537        self.advanced = advanced;
538        self
539    }
540
541    pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
542        self.advanced.cookie_prefix = Some(prefix.into());
543        self
544    }
545
546    pub fn disable_csrf_check(mut self, disabled: bool) -> Self {
547        self.advanced.disable_csrf_check = disabled;
548        self
549    }
550
551    pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
552        self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig {
553            domain: domain.into(),
554        });
555        self
556    }
557
558    /// Check whether a given origin is trusted.
559    ///
560    /// An origin is trusted if it matches:
561    /// 1. The origin extracted from [`base_url`](Self::base_url), or
562    /// 2. Any pattern in [`trusted_origins`](Self::trusted_origins) (after
563    ///    extracting the origin portion from the pattern).
564    ///
565    /// Glob patterns are supported — `*` matches any characters except `/`,
566    /// `**` matches any characters including `/`.
567    pub fn is_origin_trusted(&self, origin: &str) -> bool {
568        // Check base_url origin
569        if let Some(base_origin) = extract_origin(&self.base_url)
570            && origin == base_origin
571        {
572            return true;
573        }
574        // Check trusted_origins patterns
575        self.trusted_origins.iter().any(|pattern| {
576            let pattern_origin = extract_origin(pattern).unwrap_or_default();
577            glob_match::glob_match(&pattern_origin, origin)
578        })
579    }
580
581    /// Check whether a given path is disabled.
582    pub fn is_path_disabled(&self, path: &str) -> bool {
583        self.disabled_paths.iter().any(|disabled| disabled == path)
584    }
585    pub fn validate(&self) -> Result<(), AuthError> {
586        if self.secret.is_empty() {
587            return Err(AuthError::config("Secret key cannot be empty"));
588        }
589
590        if self.secret.len() < 32 {
591            return Err(AuthError::config(
592                "Secret key must be at least 32 characters",
593            ));
594        }
595
596        Ok(())
597    }
598}
599
600/// Extract the origin (scheme + host + port) from a URL string.
601///
602/// For example, `"https://example.com/path"` → `"https://example.com"`.
603///
604/// This is used by [`AuthConfig::is_origin_trusted`] and the CSRF middleware
605/// so that origin comparison is centralised in one place.
606pub fn extract_origin(url: &str) -> Option<String> {
607    let scheme_end = url.find("://")?;
608    let rest = &url[scheme_end + 3..];
609    let host_end = rest.find('/').unwrap_or(rest.len());
610    let origin = format!("{}{}", &url[..scheme_end + 3], &rest[..host_end]);
611    Some(origin)
612}