Skip to main content

libfw_core/
auth.rs

1//! Authorization contracts: actions, validator trait and path rules.
2//!
3//! The server-side flow is: extract `Authorization: Bearer <token>` →
4//! verify it (via a [`TokenVerifier`] of your choice) into
5//! [`TokenClaims`](crate::claims::TokenClaims) → ask a [`Validator`]
6//! whether the claims allow the requested `path` + [`Action`].
7
8use crate::claims::{Permission, TokenClaims};
9
10/// The operation a client is trying to perform.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Action {
13    /// Download / read a resource.
14    Read,
15    /// Upload / write a resource.
16    Write,
17}
18
19impl Action {
20    /// The [`Permission`] required to perform this action.
21    pub fn required_permission(self) -> Permission {
22        match self {
23            Action::Read => Permission::Read,
24            Action::Write => Permission::Write,
25        }
26    }
27}
28
29impl std::fmt::Display for Action {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Action::Read => write!(f, "read"),
33            Action::Write => write!(f, "write"),
34        }
35    }
36}
37
38/// Errors raised during authorization.
39///
40/// Servers map these to HTTP status codes: [`AuthError::MissingToken`] and
41/// [`AuthError::Expired`] → `401 Unauthorized`; [`AuthError::Forbidden`] →
42/// `403 Forbidden`.
43#[derive(Debug, thiserror::Error)]
44pub enum AuthError {
45    /// The `Authorization` header is absent or malformed.
46    #[error("missing or malformed bearer token")]
47    MissingToken,
48    /// The token payload could not be verified (bad signature, unknown issuer, …).
49    #[error("invalid token: {0}")]
50    Invalid(String),
51    /// The token has expired.
52    #[error("token expired")]
53    Expired,
54    /// The claims do not permit this path / action.
55    #[error("permission denied: {action} on `{path}`")]
56    Forbidden {
57        /// The requested path.
58        path: String,
59        /// The requested action.
60        action: Action,
61    },
62}
63
64/// Turns a raw bearer token string into validated claims.
65///
66/// Implementations are expected to hold the verification key (or call out
67/// to an external validation service). `libfw` deliberately does **not**
68/// ship a JWT implementation here so the choice stays framework-agnostic.
69pub trait TokenVerifier: Send + Sync + 'static {
70    /// Verify `token` and return its claims.
71    ///
72    /// Should return [`AuthError::MissingToken`] for empty input,
73    /// [`AuthError::Invalid`] for unverifiable tokens and
74    /// [`AuthError::Expired`] for expired ones.
75    fn verify(&self, token: &str) -> Result<TokenClaims, AuthError>;
76}
77
78/// Decides whether `claims` are allowed to perform `action` on `path`.
79pub trait Validator: Send + Sync + 'static {
80    /// Validate a request. Returns `Ok(())` or a descriptive
81    /// [`AuthError`] to be turned into `401`/`403` by the server.
82    fn validate(
83        &self,
84        claims: &TokenClaims,
85        path: &str,
86        action: Action,
87    ) -> Result<(), AuthError>;
88}
89
90/// The default validator: permission check + path-prefix check.
91///
92/// A request is allowed when:
93///
94/// 1. the token is not expired ([`TokenClaims::exp`]), and
95/// 2. it carries the [`Permission`] required by `action`, and
96/// 3. the requested path starts with one of `allowed_paths`.
97///
98/// Paths are compared on a segment boundary: `allowed_paths = ["/docs"]`
99/// matches `/docs`, `/docs/a.txt` and `/docs/` but **not** `/docshop/x`.
100/// The root prefix `"/"` (or `""`) grants access to the whole tree. An
101/// empty `allowed_paths` list denies everything.
102#[derive(Debug, Clone, Default)]
103pub struct PathValidator {
104    /// When true, `allowed_paths` are treated as raw string prefixes
105    /// (no segment-boundary normalization). Default: false.
106    pub raw_prefix_match: bool,
107}
108
109impl PathValidator {
110    /// Creates a validator with segment-boundary path matching.
111    pub fn new() -> Self {
112        PathValidator::default()
113    }
114}
115
116impl Validator for PathValidator {
117    fn validate(
118        &self,
119        claims: &TokenClaims,
120        path: &str,
121        action: Action,
122    ) -> Result<(), AuthError> {
123        if claims.is_expired(now_epoch_seconds()) {
124            return Err(AuthError::Expired);
125        }
126        if !claims.has_permission(action.required_permission()) {
127            return Err(AuthError::Forbidden {
128                path: path.to_string(),
129                action,
130            });
131        }
132        let allowed = if self.raw_prefix_match {
133            claims
134                .allowed_paths
135                .iter()
136                .any(|prefix| path.starts_with(prefix.as_str()))
137        } else {
138            path_matches_any(path, &claims.allowed_paths)
139        };
140        if !allowed {
141            return Err(AuthError::Forbidden {
142                path: path.to_string(),
143                action,
144            });
145        }
146        Ok(())
147    }
148}
149
150/// Segment-boundary prefix match for a path against a list of prefixes.
151///
152/// Normalizes a leading `/` so `/docs`, `docs` and `docs/` are equivalent.
153/// A root prefix (`""`, `"/"`, `"/"`-like) matches **everything**, which is
154/// how `allowed_paths: ["/"]` is conventionally used to grant full access.
155fn path_matches_any(path: &str, prefixes: &[String]) -> bool {
156    let p = path.trim_start_matches('/');
157    prefixes.iter().any(|prefix| {
158        let q = prefix.trim_matches('/');
159        if q.is_empty() {
160            // Root prefix → grant access to the whole tree.
161            return true;
162        }
163        if p == q {
164            return true;
165        }
166        p.starts_with(q) && p.as_bytes().get(q.len()) == Some(&b'/')
167    })
168}
169
170/// Current unix time in seconds.
171fn now_epoch_seconds() -> i64 {
172    std::time::SystemTime::now()
173        .duration_since(std::time::UNIX_EPOCH)
174        .map(|d| d.as_secs() as i64)
175        .unwrap_or(0)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn claims(perms: &[Permission], paths: &[&str], exp: Option<i64>) -> TokenClaims {
183        TokenClaims {
184            sub: "tester".into(),
185            exp,
186            permissions: perms.to_vec(),
187            allowed_paths: paths.iter().map(|s| s.to_string()).collect(),
188        }
189    }
190
191    #[test]
192    fn allows_segment_boundary_paths() {
193        let v = PathValidator::new();
194        let c = claims(&[Permission::Read], &["/docs"], None);
195        for path in ["/docs", "/docs/", "/docs/a.txt", "docs/a.txt"] {
196            assert!(v.validate(&c, path, Action::Read).is_ok(), "{path}");
197        }
198    }
199
200    #[test]
201    fn rejects_sibling_prefix_paths() {
202        let v = PathValidator::new();
203        let c = claims(&[Permission::Read], &["/docs"], None);
204        assert!(matches!(
205            v.validate(&c, "/docshop/x", Action::Read),
206            Err(AuthError::Forbidden { .. })
207        ));
208    }
209
210    #[test]
211    fn rejects_wrong_permission() {
212        let v = PathValidator::new();
213        let c = claims(&[Permission::Read], &["/docs"], None);
214        assert!(matches!(
215            v.validate(&c, "/docs/a", Action::Write),
216            Err(AuthError::Forbidden { .. })
217        ));
218    }
219
220    #[test]
221    fn rejects_expired() {
222        let v = PathValidator::new();
223        let c = claims(&[Permission::Read], &["/docs"], Some(1_000));
224        assert!(matches!(v.validate(&c, "/docs/a", Action::Read), Err(AuthError::Expired)));
225    }
226
227    #[test]
228    fn empty_allowed_paths_denies_all() {
229        let v = PathValidator::new();
230        let c = claims(&[Permission::Read], &[], None);
231        assert!(matches!(
232            v.validate(&c, "/anything", Action::Read),
233            Err(AuthError::Forbidden { .. })
234        ));
235    }
236
237    #[test]
238    fn root_prefix_grants_full_access() {
239        let v = PathValidator::new();
240        for root in ["/", "", "/ "] {
241            let c = claims(&[Permission::Read], &[root.trim()], None);
242            for path in ["/a.txt", "/deep/nested/file.bin", "/"] {
243                assert!(
244                    v.validate(&c, path, Action::Read).is_ok(),
245                    "root {root:?} should allow {path}"
246                );
247            }
248        }
249    }
250
251    #[test]
252    fn raw_prefix_mode_matches_directly() {
253        let mut v = PathValidator::new();
254        v.raw_prefix_match = true;
255        let c = claims(&[Permission::Read], &["/doc"], None);
256        assert!(v.validate(&c, "/docshop", Action::Read).is_ok());
257    }
258
259    #[test]
260    fn is_expired_boundary() {
261        let c = claims(&[], &[], Some(100));
262        assert!(!c.is_expired(50));
263        assert!(!c.is_expired(99));
264        assert!(c.is_expired(100));
265        assert!(c.is_expired(101));
266    }
267}