threatflux-core 0.2.0

Core types and utilities for ThreatFlux security platform
Documentation
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AuthError {
    #[error("Invalid credentials")]
    InvalidCredentials,

    #[error("User not found")]
    UserNotFound,

    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Unauthorized")]
    Unauthorized,

    #[error("Forbidden: {0}")]
    Forbidden(String),

    #[error("Missing entitlements: {0}")]
    MissingEntitlements(String),

    #[error("Conflict: {0}")]
    Conflict(String),

    #[error("Validation error: {0}")]
    Validation(String),

    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("JWT error: {0}")]
    Jwt(#[from] jsonwebtoken::errors::Error),

    #[error("Password hash error: {0}")]
    PasswordHash(String),

    #[error("Internal error: {0}")]
    Internal(String),

    #[error("Bad request: {0}")]
    BadRequest(String),

    // MFA-related errors
    #[error("MFA verification required")]
    MfaRequired,

    #[error("MFA is not enabled for this account")]
    MfaNotEnabled,

    #[error("Invalid TOTP code")]
    InvalidTotpCode,

    #[error("Invalid backup code")]
    InvalidBackupCode,

    #[error("Backup code has already been used")]
    BackupCodeAlreadyUsed,

    // Token-related errors
    #[error("Token has expired")]
    TokenExpired,

    #[error("Token has already been used")]
    TokenAlreadyUsed,

    #[error("Rate limit exceeded")]
    RateLimitExceeded,

    // Device-related errors
    #[error("Device not trusted")]
    DeviceNotTrusted,

    // WebAuthn-related errors
    #[error("WebAuthn error: {0}")]
    WebAuthn(String),

    // Encryption-related errors
    #[error("Encryption error: {0}")]
    Encryption(String),
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            Self::InvalidCredentials => (StatusCode::UNAUTHORIZED, "Invalid credentials"),
            Self::UserNotFound => (StatusCode::NOT_FOUND, "User not found"),
            Self::NotFound(ref msg) => (StatusCode::NOT_FOUND, msg.as_str()),
            Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
            Self::Forbidden(ref msg) => (StatusCode::FORBIDDEN, msg.as_str()),
            Self::MissingEntitlements(_) => {
                (StatusCode::FORBIDDEN, "User lacks required entitlements")
            }
            Self::Conflict(ref msg) => (StatusCode::CONFLICT, msg.as_str()),
            Self::Validation(ref msg) | Self::BadRequest(ref msg) => {
                (StatusCode::BAD_REQUEST, msg.as_str())
            }
            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error"),
            Self::Jwt(_) => (StatusCode::UNAUTHORIZED, "Invalid token"),
            Self::PasswordHash(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Password hashing error"),
            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"),
            // MFA errors
            Self::MfaRequired => (StatusCode::FORBIDDEN, "MFA verification required"),
            Self::MfaNotEnabled => (StatusCode::BAD_REQUEST, "MFA is not enabled"),
            Self::InvalidTotpCode => (StatusCode::UNAUTHORIZED, "Invalid TOTP code"),
            Self::InvalidBackupCode => (StatusCode::UNAUTHORIZED, "Invalid backup code"),
            Self::BackupCodeAlreadyUsed => (StatusCode::BAD_REQUEST, "Backup code already used"),
            // Token errors
            Self::TokenExpired => (StatusCode::UNAUTHORIZED, "Token has expired"),
            Self::TokenAlreadyUsed => (StatusCode::BAD_REQUEST, "Token has already been used"),
            Self::RateLimitExceeded => (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded"),
            // Device errors
            Self::DeviceNotTrusted => (StatusCode::FORBIDDEN, "Device not trusted"),
            // WebAuthn errors
            Self::WebAuthn(ref msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
            // Encryption errors
            Self::Encryption(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Encryption error"),
        };

        if let Self::MissingEntitlements(required) = self {
            let display = if required.trim().is_empty() {
                "the required entitlement from your administrator".to_string()
            } else {
                format!("{required} from your administrator")
            };
            let body = format!(
                "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Access Required</title></head><body><main><h1>Access required</h1><p>Please request access for {}</p></main></body></html>",
                escape_html(&display)
            );
            (status, Html(body)).into_response()
        } else {
            let body = json!({
                "error": message,
                "status": status.as_u16()
            });
            (status, Json(body)).into_response()
        }
    }
}

// Additional error conversions
impl From<validator::ValidationErrors> for AuthError {
    fn from(err: validator::ValidationErrors) -> Self {
        let message = err
            .field_errors()
            .values()
            .next()
            .and_then(|errors| errors.first())
            .map_or_else(
                || "Invalid input".to_string(),
                |error| {
                    error
                        .message
                        .as_ref()
                        .map_or_else(|| "Invalid input".to_string(), ToString::to_string)
                },
            );
        Self::Validation(message)
    }
}

impl From<argon2::password_hash::Error> for AuthError {
    fn from(err: argon2::password_hash::Error) -> Self {
        Self::PasswordHash(err.to_string())
    }
}

fn escape_html(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

#[cfg(test)]
mod tests {
    use super::AuthError;
    use argon2::password_hash::{errors::InvalidValue, Error as PasswordHashError};
    use axum::{
        http::{header::CONTENT_TYPE, StatusCode},
        response::IntoResponse,
    };
    use serde_json::Value;
    use validator::{ValidationError, ValidationErrors};

    async fn response_body(err: AuthError) -> (u16, Value) {
        let response = err.into_response();
        let status = response.status().as_u16();
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body read");
        let json: Value = serde_json::from_slice(&body).expect("json body");
        (status, json)
    }

    #[tokio::test]
    async fn into_response_maps_status_and_message() {
        let cases = vec![
            (AuthError::InvalidCredentials, 401, "Invalid credentials"),
            (AuthError::UserNotFound, 404, "User not found"),
            (AuthError::NotFound("Missing".into()), 404, "Missing"),
            (AuthError::Unauthorized, 401, "Unauthorized"),
            (AuthError::Forbidden("Nope".into()), 403, "Nope"),
            (AuthError::Conflict("Taken".into()), 409, "Taken"),
            (AuthError::Validation("Bad input".into()), 400, "Bad input"),
            (
                AuthError::Database(sqlx::Error::RowNotFound),
                500,
                "Database error",
            ),
            (
                AuthError::Jwt(jsonwebtoken::errors::Error::from(
                    jsonwebtoken::errors::ErrorKind::InvalidToken,
                )),
                401,
                "Invalid token",
            ),
            (
                AuthError::PasswordHash("oops".into()),
                500,
                "Password hashing error",
            ),
            (
                AuthError::Internal("kaput".into()),
                500,
                "Internal server error",
            ),
            (
                AuthError::BadRequest("Missing field".into()),
                400,
                "Missing field",
            ),
        ];

        for (error, expected_status, expected_message) in cases {
            let (status, body) = response_body(error).await;
            assert_eq!(status, expected_status);
            assert_eq!(body["error"], expected_message);
            assert_eq!(body["status"], expected_status);
        }
    }

    #[test]
    fn validation_error_uses_first_message() {
        let mut errors = ValidationErrors::new();
        let mut field_error = ValidationError::new("length");
        field_error.message = Some("Too short".into());
        errors.add("username", field_error);
        let auth_error = AuthError::from(errors);
        assert!(matches!(auth_error, AuthError::Validation(msg) if msg == "Too short"));
    }

    #[test]
    fn validation_error_falls_back_to_default() {
        let mut errors = ValidationErrors::new();
        errors.add("email", ValidationError::new("email"));
        let auth_error = AuthError::from(errors);
        assert!(matches!(auth_error, AuthError::Validation(msg) if msg == "Invalid input"));
    }

    #[test]
    fn password_hash_error_is_wrapped() {
        let err = PasswordHashError::SaltInvalid(InvalidValue::InvalidFormat);
        let auth_error = AuthError::from(err);
        assert!(matches!(auth_error, AuthError::PasswordHash(msg) if msg.contains("salt")));
    }

    #[tokio::test]
    async fn missing_entitlements_renders_html_page() {
        let response = AuthError::MissingEntitlements("admin".into()).into_response();
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
        let (parts, body) = response.into_parts();
        let content_type = parts
            .headers
            .get(CONTENT_TYPE)
            .and_then(|value| value.to_str().ok())
            .unwrap_or("");
        assert!(content_type.starts_with("text/html"));
        let bytes = axum::body::to_bytes(body, usize::MAX)
            .await
            .expect("body read");
        let body = String::from_utf8_lossy(&bytes);
        assert!(body.contains("Access required"));
        assert!(body.contains("admin"));
    }
}