vti_common/error.rs
1use affinidi_tdk::secrets_resolver::errors::SecretsResolverError;
2use axum::http::StatusCode;
3use axum::response::{IntoResponse, Response};
4use tracing::{debug, warn};
5
6#[derive(Debug, thiserror::Error)]
7pub enum AppError {
8 #[error("configuration error: {0}")]
9 Config(String),
10
11 #[error("io error: {0}")]
12 Io(#[from] std::io::Error),
13
14 #[error("store error: {0}")]
15 Store(#[from] fjall::Error),
16
17 #[error("serialization error: {0}")]
18 Serialization(#[from] serde_json::Error),
19
20 #[error("internal error: {0}")]
21 Internal(String),
22
23 #[error("secret store error: {0}")]
24 SecretStore(String),
25
26 #[error("not found: {0}")]
27 NotFound(String),
28
29 #[error("conflict: {0}")]
30 Conflict(String),
31
32 /// The resource existed but has been permanently, irreversibly consumed
33 /// or removed — rendered as **410 Gone**, distinct from [`Self::NotFound`]
34 /// (never existed / not visible to this caller) and [`Self::Conflict`]
35 /// (a transient state mismatch a retry or different request could
36 /// resolve). Canonical use: the TEE Mode B bootstrap carve-out after it
37 /// has been claimed — a second `/bootstrap/request` cannot succeed no
38 /// matter what the caller sends, ever again for this VTA. The SDK's
39 /// `vta_sdk::error::VtaError::Gone` mirrors this on the client side
40 /// (`from_http` maps 410 → `Gone`) with an operator-facing hint.
41 #[error("gone: {0}")]
42 Gone(String),
43
44 #[error("secrets error: {0}")]
45 Secrets(#[from] SecretsResolverError),
46
47 #[error("authentication error: {0}")]
48 Authentication(String),
49
50 #[error("unauthorized: {0}")]
51 Unauthorized(String),
52
53 #[error("forbidden: {0}")]
54 Forbidden(String),
55
56 /// Operation requires a stepped-up (`acr=aal2`) session, but
57 /// the caller's JWT carries a lower acr (typically `aal1`).
58 /// Distinct from [`Self::Forbidden`] so wallets can react —
59 /// `step_up_required` is the operator-friendly signal to
60 /// trigger a passkey-login or VTA-approval ceremony, not a
61 /// hard rejection.
62 ///
63 /// Rendered as **403 Forbidden** with body
64 /// `{ "error": "step_up_required", "message": "...",
65 /// "requiredAcr": "aal2" }` so clients can distinguish it
66 /// from a role-based rejection without parsing English.
67 #[error("step-up required: {0}")]
68 StepUpRequired(String),
69
70 /// The Policy Decision Point refused the request until an approval is
71 /// obtained, and is handing back what obtaining it requires.
72 ///
73 /// Distinct from [`Self::StepUpRequired`] because that variant can only say
74 /// *that* elevation is needed — it renders `{error, message, requiredAcr}`
75 /// and has nowhere to put the `approveRequest` the caller must get signed,
76 /// nor any way to express a consent requirement (approver set, threshold,
77 /// challenge). A REST caller receiving it could learn it was blocked but
78 /// not what to do about it, while the trust-task caller for the very same
79 /// decision received the full document. That asymmetry is what this closes.
80 ///
81 /// `code` is the stable machine-readable reason (`auth:step_up_required`,
82 /// `auth:consent_required`) — the field a client keys on, rather than the
83 /// HTTP status or the English message. `details` is merged into the body,
84 /// so the REST response carries exactly what the trust-task reject's
85 /// `details` carries.
86 ///
87 /// Rendered as **403 Forbidden**: the request was understood and the caller
88 /// authenticated; it is refused pending a decision they can still obtain.
89 #[error("approval required: {code}")]
90 ApprovalRequired {
91 code: &'static str,
92 details: serde_json::Value,
93 },
94
95 #[error("validation error: {0}")]
96 Validation(String),
97
98 /// The request did not carry a required `Trust-Task` header. Routes
99 /// registered via [`crate::trust_task::TrustTaskRouter::route_with_task`]
100 /// reject missing headers with this variant (400). Only `/health` is
101 /// allowed to omit it.
102 #[error("request is missing required Trust-Task header")]
103 TrustTaskMissing,
104
105 /// The request's `Trust-Task` header did not match the handler's
106 /// registered task. Returned as 415 per spec §16.2; the response body
107 /// carries the expected + received task URLs so clients can diagnose
108 /// without re-reading the route table.
109 #[error("Trust-Task header does not match handler (expected {expected})")]
110 TrustTaskMismatch {
111 expected: String,
112 received: Option<String>,
113 },
114
115 /// The supplied Trust-Task value was not a well-formed identifier
116 /// (empty, non-`https://`, or contained header-injection control
117 /// characters). Returned as 400.
118 #[error("malformed Trust-Task identifier: {0}")]
119 TrustTaskMalformed(String),
120
121 /// A request reused an `Idempotency-Key` it had previously sent
122 /// with a *different* body hash. The cached response is preserved
123 /// for the original requester; the conflicting retry is rejected
124 /// with 422 so clients don't silently get a stale response from a
125 /// drifting payload.
126 #[error("Idempotency-Key conflict: same key, different request body")]
127 IdempotencyKeyConflict,
128
129 /// A pagination cursor failed integrity verification — either the
130 /// HMAC tag didn't validate (tampered, forged, or signed under a
131 /// different community's audit_key) or the encoded form was
132 /// malformed. Returned as 400 with no extra detail so an attacker
133 /// can't learn whether their guessed cursor was structurally
134 /// close to a valid one.
135 #[error("invalid pagination cursor")]
136 InvalidCursor,
137
138 /// A bounded computation aborted because it hit a resource ceiling
139 /// (time/instruction budget or an input-size cap) before completing.
140 /// Used by the Rego policy evaluator to refuse pathological policies
141 /// or adversarial inputs on the unauthenticated join path rather than
142 /// burning CPU unbounded. Rendered as **503 Service Unavailable** — the
143 /// evaluation did not complete, and the message is generic so an
144 /// attacker can't probe the exact limits.
145 #[error("resource limit exceeded: {0}")]
146 ResourceExhausted(String),
147
148 /// Catch-all for service-specific errors (e.g., KeyDerivation, BadGateway, TeeAttestation).
149 /// Services create helper functions to construct these with appropriate status codes.
150 #[error("{message}")]
151 ServiceError { status: StatusCode, message: String },
152
153 /// An I/O failure in a vsock operation. Preserves the underlying
154 /// `std::io::Error` via `#[source]` while adding a human-readable
155 /// label of which operation failed (connect / read / write / flush).
156 ///
157 /// Construct via [`AppError::vsock`] for ergonomic `.map_err(...)`.
158 #[error("{operation} failed: {source}")]
159 Vsock {
160 operation: &'static str,
161 #[source]
162 source: std::io::Error,
163 },
164}
165
166impl AppError {
167 /// Build a closure suitable for `.map_err(...)` that wraps an
168 /// `std::io::Error` into [`AppError::Vsock`] with the given operation
169 /// label. Keeps the source chain intact for downstream error walkers
170 /// while giving log readers the operation name.
171 pub fn vsock(operation: &'static str) -> impl FnOnce(std::io::Error) -> AppError {
172 move |source| AppError::Vsock { operation, source }
173 }
174}
175
176/// Convert the canonical auth-flow errors into [`AppError`] so
177/// the route layer's existing `IntoResponse` plumbing renders
178/// them without backend-specific glue. Each variant lands on the
179/// HTTP status reflected in the [`crate::auth::AuthError`]
180/// doc-comments:
181///
182/// - `Forbidden`, `DidMethodRejected` → 403
183/// - `PendingChallengeLimitReached` → 429 via the Validation arm
184/// (route layer can return a typed 429 if needed; the canonical
185/// variant carries the rate-limit signal in the message).
186/// - `SessionNotFound`, `SessionStateMismatch`, `ChallengeMismatch`,
187/// `ChallengeExpired`, `SignerMismatch`, `StaleMessage`,
188/// `RefreshTokenInvalid`, `RefreshTokenExpired` → 401
189/// - `SessionIdleTimeout` → 401, but with its own message: it is only
190/// reachable by the proven session owner, so it does not share the
191/// group's enumeration-resistance constraint.
192/// - `AttestationFailed` → 503 via Internal (TEE outages are not
193/// the caller's fault).
194/// - `Internal` → 500.
195impl From<crate::auth::backend::AuthError> for AppError {
196 fn from(e: crate::auth::backend::AuthError) -> Self {
197 use crate::auth::backend::AuthError as A;
198 match e {
199 A::Forbidden | A::DidMethodRejected => AppError::Forbidden(e.to_string()),
200 A::PendingChallengeLimitReached => AppError::Validation(e.to_string()),
201 // One message for every failure reachable *without* the
202 // subject's private key. These variants differ in ways a caller
203 // must not be able to observe: "session not found" versus
204 // "signer mismatch" tells a party probing identifiers whether
205 // the subject it named is enrolled here, which is precisely what
206 // `handle_challenge` stops disclosing at the other end of the
207 // flow. The variant is preserved for the log line; the caller is
208 // told that authentication failed.
209 A::SessionNotFound
210 | A::SessionStateMismatch
211 | A::ChallengeMismatch
212 | A::ChallengeExpired
213 | A::SignerMismatch
214 | A::StaleMessage
215 | A::RefreshTokenInvalid
216 | A::RefreshTokenExpired => {
217 tracing::debug!(reason = %e, "authentication failed");
218 AppError::Authentication("authentication failed".into())
219 }
220 // Told plainly, unlike the group above, because this one is
221 // not reachable by probing. `handle_refresh` claims-and-
222 // deletes the refresh-token index before it runs, so anyone
223 // who gets this answer has already proven possession of a
224 // valid, unconsumed refresh token for an authenticated
225 // session — they are the session's owner, and there is
226 // nothing left to disclose to them. It earns its own message
227 // because "you were signed out for being away" and "your
228 // session hit its maximum age" send an operator to different
229 // places, and the generic string sends them to neither.
230 A::SessionIdleTimeout => {
231 tracing::debug!(reason = %e, "refresh refused: idle timeout");
232 AppError::Authentication(
233 "session signed out after the configured period of inactivity".into(),
234 )
235 }
236 A::AttestationFailed(msg) => AppError::Internal(format!("tee attestation: {msg}")),
237 A::Internal(msg) => AppError::Internal(msg),
238 // Deliberately no wildcard arm. `AuthError` is
239 // `#[non_exhaustive]`, but that constrains only *other*
240 // crates — in here the match stays exhaustive, so a new
241 // variant is a compile error at this spot rather than
242 // something that silently inherits a catch-all mapping.
243 }
244 }
245}
246
247impl IntoResponse for AppError {
248 fn into_response(self) -> Response {
249 let status = match &self {
250 AppError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
251 AppError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
252 AppError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
253 AppError::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
254 AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
255 AppError::SecretStore(_) => StatusCode::INTERNAL_SERVER_ERROR,
256 AppError::NotFound(_) => StatusCode::NOT_FOUND,
257 AppError::Conflict(_) => StatusCode::CONFLICT,
258 AppError::Gone(_) => StatusCode::GONE,
259 AppError::Secrets(_) => StatusCode::INTERNAL_SERVER_ERROR,
260 AppError::Authentication(_) => StatusCode::UNAUTHORIZED,
261 AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
262 AppError::Forbidden(_) => StatusCode::FORBIDDEN,
263 AppError::StepUpRequired(_) => StatusCode::FORBIDDEN,
264 AppError::ApprovalRequired { .. } => StatusCode::FORBIDDEN,
265 AppError::Validation(_) => StatusCode::BAD_REQUEST,
266 AppError::TrustTaskMissing => StatusCode::BAD_REQUEST,
267 AppError::TrustTaskMismatch { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE,
268 AppError::TrustTaskMalformed(_) => StatusCode::BAD_REQUEST,
269 AppError::IdempotencyKeyConflict => StatusCode::UNPROCESSABLE_ENTITY,
270 AppError::InvalidCursor => StatusCode::BAD_REQUEST,
271 AppError::ResourceExhausted(_) => StatusCode::SERVICE_UNAVAILABLE,
272 AppError::ServiceError { status, .. } => *status,
273 AppError::Vsock { .. } => StatusCode::INTERNAL_SERVER_ERROR,
274 };
275
276 if status.is_server_error() {
277 warn!(status = %status.as_u16(), error = %self, "server error");
278 } else {
279 debug!(status = %status.as_u16(), error = %self, "client error");
280 }
281
282 // Trust-Task variants get structured payloads so clients can
283 // diagnose without re-reading the route table. Every other
284 // variant retains the existing `{ "error": "<display>" }` shape
285 // for backwards-compat with the workspace's existing consumers.
286 let body = match &self {
287 AppError::TrustTaskMissing => serde_json::json!({
288 "error": "TrustTaskMissing",
289 "message": self.to_string(),
290 }),
291 AppError::TrustTaskMismatch { expected, received } => serde_json::json!({
292 "error": "TrustTaskMismatch",
293 "message": self.to_string(),
294 "expected": expected,
295 "received": received,
296 }),
297 AppError::TrustTaskMalformed(value) => serde_json::json!({
298 "error": "TrustTaskMalformed",
299 "message": self.to_string(),
300 "received": value,
301 }),
302 AppError::IdempotencyKeyConflict => serde_json::json!({
303 "error": "IdempotencyKeyConflict",
304 "message": self.to_string(),
305 }),
306 AppError::StepUpRequired(msg) => serde_json::json!({
307 "error": "step_up_required",
308 "message": msg,
309 "requiredAcr": "aal2",
310 }),
311 // `details` is merged at the top level rather than nested, so the
312 // body reads the same as the trust-task reject's `details` object
313 // and a client can key on one shape across both transports. `error`
314 // is written last so a `details` carrying that key cannot displace
315 // the code the caller switches on.
316 AppError::ApprovalRequired { code, details } => {
317 let mut body = match details {
318 serde_json::Value::Object(map) => map.clone(),
319 _ => serde_json::Map::new(),
320 };
321 body.insert("error".to_string(), serde_json::json!(code));
322 serde_json::Value::Object(body)
323 }
324 _ => serde_json::json!({ "error": self.to_string() }),
325 };
326 (status, axum::Json(body)).into_response()
327 }
328}
329
330/// Helper to create a service-specific error for key derivation failures.
331pub fn key_derivation_error(msg: impl Into<String>) -> AppError {
332 AppError::ServiceError {
333 status: StatusCode::BAD_REQUEST,
334 message: format!("key derivation error: {}", msg.into()),
335 }
336}
337
338/// Helper to create a service-specific error for bad gateway responses.
339pub fn bad_gateway_error(msg: impl Into<String>) -> AppError {
340 AppError::ServiceError {
341 status: StatusCode::BAD_GATEWAY,
342 message: format!("bad gateway: {}", msg.into()),
343 }
344}
345
346/// Helper to create a service-specific error for TEE attestation failures.
347pub fn tee_attestation_error(msg: impl Into<String>) -> AppError {
348 AppError::ServiceError {
349 status: StatusCode::SERVICE_UNAVAILABLE,
350 message: format!("TEE attestation error: {}", msg.into()),
351 }
352}
353
354#[cfg(test)]
355mod approval_required_tests {
356 use super::*;
357 use axum::body::to_bytes;
358 use axum::response::IntoResponse;
359
360 async fn body_of(err: AppError) -> serde_json::Value {
361 let resp = err.into_response();
362 let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
363 serde_json::from_slice(&bytes).expect("json body")
364 }
365
366 /// The point of the variant: a REST caller must receive the same actionable
367 /// payload the trust-task caller gets, not merely "you were blocked".
368 #[tokio::test]
369 async fn details_are_merged_alongside_the_code() {
370 let body = body_of(AppError::ApprovalRequired {
371 code: "auth:step_up_required",
372 details: serde_json::json!({
373 "requiredAcr": "aal2",
374 "approveRequest": { "id": "urn:uuid:abc" },
375 }),
376 })
377 .await;
378
379 assert_eq!(body["error"], "auth:step_up_required");
380 assert_eq!(body["requiredAcr"], "aal2");
381 assert_eq!(body["approveRequest"]["id"], "urn:uuid:abc");
382 }
383
384 #[tokio::test]
385 async fn consent_details_survive_the_round_trip() {
386 let body = body_of(AppError::ApprovalRequired {
387 code: "auth:consent_required",
388 details: serde_json::json!({
389 "approverSet": "ops",
390 "minApprovals": 2,
391 "excludeRequester": true,
392 }),
393 })
394 .await;
395
396 assert_eq!(body["error"], "auth:consent_required");
397 assert_eq!(body["minApprovals"], 2);
398 assert_eq!(body["excludeRequester"], true);
399 }
400
401 /// `details` is assembled from a policy decision, so a stray `error` key in
402 /// it must not be able to displace the code a client switches on.
403 #[tokio::test]
404 async fn details_cannot_overwrite_the_code() {
405 let body = body_of(AppError::ApprovalRequired {
406 code: "auth:consent_required",
407 details: serde_json::json!({ "error": "allow", "approverSet": "ops" }),
408 })
409 .await;
410
411 assert_eq!(body["error"], "auth:consent_required");
412 assert_eq!(body["approverSet"], "ops");
413 }
414
415 /// A non-object `details` must still yield a well-formed body rather than
416 /// panicking or emitting a bare scalar.
417 #[tokio::test]
418 async fn a_non_object_details_still_renders_the_code() {
419 let body = body_of(AppError::ApprovalRequired {
420 code: "auth:step_up_required",
421 details: serde_json::Value::Null,
422 })
423 .await;
424
425 assert_eq!(body["error"], "auth:step_up_required");
426 assert!(body.as_object().is_some_and(|m| m.len() == 1));
427 }
428
429 #[tokio::test]
430 async fn renders_as_forbidden() {
431 let resp = AppError::ApprovalRequired {
432 code: "auth:consent_required",
433 details: serde_json::json!({}),
434 }
435 .into_response();
436 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
437 }
438}
439
440#[cfg(test)]
441mod gone_tests {
442 use super::*;
443
444 /// The whole point of the variant: a consumed single-use resource (the
445 /// canonical example being the TEE Mode B bootstrap carve-out) must
446 /// render 410, not 403/409 — the SDK's `VtaError::from_http` keys its
447 /// `Gone` mapping off exactly this status code.
448 #[test]
449 fn renders_as_410_gone() {
450 let resp =
451 AppError::Gone("TEE first-boot carve-out has already been used".into()).into_response();
452 assert_eq!(resp.status(), StatusCode::GONE);
453 }
454
455 #[tokio::test]
456 async fn body_carries_the_message() {
457 use axum::body::to_bytes;
458
459 let resp = AppError::Gone("carve-out closed".into()).into_response();
460 let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("body");
461 let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
462 assert_eq!(body["error"], "gone: carve-out closed");
463 }
464}