Skip to main content

pylon_auth/
verification.rs

1//! Verification tokens — single-use, email-delivered random tokens
2//! that back password reset, email change, and magic-link sign-in.
3//!
4//! All three flows share the same shape: server mints a long random
5//! token, hashes it, emails the plaintext to the user, then consumes
6//! the token on the verify endpoint. Same backend pattern as
7//! [`crate::api_key`]: HMAC-SHA256 with a server pepper (NOT Argon2 —
8//! these are 32-byte random secrets, not low-entropy passwords).
9//!
10//! `kind` lets the verifier reject cross-purpose replay (a magic-link
11//! token can't be used as a password-reset token even if an attacker
12//! intercepts both emails).
13
14use 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    /// `/api/auth/password/reset/request` → `/complete`. `email`
23    /// identifies the account; `user_id` is None at mint time
24    /// (the user isn't logged in).
25    PasswordReset,
26    /// `/api/auth/email/change/request` → `/confirm`. `user_id` is
27    /// the currently-logged-in user; `payload` carries the new
28    /// email address; `email` is the new email (delivery target).
29    EmailChange,
30    /// `/api/auth/magic-link/send` → `/verify`. `email` identifies
31    /// the account; `user_id` is None until verify creates/looks
32    /// up the user.
33    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    /// Stable id for management UIs / audit logs (`vt_<24-base64url>`).
49    pub id: String,
50    pub kind: TokenKind,
51    /// Email this token was minted for. Lowercased before storage.
52    pub email: String,
53    /// User id when known at mint time (email-change flow). None for
54    /// password-reset / magic-link minted before any auth.
55    pub user_id: Option<String>,
56    /// Arbitrary opaque payload (e.g. email-change carries the
57    /// proposed new email here so consume() can apply it without a
58    /// second round-trip).
59    pub payload: Option<String>,
60    /// HMAC-SHA256 of the plaintext + server pepper (hex). Constant-
61    /// time-compared at consume time.
62    pub token_hash: String,
63    /// First 8 chars of the plaintext for index narrowing — same
64    /// trick as the org invite path.
65    pub token_prefix: String,
66    pub created_at: u64,
67    pub expires_at: u64,
68    /// Stamped on first successful consume so a replay returns the
69    /// `AlreadyConsumed` error rather than `NotFound`.
70    pub consumed_at: Option<u64>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum VerificationError {
75    NotFound,
76    Expired,
77    AlreadyConsumed,
78    /// Token was minted for a different `kind` — defends against an
79    /// attacker tricking a victim into clicking a magic-link URL
80    /// pointed at the password-reset endpoint.
81    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    /// Lookup by `token_prefix` — backends index this column so
99    /// `consume_by_plaintext` is one fast SQL hit per attempt.
100    fn by_prefix(&self, prefix: &str) -> Vec<VerificationToken>;
101    /// Mark as consumed. Implementations MUST be idempotent so
102    /// concurrent verify requests can't both succeed.
103    fn mark_consumed(&self, id: &str, now: u64) -> bool;
104    /// Best-effort sweep of expired-and-consumed rows. Called
105    /// opportunistically — never blocking the hot path.
106    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    /// Plaintext shown to the user EXACTLY once (in the email body).
171    /// Never persisted server-side after this struct is dropped.
172    pub plaintext: String,
173}
174
175impl VerificationStore {
176    /// Default expiry windows. Password reset is short to limit
177    /// blast radius if an inbox is compromised post-issue. Magic
178    /// links match the existing magic-code TTL. Email change is
179    /// longer because the user might be away from their old inbox.
180    const PASSWORD_RESET_TTL_SECS: u64 = 30 * 60; // 30 min
181    const MAGIC_LINK_TTL_SECS: u64 = 15 * 60; // 15 min
182    const EMAIL_CHANGE_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours
183
184    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    /// Mint + store a token. Returns the plaintext (caller emails it
193    /// to the user) alongside the persisted record (whose
194    /// `token_hash` is what's saved).
195    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    /// Look up + consume a plaintext token. Returns the matching
232    /// record on success — the caller then applies the side effect
233    /// (set new password, swap email, mint session). Idempotent: a
234    /// second call with the same token returns `AlreadyConsumed`.
235    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        // Constant-set HMAC prevents timing distinction from the
242        // narrow prefix lookup. Per-row hash compare is also
243        // constant-time via constant_time_eq.
244        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            // Hash matched — now run the lifecycle checks.
252            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            // Atomic mark-consumed; loses the race if another
262            // concurrent verify already did it.
263            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    /// Opportunistic sweep — call from a background tick.
272    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    // Same pepper as API keys — both classes are server-side random
290    // 32-byte secrets. Sharing the pepper is fine because we never
291    // accept one token's hash as proof for the other (the consume
292    // path narrows by `prefix` then by `token_hash` then by `kind`).
293    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        // Critical safety check: a token minted as a magic-link
349        // must NOT be accepted as a password-reset token even
350        // though both share the same hash + expiry shape.
351        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        // Email-change flow stuffs the proposed new email into payload.
378        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        // Force expiry by mutating the backend directly.
397        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}