webx_core/auth.rs
1//! Authentication and authorization traits for the rust-webx framework.
2//!
3//! Provides:
4//! - `IClaims`: Extracted auth claims (JWT or other token types).
5//! - `IAuthenticationHandler`: Authenticate an HTTP request and produce claims.
6//! - `IAuthorizationPolicy`: Check if authenticated claims can access a resource.
7
8use crate::error::{Error, Result};
9use crate::http::IHttpContext;
10use std::collections::HashMap;
11
12/// Claims extracted from an authentication token (JWT, etc.).
13///
14/// Stored in `IHttpContext` extensions via `IClaimsExt`.
15pub trait IClaims: Send + Sync {
16 /// The user / subject identifier.
17 fn subject(&self) -> &str;
18
19 /// Roles assigned to the user.
20 fn roles(&self) -> &[String];
21
22 /// Permissions assigned to the user.
23 fn permissions(&self) -> &[String];
24
25 /// Raw claims map (key-value pairs from the token).
26 fn claims(&self) -> &HashMap<String, String>;
27
28 /// Clone the claims into a new boxed trait object.
29 fn clone_box(&self) -> Box<dyn IClaims>;
30
31 // ── Convenience helpers (default implementations) ──
32
33 /// Check whether a specific role is assigned.
34 ///
35 /// ```ignore
36 /// if claims.has_role("admin") { ... }
37 /// ```
38 fn has_role(&self, role: &str) -> bool {
39 self.roles().iter().any(|r| r == role)
40 }
41
42 /// Alias for `subject()` — returns the user identifier.
43 fn get_userid(&self) -> &str {
44 self.subject()
45 }
46
47 /// Read the display name from the raw claims map (key `"name"`).
48 /// Returns `None` when absent.
49 fn get_username(&self) -> Option<&str> {
50 self.claims().get("name").map(|s| s.as_str())
51 }
52
53 /// Read the tenant identifier from the raw claims map (key `"tenant_id"`).
54 /// Returns `None` when absent.
55 fn get_tenantid(&self) -> Option<&str> {
56 self.claims()
57 .get("tenant_id")
58 .or_else(|| self.claims().get("tenant"))
59 .map(|s| s.as_str())
60 }
61
62 /// Read the email address from the raw claims map (key `"email"`).
63 /// Returns `None` when absent.
64 fn get_email(&self) -> Option<&str> {
65 self.claims().get("email").map(|s| s.as_str())
66 }
67}
68
69impl Clone for Box<dyn IClaims> {
70 fn clone(&self) -> Self {
71 self.clone_box()
72 }
73}
74
75/// Authentication scheme interface.
76///
77/// Implementations read credentials from the HTTP context (e.g., JWT bearer token,
78/// API key header, cookie) and return claims or `None` if unauthenticated.
79#[async_trait::async_trait]
80pub trait IAuthenticationHandler: Send + Sync {
81 /// Authenticate the request and return claims, or `None` if not authenticated.
82 ///
83 /// Uses `&mut dyn IHttpContext` so the returned future is `Send`
84 /// (required by `tokio::spawn` — `&dyn IHttpContext` is `!Send`
85 /// because `IHttpContext` is not `Sync`).
86 async fn authenticate(&self, ctx: &mut dyn IHttpContext) -> Result<Option<Box<dyn IClaims>>>;
87}
88
89/// Authorization policy that checks whether an authenticated user
90/// can access a given resource.
91///
92/// The `resource_key` is the original route pattern string (e.g., `"/api/users/{id}"`),
93/// enabling dynamic identity-authorization-data systems to match routes directly.
94#[async_trait::async_trait]
95pub trait IAuthorizationPolicy: Send + Sync {
96 /// Check if the authenticated user can access the resource.
97 ///
98 /// * `claims` — the user's authentication claims.
99 /// * `resource_key` — the original route pattern string.
100 /// * `method` — the HTTP method.
101 ///
102 /// Returns `Ok(())` if authorized, or an `Err` if forbidden.
103 async fn authorize(&self, claims: &dyn IClaims, resource_key: &str, method: &str)
104 -> Result<()>;
105}
106
107/// Dynamic authorizer interface — pluggable authorization for protected routes.
108///
109/// Implement this trait and register it in the DI container:
110///
111/// ```ignore
112/// svc.singleton::<dyn IDynamicAuthorizer>(|_| Arc::new(MyAuthorizer::default()))
113/// ```
114///
115/// The framework automatically detects registered `IDynamicAuthorizer` implementations
116/// and invokes them on every route that has `#[authorize]`.
117/// If no implementations are registered, authorization is pass-through (no dynamic checks).
118///
119/// # Method
120///
121/// * `authorize` — receives the user's claims, matched route pattern, and HTTP method.
122/// Returns `Ok(())` if allowed, or `Err` with details if denied.
123#[async_trait::async_trait]
124pub trait IDynamicAuthorizer: Send + Sync {
125 /// Validate whether the authenticated user can access the resource.
126 async fn authorize(
127 &self,
128 claims: &dyn IClaims,
129 route_pattern: &str,
130 method: &str,
131 ) -> Result<()>;
132}
133
134/// The standard identity a request can carry.
135///
136/// Put it in a request with `#[fromclaim]` and the binder fills it from the
137/// authenticated principal:
138///
139/// ```ignore
140/// #[derive(WebxRequestMeta)]
141/// pub struct CreateOrderRequest {
142/// #[fromclaim]
143/// pub user: CurrentUser,
144/// pub sku: String,
145/// }
146/// ```
147///
148/// The handler then reads `req.user.id`, `req.user.tenant_id` or
149/// `req.user.is_in_role("admin")` without touching the token or the context.
150/// `Option<CurrentUser>` makes the endpoint work for anonymous callers too.
151#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
152pub struct CurrentUser {
153 /// The subject (`sub`) — the stable user identifier.
154 pub id: String,
155 /// Display name (`name` claim), empty when the token has none.
156 pub name: String,
157 /// Email address (`email` claim), empty when the token has none.
158 pub email: String,
159 /// Tenant identifier (`tenant_id` / `tenant`), empty for single-tenant apps.
160 pub tenant_id: String,
161 /// Roles assigned to the user.
162 pub roles: Vec<String>,
163 /// Fine-grained permissions assigned to the user.
164 pub permissions: Vec<String>,
165}
166
167impl CurrentUser {
168 /// Build the identity from an authenticated principal.
169 pub fn from_claims(claims: &dyn IClaims) -> Self {
170 Self {
171 id: claims.subject().to_string(),
172 name: claims.get_username().unwrap_or_default().to_string(),
173 email: claims.get_email().unwrap_or_default().to_string(),
174 tenant_id: claims.get_tenantid().unwrap_or_default().to_string(),
175 roles: claims.roles().to_vec(),
176 permissions: claims.permissions().to_vec(),
177 }
178 }
179
180 /// Whether the user has `role`.
181 pub fn is_in_role(&self, role: &str) -> bool {
182 self.roles.iter().any(|assigned| assigned == role)
183 }
184
185 /// Whether the user has `permission`.
186 pub fn has_permission(&self, permission: &str) -> bool {
187 self.permissions
188 .iter()
189 .any(|assigned| assigned == permission)
190 }
191
192 /// The display name, falling back to the identifier.
193 pub fn display_name(&self) -> &str {
194 if self.name.is_empty() {
195 &self.id
196 } else {
197 &self.name
198 }
199 }
200}
201
202/// Convenience accessors for a request that carries a [`CurrentUser`].
203///
204/// `#[derive(WebxRequestMeta)]` implements this automatically when the struct
205/// has a `#[fromclaim]` field of type `CurrentUser` or `Option<CurrentUser>`.
206pub trait RequestUser {
207 /// The authenticated identity, or `None` for an anonymous request.
208 fn current_user(&self) -> Option<&CurrentUser>;
209
210 /// The subject identifier, or `None` when anonymous.
211 fn user_id(&self) -> Option<&str> {
212 self.current_user().map(|user| user.id.as_str())
213 }
214
215 /// The display name, or `None` when anonymous.
216 fn user_name(&self) -> Option<&str> {
217 self.current_user().map(|user| user.name.as_str())
218 }
219
220 /// The email address, or `None` when anonymous.
221 fn user_email(&self) -> Option<&str> {
222 self.current_user().map(|user| user.email.as_str())
223 }
224
225 /// The tenant identifier, or `None` when anonymous.
226 fn tenant_id(&self) -> Option<&str> {
227 self.current_user().map(|user| user.tenant_id.as_str())
228 }
229
230 /// The roles of the authenticated user (empty when anonymous).
231 fn roles(&self) -> &[String] {
232 match self.current_user() {
233 Some(user) => &user.roles,
234 None => &[],
235 }
236 }
237
238 /// Whether the authenticated user has `role`.
239 fn is_in_role(&self, role: &str) -> bool {
240 self.current_user()
241 .map(|user| user.is_in_role(role))
242 .unwrap_or(false)
243 }
244
245 /// The identity, or `401 Unauthorized` when the request is anonymous.
246 fn require_user(&self) -> Result<&CurrentUser> {
247 self.current_user().ok_or_else(|| {
248 Error::Unauthorized("authentication required for this endpoint".to_string())
249 })
250 }
251}