Skip to main content

alibi_core/config/
advanced.rs

1use crate::AuthError;
2use crate::config::{DatabaseIdStrategy, HashMap};
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum SameSite {
5    Strict,
6    Lax,
7    None,
8}
9
10impl std::fmt::Display for SameSite {
11    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12        match self {
13            Self::Strict => f.write_str("Strict"),
14            Self::Lax => f.write_str("Lax"),
15            Self::None => f.write_str("None"),
16        }
17    }
18}
19
20/// Advanced configuration options (mirrors TS `advanced` block).
21#[derive(Debug, Clone, Default)]
22pub struct AdvancedConfig {
23    /// Trust forwarded host/protocol for URL resolution. Enable only behind a
24    /// proxy that replaces client-supplied forwarding headers. Defaults to false.
25    pub trust_forwarded_host: bool,
26
27    /// IP address extraction configuration.
28    pub ip_address: IpAddressConfig,
29
30    /// Explicit CSRF override. `None` preserves the pinned compatibility behavior
31    /// where disabling all origin checks also disables first-login CSRF checks.
32    pub disable_csrf_check: Option<bool>,
33
34    /// If `true`, callback / redirect target origin validation is skipped.
35    ///
36    /// This mirrors Better Auth TS `advanced.disableOriginCheck`.
37    /// Request-origin validation is also skipped. First-login Fetch Metadata
38    /// checks remain enabled when `disable_csrf_check` is explicitly `Some(false)`.
39    pub disable_origin_check: bool,
40
41    /// Skip origin validation for a literal path and its descendants. This does
42    /// not disable first-login cross-site navigation protection.
43    pub disable_origin_check_paths: Vec<String>,
44
45    /// Admit trailing slashes while resolving the original registered endpoint.
46    pub skip_trailing_slashes: bool,
47
48    /// Select the secure cookie name prefix and initial Secure attribute.
49    /// None uses the configured base URL protocol. Attribute overrides do not
50    /// change this choice; the serializer enforces Secure for reserved prefixes.
51    pub use_secure_cookies: Option<bool>,
52
53    /// Cross-subdomain cookie sharing configuration.
54    pub cross_sub_domain_cookies: Option<CrossSubDomainConfig>,
55
56    /// Per-cookie-name overrides (name, attributes, prefix).
57    ///
58    /// Keys are the *logical* cookie names (e.g. `"session_token"`,
59    /// `"csrf_token"`). Values specify the attributes to override.
60    pub cookies: HashMap<String, CookieOverride>,
61
62    /// Default cookie attributes applied to *every* cookie the library sets
63    /// (individual overrides in `cookies` take precedence).
64    pub default_cookie_attributes: CookieAttributes,
65
66    /// Optional prefix prepended to every cookie name (e.g. `"myapp"` →
67    /// `"myapp.session_token"`).
68    pub cookie_prefix: Option<String>,
69
70    /// Database-related advanced options.
71    pub database: AdvancedDatabaseConfig,
72}
73
74/// IP-address extraction configuration.
75#[derive(Debug, Clone)]
76pub struct IpAddressConfig {
77    /// Ordered list of headers to check for the client IP.
78    /// Defaults to `["x-forwarded-for"]`. Header names are case insensitive.
79    pub headers: Vec<String>,
80
81    /// IP addresses or CIDRs removed from the right of a forwarded chain.
82    /// Invalid entries are ignored. Without valid entries, only a single
83    /// address is admitted. Deployments must prevent clients bypassing the
84    /// proxy and supplying their own trusted forwarding headers.
85    pub trusted_proxies: Vec<String>,
86
87    /// IPv6 grouping prefix; defaults to 64. Fractional values are floored,
88    /// negative values become zero, and values at least 128 or NaN preserve
89    /// the full address. IPv4-mapped addresses use IPv4 grouping.
90    pub ipv6_subnet: f64,
91
92    /// Fall back to localhost when no configured header resolves an address.
93    /// Defaults to true for `NODE_ENV` dev/development/test or a truthy TEST
94    /// environment flag. Applications may configure this explicitly.
95    pub localhost_fallback: bool,
96
97    /// If `true`, IP tracking is entirely disabled (no IP stored in sessions).
98    pub disable_ip_tracking: bool,
99}
100
101/// Configuration for sharing cookies across sub-domains.
102#[derive(Debug, Clone, Default)]
103pub struct CrossSubDomainConfig {
104    /// Explicit cookie domain (e.g. `".example.com"`). An empty value infers
105    /// the hostname of the configured or request-resolved base URL, without a port.
106    pub domain: String,
107}
108
109/// Overridable cookie attributes.
110#[derive(Debug, Clone, Default)]
111pub struct CookieAttributes {
112    /// Override `Secure` flag.
113    pub secure: Option<bool>,
114    /// Override `HttpOnly` flag.
115    pub http_only: Option<bool>,
116    /// Override `SameSite` policy.
117    pub same_site: Option<SameSite>,
118    /// Override `Path`.
119    pub path: Option<String>,
120    /// Override `Max-Age` (seconds).
121    pub max_age: Option<f64>,
122    /// Explicit expiry, checked at emission against the published 400-day limit.
123    pub expires: Option<chrono::DateTime<chrono::Utc>>,
124    /// Emit the published `Partitioned` attribute after `SameSite`.
125    pub partitioned: Option<bool>,
126    /// Override cookie `Domain`.
127    pub domain: Option<String>,
128}
129
130/// Per-cookie override entry.
131#[derive(Debug, Clone, Default)]
132pub struct CookieOverride {
133    /// Custom name to use instead of the logical name.
134    pub name: Option<String>,
135    /// Attribute overrides for this cookie.
136    pub attributes: CookieAttributes,
137}
138
139/// Physical storage policy for the bundled two-factor model.
140#[derive(Debug, Clone)]
141pub struct TwoFactorDatabaseConfig {
142    pub table_name: String,
143    /// Canonical ``snake_case`` column names mapped to application-owned columns.
144    pub columns: std::collections::HashMap<String, String>,
145}
146
147/// Database-related advanced options.
148#[derive(Debug, Clone)]
149pub struct AdvancedDatabaseConfig {
150    /// Default `LIMIT` for "find many" queries.
151    pub default_find_many_limit: usize,
152
153    /// PostgreSQL namespace for every auth relation; does not change ``search_path``.
154    pub schema_name: Option<String>,
155
156    /// Optional physical table and column mapping for two-factor credentials.
157    pub two_factor: Option<TwoFactorDatabaseConfig>,
158
159    /// Optional physical table and column mapping for durable SSO providers.
160    pub sso_provider: Option<SsoProviderDatabaseConfig>,
161
162    /// Declared provider fields, including input/output policy and physical names.
163    pub sso_provider_fields: crate::field_policy::SessionFields,
164
165    /// Use persistent serial identifiers when no explicit generation strategy is set.
166    /// Numeric invitation IDs require email verification by default.
167    pub use_number_id: bool,
168
169    /// Override the model default for every adapter-created identifier.
170    pub generate_id: Option<DatabaseIdStrategy>,
171}
172
173impl Default for IpAddressConfig {
174    fn default() -> Self {
175        Self {
176            headers: vec!["x-forwarded-for".to_owned()],
177            trusted_proxies: Vec::new(),
178            ipv6_subnet: 64.0,
179            localhost_fallback: matches!(
180                std::env::var("NODE_ENV").as_deref(),
181                Ok("dev" | "development" | "test")
182            ) || std::env::var("TEST")
183                .is_ok_and(|value| !matches!(value.as_str(), "" | "false")),
184            disable_ip_tracking: false,
185        }
186    }
187}
188
189impl Default for AdvancedDatabaseConfig {
190    fn default() -> Self {
191        Self {
192            default_find_many_limit: 100,
193            schema_name: None,
194            two_factor: None,
195            sso_provider: None,
196            sso_provider_fields: crate::field_policy::SessionFields::default(),
197            use_number_id: false,
198            generate_id: None,
199        }
200    }
201}
202
203impl TwoFactorDatabaseConfig {
204    /// DDL used to migrate the bundled factor model to its configured physical layout.
205    pub fn migration_statements(&self) -> crate::AuthResult<Vec<String>> {
206        const COLUMNS: &[&str] = &[
207            "id",
208            "secret",
209            "backup_codes",
210            "user_id",
211            "verified",
212            "failed_verification_count",
213            "locked_until",
214        ];
215        if self.table_name.is_empty()
216            || self
217                .columns
218                .iter()
219                .any(|(name, column)| !COLUMNS.contains(&name.as_str()) || column.is_empty())
220        {
221            return Err(AuthError::config("Invalid two-factor storage mapping"));
222        }
223        let quote = |name: &str| format!("\"{}\"", name.replace('"', "\"\""));
224        let table = quote(&self.table_name);
225        let mut sql = Vec::new();
226        if self.table_name != "twoFactor" {
227            sql.push(format!("ALTER TABLE \"twoFactor\" RENAME TO {table}"));
228        }
229        for name in COLUMNS {
230            if let Some(column) = self
231                .columns
232                .get(*name)
233                .filter(|column| column.as_str() != two_factor_column(name))
234            {
235                sql.push(format!(
236                    "ALTER TABLE {table} RENAME COLUMN {} TO {}",
237                    quote(two_factor_column(name)),
238                    quote(column)
239                ));
240            }
241        }
242        Ok(sql)
243    }
244}
245
246/// Physical column name in Better Auth's default two-factor table.
247pub(crate) fn two_factor_column(name: &str) -> &str {
248    match name {
249        "backup_codes" => "backupCodes",
250        "user_id" => "userId",
251        "failed_verification_count" => "failedVerificationCount",
252        "locked_until" => "lockedUntil",
253        name => name,
254    }
255}
256
257/// Physical storage policy for the bundled SSO provider model.
258#[derive(Debug, Clone)]
259pub struct SsoProviderDatabaseConfig {
260    pub table_name: String,
261    /// Canonical `snake_case` names mapped to application-owned column names.
262    pub columns: std::collections::HashMap<String, String>,
263}
264impl Default for SsoProviderDatabaseConfig {
265    fn default() -> Self {
266        Self {
267            table_name: "ssoProvider".into(),
268            columns: std::collections::HashMap::default(),
269        }
270    }
271}
272impl SsoProviderDatabaseConfig {
273    /// Resolve the reference adapter's additional predicate-name mapping pass.
274    /// Physical names matching canonical fields are treated as logical names again.
275    #[must_use]
276    pub fn predicate_field<'a>(&'a self, field: &'a str) -> &'a str {
277        let physical = self
278            .columns
279            .iter()
280            .find(|(name, _)| sso_provider_column(name) == field)
281            .map_or(field, |(_, physical)| physical.as_str());
282        if [
283            "id",
284            "issuer",
285            "domain",
286            "providerId",
287            "userId",
288            "organizationId",
289            "oidcConfig",
290            "samlConfig",
291            "domainVerified",
292        ]
293        .contains(&physical)
294        {
295            physical
296        } else {
297            field
298        }
299    }
300
301    /// Rename the bundled provider relation and its configured columns atomically.
302    pub fn migration_statements(&self) -> crate::AuthResult<Vec<String>> {
303        const COLUMNS: &[&str] = &[
304            "id",
305            "issuer",
306            "domain",
307            "provider_id",
308            "user_id",
309            "organization_id",
310            "oidc_config",
311            "saml_config",
312            "domain_verified",
313        ];
314        if self.table_name.is_empty()
315            || self
316                .columns
317                .iter()
318                .any(|(name, column)| !COLUMNS.contains(&name.as_str()) || column.is_empty())
319        {
320            return Err(AuthError::config("Invalid SSO provider storage mapping"));
321        }
322        let quote = |name: &str| format!("\"{}\"", name.replace('"', "\"\""));
323        let table = quote(&self.table_name);
324        let mut sql = Vec::new();
325        if self.table_name != "ssoProvider" {
326            sql.push(format!("ALTER TABLE \"ssoProvider\" RENAME TO {table}"));
327        }
328        let nonce = uuid::Uuid::new_v4();
329        let mut destinations = Vec::new();
330        for (index, name) in COLUMNS.iter().enumerate() {
331            if let Some(column) = self
332                .columns
333                .get(*name)
334                .filter(|column| column.as_str() != sso_provider_column(name))
335            {
336                let temporary = format!("__alibi_sso_{nonce}_{index}");
337                sql.push(format!(
338                    "ALTER TABLE {table} RENAME COLUMN {} TO {}",
339                    quote(sso_provider_column(name)),
340                    quote(&temporary)
341                ));
342                destinations.push((temporary, column));
343            }
344        }
345        for (temporary, column) in destinations {
346            sql.push(format!(
347                "ALTER TABLE {table} RENAME COLUMN {} TO {}",
348                quote(&temporary),
349                quote(column)
350            ));
351        }
352        Ok(sql)
353    }
354}
355pub(crate) fn sso_provider_column(name: &str) -> &str {
356    match name {
357        "provider_id" => "providerId",
358        "user_id" => "userId",
359        "organization_id" => "organizationId",
360        "oidc_config" => "oidcConfig",
361        "saml_config" => "samlConfig",
362        "domain_verified" => "domainVerified",
363        name => name,
364    }
365}