use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use tracing::{debug, warn};
pub const RATE_LIMIT_SOURCE_HEADER: &str = "x-rate-limit-source";
pub const RATE_LIMIT_SOURCE: &str = "did-host";
pub const LIMITER_PENDING_CHALLENGES_PER_DID: &str = "auth-challenge-pending-per-did";
pub const DEFAULT_PENDING_CHALLENGE_RETRY_AFTER_SECS: u64 = 30 + 600;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("configuration error: {0}")]
Config(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("store error: {0}")]
Store(String),
#[error("secret store error: {0}")]
SecretStore(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("internal error: {0}")]
Internal(String),
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("authentication error: {0}")]
Authentication(String),
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("forbidden: {0}")]
Forbidden(String),
#[error("step-up required: {0}")]
StepUpRequired(String),
#[error("validation error: {0}")]
Validation(String),
#[error("quota exceeded: {0}")]
QuotaExceeded(String),
#[error("rate limited ({limiter}): {message}")]
RateLimited {
limiter: &'static str,
message: String,
retry_after_secs: u64,
},
#[error("Trust-Task header missing")]
TrustTaskMissing,
#[error("Trust-Task header malformed: {0}")]
TrustTaskMalformed(String),
#[error("Trust-Task header mismatch: expected {expected}, got {received:?}")]
TrustTaskMismatch {
expected: String,
received: Option<String>,
},
#[error("domain '{domain}' is disabled")]
DomainDisabled {
domain: String,
message: Option<String>,
},
#[error("agent name error: {0}")]
AgentName(#[from] AgentNameError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AgentNameError {
#[error("agent name is reserved")]
Reserved,
#[error("agent name is already taken")]
Taken,
#[error("agent name not found")]
NotFound,
#[error("agent name is not disabled")]
NotDisabled,
#[error("agent name is already disabled")]
AlreadyDisabled,
#[error("submitted document's alsoKnownAs does not match the requested name")]
AlsoKnownAsMismatch,
}
impl AgentNameError {
fn http_status(self) -> StatusCode {
match self {
AgentNameError::NotFound => StatusCode::NOT_FOUND,
AgentNameError::Reserved
| AgentNameError::Taken
| AgentNameError::NotDisabled
| AgentNameError::AlreadyDisabled => StatusCode::CONFLICT,
AgentNameError::AlsoKnownAsMismatch => StatusCode::BAD_REQUEST,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationKind {
InvalidLog,
InvalidPath,
InvalidWitness,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaKind {
Size,
Count,
}
impl From<vti_common::auth::backend::AuthError> for AppError {
fn from(e: vti_common::auth::backend::AuthError) -> Self {
use vti_common::auth::backend::AuthError as A;
match e {
A::Forbidden | A::DidMethodRejected => AppError::Forbidden(e.to_string()),
A::PendingChallengeLimitReached => AppError::RateLimited {
limiter: LIMITER_PENDING_CHALLENGES_PER_DID,
message: e.to_string(),
retry_after_secs: DEFAULT_PENDING_CHALLENGE_RETRY_AFTER_SECS,
},
A::SessionNotFound
| A::SessionStateMismatch
| A::ChallengeMismatch
| A::ChallengeExpired
| A::SignerMismatch
| A::StaleMessage
| A::RefreshTokenInvalid
| A::RefreshTokenExpired
| A::SessionIdleTimeout
| A::WrongRecipient { .. } => AppError::Authentication(e.to_string()),
A::MissingRecipient => AppError::Validation(e.to_string()),
A::AttestationFailed(msg) => AppError::Internal(format!("tee attestation: {msg}")),
A::Internal(msg) => AppError::Internal(msg),
_ => AppError::Authentication(e.to_string()),
}
}
}
impl AppError {
pub fn with_retry_after(self, secs: u64) -> Self {
match self {
AppError::RateLimited {
limiter, message, ..
} => AppError::RateLimited {
limiter,
message,
retry_after_secs: secs,
},
other => other,
}
}
pub fn validation(kind: ValidationKind, msg: impl Into<String>) -> Self {
let mut s = msg.into();
let tag = match kind {
ValidationKind::InvalidLog => "[log]",
ValidationKind::InvalidPath => "[path]",
ValidationKind::InvalidWitness => "[witness]",
ValidationKind::Other => "",
};
if !tag.is_empty() {
s = format!("{tag} {s}");
}
AppError::Validation(s)
}
pub fn validation_kind(&self) -> ValidationKind {
match self {
AppError::Validation(msg) => {
if msg.starts_with("[log]") {
ValidationKind::InvalidLog
} else if msg.starts_with("[path]") {
ValidationKind::InvalidPath
} else if msg.starts_with("[witness]") {
ValidationKind::InvalidWitness
} else {
ValidationKind::Other
}
}
_ => ValidationKind::Other,
}
}
pub fn quota_kind(&self) -> QuotaKind {
match self {
AppError::QuotaExceeded(msg) if msg.contains("size") => QuotaKind::Size,
_ => QuotaKind::Count,
}
}
}
const MAX_USER_MESSAGE_LEN: usize = 256;
fn sanitize_user_message(s: &str) -> String {
let mut out: String = s.chars().filter(|c| !c.is_ascii_control()).collect();
if out.len() > MAX_USER_MESSAGE_LEN {
out.truncate(MAX_USER_MESSAGE_LEN);
out.push('…');
}
out
}
impl AppError {
pub fn didcomm_code(&self) -> &'static str {
match self {
AppError::Unauthorized(_) | AppError::Forbidden(_) => "e.p.did.unauthorized",
AppError::QuotaExceeded(_) => match self.quota_kind() {
QuotaKind::Size => "e.p.did.size-exceeded",
QuotaKind::Count => "e.p.did.quota-exceeded",
},
AppError::Conflict(_) => "e.p.did.path-unavailable",
AppError::NotFound(_) => "e.p.did.mnemonic-not-found",
AppError::Validation(_) => match self.validation_kind() {
ValidationKind::InvalidLog => "e.p.did.invalid-log",
ValidationKind::InvalidPath => "e.p.did.path-invalid",
ValidationKind::InvalidWitness => "e.p.did.witness-invalid",
ValidationKind::Other => "e.p.did.validation-error",
},
AppError::AgentName(e) => match e {
AgentNameError::NotFound => "e.p.did.mnemonic-not-found",
AgentNameError::Reserved => "e.p.did.path-invalid",
AgentNameError::Taken => "e.p.did.path-unavailable",
AgentNameError::NotDisabled
| AgentNameError::AlreadyDisabled
| AgentNameError::AlsoKnownAsMismatch => "e.p.did.validation-error",
},
_ => "e.p.did.internal-error",
}
}
pub fn user_message(&self) -> String {
match self {
AppError::NotFound(_) => "not found".into(),
AppError::Authentication(_) => "authentication failed".into(),
AppError::Unauthorized(_) => "unauthorized".into(),
AppError::Forbidden(_) => "forbidden".into(),
AppError::StepUpRequired(_) => "step_up_required".into(),
AppError::Validation(msg) => sanitize_user_message(msg),
AppError::Conflict(msg) => sanitize_user_message(msg),
AppError::QuotaExceeded(msg) => sanitize_user_message(msg),
AppError::RateLimited { message, .. } => sanitize_user_message(message),
AppError::AgentName(e) => e.to_string(),
_ => "internal error".into(),
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::SecretStore(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::Authentication(_) => StatusCode::UNAUTHORIZED,
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
AppError::StepUpRequired(_) => StatusCode::FORBIDDEN,
AppError::Validation(_) => StatusCode::BAD_REQUEST,
AppError::QuotaExceeded(_) => StatusCode::FORBIDDEN,
AppError::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
AppError::TrustTaskMissing => StatusCode::BAD_REQUEST,
AppError::TrustTaskMalformed(_) => StatusCode::BAD_REQUEST,
AppError::TrustTaskMismatch { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE,
AppError::DomainDisabled { .. } => StatusCode::SERVICE_UNAVAILABLE,
AppError::AgentName(e) => e.http_status(),
};
if status.is_server_error() && !matches!(self, AppError::DomainDisabled { .. }) {
warn!(status = %status.as_u16(), error = %self, "server error");
let body = serde_json::json!({ "error": "internal server error" });
return (status, axum::Json(body)).into_response();
}
debug!(status = %status.as_u16(), error = %self, "client error");
if let AppError::RateLimited {
limiter,
message,
retry_after_secs,
} = &self
{
let retry_after_secs = (*retry_after_secs).max(1);
let body = serde_json::json!({
"error": "rate_limited",
"limiter": limiter,
"message": sanitize_user_message(message),
"retryAfterSecs": retry_after_secs,
});
let mut response = (status, axum::Json(body)).into_response();
let headers = response.headers_mut();
headers.insert(
RATE_LIMIT_SOURCE_HEADER,
HeaderValue::from_static(RATE_LIMIT_SOURCE),
);
headers.insert(
axum::http::header::RETRY_AFTER,
HeaderValue::from(retry_after_secs),
);
return response;
}
let body = match &self {
AppError::TrustTaskMissing => serde_json::json!({ "error": "TrustTaskMissing" }),
AppError::TrustTaskMalformed(received) => serde_json::json!({
"error": "TrustTaskMalformed",
"received": received,
}),
AppError::TrustTaskMismatch { expected, received } => serde_json::json!({
"error": "TrustTaskMismatch",
"expected": expected,
"received": received,
}),
AppError::DomainDisabled { domain, message } => serde_json::json!({
"status": "disabled",
"domain": domain,
"message": message,
}),
AppError::StepUpRequired(_) => serde_json::json!({
"error": "step_up_required",
"required_acr": "aal2",
}),
_ => serde_json::json!({ "error": self.user_message() }),
};
(status, axum::Json(body)).into_response()
}
}
#[cfg(test)]
mod response_tests {
use super::*;
#[test]
fn forbidden_always_collapses_to_fixed_string() {
let leaky = AppError::Forbidden("not the owner of this DID".into());
assert_eq!(leaky.user_message(), "forbidden");
let acl = AppError::Forbidden("DID not in the ACL: did:web:bob".into());
assert_eq!(acl.user_message(), "forbidden");
}
#[test]
fn validation_strips_control_chars() {
let err = AppError::Validation("bad input: did:web:t\nrest".into());
let msg = err.user_message();
assert!(!msg.contains('\n'));
assert!(!msg.contains('\r'));
}
#[test]
fn validation_caps_length() {
let huge = "x".repeat(MAX_USER_MESSAGE_LEN + 100);
let err = AppError::Validation(huge);
let msg = err.user_message();
assert!(msg.chars().count() <= MAX_USER_MESSAGE_LEN + 1); assert!(msg.ends_with('…'));
}
#[test]
fn validation_preserves_short_messages_unchanged() {
let err = AppError::Validation("path 'foo' is already taken".into());
assert_eq!(err.user_message(), "path 'foo' is already taken");
}
#[tokio::test]
async fn rate_limited_renders_the_429_contract() {
use http_body_util::BodyExt;
let response = AppError::RateLimited {
limiter: "auth-challenge-per-ip",
message: "slow down".into(),
retry_after_secs: 17,
}
.into_response();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(response.headers()["x-rate-limit-source"], "did-host");
assert_eq!(response.headers()["retry-after"], "17");
assert_eq!(response.headers()["content-type"], "application/json");
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
body,
serde_json::json!({
"error": "rate_limited",
"limiter": "auth-challenge-per-ip",
"message": "slow down",
"retryAfterSecs": 17,
})
);
}
#[test]
fn rate_limited_retry_after_is_at_least_one_second() {
let response = AppError::RateLimited {
limiter: "x",
message: String::new(),
retry_after_secs: 0,
}
.into_response();
assert_eq!(response.headers()["retry-after"], "1");
}
#[test]
fn pending_challenge_limit_is_a_rate_limit_not_a_validation_error() {
let err: AppError =
vti_common::auth::backend::AuthError::PendingChallengeLimitReached.into();
assert!(matches!(
err,
AppError::RateLimited {
limiter: LIMITER_PENDING_CHALLENGES_PER_DID,
..
}
));
assert!(matches!(
err.with_retry_after(42),
AppError::RateLimited {
retry_after_secs: 42,
..
}
));
assert!(matches!(
AppError::Conflict("c".into()).with_retry_after(5),
AppError::Conflict(_)
));
}
#[test]
fn not_found_does_not_leak_mnemonic() {
let err = AppError::NotFound("DID not found: super-secret-mnemonic".into());
assert_eq!(err.user_message(), "not found");
}
}