Skip to main content

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}