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