1use crate::{
7 AuthContext, AuthError, AuthResult, AuthSchema, AuthVerification, CreateVerification,
8 UpdateVerification,
9 config::VerificationConfig,
10 store::{AuthStore, AuthTransaction, CacheAdapter},
11 utils::json::{self, JsValue},
12};
13use async_trait::async_trait;
14use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
15use chrono::{DateTime, Duration, SecondsFormat, Utc};
16use indexmap::IndexMap;
17use sha2::{Digest, Sha256};
18use std::{any::Any, fmt, sync::Arc};
19
20#[async_trait]
23pub trait VerificationIdentifierHasher: Send + Sync {
24 async fn hash(&self, identifier: &str) -> AuthResult<String>;
25}
26
27#[derive(Clone, Default)]
28pub enum VerificationIdentifierStrategy {
29 #[default]
30 Plain,
31 Hashed,
32 Custom(Arc<dyn VerificationIdentifierHasher>),
33}
34impl fmt::Debug for VerificationIdentifierStrategy {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 f.write_str(match self {
37 Self::Plain => "Plain",
38 Self::Hashed => "Hashed",
39 Self::Custom(_) => "Custom",
40 })
41 }
42}
43impl VerificationIdentifierStrategy {
44 async fn process(&self, identifier: &str) -> AuthResult<String> {
45 match self {
46 Self::Plain => Ok(identifier.to_owned()),
47 Self::Hashed => Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(identifier.as_bytes()))),
48 Self::Custom(hasher) => hasher.hash(identifier).await,
49 }
50 }
51}
52
53#[derive(Clone, Debug, Default)]
56pub struct VerificationIdentifierPolicy {
57 pub default: VerificationIdentifierStrategy,
58 pub overrides: IndexMap<String, VerificationIdentifierStrategy>,
59}
60impl VerificationIdentifierPolicy {
61 fn strategy(&self, identifier: &str) -> &VerificationIdentifierStrategy {
62 let integer_key = |key: &str| {
63 key.parse::<u32>()
64 .ok()
65 .filter(|number| *number != u32::MAX && number.to_string() == key)
66 };
67 let mut integers: Vec<_> = self
68 .overrides
69 .iter()
70 .filter_map(|(key, value)| integer_key(key).map(|number| (number, key, value)))
71 .collect();
72 integers.sort_by_key(|entry| entry.0);
73 for (_, key, value) in integers {
74 if identifier.starts_with(key) {
75 return value;
76 }
77 }
78 self.overrides
79 .iter()
80 .find(|(key, _)| integer_key(key).is_none() && identifier.starts_with(key.as_str()))
81 .map_or(&self.default, |(_, value)| value)
82 }
83}
84
85#[derive(Clone, Debug)]
88pub struct VerificationCreation {
89 pub id: Option<String>,
90 pub identifier: String,
91 pub value: String,
92 pub expires_at: DateTime<Utc>,
93 pub created_at: DateTime<Utc>,
94 pub updated_at: DateTime<Utc>,
95}
96impl From<CreateVerification> for VerificationCreation {
97 fn from(data: CreateVerification) -> Self {
98 let now = Utc::now();
99 Self {
100 id: None,
101 identifier: data.identifier,
102 value: data.value,
103 expires_at: data.expires_at,
104 created_at: now,
105 updated_at: now,
106 }
107 }
108}
109impl VerificationCreation {
110 #[must_use]
111 pub fn data(&self) -> CreateVerification {
112 CreateVerification {
113 identifier: self.identifier.clone(),
114 value: self.value.clone(),
115 expires_at: self.expires_at,
116 }
117 }
118 #[must_use]
119 pub fn snapshot(&self) -> VerificationSnapshot {
120 let mut fields = IndexMap::new();
121 if let Some(id) = &self.id {
122 drop(fields.insert("id".into(), JsValue::String(id.clone())));
123 }
124 for (key, value) in [
125 ("identifier", self.identifier.clone()),
126 ("value", self.value.clone()),
127 ("expiresAt", date_json(self.expires_at)),
128 ("createdAt", date_json(self.created_at)),
129 ("updatedAt", date_json(self.updated_at)),
130 ] {
131 drop(fields.insert(key.into(), JsValue::String(value)));
132 }
133 VerificationSnapshot {
134 data: JsValue::Object(fields),
135 expiry: Some(self.expires_at.timestamp_millis()),
136 physical_expiry: None,
137 original_model: None,
138 }
139 }
140}
141
142#[derive(Clone)]
146pub struct VerificationSnapshot {
147 data: JsValue,
148 expiry: Option<i64>,
149 physical_expiry: Option<DateTime<Utc>>,
150 original_model: Option<Arc<dyn Any + Send + Sync>>,
151}
152impl fmt::Debug for VerificationSnapshot {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 f.debug_struct("VerificationSnapshot")
155 .field("data", &self.data)
156 .field("physical", &self.original_model.is_some())
157 .finish()
158 }
159}
160impl VerificationSnapshot {
161 #[must_use]
162 pub fn from_model<T: AuthVerification + Clone + Send + Sync + 'static>(model: &T) -> Self {
163 let creation = VerificationCreation {
164 id: Some(model.id().into_owned()),
165 identifier: model.identifier().to_owned(),
166 value: model.value().to_owned(),
167 expires_at: model.expires_at(),
168 created_at: model.created_at(),
169 updated_at: model.updated_at(),
170 };
171 let mut snapshot = creation.snapshot();
172 snapshot.physical_expiry = Some(model.expires_at());
173 snapshot.original_model = Some(Arc::new(model.clone()));
174 snapshot
175 }
176 #[must_use]
177 pub fn data(&self) -> &JsValue {
178 &self.data
179 }
180 #[must_use]
181 pub fn original_model<T: Any>(&self) -> Option<&T> {
182 self.original_model.as_ref()?.downcast_ref()
183 }
184 #[must_use]
186 pub fn id(&self) -> Option<&str> {
187 self.data.get("id").and_then(JsValue::as_str)
188 }
189 pub fn identifier(&self) -> AuthResult<&str> {
190 self.text("identifier")
191 }
192 pub fn value(&self) -> AuthResult<&str> {
193 self.text("value")
194 }
195 fn text(&self, key: &str) -> AuthResult<&str> {
196 self.data
197 .get(key)
198 .and_then(JsValue::as_str)
199 .ok_or_else(|| AuthError::internal(format!("verification {key} is not a string")))
200 }
201 pub fn expires_at(&self) -> AuthResult<DateTime<Utc>> {
202 if let Some(expiry) = self.physical_expiry {
203 return Ok(expiry);
204 }
205 let millis = self
206 .expiry
207 .or_else(|| date_value(self.data.get("expiresAt")))
208 .ok_or_else(|| AuthError::internal("verification expiry is not a valid date"))?;
209 DateTime::from_timestamp_millis(millis).ok_or_else(|| {
210 AuthError::NotImplemented(
211 "The verification expiry is outside the physical model date range".into(),
212 )
213 })
214 }
215 #[must_use]
218 pub fn is_expired(&self) -> bool {
219 let expiry = self.expiry.map_or_else(
220 || number(self.data.get("expiresAt")),
221 |millis| millis as f64,
222 );
223 expiry < Utc::now().timestamp_millis() as f64
224 }
225 fn cached(raw: &str) -> Option<Self> {
226 let mut data = json::parse_value(raw).ok()?;
227 if !truthy(&data) {
228 return None;
229 }
230 let expiry = data
231 .get("expiresAt")
232 .and_then(JsValue::as_str)
233 .and_then(crate::session::cookie_cache::date::parse)
234 .map(|date| date.timestamp_millis());
235 crate::session::cookie_cache::date::revive(&mut data);
236 Some(Self {
237 data,
238 expiry,
239 physical_expiry: None,
240 original_model: None,
241 })
242 }
243 fn hydrate(mut self) -> Option<Self> {
244 let expiry = self
245 .expiry
246 .or_else(|| date_value(self.data.get("expiresAt")))?;
247 self.expiry = Some(expiry);
248 if let JsValue::Object(fields) = &mut self.data {
249 drop(fields.insert(
250 "expiresAt".into(),
251 JsValue::String(crate::utils::datetime::json_date_millis(expiry)),
252 ));
253 }
254 Some(self)
255 }
256}
257
258#[derive(Clone)]
261pub struct VerificationPublication {
262 pub store_in_database: bool,
263 pub secondary_storage: Option<Arc<dyn CacheAdapter>>,
264 pub cache_key: String,
265}
266impl VerificationPublication {
267 pub async fn publish(&self, snapshot: &VerificationSnapshot) -> AuthResult<()> {
268 if let Some(cache) = &self.secondary_storage
269 && let Some(expiry) = snapshot
270 .expiry
271 .or_else(|| date_value(snapshot.data.get("expiresAt")))
272 {
273 let ttl = (expiry - Utc::now().timestamp_millis()).div_euclid(1000);
274 if ttl > 0 {
275 cache
276 .set(
277 &self.cache_key,
278 &json::to_string(snapshot.data())?,
279 Duration::seconds(ttl),
280 )
281 .await?;
282 }
283 }
284 Ok(())
285 }
286}
287
288pub struct VerificationService<'a, S: AuthSchema> {
289 config: &'a VerificationConfig,
290 database: &'a dyn AuthStore<S>,
291}
292impl<S: AuthSchema> AuthContext<S> {
293 #[must_use]
294 pub fn verifications(&self) -> VerificationService<'_, S> {
295 VerificationService {
296 config: &self.config.verification,
297 database: self.database.as_ref(),
298 }
299 }
300}
301impl<S: AuthSchema> VerificationService<'_, S> {
302 fn physical(&self) -> bool {
303 self.config.secondary_storage.is_none() || self.config.store_in_database
304 }
305 async fn identifiers(&self, identifier: &str) -> AuthResult<Vec<String>> {
306 let strategy = self.config.store_identifier.strategy(identifier);
307 let stored = strategy.process(identifier).await?;
308 let mut identifiers = vec![stored];
309 if !matches!(strategy, VerificationIdentifierStrategy::Plain) {
310 identifiers.push(identifier.to_owned());
311 }
312 Ok(identifiers)
313 }
314 async fn prepare(
315 &self,
316 mut data: VerificationCreation,
317 ) -> AuthResult<(VerificationCreation, VerificationPublication)> {
318 data.identifier = self
319 .config
320 .store_identifier
321 .strategy(&data.identifier)
322 .process(&data.identifier)
323 .await?;
324 let publication = VerificationPublication {
325 store_in_database: self.physical(),
326 secondary_storage: self.config.secondary_storage.clone(),
327 cache_key: key(&data.identifier),
328 };
329 Ok((data, publication))
330 }
331 pub async fn create(
332 &self,
333 data: impl Into<VerificationCreation>,
334 ) -> AuthResult<Option<VerificationSnapshot>> {
335 let (data, publication) = self.prepare(data.into()).await?;
336 self.database
337 .create_verification_record(data, publication)
338 .await
339 }
340 pub async fn create_in_transaction(
341 &self,
342 tx: &dyn AuthTransaction<S>,
343 data: impl Into<VerificationCreation>,
344 ) -> AuthResult<Option<VerificationSnapshot>> {
345 let (data, publication) = self.prepare(data.into()).await?;
346 tx.create_verification_record(data, publication).await
347 }
348 pub async fn find(&self, identifier: &str) -> AuthResult<Option<VerificationSnapshot>> {
349 let identifiers = self.identifiers(identifier).await?;
350 if let Some(cache) = &self.config.secondary_storage {
351 for stored in &identifiers {
352 if let Some(snapshot) = cache
353 .get(&key(stored))
354 .await?
355 .as_deref()
356 .and_then(VerificationSnapshot::cached)
357 {
358 return Ok(Some(snapshot));
359 }
360 }
361 if !self.physical() {
362 return Ok(None);
363 }
364 }
365 let mut found = None;
366 for stored in &identifiers {
367 found = self
368 .database
369 .get_latest_verification_by_identifier(stored)
370 .await?;
371 if found.is_some() {
372 break;
373 }
374 }
375 if !self.config.disable_cleanup {
376 let _deleted = self.database.delete_expired_verifications().await?;
377 }
378 Ok(found.as_ref().map(VerificationSnapshot::from_model))
379 }
380 pub async fn delete(&self, identifier: &str) -> AuthResult<()> {
381 let stored = self
382 .config
383 .store_identifier
384 .strategy(identifier)
385 .process(identifier)
386 .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
465 .config
466 .store_identifier
467 .strategy(identifier)
468 .process(identifier)
469 .await?;
470 if let Some(cache) = &self.config.secondary_storage
471 && let Some(mut snapshot) = cache
472 .get(&key(&stored))
473 .await?
474 .as_deref()
475 .and_then(VerificationSnapshot::cached)
476 {
477 let mut fields = spread(&snapshot.data);
478 patch(&mut fields, &data);
479 snapshot.data = JsValue::Object(fields);
480 snapshot.expiry = date_value(snapshot.data.get("expiresAt"));
481 let publication = VerificationPublication {
482 store_in_database: self.physical(),
483 secondary_storage: Some(cache.clone()),
484 cache_key: key(&stored),
485 };
486 publication.publish(&snapshot).await?;
487 if !self.physical() {
488 return Ok(Some(snapshot));
489 }
490 }
491 if self.physical() {
492 self.database
493 .update_verification_by_identifier(&stored, data)
494 .await
495 } else {
496 let mut fields = IndexMap::new();
497 patch(&mut fields, &data);
498 Ok(Some(VerificationSnapshot {
499 data: JsValue::Object(fields),
500 expiry: data.expires_at.map(|date| date.timestamp_millis()),
501 physical_expiry: None,
502 original_model: None,
503 }))
504 }
505 }
506 pub async fn reserve(&self, mut data: CreateVerification) -> AuthResult<bool> {
507 let logical = data.identifier.clone();
508 data.identifier = self
509 .config
510 .store_identifier
511 .strategy(&logical)
512 .process(&logical)
513 .await?;
514 if !self.physical() {
515 return Err(AuthError::internal(
516 "reserveVerificationValue requires database-backed verification storage. Set verification.storeInDatabase to true for flows that reserve verification values.",
517 ));
518 }
519 let Some(model) = self
520 .database
521 .reserve_verification_record(&logical, data)
522 .await?
523 else {
524 return Ok(false);
525 };
526 let mut snapshot = VerificationSnapshot::from_model(&model);
527 if let JsValue::Object(fields) = &mut snapshot.data {
528 drop(fields.shift_remove("createdAt"));
529 drop(fields.shift_remove("updatedAt"));
530 }
531 VerificationPublication {
532 store_in_database: true,
533 secondary_storage: self.config.secondary_storage.clone(),
534 cache_key: key(model.identifier()),
535 }
536 .publish(&snapshot)
537 .await?;
538 Ok(true)
539 }
540}
541
542fn date_json(date: DateTime<Utc>) -> String {
543 date.to_rfc3339_opts(SecondsFormat::Millis, true)
544}
545fn key(identifier: &str) -> String {
546 format!("verification:{identifier}")
547}
548fn truthy(value: &JsValue) -> bool {
549 match value {
550 JsValue::Null => false,
551 JsValue::Bool(value) => *value,
552 JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
553 JsValue::String(value) => !value.is_empty(),
554 JsValue::Array(_) | JsValue::Object(_) => true,
555 }
556}
557fn number(value: Option<&JsValue>) -> f64 {
558 match value {
559 Some(JsValue::Null) => 0.0,
560 Some(JsValue::Bool(value)) => f64::from(u8::from(*value)),
561 Some(JsValue::Number(value)) => *value,
562 Some(JsValue::String(value)) => {
563 crate::utils::javascript::string_to_number(value).unwrap_or(f64::NAN)
564 }
565 Some(JsValue::Array(values)) => {
566 crate::utils::javascript::string_to_number(&array_string(values)).unwrap_or(f64::NAN)
567 }
568 _ => f64::NAN,
569 }
570}
571fn date_value(value: Option<&JsValue>) -> Option<i64> {
572 match value {
573 Some(JsValue::String(value)) => crate::utils::datetime::parse_date_millis(value),
574 Some(JsValue::Array(values)) => {
575 crate::utils::datetime::parse_date_millis(&array_string(values))
576 }
577 Some(JsValue::Object(_)) | None => None,
578 _ => {
579 let millis = number(value);
580 if millis.is_finite() && millis.abs() <= 8_640_000_000_000_000.0 {
581 #[expect(
582 clippy::cast_possible_truncation,
583 reason = "JavaScript Date TimeClip truncates finite milliseconds within its exact range"
584 )]
585 Some(millis.trunc() as i64)
586 } else {
587 None
588 }
589 }
590 }
591}
592fn array_string(values: &[JsValue]) -> String {
593 values
594 .iter()
595 .map(|value| match value {
596 JsValue::Null => String::new(),
597 JsValue::Bool(value) => value.to_string(),
598 JsValue::Number(value) => ryu_js::Buffer::new().format(*value).to_owned(),
599 JsValue::String(value) => value.clone(),
600 JsValue::Array(values) => array_string(values),
601 JsValue::Object(_) => "[object Object]".into(),
602 })
603 .collect::<Vec<_>>()
604 .join(",")
605}
606
607fn spread(value: &JsValue) -> IndexMap<String, JsValue> {
608 match value {
609 JsValue::Object(fields) => fields.clone(),
610 JsValue::Array(values) => values
611 .iter()
612 .enumerate()
613 .map(|(index, value)| (index.to_string(), value.clone()))
614 .collect(),
615 JsValue::String(value) => value
616 .chars()
617 .enumerate()
618 .map(|(index, value)| (index.to_string(), JsValue::String(value.to_string())))
619 .collect(),
620 _ => IndexMap::new(),
621 }
622}
623fn patch(fields: &mut IndexMap<String, JsValue>, data: &UpdateVerification) {
624 if let Some(value) = &data.value {
625 drop(fields.insert("value".into(), JsValue::String(value.clone())));
626 }
627 if let Some(expires) = data.expires_at {
628 drop(fields.insert("expiresAt".into(), JsValue::String(date_json(expires))));
629 }
630}