arcly-http-identity 0.8.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! One-time tokens: the shared primitive behind email/phone verification,
//! password reset, magic-link login, and login OTP.
//!
//! Security model: the plaintext token is returned to the *caller once* (to put
//! in a link/SMS) but only its **SHA-256 hash** is persisted. Tokens are
//! single-use (consumed atomically), TTL-bounded, and purpose-bound so a reset
//! token can never be replayed as a magic-link login.

use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};

use async_trait::async_trait;
use rand::RngCore;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;

use crate::error::{IdentityError, Result};

/// What a one-time token authorises. Bound into the stored record so tokens are
/// not interchangeable across flows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OttPurpose {
    EmailVerification,
    PhoneVerification,
    PasswordReset,
    MagicLink,
    LoginOtp,
}

impl OttPurpose {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::EmailVerification => "email_verification",
            Self::PhoneVerification => "phone_verification",
            Self::PasswordReset => "password_reset",
            Self::MagicLink => "magic_link",
            Self::LoginOtp => "login_otp",
        }
    }
}

/// A stored one-time token record (hash only, never plaintext).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OttRecord {
    pub user_id: String,
    pub purpose: OttPurpose,
    pub token_hash: [u8; 32],
    pub expires_at: u64,
}

/// Backend for one-time tokens — Redis with per-key TTL is the natural fit.
/// Keyed by the token hash (hex) so lookup needs only the presented token.
#[async_trait]
pub trait OttStore: Send + Sync + 'static {
    async fn put(&self, record: OttRecord) -> Result<()>;
    /// Atomically fetch **and delete** the record for `token_hash` (single-use).
    async fn take(&self, token_hash: &[u8; 32]) -> Result<Option<OttRecord>>;
}

fn unix_now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

fn hash_token(token: &str) -> [u8; 32] {
    let mut h = Sha256::new();
    h.update(token.as_bytes());
    h.finalize().into()
}

/// Issues and redeems one-time tokens over an [`OttStore`].
pub struct OneTimeTokenService {
    store: std::sync::Arc<dyn OttStore>,
}

impl OneTimeTokenService {
    pub fn new(store: std::sync::Arc<dyn OttStore>) -> Self {
        Self { store }
    }

    /// Issue a high-entropy URL-safe token for `user_id` valid for `ttl_secs`.
    /// Returns the **plaintext** token — deliver it, do not store it.
    pub async fn issue(&self, user_id: &str, purpose: OttPurpose, ttl_secs: u64) -> Result<String> {
        let mut bytes = [0u8; 32];
        rand::thread_rng().fill_bytes(&mut bytes);
        let token = base64_url(&bytes);
        let record = OttRecord {
            user_id: user_id.to_owned(),
            purpose,
            token_hash: hash_token(&token),
            expires_at: unix_now() + ttl_secs,
        };
        self.store.put(record).await?;
        Ok(token)
    }

    /// Issue a short **numeric** OTP (e.g. 6 digits) for SMS/email second-channel
    /// login. Same single-use + TTL guarantees as [`issue`](Self::issue).
    pub async fn issue_numeric(
        &self,
        user_id: &str,
        purpose: OttPurpose,
        digits: u32,
        ttl_secs: u64,
    ) -> Result<String> {
        let modulo = 10u64.pow(digits);
        let n = (rand::thread_rng().next_u64()) % modulo;
        let code = format!("{n:0width$}", width = digits as usize);
        let record = OttRecord {
            user_id: user_id.to_owned(),
            purpose,
            token_hash: hash_token(&code),
            expires_at: unix_now() + ttl_secs,
        };
        self.store.put(record).await?;
        Ok(code)
    }

    /// Redeem `token` for `purpose`. On success returns the `user_id` it was
    /// issued to. Fails closed on expiry, wrong purpose, reuse, or forgery.
    pub async fn consume(&self, token: &str, purpose: OttPurpose) -> Result<String> {
        let presented = hash_token(token);
        let record = self
            .store
            .take(&presented)
            .await?
            .ok_or(IdentityError::InvalidToken)?;

        // Defence in depth: the store keyed on the hash, but re-check in
        // constant time and validate purpose + expiry here too.
        let ok: bool = record.token_hash.ct_eq(&presented).into();
        if !ok || record.purpose != purpose || unix_now() >= record.expires_at {
            return Err(IdentityError::InvalidToken);
        }
        Ok(record.user_id)
    }
}

fn base64_url(bytes: &[u8]) -> String {
    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    use base64::Engine;
    URL_SAFE_NO_PAD.encode(bytes)
}

// ─── In-memory OttStore for tests/dev ───────────────────────────────────────

/// In-memory [`OttStore`] — tests / local dev only.
#[derive(Default)]
pub struct MemoryOttStore {
    inner: RwLock<HashMap<[u8; 32], OttRecord>>,
}

impl MemoryOttStore {
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl OttStore for MemoryOttStore {
    async fn put(&self, record: OttRecord) -> Result<()> {
        self.inner
            .write()
            .unwrap()
            .insert(record.token_hash, record);
        Ok(())
    }

    async fn take(&self, token_hash: &[u8; 32]) -> Result<Option<OttRecord>> {
        Ok(self.inner.write().unwrap().remove(token_hash))
    }
}