use std::sync::Arc;
use axum::{
Json,
extract::{Request, State},
http::{HeaderMap, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use paladin_core::platform::container::user::UserRole;
use paladin_ports::output::auth_port::{AuthClaims, AuthPort};
use serde_json::json;
use uuid::Uuid;
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized", "code": "UNAUTHORIZED" })),
)
.into_response()
}
fn forbidden() -> Response {
(
StatusCode::FORBIDDEN,
Json(json!({ "error": "Forbidden", "code": "FORBIDDEN" })),
)
.into_response()
}
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)?
.to_str()
.ok()?
.strip_prefix("Bearer ")
.map(str::trim)
.filter(|token| !token.is_empty())
}
async fn authenticate(auth: &dyn AuthPort, headers: &HeaderMap) -> Result<AuthClaims, Response> {
let token = bearer_token(headers).ok_or_else(unauthorized)?;
auth.verify_token(token).await.map_err(|_| unauthorized())
}
#[allow(clippy::result_large_err)]
fn check_admin(claims: Option<&AuthClaims>) -> Result<(), Response> {
match claims {
Some(claims) if claims.role == UserRole::Admin => Ok(()),
Some(_) => Err(forbidden()),
None => Err(unauthorized()),
}
}
#[allow(clippy::result_large_err)]
pub fn authorize_self_or_admin(claims: &AuthClaims, target: Uuid) -> Result<(), Response> {
if claims.role == UserRole::Admin || claims.user_id == target {
Ok(())
} else {
Err(forbidden())
}
}
pub async fn require_auth(
State(auth): State<Arc<dyn AuthPort>>,
mut request: Request,
next: Next,
) -> Response {
match authenticate(auth.as_ref(), request.headers()).await {
Ok(claims) => {
request.extensions_mut().insert(claims);
next.run(request).await
}
Err(response) => response,
}
}
pub async fn require_admin(request: Request, next: Next) -> Response {
match check_admin(request.extensions().get::<AuthClaims>()) {
Ok(()) => next.run(request).await,
Err(response) => response,
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use chrono::{Duration, Utc};
use paladin_ports::output::auth_port::{AuthError, AuthToken};
struct MockAuthPort {
claims: Option<AuthClaims>,
}
#[async_trait]
impl AuthPort for MockAuthPort {
async fn issue_token(
&self,
_user_id: Uuid,
_role: UserRole,
) -> Result<AuthToken, AuthError> {
Err(AuthError::Internal("not used".to_string()))
}
async fn verify_token(&self, _token: &str) -> Result<AuthClaims, AuthError> {
self.claims.clone().ok_or(AuthError::InvalidToken)
}
async fn revoke_token(&self, _token: &str) -> Result<(), AuthError> {
Ok(())
}
}
fn headers_with(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(header::AUTHORIZATION, value.parse().unwrap());
headers
}
fn claims(role: UserRole, user_id: Uuid) -> AuthClaims {
AuthClaims {
user_id,
role,
expires_at: Utc::now() + Duration::hours(1),
}
}
#[test]
fn bearer_token_parses_valid_header() {
let headers = headers_with("Bearer abc123");
assert_eq!(bearer_token(&headers), Some("abc123"));
}
#[test]
fn bearer_token_rejects_missing_and_empty() {
assert_eq!(bearer_token(&HeaderMap::new()), None);
assert_eq!(bearer_token(&headers_with("Bearer ")), None);
assert_eq!(bearer_token(&headers_with("Basic abc")), None);
}
#[tokio::test]
async fn authenticate_missing_token_is_unauthorized() {
let auth = MockAuthPort { claims: None };
let response = authenticate(&auth, &HeaderMap::new()).await.unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn authenticate_invalid_token_is_unauthorized() {
let auth = MockAuthPort { claims: None };
let response = authenticate(&auth, &headers_with("Bearer bad"))
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn authenticate_valid_token_returns_claims() {
let expected = claims(UserRole::User, Uuid::new_v4());
let auth = MockAuthPort {
claims: Some(expected.clone()),
};
let result = authenticate(&auth, &headers_with("Bearer good"))
.await
.unwrap();
assert_eq!(result, expected);
}
#[test]
fn check_admin_allows_admin() {
let claims = claims(UserRole::Admin, Uuid::new_v4());
assert!(check_admin(Some(&claims)).is_ok());
}
#[test]
fn check_admin_forbids_non_admin() {
let claims = claims(UserRole::User, Uuid::new_v4());
let response = check_admin(Some(&claims)).unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[test]
fn check_admin_unauthorized_without_claims() {
let response = check_admin(None).unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn self_scope_allows_admin_for_any_target() {
let claims = claims(UserRole::Admin, Uuid::new_v4());
assert!(authorize_self_or_admin(&claims, Uuid::new_v4()).is_ok());
}
#[test]
fn self_scope_allows_user_for_own_target() {
let id = Uuid::new_v4();
let claims = claims(UserRole::User, id);
assert!(authorize_self_or_admin(&claims, id).is_ok());
}
#[test]
fn self_scope_forbids_user_for_other_target() {
let claims = claims(UserRole::User, Uuid::new_v4());
let response = authorize_self_or_admin(&claims, Uuid::new_v4()).unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
}