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    /// The token identifier (`jti` claim), when the token carries one.
69    ///
70    /// The authentication middleware looks this up in
71    /// [`ISessionStore`](crate::security::ISessionStore) to reject revoked
72    /// tokens. A token without a `jti` cannot be revoked individually; the
73    /// revocation check then leaves it alone (it still expires at `exp`).
74    fn jti(&self) -> Option<&str> {
75        self.claims().get("jti").map(String::as_str)
76    }
77}
78
79impl Clone for Box<dyn IClaims> {
80    fn clone(&self) -> Self {
81        self.clone_box()
82    }
83}
84
85/// Authentication scheme interface.
86///
87/// Implementations read credentials from the HTTP context (e.g., JWT bearer token,
88/// API key header, cookie) and return claims or `None` if unauthenticated.
89#[async_trait::async_trait]
90pub trait IAuthenticationHandler: Send + Sync {
91    /// Authenticate the request and return claims, or `None` if not authenticated.
92    ///
93    /// Uses `&mut dyn IHttpContext` so the returned future is `Send`
94    /// (required by `tokio::spawn` — `&dyn IHttpContext` is `!Send`
95    /// because `IHttpContext` is not `Sync`).
96    async fn authenticate(&self, ctx: &mut dyn IHttpContext) -> Result<Option<Box<dyn IClaims>>>;
97}
98
99/// Authorization policy that checks whether an authenticated user
100/// can access a given resource.
101///
102/// The `resource_key` is the original route pattern string (e.g., `"/api/users/{id}"`),
103/// enabling dynamic identity-authorization-data systems to match routes directly.
104#[async_trait::async_trait]
105pub trait IAuthorizationPolicy: Send + Sync {
106    /// Check if the authenticated user can access the resource.
107    ///
108    /// * `claims`  — the user's authentication claims.
109    /// * `resource_key` — the original route pattern string.
110    /// * `method` — the HTTP method.
111    ///
112    /// Returns `Ok(())` if authorized, or an `Err` if forbidden.
113    async fn authorize(&self, claims: &dyn IClaims, resource_key: &str, method: &str)
114        -> Result<()>;
115}
116
117/// The `#[authorize]` rules declared on the matched endpoint.
118///
119/// A zero-allocation view over the route metadata's `AuthorizeRule` list — the
120/// framework hands it to [`IDynamicAuthorizer::authorize_with_rules`] so a
121/// dynamic backend sees the *same literals* the endpoint declared (e.g.
122/// `#[authorize(permission = "order.create")]`), instead of re-deriving the
123/// requirement from route + method.
124///
125/// **Multi-rule semantics are AND, preserved here:** each item is one
126/// `#[authorize]` attribute and *every* item must hold. Within one item, the
127/// role list is any-of and the permission list is any-of; an empty list leaves
128/// that dimension unconstrained, so a rule with both empty means "any
129/// authenticated user". [`DeclaredRules::roles`] and
130/// [`DeclaredRules::permissions`] therefore yield one list per rule rather than
131/// a flattened union.
132///
133/// An endpoint without `#[authorize]` has no rules ([`DeclaredRules::empty`]);
134/// a bare `#[authorize]` has one rule with both lists empty.
135///
136/// New in 1.2.0; additive — `authorize` keeps its 1.1 signature.
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
138pub struct DeclaredRules<'a> {
139    rules: &'a [crate::route::scan::AuthorizeRule],
140}
141
142impl<'a> DeclaredRules<'a> {
143    /// An endpoint with no `#[authorize]` rule.
144    pub const fn empty() -> Self {
145        Self { rules: &[] }
146    }
147
148    /// View over the endpoint's `#[authorize]` rules (usually
149    /// [`RouteEntry::authorization`](crate::route::scan::RouteEntry)).
150    pub const fn new(rules: &'a [crate::route::scan::AuthorizeRule]) -> Self {
151        Self { rules }
152    }
153
154    /// Whether the endpoint declares no `#[authorize]` rule at all.
155    pub fn is_empty(&self) -> bool {
156        self.rules.is_empty()
157    }
158
159    /// The raw rules — every item must hold (AND).
160    pub fn rules(&self) -> &'a [crate::route::scan::AuthorizeRule] {
161        self.rules
162    }
163
164    /// Declared role lists, one item per `#[authorize]` rule.
165    ///
166    /// Each item is an any-of list (`[]` = no role constraint); the items
167    /// themselves are ANDed.
168    pub fn roles(&self) -> impl Iterator<Item = &'a [&'a str]> + 'a {
169        self.rules.iter().map(|rule| rule.roles)
170    }
171
172    /// Declared permission lists, one item per `#[authorize]` rule.
173    ///
174    /// Each item is an any-of list (`[]` = no permission constraint); the items
175    /// themselves are ANDed.
176    pub fn permissions(&self) -> impl Iterator<Item = &'a [&'a str]> + 'a {
177        self.rules.iter().map(|rule| rule.permissions)
178    }
179}
180
181/// Dynamic authorizer interface — pluggable authorization for protected routes.
182///
183/// Implement this trait and register it in the DI container:
184///
185/// ```ignore
186/// svc.singleton::<dyn IDynamicAuthorizer>(|_| Arc::new(MyAuthorizer::default()))
187/// ```
188///
189/// The framework automatically detects registered `IDynamicAuthorizer` implementations
190/// and invokes them on every route that has `#[authorize]`.
191/// If no implementations are registered, authorization is pass-through (no dynamic checks).
192///
193/// # Methods
194///
195/// * `authorize` — receives the user's claims, matched route pattern, and HTTP
196///   method. Returns `Ok(())` if allowed, or `Err` with details if denied.
197///   Kept for source compatibility with 1.1.
198/// * `authorize_with_rules` — same, plus the endpoint's [`DeclaredRules`] (the
199///   rules written in `#[authorize]`, AND-preserved per attribute). The
200///   framework calls this one; the default implementation forwards to
201///   `authorize`, so an implementation that only overrides `authorize` keeps
202///   working unchanged.
203#[async_trait::async_trait]
204pub trait IDynamicAuthorizer: Send + Sync {
205    /// Validate whether the authenticated user can access the resource.
206    async fn authorize(
207        &self,
208        claims: &dyn IClaims,
209        route_pattern: &str,
210        method: &str,
211    ) -> Result<()>;
212
213    /// Validate access with the endpoint's declared role / permission codes.
214    ///
215    /// Override this instead of `authorize` when the authorization backend must
216    /// know *which* permission the endpoint declared (resource-instance checks,
217    /// permission-to-policy mapping, audit). Calls made through this method never
218    /// reach the overridden `authorize` when this one is overridden.
219    ///
220    /// The default forwards to [`IDynamicAuthorizer::authorize`], which keeps
221    /// 1.1 implementations wire-compatible.
222    async fn authorize_with_rules(
223        &self,
224        claims: &dyn IClaims,
225        route_pattern: &str,
226        method: &str,
227        _declared: &DeclaredRules<'_>,
228    ) -> Result<()> {
229        self.authorize(claims, route_pattern, method).await
230    }
231}
232
233/// The standard identity a request can carry.
234///
235/// `IdentityUser` is a **read-only projection of [`IClaims`]** for request
236/// handling — not a persistence entity. When an endpoint needs the raw claim
237/// map, read it through [`IClaimsExt`](crate::http::IClaimsExt) in middleware or
238/// infrastructure code; `IdentityUser` covers the identity fields almost every
239/// handler needs.
240///
241/// Put it in a request with `#[fromclaim]` and the binder fills it from the
242/// authenticated principal:
243///
244/// ```ignore
245/// #[derive(WebxRequestMeta)]
246/// pub struct CreateOrderRequest {
247///     #[fromclaim]
248///     pub user: IdentityUser,
249///     pub sku: String,
250/// }
251/// ```
252///
253/// The handler then reads `req.user.id`, `req.user.tenant_id` or
254/// `req.user.is_in_role("admin")` without touching the token or the context.
255/// `#[fromclaim(optional)] viewer: Option<IdentityUser>` makes the endpoint work
256/// for anonymous callers too; anonymous access must be declared explicitly.
257#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
258pub struct IdentityUser {
259    /// The subject (`sub`) — the stable user identifier.
260    pub id: String,
261    /// Display name (`name` claim), empty when the token has none.
262    pub name: String,
263    /// Email address (`email` claim), empty when the token has none.
264    pub email: String,
265    /// Tenant identifier (`tenant_id` / `tenant`).
266    ///
267    /// The **empty string means "no tenant claim"** — tokens from single-tenant
268    /// apps and tokens without the claim leave it empty. Use
269    /// [`IdentityUser::tenant`] when the difference between "no tenant claim"
270    /// and "declared empty" matters, or [`IdentityUser::require_tenant`] to
271    /// reject a principal without a tenant.
272    pub tenant_id: String,
273    /// Roles assigned to the user.
274    pub roles: Vec<String>,
275    /// Fine-grained permissions assigned to the user.
276    pub permissions: Vec<String>,
277}
278
279impl IdentityUser {
280    /// Build the identity from an authenticated principal.
281    ///
282    /// Absent `name` / `email` / `tenant_id` claims become empty strings, so an
283    /// empty [`IdentityUser::tenant_id`] means "the principal carries no tenant
284    /// claim" rather than a tenant literally named `""`.
285    pub fn from_claims(claims: &dyn IClaims) -> Self {
286        Self {
287            id: claims.subject().to_string(),
288            name: claims.get_username().unwrap_or_default().to_string(),
289            email: claims.get_email().unwrap_or_default().to_string(),
290            tenant_id: claims.get_tenantid().unwrap_or_default().to_string(),
291            roles: claims.roles().to_vec(),
292            permissions: claims.permissions().to_vec(),
293        }
294    }
295
296    /// The tenant identifier, or `None` when the principal carries no tenant
297    /// claim.
298    ///
299    /// [`IdentityUser::tenant_id`] stores "no claim" as the empty string for
300    /// backwards compatibility; this accessor makes that distinction explicit.
301    pub fn tenant(&self) -> Option<&str> {
302        if self.tenant_id.is_empty() {
303            None
304        } else {
305            Some(self.tenant_id.as_str())
306        }
307    }
308
309    /// The tenant identifier, or `Error::Forbidden` when the principal carries
310    /// no tenant claim.
311    ///
312    /// A missing tenant is **not** an authentication failure — the principal
313    /// already proved who it is — it is a scope problem: the token simply does
314    /// not reach any tenant. Reporting 403 instead of 401 keeps clients from
315    /// retrying with the same valid credentials.
316    pub fn require_tenant(&self) -> Result<&str> {
317        self.tenant().ok_or_else(|| {
318            Error::Forbidden("the authenticated principal has no tenant claim".to_string())
319        })
320    }
321
322    /// Whether the user has `role`.
323    ///
324    /// Boolean check only — for a 401/403 decision use
325    /// [`RequestUser::require_role`] instead.
326    pub fn is_in_role(&self, role: &str) -> bool {
327        self.roles.iter().any(|assigned| assigned == role)
328    }
329
330    /// Whether the user has `permission`.
331    pub fn has_permission(&self, permission: &str) -> bool {
332        self.permissions
333            .iter()
334            .any(|assigned| assigned == permission)
335    }
336
337    /// The display name, falling back to the identifier.
338    pub fn display_name(&self) -> &str {
339        if self.name.is_empty() {
340            &self.id
341        } else {
342            &self.name
343        }
344    }
345}
346
347/// Convenience accessors for a request that carries an [`IdentityUser`].
348///
349/// `#[derive(WebxRequestMeta)]` implements this automatically when the struct
350/// has a `#[fromclaim]` field of type `IdentityUser` (or
351/// `#[fromclaim(optional)]` of type `Option<IdentityUser>`).
352pub trait RequestUser {
353    /// The authenticated identity, or `None` for an anonymous request.
354    fn current_user(&self) -> Option<&IdentityUser>;
355
356    /// The subject identifier, or `None` when anonymous.
357    fn user_id(&self) -> Option<&str> {
358        self.current_user().map(|user| user.id.as_str())
359    }
360
361    /// The display name, or `None` when anonymous.
362    fn user_name(&self) -> Option<&str> {
363        self.current_user().map(|user| user.name.as_str())
364    }
365
366    /// The email address, or `None` when anonymous.
367    fn user_email(&self) -> Option<&str> {
368        self.current_user().map(|user| user.email.as_str())
369    }
370
371    /// The tenant identifier, or `None` when anonymous.
372    ///
373    /// Returns `Some("")` for an authenticated principal without a tenant claim
374    /// (that is how [`IdentityUser::tenant_id`] represents "no claim"); use
375    /// [`IdentityUser::tenant`] or [`RequestUser::require_tenant`] when the
376    /// difference matters.
377    fn tenant_id(&self) -> Option<&str> {
378        self.current_user().map(|user| user.tenant_id.as_str())
379    }
380
381    /// The roles of the authenticated user (empty when anonymous).
382    fn roles(&self) -> &[String] {
383        match self.current_user() {
384            Some(user) => &user.roles,
385            None => &[],
386        }
387    }
388
389    /// The permissions of the authenticated user (empty when anonymous).
390    fn permissions(&self) -> &[String] {
391        match self.current_user() {
392            Some(user) => &user.permissions,
393            None => &[],
394        }
395    }
396
397    /// Whether the authenticated user has `role`.
398    ///
399    /// Boolean branch only (anonymous → `false`); it cannot tell "not signed in"
400    /// from "signed in without the role". Use [`RequestUser::require_role`] for
401    /// a 401/403 decision.
402    fn is_in_role(&self, role: &str) -> bool {
403        self.current_user()
404            .map(|user| user.is_in_role(role))
405            .unwrap_or(false)
406    }
407
408    /// Whether the authenticated user has `permission` (anonymous → `false`).
409    fn has_permission(&self, permission: &str) -> bool {
410        self.current_user()
411            .map(|user| user.has_permission(permission))
412            .unwrap_or(false)
413    }
414
415    /// The identity, or `401 Unauthorized` when the request is anonymous.
416    fn require_user(&self) -> Result<&IdentityUser> {
417        self.current_user()
418            .ok_or_else(|| Error::Unauthorized("authentication required".to_string()))
419    }
420
421    /// Require `role`, separating "not signed in" from "not allowed".
422    ///
423    /// * anonymous → `401 Unauthorized` (`authentication required`)
424    /// * authenticated without the role → `403 Forbidden` (`{role} role required`)
425    ///
426    /// The 401/403 split lets clients react correctly: acquire a token, or stop
427    /// retrying because the token lacks the role.
428    fn require_role(&self, role: &str) -> Result<()> {
429        let user = self.require_user()?;
430        if user.is_in_role(role) {
431            Ok(())
432        } else {
433            Err(Error::Forbidden(format!("{role} role required")))
434        }
435    }
436
437    /// Require a tenant claim, separating "not signed in" from "no tenant
438    /// scope".
439    ///
440    /// * anonymous → `401 Unauthorized`
441    /// * authenticated without a tenant claim → delegates to
442    ///   [`IdentityUser::require_tenant`] (`403 Forbidden`)
443    fn require_tenant(&self) -> Result<&str> {
444        self.require_user()?.require_tenant()
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn declared_rules_empty_has_no_rules() {
454        let empty = DeclaredRules::empty();
455        assert!(empty.is_empty());
456        assert!(empty.rules().is_empty());
457        assert_eq!(empty.roles().count(), 0);
458        assert_eq!(empty.permissions().count(), 0);
459        assert_eq!(empty, DeclaredRules::default());
460    }
461
462    #[test]
463    fn declared_rules_keep_the_and_structure_of_multiple_authorize_attributes() {
464        const RULES: &[crate::route::scan::AuthorizeRule] = &[
465            crate::route::scan::AuthorizeRule {
466                roles: &["admin"],
467                permissions: &[],
468            },
469            crate::route::scan::AuthorizeRule {
470                roles: &[],
471                permissions: &["order.create"],
472            },
473        ];
474
475        let declared = DeclaredRules::new(RULES);
476        assert!(!declared.is_empty());
477        assert_eq!(declared.rules(), RULES);
478
479        let roles: Vec<&[&str]> = declared.roles().collect();
480        assert_eq!(roles, vec![&["admin"][..], &[][..]]);
481        let permissions: Vec<&[&str]> = declared.permissions().collect();
482        assert_eq!(permissions, vec![&[][..], &["order.create"][..]]);
483    }
484
485    /// Minimal `RequestUser` carrier: a request struct with
486    /// `#[fromclaim(optional)] viewer: Option<IdentityUser>` reduces to this.
487    struct TestRequest<'a>(Option<&'a IdentityUser>);
488
489    impl RequestUser for TestRequest<'_> {
490        fn current_user(&self) -> Option<&IdentityUser> {
491            self.0
492        }
493    }
494
495    fn multi_tenant_admin() -> IdentityUser {
496        IdentityUser {
497            id: "user-1".into(),
498            name: "Ada".into(),
499            roles: vec!["admin".into(), "user".into()],
500            permissions: vec!["orders:read".into()],
501            tenant_id: "acme".into(),
502            ..Default::default()
503        }
504    }
505
506    /// Authenticated principal from a token without a tenant claim.
507    fn single_tenant_user() -> IdentityUser {
508        IdentityUser {
509            id: "user-2".into(),
510            roles: Vec::new(),
511            permissions: Vec::new(),
512            tenant_id: String::new(),
513            ..Default::default()
514        }
515    }
516
517    #[test]
518    fn identity_tenant_is_none_when_claim_is_empty() {
519        assert_eq!(multi_tenant_admin().tenant(), Some("acme"));
520        assert_eq!(
521            single_tenant_user().tenant(),
522            None,
523            "empty tenant_id means 'no tenant claim', not a tenant named \"\""
524        );
525    }
526
527    #[test]
528    fn identity_require_tenant_is_forbidden_without_claim() {
529        let user = single_tenant_user();
530        let err = user.require_tenant().unwrap_err();
531        assert!(
532            matches!(&err, Error::Forbidden(detail) if detail.contains("no tenant claim")),
533            "expected Forbidden(no tenant claim), got {err:?}"
534        );
535        assert_eq!(err.status_code(), 403, "missing tenant scope is not 401");
536
537        assert_eq!(multi_tenant_admin().require_tenant().unwrap(), "acme");
538    }
539
540    #[test]
541    fn anonymous_request_user_is_empty_and_unauthorized() {
542        let anonymous = TestRequest(None);
543
544        assert!(anonymous.permissions().is_empty());
545        assert!(!anonymous.has_permission("orders:read"));
546        assert!(!anonymous.is_in_role("admin"));
547        assert!(anonymous.current_user().is_none());
548
549        let role_err = anonymous.require_role("admin").unwrap_err();
550        assert!(
551            matches!(&role_err, Error::Unauthorized(detail) if detail == "authentication required"),
552            "anonymous must be 401, got {role_err:?}"
553        );
554        assert_eq!(role_err.status_code(), 401);
555
556        let tenant_err = anonymous.require_tenant().unwrap_err();
557        assert_eq!(tenant_err.status_code(), 401);
558    }
559
560    #[test]
561    fn authenticated_request_user_exposes_roles_permissions_and_tenant() {
562        let user = multi_tenant_admin();
563        let request = TestRequest(Some(&user));
564
565        assert_eq!(request.permissions().len(), 1);
566        assert_eq!(request.permissions()[0], "orders:read");
567        assert!(request.has_permission("orders:read"));
568        assert!(!request.has_permission("orders:write"));
569        assert!(request.is_in_role("admin"));
570        assert!(request.require_role("admin").is_ok());
571        assert_eq!(request.require_tenant().unwrap(), "acme");
572    }
573
574    #[test]
575    fn require_role_is_forbidden_when_role_is_missing() {
576        let user = multi_tenant_admin();
577        let request = TestRequest(Some(&user));
578
579        let err = request.require_role("auditor").unwrap_err();
580        assert!(
581            matches!(&err, Error::Forbidden(detail) if detail == "auditor role required"),
582            "expected Forbidden(auditor role required), got {err:?}"
583        );
584        assert_eq!(err.status_code(), 403);
585    }
586
587    #[test]
588    fn require_tenant_is_forbidden_for_authenticated_principal_without_claim() {
589        let user = single_tenant_user();
590        let request = TestRequest(Some(&user));
591
592        // Still signed in: this is a scope problem, not a credentials problem.
593        assert!(request.current_user().is_some());
594        let err = request.require_tenant().unwrap_err();
595        assert!(matches!(&err, Error::Forbidden(_)), "got {err:?}");
596        assert_eq!(err.status_code(), 403);
597    }
598}