Skip to main content

authx_plugins/
one_time_token.rs

1/// Shared single-use token infrastructure.
2///
3/// Tokens are:
4/// - 32 random bytes → hex encoded (64 chars)
5/// - SHA-256 hashed before storage (stored hash, raw returned to caller)
6/// - Single-use (consumed on first successful verification)
7/// - TTL-scoped
8use std::{
9    collections::HashMap,
10    sync::{Arc, Mutex},
11    time::{Duration, Instant},
12};
13
14use authx_core::crypto::sha256_hex;
15use rand::Rng;
16
17#[derive(Clone)]
18struct TokenRecord {
19    kind: TokenKind,
20    user_id: uuid::Uuid,
21    expires_at: Instant,
22}
23
24#[derive(Clone, PartialEq, Eq)]
25pub enum TokenKind {
26    PasswordReset,
27    MagicLink,
28    EmailVerification,
29    EmailOtp,
30}
31
32/// In-memory single-use token store (no DB dependency — swap for Redis for
33/// multi-instance deployments).
34#[derive(Clone)]
35pub struct OneTimeTokenStore {
36    inner: Arc<Mutex<HashMap<String, TokenRecord>>>,
37    ttl: Duration,
38}
39
40impl OneTimeTokenStore {
41    pub fn new(ttl: Duration) -> Self {
42        Self {
43            inner: Arc::new(Mutex::new(HashMap::new())),
44            ttl,
45        }
46    }
47
48    pub fn issue(&self, user_id: uuid::Uuid, kind: TokenKind) -> String {
49        let raw: [u8; 32] = rand::thread_rng().r#gen();
50        let token = hex::encode(raw);
51        let hash = sha256_hex(token.as_bytes());
52
53        let record = TokenRecord {
54            kind,
55            user_id,
56            expires_at: Instant::now() + self.ttl,
57        };
58
59        let mut map = self.inner.lock().expect("token store lock poisoned");
60        let now = Instant::now();
61        map.retain(|_, r| r.expires_at > now);
62        map.insert(hash, record);
63
64        tracing::debug!(user_id = %user_id, "one-time token issued");
65        token
66    }
67
68    pub fn consume(&self, raw_token: &str, expected_kind: TokenKind) -> Option<uuid::Uuid> {
69        let hash = sha256_hex(raw_token.as_bytes());
70        let mut map = self.inner.lock().expect("token store lock poisoned");
71
72        let record = map.remove(&hash)?;
73
74        if record.kind != expected_kind {
75            map.insert(hash, record);
76            return None;
77        }
78
79        if record.expires_at < Instant::now() {
80            tracing::debug!("one-time token expired");
81            return None;
82        }
83
84        tracing::debug!(user_id = %record.user_id, "one-time token consumed");
85        Some(record.user_id)
86    }
87}