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}
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                        // Both invalidations start even when one backend call
442                        // fails, matching the independent cache publication.
443                        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}