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