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