authx_plugins/
one_time_token.rs1use 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#[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().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}