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}