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    /// The user's tenant ID (M18). Present when multi-tenancy is enabled.
30    pub tenant_id: Option<String>,
31}
32
33impl FromRequest for AuthenticatedUser {
34    type Error = ShaperailError;
35    type Future = Ready<Result<Self, Self::Error>>;
36
37    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
38        ready(extract_auth(req))
39    }
40}
41
42/// Attempts to extract an authenticated user from the request.
43///
44/// Checks in order:
45/// 1. `Authorization: Bearer <jwt>` header
46/// 2. `X-API-Key: <key>` header
47fn extract_auth(req: &HttpRequest) -> Result<AuthenticatedUser, ShaperailError> {
48    // Try JWT first
49    if let Some(auth_header) = req.headers().get("Authorization") {
50        let header_str = auth_header
51            .to_str()
52            .map_err(|_| ShaperailError::Unauthorized)?;
53        if let Some(token) = header_str.strip_prefix("Bearer ") {
54            let jwt_config = req
55                .app_data::<web::Data<Arc<JwtConfig>>>()
56                .ok_or(ShaperailError::Internal("JWT not configured".to_string()))?;
57            let claims = jwt_config
58                .decode(token)
59                .map_err(|_| ShaperailError::Unauthorized)?;
60            if claims.token_type != "access" {
61                return Err(ShaperailError::Unauthorized);
62            }
63            return Ok(AuthenticatedUser {
64                id: claims.sub,
65                role: claims.role,
66                tenant_id: claims.tenant_id,
67            });
68        }
69    }
70
71    // Try API key
72    if let Some(api_key_header) = req.headers().get("X-API-Key") {
73        let key = api_key_header
74            .to_str()
75            .map_err(|_| ShaperailError::Unauthorized)?;
76        let store = req
77            .app_data::<web::Data<Arc<ApiKeyStore>>>()
78            .ok_or(ShaperailError::Unauthorized)?;
79        if let Some(user) = store.lookup(key) {
80            return Ok(user);
81        }
82        return Err(ShaperailError::Unauthorized);
83    }
84
85    Err(ShaperailError::Unauthorized)
86}
87
88/// Optionally extracts auth — returns `None` for public endpoints instead of 401.
89pub fn try_extract_auth(req: &HttpRequest) -> Option<AuthenticatedUser> {
90    extract_auth(req).ok()
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn authenticated_user_debug() {
99        let user = AuthenticatedUser {
100            id: "u1".to_string(),
101            role: "admin".to_string(),
102            tenant_id: None,
103        };
104        assert_eq!(user.id, "u1");
105        assert_eq!(user.role, "admin");
106        // Clone works
107        let cloned = user.clone();
108        assert_eq!(cloned.id, "u1");
109    }
110}