Skip to main content

alibi_core/utils/
cookie_utils.rs

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