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 format!("better-auth.{suffix}")
208 }
209 });
210 if secure_cookie_policy(config) {
211 format!("__Secure-{name}")
212 } else {
213 name
214 }
215}
216
217fn 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
274pub(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
285pub 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
300pub(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
341fn 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 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 if attributes.partitioned == Some(true) {
409 header.push_str("; Partitioned");
410 }
411 Ok(header)
412}
413
414pub 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}