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                format!("better-auth.{suffix}")
208            }
209        });
210    if secure_cookie_policy(config) {
211        format!("__Secure-{name}")
212    } else {
213        name
214    }
215}
216
217// This is createCookieGetter's initial selection, not the final attributes.
218// Dynamic URLs retain that selection unless cross-subdomain request resolution
219// rebuilds cookies using the resolved origin (as in the published context).
220fn secure_cookie_policy(config: &AuthConfig) -> bool {
221    config.advanced.use_secure_cookies.unwrap_or_else(|| {
222        if let Some(dynamic) = &config.dynamic_base_url {
223            match dynamic.protocol {
224                Some(crate::config::BaseUrlProtocol::Https) => true,
225                Some(crate::config::BaseUrlProtocol::Http) => false,
226                _ => std::env::var("NODE_ENV").is_ok_and(|env| env == "production"),
227            }
228        } else {
229            config.base_url.starts_with("https://")
230        }
231    })
232}
233
234fn cookie_attributes(name: &str, config: &AuthConfig) -> CookieAttributes {
235    let mut attributes = CookieAttributes {
236        secure: Some(config.advanced.use_secure_cookies.unwrap_or_else(|| {
237            secure_cookie_policy(config)
238                || (config.dynamic_base_url.is_none() && config.session.cookie_secure)
239        })),
240        http_only: Some(config.session.cookie_http_only),
241        same_site: Some(config.session.cookie_same_site.clone()),
242        path: Some("/".into()),
243        domain: config
244            .advanced
245            .cross_sub_domain_cookies
246            .as_ref()
247            .and_then(|cross| {
248                if cross.domain.is_empty() {
249                    url::Url::parse(&config.base_url)
250                        .ok()
251                        .and_then(|url| url.host_str().map(str::to_owned))
252                } else {
253                    Some(cross.domain.clone())
254                }
255            }),
256        max_age: None,
257        expires: None,
258        partitioned: None,
259    };
260    apply_attributes(&mut attributes, &config.advanced.default_cookie_attributes);
261    let logical = if name == related_cookie_name(config, "session_token") {
262        Some("session_token")
263    } else {
264        config.advanced.cookies.keys().find_map(|logical| {
265            (related_cookie_name(config, logical) == name).then_some(logical.as_str())
266        })
267    };
268    if let Some(entry) = logical.and_then(|logical| config.advanced.cookies.get(logical)) {
269        apply_attributes(&mut attributes, &entry.attributes);
270    }
271    attributes
272}
273
274/// Session cache producer default followed by its own per-cookie override.
275/// Default attributes precede the producer age and cannot replace it.
276pub(crate) fn session_cache_max_age(config: &AuthConfig, producer_age: f64) -> f64 {
277    config
278        .advanced
279        .cookies
280        .get("session_data")
281        .and_then(|entry| entry.attributes.max_age)
282        .unwrap_or(producer_age)
283}
284
285/// Render an account cookie with resolved account attributes and numeric TTL.
286/// Chunk names inherit the base cookie attributes.
287///
288/// # Errors
289/// Returns an error when Max-Age exceeds the published serializer limit.
290pub fn create_account_cookie_header(
291    name: &str,
292    base: &str,
293    value: &str,
294    max_age: f64,
295    config: &AuthConfig,
296) -> AuthResult<String> {
297    create_numeric_cookie_header(name, base, value, Some(max_age), config)
298}
299
300// Cache cookies can omit Max-Age (browser sessions), while account writers
301// always supply a numeric age. Both resolve chunk attributes from the base.
302pub(crate) fn create_numeric_cookie_header(
303    name: &str,
304    base: &str,
305    value: &str,
306    max_age: Option<f64>,
307    config: &AuthConfig,
308) -> AuthResult<String> {
309    let mut attributes = cookie_attributes(base, config);
310    attributes.max_age = max_age;
311    render_encoded_cookie(name, value, &attributes)
312}
313
314fn apply_attributes(target: &mut CookieAttributes, overrides: &CookieAttributes) {
315    if overrides.secure.is_some() {
316        target.secure = overrides.secure;
317    }
318    if overrides.http_only.is_some() {
319        target.http_only = overrides.http_only;
320    }
321    if overrides.same_site.is_some() {
322        target.same_site.clone_from(&overrides.same_site);
323    }
324    if overrides.path.is_some() {
325        target.path.clone_from(&overrides.path);
326    }
327    if overrides.domain.is_some() {
328        target.domain.clone_from(&overrides.domain);
329    }
330    if overrides.expires.is_some() {
331        target.expires = overrides.expires;
332    }
333    if overrides.partitioned.is_some() {
334        target.partitioned = overrides.partitioned;
335    }
336    if overrides.max_age.is_some() {
337        target.max_age = overrides.max_age;
338    }
339}
340
341// Values arrive already encoded (including signatures); encoding them again
342// would change the credential. Keep Better Call's attribute order and omit
343// synthetic Expires. Limits fail here while the producer is emitting the cookie.
344fn render_encoded_cookie(
345    name: &str,
346    value: &str,
347    attributes: &CookieAttributes,
348) -> AuthResult<String> {
349    let host = name.starts_with("__Host-");
350    let mut header = format!("{name}={value}");
351    if let Some(age) = attributes.max_age.filter(|age| *age >= 0.0) {
352        if age > 34_560_000.0 {
353            return Err(AuthError::CallbackFailure(Box::new(AuthError::internal(
354                "Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.",
355            ))));
356        }
357        // JavaScript renders Math.floor(-0) as "0".
358        let age = if age == 0.0 { 0.0 } else { age.floor() };
359        _ = write!(header, "; Max-Age={age}");
360    }
361    if !host
362        && let Some(domain) = attributes
363            .domain
364            .as_ref()
365            .filter(|domain| !domain.is_empty())
366    {
367        _ = write!(header, "; Domain={domain}");
368    }
369    let path = if host {
370        Some("/")
371    } else {
372        attributes.path.as_deref().filter(|path| !path.is_empty())
373    };
374    if let Some(path) = path {
375        _ = write!(header, "; Path={path}");
376    }
377    if let Some(expires) = attributes.expires {
378        if expires
379            .signed_duration_since(chrono::Utc::now())
380            .num_milliseconds()
381            > 34_560_000_000
382        {
383            return Err(AuthError::CallbackFailure(Box::new(AuthError::internal(
384                "Cookies Expires SHOULD NOT be greater than 400 days (34560000 seconds) in the future.",
385            ))));
386        }
387        _ = write!(
388            header,
389            "; Expires={}",
390            expires.format("%a, %d %b %Y %H:%M:%S GMT")
391        );
392    }
393    if attributes.http_only == Some(true) {
394        header.push_str("; HttpOnly");
395    }
396    if attributes.secure == Some(true) || host || name.starts_with("__Secure-") {
397        header.push_str("; Secure");
398    }
399    if let Some(same_site) = &attributes.same_site {
400        header.push_str(match same_site {
401            SameSite::Lax => "; SameSite=Lax",
402            SameSite::Strict => "; SameSite=Strict",
403            SameSite::None => "; SameSite=None",
404        });
405    }
406    // Published Better Call appends Partitioned last. It does not retroactively
407    // append Secure when an explicit secure=false attribute was serialized.
408    if attributes.partitioned == Some(true) {
409        header.push_str("; Partitioned");
410    }
411    Ok(header)
412}
413
414/// Clear all cookies associated with the current session.
415pub fn delete_session_cookie_headers(config: &AuthConfig) -> AuthResult<Vec<String>> {
416    let mut cookies = vec![
417        create_clear_session_cookie(config)?,
418        create_clear_cookie(&related_cookie_name(config, "session_data"), config)?,
419    ];
420
421    if config.account.store_account_cookie {
422        cookies.push(create_clear_cookie(
423            &related_cookie_name(config, "account_data"),
424            config,
425        )?);
426    }
427
428    if matches!(
429        config.account.store_state_strategy,
430        crate::config::OAuthStateStrategy::Cookie
431    ) {
432        cookies.push(create_clear_cookie(
433            &related_cookie_name(config, "oauth_state"),
434            config,
435        )?);
436    }
437
438    cookies.push(create_clear_cookie(
439        &related_cookie_name(config, "dont_remember"),
440        config,
441    )?);
442    Ok(cookies)
443}