acme_proxy_admin/webadmin/
error.rs1use axum::Json;
17use axum::http::{StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use tracing::error;
20
21#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
26#[error("{code}: {message}")]
27pub struct AdminError {
28 pub status: StatusCode,
29 pub code: &'static str,
31 pub message: String,
32 retry_after: Option<u64>,
35}
36
37impl AdminError {
38 fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
39 Self {
40 status,
41 code,
42 message: message.into(),
43 retry_after: None,
44 }
45 }
46
47 pub fn bad_request(message: impl Into<String>) -> Self {
49 Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
50 }
51
52 pub fn session_invalid() -> Self {
56 Self::new(
57 StatusCode::UNAUTHORIZED,
58 "session_invalid",
59 "no valid session; sign in again",
60 )
61 }
62
63 pub fn session_expired() -> Self {
65 Self::new(
66 StatusCode::UNAUTHORIZED,
67 "session_expired",
68 "the session has expired; sign in again",
69 )
70 }
71
72 pub fn session_idle() -> Self {
74 Self::new(
75 StatusCode::UNAUTHORIZED,
76 "session_idle",
77 "the session timed out through inactivity; sign in again",
78 )
79 }
80
81 pub fn invalid_credentials() -> Self {
88 Self::new(
89 StatusCode::UNAUTHORIZED,
90 "invalid_credentials",
91 "invalid username or password",
92 )
93 }
94
95 pub fn csrf_failed(message: impl Into<String>) -> Self {
98 Self::new(StatusCode::FORBIDDEN, "csrf_failed", message)
99 }
100
101 pub fn insufficient_role() -> Self {
106 Self::new(
107 StatusCode::FORBIDDEN,
108 "insufficient_role",
109 "your role does not permit this action",
110 )
111 }
112
113 pub fn not_found(message: impl Into<String>) -> Self {
115 Self::new(StatusCode::NOT_FOUND, "not_found", message)
116 }
117
118 pub fn with_code(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
121 Self::new(status, code, message)
122 }
123
124 pub fn conflict(code: &'static str, message: impl Into<String>) -> Self {
126 Self::new(StatusCode::CONFLICT, code, message)
127 }
128
129 pub fn rate_limited(retry_after_seconds: u64) -> Self {
131 Self {
132 retry_after: Some(retry_after_seconds),
133 ..Self::new(
134 StatusCode::TOO_MANY_REQUESTS,
135 "rate_limited",
136 format!("too many failed sign-in attempts; retry in {retry_after_seconds}s"),
137 )
138 }
139 }
140
141 pub fn signer_failed(message: impl Into<String>) -> Self {
143 Self::new(StatusCode::BAD_GATEWAY, "signer_failed", message)
144 }
145
146 pub fn access_denied(message: impl Into<String>) -> Self {
148 Self::new(StatusCode::FORBIDDEN, "access_denied", message)
149 }
150
151 pub fn internal() -> Self {
153 Self::new(
154 StatusCode::INTERNAL_SERVER_ERROR,
155 "internal",
156 "internal error",
157 )
158 }
159}
160
161impl From<sqlx::Error> for AdminError {
167 fn from(error: sqlx::Error) -> Self {
168 error!(event = "admin_db_error", outcome = "failure", error = %error);
169 AdminError::internal()
170 }
171}
172
173impl IntoResponse for AdminError {
174 fn into_response(self) -> Response {
175 let body = Json(serde_json::json!({
176 "error": self.code,
177 "message": self.message,
178 }));
179
180 let mut response = (self.status, body).into_response();
181 response.headers_mut().insert(
184 header::CACHE_CONTROL,
185 header::HeaderValue::from_static("no-store"),
186 );
187 if let Some(seconds) = self.retry_after
188 && let Ok(value) = header::HeaderValue::from_str(&seconds.to_string())
189 {
190 response.headers_mut().insert(header::RETRY_AFTER, value);
191 }
192 response
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use axum::body::to_bytes;
200
201 async fn parts(error: AdminError) -> (StatusCode, serde_json::Value, axum::http::HeaderMap) {
202 let expected_status = error.status;
203 let response = error.into_response();
204 let status = response.status();
205 assert_eq!(status, expected_status);
206 let headers = response.headers().clone();
207 let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
208 (status, serde_json::from_slice(&bytes).unwrap(), headers)
209 }
210
211 #[tokio::test]
212 async fn every_constructor_has_its_own_status_and_code() {
213 let cases: Vec<(AdminError, u16, &str)> = vec![
214 (AdminError::bad_request("nope"), 400, "bad_request"),
215 (AdminError::session_invalid(), 401, "session_invalid"),
216 (AdminError::session_expired(), 401, "session_expired"),
217 (AdminError::session_idle(), 401, "session_idle"),
218 (
219 AdminError::invalid_credentials(),
220 401,
221 "invalid_credentials",
222 ),
223 (AdminError::csrf_failed("missing"), 403, "csrf_failed"),
224 (AdminError::not_found("no such thing"), 404, "not_found"),
225 (
226 AdminError::conflict("already_revoked", "already revoked"),
227 409,
228 "already_revoked",
229 ),
230 (AdminError::rate_limited(30), 429, "rate_limited"),
231 (AdminError::signer_failed("down"), 502, "signer_failed"),
232 (AdminError::internal(), 500, "internal"),
233 (
234 AdminError::with_code(StatusCode::METHOD_NOT_ALLOWED, "method_not_allowed", "no"),
235 405,
236 "method_not_allowed",
237 ),
238 ];
239
240 for (error, expected_status, expected_code) in cases {
241 let (status, body, headers) = parts(error).await;
242 assert_eq!(status.as_u16(), expected_status, "for {expected_code}");
243 assert_eq!(body["error"], expected_code);
244 assert!(
245 body["message"].as_str().is_some_and(|m| !m.is_empty()),
246 "{expected_code} must carry a human message"
247 );
248 assert_eq!(
250 headers[header::CONTENT_TYPE],
251 "application/json",
252 "{expected_code} must not be application/problem+json"
253 );
254 assert_eq!(headers[header::CACHE_CONTROL], "no-store");
255 assert!(
256 !body.to_string().contains("urn:ietf:params:acme"),
257 "{expected_code} must not carry an ACME error URN"
258 );
259 }
260 }
261
262 #[tokio::test]
263 async fn only_the_rate_limiter_sets_retry_after() {
264 let (_, body, headers) = parts(AdminError::rate_limited(42)).await;
265 assert_eq!(headers[header::RETRY_AFTER], "42");
266 assert!(body["message"].as_str().unwrap().contains("42s"));
267
268 let (_, _, headers) = parts(AdminError::internal()).await;
269 assert!(!headers.contains_key(header::RETRY_AFTER));
270 }
271
272 #[tokio::test]
275 async fn every_login_failure_looks_the_same_to_the_client() {
276 let (first_status, first_body, _) = parts(AdminError::invalid_credentials()).await;
277 let (second_status, second_body, _) = parts(AdminError::invalid_credentials()).await;
278 assert_eq!(first_status, second_status);
279 assert_eq!(first_body, second_body);
280 assert_eq!(first_body["error"], "invalid_credentials");
281 let message = first_body["message"].as_str().unwrap().to_lowercase();
283 assert!(!message.contains("no such"));
284 assert!(!message.contains("disabled"));
285 assert!(!message.contains("unknown"));
286 }
287
288 #[tokio::test]
289 async fn a_database_error_is_logged_and_answered_generically() {
290 let error = AdminError::from(sqlx::Error::RowNotFound);
291 assert_eq!(error, AdminError::internal());
292 let (status, body, _) = parts(error).await;
293 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
294 assert_eq!(body["message"], "internal error");
296 }
297
298 #[test]
299 fn display_names_the_code_and_the_message() {
300 assert_eq!(
301 AdminError::not_found("no such account: acct-1").to_string(),
302 "not_found: no such account: acct-1"
303 );
304 }
305}