use autumn_web::AutumnError;
use autumn_web::session::Session;
use autumn_web::step_up;
use axum::extract::Request;
use axum::http::{Method, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
pub async fn check_role(
role: String,
auth_session_key: String,
req: Request,
next: Next,
) -> Response {
let Some(session) = req.extensions().get::<Session>().cloned() else {
return AutumnError::internal_server_error_msg(
"SessionLayer not installed; admin plugin requires sessions",
)
.into_response();
};
if session.get(&auth_session_key).await.is_none() {
return AutumnError::unauthorized_msg("authentication required").into_response();
}
let current = session.get("role").await.unwrap_or_default();
if current != role {
return AutumnError::forbidden_msg(format!("'{role}' role required")).into_response();
}
next.run(req).await
}
pub async fn check_step_up_mutations(max_age_secs: u64, req: Request, next: Next) -> Response {
if !matches!(
req.method(),
&Method::POST | &Method::PUT | &Method::PATCH | &Method::DELETE
) {
return next.run(req).await;
}
let Some(session) = req.extensions().get::<Session>().cloned() else {
return AutumnError::internal_server_error_msg(
"SessionLayer not installed; admin step-up requires sessions",
)
.into_response();
};
if step_up::check_step_up(&session, max_age_secs)
.await
.is_err()
{
let wants_json = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|s| s.contains("application/json"));
if wants_json {
return step_up::__step_up_json_response(max_age_secs);
}
let path = req
.headers()
.get(header::REFERER)
.and_then(|v| v.to_str().ok())
.and_then(step_up::referer_path)
.unwrap_or_else(|| {
req.uri()
.path_and_query()
.map_or_else(|| req.uri().path(), axum::http::uri::PathAndQuery::as_str)
.to_owned()
});
let encoded = step_up::encode_return_to(&path);
return Redirect::to(&format!("/reauth?return_to={encoded}")).into_response();
}
next.run(req).await
}
#[cfg(test)]
mod tests {
use super::*;
use autumn_web::session::Session;
use axum::Router;
use axum::body::Body;
use axum::http::StatusCode;
use axum::middleware::from_fn;
use axum::routing::get;
use std::collections::HashMap;
use tower::ServiceExt;
fn app_with_role_gate(session: Session) -> Router {
app_with_role_gate_and_key(session, "user_id")
}
fn app_with_role_gate_and_key(session: Session, auth_session_key: &'static str) -> Router {
async fn ok() -> &'static str {
"ok"
}
let role = "admin".to_owned();
let key = auth_session_key.to_owned();
Router::new()
.route("/", get(ok))
.layer(from_fn(move |mut req: Request, next: Next| {
let session = session.clone();
let role = role.clone();
let key = key.clone();
async move {
req.extensions_mut().insert(session);
check_role(role, key, req, next).await
}
}))
}
#[tokio::test]
async fn no_session_returns_401() {
let session = Session::new_for_test("sid".into(), HashMap::new());
let app = app_with_role_gate(session);
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn wrong_role_returns_403() {
let session = Session::new_for_test(
"sid".into(),
HashMap::from([
("user_id".into(), "1".into()),
("role".into(), "viewer".into()),
]),
);
let app = app_with_role_gate(session);
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn correct_role_passes_through() {
let session = Session::new_for_test(
"sid".into(),
HashMap::from([
("user_id".into(), "1".into()),
("role".into(), "admin".into()),
]),
);
let app = app_with_role_gate(session);
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn custom_auth_session_key_is_honored() {
let with_uid = Session::new_for_test(
"sid".into(),
HashMap::from([("uid".into(), "42".into()), ("role".into(), "admin".into())]),
);
let app = app_with_role_gate_and_key(with_uid, "uid");
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"uid-keyed session should pass"
);
let with_user_id = Session::new_for_test(
"sid".into(),
HashMap::from([
("user_id".into(), "42".into()),
("role".into(), "admin".into()),
]),
);
let app = app_with_role_gate_and_key(with_user_id, "uid");
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"wrong-key session must NOT authenticate"
);
}
#[tokio::test]
async fn missing_session_extension_returns_500() {
async fn ok() -> &'static str {
"ok"
}
let role = "admin".to_owned();
let key = "user_id".to_owned();
let app =
Router::new()
.route("/", get(ok))
.layer(from_fn(move |req: Request, next: Next| {
let role = role.clone();
let key = key.clone();
async move { check_role(role, key, req, next).await }
}));
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
fn step_up_app(session: Session) -> Router {
step_up_app_with_max_age(session, autumn_web::step_up::DEFAULT_MAX_AGE_SECS)
}
fn step_up_app_with_max_age(session: Session, max_age_secs: u64) -> Router {
async fn ok() -> &'static str {
"ok"
}
Router::new()
.route("/resource", get(ok).post(ok).delete(ok))
.layer(from_fn(move |mut req: Request, next: Next| {
let session = session.clone();
async move {
req.extensions_mut().insert(session);
check_step_up_mutations(max_age_secs, req, next).await
}
}))
}
#[tokio::test]
async fn step_up_allows_get_without_claim() {
let session = Session::new_for_test("sid".into(), HashMap::new());
let app = step_up_app(session);
let res = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/resource")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"GET should pass without step-up"
);
}
#[tokio::test]
async fn step_up_blocks_post_without_claim_html_client() {
let session = Session::new_for_test("sid".into(), HashMap::new());
let app = step_up_app(session);
let res = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/resource")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
res.status().is_redirection(),
"POST without step-up should redirect HTML client: {}",
res.status()
);
let location = res.headers().get("location").unwrap().to_str().unwrap();
assert!(
location.contains("/reauth"),
"redirect should go to /reauth: {location}"
);
}
#[tokio::test]
async fn step_up_blocks_post_without_claim_json_client() {
let session = Session::new_for_test("sid".into(), HashMap::new());
let app = step_up_app(session);
let res = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/resource")
.header("Accept", "application/json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"JSON POST without step-up should return 401"
);
let www_auth = res
.headers()
.get("www-authenticate")
.unwrap()
.to_str()
.unwrap();
assert!(
www_auth.contains("StepUp"),
"should include WWW-Authenticate: StepUp header: {www_auth}"
);
}
#[tokio::test]
async fn step_up_allows_post_with_fresh_claim() {
use autumn_web::step_up::STEP_UP_SESSION_KEY;
let now_ts = chrono::Utc::now().timestamp().to_string();
let session = Session::new_for_test(
"sid".into(),
HashMap::from([(STEP_UP_SESSION_KEY.to_string(), now_ts)]),
);
let app = step_up_app(session);
let res = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/resource")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"POST with fresh step-up claim should pass"
);
}
#[tokio::test]
async fn step_up_blocks_delete_without_claim() {
let session = Session::new_for_test("sid".into(), HashMap::new());
let app = step_up_app(session);
let res = app
.oneshot(
axum::http::Request::builder()
.method("DELETE")
.uri("/resource")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
res.status().is_redirection(),
"DELETE without step-up should redirect: {}",
res.status()
);
}
#[tokio::test]
async fn step_up_uses_referer_as_return_to_for_post() {
let session = Session::new_for_test("sid".into(), HashMap::new());
let app = step_up_app(session);
let res = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/resource")
.header("Referer", "https://example.com/admin/users")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(res.status().is_redirection(), "should redirect");
let location = res.headers().get("location").unwrap().to_str().unwrap();
assert!(
location.contains("/admin/users"),
"return_to should use Referer path, not POST URI: {location}"
);
}
#[tokio::test]
async fn step_up_custom_max_age_is_honored() {
use autumn_web::step_up::STEP_UP_SESSION_KEY;
let stale_ts = (chrono::Utc::now() - chrono::Duration::seconds(10))
.timestamp()
.to_string();
let session = Session::new_for_test(
"sid".into(),
HashMap::from([(STEP_UP_SESSION_KEY.to_string(), stale_ts)]),
);
let app = step_up_app_with_max_age(session, 5);
let res = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/resource")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
res.status().is_redirection(),
"10-second-old claim should be blocked by max_age=5s"
);
}
}