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 = "/__test/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    fn valid_error_code(input: &str) -> bool {
23        !input.is_empty()
24            && input
25                .chars()
26                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '\'')
27    }
28
29    fn is_preserved_entity(input: &str) -> bool {
30        input.starts_with("amp;")
31            || input.starts_with("lt;")
32            || input.starts_with("gt;")
33            || input.starts_with("quot;")
34            || input.starts_with("#39;")
35            || input.strip_prefix("#x").is_some_and(|hex| {
36                let Some(hex) = hex.strip_suffix(';') else {
37                    return false;
38                };
39                !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit())
40            })
41            || input.strip_prefix('#').is_some_and(|digits| {
42                let Some(digits) = digits.strip_suffix(';') else {
43                    return false;
44                };
45                !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
46            })
47    }
48
49    fn sanitize_html(input: &str) -> String {
50        let mut out = String::with_capacity(input.len());
51
52        for (idx, ch) in input.char_indices() {
53            match ch {
54                '<' => out.push_str("&lt;"),
55                '>' => out.push_str("&gt;"),
56                '"' => out.push_str("&quot;"),
57                '\'' => out.push_str("&#39;"),
58                '&' => {
59                    let rest = &input[idx + ch.len_utf8()..];
60                    if is_preserved_entity(rest) {
61                        out.push('&');
62                    } else {
63                        out.push_str("&amp;");
64                    }
65                }
66                _ => out.push(ch),
67            }
68        }
69
70        out
71    }
72
73    fn default_error_description(code: &str) -> String {
74        format!(
75            "We encountered an unexpected error. Please try again or return to the home page. If you're a developer, you can find more information about the error <a href='https://better-auth.com/docs/reference/errors/{code}' target='_blank' rel=\"noopener noreferrer\" style='color: var(--foreground); text-decoration: underline;'>here</a>."
76        )
77    }
78
79    /// Build the HTML error page returned by `GET /error`.
80    ///
81    /// Matches the current TS better-auth error page renderer.
82    pub fn error_page_html(error_code: &str) -> String {
83        error_page_html_with_description(error_code, None)
84    }
85
86    /// Build the HTML error page returned by `GET /error`, optionally
87    /// overriding the default description text.
88    pub fn error_page_html_with_description(
89        error_code: &str,
90        error_description: Option<&str>,
91    ) -> String {
92        let safe_code = if valid_error_code(error_code) {
93            error_code
94        } else {
95            "UNKNOWN"
96        };
97        let description = error_description
98            .map(sanitize_html)
99            .unwrap_or_else(|| default_error_description(safe_code));
100        let ask_ai_query = format!("What%20does%20the%20error%20code%20{safe_code}%20mean%3F");
101
102        format!(
103            r#"<!DOCTYPE html>
104<html lang="en">
105  <head>
106    <meta charset="UTF-8" />
107    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
108    <title>Error</title>
109    <style>
110      * {{
111        box-sizing: border-box;
112      }}
113      body {{
114        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
115        background: var(--background);
116        color: var(--foreground);
117        margin: 0;
118      }}
119      :root,
120      :host {{
121        --spacing: 0.25rem;
122        --container-md: 28rem;
123        --text-sm: 0.875rem;
124        --text-sm--line-height: calc(1.25 / 0.875);
125        --text-2xl: 1.5rem;
126        --text-2xl--line-height: calc(2 / 1.5);
127        --text-4xl: 2.25rem;
128        --text-4xl--line-height: calc(2.5 / 2.25);
129        --text-6xl: 3rem;
130        --text-6xl--line-height: 1;
131        --font-weight-medium: 500;
132        --font-weight-semibold: 600;
133        --font-weight-bold: 700;
134        --default-transition-duration: 150ms;
135        --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
136        --radius: 0.625rem;
137        --default-mono-font-family: var(--font-geist-mono);
138        --primary: black;
139        --primary-foreground: white;
140        --background: white;
141        --foreground: oklch(0.271 0 0);
142        --border: oklch(0.89 0 0);
143        --destructive: oklch(0.55 0.15 25.723);
144        --muted-foreground: oklch(0.545 0 0);
145        --corner-border: #404040;
146      }}
147
148      button, .btn {{
149        cursor: pointer;
150        background: none;
151        border: none;
152        color: inherit;
153        font: inherit;
154        transition: all var(--default-transition-duration)
155          var(--default-transition-timing-function);
156      }}
157      button:hover, .btn:hover {{
158        opacity: 0.8;
159      }}
160
161      @media (prefers-color-scheme: dark) {{
162        :root,
163        :host {{
164          --primary: white;
165          --primary-foreground: black;
166          --background: oklch(0.15 0 0);
167          --foreground: oklch(0.98 0 0);
168          --border: oklch(0.27 0 0);
169          --destructive: oklch(0.65 0.15 25.723);
170          --muted-foreground: oklch(0.65 0 0);
171          --corner-border: #a0a0a0;
172        }}
173      }}
174      @media (max-width: 640px) {{
175        :root, :host {{
176          --text-6xl: 2.5rem;
177          --text-2xl: 1.25rem;
178          --text-sm: 0.8125rem;
179        }}
180      }}
181      @media (max-width: 480px) {{
182        :root, :host {{
183          --text-6xl: 2rem;
184          --text-2xl: 1.125rem;
185        }}
186      }}
187    </style>
188  </head>
189  <body style="width: 100vw; min-height: 100vh; overflow-x: hidden; overflow-y: auto;">
190    <div
191        style="
192            display: flex;
193            flex-direction: column;
194            align-items: center;
195            justify-content: center;
196            gap: 1.5rem;
197            position: relative;
198            width: 100%;
199            min-height: 100vh;
200            padding: 1rem;
201        "
202        >
203
204      <div
205        style="
206          position: absolute;
207          inset: 0;
208          background-image: linear-gradient(to right, var(--border) 1px, transparent 1px),
209            linear-gradient(to bottom, var(--border) 1px, transparent 1px);
210          background-size: 40px 40px;
211          opacity: 0.6;
212          pointer-events: none;
213          width: 100vw;
214          height: 100vh;
215        "
216      ></div>
217      <div
218        style="
219          position: absolute;
220          inset: 0;
221          display: flex;
222          align-items: center;
223          justify-content: center;
224          background: var(--background);
225          mask-image: radial-gradient(ellipse at center, transparent 20%, black);
226          -webkit-mask-image: radial-gradient(ellipse at center, transparent 20%, black);
227          pointer-events: none;
228        "
229      ></div>
230
231
232<div
233  style="
234    position: relative;
235    z-index: 10;
236    border: 2px solid var(--border);
237    background: var(--background);
238    padding: 1.5rem;
239    max-width: 42rem;
240    width: 100%;
241  "
242>
243    
244        <!-- Corner decorations -->
245        <div
246          style="
247            position: absolute;
248            top: -2px;
249            left: -2px;
250            width: 2rem;
251            height: 2rem;
252            border-top: 4px solid var(--corner-border);
253            border-left: 4px solid var(--corner-border);
254          "
255        ></div>
256        <div
257          style="
258            position: absolute;
259            top: -2px;
260            right: -2px;
261            width: 2rem;
262            height: 2rem;
263            border-top: 4px solid var(--corner-border);
264            border-right: 4px solid var(--corner-border);
265          "
266        ></div>
267  
268        <div
269          style="
270            position: absolute;
271            bottom: -2px;
272            left: -2px;
273            width: 2rem;
274            height: 2rem;
275            border-bottom: 4px solid var(--corner-border);
276            border-left: 4px solid var(--corner-border);
277          "
278        ></div>
279        <div
280          style="
281            position: absolute;
282            bottom: -2px;
283            right: -2px;
284            width: 2rem;
285            height: 2rem;
286            border-bottom: 4px solid var(--corner-border);
287            border-right: 4px solid var(--corner-border);
288          "
289        ></div>
290
291        <div style="text-align: center; margin-bottom: 1.5rem;">
292          <div style="margin-bottom: 1.5rem;">
293            <div
294              style="
295                display: inline-block;
296                border: 2px solid var(--destructive);
297                padding: 0.375rem 1rem;
298              "
299            >
300              <h1
301                style="
302                  font-size: var(--text-6xl);
303                  font-weight: var(--font-weight-semibold);
304                  color: var(--foreground);
305                  letter-spacing: -0.02em;
306                  margin: 0;
307                "
308              >
309                ERROR
310              </h1>
311            </div>
312            <div
313              style="
314                height: 2px;
315                background-color: var(--border);
316                width: calc(100% + 3rem);
317                margin-left: -1.5rem;
318                margin-top: 1.5rem;
319              "
320            ></div>
321          </div>
322
323          <h2
324            style="
325              font-size: var(--text-2xl);
326              font-weight: var(--font-weight-semibold);
327              color: var(--foreground);
328              margin: 0 0 1rem;
329            "
330          >
331            Something went wrong
332          </h2>
333
334          <div
335            style="
336                display: inline-flex;
337                align-items: center;
338                gap: 0.5rem;
339                border: 2px solid var(--border);
340                background-color: var(--muted);
341                padding: 0.375rem 0.75rem;
342                margin: 0 0 1rem;
343                flex-wrap: wrap;
344                justify-content: center;
345            "
346            >
347            <span
348                style="
349                font-size: 0.75rem;
350                color: var(--muted-foreground);
351                font-weight: var(--font-weight-semibold);
352                "
353            >
354                CODE:
355            </span>
356            <span
357                style="
358                font-size: var(--text-sm);
359                font-family: var(--default-mono-font-family, monospace);
360                color: var(--foreground);
361                word-break: break-all;
362                "
363            >
364                {safe_code}
365            </span>
366            </div>
367
368          <p
369            style="
370              color: var(--muted-foreground);
371              max-width: 28rem;
372              margin: 0 auto;
373              font-size: var(--text-sm);
374              line-height: 1.5;
375              text-wrap: pretty;
376            "
377          >
378            {description}
379          </p>
380        </div>
381
382        <div
383          style="
384            display: flex;
385            gap: 0.75rem;
386            margin-top: 1.5rem;
387            justify-content: center;
388            flex-wrap: wrap;
389          "
390        >
391          <a
392            href="/"
393            style="
394              text-decoration: none;
395            "
396          >
397            <div
398              style="
399                border: 2px solid var(--border);
400                background: var(--primary);
401                color: var(--primary-foreground);
402                padding: 0.5rem 1rem;
403                border-radius: 0;
404                white-space: nowrap;
405              "
406              class="btn"
407            >
408              Go Home
409            </div>
410          </a>
411          <a
412            href="https://better-auth.com/docs/reference/errors/{safe_code}?askai={ask_ai_query}"
413            target="_blank"
414            rel="noopener noreferrer"
415            style="
416              text-decoration: none;
417            "
418          >
419            <div
420              style="
421                border: 2px solid var(--border);
422                background: transparent;
423                color: var(--foreground);
424                padding: 0.5rem 1rem;
425                border-radius: 0;
426                white-space: nowrap;
427              "
428              class="btn"
429            >
430              Ask AI
431            </div>
432          </a>
433        </div>
434      </div>
435    </div>
436  </body>
437</html>"#
438        )
439    }
440}
441
442/// Main configuration for BetterAuth
443#[derive(Clone)]
444pub struct AuthConfig {
445    /// Secret key for signing tokens and sessions
446    pub secret: String,
447
448    /// Application name, used for cookie prefixes, email templates, etc.
449    ///
450    /// Defaults to `"Better Auth"`.
451    pub app_name: String,
452
453    /// Base URL for the authentication service (e.g. `"http://localhost:3000"`).
454    pub base_url: String,
455
456    /// Base path where the auth routes are mounted.
457    ///
458    /// All routes handled by BetterAuth will be prefixed with this path.
459    /// For example, with the default `"/api/auth"`, the sign-in route becomes
460    /// `"/api/auth/sign-in/email"`.
461    ///
462    /// Defaults to `"/api/auth"`.
463    pub base_path: String,
464
465    /// Origins that are trusted for CSRF and other cross-origin checks.
466    ///
467    /// Supports glob patterns (e.g. `"https://*.example.com"`).
468    /// These are shared across all middleware that needs origin validation
469    /// (CSRF, CORS, etc.).
470    pub trusted_origins: Vec<String>,
471
472    /// Paths that should be disabled (skipped) by the router.
473    ///
474    /// Any request whose path matches an entry in this list will receive
475    /// a 404 response, even if a handler is registered for it.
476    pub disabled_paths: Vec<String>,
477    /// Session configuration
478    pub session: SessionConfig,
479
480    /// JWT configuration
481    pub jwt: JwtConfig,
482
483    /// Password configuration
484    pub password: PasswordConfig,
485
486    /// Account configuration (linking, token encryption, etc.)
487    pub account: AccountConfig,
488
489    /// Email provider for sending emails (verification, password reset, etc.)
490    pub email_provider: Option<Arc<dyn EmailProvider>>,
491
492    /// Advanced configuration options
493    pub advanced: AdvancedConfig,
494}
495
496/// Account-level configuration: linking, token encryption, sign-in behavior.
497#[derive(Debug, Clone)]
498pub struct AccountConfig {
499    /// Update OAuth tokens on every sign-in (default: true)
500    pub update_account_on_sign_in: bool,
501    /// Account linking settings
502    pub account_linking: AccountLinkingConfig,
503    /// Encrypt OAuth tokens at rest (default: false)
504    pub encrypt_oauth_tokens: bool,
505    /// Store account data in an account cookie for OAuth-backed access token flows.
506    pub store_account_cookie: bool,
507    /// Where to persist OAuth state during the authorization flow.
508    pub store_state_strategy: OAuthStateStrategy,
509    /// Skip state-cookie verification during callback processing.
510    ///
511    /// This is security-sensitive and should stay disabled in normal use.
512    pub skip_state_cookie_check: bool,
513}
514
515/// Settings that control how OAuth accounts are linked to existing users.
516#[derive(Debug, Clone)]
517pub struct AccountLinkingConfig {
518    /// Enable account linking (default: true)
519    pub enabled: bool,
520    /// Trusted providers that can auto-link (default: empty = all trusted)
521    pub trusted_providers: Vec<String>,
522    /// Allow linking accounts with different emails (default: false) - SECURITY WARNING
523    pub allow_different_emails: bool,
524    /// Allow unlinking all accounts (default: false)
525    pub allow_unlinking_all: bool,
526    /// Disable implicit linking during sign-in; only explicit link-social may link.
527    pub disable_implicit_linking: bool,
528    /// Update user info when a new account is linked (default: false)
529    pub update_user_info_on_link: bool,
530}
531
532/// Strategy for persisting OAuth state between the sign-in and callback steps.
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
534pub enum OAuthStateStrategy {
535    /// Persist state in an encrypted cookie.
536    #[default]
537    Cookie,
538    /// Persist state in the verification store plus a signed state cookie.
539    Database,
540}
541
542/// Session-specific configuration
543#[derive(Debug, Clone)]
544pub struct SessionConfig {
545    /// Session expiration duration
546    pub expires_in: Duration,
547
548    /// How often to refresh the session expiry (as a Duration).
549    ///
550    /// When set, session expiry is only updated if the session is older than
551    /// this duration since the last update. When `None`, every request
552    /// refreshes the session (equivalent to the old `update_age: true`).
553    pub update_age: Option<Duration>,
554
555    /// If `true`, sessions are never automatically refreshed on access.
556    pub disable_session_refresh: bool,
557
558    /// Session freshness window. A session younger than this is considered
559    /// "fresh" (useful for step-up auth or sensitive operations).
560    pub fresh_age: Option<Duration>,
561
562    /// Cookie name for session token
563    pub cookie_name: String,
564
565    /// Cookie settings
566    pub cookie_secure: bool,
567    pub cookie_http_only: bool,
568    pub cookie_same_site: SameSite,
569
570    /// Optional cookie-based session cache to avoid DB lookups.
571    ///
572    /// When enabled, session data is cached in a signed/encrypted cookie.
573    /// `SessionManager` checks the cookie cache before hitting the database.
574    pub cookie_cache: Option<CookieCacheConfig>,
575}
576
577/// JWT configuration
578#[derive(Debug, Clone)]
579pub struct JwtConfig {
580    /// JWT expiration duration
581    pub expires_in: Duration,
582
583    /// JWT algorithm
584    pub algorithm: String,
585
586    /// Issuer claim
587    pub issuer: Option<String>,
588
589    /// Audience claim
590    pub audience: Option<String>,
591}
592
593/// Password hashing configuration
594#[derive(Debug, Clone)]
595pub struct PasswordConfig {
596    /// Minimum password length
597    pub min_length: usize,
598
599    /// Require uppercase letters
600    pub require_uppercase: bool,
601
602    /// Require lowercase letters
603    pub require_lowercase: bool,
604
605    /// Require numbers
606    pub require_numbers: bool,
607
608    /// Require special characters
609    pub require_special: bool,
610
611    /// Argon2 configuration
612    pub argon2_config: Argon2Config,
613}
614
615/// Argon2 hashing configuration
616#[derive(Debug, Clone)]
617pub struct Argon2Config {
618    pub memory_cost: u32,
619    pub time_cost: u32,
620    pub parallelism: u32,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq)]
624pub enum SameSite {
625    Strict,
626    Lax,
627    None,
628}
629
630/// Configuration for cookie-based session caching.
631///
632/// When enabled, session data is stored in a signed or encrypted cookie so that
633/// subsequent requests can skip the database lookup.
634#[derive(Debug, Clone)]
635pub struct CookieCacheConfig {
636    /// Whether the cookie cache is active.
637    pub enabled: bool,
638
639    /// Maximum age of the cached cookie before a fresh DB lookup is required.
640    ///
641    /// Default: 5 minutes.
642    pub max_age: Duration,
643
644    /// Strategy used to protect the cached cookie value.
645    pub strategy: CookieCacheStrategy,
646}
647
648/// Strategy for signing / encrypting the cookie cache.
649#[derive(Debug, Clone, PartialEq, Eq)]
650pub enum CookieCacheStrategy {
651    /// Base64url-encoded payload + HMAC-SHA256 signature.
652    Compact,
653    /// Standard JWT with HMAC signing.
654    Jwt,
655    /// JWE with AES-256-GCM encryption.
656    Jwe,
657}
658
659impl Default for CookieCacheConfig {
660    fn default() -> Self {
661        Self {
662            enabled: false,
663            max_age: Duration::minutes(5),
664            strategy: CookieCacheStrategy::Compact,
665        }
666    }
667}
668
669impl Default for AccountConfig {
670    fn default() -> Self {
671        Self {
672            update_account_on_sign_in: true,
673            account_linking: AccountLinkingConfig::default(),
674            encrypt_oauth_tokens: false,
675            store_account_cookie: false,
676            store_state_strategy: OAuthStateStrategy::Database,
677            skip_state_cookie_check: false,
678        }
679    }
680}
681
682impl Default for AccountLinkingConfig {
683    fn default() -> Self {
684        Self {
685            enabled: true,
686            trusted_providers: Vec::new(),
687            allow_different_emails: false,
688            allow_unlinking_all: false,
689            disable_implicit_linking: false,
690            update_user_info_on_link: false,
691        }
692    }
693}
694
695impl std::fmt::Display for SameSite {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        match self {
698            SameSite::Strict => f.write_str("Strict"),
699            SameSite::Lax => f.write_str("Lax"),
700            SameSite::None => f.write_str("None"),
701        }
702    }
703}
704
705// ── Advanced configuration ──────────────────────────────────────────────
706
707/// Advanced configuration options (mirrors TS `advanced` block).
708#[derive(Debug, Clone, Default)]
709pub struct AdvancedConfig {
710    /// IP address extraction configuration.
711    pub ip_address: IpAddressConfig,
712
713    /// If `true`, the CSRF-check middleware is disabled.
714    pub disable_csrf_check: bool,
715
716    /// If `true`, callback / redirect target origin validation is skipped.
717    ///
718    /// This mirrors Better Auth TS `advanced.disableOriginCheck`.
719    /// It does **not** disable the request-origin CSRF checks.
720    pub disable_origin_check: bool,
721
722    /// Cross-subdomain cookie sharing configuration.
723    pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,
724
725    /// Per-cookie-name overrides (name, attributes, prefix).
726    ///
727    /// Keys are the *logical* cookie names (e.g. `"session_token"`,
728    /// `"csrf_token"`). Values specify the attributes to override.
729    pub cookies: HashMap<String, CookieOverride>,
730
731    /// Default cookie attributes applied to *every* cookie the library sets
732    /// (individual overrides in `cookies` take precedence).
733    pub default_cookie_attributes: CookieAttributes,
734
735    /// Optional prefix prepended to every cookie name (e.g. `"myapp"` →
736    /// `"myapp.session_token"`).
737    pub cookie_prefix: Option<String>,
738
739    /// Database-related advanced options.
740    pub database: AdvancedDatabaseConfig,
741
742    /// List of header names the framework trusts for extracting the
743    /// client's real IP when behind a proxy (e.g. `X-Forwarded-For`).
744    pub trusted_proxy_headers: Vec<String>,
745}
746
747/// IP-address extraction configuration.
748#[derive(Debug, Clone)]
749pub struct IpAddressConfig {
750    /// Ordered list of headers to check for the client IP.
751    /// Defaults to `["x-forwarded-for", "x-real-ip"]`.
752    pub headers: Vec<String>,
753
754    /// If `true`, IP tracking is entirely disabled (no IP stored in sessions).
755    pub disable_ip_tracking: bool,
756}
757
758/// Configuration for sharing cookies across sub-domains.
759#[derive(Debug, Clone)]
760pub struct CrossSubDomainConfig {
761    /// The parent domain (e.g. `".example.com"`).
762    pub domain: String,
763}
764
765/// Overridable cookie attributes.
766#[derive(Debug, Clone, Default)]
767pub struct CookieAttributes {
768    /// Override `Secure` flag.
769    pub secure: Option<bool>,
770    /// Override `HttpOnly` flag.
771    pub http_only: Option<bool>,
772    /// Override `SameSite` policy.
773    pub same_site: Option<SameSite>,
774    /// Override `Path`.
775    pub path: Option<String>,
776    /// Override `Max-Age` (seconds).
777    pub max_age: Option<i64>,
778    /// Override cookie `Domain`.
779    pub domain: Option<String>,
780}
781
782/// Per-cookie override entry.
783#[derive(Debug, Clone, Default)]
784pub struct CookieOverride {
785    /// Custom name to use instead of the logical name.
786    pub name: Option<String>,
787    /// Attribute overrides for this cookie.
788    pub attributes: CookieAttributes,
789}
790
791/// Database-related advanced options.
792#[derive(Debug, Clone)]
793pub struct AdvancedDatabaseConfig {
794    /// Default `LIMIT` for "find many" queries.
795    pub default_find_many_limit: usize,
796
797    /// If `true`, auto-generated IDs will be numeric (auto-increment style)
798    /// rather than UUIDs.
799    pub use_number_id: bool,
800}
801impl Default for AuthConfig {
802    fn default() -> Self {
803        Self {
804            secret: String::new(),
805            app_name: "Better Auth".to_string(),
806            base_url: "http://localhost:3000".to_string(),
807            base_path: "/api/auth".to_string(),
808            trusted_origins: Vec::new(),
809            disabled_paths: Vec::new(),
810            session: SessionConfig::default(),
811            jwt: JwtConfig::default(),
812            password: PasswordConfig::default(),
813            account: AccountConfig::default(),
814            email_provider: None,
815            advanced: AdvancedConfig::default(),
816        }
817    }
818}
819
820impl Default for SessionConfig {
821    fn default() -> Self {
822        Self {
823            expires_in: Duration::hours(24 * 7),   // 7 days
824            update_age: Some(Duration::hours(24)), // refresh once per day
825            disable_session_refresh: false,
826            fresh_age: None,
827            cookie_name: "better-auth.session_token".to_string(),
828            // Secure flag is derived from base_url scheme (HTTPS → true).
829            // Default base_url is http://localhost:3000, so default is false.
830            cookie_secure: false,
831            cookie_http_only: true,
832            cookie_same_site: SameSite::Lax,
833            cookie_cache: None,
834        }
835    }
836}
837
838impl Default for IpAddressConfig {
839    fn default() -> Self {
840        Self {
841            headers: vec!["x-forwarded-for".to_string(), "x-real-ip".to_string()],
842            disable_ip_tracking: false,
843        }
844    }
845}
846
847impl Default for AdvancedDatabaseConfig {
848    fn default() -> Self {
849        Self {
850            default_find_many_limit: 100,
851            use_number_id: false,
852        }
853    }
854}
855
856impl Default for JwtConfig {
857    fn default() -> Self {
858        Self {
859            expires_in: Duration::hours(24), // 1 day
860            algorithm: "HS256".to_string(),
861            issuer: None,
862            audience: None,
863        }
864    }
865}
866
867impl Default for PasswordConfig {
868    fn default() -> Self {
869        Self {
870            min_length: 8,
871            require_uppercase: false,
872            require_lowercase: false,
873            require_numbers: false,
874            require_special: false,
875            argon2_config: Argon2Config::default(),
876        }
877    }
878}
879
880impl Default for Argon2Config {
881    fn default() -> Self {
882        Self {
883            memory_cost: 4096, // 4MB
884            time_cost: 3,      // 3 iterations
885            parallelism: 1,    // 1 thread
886        }
887    }
888}
889
890impl AuthConfig {
891    pub fn new(secret: impl Into<String>) -> Self {
892        Self {
893            secret: secret.into(),
894            ..Default::default()
895        }
896    }
897
898    /// Set the application name.
899    pub fn app_name(mut self, name: impl Into<String>) -> Self {
900        self.app_name = name.into();
901        self
902    }
903
904    /// Set the base URL (e.g. `"https://myapp.com"`).
905    ///
906    /// Also updates `session.cookie_secure` to match the URL scheme:
907    /// HTTPS URLs set `Secure=true`, HTTP URLs set `Secure=false`.
908    pub fn base_url(mut self, url: impl Into<String>) -> Self {
909        self.base_url = url.into();
910        self.session.cookie_secure = self.base_url.starts_with("https://");
911        self
912    }
913
914    pub fn account(mut self, account: AccountConfig) -> Self {
915        self.account = account;
916        self
917    }
918
919    /// Set the base path where auth routes are mounted.
920    pub fn base_path(mut self, path: impl Into<String>) -> Self {
921        self.base_path = path.into();
922        self
923    }
924
925    /// Add a trusted origin. Supports glob patterns (e.g. `"https://*.example.com"`).
926    pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
927        self.trusted_origins.push(origin.into());
928        self
929    }
930
931    /// Set all trusted origins at once.
932    pub fn trusted_origins(mut self, origins: Vec<String>) -> Self {
933        self.trusted_origins = origins;
934        self
935    }
936
937    /// Add a path to the disabled paths list.
938    pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
939        self.disabled_paths.push(path.into());
940        self
941    }
942
943    /// Set all disabled paths at once.
944    pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
945        self.disabled_paths = paths;
946        self
947    }
948
949    /// Set the session expiration duration.
950    pub fn session_expires_in(mut self, duration: Duration) -> Self {
951        self.session.expires_in = duration;
952        self
953    }
954
955    pub fn session_update_age(mut self, duration: Duration) -> Self {
956        self.session.update_age = Some(duration);
957        self
958    }
959
960    pub fn disable_session_refresh(mut self, disabled: bool) -> Self {
961        self.session.disable_session_refresh = disabled;
962        self
963    }
964
965    pub fn session_fresh_age(mut self, duration: Duration) -> Self {
966        self.session.fresh_age = Some(duration);
967        self
968    }
969
970    /// Set the cookie cache configuration for sessions.
971    pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
972        self.session.cookie_cache = Some(config);
973        self
974    }
975
976    /// Set the JWT expiration duration.
977    pub fn jwt_expires_in(mut self, duration: Duration) -> Self {
978        self.jwt.expires_in = duration;
979        self
980    }
981
982    /// Set the minimum password length.
983    pub fn password_min_length(mut self, length: usize) -> Self {
984        self.password.min_length = length;
985        self
986    }
987
988    pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
989        self.advanced = advanced;
990        self
991    }
992
993    pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
994        self.advanced.cookie_prefix = Some(prefix.into());
995        self
996    }
997
998    pub fn disable_csrf_check(mut self, disabled: bool) -> Self {
999        self.advanced.disable_csrf_check = disabled;
1000        self
1001    }
1002
1003    pub fn disable_origin_check(mut self, disabled: bool) -> Self {
1004        self.advanced.disable_origin_check = disabled;
1005        self
1006    }
1007
1008    pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
1009        self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig {
1010            domain: domain.into(),
1011        });
1012        self
1013    }
1014
1015    /// Check whether a given origin is trusted.
1016    ///
1017    /// An origin is trusted if it matches:
1018    /// 1. The origin extracted from [`base_url`](Self::base_url), or
1019    /// 2. Any pattern in [`trusted_origins`](Self::trusted_origins) (after
1020    ///    extracting the origin portion from the pattern).
1021    ///
1022    /// Glob patterns are supported — `*` matches any characters except `/`,
1023    /// `**` matches any characters including `/`.
1024    pub fn is_origin_trusted(&self, origin: &str) -> bool {
1025        // Check base_url origin
1026        if let Some(base_origin) = extract_origin(&self.base_url)
1027            && origin == base_origin
1028        {
1029            return true;
1030        }
1031        // Check trusted_origins patterns
1032        self.trusted_origins.iter().any(|pattern| {
1033            let pattern_origin = extract_origin(pattern).unwrap_or_default();
1034            glob_match::glob_match(&pattern_origin, origin)
1035        })
1036    }
1037
1038    /// Check whether a URL is a safe redirect target.
1039    ///
1040    /// A URL is safe if it is a relative path (starts with `/`, no
1041    /// traversal tricks) or its origin matches [`base_url`](Self::base_url)
1042    /// or [`trusted_origins`](Self::trusted_origins).
1043    ///
1044    /// This is used by both the CSRF middleware (for POST body/query
1045    /// targets) and per-endpoint origin checks (e.g. verify-email GET).
1046    pub fn is_redirect_target_trusted(&self, url: &str) -> bool {
1047        if is_safe_relative_path(url) {
1048            return true;
1049        }
1050        extract_origin(url).is_some_and(|origin| self.is_origin_trusted(&origin))
1051    }
1052
1053    /// Check whether a given path is disabled.
1054    pub fn is_path_disabled(&self, path: &str) -> bool {
1055        self.disabled_paths.iter().any(|disabled| disabled == path)
1056    }
1057    pub fn validate(&self) -> Result<(), AuthError> {
1058        if self.secret.is_empty() {
1059            return Err(AuthError::config("Secret key cannot be empty"));
1060        }
1061
1062        if self.secret.len() < 32 {
1063            return Err(AuthError::config(
1064                "Secret key must be at least 32 characters",
1065            ));
1066        }
1067
1068        Ok(())
1069    }
1070}
1071
1072/// Check whether a URL is a safe relative path.
1073///
1074/// A relative path is safe if it starts with a single `/` and has no
1075/// traversal or scheme-escape tricks (`//`, `\`, `%2f`, `%5c`).
1076///
1077/// This is used by [`AuthConfig::is_redirect_target_trusted`] and the
1078/// CSRF middleware.
1079pub fn is_safe_relative_path(value: &str) -> bool {
1080    if !value.starts_with('/') || value.starts_with("//") || value.contains('\\') {
1081        return false;
1082    }
1083
1084    let tail = &value[1..];
1085    let lower = tail.to_ascii_lowercase();
1086    !lower.starts_with("%2f") && !lower.starts_with("%5c")
1087}
1088
1089/// Extract the origin (scheme + host + port) from a URL string.
1090///
1091/// For example, `"https://example.com/path"` → `"https://example.com"`.
1092///
1093/// This is used by [`AuthConfig::is_origin_trusted`] and the CSRF middleware
1094/// so that origin comparison is centralised in one place.
1095pub fn extract_origin(url: &str) -> Option<String> {
1096    let scheme_end = url.find("://")?;
1097    let rest = &url[scheme_end + 3..];
1098    let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1099    let origin = format!("{}{}", &url[..scheme_end + 3], &rest[..host_end]);
1100    Some(origin)
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106
1107    // ── extract_origin ──────────────────────────────────────────────────
1108
1109    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1110    #[test]
1111    fn extract_origin_with_path() {
1112        assert_eq!(
1113            extract_origin("https://example.com/path"),
1114            Some("https://example.com".to_string())
1115        );
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 extract_origin_without_path() {
1121        assert_eq!(
1122            extract_origin("https://example.com"),
1123            Some("https://example.com".to_string())
1124        );
1125    }
1126
1127    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1128    #[test]
1129    fn extract_origin_with_port() {
1130        assert_eq!(
1131            extract_origin("http://localhost:3000/api"),
1132            Some("http://localhost:3000".to_string())
1133        );
1134    }
1135
1136    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1137    #[test]
1138    fn extract_origin_with_query() {
1139        assert_eq!(
1140            extract_origin("https://example.com?foo=bar"),
1141            Some("https://example.com".to_string())
1142        );
1143    }
1144
1145    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1146    #[test]
1147    fn extract_origin_with_fragment() {
1148        assert_eq!(
1149            extract_origin("https://example.com#fragment"),
1150            Some("https://example.com".to_string())
1151        );
1152    }
1153
1154    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1155    #[test]
1156    fn extract_origin_no_scheme() {
1157        assert_eq!(extract_origin("example.com"), None);
1158    }
1159
1160    // ── AuthConfig::new ─────────────────────────────────────────────────
1161
1162    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1163    #[test]
1164    fn new_config_sets_secret() {
1165        let cfg = AuthConfig::new("a]secret-that-is-at-least-32-characters-long");
1166        assert_eq!(cfg.secret, "a]secret-that-is-at-least-32-characters-long");
1167    }
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 new_config_uses_defaults() {
1172        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1173        assert_eq!(cfg.app_name, "Better Auth");
1174        assert_eq!(cfg.base_url, "http://localhost:3000");
1175        assert_eq!(cfg.base_path, "/api/auth");
1176        assert!(cfg.trusted_origins.is_empty());
1177    }
1178
1179    // ── Builder methods ─────────────────────────────────────────────────
1180
1181    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1182    #[test]
1183    fn base_url_sets_cookie_secure_for_https() {
1184        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1185        assert!(cfg.session.cookie_secure);
1186    }
1187
1188    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1189    #[test]
1190    fn base_url_clears_cookie_secure_for_http() {
1191        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1192            .base_url("https://myapp.com")
1193            .base_url("http://localhost:3000");
1194        assert!(!cfg.session.cookie_secure);
1195    }
1196
1197    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1198    #[test]
1199    fn builder_chaining() {
1200        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1201            .app_name("MyApp")
1202            .base_path("/auth")
1203            .password_min_length(12)
1204            .disable_csrf_check(true)
1205            .disable_origin_check(true)
1206            .cookie_prefix("myapp");
1207
1208        assert_eq!(cfg.app_name, "MyApp");
1209        assert_eq!(cfg.base_path, "/auth");
1210        assert_eq!(cfg.password.min_length, 12);
1211        assert!(cfg.advanced.disable_csrf_check);
1212        assert!(cfg.advanced.disable_origin_check);
1213        assert_eq!(cfg.advanced.cookie_prefix, Some("myapp".to_string()));
1214    }
1215
1216    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1217    #[test]
1218    fn trusted_origin_appends() {
1219        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1220            .trusted_origin("https://a.com")
1221            .trusted_origin("https://b.com");
1222        assert_eq!(cfg.trusted_origins.len(), 2);
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 trusted_origins_replaces() {
1228        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1229            .trusted_origin("https://old.com")
1230            .trusted_origins(vec!["https://new.com".to_string()]);
1231        assert_eq!(cfg.trusted_origins, vec!["https://new.com"]);
1232    }
1233
1234    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1235    #[test]
1236    fn disabled_path_appends() {
1237        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1238            .disabled_path("/admin")
1239            .disabled_path("/debug");
1240        assert_eq!(cfg.disabled_paths.len(), 2);
1241    }
1242
1243    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1244    #[test]
1245    fn disabled_paths_replaces() {
1246        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1247            .disabled_path("/old")
1248            .disabled_paths(vec!["/new".to_string()]);
1249        assert_eq!(cfg.disabled_paths, vec!["/new"]);
1250    }
1251
1252    // ── is_origin_trusted ───────────────────────────────────────────────
1253
1254    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1255    #[test]
1256    fn is_origin_trusted_matches_base_url() {
1257        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1258        assert!(cfg.is_origin_trusted("https://myapp.com"));
1259    }
1260
1261    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1262    #[test]
1263    fn is_origin_trusted_rejects_unknown() {
1264        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1265        assert!(!cfg.is_origin_trusted("https://evil.com"));
1266    }
1267
1268    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1269    #[test]
1270    fn is_origin_trusted_glob_pattern() {
1271        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1272            .trusted_origin("https://*.example.com");
1273        assert!(cfg.is_origin_trusted("https://sub.example.com"));
1274        assert!(!cfg.is_origin_trusted("https://other.com"));
1275    }
1276
1277    // ── is_redirect_target_trusted ─────────────────────────────────────
1278
1279    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1280    #[test]
1281    fn redirect_target_allows_relative_path() {
1282        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1283        assert!(cfg.is_redirect_target_trusted("/dashboard"));
1284        assert!(cfg.is_redirect_target_trusted("/callback?foo=bar"));
1285    }
1286
1287    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1288    #[test]
1289    fn redirect_target_allows_same_origin() {
1290        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1291        assert!(cfg.is_redirect_target_trusted("https://myapp.com/verified"));
1292    }
1293
1294    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1295    #[test]
1296    fn redirect_target_allows_trusted_origin() {
1297        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1298            .base_url("https://myapp.com")
1299            .trusted_origin("https://trusted.com");
1300        assert!(cfg.is_redirect_target_trusted("https://trusted.com/path"));
1301    }
1302
1303    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1304    #[test]
1305    fn redirect_target_rejects_untrusted_origin() {
1306        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1307        assert!(!cfg.is_redirect_target_trusted("https://evil.com/phish"));
1308    }
1309
1310    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
1311    #[test]
1312    fn redirect_target_rejects_protocol_relative() {
1313        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
1314        assert!(!cfg.is_redirect_target_trusted("//evil.com"));
1315    }
1316
1317    // ── is_path_disabled ────────────────────────────────────────────────
1318
1319    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1320    #[test]
1321    fn is_path_disabled_matches() {
1322        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").disabled_path("/admin");
1323        assert!(cfg.is_path_disabled("/admin"));
1324        assert!(!cfg.is_path_disabled("/user"));
1325    }
1326
1327    // ── validate ────────────────────────────────────────────────────────
1328
1329    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1330    #[test]
1331    fn validate_rejects_empty_secret() {
1332        let cfg = AuthConfig::default();
1333        assert!(cfg.validate().is_err());
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 validate_rejects_short_secret() {
1339        let cfg = AuthConfig::new("short");
1340        assert!(cfg.validate().is_err());
1341    }
1342
1343    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1344    #[test]
1345    fn validate_accepts_valid_secret() {
1346        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
1347        assert!(cfg.validate().is_ok());
1348    }
1349
1350    // ── Defaults ────────────────────────────────────────────────────────
1351
1352    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1353    #[test]
1354    fn session_config_defaults() {
1355        let s = SessionConfig::default();
1356        assert_eq!(s.expires_in, Duration::hours(24 * 7));
1357        assert_eq!(s.update_age, Some(Duration::hours(24)));
1358        assert!(!s.disable_session_refresh);
1359        assert_eq!(s.cookie_name, "better-auth.session_token");
1360        assert!(s.cookie_http_only);
1361        assert_eq!(s.cookie_same_site, SameSite::Lax);
1362    }
1363
1364    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1365    #[test]
1366    fn jwt_config_defaults() {
1367        let j = JwtConfig::default();
1368        assert_eq!(j.expires_in, Duration::hours(24));
1369        assert_eq!(j.algorithm, "HS256");
1370    }
1371
1372    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1373    #[test]
1374    fn password_config_defaults() {
1375        let p = PasswordConfig::default();
1376        assert_eq!(p.min_length, 8);
1377        assert!(!p.require_uppercase);
1378    }
1379
1380    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1381    #[test]
1382    fn same_site_display() {
1383        assert_eq!(SameSite::Strict.to_string(), "Strict");
1384        assert_eq!(SameSite::Lax.to_string(), "Lax");
1385        assert_eq!(SameSite::None.to_string(), "None");
1386    }
1387
1388    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1389    #[test]
1390    fn cookie_cache_config_defaults() {
1391        let c = CookieCacheConfig::default();
1392        assert!(!c.enabled);
1393        assert_eq!(c.max_age, Duration::minutes(5));
1394        assert_eq!(c.strategy, CookieCacheStrategy::Compact);
1395    }
1396
1397    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1398    #[test]
1399    fn account_config_defaults() {
1400        let a = AccountConfig::default();
1401        assert!(a.update_account_on_sign_in);
1402        assert!(!a.encrypt_oauth_tokens);
1403        assert!(a.account_linking.enabled);
1404    }
1405
1406    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1407    #[test]
1408    fn core_paths_error_page() {
1409        let html = core_paths::error_page_html("TEST_ERROR");
1410        assert!(html.contains("TEST_ERROR"));
1411        assert!(html.contains("Ask AI"));
1412        assert!(html.contains("<title>Error</title>"));
1413    }
1414
1415    // Upstream reference: packages/better-auth/src/api/routes/error.ts :: sanitize function and /^[A-Za-z0-9_'-]+$/ whitelist.
1416    #[test]
1417    fn error_page_sanitizes_script_tag() {
1418        let html = core_paths::error_page_html("<script>alert(1)</script>");
1419        assert!(html.contains("UNKNOWN"));
1420        assert!(!html.contains("<script>"));
1421    }
1422
1423    // Upstream reference: packages/better-auth/src/api/routes/error.ts :: sanitize function and /^[A-Za-z0-9_'-]+$/ whitelist.
1424    #[test]
1425    fn error_page_allows_valid_codes() {
1426        assert!(core_paths::error_page_html("SOME_ERROR-CODE").contains("SOME_ERROR-CODE"));
1427        assert!(core_paths::error_page_html("it's").contains("it's"));
1428    }
1429
1430    // ── session builder methods ─────────────────────────────────────────
1431
1432    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1433    #[test]
1434    fn session_builder_methods() {
1435        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1436            .session_expires_in(Duration::hours(1))
1437            .session_update_age(Duration::minutes(30))
1438            .disable_session_refresh(true)
1439            .session_fresh_age(Duration::minutes(5));
1440
1441        assert_eq!(cfg.session.expires_in, Duration::hours(1));
1442        assert_eq!(cfg.session.update_age, Some(Duration::minutes(30)));
1443        assert!(cfg.session.disable_session_refresh);
1444        assert_eq!(cfg.session.fresh_age, Some(Duration::minutes(5)));
1445    }
1446
1447    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1448    #[test]
1449    fn session_cookie_cache_builder() {
1450        let cache = CookieCacheConfig {
1451            enabled: true,
1452            max_age: Duration::minutes(10),
1453            strategy: CookieCacheStrategy::Jwt,
1454        };
1455        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").session_cookie_cache(cache);
1456
1457        let cc = cfg.session.cookie_cache.as_ref();
1458        assert!(cc.is_some());
1459        let cc = cc.unwrap();
1460        assert!(cc.enabled);
1461        assert_eq!(cc.strategy, CookieCacheStrategy::Jwt);
1462    }
1463
1464    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1465    #[test]
1466    fn cross_sub_domain_cookies_builder() {
1467        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
1468            .cross_sub_domain_cookies(".example.com");
1469        let csd = cfg.advanced.cross_sub_domain_cookies.as_ref();
1470        assert!(csd.is_some());
1471        assert_eq!(csd.unwrap().domain, ".example.com");
1472    }
1473
1474    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1475    #[test]
1476    fn advanced_database_defaults() {
1477        let d = AdvancedDatabaseConfig::default();
1478        assert_eq!(d.default_find_many_limit, 100);
1479        assert!(!d.use_number_id);
1480    }
1481
1482    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
1483    #[test]
1484    fn ip_address_config_defaults() {
1485        let ip = IpAddressConfig::default();
1486        assert_eq!(ip.headers, vec!["x-forwarded-for", "x-real-ip"]);
1487        assert!(!ip.disable_ip_tracking);
1488    }
1489}