use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
pub type Result<T, E = AuthError> = std::result::Result<T, E>;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Authentication required")]
Unauthenticated,
#[error("Invalid credentials: {0}")]
InvalidCredentials(String),
#[error("Token expired")]
TokenExpired,
#[error("Malformed token: {0}")]
MalformedToken(String),
#[error("Access denied: {0}")]
Forbidden(String),
#[error("Resource not found: {0}")]
ResourceNotFound(String),
#[error("API key not found or revoked")]
ApiKeyNotFound,
#[error("API key is disabled")]
ApiKeyDisabled,
#[error("Credentials expired")]
ExpiredCredentials,
#[error("Rate limit exceeded")]
RateLimitExceeded,
#[error("Storage error: {0}")]
StorageError(String),
#[error("External service error: {0}")]
External(String),
#[error("Invalid token: {0}")]
InvalidToken(String),
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Internal authentication error: {0}")]
Internal(String),
}
impl AuthError {
pub fn status_code(&self) -> StatusCode {
match self {
AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
AuthError::InvalidCredentials(_) => StatusCode::UNAUTHORIZED,
AuthError::TokenExpired => StatusCode::UNAUTHORIZED,
AuthError::MalformedToken(_) => StatusCode::UNAUTHORIZED,
AuthError::Forbidden(_) => StatusCode::FORBIDDEN,
AuthError::ResourceNotFound(_) => StatusCode::NOT_FOUND,
AuthError::ApiKeyNotFound => StatusCode::UNAUTHORIZED,
AuthError::ApiKeyDisabled => StatusCode::UNAUTHORIZED,
AuthError::ExpiredCredentials => StatusCode::UNAUTHORIZED,
AuthError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
AuthError::StorageError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AuthError::External(_) => StatusCode::INTERNAL_SERVER_ERROR,
AuthError::InvalidToken(_) => StatusCode::UNAUTHORIZED,
AuthError::Configuration(_) => StatusCode::INTERNAL_SERVER_ERROR,
AuthError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub fn error_type(&self) -> &'static str {
match self {
AuthError::Unauthenticated => "UnauthenticatedException",
AuthError::InvalidCredentials(_) => "InvalidCredentialsException",
AuthError::TokenExpired => "TokenExpiredException",
AuthError::MalformedToken(_) => "MalformedTokenException",
AuthError::Forbidden(_) => "ForbiddenException",
AuthError::ResourceNotFound(_) => "NotFoundException",
AuthError::ApiKeyNotFound => "ApiKeyNotFoundException",
AuthError::ApiKeyDisabled => "ApiKeyDisabledException",
AuthError::ExpiredCredentials => "ExpiredCredentialsException",
AuthError::RateLimitExceeded => "RateLimitExceededException",
AuthError::StorageError(_) => "StorageErrorException",
AuthError::External(_) => "ExternalServiceException",
AuthError::InvalidToken(_) => "InvalidTokenException",
AuthError::Configuration(_) => "ConfigurationException",
AuthError::Internal(_) => "InternalErrorException",
}
}
pub fn sanitized_message(&self) -> String {
if cfg!(debug_assertions) {
return self.to_string();
}
match self {
AuthError::Unauthenticated
| AuthError::TokenExpired
| AuthError::ApiKeyNotFound
| AuthError::ApiKeyDisabled
| AuthError::ExpiredCredentials
| AuthError::RateLimitExceeded => self.to_string(),
AuthError::InvalidCredentials(_) => "Invalid credentials".to_string(),
AuthError::MalformedToken(_) => "Malformed token".to_string(),
AuthError::InvalidToken(_) => "Invalid token".to_string(),
AuthError::Forbidden(_) => "Access denied".to_string(),
AuthError::ResourceNotFound(_) => "Resource not found".to_string(),
AuthError::StorageError(_)
| AuthError::External(_)
| AuthError::Configuration(_)
| AuthError::Internal(_) => {
"An internal error occurred. Please contact support.".to_string()
}
}
}
}
#[derive(Debug, Serialize)]
pub struct AuthErrorResponse {
pub error: AuthErrorBody,
}
#[derive(Debug, Serialize)]
pub struct AuthErrorBody {
pub code: u16,
pub message: String,
#[serde(rename = "type")]
pub error_type: String,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let status = self.status_code();
let body = AuthErrorResponse {
error: AuthErrorBody {
code: status.as_u16(),
message: self.sanitized_message(),
error_type: self.error_type().to_string(),
},
};
(status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_status_codes() {
assert_eq!(
AuthError::Unauthenticated.status_code(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
AuthError::Forbidden("test".into()).status_code(),
StatusCode::FORBIDDEN
);
assert_eq!(
AuthError::RateLimitExceeded.status_code(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[test]
fn test_error_display() {
let err = AuthError::InvalidCredentials("bad key".into());
assert_eq!(err.to_string(), "Invalid credentials: bad key");
}
#[test]
fn test_sanitized_messages() {
assert_eq!(
AuthError::Unauthenticated.sanitized_message(),
"Authentication required"
);
assert_eq!(
AuthError::RateLimitExceeded.sanitized_message(),
"Rate limit exceeded"
);
let internal = AuthError::Internal("secret database error".into());
let storage = AuthError::StorageError("connection refused to db.internal".into());
assert!(!internal.sanitized_message().is_empty());
assert!(!storage.sanitized_message().is_empty());
}
}