chopin_auth/extractor.rs
1//! [`Auth<C>`] request extractor and global [`JwtManager`] initialisation.
2//!
3//! Call [`init_jwt_manager`] once at server startup to install the global manager,
4//! then use `Auth<C>` as a parameter in any route handler:
5//!
6//! ```rust,ignore
7//! use chopin_auth::{Auth, init_jwt_manager, JwtManager, StandardClaims};
8//! use chopin_core::{get, Context, Response};
9//!
10//! // Once at startup:
11//! init_jwt_manager(JwtManager::new(b"secret"));
12//!
13//! #[get("/me")]
14//! fn me(ctx: Context) -> Response {
15//! let auth = match ctx.extract::<Auth<StandardClaims>>() {
16//! Ok(a) => a,
17//! Err(r) => return r, // 401 Unauthorized
18//! };
19//! Response::text(&auth.claims.sub)
20//! }
21//! ```
22//!
23//! To customise error responses, register a [`ErrorHandler`] with [`set_error_handler`].
24// src/extractor.rs
25use std::sync::OnceLock;
26
27use crate::jwt::{AuthError, HasJti, JwtManager};
28use chopin_core::extract::FromRequest;
29use chopin_core::http::{Context, Response};
30use serde::Deserialize;
31
32// ─── ErrorHandler ───────────────────────────────────────────────────────────
33
34/// Convert an [`AuthError`] into an HTTP [`Response`].
35///
36/// A default implementation is provided that returns an empty 401 for
37/// token errors and an empty 500 for internal errors, preserving
38/// backward-compatible behaviour.
39///
40/// Register a custom handler once at startup with [`set_error_handler`].
41///
42/// # Example
43/// ```rust,ignore
44/// use chopin_auth::extractor::{ErrorHandler, set_error_handler};
45/// use chopin_auth::jwt::AuthError;
46/// use chopin_core::http::Response;
47///
48/// struct JsonErrors;
49/// impl ErrorHandler for JsonErrors {
50/// fn handle(&self, err: AuthError) -> Response {
51/// let body = format!(r#"{{"error":"{err}"}}");
52/// Response::json(401, &body)
53/// }
54/// }
55///
56/// set_error_handler(JsonErrors);
57/// ```
58pub trait ErrorHandler: Send + Sync {
59 /// Convert `err` into an HTTP response that will be returned to the client.
60 fn handle(&self, err: AuthError) -> Response;
61}
62
63struct DefaultErrorHandler;
64impl ErrorHandler for DefaultErrorHandler {
65 fn handle(&self, err: AuthError) -> Response {
66 match err {
67 AuthError::Expired | AuthError::Revoked | AuthError::InvalidToken(_) => {
68 Response::new(401)
69 }
70 _ => Response::server_error(),
71 }
72 }
73}
74
75static GLOBAL_ERROR_HANDLER: OnceLock<Box<dyn ErrorHandler>> = OnceLock::new();
76
77/// Register a custom [`ErrorHandler`] used by all [`Auth`] extractors.
78///
79/// Call this **once** before starting the server, after (or alongside)
80/// [`init_jwt_manager`]. If never called, the default handler returns empty
81/// 401/500 responses.
82///
83/// Panics if called more than once.
84///
85/// # Example
86/// ```rust,ignore
87/// use chopin_auth::extractor::set_error_handler;
88/// set_error_handler(MyJsonErrorHandler);
89/// ```
90pub fn set_error_handler(handler: impl ErrorHandler + 'static) {
91 if GLOBAL_ERROR_HANDLER.set(Box::new(handler)).is_err() {
92 panic!("ErrorHandler already set — call set_error_handler only once");
93 }
94}
95
96#[inline]
97fn dispatch_error(err: AuthError) -> Response {
98 match GLOBAL_ERROR_HANDLER.get() {
99 Some(h) => h.handle(err),
100 None => DefaultErrorHandler.handle(err),
101 }
102}
103
104// ─── Global manager ──────────────────────────────────────────────────────────
105
106/// The global [`JwtManager`] shared across all threads.
107///
108/// Initialise it once at startup with [`init_jwt_manager`] before the server
109/// starts accepting requests.
110pub static GLOBAL_JWT_MANAGER: OnceLock<JwtManager> = OnceLock::new();
111
112/// Initialise the global [`JwtManager`].
113///
114/// Call this **once** before starting the server. Panics if called more than once.
115///
116/// # Example
117/// ```rust,ignore
118/// use chopin_auth::{JwtManager, init_jwt_manager};
119/// init_jwt_manager(JwtManager::new(b"my-secret"));
120/// ```
121pub fn init_jwt_manager(manager: JwtManager) {
122 if GLOBAL_JWT_MANAGER.set(manager).is_err() {
123 panic!("JwtManager already initialised — call init_jwt_manager only once");
124 }
125}
126
127// ─── Auth extractor ─────────────────────────────────────────────────────────
128
129/// A request extractor that validates the `Authorization: Bearer <token>` header
130/// and resolves to the decoded claims `T`.
131///
132/// `T` must implement both [`Deserialize`] and [`HasJti`]. Types that do not use
133/// revocation can satisfy [`HasJti`] with a one-line empty impl.
134///
135/// # Responses on failure
136/// - `401` – missing header, invalid/expired/revoked token.
137/// - `500` – the global [`JwtManager`] was not initialised.
138pub struct Auth<T> {
139 pub claims: T,
140}
141
142impl<'a, T> FromRequest<'a> for Auth<T>
143where
144 T: for<'de> Deserialize<'de> + HasJti + 'static,
145{
146 type Error = Response;
147
148 // `Response` is intentionally the error type here (HTTP 401/500 short-circuits).
149 #[allow(clippy::result_large_err)]
150 fn from_request(ctx: &'a Context<'a>) -> Result<Self, Self::Error> {
151 // Extract the Authorization header.
152 let auth_header = (0..ctx.req.header_count as usize).find_map(|i| {
153 let (k, v) = ctx.req.headers[i];
154 k.eq_ignore_ascii_case("Authorization").then_some(v)
155 });
156
157 let token = auth_header
158 .and_then(|v| v.strip_prefix("Bearer "))
159 .ok_or_else(|| Response::new(401))?;
160
161 let manager = GLOBAL_JWT_MANAGER
162 .get()
163 .ok_or_else(Response::server_error)?;
164
165 let claims = manager.decode::<T>(token).map_err(dispatch_error)?;
166
167 Ok(Auth { claims })
168 }
169}