1use axum::extract::{Request, State};
2use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
3use axum::http::{HeaderValue, StatusCode};
4use axum::middleware::Next;
5use axum::response::{IntoResponse, Response};
6use tracing::warn;
7
8use crate::error::{ApiError, KeepPlainText};
9use crate::identity::{Accepts, identify};
10use crate::state::AppStateData;
11
12pub async fn auth_when_required(
23 State((state, accepts)): State<(AppStateData, Accepts)>,
24 request: Request,
25 next: Next,
26) -> Response {
27 if !state.settings.registry.auth_required {
28 return next.run(request).await;
29 }
30
31 let (mut parts, body) = request.into_parts();
32 match identify(&mut parts, &state, accepts).await {
33 Ok(_) => next.run(Request::from_parts(parts, body)).await,
34 Err(status) => {
35 warn!("Authentication required, but failed: {status}");
36 rejection(status, accepts)
37 }
38 }
39}
40
41fn rejection(status: StatusCode, accepts: Accepts) -> Response {
42 let status = match accepts {
43 Accepts::TokenOrSession => StatusCode::UNAUTHORIZED,
44 Accepts::Token | Accepts::Session => status,
45 };
46 let mut response = ApiError::from(status).into_response();
47 if accepts != Accepts::Session {
48 response.headers_mut().insert(
50 "WWW-Authenticate",
51 HeaderValue::from_static("Basic, Bearer"),
52 );
53 }
54 response
55}
56
57const MAX_ERROR_BODY: usize = 64 * 1024;
59
60pub async fn errors_as_json(request: Request, next: Next) -> Response {
65 let response = next.run(request).await;
66 let status = response.status();
67 let is_json = response
68 .headers()
69 .get(CONTENT_TYPE)
70 .is_some_and(|v| v.as_bytes().starts_with(b"application/json"));
71 let keep = response.extensions().get::<KeepPlainText>().is_some();
72 if !(status.is_client_error() || status.is_server_error()) || is_json || keep {
73 return response;
74 }
75
76 let (parts, body) = response.into_parts();
77 let detail = axum::body::to_bytes(body, MAX_ERROR_BODY)
78 .await
79 .map(|b| String::from_utf8_lossy(&b).trim().to_string())
80 .unwrap_or_default();
81 let error = if detail.is_empty() {
82 ApiError::from(status)
83 } else {
84 ApiError::new(&detail, status)
85 };
86 let mut response = error.into_response();
87 for (name, value) in &parts.headers {
88 if name != CONTENT_TYPE && name != CONTENT_LENGTH {
89 response.headers_mut().insert(name, value.clone());
90 }
91 }
92 response
93}
94
95#[cfg(test)]
96mod auth_when_required_tests {
97 use std::sync::Arc;
98
99 use axum::Router;
100 use axum::body::Body;
101 use axum::http::header;
102 use axum::middleware::from_fn_with_state;
103 use axum::routing::get;
104 use kellnr_db::UserFlags;
105 use kellnr_db::test_utils::test_drop_table;
106 use kellnr_settings::constants::COOKIE_SESSION_ID;
107 use kellnr_test_db::TestDb;
108 use tower::ServiceExt;
109
110 use super::*;
111 use crate::test_support::{encode_cookies, session_cookie, sqlite_test_state, test_state};
112
113 type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
114
115 fn app(state: AppStateData, auth_required: bool, accepts: Accepts) -> Router {
117 let mut settings = (*state.settings).clone();
118 settings.registry.auth_required = auth_required;
119 let state = AppStateData {
120 settings: Arc::new(settings),
121 ..state
122 };
123 Router::new()
124 .route("/guarded", get(StatusCode::OK))
125 .route_layer(from_fn_with_state(
126 (state.clone(), accepts),
127 auth_when_required,
128 ))
129 .route("/not_guarded", get(StatusCode::OK))
130 .with_state(state)
131 }
132
133 async fn db_app(accepts: Accepts) -> (Router, TestDb) {
135 let (state, db) = sqlite_test_state().await;
136 (app(state, true, accepts), db)
137 }
138
139 async fn guest_session(db: &TestDb) -> String {
140 session_cookie(db, "guest", UserFlags::default()).await
141 }
142
143 fn unknown_session() -> String {
144 encode_cookies([(COOKIE_SESSION_ID, "1234")])
145 }
146
147 async fn send(app: Router, uri: &str, cookie: Option<String>) -> Result<Response> {
148 let mut request = Request::get(uri);
149 if let Some(cookie) = cookie {
150 request = request.header(header::COOKIE, cookie);
151 }
152 Ok(app.oneshot(request.body(Body::empty())?).await?)
153 }
154
155 async fn send_token(app: Router, token: &str) -> Result<Response> {
156 let request = Request::get("/guarded").header(header::AUTHORIZATION, token);
157 Ok(app.oneshot(request.body(Body::empty())?).await?)
158 }
159
160 #[tokio::test]
161 async fn guarded_route_with_valid_token() -> Result {
162 let (app, _db) = db_app(Accepts::Token).await;
163 let r = send_token(app, "token").await?;
164 assert_eq!(r.status(), StatusCode::OK);
165 Ok(())
166 }
167
168 #[tokio::test]
169 async fn guarded_route_with_invalid_token() -> Result {
170 let (app, _db) = db_app(Accepts::Token).await;
171 let r = send_token(app, "wrong_token").await?;
172 assert_eq!(r.status(), StatusCode::FORBIDDEN);
173 Ok(())
174 }
175
176 #[tokio::test]
177 async fn guarded_route_without_token() -> Result {
178 let r = send(app(test_state(), true, Accepts::Token), "/guarded", None).await?;
179 assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
180 Ok(())
181 }
182
183 #[tokio::test]
184 async fn guarded_route_with_valid_cookie() -> Result {
185 let (app, db) = db_app(Accepts::Session).await;
186 let r = send(app, "/guarded", Some(guest_session(&db).await)).await?;
187 assert_eq!(r.status(), StatusCode::OK);
188 Ok(())
189 }
190
191 #[tokio::test]
192 async fn guarded_route_with_invalid_cookie() -> Result {
193 let (app, _db) = db_app(Accepts::Session).await;
194 let r = send(app, "/guarded", Some(unknown_session())).await?;
195 assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
196 assert!(!r.headers().contains_key("WWW-Authenticate"));
197 Ok(())
198 }
199
200 #[tokio::test]
202 async fn guarded_route_with_database_error() -> Result {
203 let (app, db) = db_app(Accepts::Session).await;
204 let cookie = guest_session(&db).await;
205 test_drop_table(&db, "session").await?;
206 let r = send(app, "/guarded", Some(cookie)).await?;
207 assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
208 Ok(())
209 }
210
211 #[tokio::test]
212 async fn guarded_route_without_cookie() -> Result {
213 let r = send(app(test_state(), true, Accepts::Session), "/guarded", None).await?;
214 assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
215 Ok(())
216 }
217
218 #[tokio::test]
219 async fn not_guarded_route_without_cookie() -> Result {
220 let r = send(
221 app(test_state(), true, Accepts::Session),
222 "/not_guarded",
223 None,
224 )
225 .await?;
226 assert_eq!(r.status(), StatusCode::OK);
227 Ok(())
228 }
229
230 #[tokio::test]
231 async fn app_not_required_auth_with_guarded_route() -> Result {
232 let r = send(app(test_state(), false, Accepts::Session), "/guarded", None).await?;
233 assert_eq!(r.status(), StatusCode::OK);
234 Ok(())
235 }
236
237 #[tokio::test]
238 async fn token_or_session_accepts_session() -> Result {
239 let (app, db) = db_app(Accepts::TokenOrSession).await;
240 let r = send(app, "/guarded", Some(guest_session(&db).await)).await?;
241 assert_eq!(r.status(), StatusCode::OK);
242 Ok(())
243 }
244
245 #[tokio::test]
248 async fn token_or_session_answers_every_failure_with_401() -> Result {
249 let (app, db) = db_app(Accepts::TokenOrSession).await;
250 let cookie = guest_session(&db).await;
251 test_drop_table(&db, "session").await?;
252 let r = send(app, "/guarded", Some(cookie)).await?;
253 assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
254 assert_eq!(r.headers()["WWW-Authenticate"], "Basic, Bearer");
255 Ok(())
256 }
257}
258
259#[cfg(test)]
260mod errors_as_json_tests {
261 use axum::Router;
262 use axum::body::Body;
263 use axum::http::header;
264 use axum::routing::{get, post};
265 use tower::ServiceExt;
266
267 use super::*;
268 use crate::error::{ErrorDetails, PlainTextError};
269
270 async fn json_body() -> Result<(), ApiError> {
271 Err(ApiError::conflict("already there"))
272 }
273
274 async fn parse(_body: axum::Json<u32>) {}
275
276 fn app() -> Router {
277 Router::new()
278 .route("/json", get(json_body))
279 .route("/bare", get(|| async { StatusCode::NOT_FOUND }))
280 .route(
281 "/text",
282 get(|| async {
283 (
284 StatusCode::BAD_REQUEST,
285 [(header::ALLOW, "GET")],
286 "invalid input",
287 )
288 }),
289 )
290 .route(
291 "/plain",
292 get(|| async { PlainTextError::new(StatusCode::FORBIDDEN, "denied") }),
293 )
294 .route("/parse", post(parse))
295 .route("/ok", get(|| async { "fine" }))
296 .layer(axum::middleware::from_fn(errors_as_json))
297 }
298
299 async fn send(request: Request<Body>) -> (StatusCode, axum::http::HeaderMap, String) {
300 let response = app().oneshot(request).await.unwrap();
301 let status = response.status();
302 let headers = response.headers().clone();
303 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
304 .await
305 .unwrap();
306 (status, headers, String::from_utf8(body.to_vec()).unwrap())
307 }
308
309 fn detail(body: &str) -> String {
310 let details: ErrorDetails = serde_json::from_str(body).unwrap();
311 details.errors[0].detail.clone()
312 }
313
314 #[tokio::test]
315 async fn json_errors_are_kept() {
316 let (status, _, body) = send(Request::get("/json").body(Body::empty()).unwrap()).await;
317 assert_eq!(status, StatusCode::CONFLICT);
318 assert_eq!(detail(&body), "ERROR: already there");
319 }
320
321 #[tokio::test]
322 async fn bare_status_gets_its_reason_as_detail() {
323 let (status, _, body) = send(Request::get("/bare").body(Body::empty()).unwrap()).await;
324 assert_eq!(status, StatusCode::NOT_FOUND);
325 assert_eq!(detail(&body), "ERROR: Not Found");
326 }
327
328 #[tokio::test]
329 async fn text_becomes_the_detail_and_headers_are_kept() {
330 let (status, headers, body) =
331 send(Request::get("/text").body(Body::empty()).unwrap()).await;
332 assert_eq!(status, StatusCode::BAD_REQUEST);
333 assert_eq!(detail(&body), "ERROR: invalid input");
334 assert_eq!(headers[header::ALLOW], "GET");
335 assert_eq!(headers[CONTENT_TYPE], "application/json");
336 }
337
338 #[tokio::test]
339 async fn plain_text_errors_are_kept() {
340 let (status, _, body) = send(Request::get("/plain").body(Body::empty()).unwrap()).await;
341 assert_eq!((status, body.as_str()), (StatusCode::FORBIDDEN, "denied"));
342 }
343
344 #[tokio::test]
345 async fn extractor_rejections_become_json() {
346 let request = Request::post("/parse")
347 .header(CONTENT_TYPE, "application/json")
348 .body(Body::from("\"no number\""))
349 .unwrap();
350 let (status, _, body) = send(request).await;
351 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
352 assert!(detail(&body).starts_with("ERROR: "), "{body}");
353 }
354
355 #[tokio::test]
356 async fn success_is_untouched() {
357 let (status, _, body) = send(Request::get("/ok").body(Body::empty()).unwrap()).await;
358 assert_eq!((status, body.as_str()), (StatusCode::OK, "fine"));
359 }
360}