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}