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 {
226 token,
227 plaintext,
228 }
229 }
230
231 pub fn consume(
236 &self,
237 plaintext: &str,
238 expected_kind: TokenKind,
239 ) -> Result<VerificationToken, VerificationError> {
240 let prefix: String = plaintext.chars().take(8).collect();
241 let expected_hash = hash_token(plaintext);
245 let candidates = self.backend.by_prefix(&prefix);
246 let now = now_secs();
247 for t in candidates {
248 if !crate::constant_time_eq(t.token_hash.as_bytes(), expected_hash.as_bytes()) {
249 continue;
250 }
251 if t.kind != expected_kind {
253 return Err(VerificationError::KindMismatch);
254 }
255 if t.consumed_at.is_some() {
256 return Err(VerificationError::AlreadyConsumed);
257 }
258 if t.expires_at <= now {
259 return Err(VerificationError::Expired);
260 }
261 if !self.backend.mark_consumed(&t.id, now) {
264 return Err(VerificationError::AlreadyConsumed);
265 }
266 return Ok(t);
267 }
268 Err(VerificationError::NotFound)
269 }
270
271 pub fn purge_expired(&self) {
273 self.backend.purge_expired(now_secs());
274 }
275}
276
277fn random_token(n_bytes: usize) -> String {
278 use rand::RngCore;
279 let mut bytes = vec![0u8; n_bytes];
280 rand::thread_rng().fill_bytes(&mut bytes);
281 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
282 URL_SAFE_NO_PAD.encode(bytes)
283}
284
285fn hash_token(plaintext: &str) -> String {
286 use hmac::{Hmac, Mac};
287 use sha2::Sha256;
288 type HmacSha256 = Hmac<Sha256>;
289 let pepper = std::env::var("PYLON_API_KEY_PEPPER")
294 .unwrap_or_else(|_| "pylon-dev-api-key-pepper-not-for-production".into());
295 let mut mac = HmacSha256::new_from_slice(pepper.as_bytes())
296 .expect("HMAC accepts any key length");
297 mac.update(plaintext.as_bytes());
298 let out = mac.finalize().into_bytes();
299 use std::fmt::Write;
300 let mut s = String::with_capacity(64);
301 for b in out {
302 let _ = write!(s, "{b:02x}");
303 }
304 s
305}
306
307fn now_secs() -> u64 {
308 use std::time::{SystemTime, UNIX_EPOCH};
309 SystemTime::now()
310 .duration_since(UNIX_EPOCH)
311 .unwrap_or_default()
312 .as_secs()
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn mint_and_consume_round_trip() {
321 let store = VerificationStore::new();
322 let minted = store.mint(
323 TokenKind::PasswordReset,
324 "alice@example.com",
325 None,
326 None,
327 );
328 let consumed = store
329 .consume(&minted.plaintext, TokenKind::PasswordReset)
330 .expect("consume");
331 assert_eq!(consumed.id, minted.token.id);
332 assert_eq!(consumed.email, "alice@example.com");
333 }
334
335 #[test]
336 fn consume_is_single_use() {
337 let store = VerificationStore::new();
338 let minted = store.mint(TokenKind::MagicLink, "a@b.com", None, None);
339 store.consume(&minted.plaintext, TokenKind::MagicLink).unwrap();
340 let err = store
341 .consume(&minted.plaintext, TokenKind::MagicLink)
342 .unwrap_err();
343 assert_eq!(err, VerificationError::AlreadyConsumed);
344 }
345
346 #[test]
347 fn cross_kind_replay_rejected() {
348 let store = VerificationStore::new();
352 let minted = store.mint(TokenKind::MagicLink, "a@b.com", None, None);
353 let err = store
354 .consume(&minted.plaintext, TokenKind::PasswordReset)
355 .unwrap_err();
356 assert_eq!(err, VerificationError::KindMismatch);
357 }
358
359 #[test]
360 fn unknown_token_returns_not_found() {
361 let store = VerificationStore::new();
362 let err = store
363 .consume("nonexistent_plaintext_xxxxxxxxxxxxxxxxxxxx", TokenKind::PasswordReset)
364 .unwrap_err();
365 assert_eq!(err, VerificationError::NotFound);
366 }
367
368 #[test]
369 fn email_lowercased_at_mint() {
370 let store = VerificationStore::new();
371 let minted = store.mint(TokenKind::MagicLink, "MIXED@CASE.com", None, None);
372 assert_eq!(minted.token.email, "mixed@case.com");
373 }
374
375 #[test]
376 fn payload_round_trips() {
377 let store = VerificationStore::new();
379 let minted = store.mint(
380 TokenKind::EmailChange,
381 "new@example.com",
382 Some("user-1".into()),
383 Some("new@example.com".into()),
384 );
385 let consumed = store
386 .consume(&minted.plaintext, TokenKind::EmailChange)
387 .unwrap();
388 assert_eq!(consumed.payload.as_deref(), Some("new@example.com"));
389 assert_eq!(consumed.user_id.as_deref(), Some("user-1"));
390 }
391
392 #[test]
393 fn expired_token_rejected() {
394 let store = VerificationStore::new();
395 let minted = store.mint(TokenKind::MagicLink, "a@b.com", None, None);
396 let backend = InMemoryVerificationBackend::default();
398 let mut expired = minted.token.clone();
399 expired.expires_at = 1;
400 backend.put(&expired);
401 let store2 = VerificationStore::with_backend(Box::new(backend));
402 let err = store2
403 .consume(&minted.plaintext, TokenKind::MagicLink)
404 .unwrap_err();
405 assert_eq!(err, VerificationError::Expired);
406 }
407}