1use 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
11pub(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
23pub 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
34pub 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
61pub 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
70pub 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#[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#[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 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
123pub 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 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
141pub 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
163pub fn create_clear_session_cookie(config: &AuthConfig) -> AuthResult<String> {
165 create_clear_cookie(&related_cookie_name(config, "session_token"), config)
166}
167
168pub fn create_clear_cookie(name: &str, config: &AuthConfig) -> AuthResult<String> {
174 create_session_like_cookie(name, "", Some(0), config)
175}
176
177#[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
225fn 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
282pub(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
293pub 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
308pub(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
349fn 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 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 if attributes.partitioned == Some(true) {
417 header.push_str("; Partitioned");
418 }
419 Ok(header)
420}
421
422pub 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}