acton_htmx/auth/
extractors.rs

1//! Authentication extractors for Axum handlers
2//!
3//! Provides extractors for accessing authenticated users in request handlers.
4//!
5//! # Examples
6//!
7//! ## Requiring authentication
8//!
9//! ```rust,no_run
10//! use acton_htmx::auth::{Authenticated, User};
11//! use axum::response::IntoResponse;
12//!
13//! async fn protected_handler(
14//!     Authenticated(user): Authenticated<User>,
15//! ) -> impl IntoResponse {
16//!     format!("Hello, {}!", user.email)
17//! }
18//! ```
19//!
20//! ## Optional authentication
21//!
22//! ```rust,no_run
23//! use acton_htmx::auth::{OptionalAuth, User};
24//! use axum::response::IntoResponse;
25//!
26//! async fn optional_handler(
27//!     OptionalAuth(user): OptionalAuth<User>,
28//! ) -> impl IntoResponse {
29//!     match user {
30//!         Some(user) => format!("Hello, {}!", user.email),
31//!         None => "Hello, guest!".to_string(),
32//!     }
33//! }
34//! ```
35
36use crate::auth::{Session, User, UserError};
37use crate::middleware::is_htmx_request;
38use crate::state::ActonHtmxState;
39use axum::{
40    extract::{FromRef, FromRequestParts},
41    http::{request::Parts, StatusCode},
42    response::{IntoResponse, Redirect, Response},
43};
44
45/// Authenticated user extractor for protected routes
46///
47/// This extractor ensures that a user is authenticated before the handler runs.
48/// If no valid session exists, it returns an appropriate error response:
49/// - For HTMX requests: 401 Unauthorized with HX-Redirect header
50/// - For regular requests: 303 redirect to `/login`
51///
52/// # Example
53///
54/// ```rust,no_run
55/// use acton_htmx::auth::{Authenticated, User};
56///
57/// async fn protected_handler(
58///     Authenticated(user): Authenticated<User>,
59/// ) -> String {
60///     format!("User ID: {}", user.id)
61/// }
62/// ```
63pub struct Authenticated<T>(pub T);
64
65impl<S> FromRequestParts<S> for Authenticated<User>
66where
67    S: Send + Sync,
68    ActonHtmxState: FromRef<S>,
69{
70    type Rejection = AuthenticationError;
71
72    async fn from_request_parts(
73        parts: &mut Parts,
74        state: &S,
75    ) -> Result<Self, Self::Rejection> {
76        // Check if this is an HTMX request
77        let is_htmx = is_htmx_request(&parts.headers);
78
79        // Get session from request extensions
80        let session = parts
81            .extensions
82            .get::<Session>()
83            .cloned()
84            .ok_or_else(|| AuthenticationError::missing_session(is_htmx))?;
85
86        // Check if user is authenticated
87        let user_id = session
88            .user_id()
89            .ok_or_else(|| AuthenticationError::not_authenticated(is_htmx))?;
90
91        // Extract state to get database pool
92        let app_state = ActonHtmxState::from_ref(state);
93
94        // Load user from database
95        let user = User::find_by_id(user_id, app_state.database_pool())
96            .await
97            .map_err(|e| match e {
98                UserError::NotFound => AuthenticationError::not_authenticated(is_htmx),
99                _ => AuthenticationError::DatabaseError(e),
100            })?;
101
102        Ok(Self(user))
103    }
104}
105
106/// Optional authentication extractor
107///
108/// This extractor works for both authenticated and unauthenticated requests.
109/// It returns `Some(user)` if authenticated, `None` otherwise.
110///
111/// # Example
112///
113/// ```rust,no_run
114/// use acton_htmx::auth::{OptionalAuth, User};
115///
116/// async fn optional_handler(
117///     OptionalAuth(user): OptionalAuth<User>,
118/// ) -> String {
119///     match user {
120///         Some(u) => format!("Hello, {}!", u.email),
121///         None => "Hello, guest!".to_string(),
122///     }
123/// }
124/// ```
125pub struct OptionalAuth<T>(pub Option<T>);
126
127impl<S> FromRequestParts<S> for OptionalAuth<User>
128where
129    S: Send + Sync,
130    ActonHtmxState: FromRef<S>,
131{
132    type Rejection = AuthenticationError;
133
134    async fn from_request_parts(
135        parts: &mut Parts,
136        state: &S,
137    ) -> Result<Self, Self::Rejection> {
138        // Get session from request extensions
139        let Some(session) = parts.extensions.get::<Session>().cloned() else {
140            return Ok(Self(None)); // No session = not authenticated
141        };
142
143        // Check if user is authenticated
144        let Some(user_id) = session.user_id() else {
145            return Ok(Self(None)); // No user_id = not authenticated
146        };
147
148        // Extract state to get database pool
149        let app_state = ActonHtmxState::from_ref(state);
150
151        // Load user from database
152        let user = User::find_by_id(user_id, app_state.database_pool())
153            .await
154            .ok(); // Convert Result to Option - failures return None
155
156        Ok(Self(user))
157    }
158}
159
160/// Authentication errors for extractors
161#[derive(Debug)]
162pub enum AuthenticationError {
163    /// No session found in request extensions (HTMX request)
164    MissingSessionHtmx,
165
166    /// No session found in request extensions (regular request)
167    MissingSession,
168
169    /// Session exists but user is not authenticated (HTMX request)
170    NotAuthenticatedHtmx,
171
172    /// Session exists but user is not authenticated (regular request)
173    NotAuthenticated,
174
175    /// Database not configured (development/testing)
176    DatabaseNotConfigured,
177
178    /// Database error occurred
179    DatabaseError(UserError),
180}
181
182impl AuthenticationError {
183    /// Create a "missing session" error appropriate for the request type.
184    ///
185    /// This helper reduces duplication by encapsulating the HTMX detection logic.
186    ///
187    /// # Arguments
188    ///
189    /// * `is_htmx` - Whether the request is from HTMX
190    ///
191    /// # Returns
192    ///
193    /// * [`MissingSessionHtmx`](Self::MissingSessionHtmx) for HTMX requests
194    /// * [`MissingSession`](Self::MissingSession) for regular requests
195    #[must_use]
196    pub const fn missing_session(is_htmx: bool) -> Self {
197        if is_htmx {
198            Self::MissingSessionHtmx
199        } else {
200            Self::MissingSession
201        }
202    }
203
204    /// Create a "not authenticated" error appropriate for the request type.
205    ///
206    /// This helper reduces duplication by encapsulating the HTMX detection logic.
207    ///
208    /// # Arguments
209    ///
210    /// * `is_htmx` - Whether the request is from HTMX
211    ///
212    /// # Returns
213    ///
214    /// * [`NotAuthenticatedHtmx`](Self::NotAuthenticatedHtmx) for HTMX requests
215    /// * [`NotAuthenticated`](Self::NotAuthenticated) for regular requests
216    #[must_use]
217    pub const fn not_authenticated(is_htmx: bool) -> Self {
218        if is_htmx {
219            Self::NotAuthenticatedHtmx
220        } else {
221            Self::NotAuthenticated
222        }
223    }
224}
225
226impl IntoResponse for AuthenticationError {
227    fn into_response(self) -> Response {
228        match self {
229            Self::MissingSessionHtmx | Self::NotAuthenticatedHtmx => {
230                // For HTMX requests, return 401 with HX-Redirect header
231                (
232                    StatusCode::UNAUTHORIZED,
233                    [("HX-Redirect", "/login")],
234                    "Unauthorized",
235                )
236                    .into_response()
237            }
238            Self::MissingSession | Self::NotAuthenticated => {
239                // For regular requests, redirect to login
240                Redirect::to("/login").into_response()
241            }
242            Self::DatabaseNotConfigured => {
243                (
244                    StatusCode::INTERNAL_SERVER_ERROR,
245                    "Database not configured",
246                )
247                    .into_response()
248            }
249            Self::DatabaseError(_) => {
250                (
251                    StatusCode::INTERNAL_SERVER_ERROR,
252                    "Failed to load user",
253                )
254                    .into_response()
255            }
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use axum::http::StatusCode;
264
265    #[test]
266    fn test_authentication_error_missing_session_regular_returns_redirect() {
267        let error = AuthenticationError::MissingSession;
268        let response = error.into_response();
269
270        assert_eq!(response.status(), StatusCode::SEE_OTHER);
271        assert_eq!(
272            response.headers().get("location").unwrap(),
273            "/login"
274        );
275    }
276
277    #[test]
278    fn test_authentication_error_missing_session_htmx_returns_401_with_hx_redirect() {
279        let error = AuthenticationError::MissingSessionHtmx;
280        let response = error.into_response();
281
282        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
283        assert_eq!(
284            response.headers().get("HX-Redirect").unwrap(),
285            "/login"
286        );
287    }
288
289    #[test]
290    fn test_authentication_error_not_authenticated_regular_returns_redirect() {
291        let error = AuthenticationError::NotAuthenticated;
292        let response = error.into_response();
293
294        assert_eq!(response.status(), StatusCode::SEE_OTHER);
295        assert_eq!(
296            response.headers().get("location").unwrap(),
297            "/login"
298        );
299    }
300
301    #[test]
302    fn test_authentication_error_not_authenticated_htmx_returns_401_with_hx_redirect() {
303        let error = AuthenticationError::NotAuthenticatedHtmx;
304        let response = error.into_response();
305
306        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
307        assert_eq!(
308            response.headers().get("HX-Redirect").unwrap(),
309            "/login"
310        );
311    }
312
313    #[test]
314    fn test_authentication_error_database_not_configured_returns_500() {
315        let error = AuthenticationError::DatabaseNotConfigured;
316        let response = error.into_response();
317
318        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
319    }
320
321    #[test]
322    fn test_authentication_error_database_error_returns_500() {
323        let error = AuthenticationError::DatabaseError(UserError::NotFound);
324        let response = error.into_response();
325
326        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
327    }
328
329    #[test]
330    fn test_missing_session_helper_returns_htmx_variant_when_is_htmx_true() {
331        let error = AuthenticationError::missing_session(true);
332        assert!(matches!(error, AuthenticationError::MissingSessionHtmx));
333    }
334
335    #[test]
336    fn test_missing_session_helper_returns_regular_variant_when_is_htmx_false() {
337        let error = AuthenticationError::missing_session(false);
338        assert!(matches!(error, AuthenticationError::MissingSession));
339    }
340
341    #[test]
342    fn test_not_authenticated_helper_returns_htmx_variant_when_is_htmx_true() {
343        let error = AuthenticationError::not_authenticated(true);
344        assert!(matches!(error, AuthenticationError::NotAuthenticatedHtmx));
345    }
346
347    #[test]
348    fn test_not_authenticated_helper_returns_regular_variant_when_is_htmx_false() {
349        let error = AuthenticationError::not_authenticated(false);
350        assert!(matches!(error, AuthenticationError::NotAuthenticated));
351    }
352}