Skip to main content

shaperail_runtime/auth/
extractor.rs

1use std::future::{ready, Ready};
2use std::sync::Arc;
3
4use actix_web::dev::Payload;
5use actix_web::{web, FromRequest, HttpRequest};
6use shaperail_core::ShaperailError;
7
8use super::api_key::ApiKeyStore;
9use super::jwt::JwtConfig;
10
11/// Authenticated user extracted from a valid JWT Bearer token or API key.
12///
13/// Use as an Actix-web extractor in handler signatures:
14/// ```no_run
15/// use actix_web::Responder;
16/// use shaperail_runtime::auth::AuthenticatedUser;
17///
18/// async fn handler(user: AuthenticatedUser) -> impl Responder {
19///     format!("Hello, user {}", user.id)
20/// }
21/// ```
22/// Returns 401 if no valid JWT or API key is present.
23#[derive(Debug, Clone)]
24pub struct AuthenticatedUser {
25    /// The user's unique ID (from JWT `sub` claim or API key mapping).
26    pub id: String,
27    /// The user's role (from JWT `role` claim or API key mapping).
28    pub role: String,
29}
30
31impl FromRequest for AuthenticatedUser {
32    type Error = ShaperailError;
33    type Future = Ready<Result<Self, Self::Error>>;
34
35    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
36        ready(extract_auth(req))
37    }
38}
39
40/// Attempts to extract an authenticated user from the request.
41///
42/// Checks in order:
43/// 1. `Authorization: Bearer <jwt>` header
44/// 2. `X-API-Key: <key>` header
45fn extract_auth(req: &HttpRequest) -> Result<AuthenticatedUser, ShaperailError> {
46    // Try JWT first
47    if let Some(auth_header) = req.headers().get("Authorization") {
48        let header_str = auth_header
49            .to_str()
50            .map_err(|_| ShaperailError::Unauthorized)?;
51        if let Some(token) = header_str.strip_prefix("Bearer ") {
52            let jwt_config = req
53                .app_data::<web::Data<Arc<JwtConfig>>>()
54                .ok_or(ShaperailError::Internal("JWT not configured".to_string()))?;
55            let claims = jwt_config
56                .decode(token)
57                .map_err(|_| ShaperailError::Unauthorized)?;
58            if claims.token_type != "access" {
59                return Err(ShaperailError::Unauthorized);
60            }
61            return Ok(AuthenticatedUser {
62                id: claims.sub,
63                role: claims.role,
64            });
65        }
66    }
67
68    // Try API key
69    if let Some(api_key_header) = req.headers().get("X-API-Key") {
70        let key = api_key_header
71            .to_str()
72            .map_err(|_| ShaperailError::Unauthorized)?;
73        let store = req
74            .app_data::<web::Data<Arc<ApiKeyStore>>>()
75            .ok_or(ShaperailError::Unauthorized)?;
76        if let Some(user) = store.lookup(key) {
77            return Ok(user);
78        }
79        return Err(ShaperailError::Unauthorized);
80    }
81
82    Err(ShaperailError::Unauthorized)
83}
84
85/// Optionally extracts auth — returns `None` for public endpoints instead of 401.
86pub fn try_extract_auth(req: &HttpRequest) -> Option<AuthenticatedUser> {
87    extract_auth(req).ok()
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn authenticated_user_debug() {
96        let user = AuthenticatedUser {
97            id: "u1".to_string(),
98            role: "admin".to_string(),
99        };
100        assert_eq!(user.id, "u1");
101        assert_eq!(user.role, "admin");
102        // Clone works
103        let cloned = user.clone();
104        assert_eq!(cloned.id, "u1");
105    }
106}