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 { token, plaintext }
226    }
227
228    /// Look up + consume a plaintext token. Returns the matching
229    /// record on success — the caller then applies the side effect
230    /// (set new password, swap email, mint session). Idempotent: a
231    /// second call with the same token returns `AlreadyConsumed`.
232    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        // Constant-set HMAC prevents timing distinction from the
239        // narrow prefix lookup. Per-row hash compare is also
240        // constant-time via constant_time_eq.
241        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            // Hash matched — now run the lifecycle checks.
249            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            // Atomic mark-consumed; loses the race if another
259            // concurrent verify already did it.
260            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    /// Opportunistic sweep — call from a background tick.
269    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    // Same pepper as API keys — both classes are server-side random
287    // 32-byte secrets. Sharing the pepper is fine because we never
288    // accept one token's hash as proof for the other (the consume
289    // path narrows by `prefix` then by `token_hash` then by `kind`).
290    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        // Critical safety check: a token minted as a magic-link
343        // must NOT be accepted as a password-reset token even
344        // though both share the same hash + expiry shape.
345        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        // Email-change flow stuffs the proposed new email into payload.
375        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        // Force expiry by mutating the backend directly.
394        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}