Skip to main content

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}