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