axess_macros/lib.rs
1//! Authentication / authorization middleware macros for Axess.
2//!
3//! Generates Axum middleware (tower `Layer`s) that enforce authentication
4//! state ([`require_authn!`], [`require_partial_authn!`]) or Cedar
5//! authorization decisions ([`require_authz!`], behind the `authz` feature).
6//! All variants support both 401/403 status responses (API endpoints) and
7//! redirect responses (HTML endpoints).
8//!
9//! # Macro family
10//!
11//! | Macro | Concern | Feature |
12//! |---|---|---|
13//! | [`predicate_required!`] | foundation: gate by custom predicate | always on |
14//! | [`require_authn!`] | gate: caller must be Authenticated | always on |
15//! | [`require_partial_authn!`] | gate: caller mid-MFA (Authenticating) | always on |
16//! | [`require_authz!`] | gate: Cedar policy decision (RBAC + ABAC + ReBAC) | `authz` |
17//!
18#![forbid(unsafe_code)]
19#![deny(missing_docs)]
20
21pub use axess_core::{
22 AuthSession,
23 axum::{
24 self,
25 http::{self, Uri},
26 },
27 tracing,
28};
29
30// Re-export cedar_policy types used by the `require_authz!` expansion.
31// Hidden from public docs; the macro expands to fully-qualified paths.
32#[cfg(feature = "authz")]
33#[doc(hidden)]
34pub mod __cedar {
35 pub use cedar_policy::{Context, Entities, EntityUid};
36}
37
38// Re-export the axess-core authz facade items needed by `require_authz!`'s
39// expansion. Behind the `authz` feature so consumers without Cedar don't
40// pay the dep cost.
41#[cfg(feature = "authz")]
42#[doc(hidden)]
43pub mod __authz {
44 pub use axess_core::authz::{
45 AuthzDecision, AuthzDenied, PolicyEvaluator, PolicyStore, RequestEntityProvider,
46 };
47}
48
49// Internal helpers used by macro expansions.
50#[cfg(feature = "authz")]
51#[doc(hidden)]
52pub mod __macro_support;
53
54#[cfg(feature = "authz")]
55mod authz_macro;
56
57fn update_query(uri: &Uri, new_query: String) -> Result<Uri, http::Error> {
58 let query = form_urlencoded::parse(uri.query().map(|q| q.as_bytes()).unwrap_or_default());
59 let updated_query = form_urlencoded::Serializer::new(new_query)
60 .extend_pairs(query)
61 .finish();
62
63 let mut parts = uri.clone().into_parts();
64 parts.path_and_query = Some(format!("{}?{}", uri.path(), updated_query).parse()?);
65
66 Ok(Uri::from_parts(parts)?)
67}
68
69/// This is intended for internal use only and subject to change in the future
70/// without warning!
71#[doc(hidden)]
72pub fn url_with_redirect_query(
73 url: &str,
74 redirect_field: &str,
75 redirect_uri: Uri,
76) -> Result<Uri, http::Error> {
77 let uri = url.parse::<Uri>()?;
78
79 if uri.query().is_some_and(|q| q.contains(redirect_field)) {
80 return Ok(uri);
81 };
82
83 let redirect_uri_string = redirect_uri.to_string();
84 let redirect_uri_encoded: String =
85 form_urlencoded::byte_serialize(redirect_uri_string.as_bytes()).collect();
86 let redirect_query = format!("{redirect_field}={redirect_uri_encoded}");
87
88 update_query(&uri, redirect_query)
89}
90
91/// Predicate middleware.
92///
93/// Can be specified with a login URL and next redirect field or an alternative
94/// which implements [`IntoResponse`](axum::response::IntoResponse).
95///
96/// When the predicate passes, the request processes normally. On failure,
97/// either a redirect to the specified login URL is issued or the alternative is
98/// used as the response.
99///
100/// # Variants
101///
102/// ```text
103/// // Status code response:
104/// predicate_required!(my_check, StatusCode::FORBIDDEN)
105///
106/// // Redirect with named args:
107/// predicate_required!(my_check, url = "/login", param = "next")
108/// ```
109#[macro_export]
110macro_rules! predicate_required {
111 // Named args form
112 ($predicate:expr, url = $login_url:expr, param = $redirect_field:expr) => {
113 $crate::predicate_required!($predicate, login_url = $login_url, redirect_field = $redirect_field)
114 };
115
116 ($predicate:expr, $alternative:expr) => {{
117 use axum::{
118 middleware::{from_fn, Next},
119 response::IntoResponse,
120 };
121
122 from_fn(
123 |auth_session: $crate::AuthSession, req, next: Next| async move {
124 if $predicate(auth_session).await {
125 next.run(req).await
126 } else {
127 $alternative.into_response()
128 }
129 },
130 )
131 }};
132
133 ($predicate:expr, login_url = $login_url:expr, redirect_field = $redirect_field:expr) => {{
134 use axum::{
135 extract::OriginalUri,
136 middleware::{from_fn, Next},
137 response::{IntoResponse, Redirect},
138 };
139
140 from_fn(
141 |auth_session: $crate::AuthSession,
142 OriginalUri(original_uri): OriginalUri,
143 req,
144 next: Next| async move {
145 if $predicate(auth_session).await {
146 next.run(req).await
147 } else {
148 match $crate::url_with_redirect_query(
149 $login_url,
150 $redirect_field,
151 original_uri
152 ) {
153 Ok(login_url) => {
154 Redirect::temporary(&login_url.to_string()).into_response()
155 }
156
157 Err(err) => {
158 $crate::tracing::error!(err = %err, "Failed to build redirect URL");
159 $crate::axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
160 }
161 }
162 }
163 },
164 )
165 }};
166}
167
168/// Authentication-required middleware macro.
169///
170/// Generates Axum middleware that ensures the caller is fully authenticated
171/// (i.e. `AuthState::Authenticated`) before allowing access. If not
172/// authenticated, returns 401 or redirects to a login page depending on
173/// parameters.
174///
175/// Naming follows the axess `Authn*` convention. See also
176/// [`require_partial_authn!`] for mid-MFA gating and
177/// [`predicate_required!`] for the underlying foundation.
178///
179/// Works with both `.layer()` (all routes) and `.route_layer()` (specific routes):
180///
181/// ```text
182/// // Protect all routes in this router:
183/// Router::new()
184/// .route("/api/data", get(data_handler))
185/// .layer(require_authn!())
186///
187/// // Protect only the routes defined on this router (not nested):
188/// Router::new()
189/// .route("/dashboard", get(dashboard_handler))
190/// .route_layer(require_authn!(url = "/login"))
191/// ```
192///
193/// # Variants
194///
195/// ## Return 401 Unauthorized (API endpoints)
196/// ```text
197/// .layer(require_authn!())
198/// ```
199///
200/// ## Redirect to login page (named args, preferred)
201/// ```text
202/// .layer(require_authn!(url = "/login"))
203/// .layer(require_authn!(url = "/auth/login", param = "return_to"))
204/// ```
205///
206/// ## Redirect to login page (positional args, legacy)
207/// ```text
208/// .layer(require_authn!("/login"))
209/// .layer(require_authn!("/auth/login", "return_to"))
210/// ```
211#[macro_export]
212macro_rules! require_authn {
213 // Named args: URL and custom redirect field
214 (url = $login_url:expr, param = $redirect_field:expr) => {
215 $crate::require_authn!($login_url, $redirect_field)
216 };
217
218 // Named args: URL with default "next" field
219 (url = $login_url:expr) => {
220 $crate::require_authn!($login_url, "next")
221 };
222
223 // Positional: login URL and custom redirect field
224 ($login_url:expr, $redirect_field:expr) => {{
225 use axum::{
226 extract::OriginalUri,
227 middleware::{from_fn, Next},
228 response::{IntoResponse, Redirect},
229 };
230
231 from_fn(
232 |auth_session: $crate::AuthSession,
233 OriginalUri(original_uri): OriginalUri,
234 req,
235 next: Next| async move {
236 if auth_session.is_authenticated().await {
237 next.run(req).await
238 } else {
239 match $crate::url_with_redirect_query(
240 $login_url,
241 $redirect_field,
242 original_uri
243 ) {
244 Ok(login_url) => {
245 Redirect::temporary(&login_url.to_string()).into_response()
246 }
247 Err(err) => {
248 $crate::tracing::error!(err = %err, "Failed to build login redirect URL");
249 $crate::axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
250 }
251 }
252 }
253 },
254 )
255 }};
256
257 // Redirect with default "next" field
258 ($login_url:expr) => {
259 $crate::require_authn!($login_url, "next")
260 };
261
262 // Status code only (no redirect)
263 () => {{
264 use axum::{
265 middleware::{from_fn, Next},
266 response::IntoResponse,
267 };
268
269 from_fn(
270 |auth_session: $crate::AuthSession, req, next: Next| async move {
271 if auth_session.is_authenticated().await {
272 next.run(req).await
273 } else {
274 $crate::axum::http::StatusCode::UNAUTHORIZED.into_response()
275 }
276 },
277 )
278 }};
279}
280
281/// Partial authentication-required middleware macro.
282///
283/// Generates Axum middleware that ensures the user is in the `Authenticating`
284/// state (i.e., has started but not completed a multi-factor flow) before
285/// allowing access. Use this to guard MFA verification routes; it prevents
286/// users from accessing the TOTP/FIDO2 input page without first having
287/// entered their username/password.
288///
289/// Works with both `.layer()` and `.route_layer()`.
290///
291/// # Variants
292///
293/// ## Return 401 Unauthorized (API endpoints)
294/// ```text
295/// .route_layer(require_partial_authn!())
296/// ```
297///
298/// ## Redirect to login page (preferred short form)
299/// ```text
300/// .route_layer(require_partial_authn!(url = "/login"))
301/// .route_layer(require_partial_authn!(url = "/login", param = "next"))
302/// ```
303///
304/// ## Redirect to login page (long form)
305/// ```text
306/// .route_layer(require_partial_authn!(login_url = "/login"))
307/// .route_layer(require_partial_authn!(login_url = "/login", redirect_field = "next"))
308/// ```
309#[macro_export]
310macro_rules! require_partial_authn {
311 // Short named args (preferred)
312 (url = $login_url:expr, param = $redirect_field:expr) => {
313 $crate::require_partial_authn!(login_url = $login_url, redirect_field = $redirect_field)
314 };
315
316 (url = $login_url:expr) => {
317 $crate::require_partial_authn!(login_url = $login_url, redirect_field = "next")
318 };
319
320 // Status code only
321 () => {{
322 async fn is_partial_authenticated(auth_session: $crate::AuthSession) -> bool {
323 auth_session.auth_state().await.is_authenticating()
324 }
325
326 $crate::predicate_required!(
327 is_partial_authenticated,
328 $crate::axum::http::StatusCode::UNAUTHORIZED
329 )
330 }};
331
332 // Redirect with custom field
333 (login_url = $login_url:expr, redirect_field = $redirect_field:expr) => {{
334 async fn is_partial_authenticated(auth_session: $crate::AuthSession) -> bool {
335 auth_session.auth_state().await.is_authenticating()
336 }
337
338 $crate::predicate_required!(
339 is_partial_authenticated,
340 login_url = $login_url,
341 redirect_field = $redirect_field
342 )
343 }};
344
345 // Redirect with default "next" field
346 (login_url = $login_url:expr) => {
347 $crate::require_partial_authn!(login_url = $login_url, redirect_field = "next")
348 };
349}