nxtgauge-backend-rust/crates/contracts/src/auth_middleware.rs
2026-03-25 22:15:07 +01:00

151 lines
4.9 KiB
Rust

use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
Json,
};
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
use serde::{Deserialize, Serialize};
use std::future::Future;
use uuid::Uuid;
// ── JWT Claims ────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // user_id (UUID string)
pub email: String,
pub roles: Vec<String>,
pub active_role: String,
pub exp: usize,
pub iat: usize,
}
// ── AuthUser extractor ────────────────────────────────────────────────────────
/// Axum extractor: validates the Bearer token in the Authorization header.
/// Usage: `async fn handler(auth: AuthUser, ...) -> impl IntoResponse`
#[derive(Debug, Clone)]
pub struct AuthUser {
pub user_id: Uuid,
pub email: String,
pub claims: Claims,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = AuthError;
fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
async move {
let auth_header = auth_header.ok_or(AuthError::MissingToken)?;
// 2. Strip "Bearer " prefix
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AuthError::InvalidToken)?;
// 3. Decode & verify
let jwt_secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set — refusing to start with insecure default");
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.map_err(|e| {
tracing::debug!("JWT decode error: {}", e);
AuthError::InvalidToken
})?;
// 4. Parse user_id as UUID
let user_id = Uuid::parse_str(&token_data.claims.sub)
.map_err(|_| AuthError::InvalidToken)?;
Ok(AuthUser {
user_id,
email: token_data.claims.email.clone(),
claims: token_data.claims,
})
}
}
}
// ── Auth Error types ──────────────────────────────────────────────────────────
#[derive(Debug)]
pub enum AuthError {
MissingToken,
InvalidToken,
InsufficientPermissions,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, code, message) = match self {
AuthError::MissingToken => (
StatusCode::UNAUTHORIZED,
"MISSING_TOKEN",
"Authorization header required",
),
AuthError::InvalidToken => (
StatusCode::UNAUTHORIZED,
"INVALID_TOKEN",
"Token is invalid or expired",
),
AuthError::InsufficientPermissions => (
StatusCode::FORBIDDEN,
"INSUFFICIENT_PERMISSIONS",
"You do not have permission to access this resource",
),
};
(
status,
Json(serde_json::json!({ "error": message, "code": code })),
)
.into_response()
}
}
// ── Role guard helper ─────────────────────────────────────────────────────────
/// Returns Ok if the user's active_role matches the expected role key.
/// Otherwise returns AuthError::InsufficientPermissions.
pub fn require_role(auth: &AuthUser, expected_role: &str) -> Result<(), AuthError> {
if auth.claims.active_role == expected_role
|| auth.claims.roles.contains(&expected_role.to_string())
{
Ok(())
} else {
Err(AuthError::InsufficientPermissions)
}
}
/// Returns Ok if the user has an internal admin role (ADMIN or SUPER_ADMIN).
pub fn require_admin(auth: &AuthUser) -> Result<(), AuthError> {
let active = auth.claims.active_role.as_str();
let has_internal_admin =
active == "ADMIN"
|| active == "SUPER_ADMIN"
|| auth.claims.roles.contains(&"ADMIN".to_string())
|| auth.claims.roles.contains(&"SUPER_ADMIN".to_string());
if has_internal_admin {
Ok(())
} else {
Err(AuthError::InsufficientPermissions)
}
}