1use crate::{
7 AuthContext, AuthError, AuthResult, AuthSchema, AuthVerification, CreateVerification,
8 UpdateVerification,
9 config::VerificationConfig,
10 store::{AuthStore, AuthTransaction, CacheAdapter},
11 utils::{
12 javascript::{number_from_i64, string_to_number},
13 json::{self, JsValue},
14 },
15};
16use async_trait::async_trait;
17use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
18use chrono::{DateTime, Duration, SecondsFormat, Utc};
19use indexmap::IndexMap;
20use sha2::{Digest, Sha256};
21use std::{any::Any, fmt, sync::Arc};
22
23#[async_trait]
26pub trait VerificationIdentifierHasher: Send + Sync {
27 async fn hash(&self, identifier: &str) -> AuthResult<String>;
28}
29
30#[derive(Clone, Default)]
31pub enum VerificationIdentifierStrategy {
32 #[default]
33 Plain,
34 Hashed,
35 Custom(Arc<dyn VerificationIdentifierHasher>),
36}
37impl fmt::Debug for VerificationIdentifierStrategy {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.write_str(match self {
40 Self::Plain => "Plain",
41 Self::Hashed => "Hashed",
42 Self::Custom(_) => "Custom",
43 })
44 }
45}
46impl VerificationIdentifierStrategy {
47 async fn process(&self, identifier: &str) -> AuthResult<String> {
48 match self {
49 Self::Plain => Ok(identifier.to_owned()),
50 Self::Hashed => Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(identifier.as_bytes()))),
51 Self::Custom(hasher) => hasher.hash(identifier).await,
52 }
53 }
54}
55
56#[derive(Clone, Debug, Default)]
59pub struct VerificationIdentifierPolicy {
60 pub default: VerificationIdentifierStrategy,
61 pub overrides: IndexMap<String, VerificationIdentifierStrategy>,
62}
63impl VerificationIdentifierPolicy {
64 fn strategy(&self, identifier: &str) -> &VerificationIdentifierStrategy {
65 let integer_key = |key: &str| {
66 key.parse::<u32>()
67 .ok()
68 .filter(|number| *number != u32::MAX && number.to_string() == key)
69 };
70 let mut integers: Vec<_> = self
71 .overrides
72 .iter()
73 .filter_map(|(key, value)| integer_key(key).map(|number| (number, key, value)))
74 .collect();
75 integers.sort_by_key(|entry| entry.0);
76 for (_, key, value) in integers {
77 if identifier.starts_with(key) {
78 return value;
79 }
80 }
81 self.overrides
82 .iter()
83 .find(|(key, _)| integer_key(key).is_none() && identifier.starts_with(key.as_str()))
84 .map_or(&self.default, |(_, value)| value)
85 }
86}
87
88#[derive(Clone, Debug)]
91pub struct VerificationCreation {
92 pub id: Option<String>,
93 pub identifier: String,
94 pub value: String,
95 pub expires_at: DateTime<Utc>,
96 pub created_at: DateTime<Utc>,
97 pub updated_at: DateTime<Utc>,
98}
99impl From<CreateVerification> for VerificationCreation {
100 fn from(data: CreateVerification) -> Self {
101 let now = Utc::now();
102 Self {
103 id: None,
104 identifier: data.identifier,
105 value: data.value,
106 expires_at: data.expires_at,
107 created_at: now,
108 updated_at: now,
109 }
110 }
111}
112impl VerificationCreation {
113 #[must_use]
114 pub fn data(&self) -> CreateVerification {
115 CreateVerification {
116 identifier: self.identifier.clone(),
117 value: self.value.clone(),
118 expires_at: self.expires_at,
119 }
120 }
121 #[must_use]
122 pub fn snapshot(&self) -> VerificationSnapshot {
123 let mut fields = IndexMap::new();
124 if let Some(id) = &self.id {
125 _ = fields.insert("id".into(), JsValue::String(id.clone()));
126 }
127 for (key, value) in [
128 ("identifier", self.identifier.clone()),
129 ("value", self.value.clone()),
130 ("expiresAt", date_json(self.expires_at)),
131 ("createdAt", date_json(self.created_at)),
132 ("updatedAt", date_json(self.updated_at)),
133 ] {
134 _ = fields.insert(key.into(), JsValue::String(value));
135 }
136 VerificationSnapshot {
137 data: JsValue::Object(fields),
138 expiry: Some(self.expires_at.timestamp_millis()),
139 physical_expiry: None,
140 original_model: None,
141 }
142 }
143}
144
145#[derive(Clone)]
149pub struct VerificationSnapshot {
150 data: JsValue,
151 expiry: Option<i64>,
152 physical_expiry: Option<DateTime<Utc>>,
153 original_model: Option<Arc<dyn Any + Send + Sync>>,
154}
155impl fmt::Debug for VerificationSnapshot {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 f.debug_struct("VerificationSnapshot")
158 .field("data", &self.data)
159 .field("physical", &self.original_model.is_some())
160 .finish_non_exhaustive()
161 }
162}
163impl VerificationSnapshot {
164 #[must_use]
165 pub fn from_model<T: AuthVerification + Clone + Send + Sync + 'static>(model: &T) -> Self {
166 let creation = VerificationCreation {
167 id: Some(model.id().into_owned()),
168 identifier: model.identifier().to_owned(),
169 value: model.value().to_owned(),
170 expires_at: model.expires_at(),
171 created_at: model.created_at(),
172 updated_at: model.updated_at(),
173 };
174 let mut snapshot = creation.snapshot();
175 snapshot.physical_expiry = Some(model.expires_at());
176 snapshot.original_model = Some(Arc::new(model.clone()));
177 snapshot
178 }
179 #[must_use]
180 pub fn data(&self) -> &JsValue {
181 &self.data
182 }
183 #[must_use]
184 pub fn original_model<T: Any>(&self) -> Option<&T> {
185 self.original_model.as_ref()?.downcast_ref()
186 }
187 #[must_use]
189 pub fn id(&self) -> Option<&str> {
190 self.data.get("id").and_then(JsValue::as_str)
191 }
192 pub fn identifier(&self) -> AuthResult<&str> {
193 self.text("identifier")
194 }
195 pub fn value(&self) -> AuthResult<&str> {
196 self.text("value")
197 }
198 fn text(&self, key: &str) -> AuthResult<&str> {
199 self.data
200 .get(key)
201 .and_then(JsValue::as_str)
202 .ok_or_else(|| AuthError::internal(format!("verification {key} is not a string")))
203 }
204 pub fn expires_at(&self) -> AuthResult<DateTime<Utc>> {
205 if let Some(expiry) = self.physical_expiry {
206 return Ok(expiry);
207 }
208 let millis = self
209 .expiry
210 .or_else(|| date_value(self.data.get("expiresAt")))
211 .ok_or_else(|| AuthError::internal("verification expiry is not a valid date"))?;
212 DateTime::from_timestamp_millis(millis).ok_or_else(|| {
213 AuthError::NotImplemented(
214 "The verification expiry is outside the physical model date range".into(),
215 )
216 })
217 }
218 #[must_use]
221 pub fn is_expired(&self) -> bool {
222 let expiry = self
223 .expiry
224 .map_or_else(|| number(self.data.get("expiresAt")), number_from_i64);
225 expiry < number_from_i64(Utc::now().timestamp_millis())
226 }
227 fn cached(raw: &str) -> Option<Self> {
228 let mut data = json::parse_value(raw).ok()?;
229 if !data.is_truthy() {
230 return None;
231 }
232 let expiry = data
233 .get("expiresAt")
234 .and_then(JsValue::as_str)
235 .and_then(crate::session::cookie_cache::date::parse)
236 .map(|date| date.timestamp_millis());
237 crate::session::cookie_cache::date::revive(&mut data);
238 Some(Self {
239 data,
240 expiry,
241 physical_expiry: None,
242 original_model: None,
243 })
244 }
245 fn hydrate(mut self) -> Option<Self> {
246 let expiry = self
247 .expiry
248 .or_else(|| date_value(self.data.get("expiresAt")))?;
249 self.expiry = Some(expiry);
250 if let JsValue::Object(fields) = &mut self.data {
251 _ = fields.insert(
252 "expiresAt".into(),
253 JsValue::String(crate::utils::datetime::json_date_millis(expiry)),
254 );
255 }
256 Some(self)
257 }
258}
259
260#[derive(Clone)]
263pub struct VerificationPublication {
264 pub store_in_database: bool,
265 pub secondary_storage: Option<Arc<dyn CacheAdapter>>,
266 pub cache_key: String,
267}
268impl VerificationPublication {
269 pub async fn publish(&self, snapshot: &VerificationSnapshot) -> AuthResult<()> {
270 if let Some(cache) = &self.secondary_storage
271 && let Some(expiry) = snapshot
272 .expiry
273 .or_else(|| date_value(snapshot.data.get("expiresAt")))
274 {
275 let ttl = (expiry - Utc::now().timestamp_millis()).div_euclid(1000);
276 if ttl > 0 {
277 cache
278 .set(
279 &self.cache_key,
280 &json::to_string(snapshot.data())?,
281 Duration::seconds(ttl),
282 )
283 .await?;
284 }
285 }
286 Ok(())
287 }
288}
289
290pub struct VerificationService<'a, S: AuthSchema> {
291 config: &'a VerificationConfig,
292 database: &'a dyn AuthStore<S>,
293}
294impl<S: AuthSchema> AuthContext<S> {
295 #[must_use]
296 pub fn verifications(&self) -> VerificationService<'_, S> {
297 VerificationService {
298 config: &self.config.verification,
299 database: self.database.as_ref(),
300 }
301 }
302}
303pub struct VerificationConsumption {
305 identifiers: Vec<String>,
306 secondary_storage: Option<Arc<dyn CacheAdapter>>,
307}
308
309impl VerificationConsumption {
310 pub async fn consume_in<S: AuthSchema>(
317 &self,
318 tx: &dyn crate::store::AuthTransaction<S>,
319 ) -> AuthResult<Option<VerificationSnapshot>> {
320 for stored in &self.identifiers {
321 if let Some(model) = tx.consume_verification_snapshot(stored).await? {
322 return Ok(Some(VerificationSnapshot::from_model(&model))
323 .filter(|value| !value.is_expired()));
324 }
325 }
326 Ok(None)
327 }
328 pub async fn evict(&self) -> AuthResult<()> {
334 match &self.secondary_storage {
335 Some(cache) => evict_cached(cache.as_ref(), &self.identifiers).await,
336 None => Ok(()),
337 }
338 }
339}
340
341async fn evict_cached(cache: &dyn CacheAdapter, identifiers: &[String]) -> AuthResult<()> {
342 match identifiers {
343 [stored, plain] => {
344 let stored_key = key(stored);
345 let plain_key = key(plain);
346 let (stored_result, plain_result) =
349 tokio::join!(cache.delete(&stored_key), cache.delete(&plain_key));
350 stored_result?;
351 plain_result
352 }
353 [stored] => cache.delete(&key(stored)).await,
354 _ => Err(AuthError::internal(
355 "Invalid verification identifier candidate count",
356 )),
357 }
358}
359
360impl<S: AuthSchema> VerificationService<'_, S> {
361 fn physical(&self) -> bool {
362 self.config.secondary_storage.is_none() || self.config.store_in_database
363 }
364 async fn stored_identifier(&self, identifier: &str) -> AuthResult<String> {
365 self.config
366 .store_identifier
367 .strategy(identifier)
368 .process(identifier)
369 .await
370 }
371 async fn identifiers(&self, identifier: &str) -> AuthResult<Vec<String>> {
372 let mut identifiers = vec![self.stored_identifier(identifier).await?];
373 if !matches!(
374 self.config.store_identifier.strategy(identifier),
375 VerificationIdentifierStrategy::Plain
376 ) {
377 identifiers.push(identifier.to_owned());
378 }
379 Ok(identifiers)
380 }
381 async fn prepare(
382 &self,
383 mut data: VerificationCreation,
384 ) -> AuthResult<(VerificationCreation, VerificationPublication)> {
385 data.identifier = self.stored_identifier(&data.identifier).await?;
386 let publication = VerificationPublication {
387 store_in_database: self.physical(),
388 secondary_storage: self.config.secondary_storage.clone(),
389 cache_key: key(&data.identifier),
390 };
391 Ok((data, publication))
392 }
393 pub async fn create(
394 &self,
395 data: impl Into<VerificationCreation>,
396 ) -> AuthResult<Option<VerificationSnapshot>> {
397 let (data, publication) = self.prepare(data.into()).await?;
398 self.database
399 .create_verification_record(data, publication)
400 .await
401 }
402 pub async fn create_in_transaction(
403 &self,
404 tx: &dyn AuthTransaction<S>,
405 data: impl Into<VerificationCreation>,
406 ) -> AuthResult<Option<VerificationSnapshot>> {
407 let (data, publication) = self.prepare(data.into()).await?;
408 tx.create_verification_record(data, publication).await
409 }
410 pub async fn find(&self, identifier: &str) -> AuthResult<Option<VerificationSnapshot>> {
411 let identifiers = self.identifiers(identifier).await?;
412 if let Some(cache) = &self.config.secondary_storage {
413 for stored in &identifiers {
414 if let Some(snapshot) = cache
415 .get(&key(stored))
416 .await?
417 .as_deref()
418 .and_then(VerificationSnapshot::cached)
419 {
420 return Ok(Some(snapshot));
421 }
422 }
423 if !self.physical() {
424 return Ok(None);
425 }
426 }
427 let mut found = None;
428 for stored in &identifiers {
429 found = self
430 .database
431 .get_latest_verification_by_identifier(stored)
432 .await?;
433 if found.is_some() {
434 break;
435 }
436 }
437 if !self.config.disable_cleanup {
438 _ = self.database.delete_expired_verifications().await?;
439 }
440 Ok(found.as_ref().map(VerificationSnapshot::from_model))
441 }
442 pub async fn delete(&self, identifier: &str) -> AuthResult<()> {
443 let stored = self.stored_identifier(identifier).await?;
444 if let Some(cache) = &self.config.secondary_storage {
445 cache.delete(&key(&stored)).await?;
446 }
447 if self.physical() {
448 self.database
449 .delete_verifications_by_identifier(&stored)
450 .await?;
451 }
452 Ok(())
453 }
454 pub async fn consume(&self, identifier: &str) -> AuthResult<Option<VerificationSnapshot>> {
455 let identifiers = self.identifiers(identifier).await?;
456 let mut consumed = None;
457 if let Some(cache) = self
458 .config
459 .secondary_storage
460 .as_ref()
461 .filter(|_| !self.physical())
462 {
463 for stored in &identifiers {
464 consumed = cache
465 .get_and_delete(&key(stored))
466 .await?
467 .as_deref()
468 .and_then(VerificationSnapshot::cached)
469 .and_then(VerificationSnapshot::hydrate);
470 if consumed.is_some() {
471 for other in &identifiers {
472 if other != stored {
473 cache.delete(&key(other)).await?;
474 }
475 }
476 break;
477 }
478 }
479 } else {
480 for stored in &identifiers {
481 consumed = self
482 .database
483 .consume_verification_snapshot(stored)
484 .await?
485 .as_ref()
486 .map(VerificationSnapshot::from_model);
487 if consumed.is_some() {
488 break;
489 }
490 }
491 if consumed.is_some()
492 && let Some(cache) = &self.config.secondary_storage
493 {
494 evict_cached(cache.as_ref(), &identifiers).await?;
495 }
496 }
497 Ok(consumed.filter(|value| !value.is_expired()))
498 }
499 pub async fn transactional_consumption(
506 &self,
507 identifier: &str,
508 ) -> AuthResult<Option<VerificationConsumption>> {
509 if !self.physical() {
510 return Ok(None);
511 }
512 Ok(Some(VerificationConsumption {
513 identifiers: self.identifiers(identifier).await?,
514 secondary_storage: self.config.secondary_storage.clone(),
515 }))
516 }
517 pub async fn update(
518 &self,
519 identifier: &str,
520 data: UpdateVerification,
521 ) -> AuthResult<Option<VerificationSnapshot>> {
522 let stored = self.stored_identifier(identifier).await?;
523 if let Some(cache) = &self.config.secondary_storage
524 && let Some(mut snapshot) = cache
525 .get(&key(&stored))
526 .await?
527 .as_deref()
528 .and_then(VerificationSnapshot::cached)
529 {
530 let mut fields = spread(&snapshot.data);
531 patch(&mut fields, &data);
532 snapshot.data = JsValue::Object(fields);
533 snapshot.expiry = date_value(snapshot.data.get("expiresAt"));
534 let publication = VerificationPublication {
535 store_in_database: self.physical(),
536 secondary_storage: Some(cache.clone()),
537 cache_key: key(&stored),
538 };
539 publication.publish(&snapshot).await?;
540 if !self.physical() {
541 return Ok(Some(snapshot));
542 }
543 }
544 if self.physical() {
545 self.database
546 .update_verification_by_identifier(&stored, data)
547 .await
548 } else {
549 let mut fields = IndexMap::new();
550 patch(&mut fields, &data);
551 Ok(Some(VerificationSnapshot {
552 data: JsValue::Object(fields),
553 expiry: data.expires_at.map(|date| date.timestamp_millis()),
554 physical_expiry: None,
555 original_model: None,
556 }))
557 }
558 }
559 pub async fn reserve(&self, mut data: CreateVerification) -> AuthResult<bool> {
560 let logical = data.identifier.clone();
561 data.identifier = self.stored_identifier(&logical).await?;
562 if !self.physical() {
563 return Err(AuthError::internal(
564 "reserveVerificationValue requires database-backed verification storage. Set verification.storeInDatabase to true for flows that reserve verification values.",
565 ));
566 }
567 let Some(model) = self
568 .database
569 .reserve_verification_record(&logical, data)
570 .await?
571 else {
572 return Ok(false);
573 };
574 let mut snapshot = VerificationSnapshot::from_model(&model);
575 if let JsValue::Object(fields) = &mut snapshot.data {
576 _ = fields.shift_remove("createdAt");
577 _ = fields.shift_remove("updatedAt");
578 }
579 VerificationPublication {
580 store_in_database: true,
581 secondary_storage: self.config.secondary_storage.clone(),
582 cache_key: key(model.identifier()),
583 }
584 .publish(&snapshot)
585 .await?;
586 Ok(true)
587 }
588}
589
590fn date_json(date: DateTime<Utc>) -> String {
591 date.to_rfc3339_opts(SecondsFormat::Millis, true)
592}
593fn key(identifier: &str) -> String {
594 format!("verification:{identifier}")
595}
596fn number(value: Option<&JsValue>) -> f64 {
597 match value {
598 Some(JsValue::Null) => 0.0,
599 Some(JsValue::Bool(value)) => f64::from(u8::from(*value)),
600 Some(JsValue::Number(value)) => *value,
601 Some(JsValue::String(value)) => string_to_number(value).unwrap_or(f64::NAN),
602 Some(JsValue::Array(values)) => string_to_number(&array_string(values)).unwrap_or(f64::NAN),
603 _ => f64::NAN,
604 }
605}
606fn date_value(value: Option<&JsValue>) -> Option<i64> {
607 match value {
608 Some(JsValue::String(value)) => crate::utils::datetime::parse_date_millis(value),
609 Some(JsValue::Array(values)) => {
610 crate::utils::datetime::parse_date_millis(&array_string(values))
611 }
612 Some(JsValue::Object(_)) | None => None,
613 _ => {
614 let millis = number(value);
615 if millis.is_finite() && millis.abs() <= 8_640_000_000_000_000.0 {
616 #[expect(
617 clippy::cast_possible_truncation,
618 reason = "JavaScript Date TimeClip truncates finite milliseconds within its exact range"
619 )]
620 Some(millis.trunc() as i64)
621 } else {
622 None
623 }
624 }
625 }
626}
627fn array_string(values: &[JsValue]) -> String {
628 values
629 .iter()
630 .map(|value| match value {
631 JsValue::Null => String::new(),
632 JsValue::Bool(value) => value.to_string(),
633 JsValue::Number(value) => ryu_js::Buffer::new().format(*value).to_owned(),
634 JsValue::String(value) => value.clone(),
635 JsValue::Date(value) => value
636 .format("%a %b %d %Y %H:%M:%S GMT%z (Coordinated Universal Time)")
637 .to_string(),
638 JsValue::Array(values) => array_string(values),
639 JsValue::Object(_) => "[object Object]".into(),
640 })
641 .collect::<Vec<_>>()
642 .join(",")
643}
644
645fn spread(value: &JsValue) -> IndexMap<String, JsValue> {
646 match value {
647 JsValue::Object(fields) => fields.clone(),
648 JsValue::Array(values) => values
649 .iter()
650 .enumerate()
651 .map(|(index, value)| (index.to_string(), value.clone()))
652 .collect(),
653 JsValue::String(value) => value
654 .chars()
655 .enumerate()
656 .map(|(index, value)| (index.to_string(), JsValue::String(value.to_string())))
657 .collect(),
658 _ => IndexMap::new(),
659 }
660}
661fn patch(fields: &mut IndexMap<String, JsValue>, data: &UpdateVerification) {
662 if let Some(value) = &data.value {
663 _ = fields.insert("value".into(), JsValue::String(value.clone()));
664 }
665 if let Some(expires) = data.expires_at {
666 _ = fields.insert("expiresAt".into(), JsValue::String(date_json(expires)));
667 }
668}