Skip to main content

kellnr_http/
middleware.rs

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
12/// Middleware that rejects requests without a login of one of the accepted
13/// methods when `settings.registry.auth_required` is `true`.
14///
15/// Endpoints for cargo answer with a `WWW-Authenticate` header. Cargo and
16/// other registry clients retry with credentials after a 401, so `/config.json`
17/// is not public even if `auth_required` is `true`. See:
18/// - <https://github.com/kellnr/kellnr/pull/773#discussion_r2300752458>
19/// - <https://doc.rust-lang.org/cargo/reference/registry-index.html#sparse-authentication>
20///
21/// If both methods are accepted, every failed login is a 401.
22pub 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        // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/WWW-Authenticate
49        response.headers_mut().insert(
50            "WWW-Authenticate",
51            HeaderValue::from_static("Basic, Bearer"),
52        );
53    }
54    response
55}
56
57/// Largest body of an error response that [`errors_as_json`] reads.
58const MAX_ERROR_BODY: usize = 64 * 1024;
59
60/// Turns error responses that are not JSON yet, like the rejections of axum's
61/// extractors, into the error format of all Kellnr endpoints. Their text
62/// becomes the detail; other headers are kept. A
63/// [`PlainTextError`](crate::error::PlainTextError) stays as it is.
64pub 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    /// Router with `/guarded` behind the middleware and `/not_guarded` without.
116    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    /// App on a database that holds `admin` with the cargo token `token`.
134    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    /// A failing database is not a missing login.
201    #[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    /// Cargo retries with credentials after a 401, so every failed login is
246    /// one, also a failing database.
247    #[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}