Skip to main content

alibi_core/
verification.rs

1//! Initialized verification policy, raw snapshots and adapter publication phases.
2//!
3//! Adapter models remain physical database projections. Secondary-only values
4//! carry the actual admitted data, including an absent primary ID.
5
6use 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/// Application-owned asynchronous identifier transform. Errors stop the
24/// operation before any cache or database mutation.
25#[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/// Prefix overrides have JavaScript Object.entries order: integer keys first
57/// in numeric order, then other keys in their registered insertion order.
58#[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/// Trusted creation candidate supplied to adapter hooks before persistence.
89/// Dates and an explicitly supplied primary ID survive hook transformations.
90#[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/// Actual verification data, without manufacturing a database model or ID.
146/// Cached find preserves every truthy JSON value and safeJSONParse's ISO date
147/// reviver. Consume separately hydrates expiresAt before its liveness gate.
148#[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    /// An absent ID is a real secondary-only snapshot, not a synthetic ID.
188    #[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    /// Source compares the actual cached field to a Date. ISO-Z values were
219    /// revived; other strings retain their numeric relational coercion.
220    #[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/// The adapter owns this phase between its real write and after-create hooks.
261/// It is also used inside transactions, before commit/deferred after hooks.
262#[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}
303/// A database consumption prepared outside the transaction that spends it.
304pub struct VerificationConsumption {
305    identifiers: Vec<String>,
306    secondary_storage: Option<Arc<dyn CacheAdapter>>,
307}
308
309impl VerificationConsumption {
310    /// Spend the verification on `tx`, as [`VerificationService::consume`]
311    /// does. A rolled-back transaction leaves it usable.
312    ///
313    /// # Errors
314    ///
315    /// Propagates transaction storage errors.
316    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    /// Drop cached copies once the consuming transaction has committed.
329    ///
330    /// # Errors
331    ///
332    /// Propagates secondary-storage errors.
333    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            // Both invalidations start even when one backend call
347            // fails, matching the independent cache publication.
348            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    /// Prepare a consumption that joins an adapter transaction, or `None` when
500    /// values live only in secondary storage and cannot roll back.
501    ///
502    /// # Errors
503    ///
504    /// Propagates identifier hashing errors.
505    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}