1use 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
13pub 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
24pub 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
51pub 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
60pub 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#[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#[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 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
128pub 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 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
146pub 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
168pub fn create_clear_session_cookie(config: &AuthConfig) -> crate::AuthResult<String> {
170 create_clear_cookie(&related_cookie_name(config, "session_token"), config)
171}
172
173pub fn create_clear_cookie(name: &str, config: &AuthConfig) -> crate::AuthResult<String> {
179 create_session_like_cookie(name, "", Some(0), config)
180}
181
182#[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
230fn 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
287pub(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
298pub 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
313pub(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
354fn 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 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 if attributes.partitioned == Some(true) {
426 header.push_str("; Partitioned");
427 }
428 Ok(header)
429}
430
431pub 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}