arcly_http/auth/guards.rs
1//! Request guards — composable preconditions evaluated before a handler runs.
2//!
3//! Guards operate on [`RequestContext`] and return [`Error`] on rejection.
4//! Apply manually at the top of a handler:
5//!
6//! ```ignore
7//! BearerAuth::new("secret").check(&ctx)?;
8//! ```
9
10use crate::web::{Error, RequestContext};
11
12// RateLimit moved to `resilience::rate_limit` — re-exported here so the
13// long-standing `arcly_http::guards::RateLimit` import path keeps working.
14pub use crate::resilience::rate_limit::RateLimit;
15
16pub trait Guard: Send + Sync + 'static {
17 fn check(&self, ctx: &RequestContext) -> Result<(), Error>;
18}
19
20// ─── Bearer-token auth ───────────────────────────────────────────────────
21/// Minimal `Authorization: Bearer <token>` guard. Comparison is constant-time.
22pub struct BearerAuth {
23 expected: &'static str,
24}
25
26impl BearerAuth {
27 pub const fn new(token: &'static str) -> Self {
28 Self { expected: token }
29 }
30}
31
32impl Guard for BearerAuth {
33 fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
34 let h = ctx.header("authorization").ok_or(Error::Unauthorized)?;
35 let token = h.strip_prefix("Bearer ").ok_or(Error::Unauthorized)?;
36 if ct_eq(token.as_bytes(), self.expected.as_bytes()) {
37 Ok(())
38 } else {
39 Err(Error::Unauthorized)
40 }
41 }
42}
43
44#[inline]
45fn ct_eq(a: &[u8], b: &[u8]) -> bool {
46 if a.len() != b.len() {
47 return false;
48 }
49 let mut diff = 0u8;
50 for i in 0..a.len() {
51 diff |= a[i] ^ b[i];
52 }
53 diff == 0
54}
55
56// ─── JWT auth guard ───────────────────────────────────────────────────────────
57
58/// Checks that the incoming request carried a valid JWT.
59///
60/// This guard is **zero-overhead**: it inspects `ctx.claims()` which was already
61/// decoded by the HTTP boundary when the request arrived. No re-decoding is done.
62///
63/// Use the singleton `JWT_AUTH` so the guard needs no heap allocation:
64/// ```ignore
65/// JWT_AUTH.check(&ctx)?;
66/// ```
67pub struct JwtAuthGuard;
68
69/// Ready-to-use singleton for `JwtAuthGuard`. Import and call:
70/// ```ignore
71/// use arcly_http::guards::{Guard, JWT_AUTH};
72/// JWT_AUTH.check(&ctx)?;
73/// ```
74pub static JWT_AUTH: JwtAuthGuard = JwtAuthGuard;
75
76impl Guard for JwtAuthGuard {
77 fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
78 ctx.claims().map(|_| ()).ok_or(Error::Unauthorized)
79 }
80}
81
82// ─── Role guard ───────────────────────────────────────────────────────────────
83
84/// Checks that the authenticated principal's `"role"` claim matches a required value.
85///
86/// Build a `const`/`static` instance with `RoleGuard::require`:
87/// ```ignore
88/// static ADMIN: RoleGuard = RoleGuard::require("admin");
89/// ADMIN.check(&ctx)?; // 403 if role != "admin", 401 if no claims at all
90/// ```
91pub struct RoleGuard {
92 pub role: &'static str,
93}
94
95impl RoleGuard {
96 pub const fn require(role: &'static str) -> Self {
97 Self { role }
98 }
99}
100
101impl Guard for RoleGuard {
102 fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
103 let claims = ctx.claims().ok_or(Error::Unauthorized)?;
104 match claims.get("role").and_then(|v| v.as_str()) {
105 Some(r) if r == self.role => Ok(()),
106 Some(_) => Err(Error::Forbidden),
107 None => Err(Error::Forbidden),
108 }
109 }
110}
111
112// ─── Session auth guard ───────────────────────────────────────────────────────
113
114/// Passes if the request has a loaded server-side session.
115///
116/// Requires `SessionManager` to be provided in the DI container so the HTTP
117/// boundary loads the session before the guard runs. Returns `Unauthorized`
118/// if no session was loaded (cookie absent, tampered, or expired in the store).
119pub struct SessionAuthGuard;
120
121/// Ready-to-use singleton for `SessionAuthGuard`.
122pub static SESSION_AUTH: SessionAuthGuard = SessionAuthGuard;
123
124impl Guard for SessionAuthGuard {
125 fn check(&self, ctx: &RequestContext) -> Result<(), Error> {
126 ctx.session().map(|_| ()).ok_or(Error::Unauthorized)
127 }
128}