Skip to main content

auth_framework/api/
auth.rs

1//! Authentication API Endpoints
2//!
3//! Handles login, logout, token refresh, and related authentication operations
4
5use crate::api::{ApiResponse, ApiState, extract_bearer_token};
6use axum::{Json, extract::State, http::HeaderMap};
7use serde::{Deserialize, Serialize};
8
9/// Login request payload.
10///
11/// When `challenge_id` and `mfa_code` are both present the login completes an
12/// in-progress MFA challenge instead of performing a fresh password check.
13/// The two fields must appear together — providing one without the other is
14/// rejected with a validation error.
15#[derive(Debug, Deserialize)]
16pub struct LoginRequest {
17    pub username: String,
18    pub password: String,
19    /// Identifier of a pending MFA challenge (returned by a prior login attempt).
20    #[serde(default)]
21    pub challenge_id: Option<String>,
22    /// The TOTP or backup code that answers the MFA challenge.
23    #[serde(default)]
24    pub mfa_code: Option<String>,
25    /// When `true`, the session lifetime is extended (e.g. "remember me" cookie).
26    #[serde(default)]
27    pub remember_me: bool,
28}
29
30/// Successful login response.
31///
32/// Contains access and refresh tokens plus user metadata and adaptive risk
33/// information.  Clients should inspect `login_risk_level` and
34/// `security_warnings` to decide whether to prompt for additional verification.
35#[derive(Debug, Serialize)]
36pub struct LoginResponse {
37    pub access_token: String,
38    pub refresh_token: String,
39    pub token_type: String,
40    pub expires_in: u64,
41    pub user: LoginUserInfo,
42    /// Risk level of this login attempt: "low", "medium", "high", or "critical".
43    /// Clients can use this to prompt the user to enable MFA or perform additional verification.
44    pub login_risk_level: String,
45    /// Non-blocking security advisories for the authenticated session.
46    /// Empty in the common case; populated when adaptive risk policy detects elevated risk.
47    pub security_warnings: Vec<String>,
48}
49
50/// User information embedded in login and validation responses.
51#[derive(Debug, Serialize)]
52pub struct LoginUserInfo {
53    pub id: String,
54    pub username: String,
55    pub roles: Vec<String>,
56    pub permissions: Vec<String>,
57}
58
59async fn build_login_response(
60    state: &ApiState,
61    user_id: &str,
62    username: String,
63    permissions: Vec<String>,
64) -> ApiResponse<LoginResponse> {
65    let user_key = format!("user:{}", user_id);
66    let roles: Vec<String> = match state.auth_framework.storage().get_kv(&user_key).await {
67        Ok(Some(bytes)) => {
68            let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
69            json["roles"]
70                .as_array()
71                .map(|arr| {
72                    arr.iter()
73                        .filter_map(|value| value.as_str())
74                        .map(|value| value.to_string())
75                        .collect()
76                })
77                .unwrap_or_default()
78        }
79        _ => vec![],
80    };
81
82    let user_info = LoginUserInfo {
83        id: user_id.to_string(),
84        username,
85        roles: roles.clone(),
86        permissions,
87    };
88
89    let token_lifetime = state.auth_framework.config().token_lifetime;
90    let access_token = match state.auth_framework.token_manager().create_jwt_token(
91        user_id,
92        roles,
93        Some(token_lifetime),
94    ) {
95        Ok(jwt) => jwt,
96        Err(e) => {
97            tracing::error!("Failed to create JWT token: {}", e);
98            return ApiResponse::error_typed(
99                "TOKEN_CREATION_FAILED",
100                "Failed to create access token",
101            );
102        }
103    };
104
105    let refresh_token_lifetime = state.auth_framework.config().refresh_token_lifetime;
106    let refresh_token = match state.auth_framework.token_manager().create_jwt_token(
107        user_id,
108        vec!["refresh".to_string()],
109        Some(refresh_token_lifetime),
110    ) {
111        Ok(jwt) => jwt,
112        Err(e) => {
113            tracing::error!("Failed to create refresh token: {}", e);
114            return ApiResponse::error_typed(
115                "TOKEN_CREATION_FAILED",
116                "Failed to create refresh token",
117            );
118        }
119    };
120
121    ApiResponse::success(LoginResponse {
122        access_token,
123        refresh_token,
124        token_type: "Bearer".to_string(),
125        expires_in: token_lifetime.as_secs(),
126        user: user_info,
127        login_risk_level: "low".to_string(), // Caller may override after build
128        security_warnings: Vec::new(),       // Caller may override after build
129    })
130}
131
132/// Token refresh request.
133#[derive(Debug, Deserialize)]
134pub struct RefreshRequest {
135    /// The refresh token issued during a previous login.
136    pub refresh_token: String,
137}
138
139/// Token refresh response.
140#[derive(Debug, Serialize)]
141pub struct RefreshResponse {
142    /// The newly issued access token.
143    pub access_token: String,
144    /// Always `"Bearer"`.
145    pub token_type: String,
146    /// Seconds until the new access token expires.
147    pub expires_in: u64,
148}
149
150/// Logout request.
151#[derive(Debug, Deserialize)]
152pub struct LogoutRequest {
153    /// Optional refresh token to revoke alongside the access token.
154    #[serde(default)]
155    pub refresh_token: Option<String>,
156}
157
158/// Compute a login risk level string from request headers.
159///
160/// This is a lightweight, header-only heuristic used to populate the
161/// `login_risk_level` field in the login response and to trigger security
162/// warnings when MFA is not enrolled.  It does **not** replace a full
163/// risk-engine evaluation — for that, see `AuthorizationContextBuilder`.
164pub(crate) fn login_risk_level(headers: &HeaderMap) -> (&'static str, Vec<String>) {
165    let user_agent = headers
166        .get("user-agent")
167        .and_then(|v| v.to_str().ok())
168        .unwrap_or("");
169
170    let forwarded_for = headers
171        .get("x-forwarded-for")
172        .and_then(|v| v.to_str().ok())
173        .unwrap_or("");
174
175    let mut risk_points: u8 = 0;
176    let mut warnings: Vec<String> = Vec::new();
177
178    if user_agent.is_empty() {
179        risk_points = risk_points.saturating_add(30);
180        warnings.push(
181            "No browser User-Agent detected; this request may originate from an automated script."
182                .to_string(),
183        );
184    }
185
186    // Detect Tor exit nodes by well-known header patterns.  This is a best-effort
187    // check and can be defeated; a proper geo-IP database lookup is more reliable.
188    if user_agent.to_lowercase().contains("tor browser") {
189        risk_points = risk_points.saturating_add(40);
190        warnings.push("Login originated from the Tor Browser.".to_string());
191    }
192
193    // Multiple IPs in X-Forwarded-For typically indicate a proxy or VPN hop.
194    let hop_count = forwarded_for.split(',').count();
195    if hop_count >= 2 {
196        risk_points = risk_points.saturating_add(15);
197        warnings.push(format!(
198            "Request passed through {} proxy hops (X-Forwarded-For).",
199            hop_count
200        ));
201    }
202
203    let level = match risk_points {
204        0..=9 => "low",
205        10..=29 => "medium",
206        30..=59 => "high",
207        _ => "critical",
208    };
209    (level, warnings)
210}
211
212/// Increment the per-username failed login counter with a sliding TTL window.
213async fn increment_login_failure(state: &ApiState, lockout_key: &str, window_secs: u64) {
214    let current: u64 = match state.auth_framework.storage().get_kv(lockout_key).await {
215        Ok(Some(bytes)) => std::str::from_utf8(&bytes)
216            .ok()
217            .and_then(|s| s.parse().ok())
218            .unwrap_or(0),
219        _ => 0,
220    };
221    let new_count = current.saturating_add(1);
222    let _ = state
223        .auth_framework
224        .storage()
225        .store_kv(
226            lockout_key,
227            new_count.to_string().as_bytes(),
228            Some(std::time::Duration::from_secs(window_secs)),
229        )
230        .await;
231}
232
233/// `POST /auth/login` — authenticate a user with username/password.
234///
235/// On success returns access + refresh tokens, user metadata, and adaptive
236/// risk information.  Returns `ACCOUNT_LOCKED` after 5 consecutive failures
237/// within 15 minutes.
238pub async fn login(
239    State(state): State<ApiState>,
240    headers: HeaderMap,
241    Json(req): Json<LoginRequest>,
242) -> ApiResponse<LoginResponse> {
243    // Validate required fields
244    if req.username.is_empty() || req.password.is_empty() {
245        return ApiResponse::validation_error_typed("Username and password are required");
246    }
247
248    if req.challenge_id.is_some() ^ req.mfa_code.is_some() {
249        return ApiResponse::validation_error_typed(
250            "challenge_id and mfa_code must be provided together",
251        );
252    }
253
254    if let (Some(challenge_id), Some(mfa_code)) =
255        (req.challenge_id.clone(), req.mfa_code.as_deref())
256    {
257        return match state
258            .auth_framework
259            .complete_mfa_by_id(&challenge_id, mfa_code)
260            .await
261        {
262            Ok(token) => {
263                let mut response = build_login_response(
264                    &state,
265                    &token.user_id,
266                    req.username,
267                    token.permissions.to_vec(),
268                )
269                .await;
270                // MFA completion means the step-up was satisfied — mark as low risk.
271                if let Some(data) = response.data.as_mut() {
272                    data.login_risk_level = "low".to_string();
273                }
274                response
275            }
276            Err(e) => {
277                tracing::debug!("MFA completion failed during login: {}", e);
278                ApiResponse::error_typed(
279                    "MFA_INVALID_CODE",
280                    "Invalid or expired MFA challenge or code",
281                )
282            }
283        };
284    }
285
286    // Compute adaptive risk from request headers before touching the auth core
287    // so the risk assessment can influence the response even on success.
288    let (risk_level, mut risk_warnings) = login_risk_level(&headers);
289
290    // SEC-M1/L1: Per-username failed login rate limiting / account lockout.
291    // Key: "login_failures:{username}" — stores the current failure count as a
292    // decimal string with a 15-minute TTL (auto-resets after 15 min of no attempts).
293    let lockout_key = format!("login_failures:{}", req.username);
294    const MAX_FAILED_ATTEMPTS: u64 = 5;
295    const LOCKOUT_WINDOW_SECS: u64 = 900; // 15 minutes
296    if let Ok(Some(count_bytes)) = state.auth_framework.storage().get_kv(&lockout_key).await {
297        if let Ok(count_str) = std::str::from_utf8(&count_bytes) {
298            if let Ok(count) = count_str.parse::<u64>() {
299                if count >= MAX_FAILED_ATTEMPTS {
300                    tracing::warn!(
301                        username = %req.username,
302                        failed_attempts = count,
303                        "Login rejected — account temporarily locked due to repeated failures"
304                    );
305                    return ApiResponse::error_typed(
306                        "ACCOUNT_LOCKED",
307                        "Too many failed login attempts. Please try again later.",
308                    );
309                }
310            }
311        }
312    }
313
314    // Create credential for authentication
315    let credential = crate::authentication::credentials::Credential::Password {
316        username: req.username.clone(),
317        password: req.password.clone(),
318    };
319
320    // Attempt authentication
321    match state
322        .auth_framework
323        .authenticate("password", credential)
324        .await
325    {
326        Ok(auth_result) => match auth_result {
327            crate::auth::AuthResult::Success(token) => {
328                // Check if the user has MFA enrolled.  If not and the risk level is
329                // elevated, add a security advisory recommending MFA enrollment.
330                let mfa_enrolled =
331                    crate::api::mfa::check_user_mfa_status(&state.auth_framework, &token.user_id)
332                        .await;
333
334                if !mfa_enrolled && matches!(risk_level, "high" | "critical") {
335                    risk_warnings.push(
336                        "Your account does not have multi-factor authentication enabled. \
337                         Enable MFA to protect this account from high-risk login contexts."
338                            .to_string(),
339                    );
340                    tracing::warn!(
341                        user_id = %token.user_id,
342                        risk_level = %risk_level,
343                        "High-risk login without MFA enrolled"
344                    );
345                } else {
346                    tracing::info!(
347                        user_id = %token.user_id,
348                        risk_level = %risk_level,
349                        mfa_enrolled = %mfa_enrolled,
350                        "Successful login"
351                    );
352                }
353
354                let mut response = build_login_response(
355                    &state,
356                    &token.user_id,
357                    req.username,
358                    token.permissions.to_vec(),
359                )
360                .await;
361
362                // Reset failed login counter on successful authentication
363                let _ = state.auth_framework.storage().delete_kv(&lockout_key).await;
364
365                if let Some(data) = response.data.as_mut() {
366                    data.login_risk_level = risk_level.to_string();
367                    data.security_warnings = risk_warnings;
368                }
369                response
370            }
371            crate::auth::AuthResult::MfaRequired(challenge) => {
372                // Return the challenge data so the client knows how to fulfill MFA.
373                // The client should prompt for the appropriate second factor and then
374                // re-submit the login request with both challenge_id and mfa_code.
375                let mfa_type_str = match &challenge.mfa_type {
376                    crate::methods::MfaType::Totp => "totp",
377                    crate::methods::MfaType::Sms { .. } => "sms",
378                    crate::methods::MfaType::Email { .. } => "email",
379                    crate::methods::MfaType::Push { .. } => "push",
380                    crate::methods::MfaType::SecurityKey => "security_key",
381                    crate::methods::MfaType::BackupCode => "backup_code",
382                    crate::methods::MfaType::MultiMethod => "totp_or_backup_code",
383                };
384                ApiResponse::<()>::error_with_details(
385                    "MFA_REQUIRED",
386                    "Multi-factor authentication required",
387                    serde_json::json!({
388                        "challenge_id": challenge.id,
389                        "mfa_type": mfa_type_str,
390                        "expires_at": challenge.expires_at.to_rfc3339(),
391                        "message": challenge.message,
392                    }),
393                )
394                .cast()
395            }
396            crate::auth::AuthResult::Failure(reason) => {
397                // Increment failed login counter
398                increment_login_failure(&state, &lockout_key, LOCKOUT_WINDOW_SECS).await;
399                ApiResponse::error_typed("AUTHENTICATION_FAILED", reason)
400            }
401        },
402        Err(e) => {
403            // Increment failed login counter
404            increment_login_failure(&state, &lockout_key, LOCKOUT_WINDOW_SECS).await;
405            // Always return the same error code/message regardless of *why* auth failed.
406            // This prevents timing and enumeration attacks that could distinguish
407            // "user not found" from "wrong password".
408            tracing::debug!(
409                "Authentication error (reported as INVALID_CREDENTIALS): {}",
410                e
411            );
412            ApiResponse::error_typed("INVALID_CREDENTIALS", "Invalid username or password")
413        }
414    }
415}
416
417/// `POST /auth/refresh` — exchange a valid refresh token for a new access token.
418pub async fn refresh_token(
419    State(state): State<ApiState>,
420    Json(req): Json<RefreshRequest>,
421) -> ApiResponse<RefreshResponse> {
422    if req.refresh_token.is_empty() {
423        return ApiResponse::validation_error_typed("Invalid request");
424    }
425
426    // Validate the refresh token
427    match state
428        .auth_framework
429        .token_manager()
430        .validate_jwt_token(&req.refresh_token)
431    {
432        Ok(claims) => {
433            // Check if this is actually a refresh token
434            if !claims.scope.contains("refresh") {
435                return ApiResponse::error_typed(
436                    "INVALID_TOKEN",
437                    "Expected a refresh token, but received an access token",
438                );
439            }
440
441            // SECURITY: Reject revoked refresh tokens (e.g. those already passed to /auth/logout).
442            let revocation_key = format!("revoked_token:{}", claims.jti);
443            match state.auth_framework.storage().get_kv(&revocation_key).await {
444                Ok(Some(_)) => {
445                    return ApiResponse::error_typed(
446                        "INVALID_TOKEN",
447                        "Refresh token has been revoked",
448                    );
449                }
450                Ok(None) => {} // Not revoked — proceed
451                Err(e) => {
452                    tracing::error!("Refresh token revocation check failed: {}", e);
453                    return ApiResponse::error_typed(
454                        "INTERNAL_ERROR",
455                        "Unable to verify token status",
456                    );
457                }
458            }
459
460            // Create new access token, preserving the user's actual permissions from storage.
461            // H-1 fix: do NOT hard-code ["read","write"] — load the real permission set.
462
463            // SECURITY: Reject refresh if the user's account has been deactivated
464            // since the refresh token was issued.  Without this check a deactivated
465            // user can indefinitely obtain new access tokens.
466            {
467                let user_key = format!("user:{}", claims.sub);
468                if let Ok(Some(user_bytes)) = state.auth_framework.storage().get_kv(&user_key).await
469                {
470                    let user_json: serde_json::Value =
471                        serde_json::from_slice(&user_bytes).unwrap_or_default();
472                    let active = user_json["active"].as_bool().unwrap_or(true);
473                    if !active {
474                        return ApiResponse::error_typed(
475                            "ACCOUNT_DEACTIVATED",
476                            "Account has been deactivated",
477                        );
478                    }
479                }
480            }
481
482            let permissions: Vec<String> = match state
483                .auth_framework
484                .storage()
485                .get_kv(&format!("user_permissions:{}", claims.sub))
486                .await
487            {
488                Ok(Some(data)) => serde_json::from_slice(&data).unwrap_or_default(),
489                _ => vec![],
490            };
491
492            let token_lifetime = state.auth_framework.config().token_lifetime;
493            let new_access_token = match state.auth_framework.token_manager().create_jwt_token(
494                &claims.sub,
495                permissions,
496                Some(token_lifetime),
497            ) {
498                Ok(jwt) => jwt,
499                Err(e) => {
500                    tracing::error!("Failed to create new access token: {}", e);
501                    return ApiResponse::error_typed(
502                        "TOKEN_CREATION_FAILED",
503                        "Failed to create new access token",
504                    );
505                }
506            };
507
508            let response = RefreshResponse {
509                access_token: new_access_token,
510                token_type: "Bearer".to_string(),
511                expires_in: token_lifetime.as_secs(),
512            };
513
514            ApiResponse::success(response)
515        }
516        Err(e) => {
517            tracing::warn!("Invalid refresh token: {}", e);
518            ApiResponse::error_typed("INVALID_TOKEN", "Invalid or expired refresh token")
519        }
520    }
521}
522
523/// `POST /auth/logout` — revoke access and (optionally) refresh tokens.
524pub async fn logout(
525    State(state): State<ApiState>,
526    headers: HeaderMap,
527    Json(req): Json<LogoutRequest>,
528) -> ApiResponse<()> {
529    // Revoke the access token by storing it in the blocklist keyed by JTI
530    if let Some(token) = extract_bearer_token(&headers) {
531        match state
532            .auth_framework
533            .token_manager()
534            .validate_jwt_token(&token)
535        {
536            Ok(claims) => {
537                let revocation_key = format!("revoked_token:{}", claims.jti);
538                // TTL: longer of token's remaining lifetime or 1 hour; cap at 7 days
539                let ttl = std::time::Duration::from_secs(7 * 86400);
540                if let Err(e) = state
541                    .auth_framework
542                    .storage()
543                    .store_kv(revocation_key.as_str(), b"revoked", Some(ttl))
544                    .await
545                {
546                    tracing::error!("Failed to revoke access token JTI {}: {}", claims.jti, e);
547                } else {
548                    tracing::info!("Access token revoked (JTI: {})", claims.jti);
549                }
550            }
551            Err(_) => {
552                // Token was already invalid; no action needed
553                tracing::debug!("Logout called with invalid/expired access token");
554            }
555        }
556    }
557
558    // If refresh token provided, revoke it too
559    if let Some(ref refresh_token) = req.refresh_token {
560        match state
561            .auth_framework
562            .token_manager()
563            .validate_jwt_token(refresh_token)
564        {
565            Ok(claims) => {
566                let revocation_key = format!("revoked_token:{}", claims.jti);
567                let ttl = std::time::Duration::from_secs(7 * 86400);
568                if let Err(e) = state
569                    .auth_framework
570                    .storage()
571                    .store_kv(revocation_key.as_str(), b"revoked", Some(ttl))
572                    .await
573                {
574                    tracing::error!("Failed to revoke refresh token JTI {}: {}", claims.jti, e);
575                } else {
576                    tracing::info!("Refresh token revoked (JTI: {})", claims.jti);
577                }
578            }
579            Err(_) => {
580                tracing::debug!("Logout called with invalid/expired refresh token");
581            }
582        }
583    }
584
585    ApiResponse::<()>::ok_with_message("Successfully logged out")
586}
587
588/// GET /auth/validate
589/// Validate current token and return user information
590pub async fn validate_token(
591    State(state): State<ApiState>,
592    headers: HeaderMap,
593) -> ApiResponse<LoginUserInfo> {
594    match extract_bearer_token(&headers) {
595        Some(token) => {
596            match crate::api::validate_api_token(&state.auth_framework, &token).await {
597                Ok(auth_token) => {
598                    // Fetch actual user information from storage
599                    let username = match state
600                        .auth_framework
601                        .get_user_profile(&auth_token.user_id)
602                        .await
603                    {
604                        Ok(profile) => profile
605                            .username
606                            .unwrap_or_else(|| format!("user_{}", auth_token.user_id)),
607                        Err(_) => format!("user_{}", auth_token.user_id), // Fallback if profile fetch fails
608                    };
609
610                    let user_info = LoginUserInfo {
611                        id: auth_token.user_id,
612                        username,
613                        roles: auth_token.roles.to_vec(),
614                        permissions: auth_token.permissions.to_vec(),
615                    };
616                    ApiResponse::success(user_info)
617                }
618                Err(_e) => ApiResponse::error_typed("AUTH_ERROR", "Token validation failed"),
619            }
620        }
621        None => ApiResponse::unauthorized_typed(),
622    }
623}
624
625/// `GET /auth/providers` — list available OAuth providers and their authorize URLs.
626pub async fn list_providers(State(_state): State<ApiState>) -> ApiResponse<Vec<ProviderInfo>> {
627    let providers = vec![
628        ProviderInfo {
629            name: "google".to_string(),
630            display_name: "Google".to_string(),
631            auth_url: "/oauth/google".to_string(),
632        },
633        ProviderInfo {
634            name: "github".to_string(),
635            display_name: "GitHub".to_string(),
636            auth_url: "/oauth/github".to_string(),
637        },
638        ProviderInfo {
639            name: "microsoft".to_string(),
640            display_name: "Microsoft".to_string(),
641            auth_url: "/oauth/microsoft".to_string(),
642        },
643    ];
644
645    ApiResponse::success(providers)
646}
647
648/// Summary of an available OAuth login provider.
649#[derive(Debug, Serialize)]
650pub struct ProviderInfo {
651    /// Machine-readable identifier (e.g. `"google"`).
652    pub name: String,
653    /// Human-readable label.
654    pub display_name: String,
655    /// Relative URL to initiate the OAuth flow.
656    pub auth_url: String,
657}
658
659/// User registration request.
660#[derive(Debug, Deserialize)]
661pub struct RegisterRequest {
662    /// Desired username (validated for length, characters, must start with a letter).
663    pub username: String,
664    /// Email address for the new account.
665    pub email: String,
666    /// Password (validated against minimum complexity rules).
667    pub password: String,
668}
669
670/// User registration response.
671#[derive(Debug, Serialize)]
672pub struct RegisterResponse {
673    /// System-generated unique user identifier.
674    pub user_id: String,
675    /// The registered username.
676    pub username: String,
677    /// The registered email.
678    pub email: String,
679}
680
681/// `POST /auth/register` — create a new user account.
682///
683/// Validates username format, password complexity, and email uniqueness
684/// before persisting the new user.
685pub async fn register(
686    State(state): State<ApiState>,
687    Json(req): Json<RegisterRequest>,
688) -> ApiResponse<RegisterResponse> {
689    // Validate required fields
690    if req.username.is_empty() || req.password.is_empty() || req.email.is_empty() {
691        return ApiResponse::validation_error_typed("Username, password, and email are required");
692    }
693
694    // Validate username format (length, allowed characters, must start with letter).
695    if let Err(e) = crate::utils::validation::validate_username(&req.username) {
696        return ApiResponse::validation_error_typed(format!("{e}"));
697    }
698
699    // Validate password strength (min 8 characters with complexity)
700    if let Err(e) = crate::utils::validation::validate_password(&req.password) {
701        return ApiResponse::validation_error_typed(format!("{e}"));
702    }
703
704    // Check password against known data breaches (HIBP k-anonymity API)
705    match crate::utils::breach_check::is_password_breached(&req.password).await {
706        Ok(true) => {
707            return ApiResponse::validation_error_typed(
708                "This password has appeared in a known data breach. Please choose a different password.",
709            );
710        }
711        Ok(false) => {} // Password not breached, continue
712        Err(_) => {}    // HIBP API unreachable; fail open to avoid blocking registration
713    }
714
715    // Validate email format
716    if let Err(e) = crate::utils::validation::validate_email(&req.email) {
717        return ApiResponse::validation_error_typed(format!("{e}"));
718    }
719
720    // Check if username already exists
721    let username_key = format!("user:credentials:{}", req.username);
722    match state.auth_framework.storage().get_kv(&username_key).await {
723        Ok(Some(_)) => {
724            // Use a generic message for both username and email conflicts to prevent
725            // enumeration of existing accounts via the public registration endpoint.
726            return ApiResponse::error_typed(
727                "CONFLICT",
728                "An account with the provided details already exists",
729            );
730        }
731        Err(e) => {
732            tracing::error!("Storage error checking username: {}", e);
733            return ApiResponse::internal_error_typed();
734        }
735        Ok(None) => {}
736    }
737
738    // Check if email already exists
739    let email_key = format!("user:email:{}", req.email);
740    match state.auth_framework.storage().get_kv(&email_key).await {
741        Ok(Some(_)) => {
742            return ApiResponse::error_typed(
743                "CONFLICT",
744                "An account with the provided details already exists",
745            );
746        }
747        Err(e) => {
748            tracing::error!("Storage error checking email: {}", e);
749            return ApiResponse::internal_error_typed();
750        }
751        Ok(None) => {}
752    }
753
754    // Hash the password
755    let password_hash = match crate::utils::password::hash_password(&req.password) {
756        Ok(hash) => hash,
757        Err(e) => {
758            tracing::error!("Password hashing failed: {:?}", e);
759            return ApiResponse::error_typed("REGISTRATION_FAILED", "Failed to process password");
760        }
761    };
762
763    // Generate user ID
764    let user_id = format!("user_{}", uuid::Uuid::new_v4().as_simple());
765    let created_at = chrono::Utc::now().to_rfc3339();
766
767    // Build user record
768    let user_data = serde_json::json!({
769        "user_id": user_id,
770        "username": req.username,
771        "email": req.email,
772        "password_hash": password_hash,
773        "created_at": created_at,
774    });
775    let user_data_bytes = user_data.to_string().into_bytes();
776
777    // Store main user record
778    if let Err(e) = state
779        .auth_framework
780        .storage()
781        .store_kv(&username_key, &user_data_bytes, None)
782        .await
783    {
784        tracing::error!("User registration storage failed: {:?}", e);
785        return ApiResponse::error_typed("REGISTRATION_FAILED", "Failed to create user account");
786    }
787
788    // Store email → user_id mapping for duplicate checking
789    if let Err(e) = state
790        .auth_framework
791        .storage()
792        .store_kv(&email_key, user_id.as_bytes(), None)
793        .await
794    {
795        tracing::error!("Email mapping storage failed: {:?}", e);
796        // Best-effort rollback
797        let _ = state
798            .auth_framework
799            .storage()
800            .delete_kv(&username_key)
801            .await;
802        return ApiResponse::error_typed("REGISTRATION_FAILED", "Failed to create user account");
803    }
804
805    // Store the canonical user record used by admin operations (set_user_active,
806    // update_user_password, delete_user, etc.) which all key on user:{user_id}.
807    let canonical_user_data = serde_json::json!({
808        "user_id": user_id,
809        "username": req.username,
810        "email": req.email,
811        "password_hash": password_hash,
812        "roles": ["user"],
813        "active": true,
814        "created_at": created_at,
815    });
816    let canonical_key = format!("user:{}", user_id);
817    if let Err(e) = state
818        .auth_framework
819        .storage()
820        .store_kv(
821            &canonical_key,
822            canonical_user_data.to_string().as_bytes(),
823            None,
824        )
825        .await
826    {
827        tracing::error!("Canonical user record storage failed: {:?}", e);
828        let _ = state
829            .auth_framework
830            .storage()
831            .delete_kv(&username_key)
832            .await;
833        let _ = state.auth_framework.storage().delete_kv(&email_key).await;
834        return ApiResponse::error_typed("REGISTRATION_FAILED", "Failed to create user account");
835    }
836
837    // Store username → user_id mapping (used by get_username_by_id reverse-lookup).
838    let username_id_key = format!("user:username:{}", req.username);
839    if let Err(e) = state
840        .auth_framework
841        .storage()
842        .store_kv(&username_id_key, user_id.as_bytes(), None)
843        .await
844    {
845        tracing::error!("Username-id mapping storage failed: {:?}", e);
846        let _ = state
847            .auth_framework
848            .storage()
849            .delete_kv(&username_key)
850            .await;
851        let _ = state.auth_framework.storage().delete_kv(&email_key).await;
852        let _ = state
853            .auth_framework
854            .storage()
855            .delete_kv(&canonical_key)
856            .await;
857        return ApiResponse::error_typed("REGISTRATION_FAILED", "Failed to create user account");
858    }
859
860    // Add user to the global users:index so admin list endpoints include self-registered users.
861    let index_key = "users:index";
862    let mut ids: Vec<String> = match state.auth_framework.storage().get_kv(index_key).await {
863        Ok(Some(bytes)) => serde_json::from_slice(&bytes).unwrap_or_default(),
864        _ => vec![],
865    };
866    ids.push(user_id.clone());
867    if let Ok(idx_json) = serde_json::to_vec(&ids) {
868        if let Err(e) = state
869            .auth_framework
870            .storage()
871            .store_kv(index_key, &idx_json, None)
872            .await
873        {
874            tracing::warn!("Failed to update user index after registration: {}", e);
875        }
876    }
877
878    tracing::info!("New user registered: {} ({})", req.username, user_id);
879
880    ApiResponse::success(RegisterResponse {
881        user_id,
882        username: req.username,
883        email: req.email,
884    })
885}
886
887/// Response returned when an API key is successfully created.
888#[derive(Debug, Serialize)]
889pub struct CreateApiKeyResponse {
890    /// The new API key – treat as a secret and display only once.
891    pub api_key: String,
892    /// Always `"ApiKey"`.
893    pub token_type: String,
894}
895
896/// POST /api-keys – create an API key (requires authentication)
897pub async fn create_api_key(
898    State(state): State<ApiState>,
899    headers: HeaderMap,
900) -> ApiResponse<CreateApiKeyResponse> {
901    // Require a valid Bearer token; reject unauthenticated requests with 401.
902    let token = match crate::api::extract_bearer_token(&headers) {
903        Some(t) => t,
904        None => return ApiResponse::unauthorized_typed(),
905    };
906
907    // Validate the token and extract the caller's identity.
908    let auth_token = match crate::api::validate_api_token(&state.auth_framework, &token).await {
909        Ok(t) => t,
910        Err(_) => return ApiResponse::unauthorized_typed(),
911    };
912
913    // Create an API key scoped to the authenticated user.
914    match state
915        .auth_framework
916        .create_api_key(&auth_token.user_id, None)
917        .await
918    {
919        Ok(api_key) => ApiResponse::success(CreateApiKeyResponse {
920            api_key,
921            token_type: "ApiKey".to_string(),
922        }),
923        Err(e) => {
924            tracing::error!(
925                "Failed to create API key for user {}: {}",
926                auth_token.user_id,
927                e
928            );
929            ApiResponse::internal_error_typed()
930        }
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use axum::http::HeaderMap;
938
939    #[test]
940    fn test_login_risk_low_normal_request() {
941        let mut headers = HeaderMap::new();
942        headers.insert(
943            "user-agent",
944            "Mozilla/5.0 (Windows NT 10.0; Win64; x64)".parse().unwrap(),
945        );
946        let (level, warnings) = login_risk_level(&headers);
947        assert_eq!(level, "low");
948        assert!(warnings.is_empty());
949    }
950
951    #[test]
952    fn test_login_risk_high_no_user_agent() {
953        let headers = HeaderMap::new();
954        let (level, warnings) = login_risk_level(&headers);
955        assert_eq!(level, "high");
956        assert!(!warnings.is_empty());
957    }
958
959    #[test]
960    fn test_login_risk_tor_browser() {
961        let mut headers = HeaderMap::new();
962        headers.insert("user-agent", "Mozilla/5.0 (Tor Browser)".parse().unwrap());
963        let (level, warnings) = login_risk_level(&headers);
964        // Tor alone is 40 points → "high"
965        assert!(level == "high" || level == "critical");
966        assert!(warnings.iter().any(|w| w.contains("Tor")));
967    }
968
969    #[test]
970    fn test_login_risk_proxy_hops() {
971        let mut headers = HeaderMap::new();
972        headers.insert("user-agent", "Mozilla/5.0".parse().unwrap());
973        headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
974        let (level, warnings) = login_risk_level(&headers);
975        assert_eq!(level, "medium");
976        assert!(warnings.iter().any(|w| w.contains("proxy")));
977    }
978
979    #[test]
980    fn test_login_request_deserialization() {
981        let json = r#"{"username":"alice","password":"secret"}"#;
982        let req: LoginRequest = serde_json::from_str(json).unwrap();
983        assert_eq!(req.username, "alice");
984        assert_eq!(req.password, "secret");
985        assert!(!req.remember_me);
986        assert!(req.challenge_id.is_none());
987        assert!(req.mfa_code.is_none());
988    }
989
990    #[test]
991    fn test_login_response_serialization() {
992        let resp = LoginResponse {
993            access_token: "at".into(),
994            refresh_token: "rt".into(),
995            token_type: "Bearer".into(),
996            expires_in: 3600,
997            user: LoginUserInfo {
998                id: "uid".into(),
999                username: "alice".into(),
1000                roles: vec!["user".into()],
1001                permissions: vec![],
1002            },
1003            login_risk_level: "low".into(),
1004            security_warnings: vec![],
1005        };
1006        let json = serde_json::to_value(&resp).unwrap();
1007        assert_eq!(json["token_type"], "Bearer");
1008        assert_eq!(json["expires_in"], 3600);
1009        assert_eq!(json["user"]["username"], "alice");
1010    }
1011
1012    #[test]
1013    fn test_register_request_deserialization() {
1014        let json = r#"{"username":"bob","password":"StrongP@ss1","email":"bob@example.com"}"#;
1015        let req: RegisterRequest = serde_json::from_str(json).unwrap();
1016        assert_eq!(req.username, "bob");
1017        assert_eq!(req.email, "bob@example.com");
1018    }
1019
1020    #[test]
1021    fn test_refresh_request_deserialization() {
1022        let json = r#"{"refresh_token":"some_token"}"#;
1023        let req: RefreshRequest = serde_json::from_str(json).unwrap();
1024        assert_eq!(req.refresh_token, "some_token");
1025    }
1026}