use axum::extract::{Request, State};
use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use tracing::warn;
use crate::error::{ApiError, KeepPlainText};
use crate::identity::{Accepts, identify};
use crate::state::AppStateData;
pub async fn auth_when_required(
State((state, accepts)): State<(AppStateData, Accepts)>,
request: Request,
next: Next,
) -> Response {
if !state.settings.registry.auth_required {
return next.run(request).await;
}
let (mut parts, body) = request.into_parts();
match identify(&mut parts, &state, accepts).await {
Ok(_) => next.run(Request::from_parts(parts, body)).await,
Err(status) => {
warn!("Authentication required, but failed: {status}");
rejection(status, accepts)
}
}
}
fn rejection(status: StatusCode, accepts: Accepts) -> Response {
let status = match accepts {
Accepts::TokenOrSession => StatusCode::UNAUTHORIZED,
Accepts::Token | Accepts::Session => status,
};
let mut response = ApiError::from(status).into_response();
if accepts != Accepts::Session {
response.headers_mut().insert(
"WWW-Authenticate",
HeaderValue::from_static("Basic, Bearer"),
);
}
response
}
const MAX_ERROR_BODY: usize = 64 * 1024;
pub async fn errors_as_json(request: Request, next: Next) -> Response {
let response = next.run(request).await;
let status = response.status();
let is_json = response
.headers()
.get(CONTENT_TYPE)
.is_some_and(|v| v.as_bytes().starts_with(b"application/json"));
let keep = response.extensions().get::<KeepPlainText>().is_some();
if !(status.is_client_error() || status.is_server_error()) || is_json || keep {
return response;
}
let (parts, body) = response.into_parts();
let detail = axum::body::to_bytes(body, MAX_ERROR_BODY)
.await
.map(|b| String::from_utf8_lossy(&b).trim().to_string())
.unwrap_or_default();
let error = if detail.is_empty() {
ApiError::from(status)
} else {
ApiError::new(&detail, status)
};
let mut response = error.into_response();
for (name, value) in &parts.headers {
if name != CONTENT_TYPE && name != CONTENT_LENGTH {
response.headers_mut().insert(name, value.clone());
}
}
response
}
#[cfg(test)]
mod auth_when_required_tests {
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::header;
use axum::middleware::from_fn_with_state;
use axum::routing::get;
use kellnr_db::UserFlags;
use kellnr_db::test_utils::test_drop_table;
use kellnr_settings::constants::COOKIE_SESSION_ID;
use kellnr_test_db::TestDb;
use tower::ServiceExt;
use super::*;
use crate::test_support::{encode_cookies, session_cookie, sqlite_test_state, test_state};
type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
fn app(state: AppStateData, auth_required: bool, accepts: Accepts) -> Router {
let mut settings = (*state.settings).clone();
settings.registry.auth_required = auth_required;
let state = AppStateData {
settings: Arc::new(settings),
..state
};
Router::new()
.route("/guarded", get(StatusCode::OK))
.route_layer(from_fn_with_state(
(state.clone(), accepts),
auth_when_required,
))
.route("/not_guarded", get(StatusCode::OK))
.with_state(state)
}
async fn db_app(accepts: Accepts) -> (Router, TestDb) {
let (state, db) = sqlite_test_state().await;
(app(state, true, accepts), db)
}
async fn guest_session(db: &TestDb) -> String {
session_cookie(db, "guest", UserFlags::default()).await
}
fn unknown_session() -> String {
encode_cookies([(COOKIE_SESSION_ID, "1234")])
}
async fn send(app: Router, uri: &str, cookie: Option<String>) -> Result<Response> {
let mut request = Request::get(uri);
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
Ok(app.oneshot(request.body(Body::empty())?).await?)
}
async fn send_token(app: Router, token: &str) -> Result<Response> {
let request = Request::get("/guarded").header(header::AUTHORIZATION, token);
Ok(app.oneshot(request.body(Body::empty())?).await?)
}
#[tokio::test]
async fn guarded_route_with_valid_token() -> Result {
let (app, _db) = db_app(Accepts::Token).await;
let r = send_token(app, "token").await?;
assert_eq!(r.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn guarded_route_with_invalid_token() -> Result {
let (app, _db) = db_app(Accepts::Token).await;
let r = send_token(app, "wrong_token").await?;
assert_eq!(r.status(), StatusCode::FORBIDDEN);
Ok(())
}
#[tokio::test]
async fn guarded_route_without_token() -> Result {
let r = send(app(test_state(), true, Accepts::Token), "/guarded", None).await?;
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
Ok(())
}
#[tokio::test]
async fn guarded_route_with_valid_cookie() -> Result {
let (app, db) = db_app(Accepts::Session).await;
let r = send(app, "/guarded", Some(guest_session(&db).await)).await?;
assert_eq!(r.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn guarded_route_with_invalid_cookie() -> Result {
let (app, _db) = db_app(Accepts::Session).await;
let r = send(app, "/guarded", Some(unknown_session())).await?;
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
assert!(!r.headers().contains_key("WWW-Authenticate"));
Ok(())
}
#[tokio::test]
async fn guarded_route_with_database_error() -> Result {
let (app, db) = db_app(Accepts::Session).await;
let cookie = guest_session(&db).await;
test_drop_table(&db, "session").await?;
let r = send(app, "/guarded", Some(cookie)).await?;
assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
Ok(())
}
#[tokio::test]
async fn guarded_route_without_cookie() -> Result {
let r = send(app(test_state(), true, Accepts::Session), "/guarded", None).await?;
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
Ok(())
}
#[tokio::test]
async fn not_guarded_route_without_cookie() -> Result {
let r = send(
app(test_state(), true, Accepts::Session),
"/not_guarded",
None,
)
.await?;
assert_eq!(r.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn app_not_required_auth_with_guarded_route() -> Result {
let r = send(app(test_state(), false, Accepts::Session), "/guarded", None).await?;
assert_eq!(r.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn token_or_session_accepts_session() -> Result {
let (app, db) = db_app(Accepts::TokenOrSession).await;
let r = send(app, "/guarded", Some(guest_session(&db).await)).await?;
assert_eq!(r.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn token_or_session_answers_every_failure_with_401() -> Result {
let (app, db) = db_app(Accepts::TokenOrSession).await;
let cookie = guest_session(&db).await;
test_drop_table(&db, "session").await?;
let r = send(app, "/guarded", Some(cookie)).await?;
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
assert_eq!(r.headers()["WWW-Authenticate"], "Basic, Bearer");
Ok(())
}
}
#[cfg(test)]
mod errors_as_json_tests {
use axum::Router;
use axum::body::Body;
use axum::http::header;
use axum::routing::{get, post};
use tower::ServiceExt;
use super::*;
use crate::error::{ErrorDetails, PlainTextError};
async fn json_body() -> Result<(), ApiError> {
Err(ApiError::conflict("already there"))
}
async fn parse(_body: axum::Json<u32>) {}
fn app() -> Router {
Router::new()
.route("/json", get(json_body))
.route("/bare", get(|| async { StatusCode::NOT_FOUND }))
.route(
"/text",
get(|| async {
(
StatusCode::BAD_REQUEST,
[(header::ALLOW, "GET")],
"invalid input",
)
}),
)
.route(
"/plain",
get(|| async { PlainTextError::new(StatusCode::FORBIDDEN, "denied") }),
)
.route("/parse", post(parse))
.route("/ok", get(|| async { "fine" }))
.layer(axum::middleware::from_fn(errors_as_json))
}
async fn send(request: Request<Body>) -> (StatusCode, axum::http::HeaderMap, String) {
let response = app().oneshot(request).await.unwrap();
let status = response.status();
let headers = response.headers().clone();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
(status, headers, String::from_utf8(body.to_vec()).unwrap())
}
fn detail(body: &str) -> String {
let details: ErrorDetails = serde_json::from_str(body).unwrap();
details.errors[0].detail.clone()
}
#[tokio::test]
async fn json_errors_are_kept() {
let (status, _, body) = send(Request::get("/json").body(Body::empty()).unwrap()).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(detail(&body), "ERROR: already there");
}
#[tokio::test]
async fn bare_status_gets_its_reason_as_detail() {
let (status, _, body) = send(Request::get("/bare").body(Body::empty()).unwrap()).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(detail(&body), "ERROR: Not Found");
}
#[tokio::test]
async fn text_becomes_the_detail_and_headers_are_kept() {
let (status, headers, body) =
send(Request::get("/text").body(Body::empty()).unwrap()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(detail(&body), "ERROR: invalid input");
assert_eq!(headers[header::ALLOW], "GET");
assert_eq!(headers[CONTENT_TYPE], "application/json");
}
#[tokio::test]
async fn plain_text_errors_are_kept() {
let (status, _, body) = send(Request::get("/plain").body(Body::empty()).unwrap()).await;
assert_eq!((status, body.as_str()), (StatusCode::FORBIDDEN, "denied"));
}
#[tokio::test]
async fn extractor_rejections_become_json() {
let request = Request::post("/parse")
.header(CONTENT_TYPE, "application/json")
.body(Body::from("\"no number\""))
.unwrap();
let (status, _, body) = send(request).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert!(detail(&body).starts_with("ERROR: "), "{body}");
}
#[tokio::test]
async fn success_is_untouched() {
let (status, _, body) = send(Request::get("/ok").body(Body::empty()).unwrap()).await;
assert_eq!((status, body.as_str()), (StatusCode::OK, "fine"));
}
}