//! Auth extractors for API handlers //! //! Provides the `CurrentUser` extractor that validates JWT Bearer tokens. use axum::{extract::FromRequestParts, http::request::Parts}; use domain::User; use crate::error::ApiError; use crate::state::AppState; /// Extracted current user from the request. /// /// Validates a JWT Bearer token from the `Authorization` header. pub struct CurrentUser(pub User); impl FromRequestParts for CurrentUser { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { #[cfg(feature = "auth-jwt")] { return match try_jwt_auth(parts, state).await { Ok(user) => Ok(CurrentUser(user)), Err(e) => Err(e), }; } #[cfg(not(feature = "auth-jwt"))] { let _ = (parts, state); Err(ApiError::Unauthorized( "No authentication backend configured".to_string(), )) } } } /// Optional current user — returns None instead of error when auth is missing/invalid. /// /// Checks `Authorization: Bearer ` first; falls back to `?token=` query param /// so IPTV clients and direct stream links work without custom headers. pub struct OptionalCurrentUser(pub Option); impl FromRequestParts for OptionalCurrentUser { type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { #[cfg(feature = "auth-jwt")] { // Try Authorization header first if let Ok(user) = try_jwt_auth(parts, state).await { return Ok(OptionalCurrentUser(Some(user))); } // Fall back to ?token= query param let query_token = parts.uri.query().and_then(|q| { q.split('&') .find(|seg| seg.starts_with("token=")) .map(|seg| seg[6..].to_owned()) }); if let Some(token) = query_token { let user = validate_jwt_token(&token, state).await.ok(); return Ok(OptionalCurrentUser(user)); } return Ok(OptionalCurrentUser(None)); } #[cfg(not(feature = "auth-jwt"))] { let _ = (parts, state); Ok(OptionalCurrentUser(None)) } } } /// Extracted admin user — returns 403 if user is not an admin. pub struct AdminUser(pub User); impl FromRequestParts for AdminUser { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?; if !user.is_admin { return Err(ApiError::Forbidden("Admin access required".to_string())); } Ok(AdminUser(user)) } } /// Authenticate using JWT Bearer token from the `Authorization` header. #[cfg(feature = "auth-jwt")] async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result { use axum::http::header::AUTHORIZATION; let auth_header = parts .headers .get(AUTHORIZATION) .ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?; let auth_str = auth_header .to_str() .map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?; let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| { ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string()) })?; validate_jwt_token(token, state).await } /// Validate a raw JWT string and return the corresponding `User`. #[cfg(feature = "auth-jwt")] pub(crate) async fn validate_jwt_token(token: &str, state: &AppState) -> Result { let validator = state .jwt_validator .as_ref() .ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?; let claims = validator.validate_token(token).map_err(|e| { tracing::debug!("JWT validation failed: {:?}", e); match e { infra::auth::jwt::JwtError::Expired => { ApiError::Unauthorized("Token expired".to_string()) } infra::auth::jwt::JwtError::InvalidFormat => { ApiError::Unauthorized("Invalid token format".to_string()) } _ => ApiError::Unauthorized("Token validation failed".to_string()), } })?; let user_id: uuid::Uuid = claims .sub .parse() .map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?; let user = state .user_service .find_by_id(user_id) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?; Ok(user) }