use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
pub const PROTECTED_PREFIX: &str = "/api/v1/";
#[derive(Debug, Clone, Default)]
pub struct AuthState {
token: Option<Arc<String>>,
}
impl AuthState {
pub fn from_token(t: impl Into<String>) -> Self {
let s = t.into();
if s.is_empty() {
AuthState { token: None }
} else {
AuthState {
token: Some(Arc::new(s)),
}
}
}
pub fn from_env() -> Self {
match std::env::var(API_TOKEN_ENV) {
Ok(s) if !s.is_empty() => AuthState::from_token(s),
_ => AuthState::default(),
}
}
pub fn is_enabled(&self) -> bool {
self.token.is_some()
}
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
pub async fn require_bearer(
State(auth): State<AuthState>,
request: Request,
next: Next,
) -> Response {
let Some(expected) = auth.token.clone() else {
return next.run(request).await;
};
if !request.uri().path().starts_with(PROTECTED_PREFIX) {
return next.run(request).await;
}
let supplied = request
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| {
let (scheme, token) = s.split_once(' ')?;
scheme
.eq_ignore_ascii_case("Bearer")
.then(|| token.trim_start())
});
let ok = supplied
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
if ok {
next.run(request).await
} else {
(
StatusCode::UNAUTHORIZED,
"missing or invalid bearer token (set Authorization: Bearer <RUVIEW_API_TOKEN>)\n",
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use tower::ServiceExt;
fn ok_handler() -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/api/v1/info", get(|| async { "ok" }))
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
.route("/ui/index.html", get(|| async { "<html/>" }))
}
fn wrap(auth: AuthState) -> Router {
ok_handler().layer(axum::middleware::from_fn_with_state(auth, require_bearer))
}
async fn status(router: Router, method: &str, path: &str, auth: Option<&str>) -> StatusCode {
let mut req = Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.unwrap();
if let Some(t) = auth {
req.headers_mut()
.insert(AUTHORIZATION, format!("Bearer {t}").parse().unwrap());
}
router.oneshot(req).await.unwrap().status()
}
#[tokio::test]
async fn middleware_is_no_op_when_token_unset() {
let r = wrap(AuthState::default());
assert_eq!(
status(r.clone(), "GET", "/api/v1/info", None).await,
StatusCode::OK
);
assert_eq!(
status(r.clone(), "POST", "/api/v1/sensitive", None).await,
StatusCode::OK
);
assert_eq!(
status(r.clone(), "GET", "/health", None).await,
StatusCode::OK
);
assert_eq!(
status(r, "GET", "/ui/index.html", None).await,
StatusCode::OK
);
}
#[tokio::test]
async fn enabled_blocks_api_without_bearer() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/api/v1/info", None).await,
StatusCode::UNAUTHORIZED
);
assert_eq!(
status(r, "POST", "/api/v1/sensitive", None).await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn accepts_case_insensitive_bearer_scheme() {
async fn req_status(auth_value: &str) -> StatusCode {
let r = wrap(AuthState::from_token("s3cr3t"));
let mut req = Request::builder()
.method("GET")
.uri("/api/v1/info")
.body(Body::empty())
.unwrap();
req.headers_mut()
.insert(AUTHORIZATION, auth_value.parse().unwrap());
r.oneshot(req).await.unwrap().status()
}
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("bearer s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("BEARER s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK); assert_eq!(req_status("bearer nope").await, StatusCode::UNAUTHORIZED);
assert_eq!(req_status("Basic s3cr3t").await, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn enabled_blocks_api_with_wrong_bearer() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/api/v1/info", Some("nope")).await,
StatusCode::UNAUTHORIZED
);
let mut req = Request::builder()
.method("GET")
.uri("/api/v1/info")
.body(Body::empty())
.unwrap();
req.headers_mut()
.insert(AUTHORIZATION, "Basic s3cr3t".parse().unwrap());
assert_eq!(
r.oneshot(req).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn enabled_allows_api_with_correct_bearer() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/api/v1/info", Some("s3cr3t")).await,
StatusCode::OK
);
assert_eq!(
status(r, "POST", "/api/v1/sensitive", Some("s3cr3t")).await,
StatusCode::OK
);
}
#[tokio::test]
async fn enabled_never_gates_paths_outside_api_v1() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/health", None).await,
StatusCode::OK
);
assert_eq!(
status(r, "GET", "/ui/index.html", None).await,
StatusCode::OK
);
}
#[test]
fn ct_eq_basics() {
assert!(ct_eq(b"abc", b"abc"));
assert!(!ct_eq(b"abc", b"abd"));
assert!(!ct_eq(b"abc", b"ab")); assert!(!ct_eq(b"", b"x"));
assert!(ct_eq(b"", b""));
}
#[test]
fn from_env_treats_empty_as_disabled() {
assert!(!AuthState::from_token("").is_enabled());
assert!(AuthState::from_token("x").is_enabled());
}
#[test]
fn protected_prefix_and_env_constants_are_stable() {
assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN");
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
}
}