Skip to main content

alibi_core/utils/
cookie_utils.rs

1//! Shared cookie utilities for building `Set-Cookie` headers.
2//!
3//! This module centralises the session cookie construction that was previously
4//! duplicated across every plugin (`email_password`, `passkey`, `two_factor`,
5//! `admin`, `password_management`, `session_management`, `email_verification`).
6
7use crate::config::{AuthConfig, CookieAttributes, SameSite};
8use base64::{Engine, engine::general_purpose::STANDARD};
9use hmac::{Hmac, KeyInit, Mac};
10use sha2::Sha256;
11use std::fmt::Write as _;
12
13/// Build a `Set-Cookie` header value for an arbitrary cookie using the auth
14/// config's session cookie attributes for consistency.
15pub fn create_cookie(
16    name: &str,
17    value: &str,
18    max_age_seconds: i64,
19    config: &AuthConfig,
20) -> crate::AuthResult<String> {
21    create_cookie_with_max_age(name, value, Some(max_age_seconds as f64), config)
22}
23
24/// Create a factory cookie: producer attributes precede per-cookie overrides.
25/// Unlike session-token issuance, the configured family age takes precedence.
26///
27/// # Errors
28/// Returns the published serializer's Max-Age or Expires limit error at emission.
29pub fn create_cookie_with_max_age(
30    name: &str,
31    value: &str,
32    producer_age: Option<f64>,
33    config: &AuthConfig,
34) -> crate::AuthResult<String> {
35    let mut attributes = cookie_attributes(name, config);
36    if producer_age.is_some() {
37        attributes.max_age = producer_age;
38        if let Some(age) = config
39            .advanced
40            .cookies
41            .iter()
42            .find(|(logical, _)| related_cookie_name(config, logical) == name)
43            .and_then(|(_, entry)| entry.attributes.max_age)
44        {
45            attributes.max_age = Some(age);
46        }
47    }
48    render_encoded_cookie(name, value, &attributes)
49}
50
51/// Build a `Set-Cookie` header value for a signed session token.
52pub fn create_session_cookie(token: &str, config: &AuthConfig) -> crate::AuthResult<String> {
53    create_session_cookie_with_max_age(
54        Some(token),
55        Some(config.session.expires_in.num_seconds()),
56        config,
57    )
58}
59
60/// Build a `Set-Cookie` header value for a session token using the session
61/// cookie attributes, optionally omitting `Max-Age` to create a
62/// browser-session cookie.
63pub fn create_session_cookie_with_max_age(
64    token: Option<&str>,
65    max_age_seconds: Option<i64>,
66    config: &AuthConfig,
67) -> crate::AuthResult<String> {
68    let signed = token
69        .filter(|token| !token.is_empty())
70        .map(|token| sign_cookie_value(token, config.current_secret()));
71    create_session_like_cookie(
72        &related_cookie_name(config, "session_token"),
73        signed.as_deref().unwrap_or(""),
74        max_age_seconds,
75        config,
76    )
77}
78
79/// Sign and URI-encode a cookie value using Better Call's HMAC-SHA256 encoding.
80#[expect(
81    clippy::expect_used,
82    reason = "HMAC-SHA256 accepts keys of every length"
83)]
84#[expect(
85    clippy::missing_panics_doc,
86    reason = "HMAC accepts keys of every length, so key construction cannot fail"
87)]
88#[must_use]
89pub fn sign_cookie_value(value: &str, secret: &str) -> String {
90    const COMPONENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
91        .remove(b'-')
92        .remove(b'_')
93        .remove(b'.')
94        .remove(b'!')
95        .remove(b'~')
96        .remove(b'*')
97        .remove(b'\'')
98        .remove(b'(')
99        .remove(b')');
100
101    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key");
102    mac.update(value.as_bytes());
103    let signed = format!("{value}.{}", STANDARD.encode(mac.finalize().into_bytes()));
104
105    percent_encoding::utf8_percent_encode(&signed, COMPONENT).to_string()
106}
107
108/// Return a cookie's payload only when its signature verifies.
109#[must_use]
110pub fn verify_cookie_value(value: &str, secret: &str) -> Option<String> {
111    let decoded = percent_encoding::percent_decode_str(value)
112        .decode_utf8()
113        .ok()?;
114    let (payload, signature) = decoded.rsplit_once('.')?;
115    // Better Call's atob verifier discards unused bits before padding. They
116    // do not change the authenticated HMAC bytes or weaken signature checks.
117    let decoder = base64::engine::GeneralPurpose::new(
118        &base64::alphabet::STANDARD,
119        base64::engine::GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
120    );
121    let signature = decoder.decode(signature).ok()?;
122    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
123    mac.update(payload.as_bytes());
124    mac.verify_slice(&signature).ok()?;
125    Some(payload.to_owned())
126}
127
128/// Build a `Set-Cookie` header value using the session cookie attributes for
129/// an arbitrary cookie name.
130pub fn create_session_like_cookie(
131    name: &str,
132    value: &str,
133    max_age_seconds: Option<i64>,
134    config: &AuthConfig,
135) -> crate::AuthResult<String> {
136    let mut attributes = cookie_attributes(name, config);
137    // setSessionCookie explicitly replaces the token's configured Max-Age,
138    // including with undefined for a browser session. Other cookie producers
139    // retain their configured age when they supply no override.
140    if name == related_cookie_name(config, "session_token") || max_age_seconds.is_some() {
141        attributes.max_age = max_age_seconds.map(|age| age as f64);
142    }
143    render_encoded_cookie(name, value, &attributes)
144}
145
146/// Build a derived cookie with the resolved session-token attributes.
147/// Multiple-session proofs inherit the token's overrides as well as defaults.
148pub fn create_derived_session_cookie(
149    name: &str,
150    value: &str,
151    clear: bool,
152    config: &AuthConfig,
153) -> crate::AuthResult<String> {
154    let mut attributes = cookie_attributes(&related_cookie_name(config, "session_token"), config);
155    attributes.max_age = Some(if clear {
156        0.0
157    } else {
158        config
159            .advanced
160            .cookies
161            .get("session_token")
162            .and_then(|entry| entry.attributes.max_age)
163            .unwrap_or(config.session.expires_in.num_seconds() as f64)
164    });
165    render_encoded_cookie(name, value, &attributes)
166}
167
168/// Build a `Set-Cookie` header value that clears the session cookie.
169pub fn create_clear_session_cookie(config: &AuthConfig) -> crate::AuthResult<String> {
170    create_clear_cookie(&related_cookie_name(config, "session_token"), config)
171}
172
173/// Build a `Set-Cookie` header value that clears an arbitrary cookie by name,
174/// using the session config's cookie attributes for consistency.
175///
176/// Mirrors the TypeScript `expireCookie`, which clears a cookie with `Max-Age=0`
177/// while preserving its attributes, including an explicitly configured `Expires`.
178pub fn create_clear_cookie(name: &str, config: &AuthConfig) -> crate::AuthResult<String> {
179    create_session_like_cookie(name, "", Some(0), config)
180}
181
182/// Build a Better Auth related cookie name using the configured session cookie
183/// prefix. For example, `better-auth.session_token` + `session_data` becomes
184/// `better-auth.session_data`.
185#[must_use]
186pub fn related_cookie_name(config: &AuthConfig, suffix: &str) -> String {
187    let token_name = config
188        .advanced
189        .cookies
190        .get("session_token")
191        .and_then(|entry| entry.name.as_deref())
192        .filter(|name| !name.is_empty())
193        .unwrap_or(&config.session.cookie_name);
194    let name = config
195        .advanced
196        .cookies
197        .get(suffix)
198        .and_then(|entry| entry.name.as_ref())
199        .filter(|name| !name.is_empty())
200        .cloned()
201        .unwrap_or_else(|| {
202            if let Some(prefix) = config
203                .advanced
204                .cookie_prefix
205                .as_ref()
206                .filter(|p| !p.is_empty())
207            {
208                format!("{prefix}.{suffix}")
209            } else if suffix == "session_token" {
210                token_name.to_owned()
211            } else {
212                let legacy = if config.advanced.cookies.contains_key("session_token") {
213                    None
214                } else {
215                    config.session.cookie_name.strip_suffix("session_token")
216                };
217                legacy.map_or_else(
218                    || format!("better-auth.{suffix}"),
219                    |prefix| format!("{prefix}{suffix}"),
220                )
221            }
222        });
223    if secure_cookie_policy(config) {
224        format!("__Secure-{name}")
225    } else {
226        name
227    }
228}
229
230// This is createCookieGetter's initial selection, not the final attributes.
231// Dynamic URLs retain that selection unless cross-subdomain request resolution
232// rebuilds cookies using the resolved origin (as in the published context).
233fn secure_cookie_policy(config: &AuthConfig) -> bool {
234    config.advanced.use_secure_cookies.unwrap_or_else(|| {
235        if let Some(dynamic) = &config.dynamic_base_url {
236            match dynamic.protocol {
237                Some(crate::config::BaseUrlProtocol::Https) => true,
238                Some(crate::config::BaseUrlProtocol::Http) => false,
239                _ => std::env::var("NODE_ENV").is_ok_and(|env| env == "production"),
240            }
241        } else {
242            config.base_url.starts_with("https://")
243        }
244    })
245}
246
247fn cookie_attributes(name: &str, config: &AuthConfig) -> CookieAttributes {
248    let mut attributes = CookieAttributes {
249        secure: Some(config.advanced.use_secure_cookies.unwrap_or_else(|| {
250            secure_cookie_policy(config)
251                || (config.dynamic_base_url.is_none() && config.session.cookie_secure)
252        })),
253        http_only: Some(config.session.cookie_http_only),
254        same_site: Some(config.session.cookie_same_site.clone()),
255        path: Some("/".into()),
256        domain: config
257            .advanced
258            .cross_sub_domain_cookies
259            .as_ref()
260            .and_then(|cross| {
261                if cross.domain.is_empty() {
262                    url::Url::parse(&config.base_url)
263                        .ok()
264                        .and_then(|url| url.host_str().map(str::to_owned))
265                } else {
266                    Some(cross.domain.clone())
267                }
268            }),
269        max_age: None,
270        expires: None,
271        partitioned: None,
272    };
273    apply_attributes(&mut attributes, &config.advanced.default_cookie_attributes);
274    let logical = if name == related_cookie_name(config, "session_token") {
275        Some("session_token")
276    } else {
277        config.advanced.cookies.keys().find_map(|logical| {
278            (related_cookie_name(config, logical) == name).then_some(logical.as_str())
279        })
280    };
281    if let Some(entry) = logical.and_then(|logical| config.advanced.cookies.get(logical)) {
282        apply_attributes(&mut attributes, &entry.attributes);
283    }
284    attributes
285}
286
287/// Session cache producer default followed by its own per-cookie override.
288/// Default attributes precede the producer age and cannot replace it.
289pub(crate) fn session_cache_max_age(config: &AuthConfig, producer_age: f64) -> f64 {
290    config
291        .advanced
292        .cookies
293        .get("session_data")
294        .and_then(|entry| entry.attributes.max_age)
295        .unwrap_or(producer_age)
296}
297
298/// Render an account cookie with resolved account attributes and numeric TTL.
299/// Chunk names inherit the base cookie attributes.
300///
301/// # Errors
302/// Returns an error when Max-Age exceeds the published serializer limit.
303pub fn create_account_cookie_header(
304    name: &str,
305    base: &str,
306    value: &str,
307    max_age: f64,
308    config: &AuthConfig,
309) -> crate::AuthResult<String> {
310    create_numeric_cookie_header(name, base, value, Some(max_age), config)
311}
312
313// Cache cookies can omit Max-Age (browser sessions), while account writers
314// always supply a numeric age. Both resolve chunk attributes from the base.
315pub(crate) fn create_numeric_cookie_header(
316    name: &str,
317    base: &str,
318    value: &str,
319    max_age: Option<f64>,
320    config: &AuthConfig,
321) -> crate::AuthResult<String> {
322    let mut attributes = cookie_attributes(base, config);
323    attributes.max_age = max_age;
324    render_encoded_cookie(name, value, &attributes)
325}
326
327fn apply_attributes(target: &mut CookieAttributes, overrides: &CookieAttributes) {
328    if overrides.secure.is_some() {
329        target.secure = overrides.secure;
330    }
331    if overrides.http_only.is_some() {
332        target.http_only = overrides.http_only;
333    }
334    if overrides.same_site.is_some() {
335        target.same_site.clone_from(&overrides.same_site);
336    }
337    if overrides.path.is_some() {
338        target.path.clone_from(&overrides.path);
339    }
340    if overrides.domain.is_some() {
341        target.domain.clone_from(&overrides.domain);
342    }
343    if overrides.expires.is_some() {
344        target.expires = overrides.expires;
345    }
346    if overrides.partitioned.is_some() {
347        target.partitioned = overrides.partitioned;
348    }
349    if overrides.max_age.is_some() {
350        target.max_age = overrides.max_age;
351    }
352}
353
354// Values arrive already encoded (including signatures); encoding them again
355// would change the credential. Keep Better Call's attribute order and omit
356// synthetic Expires. Limits fail here while the producer is emitting the cookie.
357fn render_encoded_cookie(
358    name: &str,
359    value: &str,
360    attributes: &CookieAttributes,
361) -> crate::AuthResult<String> {
362    let host = name.starts_with("__Host-");
363    let mut header = format!("{name}={value}");
364    if let Some(age) = attributes.max_age.filter(|age| *age >= 0.0) {
365        if age > 34_560_000.0 {
366            return Err(crate::AuthError::CallbackFailure(Box::new(
367                crate::AuthError::internal(
368                    "Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.",
369                ),
370            )));
371        }
372        // JavaScript renders Math.floor(-0) as "0".
373        let age = if age == 0.0 { 0.0 } else { age.floor() };
374        _ = write!(header, "; Max-Age={age}");
375    }
376    if !host
377        && let Some(domain) = attributes
378            .domain
379            .as_ref()
380            .filter(|domain| !domain.is_empty())
381    {
382        _ = write!(header, "; Domain={domain}");
383    }
384    let path = if host {
385        Some("/")
386    } else {
387        attributes.path.as_deref().filter(|path| !path.is_empty())
388    };
389    if let Some(path) = path {
390        _ = write!(header, "; Path={path}");
391    }
392    if let Some(expires) = attributes.expires {
393        if expires
394            .signed_duration_since(chrono::Utc::now())
395            .num_milliseconds()
396            > 34_560_000_000
397        {
398            return Err(crate::AuthError::CallbackFailure(Box::new(
399                crate::AuthError::internal(
400                    "Cookies Expires SHOULD NOT be greater than 400 days (34560000 seconds) in the future.",
401                ),
402            )));
403        }
404        _ = write!(
405            header,
406            "; Expires={}",
407            expires.format("%a, %d %b %Y %H:%M:%S GMT")
408        );
409    }
410    if attributes.http_only == Some(true) {
411        header.push_str("; HttpOnly");
412    }
413    if attributes.secure == Some(true) || host || name.starts_with("__Secure-") {
414        header.push_str("; Secure");
415    }
416    if let Some(same_site) = &attributes.same_site {
417        header.push_str(match same_site {
418            SameSite::Lax => "; SameSite=Lax",
419            SameSite::Strict => "; SameSite=Strict",
420            SameSite::None => "; SameSite=None",
421        });
422    }
423    // Published Better Call appends Partitioned last. It does not retroactively
424    // append Secure when an explicit secure=false attribute was serialized.
425    if attributes.partitioned == Some(true) {
426        header.push_str("; Partitioned");
427    }
428    Ok(header)
429}
430
431/// Clear all cookies associated with the current session.
432pub fn delete_session_cookie_headers(config: &AuthConfig) -> crate::AuthResult<Vec<String>> {
433    let mut cookies = vec![
434        create_clear_session_cookie(config)?,
435        create_clear_cookie(&related_cookie_name(config, "session_data"), config)?,
436    ];
437
438    if config.account.store_account_cookie {
439        cookies.push(create_clear_cookie(
440            &related_cookie_name(config, "account_data"),
441            config,
442        )?);
443    }
444
445    if matches!(
446        config.account.store_state_strategy,
447        crate::config::OAuthStateStrategy::Cookie
448    ) {
449        cookies.push(create_clear_cookie(
450            &related_cookie_name(config, "oauth_state"),
451            config,
452        )?);
453    }
454
455    cookies.push(create_clear_cookie(
456        &related_cookie_name(config, "dont_remember"),
457        config,
458    )?);
459    Ok(cookies)
460}