Skip to main content

better_auth_core/
config.rs

1mod api_error;
2mod error_page;
3pub use api_error::{
4    ApiErrorConfig, ErrorPageColors, ErrorPageCustomization, ErrorPageFont, ErrorPageSize,
5};
6
7pub use crate::request_runtime::{
8    BaseUrl, BaseUrlProtocol, DynamicBaseUrl, TrustedValues, TrustedValuesResolver,
9};
10mod cookie_cache;
11mod scalars;
12mod secrets;
13mod storage;
14mod verification;
15use crate::email::EmailProvider;
16use crate::error::{AuthError, AuthResult};
17pub use crate::user_fields::{
18    FieldTransforms, UserConfig, UserFieldConfig, UserFieldFactory, UserFieldReference,
19    UserFieldTransform, UserFieldType, UserFieldValidator,
20};
21use chrono::Duration;
22pub use cookie_cache::{CookieCacheVersion, CookieCacheVersionCallback};
23pub(crate) use secrets::parse_secret_version;
24pub use secrets::{SecretKey, VersionedSecret};
25use std::collections::HashMap;
26use std::sync::Arc;
27pub use verification::{
28    VerificationIdentifierConfig, VerificationIdentifierHasher, VerificationIdentifierStorage,
29};
30
31/// Well-known core route paths.
32///
33/// These constants are the single source of truth for route paths used by both
34/// the core request dispatcher (`handle_core_request`) and framework-specific
35/// routers (e.g. Axum) so that path strings are never duplicated.
36pub mod core_paths {
37    pub const OK: &str = "/ok";
38    pub const ERROR: &str = "/error";
39    pub const HEALTH: &str = "/health";
40    pub const OPENAPI_SPEC: &str = "/__test/openapi.json";
41    pub const UPDATE_USER: &str = "/update-user";
42    pub const DELETE_USER: &str = "/delete-user";
43    pub const CHANGE_EMAIL: &str = "/change-email";
44    pub const DELETE_USER_CALLBACK: &str = "/delete-user/callback";
45
46    pub use super::error_page::{error_page_html, error_page_html_with_description};
47}
48
49/// Main configuration for BetterAuth
50#[derive(Clone)]
51pub struct AuthConfig {
52    /// Per-instance diagnostics. Custom sinks receive unformatted messages and arguments.
53    pub logger: crate::observability::LoggerConfig,
54    /// Experimental runtime instrumentation.
55    pub experimental: crate::observability::ExperimentalConfig,
56    /// Usage reporting is disabled by default.
57    pub telemetry: crate::observability::TelemetryConfig,
58    pub(crate) resolved_cookies: Option<crate::request_runtime::CookieSettings>,
59    pub(crate) session_input_fields: Option<UserConfig>,
60    /// Secret key for signing tokens and sessions
61    pub secret: String,
62
63    /// Ordered encryption keys. The first key also signs new cookies and tokens.
64    /// `secret` remains the explicit legacy decryption key when this is set.
65    pub secrets: Option<Vec<VersionedSecret>>,
66
67    /// Application name, used for cookie prefixes, email templates, etc.
68    ///
69    /// Defaults to `"Better Auth"`.
70    pub app_name: String,
71
72    /// Base URL for the authentication service (e.g. `"http://localhost:3000"`).
73    pub base_url: BaseUrl,
74
75    /// Base path where the auth routes are mounted.
76    ///
77    /// All routes handled by BetterAuth will be prefixed with this path.
78    /// For example, with the default `"/api/auth"`, the sign-in route becomes
79    /// `"/api/auth/sign-in/email"`.
80    ///
81    /// Defaults to `"/api/auth"`.
82    pub base_path: String,
83
84    /// Origins that are trusted for CSRF and other cross-origin checks.
85    ///
86    /// Supports glob patterns (e.g. `"https://*.example.com"`).
87    /// These are shared across all middleware that needs origin validation
88    /// (CSRF, CORS, etc.).
89    pub trusted_origins: Option<TrustedValues>,
90
91    /// Paths that should be disabled (skipped) by the router.
92    ///
93    /// Any request whose path matches an entry in this list will receive
94    /// a 404 response, even if a handler is registered for it.
95    pub disabled_paths: Vec<String>,
96    /// HTTP error policy and the default error page.
97    pub api_error: ApiErrorConfig,
98    /// Session configuration
99    pub session: SessionConfig,
100    /// Verification persistence when secondary storage is installed.
101    pub verification: VerificationConfig,
102    /// Application user field configuration.
103    pub user: UserConfig,
104
105    /// JWT configuration
106    pub jwt: JwtConfig,
107
108    /// Password configuration
109    pub password: PasswordConfig,
110
111    /// Account configuration (linking, token encryption, etc.)
112    pub account: AccountConfig,
113
114    /// Email provider for sending emails (verification, password reset, etc.)
115    pub email_provider: Option<Arc<dyn EmailProvider>>,
116
117    /// Advanced configuration options
118    pub advanced: AdvancedConfig,
119}
120
121/// Account-level configuration: linking, token encryption, sign-in behavior.
122#[derive(Debug, Clone, Default)]
123pub struct AccountConfig {
124    /// Adapter fields, including schema replacements for built-in account fields.
125    pub additional_fields: indexmap::IndexMap<String, crate::user_fields::UserFieldConfig>,
126    /// Update OAuth tokens on every sign-in (default: true)
127    pub update_account_on_sign_in: Option<bool>,
128    /// Account linking settings
129    pub account_linking: AccountLinkingConfig,
130    /// Encrypt OAuth tokens at rest (default: false)
131    pub encrypt_oauth_tokens: Option<bool>,
132    /// Store account data in an account cookie for OAuth-backed access token flows.
133    pub store_account_cookie: Option<bool>,
134    /// Where to persist OAuth state during the authorization flow.
135    pub store_state_strategy: Option<OAuthStateStrategy>,
136    /// Skip state-cookie verification during callback processing.
137    ///
138    /// This is security-sensitive and should stay disabled in normal use.
139    pub skip_state_cookie_check: bool,
140}
141
142/// Settings that control how OAuth accounts are linked to existing users.
143#[derive(Debug, Clone)]
144pub struct AccountLinkingConfig {
145    /// Enable account linking. Omission uses true.
146    pub enabled: Option<bool>,
147    /// Trusted providers that can auto-link (default: empty = all trusted)
148    pub trusted_providers: Option<TrustedValues>,
149    /// Allow linking accounts with different emails (default: false) - SECURITY WARNING
150    pub allow_different_emails: bool,
151    /// Allow unlinking all accounts (default: false)
152    pub allow_unlinking_all: Option<bool>,
153    /// Disable implicit linking during sign-in; only explicit link-social may link.
154    pub disable_implicit_linking: bool,
155    /// Require the *existing local* account's email to be verified before a
156    /// social account may be linked to it implicitly (default: true).
157    pub require_local_email_verified: bool,
158    /// Update user info when a new account is linked (default: false)
159    pub update_user_info_on_link: Option<bool>,
160}
161
162/// Strategy for persisting OAuth state between the sign-in and callback steps.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub enum OAuthStateStrategy {
165    /// Persist state in an encrypted cookie.
166    #[default]
167    Cookie,
168    /// Persist state in the verification store plus a signed state cookie.
169    Database,
170}
171
172/// Verification persistence with secondary storage. Without secondary storage, the database remains authoritative.
173#[derive(Debug, Clone, Default)]
174pub struct VerificationConfig {
175    /// Adapter fields. Pure secondary storage does not apply these adapter policies.
176    pub additional_fields: indexmap::IndexMap<String, crate::user_fields::UserFieldConfig>,
177    /// Identifier transformation with ordered prefix overrides and plain-data read migration.
178    pub store_identifier: VerificationIdentifierConfig,
179    /// Persist verification records in the database and use database atomic consumption.
180    pub store_in_database: bool,
181    /// Do not delete expired database verification records during a lookup.
182    pub disable_cleanup: Option<bool>,
183}
184
185/// Session-specific configuration
186#[derive(Clone, Default)]
187pub struct SessionConfig {
188    /// Persist sessions in the database as well as secondary storage.
189    pub store_session_in_database: Option<bool>,
190    /// Keep ended database sessions for audit and disable database fallback on cache misses.
191    pub preserve_session_in_database: Option<bool>,
192
193    /// Session expiration duration. Omission uses seven days.
194    pub expires_in: Option<Duration>,
195
196    /// How often to refresh the session expiry (as a Duration).
197    ///
198    /// Refresh when the remaining lifetime falls below `expires_in - update_age`.
199    /// Omission uses one day. Zero refreshes on every authoritative read.
200    pub update_age: Option<Duration>,
201
202    /// If `true`, sessions are never automatically refreshed on access.
203    pub disable_session_refresh: Option<bool>,
204
205    /// Report `needsRefresh` on `GET /get-session` and refresh on `POST /get-session`.
206    /// Upstream rejects the POST form with 405 unless this is enabled.
207    pub defer_session_refresh: bool,
208
209    /// Session freshness window. A session younger than this is considered
210    /// "fresh" (useful for step-up auth or sensitive operations).
211    pub fresh_age: Option<Duration>,
212
213    /// Optional cookie-based session cache to avoid DB lookups.
214    ///
215    /// When enabled, session data is cached in a signed/encrypted cookie.
216    /// `SessionManager` checks the cookie cache before hitting the database.
217    pub cookie_cache: Option<CookieCacheConfig>,
218
219    /// Enable the upstream Bearer plugin behavior for session tokens.
220    /// API keys use their own plugin configuration.
221    pub bearer: Option<BearerConfig>,
222
223    /// Application session fields accepted by `/update-session` and included in session responses.
224    /// `None` preserves omission; an explicitly empty map remains configured.
225    pub additional_fields: Option<indexmap::IndexMap<String, SessionFieldConfig>>,
226}
227
228/// Session fields use the same validation, defaults, transforms, and visibility as user fields.
229pub type SessionFieldConfig = UserFieldConfig;
230
231impl SessionConfig {
232    /// Runtime fields. An omitted map has no fields.
233    pub fn fields(&self) -> &indexmap::IndexMap<String, SessionFieldConfig> {
234        crate::user_fields::fields_or_empty(&self.additional_fields)
235    }
236
237    /// Configure fields, creating an explicitly present map when omitted.
238    pub fn fields_mut(&mut self) -> &mut indexmap::IndexMap<String, SessionFieldConfig> {
239        self.additional_fields.get_or_insert_default()
240    }
241
242    pub fn field_schema(&self) -> UserConfig {
243        UserConfig {
244            additional_fields: self.additional_fields.clone(),
245        }
246    }
247
248    /// Resolve the adapter-owned ID and retain native Session field positions.
249    #[doc(hidden)]
250    pub fn adapter_schema(&self) -> UserConfig {
251        self.field_schema().adapter_fields(&[
252            "expiresAt",
253            "token",
254            "createdAt",
255            "updatedAt",
256            "ipAddress",
257            "userAgent",
258            "userId",
259        ])
260    }
261
262    /// Evaluate creation defaults without validating or transforming session input.
263    pub fn default_fields(&self) -> AuthResult<crate::FieldMap> {
264        session_defaults(&self.field_schema())
265    }
266}
267
268fn session_defaults(schema: &UserConfig) -> AuthResult<crate::FieldMap> {
269    let mut defaults = crate::FieldMap::new();
270    for (name, field) in schema
271        .ordered_fields(&[])
272        .into_iter()
273        .filter(|(_, field)| field.has_storage_default())
274    {
275        if let Some(value) = field.default_value()? {
276            let _ = defaults.insert(name.to_owned(), value);
277        }
278    }
279    Ok(defaults)
280}
281
282/// Options for authenticating sessions through the Authorization header.
283#[derive(Debug, Clone, Default)]
284pub struct BearerConfig {
285    /// Reject unsigned bearer tokens when enabled.
286    pub require_signature: bool,
287}
288
289/// JWT configuration
290#[derive(Debug, Clone)]
291pub struct JwtConfig {
292    /// JWT expiration duration
293    pub expires_in: Duration,
294
295    /// JWT algorithm
296    pub algorithm: String,
297
298    /// Issuer claim
299    pub issuer: Option<String>,
300
301    /// Audience claim
302    pub audience: Option<String>,
303}
304
305/// Password hashing configuration
306#[derive(Debug, Clone)]
307pub struct PasswordConfig {
308    /// Minimum password length
309    pub min_length: usize,
310
311    /// Maximum password length in UTF-16 code units. Defaults to 128.
312    pub max_length: usize,
313
314    /// Require uppercase letters
315    pub require_uppercase: bool,
316
317    /// Require lowercase letters
318    pub require_lowercase: bool,
319
320    /// Require numbers
321    pub require_numbers: bool,
322
323    /// Require special characters
324    pub require_special: bool,
325
326    /// Argon2 configuration
327    pub argon2_config: Argon2Config,
328}
329
330/// Argon2 hashing configuration
331#[derive(Debug, Clone)]
332pub struct Argon2Config {
333    pub memory_cost: u32,
334    pub time_cost: u32,
335    pub parallelism: u32,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq)]
339pub enum SameSite {
340    Strict,
341    Lax,
342    None,
343}
344
345/// Configuration for cookie-based session caching.
346///
347/// When enabled, session data is stored in a signed or encrypted cookie so that
348/// subsequent requests can skip the database lookup.
349#[derive(Debug, Clone)]
350pub struct CookieCacheConfig {
351    /// Enable caching. Omission enables caching only when no server session store exists.
352    pub enabled: Option<bool>,
353    /// Cache lifetime. Omission uses session expiry in stateless mode and five minutes otherwise.
354    pub max_age: Option<Duration>,
355    /// Cookie protection. Omission uses JWE in stateless mode and Compact otherwise.
356    pub strategy: Option<CookieCacheStrategy>,
357    /// Refresh a valid cookie near expiry without changing the session expiry.
358    pub refresh: Option<CookieCacheRefresh>,
359    /// Cache version evaluated against the session and user on writes and cache reads.
360    pub version: CookieCacheVersion,
361}
362
363/// Stateless cookie refresh policy. Server-backed sessions disable this policy.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum CookieCacheRefresh {
366    /// Do not refresh a valid cookie.
367    Disabled,
368    /// Refresh when the remaining cookie lifetime is below twenty percent of its maximum age.
369    Enabled,
370    /// Refresh when the remaining cookie lifetime is below this duration.
371    After(Duration),
372}
373
374/// Strategy for signing / encrypting the cookie cache.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub enum CookieCacheStrategy {
377    /// Base64url-encoded payload + HMAC-SHA256 signature.
378    Compact,
379    /// Standard JWT with HMAC signing.
380    Jwt,
381    /// JWE with AES-256-CBC and HMAC-SHA512 authentication.
382    Jwe,
383}
384
385impl Default for CookieCacheConfig {
386    fn default() -> Self {
387        Self {
388            enabled: None,
389            max_age: None,
390            strategy: None,
391            refresh: None,
392            version: "1".into(),
393        }
394    }
395}
396
397impl CookieCacheConfig {
398    /// Read the effective cache setting after storage defaults are resolved.
399    pub fn enabled(&self) -> bool {
400        self.enabled.unwrap_or(false)
401    }
402    /// Read the effective cache lifetime.
403    pub fn max_age(&self) -> Duration {
404        self.max_age
405            .filter(|age| !age.is_zero())
406            .unwrap_or_else(|| Duration::minutes(5))
407    }
408    /// Read the effective cookie protection strategy.
409    pub fn strategy(&self) -> CookieCacheStrategy {
410        self.strategy.unwrap_or(CookieCacheStrategy::Compact)
411    }
412    /// Read the refresh threshold after storage defaults are resolved.
413    pub fn refresh_age(&self) -> Option<Duration> {
414        match self.refresh {
415            None | Some(CookieCacheRefresh::Disabled) => None,
416            Some(CookieCacheRefresh::After(age)) => Some(age),
417            Some(CookieCacheRefresh::Enabled) => {
418                let age = self.max_age().as_seconds_f64();
419                Some(Duration::seconds((age * 0.2).floor() as i64))
420            }
421        }
422    }
423}
424
425impl AccountConfig {
426    /// Read the effective account-cookie setting.
427    pub fn store_account_cookie(&self) -> bool {
428        self.store_account_cookie.unwrap_or(false)
429    }
430    /// Read the effective OAuth state strategy.
431    pub fn store_state_strategy(&self) -> OAuthStateStrategy {
432        self.store_state_strategy
433            .unwrap_or(OAuthStateStrategy::Database)
434    }
435}
436
437impl Default for AccountLinkingConfig {
438    fn default() -> Self {
439        Self {
440            enabled: None,
441            trusted_providers: None,
442            allow_different_emails: false,
443            allow_unlinking_all: None,
444            disable_implicit_linking: false,
445            require_local_email_verified: true,
446            update_user_info_on_link: None,
447        }
448    }
449}
450
451impl std::fmt::Display for SameSite {
452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453        match self {
454            SameSite::Strict => f.write_str("Strict"),
455            SameSite::Lax => f.write_str("Lax"),
456            SameSite::None => f.write_str("None"),
457        }
458    }
459}
460
461// ── Advanced configuration ──────────────────────────────────────────────
462
463/// Advanced configuration options (mirrors TS `advanced` block).
464#[derive(Debug, Clone, Default)]
465pub struct AdvancedConfig {
466    /// Retain running notification tasks instead of awaiting delivery in the endpoint.
467    pub background_tasks: Option<crate::background::BackgroundTasks>,
468    /// Legacy context-only override. Database inserts use `database.generate_id`.
469    pub generate_id: Option<crate::id::IdGenerator>,
470    /// Match HTTP routes with or without one trailing slash. Default: false.
471    /// Consecutive slashes remain invalid. Native endpoint calls use exact paths.
472    pub skip_trailing_slashes: bool,
473
474    /// IP address extraction configuration.
475    pub ip_address: IpAddressConfig,
476
477    /// Explicit CSRF policy. `None` inherits the legacy `disable_origin_check` behavior.
478    pub disable_csrf_check: Option<bool>,
479
480    /// If `true`, callback / redirect target origin validation is skipped.
481    ///
482    /// This mirrors Better Auth TS `advanced.disableOriginCheck`.
483    /// When `disable_csrf_check` is unset, this also disables form CSRF checks.
484    pub disable_origin_check: bool,
485
486    /// Cross-subdomain cookie sharing configuration.
487    pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,
488
489    /// Per-cookie-name overrides (name, attributes, prefix).
490    ///
491    /// Keys are the *logical* cookie names (e.g. `"session_token"`,
492    /// `"csrf_token"`). Values specify the attributes to override.
493    pub cookies: Option<HashMap<String, CookieOverride>>,
494
495    /// Default cookie attributes applied to *every* cookie the library sets
496    /// (individual overrides in `cookies` take precedence).
497    pub default_cookie_attributes: CookieAttributes,
498
499    /// Optional prefix prepended to every cookie name (e.g. `"myapp"` →
500    /// `"myapp.session_token"`).
501    pub cookie_prefix: Option<String>,
502
503    /// Database-related advanced options.
504    pub database: AdvancedDatabaseConfig,
505
506    /// Trust forwarded host and protocol headers when resolving the service URL.
507    pub trusted_proxy_headers: bool,
508    /// Override the initial secure-cookie policy.
509    pub use_secure_cookies: Option<bool>,
510}
511
512impl AdvancedConfig {
513    pub fn generate_id(
514        &self,
515        model: &str,
516        size: Option<usize>,
517    ) -> crate::AuthResult<Option<String>> {
518        let request = crate::id::IdGenerationRequest { model, size };
519        match &self.generate_id {
520            Some(generator) => generator.generate(request),
521            None => self.database.generate_id().generate(request),
522        }
523    }
524
525    pub(crate) fn csrf_check_disabled(&self) -> bool {
526        self.disable_csrf_check.unwrap_or(self.disable_origin_check)
527    }
528}
529
530/// IP-address extraction configuration.
531#[derive(Debug, Clone)]
532pub struct IpAddressConfig {
533    /// Ordered list of headers to check for the client IP.
534    /// Defaults to `["x-forwarded-for"]`.
535    pub headers: Option<Vec<String>>,
536
537    /// If `true`, IP tracking is entirely disabled (no IP stored in sessions).
538    pub disable_ip_tracking: Option<bool>,
539    /// IPv6 network prefix retained when normalizing addresses. Defaults to 64.
540    pub ipv6_subnet: f64,
541    /// Trusted proxy addresses or CIDR ranges, removed from the right of forwarded chains.
542    pub trusted_proxies: Vec<String>,
543}
544
545/// Configuration for sharing cookies across sub-domains.
546#[derive(Debug, Clone, Default)]
547pub struct CrossSubDomainConfig {
548    /// Enable cross-subdomain cookies. Omission disables sharing.
549    pub enabled: Option<bool>,
550    /// Additional cookie names included in the configured-option telemetry.
551    /// Better Auth 1.7.6 declares this option but does not consume it in cookie resolution.
552    pub additional_cookies: Option<Vec<String>>,
553    /// The parent domain (e.g. `".example.com"`).
554    pub domain: Option<String>,
555}
556
557/// Overridable cookie attributes.
558#[derive(Debug, Clone, Default)]
559pub struct CookieAttributes {
560    /// Override `Secure` flag.
561    pub secure: Option<bool>,
562    /// Enable partitioned storage. The writer applies the upstream attribute mutation order.
563    pub partitioned: Option<bool>,
564    /// Override `HttpOnly` flag.
565    pub http_only: Option<bool>,
566    /// Override `SameSite` policy.
567    pub same_site: Option<SameSite>,
568    /// Override `Path`.
569    pub path: Option<String>,
570    /// Override `Max-Age` (seconds).
571    /// Cookie lifetime in seconds, including fractional seconds.
572    pub max_age: Option<f64>,
573    /// Override the absolute cookie expiration date.
574    /// The HTTP writer rejects dates more than 400 days in the future.
575    pub expires: Option<chrono::DateTime<chrono::Utc>>,
576    /// Override cookie `Domain`.
577    pub domain: Option<String>,
578}
579
580/// Per-cookie override entry.
581#[derive(Debug, Clone, Default)]
582pub struct CookieOverride {
583    /// Custom name to use instead of the logical name.
584    pub name: Option<String>,
585    /// Attribute overrides for this cookie.
586    pub attributes: CookieAttributes,
587}
588
589/// Database-related advanced options.
590#[derive(Debug, Clone, Default)]
591pub struct AdvancedDatabaseConfig {
592    /// Validate physical tables and written columns at runtime unless explicitly disabled.
593    /// Omission reports unsupported adapters at debug level; explicit true reports a warning.
594    pub validate_schema: Option<bool>,
595
596    /// Configured ID policy. Omission generates random alphanumeric IDs.
597    pub generate_id: Option<crate::id::IdGeneration>,
598    /// Default limit for find-many queries. Omission uses 100; adapters preserve numeric semantics.
599    pub default_find_many_limit: Option<f64>,
600    /// Use native adapter joins when supported. Omission uses fallback reads.
601    pub joins: Option<bool>,
602}
603impl Default for AuthConfig {
604    fn default() -> Self {
605        Self {
606            logger: Default::default(),
607            experimental: Default::default(),
608            telemetry: Default::default(),
609            resolved_cookies: None,
610            session_input_fields: None,
611            secret: String::new(),
612            secrets: None,
613            app_name: "Better Auth".to_string(),
614            base_url: BaseUrl::Auto,
615            base_path: "/api/auth".to_string(),
616            trusted_origins: None,
617            disabled_paths: Vec::new(),
618            api_error: ApiErrorConfig::default(),
619            session: SessionConfig::default(),
620            verification: VerificationConfig::default(),
621            user: UserConfig::default(),
622            jwt: JwtConfig::default(),
623            password: PasswordConfig::default(),
624            account: AccountConfig::default(),
625            email_provider: None,
626            advanced: AdvancedConfig::default(),
627        }
628    }
629}
630
631impl Default for IpAddressConfig {
632    fn default() -> Self {
633        Self {
634            headers: None,
635            disable_ip_tracking: None,
636            ipv6_subnet: 64.0,
637            trusted_proxies: Vec::new(),
638        }
639    }
640}
641
642impl AdvancedDatabaseConfig {
643    /// Return the configured default, including zero and non-finite values.
644    pub fn find_many_limit(&self) -> f64 {
645        self.default_find_many_limit.unwrap_or(100.0)
646    }
647}
648
649impl Default for JwtConfig {
650    fn default() -> Self {
651        Self {
652            expires_in: Duration::hours(24), // 1 day
653            algorithm: "HS256".to_string(),
654            issuer: None,
655            audience: None,
656        }
657    }
658}
659
660impl Default for PasswordConfig {
661    fn default() -> Self {
662        Self {
663            min_length: 8,
664            max_length: 128,
665            require_uppercase: false,
666            require_lowercase: false,
667            require_numbers: false,
668            require_special: false,
669            argon2_config: Argon2Config::default(),
670        }
671    }
672}
673
674impl Default for Argon2Config {
675    fn default() -> Self {
676        Self {
677            memory_cost: 4096, // 4MB
678            time_cost: 3,      // 3 iterations
679            parallelism: 1,    // 1 thread
680        }
681    }
682}
683
684impl AuthConfig {
685    /// Evaluate Session creation defaults with plugin input precedence.
686    #[doc(hidden)]
687    pub fn session_default_fields(&self) -> AuthResult<crate::FieldMap> {
688        self.session_input_fields
689            .as_ref()
690            .map_or_else(|| self.session.default_fields(), session_defaults)
691    }
692
693    /// Configure current and retained encryption keys in priority order.
694    pub fn secrets(mut self, keys: Vec<VersionedSecret>) -> Self {
695        self.secrets = Some(keys);
696        self
697    }
698
699    pub fn signing_secret(&self) -> &str {
700        self.secrets
701            .as_deref()
702            .and_then(|keys| keys.first())
703            .map_or(&self.secret, |key| key.value.as_str())
704    }
705
706    pub fn encryption_secret(&self) -> SecretKey<'_> {
707        match self.secrets.as_deref() {
708            Some(keys) => SecretKey::Versioned {
709                keys,
710                legacy_secret: (!self.secret.is_empty() && self.secret != secrets::DEFAULT_SECRET)
711                    .then_some(self.secret.as_str()),
712            },
713            None => SecretKey::Single(&self.secret),
714        }
715    }
716
717    pub fn new(secret: impl Into<String>) -> Self {
718        Self {
719            secret: secret.into(),
720            ..Default::default()
721        }
722    }
723
724    /// Set the application name.
725    pub fn app_name(mut self, name: impl Into<String>) -> Self {
726        self.app_name = name.into();
727        self
728    }
729
730    /// Set the base URL (e.g. `"https://myapp.com"`).
731    pub fn base_url(mut self, url: impl Into<BaseUrl>) -> Self {
732        self.base_url = url.into();
733        self
734    }
735
736    pub fn account(mut self, account: AccountConfig) -> Self {
737        self.account = account;
738        self
739    }
740
741    /// Set the base path where auth routes are mounted.
742    pub fn base_path(mut self, path: impl Into<String>) -> Self {
743        self.base_path = path.into();
744        self
745    }
746
747    /// Add a trusted origin. Supports glob patterns (e.g. `"https://*.example.com"`).
748    pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
749        self.trusted_origins = Some(TrustedValues::merge(
750            self.trusted_origins
751                .into_iter()
752                .chain([vec![origin.into()].into()])
753                .collect(),
754        ));
755        self
756    }
757
758    /// Set all trusted origins at once.
759    pub fn trusted_origins(mut self, origins: impl Into<TrustedValues>) -> Self {
760        self.trusted_origins = Some(origins.into());
761        self
762    }
763
764    /// Add a path to the disabled paths list.
765    pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
766        self.disabled_paths.push(path.into());
767        self
768    }
769
770    /// Set all disabled paths at once.
771    pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
772        self.disabled_paths = paths;
773        self
774    }
775
776    /// Set the session expiration duration.
777    pub fn session_expires_in(mut self, duration: Duration) -> Self {
778        self.session.expires_in = Some(duration);
779        self
780    }
781
782    pub fn session_update_age(mut self, duration: Duration) -> Self {
783        self.session.update_age = Some(duration);
784        self
785    }
786
787    pub fn disable_session_refresh(mut self, disabled: bool) -> Self {
788        self.session.disable_session_refresh = Some(disabled);
789        self
790    }
791
792    pub fn session_fresh_age(mut self, duration: Duration) -> Self {
793        self.session.fresh_age = Some(duration);
794        self
795    }
796
797    /// Set the cookie cache configuration for sessions.
798    pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
799        self.session.cookie_cache = Some(config);
800        self
801    }
802
803    /// Set the JWT expiration duration.
804    pub fn jwt_expires_in(mut self, duration: Duration) -> Self {
805        self.jwt.expires_in = duration;
806        self
807    }
808
809    /// Set the minimum password length.
810    pub fn password_min_length(mut self, length: usize) -> Self {
811        self.password.min_length = length;
812        self
813    }
814
815    /// Set the maximum password length in UTF-16 code units.
816    pub fn password_max_length(mut self, length: usize) -> Self {
817        self.password.max_length = length;
818        self
819    }
820
821    pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
822        self.advanced = advanced;
823        self
824    }
825
826    pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
827        self.advanced.cookie_prefix = Some(prefix.into());
828        self
829    }
830
831    pub fn disable_csrf_check(mut self, disabled: bool) -> Self {
832        self.advanced.disable_csrf_check = Some(disabled);
833        self
834    }
835
836    pub fn disable_origin_check(mut self, disabled: bool) -> Self {
837        self.advanced.disable_origin_check = disabled;
838        self
839    }
840
841    pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
842        self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig {
843            domain: Some(domain.into()),
844            enabled: Some(true),
845            ..Default::default()
846        });
847        self
848    }
849
850    /// Check whether a given origin is trusted.
851    ///
852    /// An origin is trusted if it matches:
853    /// 1. The origin extracted from [`base_url`](Self::base_url), or
854    /// 2. Any pattern in [`trusted_origins`](Self::trusted_origins) (after
855    ///    extracting the origin portion from the pattern).
856    ///
857    /// Glob patterns are supported — `*` matches any characters except `/`,
858    /// `**` matches any characters including `/`.
859    pub fn is_origin_trusted(&self, origin: &str) -> bool {
860        // Check base_url origin
861        if let Some(base_origin) = self.base_url.as_static().and_then(extract_origin)
862            && origin == base_origin
863        {
864            return true;
865        }
866        // Check trusted_origins patterns
867        self.trusted_origin_values()
868            .as_static()
869            .unwrap_or(&[])
870            .iter()
871            .any(|pattern| {
872                let pattern_origin = extract_origin(pattern).unwrap_or_default();
873                glob_match::glob_match(&pattern_origin, origin)
874            })
875    }
876
877    /// Check whether a URL is a safe redirect target.
878    ///
879    /// A URL is safe if it is a relative path (starts with `/`, no
880    /// traversal tricks) or its origin matches [`base_url`](Self::base_url)
881    /// or [`trusted_origins`](Self::trusted_origins).
882    ///
883    /// This is used by both the CSRF middleware (for POST body/query
884    /// targets) and per-endpoint origin checks (e.g. verify-email GET).
885    pub fn is_redirect_target_trusted(&self, url: &str) -> bool {
886        if is_safe_relative_path(url) {
887            return true;
888        }
889        extract_origin(url).is_some_and(|origin| self.is_origin_trusted(&origin))
890    }
891
892    /// Check whether a given path is disabled.
893    pub fn is_path_disabled(&self, path: &str) -> bool {
894        self.disabled_paths.iter().any(|disabled| disabled == path)
895    }
896    pub fn validate(&self) -> Result<(), AuthError> {
897        if self.secrets.is_some() {
898            return self.encryption_secret().validate(&self.logger);
899        }
900        if self.secret.is_empty() {
901            return Err(AuthError::config("Secret key cannot be empty"));
902        }
903
904        secrets::warn_secret_strength(&self.secret, &self.logger);
905        Ok(())
906    }
907}
908
909/// Check whether a URL is a safe relative path.
910///
911/// A relative path is safe if it starts with a single `/` and has no
912/// traversal or scheme-escape tricks (`//`, `\`, `%2f`, `%5c`).
913///
914/// This is used by [`AuthConfig::is_redirect_target_trusted`] and the
915/// CSRF middleware.
916pub fn is_safe_relative_path(value: &str) -> bool {
917    if !value.starts_with('/') || value.starts_with("//") || value.contains('\\') {
918        return false;
919    }
920
921    let tail = &value[1..];
922    let lower = tail.to_ascii_lowercase();
923    !lower.starts_with("%2f") && !lower.starts_with("%5c")
924}
925
926/// Extract the origin (scheme + host + port) from a URL string.
927///
928/// For example, `"https://example.com/path"` → `"https://example.com"`.
929///
930/// This is used by [`AuthConfig::is_origin_trusted`] and the CSRF middleware
931/// so that origin comparison is centralised in one place.
932pub fn extract_origin(url: &str) -> Option<String> {
933    let scheme_end = url.find("://")?;
934    let rest = &url[scheme_end + 3..];
935    let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
936    let origin = format!("{}{}", &url[..scheme_end + 3], &rest[..host_end]);
937    Some(origin)
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943
944    // ── extract_origin ──────────────────────────────────────────────────
945
946    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
947    #[test]
948    fn extract_origin_with_path() {
949        assert_eq!(
950            extract_origin("https://example.com/path"),
951            Some("https://example.com".to_string())
952        );
953    }
954
955    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
956    #[test]
957    fn extract_origin_without_path() {
958        assert_eq!(
959            extract_origin("https://example.com"),
960            Some("https://example.com".to_string())
961        );
962    }
963
964    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
965    #[test]
966    fn extract_origin_with_port() {
967        assert_eq!(
968            extract_origin("http://localhost:3000/api"),
969            Some("http://localhost:3000".to_string())
970        );
971    }
972
973    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
974    #[test]
975    fn extract_origin_with_query() {
976        assert_eq!(
977            extract_origin("https://example.com?foo=bar"),
978            Some("https://example.com".to_string())
979        );
980    }
981
982    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
983    #[test]
984    fn extract_origin_with_fragment() {
985        assert_eq!(
986            extract_origin("https://example.com#fragment"),
987            Some("https://example.com".to_string())
988        );
989    }
990
991    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
992    #[test]
993    fn extract_origin_no_scheme() {
994        assert_eq!(extract_origin("example.com"), None);
995    }
996
997    // ── AuthConfig::new ─────────────────────────────────────────────────
998
999    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1000    #[test]
1001    fn new_config_sets_secret() {
1002        let cfg = AuthConfig::new("a]secret-that-is-at-least-32-characters-long");
1003        assert_eq!(cfg.secret, "a]secret-that-is-at-least-32-characters-long");
1004    }
1005
1006    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1007    #[test]
1008    fn new_config_uses_defaults() {
1009        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1010        assert_eq!(cfg.app_name, "Better Auth");
1011        assert!(matches!(cfg.base_url, BaseUrl::Auto));
1012        assert_eq!(cfg.base_path, "/api/auth");
1013        assert!(cfg.trusted_origins.is_none());
1014    }
1015
1016    // ── Builder methods ─────────────────────────────────────────────────
1017
1018    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1019    #[test]
1020    fn base_url_sets_cookie_secure_for_https() {
1021        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1022        assert_eq!(
1023            cfg.auth_cookie("session_token", Default::default())
1024                .attributes
1025                .secure,
1026            Some(true)
1027        );
1028    }
1029
1030    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1031    #[test]
1032    fn base_url_clears_cookie_secure_for_http() {
1033        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1034            .base_url("https://myapp.com")
1035            .base_url("http://localhost:3000");
1036        assert_eq!(
1037            cfg.auth_cookie("session_token", Default::default())
1038                .attributes
1039                .secure,
1040            Some(false)
1041        );
1042    }
1043
1044    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1045    #[test]
1046    fn builder_chaining() {
1047        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1048            .app_name("MyApp")
1049            .base_path("/auth")
1050            .password_min_length(12)
1051            .disable_csrf_check(true)
1052            .disable_origin_check(true)
1053            .cookie_prefix("myapp");
1054
1055        assert_eq!(cfg.app_name, "MyApp");
1056        assert_eq!(cfg.base_path, "/auth");
1057        assert_eq!(cfg.password.min_length, 12);
1058        assert_eq!(cfg.advanced.disable_csrf_check, Some(true));
1059        assert!(cfg.advanced.disable_origin_check);
1060        assert_eq!(cfg.advanced.cookie_prefix, Some("myapp".to_string()));
1061    }
1062
1063    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1064    #[test]
1065    fn trusted_origin_appends() {
1066        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1067            .trusted_origin("https://a.com")
1068            .trusted_origin("https://b.com");
1069        assert_eq!(cfg.trusted_origin_values().as_static().unwrap().len(), 2);
1070    }
1071
1072    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1073    #[test]
1074    fn trusted_origins_replaces() {
1075        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1076            .trusted_origin("https://old.com")
1077            .trusted_origins(vec!["https://new.com".to_string()]);
1078        assert_eq!(
1079            cfg.trusted_origin_values().as_static().unwrap(),
1080            &["https://new.com"]
1081        );
1082    }
1083
1084    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1085    #[test]
1086    fn disabled_path_appends() {
1087        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1088            .disabled_path("/admin")
1089            .disabled_path("/debug");
1090        assert_eq!(cfg.disabled_paths.len(), 2);
1091    }
1092
1093    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1094    #[test]
1095    fn disabled_paths_replaces() {
1096        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1097            .disabled_path("/old")
1098            .disabled_paths(vec!["/new".to_string()]);
1099        assert_eq!(cfg.disabled_paths, vec!["/new"]);
1100    }
1101
1102    // ── is_origin_trusted ───────────────────────────────────────────────
1103
1104    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1105    #[test]
1106    fn is_origin_trusted_matches_base_url() {
1107        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1108        assert!(cfg.is_origin_trusted("https://myapp.com"));
1109    }
1110
1111    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1112    #[test]
1113    fn is_origin_trusted_rejects_unknown() {
1114        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1115        assert!(!cfg.is_origin_trusted("https://evil.com"));
1116    }
1117
1118    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1119    #[test]
1120    fn is_origin_trusted_glob_pattern() {
1121        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1122            .trusted_origin("https://*.example.com");
1123        assert!(cfg.is_origin_trusted("https://sub.example.com"));
1124        assert!(!cfg.is_origin_trusted("https://other.com"));
1125    }
1126
1127    // ── is_redirect_target_trusted ─────────────────────────────────────
1128
1129    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1130    #[test]
1131    fn redirect_target_allows_relative_path() {
1132        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1133        assert!(cfg.is_redirect_target_trusted("/dashboard"));
1134        assert!(cfg.is_redirect_target_trusted("/callback?foo=bar"));
1135    }
1136
1137    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1138    #[test]
1139    fn redirect_target_allows_same_origin() {
1140        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1141        assert!(cfg.is_redirect_target_trusted("https://myapp.com/verified"));
1142    }
1143
1144    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1145    #[test]
1146    fn redirect_target_allows_trusted_origin() {
1147        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1148            .base_url("https://myapp.com")
1149            .trusted_origin("https://trusted.com");
1150        assert!(cfg.is_redirect_target_trusted("https://trusted.com/path"));
1151    }
1152
1153    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1154    #[test]
1155    fn redirect_target_rejects_untrusted_origin() {
1156        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1157        assert!(!cfg.is_redirect_target_trusted("https://evil.com/phish"));
1158    }
1159
1160    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1161    #[test]
1162    fn redirect_target_rejects_protocol_relative() {
1163        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1164        assert!(!cfg.is_redirect_target_trusted("//evil.com"));
1165    }
1166
1167    // ── is_path_disabled ────────────────────────────────────────────────
1168
1169    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1170    #[test]
1171    fn is_path_disabled_matches() {
1172        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").disabled_path("/admin");
1173        assert!(cfg.is_path_disabled("/admin"));
1174        assert!(!cfg.is_path_disabled("/user"));
1175    }
1176
1177    // ── validate ────────────────────────────────────────────────────────
1178
1179    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1180    #[test]
1181    fn validate_rejects_empty_secret() {
1182        let cfg = AuthConfig::default();
1183        assert!(cfg.validate().is_err());
1184    }
1185
1186    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1187    #[test]
1188    fn validate_accepts_short_secret_with_warning() {
1189        let cfg = AuthConfig::new("short");
1190        assert!(cfg.validate().is_ok());
1191    }
1192
1193    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1194    #[test]
1195    fn validate_accepts_valid_secret() {
1196        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1197        assert!(cfg.validate().is_ok());
1198    }
1199
1200    // ── Defaults ────────────────────────────────────────────────────────
1201
1202    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1203    #[test]
1204    fn session_config_defaults() {
1205        let s = SessionConfig::default();
1206        assert_eq!(s.expires_in, None);
1207        assert_eq!(s.expires_in(), Duration::hours(24 * 7));
1208        assert_eq!(s.update_age, None);
1209        assert_eq!(s.update_age(), Duration::hours(24));
1210        assert!(!s.disable_session_refresh());
1211        let cookie = AuthConfig::default().auth_cookie("session_token", Default::default());
1212        assert_eq!(cookie.name, "better-auth.session_token");
1213        assert_eq!(cookie.attributes.http_only, Some(true));
1214        assert_eq!(cookie.attributes.same_site, Some(SameSite::Lax));
1215    }
1216
1217    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1218    #[test]
1219    fn jwt_config_defaults() {
1220        let j = JwtConfig::default();
1221        assert_eq!(j.expires_in, Duration::hours(24));
1222        assert_eq!(j.algorithm, "HS256");
1223    }
1224
1225    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1226    #[test]
1227    fn password_config_defaults() {
1228        let p = PasswordConfig::default();
1229        assert_eq!(p.min_length, 8);
1230        assert!(!p.require_uppercase);
1231    }
1232
1233    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1234    #[test]
1235    fn same_site_display() {
1236        assert_eq!(SameSite::Strict.to_string(), "Strict");
1237        assert_eq!(SameSite::Lax.to_string(), "Lax");
1238        assert_eq!(SameSite::None.to_string(), "None");
1239    }
1240
1241    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1242    #[test]
1243    fn cookie_cache_config_defaults() {
1244        let c = CookieCacheConfig::default();
1245        assert!(!c.enabled());
1246        assert_eq!(c.max_age(), Duration::minutes(5));
1247        assert_eq!(c.strategy(), CookieCacheStrategy::Compact);
1248    }
1249
1250    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1251    #[test]
1252    fn account_config_defaults() {
1253        let a = AccountConfig::default();
1254        assert!(a.update_account_on_sign_in());
1255        assert!(!a.encrypt_oauth_tokens());
1256        assert!(a.account_linking.enabled());
1257    }
1258
1259    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1260    #[test]
1261    fn core_paths_error_page() {
1262        let html = core_paths::error_page_html("TEST_ERROR");
1263        assert!(html.contains("TEST_ERROR"));
1264        assert!(html.contains("Ask AI"));
1265        assert!(html.contains("<title>Error</title>"));
1266    }
1267
1268    // Upstream reference: packages/better-auth/src/api/routes/error.ts :: sanitize function and /^[A-Za-z0-9_'-]+$/ whitelist.
1269    #[test]
1270    fn error_page_sanitizes_script_tag() {
1271        let html = core_paths::error_page_html("<script>alert(1)</script>");
1272        assert!(html.contains("UNKNOWN"));
1273        assert!(!html.contains("<script>"));
1274    }
1275
1276    // Upstream reference: packages/better-auth/src/api/routes/error.ts :: sanitize function and /^[A-Za-z0-9_'-]+$/ whitelist.
1277    #[test]
1278    fn error_page_allows_valid_codes() {
1279        assert!(core_paths::error_page_html("SOME_ERROR-CODE").contains("SOME_ERROR-CODE"));
1280        assert!(core_paths::error_page_html("it's").contains("it's"));
1281    }
1282
1283    // ── session builder methods ─────────────────────────────────────────
1284
1285    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1286    #[test]
1287    fn session_builder_methods() {
1288        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1289            .session_expires_in(Duration::hours(1))
1290            .session_update_age(Duration::minutes(30))
1291            .disable_session_refresh(true)
1292            .session_fresh_age(Duration::minutes(5));
1293
1294        assert_eq!(cfg.session.expires_in(), Duration::hours(1));
1295        assert_eq!(cfg.session.update_age, Some(Duration::minutes(30)));
1296        assert!(cfg.session.disable_session_refresh());
1297        assert_eq!(cfg.session.fresh_age, Some(Duration::minutes(5)));
1298    }
1299
1300    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1301    #[test]
1302    fn session_cookie_cache_builder() {
1303        let cache = CookieCacheConfig {
1304            enabled: Some(true),
1305            max_age: Some(Duration::minutes(10)),
1306            strategy: Some(CookieCacheStrategy::Jwt),
1307            ..Default::default()
1308        };
1309        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").session_cookie_cache(cache);
1310
1311        let cc = cfg.session.cookie_cache.as_ref();
1312        assert!(cc.is_some());
1313        let cc = cc.unwrap();
1314        assert!(cc.enabled());
1315        assert_eq!(cc.strategy(), CookieCacheStrategy::Jwt);
1316    }
1317
1318    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1319    #[test]
1320    fn cross_sub_domain_cookies_builder() {
1321        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1322            .cross_sub_domain_cookies(".example.com");
1323        let csd = cfg.advanced.cross_sub_domain_cookies.as_ref();
1324        assert!(csd.is_some());
1325        assert_eq!(csd.unwrap().domain.as_deref(), Some(".example.com"));
1326    }
1327
1328    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1329    #[test]
1330    fn advanced_database_defaults() {
1331        let d = AdvancedDatabaseConfig::default();
1332        assert_eq!(d.default_find_many_limit, None);
1333        assert!(d.generate_id.is_none());
1334    }
1335
1336    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1337    #[test]
1338    fn ip_address_config_defaults() {
1339        let ip = IpAddressConfig::default();
1340        assert_eq!(ip.headers, None);
1341        assert_eq!(ip.disable_ip_tracking, None);
1342    }
1343}