Skip to main content

better_auth_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::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/// Application-owned asynchronous identifier transform. Errors stop the
21/// operation before any cache or database mutation.
22#[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/// Prefix overrides have JavaScript Object.entries order: integer keys first
54/// in numeric order, then other keys in their registered insertion order.
55#[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/// Trusted creation candidate supplied to adapter hooks before persistence.
86/// Dates and an explicitly supplied primary ID survive hook transformations.
87#[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/// Actual verification data, without manufacturing a database model or ID.
143/// Cached find preserves every truthy JSON value and safeJSONParse's ISO date
144/// reviver. Consume separately hydrates expiresAt before its liveness gate.
145#[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    /// An absent ID is a real secondary-only snapshot, not a synthetic ID.
185    #[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    /// Source compares the actual cached field to a Date. ISO-Z values were
216    /// revived; other strings retain their numeric relational coercion.
217    #[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/// The adapter owns this phase between its real write and after-create hooks.
259/// It is also used inside transactions, before commit/deferred after hooks.
260#[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                        // 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
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}