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;