1use std::collections::HashMap;
15use std::sync::Mutex;
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum TokenKind {
22 PasswordReset,
26 EmailChange,
30 MagicLink,
34}
35
36impl TokenKind {
37 pub fn as_str(&self) -> &'static str {
38 match self {
39 Self::PasswordReset => "password_reset",
40 Self::EmailChange => "email_change",
41 Self::MagicLink => "magic_link",
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct VerificationToken {
48 pub id: String,
50 pub kind: TokenKind,
51 pub email: String,
53 pub user_id: Option<String>,
56 pub payload: Option<String>,
60 pub token_hash: String,
63 pub token_prefix: String,
66 pub created_at: u64,
67 pub expires_at: u64,
68 pub consumed_at: Option<u64>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum VerificationError {
75 NotFound,
76 Expired,
77 AlreadyConsumed,
78 KindMismatch,
82}
83
84impl std::fmt::Display for VerificationError {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.write_str(match self {
87 Self::NotFound => "verification token not found",
88 Self::Expired => "verification token expired",
89 Self::AlreadyConsumed => "verification token already consumed",
90 Self::KindMismatch => "verification token is for a different flow",
91 })
92 }
93}
94
95pub trait VerificationBackend: Send + Sync {
96 fn put(&self, token: &VerificationToken);
97 fn get(&self, id: &str) -> Option<VerificationToken>;
98 fn by_prefix(&self, prefix: &str) -> Vec<VerificationToken>;
101 fn mark_consumed(&self, id: &str, now: u64) -> bool;
104 fn purge_expired(&self, now: u64);
107}
108
109pub struct InMemoryVerificationBackend {
110 tokens: Mutex<HashMap<String, VerificationToken>>,
111}
112
113impl Default for InMemoryVerificationBackend {
114 fn default() -> Self {
115 Self {
116 tokens: Mutex::new(HashMap::new()),
117 }
118 }
119}
120
121impl VerificationBackend for InMemoryVerificationBackend {
122 fn put(&self, token: &VerificationToken) {
123 self.tokens
124 .lock()
125 .unwrap()
126 .insert(token.id.clone(), token.clone());
127 }
128 fn get(&self, id: &str) -> Option<VerificationToken> {
129 self.tokens.lock().unwrap().get(id).cloned()
130 }
131 fn by_prefix(&self, prefix: &str) -> Vec<VerificationToken> {
132 self.tokens
133 .lock()
134 .unwrap()
135 .values()
136 .filter(|t| t.token_prefix == prefix)
137 .cloned()
138 .collect()
139 }
140 fn mark_consumed(&self, id: &str, now: u64) -> bool {
141 let mut map = self.tokens.lock().unwrap();
142 let Some(t) = map.get_mut(id) else {
143 return false;
144 };
145 if t.consumed_at.is_some() {
146 return false;
147 }
148 t.consumed_at = Some(now);
149 true
150 }
151 fn purge_expired(&self, now: u64) {
152 let mut map = self.tokens.lock().unwrap();
153 map.retain(|_, t| t.expires_at > now || t.consumed_at.is_none());
154 }
155}
156
157pub struct VerificationStore {
158 backend: Box<dyn VerificationBackend>,
159}
160
161impl Default for VerificationStore {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167#[derive(Debug, Clone)]
168pub struct MintedToken {
169 pub token: VerificationToken,
170 pub plaintext: String,
173}
174
175impl VerificationStore {
176 const PASSWORD_RESET_TTL_SECS: u64 = 30 * 60; const MAGIC_LINK_TTL_SECS: u64 = 15 * 60; const EMAIL_CHANGE_TTL_SECS: u64 = 24 * 60 * 60; pub fn new() -> Self {
185 Self::with_backend(Box::new(InMemoryVerificationBackend::default()))
186 }
187
188 pub fn with_backend(backend: Box<dyn VerificationBackend>) -> Self {
189 Self { backend }
190 }
191
192 pub fn mint(
196 &self,
197 kind: TokenKind,
198 email: &str,
199 user_id: Option<String>,
200 payload: Option<String>,
201 ) -> MintedToken {
202 let id = format!("vt_{}", random_token(20));
203 let plaintext = random_token(32);
204 let prefix: String = plaintext.chars().take(8).collect();
205 let token_hash = hash_token(&plaintext);
206 let now = now_secs();
207 let ttl = match kind {
208 TokenKind::PasswordReset => Self::PASSWORD_RESET_TTL_SECS,
209 TokenKind::MagicLink => Self::MAGIC_LINK_TTL_SECS,
210 TokenKind::EmailChange => Self::EMAIL_CHANGE_TTL_SECS,
211 };
212 let token = VerificationToken {
213 id,
214 kind,
215 email: email.to_lowercase(),
216 user_id,
217 payload,
218 token_hash,
219 token_prefix: prefix,
220 created_at: now,
221 expires_at: now + ttl,
222 consumed_at: None,
223 };
224 self.backend.put(&token);
225 MintedToken { token, plaintext }
226 }
227
228 pub fn consume(
233 &self,
234 plaintext: &str,
235 expected_kind: TokenKind,
236 ) -> Result<VerificationToken, VerificationError> {
237 let prefix: String = plaintext.chars().take(8).collect();
238 let expected_hash = hash_token(plaintext);
242 let candidates = self.backend.by_prefix(&prefix);
243 let now = now_secs();
244 for t in candidates {
245 if !crate::constant_time_eq(t.token_hash.as_bytes(), expected_hash.as_bytes()) {
246 continue;
247 }
248 if t.kind != expected_kind {
250 return Err(VerificationError::KindMismatch);
251 }
252 if t.consumed_at.is_some() {
253 return Err(VerificationError::AlreadyConsumed);
254 }
255 if t.expires_at <= now {
256 return Err(VerificationError::Expired);
257 }
258 if !self.backend.mark_consumed(&t.id, now) {
261 return Err(VerificationError::AlreadyConsumed);
262 }
263 return Ok(t);
264 }
265 Err(VerificationError::NotFound)
266 }
267
268 pub fn purge_expired(&self) {
270 self.backend.purge_expired(now_secs());
271 }
272}
273
274fn random_token(n_bytes: usize) -> String {
275 use rand::RngCore;
276 let mut bytes = vec![0u8; n_bytes];
277 rand::thread_rng().fill_bytes(&mut bytes);
278 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
279 URL_SAFE_NO_PAD.encode(bytes)
280}
281
282fn hash_token(plaintext: &str) -> String {
283 use hmac::{Hmac, Mac};
284 use sha2::Sha256;
285 type HmacSha256 = Hmac<Sha256>;
286 let pepper = std::env::var("PYLON_API_KEY_PEPPER")
291 .unwrap_or_else(|_| "pylon-dev-api-key-pepper-not-for-production".into());
292 let mut mac =
293 HmacSha256::new_from_slice(pepper.as_bytes()).expect("HMAC accepts any key length");
294 mac.update(plaintext.as_bytes());
295 let out = mac.finalize().into_bytes();
296 use std::fmt::Write;
297 let mut s = String::with_capacity(64);
298 for b in out {
299 let _ = write!(s, "{b:02x}");
300 }
301 s
302}
303
304fn now_secs() -> u64 {
305 use std::time::{SystemTime, UNIX_EPOCH};
306 SystemTime::now()
307 .duration_since(UNIX_EPOCH)
308 .unwrap_or_default()
309 .as_secs()
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn mint_and_consume_round_trip() {
318 let store = VerificationStore::new();
319 let minted = store.mint(TokenKind::PasswordReset, "alice@example.com", None, None);
320 let consumed = store
321 .consume(&minted.plaintext, TokenKind::PasswordReset)
322 .expect("consume");
323 assert_eq!(consumed.id, minted.token.id);
324 assert_eq!(consumed.email, "alice@example.com");
325 }
326
327 #[test]
328 fn consume_is_single_use() {
329 let store = VerificationStore::new();
330 let minted = store.mint(TokenKind::MagicLink, "a@b.com", None, None);
331 store
332 .consume(&minted.plaintext, TokenKind::MagicLink)
333 .unwrap();
334 let err = store
335 .consume(&minted.plaintext, TokenKind::MagicLink)
336 .unwrap_err();
337 assert_eq!(err, VerificationError::AlreadyConsumed);
338 }
339
340 #[test]
341 fn cross_kind_replay_rejected() {
342 let store = VerificationStore::new();
346 let minted = store.mint(TokenKind::MagicLink, "a@b.com", None, None);
347 let err = store
348 .consume(&minted.plaintext, TokenKind::PasswordReset)
349 .unwrap_err();
350 assert_eq!(err, VerificationError::KindMismatch);
351 }
352
353 #[test]
354 fn unknown_token_returns_not_found() {
355 let store = VerificationStore::new();
356 let err = store
357 .consume(
358 "nonexistent_plaintext_xxxxxxxxxxxxxxxxxxxx",
359 TokenKind::PasswordReset,
360 )
361 .unwrap_err();
362 assert_eq!(err, VerificationError::NotFound);
363 }
364
365 #[test]
366 fn email_lowercased_at_mint() {
367 let store = VerificationStore::new();
368 let minted = store.mint(TokenKind::MagicLink, "MIXED@CASE.com", None, None);
369 assert_eq!(minted.token.email, "mixed@case.com");
370 }
371
372 #[test]
373 fn payload_round_trips() {
374 let store = VerificationStore::new();
376 let minted = store.mint(
377 TokenKind::EmailChange,
378 "new@example.com",
379 Some("user-1".into()),
380 Some("new@example.com".into()),
381 );
382 let consumed = store
383 .consume(&minted.plaintext, TokenKind::EmailChange)
384 .unwrap();
385 assert_eq!(consumed.payload.as_deref(), Some("new@example.com"));
386 assert_eq!(consumed.user_id.as_deref(), Some("user-1"));
387 }
388
389 #[test]
390 fn expired_token_rejected() {
391 let store = VerificationStore::new();
392 let minted = store.mint(TokenKind::MagicLink, "a@b.com", None, None);
393 let backend = InMemoryVerificationBackend::default();
395 let mut expired = minted.token.clone();
396 expired.expires_at = 1;
397 backend.put(&expired);
398 let store2 = VerificationStore::with_backend(Box::new(backend));
399 let err = store2
400 .consume(&minted.plaintext, TokenKind::MagicLink)
401 .unwrap_err();
402 assert_eq!(err, VerificationError::Expired);
403 }
404}