Skip to main content

better_auth_core/config/
mod.rs

1mod account;
2mod advanced;
3mod identity;
4mod session;
5
6pub use account::AccountConfig;
7pub use account::AccountLinkingConfig;
8pub use account::OAuthStateStrategy;
9pub use advanced::AdvancedConfig;
10pub use advanced::AdvancedDatabaseConfig;
11pub use advanced::CookieAttributes;
12pub use advanced::CookieOverride;
13pub use advanced::CrossSubDomainConfig;
14pub use advanced::IpAddressConfig;
15pub use advanced::SameSite;
16pub use identity::PasswordConfig;
17pub use identity::UserConfig;
18pub use identity::VerificationConfig;
19pub use session::CookieCacheConfig;
20pub use session::CookieCacheStrategy;
21pub use session::CookieRefreshCache;
22pub use session::JwtConfig;
23pub use session::SessionConfig;
24mod client_ip;
25mod secrets;
26pub use secrets::ManagedSecrets;
27
28/// Well-known core route paths.
29///
30/// These constants are the single source of truth for route paths used by both
31/// the core request dispatcher (`handle_core_request`) and framework-specific
32/// routers (e.g. Axum) so that path strings are never duplicated.
33pub mod core_paths {
34
35    pub const OK: &str = "/ok";
36    pub const ERROR: &str = "/error";
37    pub const HEALTH: &str = "/health";
38    pub const OPENAPI_SPEC: &str = "/__test/openapi.json";
39    pub const UPDATE_USER: &str = "/update-user";
40    pub const DELETE_USER: &str = "/delete-user";
41    pub const CHANGE_EMAIL: &str = "/change-email";
42    pub const DELETE_USER_CALLBACK: &str = "/delete-user/callback";
43}
44
45use crate::email::EmailProvider;
46use crate::error::AuthError;
47use chrono::Duration;
48use std::collections::HashMap;
49use std::sync::Arc;
50
51/// Response policy for awaited lifecycle notifications. Explicit verification
52/// delivery retains its direct error contract; deferred work logs its errors.
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
54pub enum AwaitedNotificationErrorPolicy {
55    /// Propagate the callback error after retaining any committed auth writes.
56    #[default]
57    Propagate,
58    /// Log delivery failure and continue the authentication response.
59    LogAndContinue,
60}
61
62mod origin;
63pub use origin::{
64    BaseUrlProtocol, DynamicBaseUrl, TrustedOriginsResolver, TrustedProvidersResolver,
65};
66
67/// Main configuration for `BetterAuth`
68#[derive(Clone)]
69pub struct AuthConfig {
70    /// Single-secret input. Managed mode uses `current_secret()` instead.
71    pub secret: String,
72
73    /// Versioned encryption keys. When present, its current key signs new tokens.
74    /// Bare encrypted values require an explicitly configured legacy key.
75    pub managed_secrets: Option<ManagedSecrets>,
76
77    /// Application name, used for cookie prefixes, email templates, etc.
78    ///
79    /// Defaults to `"Better Auth"`.
80    pub app_name: String,
81
82    /// Base URL for the authentication service (e.g. `"http://localhost:3000"`).
83    pub base_url: String,
84
85    /// Request-dependent host allowlist. Static `base_url` remains the default.
86    pub dynamic_base_url: Option<DynamicBaseUrl>,
87
88    /// Application callback resolving additional trusted origins from the real request.
89    pub trusted_origins_resolver: Option<std::sync::Arc<dyn TrustedOriginsResolver>>,
90
91    /// Base path where the auth routes are mounted.
92    ///
93    /// All routes handled by `BetterAuth` will be prefixed with this path.
94    /// For example, with the default `"/api/auth"`, the sign-in route becomes
95    /// `"/api/auth/sign-in/email"`.
96    ///
97    /// Defaults to `"/api/auth"`.
98    pub base_path: String,
99
100    /// Origins that are trusted for CSRF and other cross-origin checks.
101    ///
102    /// Supports glob patterns (e.g. `"https://*.example.com"`).
103    /// These are shared across all middleware that needs origin validation
104    /// (CSRF, CORS, etc.).
105    pub trusted_origins: Vec<String>,
106
107    /// Paths that should be disabled (skipped) by the router.
108    ///
109    /// Any request whose path matches an entry in this list will receive
110    /// a 404 response, even if a handler is registered for it.
111    pub disabled_paths: Vec<String>,
112
113    /// Render the built-in HTML page for `GET /error` instead of redirecting to `/`.
114    ///
115    /// Defaults to `false` when `NODE_ENV` is `production`, and `true` otherwise.
116    /// Set this to `true` to enable the renderer in production, corresponding to
117    /// upstream's explicit `onAPIError.customizeDefaultErrorPage` option.
118    pub render_error_page: bool,
119
120    /// Default OAuth error destination, corresponding to `onAPIError.errorURL`.
121    pub api_error_url: Option<String>,
122
123    /// Session configuration
124    pub session: SessionConfig,
125
126    /// Application user-field input, adapter and public output policies.
127    pub user: UserConfig,
128
129    /// Verification storage lifecycle settings.
130    pub verification: VerificationConfig,
131
132    /// JWT configuration
133    pub jwt: JwtConfig,
134
135    /// Password configuration
136    pub password: PasswordConfig,
137
138    /// Account configuration (linking, token encryption, etc.)
139    pub account: AccountConfig,
140
141    /// Email provider for sending emails (verification, password reset, etc.)
142    pub email_provider: Option<Arc<dyn EmailProvider>>,
143    /// Observe already running deferred operations. Ignoring their completion
144    /// does not cancel them; applications may retain completions for shutdown.
145    pub background_tasks: Option<Arc<dyn crate::BackgroundTaskHandler>>,
146    /// Error handling when the request awaits a lifecycle notification.
147    pub awaited_notification_errors: AwaitedNotificationErrorPolicy,
148
149    /// Validate fresh identity data before its creation hooks or provider
150    /// account/session writes. Returning non-provider sign-ins are unchanged.
151    pub user_validation: Option<Arc<dyn crate::user_validation::UserInfoValidator>>,
152
153    /// Advanced configuration options
154    pub advanced: AdvancedConfig,
155}
156
157impl Default for AuthConfig {
158    fn default() -> Self {
159        Self {
160            secret: String::new(),
161            managed_secrets: None,
162            app_name: "Better Auth".to_owned(),
163            base_url: "http://localhost:3000".to_owned(),
164            dynamic_base_url: None,
165            trusted_origins_resolver: None,
166            base_path: "/api/auth".to_owned(),
167            trusted_origins: Vec::new(),
168            disabled_paths: Vec::new(),
169            render_error_page: !std::env::var("NODE_ENV").is_ok_and(|value| value == "production"),
170            api_error_url: None,
171            session: SessionConfig::default(),
172            user: UserConfig::default(),
173            verification: VerificationConfig::default(),
174            jwt: JwtConfig::default(),
175            password: PasswordConfig::default(),
176            account: AccountConfig::default(),
177            email_provider: None,
178            background_tasks: None,
179            awaited_notification_errors: AwaitedNotificationErrorPolicy::default(),
180            user_validation: None,
181            advanced: AdvancedConfig::default(),
182        }
183    }
184}
185
186impl AuthConfig {
187    /// Choose whether awaited lifecycle email delivery failures fail the request.
188    #[must_use]
189    pub const fn awaited_notification_errors(
190        mut self,
191        policy: AwaitedNotificationErrorPolicy,
192    ) -> Self {
193        self.awaited_notification_errors = policy;
194        self
195    }
196
197    /// Integrate deferred task completions with the application executor.
198    #[must_use]
199    pub fn background_tasks(mut self, handler: Arc<dyn crate::BackgroundTaskHandler>) -> Self {
200        self.background_tasks = Some(handler);
201        self
202    }
203
204    #[must_use]
205    pub fn new(secret: impl Into<String>) -> Self {
206        Self {
207            secret: secret.into(),
208            ..Default::default()
209        }
210    }
211
212    /// Set the application name.
213    #[must_use]
214    pub fn app_name(mut self, name: impl Into<String>) -> Self {
215        self.app_name = name.into();
216        self
217    }
218
219    /// Set the base URL (e.g. `"https://myapp.com"`).
220    ///
221    /// Also updates `session.cookie_secure` to match the URL scheme:
222    /// HTTPS URLs set `Secure=true`, HTTP URLs set `Secure=false`.
223    #[must_use]
224    pub fn base_url(mut self, url: impl Into<String>) -> Self {
225        self.base_url = url.into();
226        self.session.cookie_secure = self.base_url.starts_with("https://");
227        self
228    }
229
230    #[must_use]
231    pub fn account(mut self, account: AccountConfig) -> Self {
232        self.account = account;
233        self
234    }
235
236    /// Configure the default OAuth error destination (`onAPIError.errorURL`).
237    /// An empty value uses the endpoint's ordinary fallback.
238    #[must_use]
239    pub fn api_error_url(mut self, url: impl Into<String>) -> Self {
240        self.api_error_url = Some(url.into());
241        self
242    }
243
244    /// Set the base path where auth routes are mounted.
245    #[must_use]
246    pub fn base_path(mut self, path: impl Into<String>) -> Self {
247        self.base_path = path.into();
248        self
249    }
250
251    /// Add a trusted origin. Supports glob patterns (e.g. `"https://*.example.com"`).
252    #[must_use]
253    pub fn trusted_origin(mut self, origin: impl Into<String>) -> Self {
254        self.trusted_origins.push(origin.into());
255        self
256    }
257
258    /// Set all trusted origins at once.
259    #[must_use]
260    pub fn trusted_origins(mut self, origins: Vec<String>) -> Self {
261        self.trusted_origins = origins;
262        self
263    }
264
265    /// Add a path to the disabled paths list.
266    #[must_use]
267    pub fn disabled_path(mut self, path: impl Into<String>) -> Self {
268        self.disabled_paths.push(path.into());
269        self
270    }
271
272    /// Set all disabled paths at once.
273    #[must_use]
274    pub fn disabled_paths(mut self, paths: Vec<String>) -> Self {
275        self.disabled_paths = paths;
276        self
277    }
278
279    /// Set the session expiration duration.
280    #[must_use]
281    pub const fn session_expires_in(mut self, duration: Duration) -> Self {
282        self.session.expires_in = duration;
283        self
284    }
285
286    #[must_use]
287    pub const fn session_update_age(mut self, duration: Duration) -> Self {
288        self.session.update_age = Some(duration);
289        self
290    }
291
292    #[must_use]
293    pub const fn disable_session_refresh(mut self, disabled: bool) -> Self {
294        self.session.disable_session_refresh = disabled;
295        self
296    }
297
298    #[must_use]
299    pub const fn session_fresh_age(mut self, duration: Duration) -> Self {
300        self.session.fresh_age = Some(duration);
301        self
302    }
303
304    /// Set the cookie cache configuration for sessions.
305    #[must_use]
306    pub fn session_cookie_cache(mut self, config: CookieCacheConfig) -> Self {
307        self.session.cookie_cache = Some(config);
308        self
309    }
310
311    /// Set the JWT expiration duration.
312    #[must_use]
313    pub const fn jwt_expires_in(mut self, duration: Duration) -> Self {
314        self.jwt.expires_in = duration;
315        self
316    }
317
318    #[must_use]
319    pub fn advanced(mut self, advanced: AdvancedConfig) -> Self {
320        self.advanced = advanced;
321        self
322    }
323
324    #[must_use]
325    pub fn cookie_prefix(mut self, prefix: impl Into<String>) -> Self {
326        self.advanced.cookie_prefix = Some(prefix.into());
327        self
328    }
329
330    #[must_use]
331    pub const fn disable_csrf_check(mut self, disabled: bool) -> Self {
332        self.advanced.disable_csrf_check = Some(disabled);
333        self
334    }
335
336    /// Current key for signing tokens and cookies. Retained keys are encryption
337    /// readers; signed cookies and signed JWTs deliberately use only this key.
338    #[must_use]
339    pub fn current_secret(&self) -> &str {
340        self.managed_secrets
341            .as_ref()
342            .map_or(self.secret.as_str(), ManagedSecrets::current_secret)
343    }
344
345    /// Encryption readers in configured order, followed by a distinct legacy key.
346    pub fn verification_secrets(&self) -> impl Iterator<Item = &str> {
347        let keys = self
348            .managed_secrets
349            .as_ref()
350            .map(|secrets| secrets.verification_secrets());
351        keys.into_iter().flatten().chain(
352            self.managed_secrets
353                .is_none()
354                .then_some(self.secret.as_str()),
355        )
356    }
357
358    /// Enable managed encryption with an explicit current version and readers.
359    #[must_use]
360    pub fn managed_secrets(mut self, secrets: ManagedSecrets) -> Self {
361        self.managed_secrets = Some(secrets);
362        self
363    }
364
365    /// Determine whether this request path opts out of origin validation.
366    #[must_use]
367    pub fn origin_check_disabled_for(&self, path: &str) -> bool {
368        if self.advanced.disable_origin_check {
369            return true;
370        }
371        let path = path
372            .strip_prefix(self.base_path.trim_end_matches('/'))
373            .filter(|suffix| suffix.starts_with('/'))
374            .unwrap_or(path)
375            .trim_end_matches('/');
376        self.advanced.disable_origin_check_paths.iter().any(|skip| {
377            let skip = skip.trim_end_matches('/');
378            path == skip
379                || path
380                    .strip_prefix(skip)
381                    .is_some_and(|suffix| suffix.starts_with('/'))
382        })
383    }
384
385    /// Apply the configured origin policy at an initialized request callback.
386    #[must_use]
387    pub fn current_origin_check_disabled(&self) -> bool {
388        crate::hooks::current_request_hook_context()
389            .map_or(self.advanced.disable_origin_check, |request| {
390                self.origin_check_disabled_for(&request.path)
391            })
392    }
393
394    #[must_use]
395    pub const fn disable_origin_check(mut self, disabled: bool) -> Self {
396        self.advanced.disable_origin_check = disabled;
397        self
398    }
399
400    #[must_use]
401    pub fn cross_sub_domain_cookies(mut self, domain: impl Into<String>) -> Self {
402        self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig {
403            domain: domain.into(),
404        });
405        self
406    }
407
408    /// Share cookies using the configured or request-resolved base URL's hostname.
409    /// This follows the published hostname directly; it does not infer a parent
410    /// or registrable domain. Use [`cross_sub_domain_cookies`](Self::cross_sub_domain_cookies)
411    /// when a deployment needs an explicit parent domain.
412    #[must_use]
413    pub fn cross_sub_domain_cookies_from_base_url(mut self) -> Self {
414        self.advanced.cross_sub_domain_cookies = Some(CrossSubDomainConfig::default());
415        self
416    }
417
418    /// Check the resolved base origin or a configured trusted pattern.
419    ///
420    /// Explicit patterns follow Better Auth 1.7.6: `*` and `?` match URL origins
421    /// (or hosts for patterns without a scheme), while custom schemes retain
422    /// authority and normalized path-prefix constraints. Resolve dynamic policies
423    /// with [`resolve_request`](Self::resolve_request) before evaluating them.
424    #[must_use]
425    pub fn is_origin_trusted(&self, origin: &str) -> bool {
426        // Check base_url origin
427        if self.dynamic_base_url.is_none()
428            && let Some(base_origin) = extract_origin(&self.base_url)
429            && (origin == base_origin
430                || (self.base_url.split_once(':').is_some_and(|(scheme, _)| {
431                    scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
432                }) && extract_origin(origin).as_deref() == Some(base_origin.as_str())))
433        {
434            return true;
435        }
436        self.trusted_origins
437            .iter()
438            .any(|pattern| origin::matches_origin(origin, pattern))
439    }
440
441    /// Check whether a URL is a safe redirect target.
442    ///
443    /// A URL is safe if it is a relative path (starts with `/`, no
444    /// traversal tricks) or its origin matches [`base_url`](Self::base_url)
445    /// or [`trusted_origins`](Self::trusted_origins).
446    ///
447    /// This is used by both the CSRF middleware (for POST body/query
448    /// targets) and per-endpoint origin checks (e.g. verify-email GET).
449    #[must_use]
450    pub fn is_redirect_target_trusted(&self, url: &str) -> bool {
451        if is_safe_relative_path(url) {
452            return true;
453        }
454        self.is_origin_trusted(url)
455    }
456
457    /// Check whether a given path is disabled.
458    #[must_use]
459    pub fn is_path_disabled(&self, path: &str) -> bool {
460        self.disabled_paths.iter().any(|disabled| disabled == path)
461    }
462
463    ///
464    /// # Errors
465    ///
466    /// Returns a configuration error if the signing secret is empty or shorter than 32 bytes.
467    pub fn validate(&self) -> Result<(), AuthError> {
468        if self.session.stateless
469            && (self.session.secondary_storage.is_some()
470                || self.session.store_in_database
471                || self.session.preserve_in_database)
472        {
473            return Err(AuthError::config(
474                "Stateless sessions cannot use server session storage",
475            ));
476        }
477        if self
478            .dynamic_base_url
479            .as_ref()
480            .is_some_and(|policy| policy.allowed_hosts.is_empty())
481        {
482            return Err(AuthError::config(
483                "Dynamic base URL allowed hosts cannot be empty",
484            ));
485        }
486        if let Some(secrets) = &self.managed_secrets {
487            return secrets.validate();
488        }
489        if self.secret.is_empty() {
490            return Err(AuthError::config("Secret key cannot be empty"));
491        }
492
493        if self.secret.len() < 32 {
494            return Err(AuthError::config(
495                "Secret key must be at least 32 characters",
496            ));
497        }
498
499        Ok(())
500    }
501}
502
503impl std::fmt::Debug for AuthConfig {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        f.debug_struct("AuthConfig").finish_non_exhaustive()
506    }
507}
508
509/// Check whether a URL is a safe relative path.
510///
511/// A relative path is safe if it starts with a single `/` and has no
512/// traversal or scheme-escape tricks (`//`, `\`, `%2f`, `%5c`).
513///
514/// This is used by [`AuthConfig::is_redirect_target_trusted`] and the
515/// CSRF middleware.
516#[must_use]
517pub fn is_safe_relative_path(value: &str) -> bool {
518    if !value.starts_with('/')
519        || value.starts_with("//")
520        || value.contains('\\')
521        || value
522            .chars()
523            .any(|character| matches!(character, '\u{0000}'..='\u{001f}' | '\u{007f}'..='\u{009f}'))
524    {
525        return false;
526    }
527
528    let path = value.split(['?', '#']).next().unwrap_or(value);
529    let lower = path.to_ascii_lowercase();
530    !lower.contains("%2f")
531        && !lower.contains("%5c")
532        && url::Url::parse("https://better-auth.invalid")
533            .and_then(|base| base.join(value))
534            .is_ok_and(|url| url.origin().ascii_serialization() == "https://better-auth.invalid")
535}
536
537/// Extract the origin (scheme + host + port) from a URL string.
538///
539/// For example, `"https://example.com/path"` → `"https://example.com"`.
540///
541/// This is used by [`AuthConfig::is_origin_trusted`] and the CSRF middleware
542/// so that origin comparison is centralised in one place.
543#[must_use]
544pub fn extract_origin(url: &str) -> Option<String> {
545    if let Ok(parsed) = url::Url::parse(url)
546        && matches!(parsed.scheme(), "http" | "https")
547    {
548        return match parsed.origin() {
549            url::Origin::Tuple(..) => Some(parsed.origin().ascii_serialization()),
550            url::Origin::Opaque(_) => None,
551        };
552    }
553    // Custom schemes retain the existing Rust contract. Their upstream
554    // authority/path matcher is a separate capability.
555    legacy_pattern_origin(url)
556}
557
558// Configured glob/custom-scheme matching keeps its existing contract; avoid
559// normalizing an explicit pattern into a newly trusted authority.
560fn legacy_pattern_origin(url: &str) -> Option<String> {
561    let scheme_end = url.find("://")?;
562    let rest = url.get(scheme_end + 3..)?;
563    let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
564    let origin = format!("{}{}", url.get(..scheme_end + 3)?, rest.get(..host_end)?);
565    Some(origin)
566}
567
568// LCOV_EXCL_START
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    // ── extract_origin ──────────────────────────────────────────────────
574
575    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
576    #[test]
577    fn extract_origin_with_path() {
578        assert_eq!(
579            extract_origin("https://example.com/path"),
580            Some("https://example.com".to_owned())
581        );
582    }
583
584    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
585    #[test]
586    fn extract_origin_without_path() {
587        assert_eq!(
588            extract_origin("https://example.com"),
589            Some("https://example.com".to_owned())
590        );
591    }
592
593    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
594    #[test]
595    fn extract_origin_with_port() {
596        assert_eq!(
597            extract_origin("http://localhost:3000/api"),
598            Some("http://localhost:3000".to_owned())
599        );
600    }
601
602    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
603    #[test]
604    fn extract_origin_with_query() {
605        assert_eq!(
606            extract_origin("https://example.com?foo=bar"),
607            Some("https://example.com".to_owned())
608        );
609    }
610
611    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
612    #[test]
613    fn extract_origin_with_fragment() {
614        assert_eq!(
615            extract_origin("https://example.com#fragment"),
616            Some("https://example.com".to_owned())
617        );
618    }
619
620    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
621    #[test]
622    fn extract_origin_no_scheme() {
623        assert_eq!(extract_origin("example.com"), None);
624    }
625
626    // ── AuthConfig::new ─────────────────────────────────────────────────
627
628    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
629    #[test]
630    fn new_config_sets_secret() {
631        let cfg = AuthConfig::new("a]secret-that-is-at-least-32-characters-long");
632        assert_eq!(cfg.secret, "a]secret-that-is-at-least-32-characters-long");
633    }
634
635    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
636    #[test]
637    fn new_config_uses_defaults() {
638        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
639        assert_eq!(cfg.app_name, "Better Auth");
640        assert_eq!(cfg.base_url, "http://localhost:3000");
641        assert_eq!(cfg.base_path, "/api/auth");
642        assert_eq!(cfg.trusted_origins, Vec::<String>::new());
643    }
644
645    // ── Builder methods ─────────────────────────────────────────────────
646
647    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
648    #[test]
649    fn base_url_sets_cookie_secure_for_https() {
650        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
651        assert!(cfg.session.cookie_secure);
652    }
653
654    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
655    #[test]
656    fn base_url_clears_cookie_secure_for_http() {
657        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
658            .base_url("https://myapp.com")
659            .base_url("http://localhost:3000");
660        assert!(!cfg.session.cookie_secure);
661    }
662
663    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
664    #[test]
665    fn builder_chaining() {
666        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
667            .app_name("MyApp")
668            .base_path("/auth")
669            .disable_csrf_check(true)
670            .disable_origin_check(true)
671            .cookie_prefix("myapp");
672
673        assert_eq!(cfg.app_name, "MyApp");
674        assert_eq!(cfg.base_path, "/auth");
675        assert_eq!(cfg.advanced.disable_csrf_check, Some(true));
676        assert!(cfg.advanced.disable_origin_check);
677        assert_eq!(cfg.advanced.cookie_prefix, Some("myapp".to_owned()));
678    }
679
680    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
681    #[test]
682    fn trusted_origin_appends() {
683        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
684            .trusted_origin("https://a.com")
685            .trusted_origin("https://b.com");
686        assert_eq!(cfg.trusted_origins.len(), 2);
687    }
688
689    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
690    #[test]
691    fn trusted_origins_replaces() {
692        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
693            .trusted_origin("https://old.com")
694            .trusted_origins(vec!["https://new.com".to_owned()]);
695        assert_eq!(cfg.trusted_origins, vec!["https://new.com"]);
696    }
697
698    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
699    #[test]
700    fn disabled_path_appends() {
701        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
702            .disabled_path("/admin")
703            .disabled_path("/debug");
704        assert_eq!(cfg.disabled_paths.len(), 2);
705    }
706
707    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
708    #[test]
709    fn disabled_paths_replaces() {
710        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
711            .disabled_path("/old")
712            .disabled_paths(vec!["/new".to_owned()]);
713        assert_eq!(cfg.disabled_paths, vec!["/new"]);
714    }
715
716    // ── is_origin_trusted ───────────────────────────────────────────────
717
718    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
719    #[test]
720    fn is_origin_trusted_matches_base_url() {
721        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
722        assert!(cfg.is_origin_trusted("https://myapp.com"));
723        let native_custom =
724            AuthConfig::new("test-secret-min-32-chars-1234567").base_url("myapp://auth");
725        assert!(native_custom.is_origin_trusted("myapp://auth"));
726        assert!(!native_custom.is_origin_trusted("myapp://auth/callback"));
727    }
728
729    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
730    #[test]
731    fn is_origin_trusted_rejects_unknown() {
732        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
733        assert!(!cfg.is_origin_trusted("https://evil.com"));
734    }
735
736    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
737    #[test]
738    fn is_origin_trusted_glob_pattern() {
739        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
740            .trusted_origin("https://*.example.com");
741        assert!(cfg.is_origin_trusted("https://sub.example.com"));
742        assert!(!cfg.is_origin_trusted("https://other.com"));
743    }
744
745    // ── is_redirect_target_trusted ─────────────────────────────────────
746
747    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
748    #[test]
749    fn redirect_target_allows_relative_path() {
750        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
751        assert!(cfg.is_redirect_target_trusted("/dashboard"));
752        assert!(cfg.is_redirect_target_trusted("/callback?foo=bar"));
753    }
754
755    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
756    #[test]
757    fn redirect_target_allows_same_origin() {
758        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
759        assert!(cfg.is_redirect_target_trusted("https://myapp.com/verified"));
760    }
761
762    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
763    #[test]
764    fn redirect_target_allows_trusted_origin() {
765        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
766            .base_url("https://myapp.com")
767            .trusted_origin("https://trusted.com");
768        assert!(cfg.is_redirect_target_trusted("https://trusted.com/path"));
769    }
770
771    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
772    #[test]
773    fn redirect_target_rejects_untrusted_origin() {
774        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
775        assert!(!cfg.is_redirect_target_trusted("https://evil.com/phish"));
776    }
777
778    // Upstream reference: packages/better-auth/src/api/middlewares/origin-check.ts :: originCheck validates callbackURL against trustedOrigins.
779    #[test]
780    fn redirect_target_rejects_protocol_relative() {
781        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").base_url("https://myapp.com");
782        assert!(!cfg.is_redirect_target_trusted("//evil.com"));
783    }
784
785    // ── is_path_disabled ────────────────────────────────────────────────
786
787    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
788    #[test]
789    fn is_path_disabled_matches() {
790        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").disabled_path("/admin");
791        assert!(cfg.is_path_disabled("/admin"));
792        assert!(!cfg.is_path_disabled("/user"));
793    }
794
795    // ── validate ────────────────────────────────────────────────────────
796
797    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
798    #[test]
799    fn validate_rejects_empty_secret() {
800        let cfg = AuthConfig::default();
801        assert!(cfg.validate().is_err());
802    }
803
804    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
805    #[test]
806    fn validate_rejects_short_secret() {
807        let cfg = AuthConfig::new("short");
808        assert!(cfg.validate().is_err());
809    }
810
811    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
812    #[test]
813    fn validate_accepts_valid_secret() {
814        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567");
815        assert!(cfg.validate().is_ok());
816    }
817
818    // ── Defaults ────────────────────────────────────────────────────────
819
820    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
821    #[test]
822    fn session_config_defaults() {
823        let s = SessionConfig::default();
824        assert_eq!(s.expires_in, Duration::hours(24 * 7));
825        assert_eq!(s.update_age, Some(Duration::hours(24)));
826        assert!(!s.disable_session_refresh);
827        assert_eq!(s.cookie_name, "better-auth.session_token");
828        assert!(s.cookie_http_only);
829        assert_eq!(s.cookie_same_site, SameSite::Lax);
830    }
831
832    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
833    #[test]
834    fn jwt_config_defaults() {
835        let j = JwtConfig::default();
836        assert_eq!(j.expires_in, Duration::hours(24));
837        assert_eq!(j.algorithm, "HS256");
838    }
839
840    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
841    #[test]
842    fn password_config_defaults() {
843        let p = PasswordConfig::default();
844        assert!(!p.require_uppercase);
845    }
846
847    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
848    #[test]
849    fn same_site_display() {
850        assert_eq!(SameSite::Strict.to_string(), "Strict");
851        assert_eq!(SameSite::Lax.to_string(), "Lax");
852        assert_eq!(SameSite::None.to_string(), "None");
853    }
854
855    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
856    #[test]
857    fn cookie_cache_config_defaults() {
858        let c = CookieCacheConfig::default();
859        assert!(!c.enabled);
860        assert!((c.max_age - 300.0).abs() < f64::EPSILON);
861        assert_eq!(c.strategy, CookieCacheStrategy::Compact);
862    }
863
864    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
865    #[test]
866    fn account_config_defaults() {
867        let a = AccountConfig::default();
868        assert!(a.update_account_on_sign_in);
869        assert!(!a.encrypt_oauth_tokens);
870        assert!(a.account_linking.enabled);
871    }
872
873    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
874    #[test]
875    fn core_paths_error_page() {
876        let html = crate::error::page::error_page_html("TEST_ERROR");
877        assert!(html.contains("TEST_ERROR"));
878        assert!(html.contains("Ask AI"));
879        assert!(html.contains("<title>Error</title>"));
880    }
881
882    // Upstream reference: packages/better-auth/src/api/routes/error.ts :: sanitize function and /^[A-Za-z0-9_'-]+$/ whitelist.
883    #[test]
884    fn error_page_sanitizes_script_tag() {
885        let html = crate::error::page::error_page_html("<script>alert(1)</script>");
886        assert!(html.contains("UNKNOWN"));
887        assert!(!html.contains("<script>"));
888    }
889
890    // Upstream reference: packages/better-auth/src/api/routes/error.ts :: sanitize function and /^[A-Za-z0-9_'-]+$/ whitelist.
891    #[test]
892    fn error_page_allows_valid_codes() {
893        assert!(crate::error::page::error_page_html("SOME_ERROR-CODE").contains("SOME_ERROR-CODE"));
894        assert!(crate::error::page::error_page_html("it's").contains("it's"));
895    }
896
897    // ── session builder methods ─────────────────────────────────────────
898
899    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
900    #[test]
901    fn session_builder_methods() {
902        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
903            .session_expires_in(Duration::hours(1))
904            .session_update_age(Duration::minutes(30))
905            .disable_session_refresh(true)
906            .session_fresh_age(Duration::minutes(5));
907
908        assert_eq!(cfg.session.expires_in, Duration::hours(1));
909        assert_eq!(cfg.session.update_age, Some(Duration::minutes(30)));
910        assert!(cfg.session.disable_session_refresh);
911        assert_eq!(cfg.session.fresh_age, Some(Duration::minutes(5)));
912    }
913
914    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
915    #[test]
916    fn session_cookie_cache_builder() {
917        let cache = CookieCacheConfig {
918            enabled: true,
919            max_age: 600.0,
920            strategy: CookieCacheStrategy::Jwt,
921            ..CookieCacheConfig::default()
922        };
923        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567").session_cookie_cache(cache);
924
925        let cc = cfg.session.cookie_cache.as_ref();
926        assert!(cc.is_some());
927        let cc = cc.unwrap();
928        assert!(cc.enabled);
929        assert_eq!(cc.strategy, CookieCacheStrategy::Jwt);
930    }
931
932    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
933    #[test]
934    fn cross_sub_domain_cookies_builder() {
935        let cfg = AuthConfig::new("test-secret-min-32-chars-1234567")
936            .cross_sub_domain_cookies(".example.com");
937        let csd = cfg.advanced.cross_sub_domain_cookies.as_ref();
938        assert!(csd.is_some());
939        assert_eq!(csd.unwrap().domain, ".example.com");
940    }
941
942    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
943    #[test]
944    fn advanced_database_defaults() {
945        let d = AdvancedDatabaseConfig::default();
946        assert_eq!(d.default_find_many_limit, 100);
947        assert!(!d.use_number_id);
948    }
949
950    // Rust-specific surface: `AuthConfig`, related configuration builders, and `core_paths` are public Rust APIs with no direct TS analogue.
951    #[test]
952    fn ip_address_config_defaults() {
953        let ip = IpAddressConfig::default();
954        assert_eq!(ip.headers, vec!["x-forwarded-for"]);
955        assert!(!ip.disable_ip_tracking);
956    }
957}
958// LCOV_EXCL_STOP