1use crate::utils::json::JsValue;
7use crate::{AdapterOutput, AdapterRecord};
8pub(crate) mod date;
9
10pub mod jwt;
11pub mod runtime;
12
13use crate::{AuthResult, AuthSession, AuthUser, SessionView, UserView};
14use async_trait::async_trait;
15use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
16use chrono::{SecondsFormat, Utc};
17use hmac::{Hmac, KeyInit, Mac};
18use serde_json::{Value, json};
19use sha2::Sha256;
20use std::{any::Any, fmt, sync::Arc};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum CacheVersionSource {
25 Created,
26 Stored,
28 Cached,
29}
30
31#[derive(Clone)]
37pub struct CacheVersionContext {
38 user: UserView,
39 session: SessionView,
40 originals: Option<(Arc<dyn Any + Send + Sync>, Arc<dyn Any + Send + Sync>)>,
41 source: CacheVersionSource,
42 public_projection: Option<(UserView, SessionView)>,
43 user_output: Option<AdapterOutput>,
44 session_output: Option<AdapterOutput>,
45}
46
47impl fmt::Debug for CacheVersionContext {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.debug_struct("CacheVersionContext")
50 .field("source", &self.source())
51 .field("user", &self.user)
52 .field("session", &self.session)
53 .finish_non_exhaustive()
54 }
55}
56
57impl CacheVersionContext {
58 pub fn created<U: AuthUser, T: AuthSession>(
60 user: U,
61 session: T,
62 user_view: UserView,
63 session_view: SessionView,
64 ) -> Self {
65 Self {
66 user_output: user.adapter_snapshot().cloned(),
67 session_output: session.adapter_snapshot().cloned(),
68 user: user_view,
69 session: session_view,
70 originals: Some((Arc::new(user), Arc::new(session))),
71 source: CacheVersionSource::Created,
72 public_projection: None,
73 }
74 }
75 #[must_use]
77 pub fn cached(user: UserView, session: SessionView) -> Self {
78 Self {
79 user,
80 session,
81 originals: None,
82 source: CacheVersionSource::Cached,
83 public_projection: None,
84 user_output: None,
85 session_output: None,
86 }
87 }
88 pub(crate) fn stored(
89 user: UserView,
90 session: SessionView,
91 user_output: Option<AdapterOutput>,
92 session_output: Option<AdapterOutput>,
93 ) -> Self {
94 Self {
95 user,
96 session,
97 originals: None,
98 source: CacheVersionSource::Stored,
99 public_projection: None,
100 user_output,
101 session_output,
102 }
103 }
104 pub(crate) fn with_public_projection(mut self, user: UserView, session: SessionView) -> Self {
105 self.public_projection = Some((user, session));
106 self
107 }
108 pub(crate) fn public_user(&self) -> &UserView {
109 self.public_projection
110 .as_ref()
111 .map_or(&self.user, |projection| &projection.0)
112 }
113 pub(crate) fn public_session(&self) -> &SessionView {
114 self.public_projection
115 .as_ref()
116 .map_or(&self.session, |projection| &projection.1)
117 }
118 #[must_use]
119 pub const fn source(&self) -> CacheVersionSource {
120 self.source
121 }
122 #[must_use]
123 pub const fn user(&self) -> &UserView {
124 &self.user
125 }
126 #[must_use]
127 pub const fn session(&self) -> &SessionView {
128 &self.session
129 }
130 #[must_use]
133 pub const fn user_output(&self) -> Option<&AdapterOutput> {
134 self.user_output.as_ref()
135 }
136 #[must_use]
137 pub const fn session_output(&self) -> Option<&AdapterOutput> {
138 self.session_output.as_ref()
139 }
140 #[must_use]
141 pub fn stored_user<T: AuthUser>(&self) -> Option<&T> {
142 let original = &self.originals.as_ref()?.0;
143 original.downcast_ref().or_else(|| {
144 original
145 .downcast_ref::<AdapterRecord<T>>()
146 .map(AdapterRecord::stored)
147 })
148 }
149 #[must_use]
150 pub fn stored_session<T: AuthSession>(&self) -> Option<&T> {
151 let original = &self.originals.as_ref()?.1;
152 original.downcast_ref().or_else(|| {
153 original
154 .downcast_ref::<AdapterRecord<T>>()
155 .map(AdapterRecord::stored)
156 })
157 }
158}
159
160#[async_trait]
162pub trait CookieCacheVersionResolver: Send + Sync {
163 async fn resolve(&self, context: &CacheVersionContext) -> AuthResult<String>;
164}
165
166#[derive(Clone)]
168pub enum CookieCacheVersion {
169 Literal(String),
170 Resolver(Arc<dyn CookieCacheVersionResolver>),
171}
172
173impl fmt::Debug for CookieCacheVersion {
174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175 match self {
176 Self::Literal(value) => f.debug_tuple("Literal").field(value).finish(),
177 Self::Resolver(_) => f.write_str("Resolver(..)"),
178 }
179 }
180}
181
182impl CookieCacheVersion {
183 pub async fn resolve(&self, context: &CacheVersionContext) -> AuthResult<String> {
184 match self {
185 Self::Literal(value) => Ok(if value.is_empty() {
186 "1".into()
187 } else {
188 value.clone()
189 }),
190 Self::Resolver(resolver) => resolver.resolve(context).await,
191 }
192 }
193}
194
195#[derive(Clone, Debug)]
198pub struct CompactCache {
199 pub user: UserView,
200 pub session: SessionView,
201 pub updated_at: f64,
202 pub version: Option<String>,
203 pub expires_at: f64,
204}
205
206#[derive(Clone, Debug)]
208pub enum CacheValidation {
209 Invalid,
210 Hit(Box<CompactCache>),
211}
212
213#[must_use]
215pub fn effective_max_age(max_age: f64) -> f64 {
216 if max_age == 0.0 || max_age.is_nan() {
217 300.0
218 } else {
219 max_age
220 }
221}
222
223#[expect(
229 clippy::as_conversions,
230 clippy::cast_precision_loss,
231 clippy::suboptimal_flops,
232 reason = "Match JavaScript Number arithmetic and its separate rounding steps for cookie expiry"
233)]
234pub fn encode_compact(
235 user: &UserView,
236 session: &SessionView,
237 version: &str,
238 now_ms: i64,
239 max_age: f64,
240 dont_remember: bool,
241 secret: &str,
242) -> AuthResult<String> {
243 let payload = jwt::payload(user, session, version, now_ms);
244 let expiry = now_ms as f64
245 + if dont_remember {
246 60.0
247 } else {
248 effective_max_age(max_age)
249 } * 1000.0;
250 let expiry = if expiry.is_finite() && expiry.abs() <= 8.64e15 {
251 json!(expiry.trunc())
252 } else {
253 Value::Null
254 };
255 let mut signed = payload.as_object().cloned().unwrap_or_default();
256 _ = signed.insert("expiresAt".into(), expiry.clone());
257 let signature = signature(secret, crate::utils::json::to_string(&signed)?.as_bytes());
258 let envelope = json!({"session":payload,"expiresAt":expiry,"signature":signature});
259 Ok(URL_SAFE_NO_PAD.encode(crate::utils::json::to_string(&envelope)?))
260}
261
262#[expect(clippy::expect_used, reason = "HMAC-SHA256 accepts keys of any length")]
263fn signature(secret: &str, data: &[u8]) -> String {
264 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key");
265 mac.update(data);
266 URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
267}
268
269pub(crate) fn decode_base64(value: &str) -> Result<Vec<u8>, &'static str> {
272 let alphabet = if value.contains(['-', '_']) {
273 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
274 } else {
275 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
276 };
277 let mut output = Vec::new();
278 let mut buffer = 0u32;
279 let mut bits = 0;
280 for byte in value.bytes().take_while(|byte| *byte != b'=') {
281 let digit = alphabet
282 .iter()
283 .position(|candidate| *candidate == byte)
284 .and_then(|digit| u32::try_from(digit).ok())
285 .ok_or("Invalid Base64 character")?;
286 buffer = buffer.wrapping_shl(6) | digit;
287 bits += 6;
288 if bits >= 8 {
289 bits -= 8;
290 output.push(u8::try_from((buffer >> bits) & 255).map_err(|_| "Invalid Base64 byte")?);
291 }
292 }
293 Ok(output)
294}
295
296pub fn decode_compact(value: &str, secret: &str) -> Option<CompactCache> {
299 decode_compact_http(value, secret).ok().flatten()
300}
301
302pub(crate) fn decode_compact_http(value: &str, secret: &str) -> AuthResult<Option<CompactCache>> {
305 let bytes = decode_base64(value).map_err(crate::AuthError::internal)?;
306 let text = String::from_utf8_lossy(&bytes);
307 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
308 let Ok(parsed) = crate::utils::json::parse_value(text) else {
309 return Ok(None);
310 };
311 let Some((payload, expires_at, signature)) = compact_envelope(&parsed) else {
312 return Ok(None);
313 };
314 let signature = decode_base64(signature).map_err(crate::AuthError::internal)?;
315 Ok(authenticate_compact(
316 payload, expires_at, &signature, secret,
317 ))
318}
319
320fn compact_envelope(parsed: &JsValue) -> Option<(&JsValue, f64, &str)> {
321 let expires_at = parsed
322 .get("expiresAt")?
323 .as_f64()
324 .filter(|value| value.is_finite())?;
325 let signature = parsed.get("signature")?.as_str()?;
326 let payload = parsed.get("session")?;
327 _ = payload.as_object()?;
328 Some((payload, expires_at, signature))
329}
330
331fn authenticate_compact(
332 original_payload: &JsValue,
333 expires_at: f64,
334 signature: &[u8],
335 secret: &str,
336) -> Option<CompactCache> {
337 let mut normalized = original_payload.clone();
338 date::revive(&mut normalized);
339 let mut signed = normalized.as_object()?.clone();
340 _ = signed.insert("expiresAt".into(), JsValue::Number(expires_at));
341 let message = crate::utils::json::to_string(&signed).ok()?;
342 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
343 mac.update(message.as_bytes());
344 mac.verify_slice(signature).ok()?;
345 parse_payload(original_payload, expires_at)
346}
347
348pub(crate) fn parse_payload(original_payload: &JsValue, expires_at: f64) -> Option<CompactCache> {
350 for (object, required, optional) in [
353 (
354 original_payload.get("user")?.as_object()?,
355 &["id", "email", "name"][..],
356 &["image"][..],
357 ),
358 (
359 original_payload.get("session")?.as_object()?,
360 &["id", "token"][..],
361 &["ipAddress", "userAgent"][..],
362 ),
363 ] {
364 for field in required {
365 let text = object.get(*field)?.as_str()?;
366 if date::parse(text).is_some() {
367 return None;
368 }
369 }
370 for field in optional {
371 if object
372 .get(*field)
373 .and_then(JsValue::as_str)
374 .is_some_and(|text| date::parse(text).is_some())
375 {
376 return None;
377 }
378 }
379 for field in ["createdAt", "updatedAt"] {
380 if let Some(value) = object.get(field) {
381 _ = date::parse(value.as_str()?)?;
382 }
383 }
384 }
385 _ = date::parse(
386 original_payload
387 .get("session")?
388 .get("expiresAt")?
389 .as_str()?,
390 )?;
391 if original_payload
392 .get("version")
393 .and_then(JsValue::as_str)
394 .is_some_and(|text| date::parse(text).is_some())
395 {
396 return None;
397 }
398 let mut normalized = original_payload.clone();
399 date::revive_parsed(&mut normalized);
400 let payload = normalized.as_object()?;
401 let updated_at = payload
402 .get("updatedAt")?
403 .as_f64()
404 .filter(|value| value.is_finite())?;
405 let version = match payload.get("version") {
406 None => None,
407 Some(value) => Some(value.as_str()?.to_owned()),
408 };
409 let mut user = payload.get("user")?.to_json_value().ok()?;
410 let mut session = payload.get("session")?.to_json_value().ok()?;
411 let user_id = date::coerce_id(original_payload.get("session")?.get("userId")?)?;
412 _ = session
413 .as_object_mut()?
414 .insert("userId".into(), Value::String(user_id));
415 for object in [&mut user, &mut session] {
418 let map = object.as_object_mut()?;
419 for key in ["createdAt", "updatedAt"] {
420 if !map.contains_key(key) {
421 _ = map.insert(
422 key.into(),
423 json!(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)),
424 );
425 }
426 }
427 }
428 let map = user.as_object_mut()?;
429 let email = map.get("email")?.as_str()?.to_lowercase();
430 _ = map.insert("email".into(), Value::String(email));
431 _ = map.get("name")?.as_str()?;
432 if !map.contains_key("emailVerified") {
433 _ = map.insert("emailVerified".into(), Value::Bool(false));
434 }
435 let null_user_extensions: Vec<_> = [
436 "username",
437 "displayUsername",
438 "twoFactorEnabled",
439 "role",
440 "banned",
441 "banReason",
442 "banExpires",
443 "isAnonymous",
444 "phoneNumber",
445 "phoneNumberVerified",
446 "lastLoginMethod",
447 ]
448 .into_iter()
449 .filter(|name| user.get(*name).is_some_and(Value::is_null))
450 .collect();
451 let mut user: UserView = serde_json::from_value(user).ok()?;
452 if original_payload.get("user")?.get("image").is_none() {
453 _ = user.omitted_fields.insert("image".into());
454 }
455 for name in null_user_extensions {
456 _ = user.extension_fields.insert(name.into(), Value::Null);
457 }
458 let null_extensions: Vec<_> = ["activeOrganizationId", "activeTeamId", "impersonatedBy"]
459 .into_iter()
460 .filter(|name| session.get(*name).is_some_and(Value::is_null))
461 .collect();
462 let mut session: SessionView = serde_json::from_value(session).ok()?;
463 for name in ["ipAddress", "userAgent"] {
464 if original_payload.get("session")?.get(name).is_none() {
465 _ = session.omitted_fields.insert(name.into());
466 }
467 }
468 for name in null_extensions {
469 _ = session.extension_fields.insert(name.into(), Value::Null);
470 }
471 Some(CompactCache {
472 user,
473 session,
474 updated_at,
475 version,
476 expires_at,
477 })
478}
479
480#[expect(
484 clippy::as_conversions,
485 clippy::cast_precision_loss,
486 reason = "Preserve JavaScript Number rounding at the compatibility boundary"
487)]
488pub async fn validate_compact(
489 cache: CompactCache,
490 token: &str,
491 policy: Option<&CookieCacheVersion>,
492 now_ms: i64,
493) -> AuthResult<CacheValidation> {
494 if cache.session.token != token {
495 return Ok(CacheValidation::Invalid);
496 }
497 let expected = match policy {
498 Some(policy) => {
499 policy
500 .resolve(&CacheVersionContext::cached(
501 cache.user.clone(),
502 cache.session.clone(),
503 ))
504 .await?
505 }
506 None => "1".into(),
507 };
508 let version = cache
509 .version
510 .as_deref()
511 .filter(|version| !version.is_empty())
512 .unwrap_or("1");
513 if version != expected
514 || cache.expires_at < now_ms as f64
515 || cache.session.expires_at.timestamp_millis() < now_ms
516 {
517 return Ok(CacheValidation::Invalid);
518 }
519 Ok(CacheValidation::Hit(Box::new(cache)))
520}
521
522pub fn cookie_header(
528 name: &str,
529 value: &str,
530 max_age: Option<f64>,
531 config: &crate::AuthConfig,
532) -> AuthResult<String> {
533 let bases = ["session_data", "account_data"]
534 .map(|logical| crate::utils::cookie_utils::related_cookie_name(config, logical));
535 let base = (!bases.iter().any(|base| base == name))
536 .then(|| {
537 bases
538 .iter()
539 .find(|base| runtime::chunk_index(name, base).is_some())
540 })
541 .flatten();
542 let encoded =
543 percent_encoding::utf8_percent_encode(value, crate::utils::cookie_utils::URI_COMPONENT)
544 .to_string();
545 crate::utils::cookie_utils::create_numeric_cookie_header(
546 name,
547 base.map_or(name, String::as_str),
548 &encoded,
549 max_age,
550 config,
551 )
552}