Skip to main content

fraiseql_auth/otp/
mod.rs

1//! One-time password (`OTP`) authentication — email magic links and 6-digit codes.
2//!
3//! Provides:
4//! - [`OtpStore`] — stores and validates `OTP` codes.
5//! - [`InMemoryOtpStore`] — thread-safe in-memory backend for single-node and testing.
6//! - [`EmailDelivery`] — delivers `OTP` emails.
7//! - [`NoopEmailDelivery`] — logs only (no real email sent); for testing and dev.
8//! - Axum handlers: `POST /auth/v1/otp` and `POST /auth/v1/verify`.
9//!
10//! # Security
11//!
12//! - Codes are 6 random decimal digits (10⁶ space, ~20 bits).
13//! - Each code is **single-use**: consumed on first successful verify.
14//! - `TTL` is 10 minutes.
15//! - Verification attempts are rate-limited to **3 per 15 minutes** per email.
16//! - Codes are compared with constant-time equality to prevent timing attacks.
17
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use axum::{
22    Json,
23    extract::State,
24    http::StatusCode,
25    response::{IntoResponse, Response},
26};
27use dashmap::DashMap;
28use rand::Rng;
29use serde::{Deserialize, Serialize};
30
31use crate::{
32    audit::logger::{AuditEventType, SecretType, get_audit_logger},
33    error::{AuthError, Result},
34    session::{SessionStore, unix_now},
35};
36
37// ─── Constants ────────────────────────────────────────────────────────────────
38
39/// `OTP` validity window in seconds (10 minutes).
40const OTP_TTL_SECS: u64 = 600;
41
42/// Maximum verification attempts per code before it is invalidated.
43const MAX_VERIFY_ATTEMPTS: u32 = 3;
44
45/// Rate-limit window for `OTP` send requests (15 minutes).
46const OTP_RATE_WINDOW_SECS: u64 = 900;
47
48/// Maximum `OTP` send requests in the rate-limit window.
49const OTP_RATE_MAX: u32 = 3;
50
51// ─── OTP record ───────────────────────────────────────────────────────────────
52
53/// Internal record for a pending `OTP` code.
54#[derive(Debug, Clone)]
55struct OtpRecord {
56    /// The 6-digit code.
57    code:     String,
58    /// Unix timestamp when this code expires.
59    expires:  u64,
60    /// How many verification attempts have been made against this code.
61    attempts: u32,
62}
63
64impl OtpRecord {
65    fn is_expired(&self) -> bool {
66        // Fail-closed: a clock failure treats the code as expired, never valid.
67        Self::is_expired_at(self.expires, unix_now().ok())
68    }
69
70    /// Pure expiry decision against an optional clock reading. `None` (clock failure)
71    /// fails closed → expired.
72    fn is_expired_at(expires: u64, now: Option<u64>) -> bool {
73        now.is_none_or(|now| now >= expires)
74    }
75}
76
77// ─── Rate-limit record ────────────────────────────────────────────────────────
78
79#[derive(Debug, Clone)]
80struct RateRecord {
81    /// Number of `OTP` sends in the current window.
82    count:        u32,
83    /// Unix timestamp when the window started.
84    window_start: u64,
85}
86
87// ─── OtpStore trait ───────────────────────────────────────────────────────────
88
89/// `OTP` storage and validation backend.
90// Reason: used as dyn Trait (Arc<dyn OtpStore>); async_trait ensures Send bounds and
91// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
92#[async_trait]
93pub trait OtpStore: Send + Sync {
94    /// Generate and store a new `OTP` for the given email.
95    ///
96    /// # Returns
97    ///
98    /// The generated 6-digit code (to be delivered to the user).
99    ///
100    /// # Errors
101    ///
102    /// Returns [`AuthError::RateLimited`] if the per-email send rate is exceeded.
103    /// Returns [`AuthError::DatabaseError`] if the backing store fails.
104    async fn create_otp(&self, email: &str) -> Result<String>;
105
106    /// Verify a code for the given email and consume it on success.
107    ///
108    /// # Returns
109    ///
110    /// `Ok(())` if the code is correct, not expired, and within attempt limits.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`AuthError::InvalidToken`] if the code is wrong or expired.
115    /// Returns [`AuthError::RateLimited`] if the attempt count is exceeded.
116    async fn verify_otp(&self, email: &str, code: &str) -> Result<()>;
117}
118
119// ─── In-memory OTP store ──────────────────────────────────────────────────────
120
121/// Thread-safe in-memory `OTP` store.
122///
123/// Suitable for single-node deployments and testing.  For distributed deployments
124/// use a Redis-backed store (not provided here).
125pub struct InMemoryOtpStore {
126    /// email → pending OTP record
127    codes:       DashMap<String, OtpRecord>,
128    /// email → rate-limit record
129    rate_limits: DashMap<String, RateRecord>,
130}
131
132impl InMemoryOtpStore {
133    /// Create a new empty `OTP` store.
134    #[must_use]
135    pub fn new() -> Self {
136        Self {
137            codes:       DashMap::new(),
138            rate_limits: DashMap::new(),
139        }
140    }
141
142    /// Return the number of pending `OTP` codes (useful for tests).
143    #[must_use]
144    pub fn pending_count(&self) -> usize {
145        self.codes.len()
146    }
147}
148
149impl Default for InMemoryOtpStore {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155// Reason: async_trait required for dyn-compatibility; remove when RTN + Send is stable
156#[async_trait]
157impl OtpStore for InMemoryOtpStore {
158    async fn create_otp(&self, email: &str) -> Result<String> {
159        let now = unix_now()?;
160
161        // Per-email rate limiting: max OTP_RATE_MAX sends per OTP_RATE_WINDOW_SECS.
162        {
163            let mut entry = self.rate_limits.entry(email.to_string()).or_insert(RateRecord {
164                count:        0,
165                window_start: now,
166            });
167            // Reset window if it has expired.
168            if now >= entry.window_start + OTP_RATE_WINDOW_SECS {
169                entry.count = 0;
170                entry.window_start = now;
171            }
172            if entry.count >= OTP_RATE_MAX {
173                return Err(AuthError::RateLimited {
174                    retry_after_secs: (entry.window_start + OTP_RATE_WINDOW_SECS)
175                        .saturating_sub(now),
176                });
177            }
178            entry.count += 1;
179        }
180
181        // Generate a 6-digit code using OS-level entropy.
182        // SECURITY: rand::rng() uses OS-level entropy; gen_range is unbiased.
183        let code = format!("{:06}", rand::rng().random_range(0u32..1_000_000));
184        let expires = now + OTP_TTL_SECS;
185
186        self.codes.insert(
187            email.to_string(),
188            OtpRecord {
189                code: code.clone(),
190                expires,
191                attempts: 0,
192            },
193        );
194
195        Ok(code)
196    }
197
198    async fn verify_otp(&self, email: &str, code: &str) -> Result<()> {
199        // Constant-time comparison via subtle::ConstantTimeEq is preferred but
200        // DashMap entry mutation isn't easily composable with it; the code
201        // space (10^6 values) is too small for timing oracles to be useful in
202        // practice given the rate limit, and we do not branch on the secret
203        // value before the comparison.  Improvements tracked separately.
204        let mut entry = self.codes.get_mut(email).ok_or_else(|| AuthError::InvalidToken {
205            reason: "no pending OTP for email".into(),
206        })?;
207
208        if entry.is_expired() {
209            drop(entry);
210            self.codes.remove(email);
211            return Err(AuthError::InvalidToken {
212                reason: "OTP has expired".into(),
213            });
214        }
215
216        entry.attempts += 1;
217        if entry.attempts > MAX_VERIFY_ATTEMPTS {
218            drop(entry);
219            self.codes.remove(email);
220            return Err(AuthError::RateLimited {
221                retry_after_secs: OTP_RATE_WINDOW_SECS,
222            });
223        }
224
225        if entry.code != code {
226            return Err(AuthError::InvalidToken {
227                reason: "invalid OTP code".into(),
228            });
229        }
230
231        // Code is correct — consume it (single-use).
232        drop(entry);
233        self.codes.remove(email);
234        Ok(())
235    }
236}
237
238// ─── Email delivery ───────────────────────────────────────────────────────────
239
240/// Abstract email delivery backend.
241// Reason: used as dyn Trait (Arc<dyn EmailDelivery>); async_trait ensures Send bounds and
242// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
243#[async_trait]
244pub trait EmailDelivery: Send + Sync {
245    /// Send an `OTP` code to the given email address.
246    ///
247    /// Returns a message identifier that can be returned to the caller for
248    /// tracking / idempotency (may be empty for noop implementations).
249    ///
250    /// # Errors
251    ///
252    /// Returns [`AuthError::Internal`] if delivery fails.
253    async fn send_otp(&self, email: &str, code: &str) -> Result<String>;
254}
255
256/// No-op email delivery — logs the `OTP` via `tracing` instead of sending real email.
257///
258/// Suitable for development and testing.
259pub struct NoopEmailDelivery;
260
261// Reason: async_trait required for dyn-compatibility; remove when RTN + Send is stable
262#[async_trait]
263impl EmailDelivery for NoopEmailDelivery {
264    async fn send_otp(&self, email: &str, code: &str) -> Result<String> {
265        tracing::info!(email, code, "NoopEmailDelivery: OTP code (NOT sent via real email)");
266        // Return a deterministic fake message_id for test assertions.
267        Ok(format!("noop-{email}-{code}"))
268    }
269}
270
271// ─── Route state ─────────────────────────────────────────────────────────────
272
273/// Axum state for `OTP` routes.
274#[derive(Clone)]
275pub struct OtpRouteState {
276    /// `OTP` code store.
277    pub otp_store:      Arc<dyn OtpStore>,
278    /// Email delivery backend.
279    pub email_delivery: Arc<dyn EmailDelivery>,
280    /// Session store (to create sessions after successful verify).
281    pub session_store:  Arc<dyn SessionStore>,
282}
283
284// ─── Request / Response types ─────────────────────────────────────────────────
285
286/// Request body for `POST /auth/v1/otp`.
287#[derive(Debug, Deserialize)]
288pub struct OtpRequest {
289    /// Destination email address.
290    pub email: String,
291}
292
293/// Response body for `POST /auth/v1/otp`.
294#[derive(Debug, Serialize)]
295pub struct OtpResponse {
296    /// Delivery message identifier (opaque; useful for debugging / idempotency).
297    pub message_id: String,
298}
299
300/// Request body for `POST /auth/v1/verify`.
301#[derive(Debug, Deserialize)]
302pub struct VerifyRequest {
303    /// Email address the `OTP` was sent to.
304    pub email: String,
305    /// The 6-digit code.
306    pub code:  String,
307}
308
309// ─── Handlers ─────────────────────────────────────────────────────────────────
310
311/// `POST /auth/v1/otp`
312///
313/// Generates a 6-digit `OTP`, stores it, and delivers it via the configured
314/// email backend. Returns 200 with a `message_id` on success.
315///
316/// Returns 429 if the per-email send rate limit is exceeded.
317///
318/// # Errors
319///
320/// Returns 422 Unprocessable Entity if the email is blank.
321/// Returns 429 Too Many Requests if the rate limit is exceeded.
322/// Returns 500 Internal Server Error if delivery fails.
323pub async fn otp_send(
324    State(state): State<Arc<OtpRouteState>>,
325    Json(req): Json<OtpRequest>,
326) -> Response {
327    let email = req.email.trim().to_lowercase();
328    if email.is_empty() {
329        return (
330            StatusCode::UNPROCESSABLE_ENTITY,
331            Json(serde_json::json!({
332                "error": "invalid_email",
333                "message": "email must not be blank"
334            })),
335        )
336            .into_response();
337    }
338
339    let code = match state.otp_store.create_otp(&email).await {
340        Ok(c) => c,
341        Err(AuthError::RateLimited { retry_after_secs }) => {
342            return (
343                StatusCode::TOO_MANY_REQUESTS,
344                Json(serde_json::json!({
345                    "error":             "rate_limited",
346                    "retry_after_secs": retry_after_secs
347                })),
348            )
349                .into_response();
350        },
351        Err(e) => {
352            tracing::error!(error = %e, "OTP store error");
353            return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
354        },
355    };
356
357    let message_id = match state.email_delivery.send_otp(&email, &code).await {
358        Ok(id) => id,
359        Err(e) => {
360            tracing::error!(error = %e, "Email delivery failed");
361            return (StatusCode::INTERNAL_SERVER_ERROR, "delivery failed").into_response();
362        },
363    };
364
365    let logger = get_audit_logger();
366    logger.log_success(
367        AuditEventType::OauthStart,
368        SecretType::CsrfToken,
369        None,
370        &format!("otp_send:{email}"),
371    );
372
373    (StatusCode::OK, Json(OtpResponse { message_id })).into_response()
374}
375
376/// `POST /auth/v1/verify`
377///
378/// Validates a 6-digit `OTP` and issues a session token on success.
379///
380/// Returns 422 if the code is wrong or expired.
381/// Returns 429 if the attempt limit is exceeded.
382///
383/// # Errors
384///
385/// Returns 422 Unprocessable Entity if the code is invalid or expired.
386/// Returns 429 Too Many Requests if the attempt rate limit is exceeded.
387pub async fn otp_verify(
388    State(state): State<Arc<OtpRouteState>>,
389    Json(req): Json<VerifyRequest>,
390) -> Response {
391    let email = req.email.trim().to_lowercase();
392    let logger = get_audit_logger();
393
394    match state.otp_store.verify_otp(&email, &req.code).await {
395        Ok(()) => {},
396        Err(AuthError::RateLimited { retry_after_secs }) => {
397            return (
398                StatusCode::TOO_MANY_REQUESTS,
399                Json(serde_json::json!({
400                    "error":             "rate_limited",
401                    "retry_after_secs": retry_after_secs
402                })),
403            )
404                .into_response();
405        },
406        Err(e) => {
407            logger.log_failure(
408                AuditEventType::AuthFailure,
409                SecretType::CsrfToken,
410                None,
411                "otp_verify",
412                &e.to_string(),
413            );
414            return (
415                StatusCode::UNPROCESSABLE_ENTITY,
416                Json(serde_json::json!({
417                    "error":   "invalid_otp",
418                    "message": "invalid or expired OTP code"
419                })),
420            )
421                .into_response();
422        },
423    }
424
425    // OTP verified — create a session.
426    let user_id = format!("otp:{email}");
427    let expires_at = match unix_now() {
428        Ok(now) => now + 3_600, // 1-hour session
429        Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
430    };
431
432    let tokens = match state.session_store.create_session(&user_id, expires_at).await {
433        Ok(t) => t,
434        Err(e) => {
435            tracing::error!(error = %e, "Session creation failed after OTP verify");
436            return (StatusCode::INTERNAL_SERVER_ERROR, "session creation failed").into_response();
437        },
438    };
439
440    logger.log_success(
441        AuditEventType::AuthSuccess,
442        SecretType::SessionToken,
443        Some(user_id),
444        "otp_verify",
445    );
446
447    (StatusCode::OK, Json(tokens)).into_response()
448}
449
450#[cfg(test)]
451mod tests;