use axum::{
extract::{Request, State},
http::{StatusCode, header},
middleware::Next,
response::{IntoResponse, Redirect, Response},
};
use std::{fmt::Display, sync::Arc};
use surrealdb::Connection;
use super::{AuthSession, AuthUser, PublicRoutes, is_public_endpoint};
pub const UNAUTHORIZED_BODY: &str =
r#"{"message":"Your session has expired. Please sign in again.","code":401}"#;
pub fn is_document_navigation(request: &Request) -> bool {
if let Some(destination) = request
.headers()
.get("sec-fetch-dest")
.and_then(|value| value.to_str().ok())
{
return destination == "document";
}
!request.uri().path().starts_with("/api/")
}
pub fn is_static_asset(path: &str) -> bool {
path.starts_with("/wasm/") || path.starts_with("/assets/") || path == "/favicon.ico"
}
pub fn unauthenticated_response(request: &Request, splash: &str) -> Response {
if is_document_navigation(request) {
return Redirect::to(splash).into_response();
}
(
StatusCode::UNAUTHORIZED,
[(header::CONTENT_TYPE, "application/json")],
UNAUTHORIZED_BODY,
)
.into_response()
}
#[derive(Clone, Debug)]
pub struct AuthGuard {
splash: Arc<str>,
public_page: fn(&str) -> bool,
}
impl AuthGuard {
pub fn for_routes<R: PublicRoutes + Display>(splash: R) -> Self {
Self::new(splash.to_string(), R::is_public_path)
}
pub fn new(splash: impl Into<String>, public_page: fn(&str) -> bool) -> Self {
let splash: Arc<str> = splash.into().into();
let guard = Self {
splash,
public_page,
};
assert!(
guard.allows_signed_out(guard.splash_path()),
"the splash `{}` must be a public page: the guard sends every signed-out page load \
there, so guarding it makes that redirect loop forever. Mark it `#[public]`.",
guard.splash,
);
guard
}
pub fn splash(&self) -> &str {
&self.splash
}
fn splash_path(&self) -> &str {
self.splash.split(['?', '#']).next().unwrap_or_default()
}
pub fn allows_signed_out(&self, path: &str) -> bool {
is_static_asset(path) || (self.public_page)(path) || is_public_endpoint(path)
}
}
pub async fn require_session<U: AuthUser, C: Connection>(
State(guard): State<AuthGuard>,
request: Request,
next: Next,
) -> Response {
if guard.allows_signed_out(request.uri().path()) {
return next.run(request).await;
}
let is_authenticated = request
.extensions()
.get::<AuthSession<U, C>>()
.is_some_and(AuthSession::is_authenticated);
if is_authenticated {
next.run(request).await
} else {
unauthenticated_response(&request, guard.splash())
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
fn request_with(path: &str, headers: &[(&str, &str)]) -> Request {
let mut builder = Request::builder().uri(path);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
builder.body(Body::empty()).expect("valid test request")
}
#[test]
fn browser_navigations_are_documents() {
assert!(is_document_navigation(&request_with(
"/profile",
&[("sec-fetch-dest", "document"), ("accept", "text/html")]
)));
}
#[test]
fn server_function_calls_are_not_documents() {
assert!(!is_document_navigation(&request_with(
"/api/v1/get_bookmarks",
&[("sec-fetch-dest", "empty"), ("accept", "text/html")]
)));
}
#[test]
fn falls_back_to_the_path_without_fetch_metadata() {
assert!(!is_document_navigation(&request_with(
"/api/v1/get_bookmarks",
&[]
)));
assert!(is_document_navigation(&request_with("/", &[])));
assert!(is_document_navigation(&request_with("/profile", &[])));
}
#[test]
fn signed_out_responses() {
let page = unauthenticated_response(&request_with("/profile", &[]), "/");
assert!(page.status().is_redirection());
assert_eq!(page.headers()[header::LOCATION], "/");
let fetch = unauthenticated_response(&request_with("/api/v1/x", &[]), "/");
assert_eq!(fetch.status(), StatusCode::UNAUTHORIZED);
assert_eq!(fetch.headers()[header::CONTENT_TYPE], "application/json");
}
#[test]
fn the_guard_lets_through_assets_and_named_pages_only() {
fn is_public_page(path: &str) -> bool {
matches!(path, "/" | "/signin")
}
let guard = AuthGuard::new("/", is_public_page);
assert!(guard.allows_signed_out("/"));
assert!(guard.allows_signed_out("/signin"));
assert!(guard.allows_signed_out("/wasm/app_bg.wasm"));
assert!(guard.allows_signed_out("/assets/logo.svg"));
assert!(guard.allows_signed_out("/favicon.ico"));
assert!(!guard.allows_signed_out("/profile"));
assert!(!guard.allows_signed_out("/api/v1/get_bookmarks"));
}
#[test]
#[should_panic(expected = "must be a public page")]
fn a_guarded_splash_is_refused() {
fn only_signin(path: &str) -> bool {
path == "/signin"
}
AuthGuard::new("/", only_signin);
}
}