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}
303impl<S: AuthSchema> VerificationService<'_, S> {
304 fn physical(&self) -> bool {
305 self.config.secondary_storage.is_none() || self.config.store_in_database
306 }
307 async fn stored_identifier(&self, identifier: &str) -> AuthResult<String> {
308 self.config
309 .store_identifier
310 .strategy(identifier)
311 .process(identifier)
312 .await
313 }
314 async fn identifiers(&self, identifier: &str) -> AuthResult<Vec<String>> {
315 let mut identifiers = vec![self.stored_identifier(identifier).await?];
316 if !matches!(
317 self.config.store_identifier.strategy(identifier),
318 VerificationIdentifierStrategy::Plain
319 ) {
320 identifiers.push(identifier.to_owned());
321 }
322 Ok(identifiers)
323 }
324 async fn prepare(
325 &self,
326 mut data: VerificationCreation,
327 ) -> AuthResult<(VerificationCreation, VerificationPublication)> {
328 data.identifier = self.stored_identifier(&data.identifier).await?;
329 let publication = VerificationPublication {
330 store_in_database: self.physical(),
331 secondary_storage: self.config.secondary_storage.clone(),
332 cache_key: key(&data.identifier),
333 };
334 Ok((data, publication))
335 }
336 pub async fn create(
337 &self,
338 data: impl Into<VerificationCreation>,
339 ) -> AuthResult<Option<VerificationSnapshot>> {
340 let (data, publication) = self.prepare(data.into()).await?;
341 self.database
342 .create_verification_record(data, publication)
343 .await
344 }
345 pub async fn create_in_transaction(
346 &self,
347 tx: &dyn AuthTransaction<S>,
348 data: impl Into<VerificationCreation>,
349 ) -> AuthResult<Option<VerificationSnapshot>> {
350 let (data, publication) = self.prepare(data.into()).await?;
351 tx.create_verification_record(data, publication).await
352 }
353 pub async fn find(&self, identifier: &str) -> AuthResult<Option<VerificationSnapshot>> {
354 let identifiers = self.identifiers(identifier).await?;
355 if let Some(cache) = &self.config.secondary_storage {
356 for stored in &identifiers {
357 if let Some(snapshot) = cache
358 .get(&key(stored))
359 .await?
360 .as_deref()
361 .and_then(VerificationSnapshot::cached)
362 {
363 return Ok(Some(snapshot));
364 }
365 }
366 if !self.physical() {
367 return Ok(None);
368 }
369 }
370 let mut found = None;
371 for stored in &identifiers {
372 found = self
373 .database
374 .get_latest_verification_by_identifier(stored)
375 .await?;
376 if found.is_some() {
377 break;
378 }
379 }
380 if !self.config.disable_cleanup {
381 _ = self.database.delete_expired_verifications().await?;
382 }
383 Ok(found.as_ref().map(VerificationSnapshot::from_model))
384 }
385 pub async fn delete(&self, identifier: &str) -> AuthResult<()> {
386 let stored = self.stored_identifier(identifier).await?;
387 if let Some(cache) = &self.config.secondary_storage {
388 cache.delete(&key(&stored)).await?;
389 }
390 if self.physical() {
391 self.database
392 .delete_verifications_by_identifier(&stored)
393 .await?;
394 }
395 Ok(())
396 }
397 pub async fn consume(&self, identifier: &str) -> AuthResult<Option<VerificationSnapshot>> {
398 let identifiers = self.identifiers(identifier).await?;
399 let mut consumed = None;
400 if let Some(cache) = self
401 .config
402 .secondary_storage
403 .as_ref()
404 .filter(|_| !self.physical())
405 {
406 for stored in &identifiers {
407 consumed = cache
408 .get_and_delete(&key(stored))
409 .await?
410 .as_deref()
411 .and_then(VerificationSnapshot::cached)
412 .and_then(VerificationSnapshot::hydrate);
413 if consumed.is_some() {
414 for other in &identifiers {
415 if other != stored {
416 cache.delete(&key(other)).await?;
417 }
418 }
419 break;
420 }
421 }
422 } else {
423 for stored in &identifiers {
424 consumed = self
425 .database
426 .consume_verification_snapshot(stored)
427 .await?
428 .as_ref()
429 .map(VerificationSnapshot::from_model);
430 if consumed.is_some() {
431 break;
432 }
433 }
434 if consumed.is_some()
435 && let Some(cache) = &self.config.secondary_storage
436 {
437 match identifiers.as_slice() {
438 [stored, plain] => {
439 let stored_key = key(stored);
440 let plain_key = key(plain);
441 let (stored_result, plain_result) =
444 tokio::join!(cache.delete(&stored_key), cache.delete(&plain_key));
445 stored_result?;
446 plain_result?;
447 }
448 [stored] => cache.delete(&key(stored)).await?,
449 _ => {
450 return Err(AuthError::internal(
451 "Invalid verification identifier candidate count",
452 ));
453 }
454 }
455 }
456 }
457 Ok(consumed.filter(|value| !value.is_expired()))
458 }
459 pub async fn update(
460 &self,
461 identifier: &str,
462 data: UpdateVerification,
463 ) -> AuthResult<Option<VerificationSnapshot>> {
464 let stored = self.stored_identifier(identifier).await?;
465 if let Some(cache) = &self.config.secondary_storage
466 && let Some(mut snapshot) = cache
467 .get(&key(&stored))
468 .await?
469 .as_deref()
470 .and_then(VerificationSnapshot::cached)
471 {
472 let mut fields = spread(&snapshot.data);
473 patch(&mut fields, &data);
474 snapshot.data = JsValue::Object(fields);
475 snapshot.expiry = date_value(snapshot.data.get("expiresAt"));
476 let publication = VerificationPublication {
477 store_in_database: self.physical(),
478 secondary_storage: Some(cache.clone()),
479 cache_key: key(&stored),
480 };
481 publication.publish(&snapshot).await?;
482 if !self.physical() {
483 return Ok(Some(snapshot));
484 }
485 }
486 if self.physical() {
487 self.database
488 .update_verification_by_identifier(&stored, data)
489 .await
490 } else {
491 let mut fields = IndexMap::new();
492 patch(&mut fields, &data);
493 Ok(Some(VerificationSnapshot {
494 data: JsValue::Object(fields),
495 expiry: data.expires_at.map(|date| date.timestamp_millis()),
496 physical_expiry: None,
497 original_model: None,
498 }))
499 }
500 }
501 pub async fn reserve(&self, mut data: CreateVerification) -> AuthResult<bool> {
502 let logical = data.identifier.clone();
503 data.identifier = self.stored_identifier(&logical).await?;
504 if !self.physical() {
505 return Err(AuthError::internal(
506 "reserveVerificationValue requires database-backed verification storage. Set verification.storeInDatabase to true for flows that reserve verification values.",
507 ));
508 }
509 let Some(model) = self
510 .database
511 .reserve_verification_record(&logical, data)
512 .await?
513 else {
514 return Ok(false);
515 };
516 let mut snapshot = VerificationSnapshot::from_model(&model);
517 if let JsValue::Object(fields) = &mut snapshot.data {
518 _ = fields.shift_remove("createdAt");
519 _ = fields.shift_remove("updatedAt");
520 }
521 VerificationPublication {
522 store_in_database: true,
523 secondary_storage: self.config.secondary_storage.clone(),
524 cache_key: key(model.identifier()),
525 }
526 .publish(&snapshot)
527 .await?;
528 Ok(true)
529 }
530}
531
532fn date_json(date: DateTime<Utc>) -> String {
533 date.to_rfc3339_opts(SecondsFormat::Millis, true)
534}
535fn key(identifier: &str) -> String {
536 format!("verification:{identifier}")
537}
538fn number(value: Option<&JsValue>) -> f64 {
539 match value {
540 Some(JsValue::Null) => 0.0,
541 Some(JsValue::Bool(value)) => f64::from(u8::from(*value)),
542 Some(JsValue::Number(value)) => *value,
543 Some(JsValue::String(value)) => string_to_number(value).unwrap_or(f64::NAN),
544 Some(JsValue::Array(values)) => string_to_number(&array_string(values)).unwrap_or(f64::NAN),
545 _ => f64::NAN,
546 }
547}
548fn date_value(value: Option<&JsValue>) -> Option<i64> {
549 match value {
550 Some(JsValue::String(value)) => crate::utils::datetime::parse_date_millis(value),
551 Some(JsValue::Array(values)) => {
552 crate::utils::datetime::parse_date_millis(&array_string(values))
553 }
554 Some(JsValue::Object(_)) | None => None,
555 _ => {
556 let millis = number(value);
557 if millis.is_finite() && millis.abs() <= 8_640_000_000_000_000.0 {
558 #[expect(
559 clippy::cast_possible_truncation,
560 reason = "JavaScript Date TimeClip truncates finite milliseconds within its exact range"
561 )]
562 Some(millis.trunc() as i64)
563 } else {
564 None
565 }
566 }
567 }
568}
569fn array_string(values: &[JsValue]) -> String {
570 values
571 .iter()
572 .map(|value| match value {
573 JsValue::Null => String::new(),
574 JsValue::Bool(value) => value.to_string(),
575 JsValue::Number(value) => ryu_js::Buffer::new().format(*value).to_owned(),
576 JsValue::String(value) => value.clone(),
577 JsValue::Date(value) => value
578 .format("%a %b %d %Y %H:%M:%S GMT%z (Coordinated Universal Time)")
579 .to_string(),
580 JsValue::Array(values) => array_string(values),
581 JsValue::Object(_) => "[object Object]".into(),
582 })
583 .collect::<Vec<_>>()
584 .join(",")
585}
586
587fn spread(value: &JsValue) -> IndexMap<String, JsValue> {
588 match value {
589 JsValue::Object(fields) => fields.clone(),
590 JsValue::Array(values) => values
591 .iter()
592 .enumerate()
593 .map(|(index, value)| (index.to_string(), value.clone()))
594 .collect(),
595 JsValue::String(value) => value
596 .chars()
597 .enumerate()
598 .map(|(index, value)| (index.to_string(), JsValue::String(value.to_string())))
599 .collect(),
600 _ => IndexMap::new(),
601 }
602}
603fn patch(fields: &mut IndexMap<String, JsValue>, data: &UpdateVerification) {
604 if let Some(value) = &data.value {
605 _ = fields.insert("value".into(), JsValue::String(value.clone()));
606 }
607 if let Some(expires) = data.expires_at {
608 _ = fields.insert("expiresAt".into(), JsValue::String(date_json(expires)));
609 }
610}