Skip to main content

arcly_http_identity/
ott.rs

1//! One-time tokens: the shared primitive behind email/phone verification,
2//! password reset, magic-link login, and login OTP.
3//!
4//! Security model: the plaintext token is returned to the *caller once* (to put
5//! in a link/SMS) but only its **SHA-256 hash** is persisted. Tokens are
6//! single-use (consumed atomically), TTL-bounded, and purpose-bound so a reset
7//! token can never be replayed as a magic-link login.
8
9use std::collections::HashMap;
10use std::sync::RwLock;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use async_trait::async_trait;
14use rand::RngCore;
15use sha2::{Digest, Sha256};
16use subtle::ConstantTimeEq;
17
18use crate::error::{IdentityError, Result};
19
20/// What a one-time token authorises. Bound into the stored record so tokens are
21/// not interchangeable across flows.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum OttPurpose {
25    EmailVerification,
26    PhoneVerification,
27    PasswordReset,
28    MagicLink,
29    LoginOtp,
30}
31
32impl OttPurpose {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::EmailVerification => "email_verification",
36            Self::PhoneVerification => "phone_verification",
37            Self::PasswordReset => "password_reset",
38            Self::MagicLink => "magic_link",
39            Self::LoginOtp => "login_otp",
40        }
41    }
42}
43
44/// A stored one-time token record (hash only, never plaintext).
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct OttRecord {
47    pub user_id: String,
48    pub purpose: OttPurpose,
49    pub token_hash: [u8; 32],
50    pub expires_at: u64,
51}
52
53/// Backend for one-time tokens — Redis with per-key TTL is the natural fit.
54/// Keyed by the token hash (hex) so lookup needs only the presented token.
55#[async_trait]
56pub trait OttStore: Send + Sync + 'static {
57    async fn put(&self, record: OttRecord) -> Result<()>;
58    /// Atomically fetch **and delete** the record for `token_hash` (single-use).
59    async fn take(&self, token_hash: &[u8; 32]) -> Result<Option<OttRecord>>;
60}
61
62fn unix_now() -> u64 {
63    SystemTime::now()
64        .duration_since(UNIX_EPOCH)
65        .map(|d| d.as_secs())
66        .unwrap_or(0)
67}
68
69fn hash_token(token: &str) -> [u8; 32] {
70    let mut h = Sha256::new();
71    h.update(token.as_bytes());
72    h.finalize().into()
73}
74
75/// Issues and redeems one-time tokens over an [`OttStore`].
76pub struct OneTimeTokenService {
77    store: std::sync::Arc<dyn OttStore>,
78}
79
80impl OneTimeTokenService {
81    pub fn new(store: std::sync::Arc<dyn OttStore>) -> Self {
82        Self { store }
83    }
84
85    /// Issue a high-entropy URL-safe token for `user_id` valid for `ttl_secs`.
86    /// Returns the **plaintext** token — deliver it, do not store it.
87    pub async fn issue(&self, user_id: &str, purpose: OttPurpose, ttl_secs: u64) -> Result<String> {
88        let mut bytes = [0u8; 32];
89        rand::thread_rng().fill_bytes(&mut bytes);
90        let token = base64_url(&bytes);
91        let record = OttRecord {
92            user_id: user_id.to_owned(),
93            purpose,
94            token_hash: hash_token(&token),
95            expires_at: unix_now() + ttl_secs,
96        };
97        self.store.put(record).await?;
98        Ok(token)
99    }
100
101    /// Issue a short **numeric** OTP (e.g. 6 digits) for SMS/email second-channel
102    /// login. Same single-use + TTL guarantees as [`issue`](Self::issue).
103    pub async fn issue_numeric(
104        &self,
105        user_id: &str,
106        purpose: OttPurpose,
107        digits: u32,
108        ttl_secs: u64,
109    ) -> Result<String> {
110        let modulo = 10u64.pow(digits);
111        let n = (rand::thread_rng().next_u64()) % modulo;
112        let code = format!("{n:0width$}", width = digits as usize);
113        let record = OttRecord {
114            user_id: user_id.to_owned(),
115            purpose,
116            token_hash: hash_token(&code),
117            expires_at: unix_now() + ttl_secs,
118        };
119        self.store.put(record).await?;
120        Ok(code)
121    }
122
123    /// Redeem `token` for `purpose`. On success returns the `user_id` it was
124    /// issued to. Fails closed on expiry, wrong purpose, reuse, or forgery.
125    pub async fn consume(&self, token: &str, purpose: OttPurpose) -> Result<String> {
126        let presented = hash_token(token);
127        let record = self
128            .store
129            .take(&presented)
130            .await?
131            .ok_or(IdentityError::InvalidToken)?;
132
133        // Defence in depth: the store keyed on the hash, but re-check in
134        // constant time and validate purpose + expiry here too.
135        let ok: bool = record.token_hash.ct_eq(&presented).into();
136        if !ok || record.purpose != purpose || unix_now() >= record.expires_at {
137            return Err(IdentityError::InvalidToken);
138        }
139        Ok(record.user_id)
140    }
141}
142
143fn base64_url(bytes: &[u8]) -> String {
144    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
145    use base64::Engine;
146    URL_SAFE_NO_PAD.encode(bytes)
147}
148
149// ─── In-memory OttStore for tests/dev ───────────────────────────────────────
150
151/// In-memory [`OttStore`] — tests / local dev only.
152#[derive(Default)]
153pub struct MemoryOttStore {
154    inner: RwLock<HashMap<[u8; 32], OttRecord>>,
155}
156
157impl MemoryOttStore {
158    pub fn new() -> Self {
159        Self::default()
160    }
161}
162
163#[async_trait]
164impl OttStore for MemoryOttStore {
165    async fn put(&self, record: OttRecord) -> Result<()> {
166        self.inner
167            .write()
168            .unwrap()
169            .insert(record.token_hash, record);
170        Ok(())
171    }
172
173    async fn take(&self, token_hash: &[u8; 32]) -> Result<Option<OttRecord>> {
174        Ok(self.inner.write().unwrap().remove(token_hash))
175    }
176}