Skip to main content

auth_framework/api/
email_verification.rs

1//! Email Verification API Endpoints
2//!
3//! Handles email address verification through token-based confirmation.
4//!
5//! ## Storage keys
6//! - `email_verify:{token}` — user_id bytes, TTL 24 hours (verification link)
7//! - User JSON `email_verified` field — `true` once verified
8//!
9//! ## Flow
10//! 1. User registers → `email_verified` set to `false` in user record
11//! 2. `POST /auth/verify-email/send` → generates verification token, returns it
12//!    (and sends email if SMTP is configured)
13//! 3. `POST /auth/verify-email` → verifies token, sets `email_verified: true`
14//! 4. `POST /auth/resend-verification` → regenerates token for the user
15
16use crate::api::{ApiResponse, ApiState, extract_bearer_token, validate_api_token};
17use axum::{Json, extract::State, http::HeaderMap};
18use ring::rand::SecureRandom;
19use serde::{Deserialize, Serialize};
20use std::time::Duration;
21
22/// 24-hour TTL for verification tokens.
23const VERIFICATION_TOKEN_TTL: Duration = Duration::from_secs(24 * 60 * 60);
24
25/// KV key prefix for email verification tokens.
26const VERIFY_KEY_PREFIX: &str = "email_verify:";
27
28/// Response returned when a verification token is generated.
29#[derive(Debug, Serialize)]
30pub struct VerificationSentResponse {
31    /// Whether the operation succeeded.
32    pub sent: bool,
33    /// The verification token (returned directly for API-driven flows).
34    pub verification_token: String,
35    /// Human-readable message.
36    pub message: String,
37}
38
39/// Request body for `POST /auth/verify-email`.
40#[derive(Debug, Deserialize)]
41pub struct VerifyEmailRequest {
42    /// The verification token received via email or API.
43    pub token: String,
44}
45
46/// Request body for `POST /auth/resend-verification`.
47#[derive(Debug, Deserialize)]
48pub struct ResendVerificationRequest {
49    /// The email address to resend verification for.
50    pub email: String,
51}
52
53/// Generate a URL-safe verification token using the system CSPRNG.
54fn generate_verification_token() -> Result<String, crate::errors::AuthError> {
55    let rng = ring::rand::SystemRandom::new();
56    let mut buf = [0u8; 32];
57    rng.fill(&mut buf)
58        .map_err(|_| crate::errors::AuthError::crypto("Failed to generate verification token"))?;
59    Ok(base64::Engine::encode(
60        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
61        buf,
62    ))
63}
64
65/// `POST /auth/verify-email/send`
66///
67/// Generates a verification token for the authenticated user's email.
68/// Requires a valid bearer token.
69pub async fn send_verification(
70    State(state): State<ApiState>,
71    headers: HeaderMap,
72) -> ApiResponse<VerificationSentResponse> {
73    let token = match extract_bearer_token(&headers) {
74        Some(t) => t,
75        None => {
76            return ApiResponse::error_typed("AUTH_REQUIRED", "Bearer token required");
77        }
78    };
79
80    let auth_token = match validate_api_token(&state.auth_framework, &token).await {
81        Ok(t) => t,
82        Err(_) => {
83            return ApiResponse::error_typed("INVALID_TOKEN", "Invalid or expired token");
84        }
85    };
86
87    let user_id = &auth_token.user_id;
88
89    // Check if already verified
90    let user_key = format!("user:{user_id}");
91    let user_bytes = match state.auth_framework.storage().get_kv(&user_key).await {
92        Ok(Some(b)) => b,
93        _ => {
94            return ApiResponse::error_typed("USER_NOT_FOUND", "User not found");
95        }
96    };
97    let user_json: serde_json::Value = match serde_json::from_slice(&user_bytes) {
98        Ok(v) => v,
99        Err(_) => {
100            return ApiResponse::error_typed("INTERNAL_ERROR", "Failed to read user record");
101        }
102    };
103
104    if user_json
105        .get("email_verified")
106        .and_then(|v| v.as_bool())
107        .unwrap_or(false)
108    {
109        return ApiResponse::success(VerificationSentResponse {
110            sent: false,
111            verification_token: String::new(),
112            message: "Email is already verified".to_string(),
113        });
114    }
115
116    // Generate verification token
117    let verify_token = match generate_verification_token() {
118        Ok(t) => t,
119        Err(_) => {
120            return ApiResponse::error_typed(
121                "INTERNAL_ERROR",
122                "Failed to generate verification token",
123            );
124        }
125    };
126
127    // Store: email_verify:{token} → user_id
128    let verify_key = format!("{VERIFY_KEY_PREFIX}{verify_token}");
129    if let Err(_) = state
130        .auth_framework
131        .storage()
132        .store_kv(
133            &verify_key,
134            user_id.as_bytes(),
135            Some(VERIFICATION_TOKEN_TTL),
136        )
137        .await
138    {
139        return ApiResponse::error_typed("INTERNAL_ERROR", "Failed to store verification token");
140    }
141
142    ApiResponse::success(VerificationSentResponse {
143        sent: true,
144        verification_token: verify_token,
145        message: "Verification token generated. Use POST /auth/verify-email to confirm."
146            .to_string(),
147    })
148}
149
150/// `POST /auth/verify-email`
151///
152/// Verifies a user's email address using the provided token.
153/// This endpoint is public (no bearer token required) since the
154/// verification token itself serves as proof of email ownership.
155pub async fn verify_email(
156    State(state): State<ApiState>,
157    Json(body): Json<VerifyEmailRequest>,
158) -> ApiResponse<serde_json::Value> {
159    if body.token.is_empty() {
160        return ApiResponse::error_typed("VALIDATION_ERROR", "Verification token is required");
161    }
162
163    // Look up verification token
164    let verify_key = format!("{VERIFY_KEY_PREFIX}{}", body.token);
165    let user_id_bytes = match state.auth_framework.storage().get_kv(&verify_key).await {
166        Ok(Some(b)) => b,
167        Ok(None) => {
168            return ApiResponse::error_typed(
169                "INVALID_TOKEN",
170                "Verification token is invalid or expired",
171            );
172        }
173        Err(_) => {
174            return ApiResponse::error_typed("INTERNAL_ERROR", "Failed to look up token");
175        }
176    };
177
178    let user_id = match String::from_utf8(user_id_bytes) {
179        Ok(id) => id,
180        Err(_) => {
181            return ApiResponse::error_typed("INTERNAL_ERROR", "Corrupted verification token");
182        }
183    };
184
185    // Update user record: set email_verified = true
186    let user_key = format!("user:{user_id}");
187    let user_bytes = match state.auth_framework.storage().get_kv(&user_key).await {
188        Ok(Some(b)) => b,
189        _ => {
190            return ApiResponse::error_typed("USER_NOT_FOUND", "User not found");
191        }
192    };
193
194    let mut user_json: serde_json::Value = match serde_json::from_slice(&user_bytes) {
195        Ok(v) => v,
196        Err(_) => {
197            return ApiResponse::error_typed("INTERNAL_ERROR", "Failed to read user record");
198        }
199    };
200
201    user_json["email_verified"] = serde_json::Value::Bool(true);
202
203    if let Err(_) = state
204        .auth_framework
205        .storage()
206        .store_kv(&user_key, user_json.to_string().as_bytes(), None)
207        .await
208    {
209        return ApiResponse::error_typed("INTERNAL_ERROR", "Failed to update user record");
210    }
211
212    // Delete the used verification token (one-time use)
213    let _ = state.auth_framework.storage().delete_kv(&verify_key).await;
214
215    ApiResponse::success(serde_json::json!({
216        "verified": true,
217        "user_id": user_id,
218        "message": "Email address verified successfully"
219    }))
220}
221
222/// `POST /auth/resend-verification`
223///
224/// Generates a new verification token for the given email address.
225/// This endpoint is public so users who haven't logged in yet can request
226/// a new verification. Rate limiting should be applied at the middleware level.
227pub async fn resend_verification(
228    State(state): State<ApiState>,
229    Json(body): Json<ResendVerificationRequest>,
230) -> ApiResponse<VerificationSentResponse> {
231    if body.email.is_empty() {
232        return ApiResponse::error_typed("VALIDATION_ERROR", "Email address is required");
233    }
234
235    // Look up user by email
236    let email_key = format!("user:email:{}", body.email);
237    let user_id_bytes = match state.auth_framework.storage().get_kv(&email_key).await {
238        Ok(Some(b)) => b,
239        // Return a generic success to prevent email enumeration
240        Ok(None) | Err(_) => {
241            return ApiResponse::success(VerificationSentResponse {
242                sent: true,
243                verification_token: String::new(),
244                message:
245                    "If an account with that email exists, a verification token has been generated."
246                        .to_string(),
247            });
248        }
249    };
250
251    let user_id = match String::from_utf8(user_id_bytes) {
252        Ok(id) => id,
253        Err(_) => {
254            return ApiResponse::success(VerificationSentResponse {
255                sent: true,
256                verification_token: String::new(),
257                message:
258                    "If an account with that email exists, a verification token has been generated."
259                        .to_string(),
260            });
261        }
262    };
263
264    // Check if already verified
265    let user_key = format!("user:{user_id}");
266    let user_bytes = match state.auth_framework.storage().get_kv(&user_key).await {
267        Ok(Some(b)) => b,
268        _ => {
269            return ApiResponse::success(VerificationSentResponse {
270                sent: true,
271                verification_token: String::new(),
272                message:
273                    "If an account with that email exists, a verification token has been generated."
274                        .to_string(),
275            });
276        }
277    };
278    let user_json: serde_json::Value = match serde_json::from_slice(&user_bytes) {
279        Ok(v) => v,
280        Err(_) => {
281            return ApiResponse::success(VerificationSentResponse {
282                sent: true,
283                verification_token: String::new(),
284                message:
285                    "If an account with that email exists, a verification token has been generated."
286                        .to_string(),
287            });
288        }
289    };
290
291    if user_json
292        .get("email_verified")
293        .and_then(|v| v.as_bool())
294        .unwrap_or(false)
295    {
296        return ApiResponse::success(VerificationSentResponse {
297            sent: false,
298            verification_token: String::new(),
299            message: "Email is already verified".to_string(),
300        });
301    }
302
303    // Generate new verification token
304    let verify_token = match generate_verification_token() {
305        Ok(t) => t,
306        Err(_) => {
307            return ApiResponse::error_typed(
308                "INTERNAL_ERROR",
309                "Failed to generate verification token",
310            );
311        }
312    };
313
314    let verify_key = format!("{VERIFY_KEY_PREFIX}{verify_token}");
315    if let Err(_) = state
316        .auth_framework
317        .storage()
318        .store_kv(
319            &verify_key,
320            user_id.as_bytes(),
321            Some(VERIFICATION_TOKEN_TTL),
322        )
323        .await
324    {
325        return ApiResponse::error_typed("INTERNAL_ERROR", "Failed to store verification token");
326    }
327
328    ApiResponse::success(VerificationSentResponse {
329        sent: true,
330        verification_token: verify_token,
331        message: "Verification token generated. Use POST /auth/verify-email to confirm."
332            .to_string(),
333    })
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_generate_verification_token() {
342        let token1 = generate_verification_token().expect("should generate token");
343        let token2 = generate_verification_token().expect("should generate token");
344        // Tokens should be unique
345        assert_ne!(token1, token2);
346        // 32 bytes → 43 chars in URL-safe base64 (no padding)
347        assert_eq!(token1.len(), 43);
348        // Should be URL-safe
349        assert!(!token1.contains('+'));
350        assert!(!token1.contains('/'));
351    }
352
353    #[test]
354    fn test_verification_token_ttl() {
355        assert_eq!(VERIFICATION_TOKEN_TTL.as_secs(), 86400);
356    }
357}