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;