Skip to main content

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/// `IdentityUser` is a **read-only projection of [`IClaims`]** for request
137/// handling — not a persistence entity. When an endpoint needs the raw claim
138/// map, read it through [`IClaimsExt`](crate::http::IClaimsExt) in middleware or
139/// infrastructure code; `IdentityUser` covers the identity fields almost every
140/// handler needs.
141///
142/// Put it in a request with `#[fromclaim]` and the binder fills it from the
143/// authenticated principal:
144///
145/// ```ignore
146/// #[derive(WebxRequestMeta)]
147/// pub struct CreateOrderRequest {
148///     #[fromclaim]
149///     pub user: IdentityUser,
150///     pub sku: String,
151/// }
152/// ```
153///
154/// The handler then reads `req.user.id`, `req.user.tenant_id` or
155/// `req.user.is_in_role("admin")` without touching the token or the context.
156/// `#[fromclaim(optional)] viewer: Option<IdentityUser>` makes the endpoint work
157/// for anonymous callers too; anonymous access must be declared explicitly.
158#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
159pub struct IdentityUser {
160    /// The subject (`sub`) — the stable user identifier.
161    pub id: String,
162    /// Display name (`name` claim), empty when the token has none.
163    pub name: String,
164    /// Email address (`email` claim), empty when the token has none.
165    pub email: String,
166    /// Tenant identifier (`tenant_id` / `tenant`).
167    ///
168    /// The **empty string means "no tenant claim"** — tokens from single-tenant
169    /// apps and tokens without the claim leave it empty. Use
170    /// [`IdentityUser::tenant`] when the difference between "no tenant claim"
171    /// and "declared empty" matters, or [`IdentityUser::require_tenant`] to
172    /// reject a principal without a tenant.
173    pub tenant_id: String,
174    /// Roles assigned to the user.
175    pub roles: Vec<String>,
176    /// Fine-grained permissions assigned to the user.
177    pub permissions: Vec<String>,
178}
179
180impl IdentityUser {
181    /// Build the identity from an authenticated principal.
182    ///
183    /// Absent `name` / `email` / `tenant_id` claims become empty strings, so an
184    /// empty [`IdentityUser::tenant_id`] means "the principal carries no tenant
185    /// claim" rather than a tenant literally named `""`.
186    pub fn from_claims(claims: &dyn IClaims) -> Self {
187        Self {
188            id: claims.subject().to_string(),
189            name: claims.get_username().unwrap_or_default().to_string(),
190            email: claims.get_email().unwrap_or_default().to_string(),
191            tenant_id: claims.get_tenantid().unwrap_or_default().to_string(),
192            roles: claims.roles().to_vec(),
193            permissions: claims.permissions().to_vec(),
194        }
195    }
196
197    /// The tenant identifier, or `None` when the principal carries no tenant
198    /// claim.
199    ///
200    /// [`IdentityUser::tenant_id`] stores "no claim" as the empty string for
201    /// backwards compatibility; this accessor makes that distinction explicit.
202    pub fn tenant(&self) -> Option<&str> {
203        if self.tenant_id.is_empty() {
204            None
205        } else {
206            Some(self.tenant_id.as_str())
207        }
208    }
209
210    /// The tenant identifier, or `Error::Forbidden` when the principal carries
211    /// no tenant claim.
212    ///
213    /// A missing tenant is **not** an authentication failure — the principal
214    /// already proved who it is — it is a scope problem: the token simply does
215    /// not reach any tenant. Reporting 403 instead of 401 keeps clients from
216    /// retrying with the same valid credentials.
217    pub fn require_tenant(&self) -> Result<&str> {
218        self.tenant().ok_or_else(|| {
219            Error::Forbidden("the authenticated principal has no tenant claim".to_string())
220        })
221    }
222
223    /// Whether the user has `role`.
224    ///
225    /// Boolean check only — for a 401/403 decision use
226    /// [`RequestUser::require_role`] instead.
227    pub fn is_in_role(&self, role: &str) -> bool {
228        self.roles.iter().any(|assigned| assigned == role)
229    }
230
231    /// Whether the user has `permission`.
232    pub fn has_permission(&self, permission: &str) -> bool {
233        self.permissions
234            .iter()
235            .any(|assigned| assigned == permission)
236    }
237
238    /// The display name, falling back to the identifier.
239    pub fn display_name(&self) -> &str {
240        if self.name.is_empty() {
241            &self.id
242        } else {
243            &self.name
244        }
245    }
246}
247
248/// Convenience accessors for a request that carries an [`IdentityUser`].
249///
250/// `#[derive(WebxRequestMeta)]` implements this automatically when the struct
251/// has a `#[fromclaim]` field of type `IdentityUser` (or
252/// `#[fromclaim(optional)]` of type `Option<IdentityUser>`).
253pub trait RequestUser {
254    /// The authenticated identity, or `None` for an anonymous request.
255    fn current_user(&self) -> Option<&IdentityUser>;
256
257    /// The subject identifier, or `None` when anonymous.
258    fn user_id(&self) -> Option<&str> {
259        self.current_user().map(|user| user.id.as_str())
260    }
261
262    /// The display name, or `None` when anonymous.
263    fn user_name(&self) -> Option<&str> {
264        self.current_user().map(|user| user.name.as_str())
265    }
266
267    /// The email address, or `None` when anonymous.
268    fn user_email(&self) -> Option<&str> {
269        self.current_user().map(|user| user.email.as_str())
270    }
271
272    /// The tenant identifier, or `None` when anonymous.
273    ///
274    /// Returns `Some("")` for an authenticated principal without a tenant claim
275    /// (that is how [`IdentityUser::tenant_id`] represents "no claim"); use
276    /// [`IdentityUser::tenant`] or [`RequestUser::require_tenant`] when the
277    /// difference matters.
278    fn tenant_id(&self) -> Option<&str> {
279        self.current_user().map(|user| user.tenant_id.as_str())
280    }
281
282    /// The roles of the authenticated user (empty when anonymous).
283    fn roles(&self) -> &[String] {
284        match self.current_user() {
285            Some(user) => &user.roles,
286            None => &[],
287        }
288    }
289
290    /// The permissions of the authenticated user (empty when anonymous).
291    fn permissions(&self) -> &[String] {
292        match self.current_user() {
293            Some(user) => &user.permissions,
294            None => &[],
295        }
296    }
297
298    /// Whether the authenticated user has `role`.
299    ///
300    /// Boolean branch only (anonymous → `false`); it cannot tell "not signed in"
301    /// from "signed in without the role". Use [`RequestUser::require_role`] for
302    /// a 401/403 decision.
303    fn is_in_role(&self, role: &str) -> bool {
304        self.current_user()
305            .map(|user| user.is_in_role(role))
306            .unwrap_or(false)
307    }
308
309    /// Whether the authenticated user has `permission` (anonymous → `false`).
310    fn has_permission(&self, permission: &str) -> bool {
311        self.current_user()
312            .map(|user| user.has_permission(permission))
313            .unwrap_or(false)
314    }
315
316    /// The identity, or `401 Unauthorized` when the request is anonymous.
317    fn require_user(&self) -> Result<&IdentityUser> {
318        self.current_user()
319            .ok_or_else(|| Error::Unauthorized("authentication required".to_string()))
320    }
321
322    /// Require `role`, separating "not signed in" from "not allowed".
323    ///
324    /// * anonymous → `401 Unauthorized` (`authentication required`)
325    /// * authenticated without the role → `403 Forbidden` (`{role} role required`)
326    ///
327    /// The 401/403 split lets clients react correctly: acquire a token, or stop
328    /// retrying because the token lacks the role.
329    fn require_role(&self, role: &str) -> Result<()> {
330        let user = self.require_user()?;
331        if user.is_in_role(role) {
332            Ok(())
333        } else {
334            Err(Error::Forbidden(format!("{role} role required")))
335        }
336    }
337
338    /// Require a tenant claim, separating "not signed in" from "no tenant
339    /// scope".
340    ///
341    /// * anonymous → `401 Unauthorized`
342    /// * authenticated without a tenant claim → delegates to
343    ///   [`IdentityUser::require_tenant`] (`403 Forbidden`)
344    fn require_tenant(&self) -> Result<&str> {
345        self.require_user()?.require_tenant()
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    /// Minimal `RequestUser` carrier: a request struct with
354    /// `#[fromclaim(optional)] viewer: Option<IdentityUser>` reduces to this.
355    struct TestRequest<'a>(Option<&'a IdentityUser>);
356
357    impl RequestUser for TestRequest<'_> {
358        fn current_user(&self) -> Option<&IdentityUser> {
359            self.0
360        }
361    }
362
363    fn multi_tenant_admin() -> IdentityUser {
364        IdentityUser {
365            id: "user-1".into(),
366            name: "Ada".into(),
367            roles: vec!["admin".into(), "user".into()],
368            permissions: vec!["orders:read".into()],
369            tenant_id: "acme".into(),
370            ..Default::default()
371        }
372    }
373
374    /// Authenticated principal from a token without a tenant claim.
375    fn single_tenant_user() -> IdentityUser {
376        IdentityUser {
377            id: "user-2".into(),
378            roles: Vec::new(),
379            permissions: Vec::new(),
380            tenant_id: String::new(),
381            ..Default::default()
382        }
383    }
384
385    #[test]
386    fn identity_tenant_is_none_when_claim_is_empty() {
387        assert_eq!(multi_tenant_admin().tenant(), Some("acme"));
388        assert_eq!(
389            single_tenant_user().tenant(),
390            None,
391            "empty tenant_id means 'no tenant claim', not a tenant named \"\""
392        );
393    }
394
395    #[test]
396    fn identity_require_tenant_is_forbidden_without_claim() {
397        let user = single_tenant_user();
398        let err = user.require_tenant().unwrap_err();
399        assert!(
400            matches!(&err, Error::Forbidden(detail) if detail.contains("no tenant claim")),
401            "expected Forbidden(no tenant claim), got {err:?}"
402        );
403        assert_eq!(err.status_code(), 403, "missing tenant scope is not 401");
404
405        assert_eq!(multi_tenant_admin().require_tenant().unwrap(), "acme");
406    }
407
408    #[test]
409    fn anonymous_request_user_is_empty_and_unauthorized() {
410        let anonymous = TestRequest(None);
411
412        assert!(anonymous.permissions().is_empty());
413        assert!(!anonymous.has_permission("orders:read"));
414        assert!(!anonymous.is_in_role("admin"));
415        assert!(anonymous.current_user().is_none());
416
417        let role_err = anonymous.require_role("admin").unwrap_err();
418        assert!(
419            matches!(&role_err, Error::Unauthorized(detail) if detail == "authentication required"),
420            "anonymous must be 401, got {role_err:?}"
421        );
422        assert_eq!(role_err.status_code(), 401);
423
424        let tenant_err = anonymous.require_tenant().unwrap_err();
425        assert_eq!(tenant_err.status_code(), 401);
426    }
427
428    #[test]
429    fn authenticated_request_user_exposes_roles_permissions_and_tenant() {
430        let user = multi_tenant_admin();
431        let request = TestRequest(Some(&user));
432
433        assert_eq!(request.permissions().len(), 1);
434        assert_eq!(request.permissions()[0], "orders:read");
435        assert!(request.has_permission("orders:read"));
436        assert!(!request.has_permission("orders:write"));
437        assert!(request.is_in_role("admin"));
438        assert!(request.require_role("admin").is_ok());
439        assert_eq!(request.require_tenant().unwrap(), "acme");
440    }
441
442    #[test]
443    fn require_role_is_forbidden_when_role_is_missing() {
444        let user = multi_tenant_admin();
445        let request = TestRequest(Some(&user));
446
447        let err = request.require_role("auditor").unwrap_err();
448        assert!(
449            matches!(&err, Error::Forbidden(detail) if detail == "auditor role required"),
450            "expected Forbidden(auditor role required), got {err:?}"
451        );
452        assert_eq!(err.status_code(), 403);
453    }
454
455    #[test]
456    fn require_tenant_is_forbidden_for_authenticated_principal_without_claim() {
457        let user = single_tenant_user();
458        let request = TestRequest(Some(&user));
459
460        // Still signed in: this is a scope problem, not a credentials problem.
461        assert!(request.current_user().is_some());
462        let err = request.require_tenant().unwrap_err();
463        assert!(matches!(&err, Error::Forbidden(_)), "got {err:?}");
464        assert_eq!(err.status_code(), 403);
465    }
466}