umbral_auth/extractors.rs
1//! Axum extractors that resolve a request to an
2//! `umbral::auth::Identity`.
3//!
4//! Companion to the built-in [`crate::SessionAuthentication`] and
5//! [`crate::BearerAuthentication`] classes — those run inside
6//! `RestPlugin`'s CRUD handlers and stash the result for the
7//! permission layer. Custom (non-CRUD) handlers don't go through
8//! that pipeline; these extractors let them get at the same
9//! `Identity` shape with one line in the handler signature.
10//!
11//! ```ignore
12//! use umbral::web::Json;
13//! use umbral_auth::OptionalIdentity;
14//!
15//! async fn me(OptionalIdentity(id): OptionalIdentity) -> Json<Value> {
16//! Json(json!({ "authenticated": id.is_some() }))
17//! }
18//! ```
19//!
20//! ## How they resolve
21//!
22//! Both extractors run the same chain `SessionAuthentication` runs
23//! first, then `BearerAuthentication` — the same order
24//! `ChainAuthentication([Session, Bearer])` would. If a handler
25//! needs a different order, write a custom extractor instead;
26//! the two built-ins are the common case.
27//!
28//! ## Custom user models
29//!
30//! These extractors assume `AuthUser` for the is_staff lookup (the
31//! bearer path joins `auth_token` → `auth_user`; the session path
32//! reads `session.user_id` and joins `auth_user`). Apps using a
33//! custom `UserModel` should write their own extractor that joins
34//! their user table instead.
35
36use crate::bearer_auth::parse_bearer_header;
37use crate::login_required::current_session_user_id;
38use crate::token::AuthToken;
39use crate::{AuthUser, auth_user};
40use axum_core::extract::FromRequestParts;
41use axum_core::response::{IntoResponse, Response};
42use http::request::Parts;
43use http::{HeaderMap, StatusCode};
44use umbral::auth::Identity;
45
46/// `OptionalIdentity(Option<Identity>)` — never rejects. Returns
47/// the identity if either the session cookie or the bearer token
48/// resolves to an active user; otherwise `None`.
49///
50/// Use when the handler can do something useful for anonymous
51/// callers (a `/me` endpoint that returns `{authenticated: false}`,
52/// a homepage that shows different links when logged in, an audit
53/// log that records the actor when known but doesn't gate on it).
54pub struct OptionalIdentity(pub Option<Identity>);
55
56impl<S> FromRequestParts<S> for OptionalIdentity
57where
58 S: Send + Sync,
59{
60 type Rejection = std::convert::Infallible;
61
62 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
63 Ok(Self(resolve_identity(&parts.headers).await))
64 }
65}
66
67/// `CurrentIdentity(Identity)` — rejects with 401 if neither
68/// authentication path resolves. Use when the handler genuinely
69/// needs an authenticated caller and an anonymous request is an
70/// error.
71///
72/// The 401 body matches the JSON shape `umbral-rest` returns for
73/// `Permission::AuthenticationRequired` so a single client error
74/// handler can deal with both surfaces uniformly.
75pub struct CurrentIdentity(pub Identity);
76
77impl<S> FromRequestParts<S> for CurrentIdentity
78where
79 S: Send + Sync,
80{
81 type Rejection = Response;
82
83 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
84 match resolve_identity(&parts.headers).await {
85 Some(id) => Ok(Self(id)),
86 None => Err((
87 StatusCode::UNAUTHORIZED,
88 axum_core::body::Body::from(
89 r#"{"error":"authentication required","code":"unauthenticated"}"#,
90 ),
91 )
92 .into_response()),
93 }
94 }
95}
96
97/// Run the session-then-bearer chain. Public so handlers that need
98/// the resolution logic without the extractor framing can call it
99/// directly (`resolve_identity(&headers).await`).
100///
101/// Session takes precedence because the cookie path is cheaper —
102/// one indexed SELECT against the session table joined to
103/// auth_user. Bearer needs a separate token table lookup plus the
104/// user join.
105pub async fn resolve_identity(headers: &HeaderMap) -> Option<Identity> {
106 if let Some(id) = identity_from_session(headers).await {
107 return Some(id);
108 }
109 identity_from_bearer(headers).await
110}
111
112async fn identity_from_session(headers: &HeaderMap) -> Option<Identity> {
113 let user_id = current_session_user_id(headers).await?;
114 let user: AuthUser = AuthUser::objects()
115 .filter(auth_user::ID.eq(user_id) & auth_user::IS_ACTIVE.eq(true))
116 .first()
117 .await
118 .ok()
119 .flatten()?;
120 Some(
121 Identity::user(crate::UserModel::id_string(&user))
122 .with_staff(user.is_staff)
123 .with_superuser(user.is_superuser)
124 .with_extra("auth", serde_json::json!("session")),
125 )
126}
127
128async fn identity_from_bearer(headers: &HeaderMap) -> Option<Identity> {
129 let plaintext = parse_bearer_header(headers)?;
130 let token = AuthToken::lookup(plaintext).await.ok().flatten()?;
131 let user: AuthUser = AuthUser::objects()
132 .filter(auth_user::ID.eq(token.user_id.id()) & auth_user::IS_ACTIVE.eq(true))
133 .first()
134 .await
135 .ok()
136 .flatten()?;
137 token.touch_last_used().await;
138 Some(
139 Identity::user(crate::UserModel::id_string(&user))
140 .with_staff(user.is_staff)
141 .with_superuser(user.is_superuser)
142 .with_extra("auth", serde_json::json!("bearer")),
143 )
144}
145
146/// Why a [`RequireStaff`] extraction was rejected. Split out so the gate rules
147/// are unit-testable without wiring a DB session.
148#[derive(Debug, PartialEq, Eq)]
149pub(crate) enum StaffReject {
150 /// No authenticated identity on the request.
151 Unauthenticated,
152 /// Authenticated, but not a staff user.
153 NotStaff,
154 /// Staff, but `user_id` doesn't parse into the requested pk type.
155 BadPk,
156}
157
158/// The staff-gate decision: require a present, staff identity whose pk parses
159/// into `T`. Pure + synchronous so the rules are testable without a session; the
160/// async [`RequireStaff`] extractor resolves the identity, then calls this.
161pub(crate) fn require_staff_decision<T: std::str::FromStr>(
162 identity: Option<&Identity>,
163) -> Result<T, StaffReject> {
164 let id = identity.ok_or(StaffReject::Unauthenticated)?;
165 if !id.is_staff {
166 return Err(StaffReject::NotStaff);
167 }
168 id.user_pk::<T>().map_err(|_| StaffReject::BadPk)
169}
170
171/// Extractor requiring an authenticated STAFF user; yields their primary key
172/// parsed into `T` (default `i64`). Replaces the `require_staff(&identity)`
173/// helper consumers copy-paste across plugins:
174///
175/// ```ignore
176/// async fn organizers_only(RequireStaff(uid): RequireStaff) -> impl IntoResponse {
177/// // `uid: i64` — the authenticated staff user's id, already typed.
178/// }
179/// ```
180///
181/// Rejections: `401` (not authenticated), `403` (authenticated but not staff),
182/// `400` (pk doesn't parse into `T`).
183pub struct RequireStaff<T = i64>(pub T);
184
185impl<T, S> FromRequestParts<S> for RequireStaff<T>
186where
187 T: std::str::FromStr + Send,
188 S: Send + Sync,
189{
190 type Rejection = Response;
191
192 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
193 let identity = resolve_identity(&parts.headers).await;
194 match require_staff_decision::<T>(identity.as_ref()) {
195 Ok(pk) => Ok(Self(pk)),
196 Err(StaffReject::Unauthenticated) => Err((
197 StatusCode::UNAUTHORIZED,
198 axum_core::body::Body::from(
199 r#"{"error":"authentication required","code":"unauthenticated"}"#,
200 ),
201 )
202 .into_response()),
203 Err(StaffReject::NotStaff) => Err((
204 StatusCode::FORBIDDEN,
205 axum_core::body::Body::from(
206 r#"{"error":"staff access required","code":"forbidden"}"#,
207 ),
208 )
209 .into_response()),
210 Err(StaffReject::BadPk) => Err((
211 StatusCode::BAD_REQUEST,
212 axum_core::body::Body::from(r#"{"error":"invalid user id","code":"bad_identity"}"#),
213 )
214 .into_response()),
215 }
216 }
217}
218
219/// Extractor requiring *any* authenticated user; yields their primary key parsed
220/// into `T` (default `i64`) — the sibling of [`RequireStaff`], and the one most
221/// handlers actually want.
222///
223/// ```ignore
224/// async fn my_profile(RequireAuth(uid): RequireAuth) -> impl IntoResponse {
225/// // `uid: i64` — the caller's id, already typed. An anonymous request
226/// // never reaches this body; the extractor 401s.
227/// }
228/// ```
229///
230/// Prefer this over a hand-written `fn require_auth(&identity) -> Result<i64, _>`
231/// helper: a helper is a gate you have to remember to call, and a handler that
232/// forgets it still compiles, still routes and still runs. This is a gate you
233/// cannot write the handler without (gaps3 #37).
234pub struct RequireAuth<T = i64>(pub T);
235
236impl<T, S> FromRequestParts<S> for RequireAuth<T>
237where
238 T: std::str::FromStr + Send,
239 S: Send + Sync,
240{
241 type Rejection = Response;
242
243 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
244 let identity = resolve_identity(&parts.headers).await;
245 let Some(identity) = identity else {
246 return Err((
247 StatusCode::UNAUTHORIZED,
248 axum_core::body::Body::from(
249 r#"{"error":"authentication required","code":"unauthenticated"}"#,
250 ),
251 )
252 .into_response());
253 };
254 identity.user_id.parse::<T>().map(Self).map_err(|_| {
255 (
256 StatusCode::BAD_REQUEST,
257 axum_core::body::Body::from(r#"{"error":"invalid user id","code":"bad_identity"}"#),
258 )
259 .into_response()
260 })
261 }
262}
263
264#[cfg(test)]
265mod require_staff_tests {
266 use super::{StaffReject, require_staff_decision};
267 use umbral::auth::Identity;
268
269 #[test]
270 fn gates_unauthenticated_staff_and_typed_pk() {
271 // No identity → unauthenticated.
272 assert!(matches!(
273 require_staff_decision::<i64>(None),
274 Err(StaffReject::Unauthenticated)
275 ));
276 // Authenticated non-staff → forbidden.
277 let member = Identity::user(5);
278 assert!(matches!(
279 require_staff_decision::<i64>(Some(&member)),
280 Err(StaffReject::NotStaff)
281 ));
282 // Staff → returns the pk parsed into the requested type.
283 let staff = Identity::user(7).with_staff(true);
284 assert_eq!(require_staff_decision::<i64>(Some(&staff)).unwrap(), 7);
285 // Staff, but the pk can't parse into the requested type → bad identity.
286 let named = Identity::user("codename").with_staff(true);
287 assert!(matches!(
288 require_staff_decision::<i64>(Some(&named)),
289 Err(StaffReject::BadPk)
290 ));
291 // Non-i64 keys work through the same path.
292 let named_ok = Identity::user("codename").with_staff(true);
293 assert_eq!(
294 require_staff_decision::<String>(Some(&named_ok)).unwrap(),
295 "codename"
296 );
297 }
298}