umbral_core/auth_contract.rs
1//! The authentication identity contract — who is the caller?
2//!
3//! [`Identity`] and [`Authentication`] are the two types every auth
4//! backend and every permission class speaks. They live here in
5//! `umbral-core` (re-exported from the `umbral` facade at `umbral::auth`)
6//! so that `umbral-auth` and `umbral-rest` both depend *inward* on core
7//! rather than one depending on the other.
8//!
9//! This is the architectural fix for gaps2 #76: previously
10//! `umbral-auth` depended on `umbral-rest` to get `Identity` and
11//! `Authentication`, which forced REST into every app that used auth —
12//! even REST-free HTML apps. After this move, `umbral-auth` names
13//! `umbral::auth::*` (the facade path), and `umbral-rest` re-exports the
14//! same types from here rather than defining them itself.
15//!
16//! ## Built-ins
17//!
18//! - [`NoAuthentication`] — always returns `None`. The default; every
19//! request looks anonymous. Pair with `AllowAny` for fully open
20//! endpoints.
21//! - [`FnAuthentication`] — wraps an async closure of your shape.
22//! The escape hatch for session-cookie auth (against
23//! `umbral_auth::current_user`), HTTP Basic Auth, API key,
24//! JWT, and anything else.
25//! - [`ChainAuthentication`] — try multiple backends in order; first
26//! success wins.
27//!
28//! Session / Basic / Token / JWT specifics aren't baked into the
29//! crate — they're 5-line `FnAuthentication` wrappers in your app
30//! code, which avoids forcing a transitive dep on every auth scheme
31//! onto users who only need one of them.
32
33use std::pin::Pin;
34use std::sync::Arc;
35
36use async_trait::async_trait;
37use base64::Engine;
38use serde::{Deserialize, Serialize};
39
40use crate::web::{HeaderMap, header};
41
42/// [`Identity::pk`] could not convert the stringified key back to its type.
43///
44/// In a correctly-configured app this cannot happen — the string was produced by
45/// `Display` on that very key type — which is exactly why hand-writing the parse (and its
46/// error branch) at every call site is waste.
47#[derive(Debug, Clone)]
48pub struct IdentityPkError {
49 /// The value that would not parse.
50 pub value: String,
51 /// The Rust type it was asked to become.
52 pub target: &'static str,
53}
54
55impl std::fmt::Display for IdentityPkError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 write!(
58 f,
59 "umbral: identity primary key `{}` is not a valid `{}` — the active user model's \
60 key type and the session's stored key disagree",
61 self.value, self.target
62 )
63 }
64}
65
66impl std::error::Error for IdentityPkError {}
67
68/// Who the request belongs to, after authentication.
69///
70/// The shape is intentionally narrow: `user_id`, `is_staff`, and
71/// `is_superuser` cover most permission checks. An `extras` map carries
72/// app-specific bits (role names, organisation id, scope strings) for
73/// custom permission impls.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Identity {
76 /// The authenticated user's primary key, stringified so the same `Identity` shape
77 /// works whether the active user model has an `i64`, `String`, or UUID primary key.
78 /// The framework's own permissions plugin and session store speak strings.
79 ///
80 /// **To get the typed key back, use [`Identity::pk`] — not `.parse()`.** This
81 /// doc-comment used to say "parse on demand (`identity.user_id.parse::<i64>()`)",
82 /// and a live consumer duly wrote that expression ~19 times, each with its own
83 /// bespoke error branch for a failure that cannot happen. Documentation that hands
84 /// you a snippet is documentation that decides your code; this one was teaching the
85 /// boilerplate it should have been replacing. (gaps3 #57.)
86 ///
87 /// In a handler, prefer not to touch this field at all — the
88 /// `RequireAuth<T>` / `RequireStaff` extractors hand you the typed key in the
89 /// signature, so a handler that forgot to authenticate cannot be written.
90 pub user_id: String,
91 /// Staff flag. Used by the
92 /// built-in `IsStaff` permission class in `umbral-rest`.
93 pub is_staff: bool,
94 /// Superuser flag. A
95 /// superuser bypasses all permission checks in the built-in
96 /// permission classes; custom permission impls can consult this
97 /// field to grant unconditional access.
98 #[serde(default)]
99 pub is_superuser: bool,
100 /// App-specific extras a permission check might want to consult.
101 /// `umbral-auth` doesn't populate this; user-defined auth backends
102 /// can stuff role names, organisation ids, etc. here.
103 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
104 pub extras: std::collections::HashMap<String, serde_json::Value>,
105}
106
107impl Identity {
108 /// The user's primary key, typed (gaps3 #57).
109 ///
110 /// `user_id` is a `String` because the framework supports `i64`, `String` and UUID
111 /// primary keys behind one `Identity` shape. This converts it back:
112 ///
113 /// ```ignore
114 /// let uid: i64 = identity.pk()?;
115 /// ```
116 ///
117 /// `Err` carries the unparseable value, which is the only useful thing to say about
118 /// it — but note that in a correctly-configured app this cannot fail: the string was
119 /// produced by `Display` on that very key type. That is precisely why hand-writing
120 /// `.parse::<i64>().map_err(|_| some_500())?` at every call site is waste: it is an
121 /// error branch for an impossible state, repeated once per handler.
122 pub fn pk<T: std::str::FromStr>(&self) -> Result<T, IdentityPkError> {
123 self.user_id.parse::<T>().map_err(|_| IdentityPkError {
124 value: self.user_id.clone(),
125 target: std::any::type_name::<T>(),
126 })
127 }
128
129 /// Convenience constructor for a non-staff user. Accepts any
130 /// stringifiable PK — `Identity::user(42)`, `Identity::user("42")`,
131 /// or `Identity::user(uuid.to_string())` all work because the
132 /// argument is `impl ToString`.
133 pub fn user(user_id: impl ToString) -> Self {
134 Self {
135 user_id: user_id.to_string(),
136 is_staff: false,
137 is_superuser: false,
138 extras: Default::default(),
139 }
140 }
141
142 /// Promote to staff. Chainable.
143 pub fn staff(mut self) -> Self {
144 self.is_staff = true;
145 self
146 }
147
148 /// Set the staff flag explicitly. Chainable.
149 pub fn with_staff(mut self, is_staff: bool) -> Self {
150 self.is_staff = is_staff;
151 self
152 }
153
154 /// Set the superuser flag explicitly. Chainable.
155 pub fn with_superuser(mut self, is_superuser: bool) -> Self {
156 self.is_superuser = is_superuser;
157 self
158 }
159
160 /// Insert an extras entry. Chainable.
161 pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
162 self.extras.insert(key.into(), value);
163 self
164 }
165
166 /// Parse the stringified [`user_id`](Self::user_id) back into the caller's
167 /// primary-key type.
168 ///
169 /// `Identity::user_id` is a `String` — the lowest common denominator across
170 /// `i64` / `String` / UUID user models. Rather than hand-roll
171 /// `identity.user_id.parse::<i64>().map_err(|_| /* 401 */)?` in every scoped
172 /// handler (the pattern a live consumer repeated ~8×), call
173 /// `identity.user_pk::<i64>()?`. Generic over any `T: FromStr`, so it works
174 /// for numeric, string, and UUID keys alike; a parse mismatch is returned
175 /// as `T::Err` so the caller owns the HTTP shape (usually a 401/400).
176 pub fn user_pk<T: std::str::FromStr>(&self) -> Result<T, T::Err> {
177 self.user_id.parse()
178 }
179}
180
181/// The authentication contract. Inspect headers, return an `Identity`
182/// if recognised. Async because most real backends hit the DB.
183///
184/// Object-safe via `async-trait`'s `Pin<Box<...>>` desugaring; that's
185/// what makes `Arc<dyn Authentication>` work in `RestPlugin`.
186#[async_trait]
187pub trait Authentication: Send + Sync + 'static {
188 /// Try to identify the caller. `None` means "anonymous"; the
189 /// permission check decides whether to allow that.
190 ///
191 /// Returning an error isn't part of the contract — auth backends
192 /// should silently return `None` on invalid credentials and let
193 /// the permission check produce a 403. The alternative
194 /// (returning a typed error) leaks "which credential you tried"
195 /// information to the client.
196 async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity>;
197
198 /// OpenAPI `securitySchemes` entry this backend contributes —
199 /// `Some((name, scheme_value))` for documented schemes, `None`
200 /// to skip.
201 ///
202 /// `name` is the key under
203 /// `components.securitySchemes.<name>`; consumers also reference
204 /// it from operation-level `security: [{<name>: []}]` entries.
205 /// `scheme_value` is the [OpenAPI 3.0 Security Scheme Object][1]
206 /// serialised as a `serde_json::Value`.
207 ///
208 /// Default `None` — anonymous / no-auth backends contribute
209 /// nothing. Concrete classes can override when they want to
210 /// document their shape.
211 ///
212 /// [1]: https://spec.openapis.org/oas/v3.0.3#security-scheme-object
213 fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
214 None
215 }
216
217 /// All `securitySchemes` entries the backend (and any children
218 /// it might wrap) contributes. The default impl returns
219 /// `self.security_scheme().into_iter().collect()` — fine for
220 /// every leaf backend. `ChainAuthentication` overrides to walk
221 /// every child so the OpenAPI plugin can publish the full list.
222 fn security_schemes_all(&self) -> Vec<(String, serde_json::Value)> {
223 self.security_scheme().into_iter().collect()
224 }
225
226 /// True when this backend never identifies anyone — every request is
227 /// anonymous ([`NoAuthentication`]). Used only by the boot-time
228 /// security warning (WEB-1); defaults to `false` so a real backend is
229 /// never mistaken for the no-op.
230 fn is_anonymous(&self) -> bool {
231 false
232 }
233}
234
235// =========================================================================
236// Built-in: NoAuthentication — default. Always anonymous.
237// =========================================================================
238
239/// The do-nothing authenticator. Always returns `None`, so the
240/// permission check sees anonymous. Default for `RestPlugin`
241/// — opt in to real auth via `RestPlugin::authenticate`.
242#[derive(Debug, Default, Clone, Copy)]
243pub struct NoAuthentication;
244
245#[async_trait]
246impl Authentication for NoAuthentication {
247 async fn authenticate(&self, _headers: &HeaderMap) -> Option<Identity> {
248 None
249 }
250
251 fn is_anonymous(&self) -> bool {
252 true
253 }
254}
255
256// =========================================================================
257// Built-in: FnAuthentication — wrap any closure.
258// =========================================================================
259
260/// `Authentication` from a user-supplied async closure. Keeps the
261/// shape pluggable without dragging session / basic / JWT crates into
262/// `umbral-rest` itself.
263///
264/// ```ignore
265/// // Session-cookie auth via umbral-sessions:
266/// RestPlugin::default().authenticate(FnAuthentication::new(|headers| async move {
267/// let user = umbral_auth::current_user(&headers).await.ok().flatten()?;
268/// Some(Identity::user(user.id).with_staff(user.is_staff))
269/// }));
270///
271/// // HTTP Basic Auth against umbral-auth:
272/// RestPlugin::default().authenticate(FnAuthentication::new(|headers| async move {
273/// let (user, pass) = umbral::auth::parse_basic_credentials(&headers)?;
274/// let auth_user = umbral_auth::authenticate(&user, &pass).await.ok()?;
275/// Some(Identity::user(auth_user.id).with_staff(auth_user.is_staff))
276/// }));
277/// ```
278///
279/// The closure takes an owned `HeaderMap` (cheap, internal Bytes
280/// references). That lets the future capture the headers without
281/// fighting lifetimes.
282#[derive(Clone)]
283pub struct FnAuthentication {
284 f: Arc<
285 dyn Fn(HeaderMap) -> Pin<Box<dyn std::future::Future<Output = Option<Identity>> + Send>>
286 + Send
287 + Sync,
288 >,
289}
290
291impl std::fmt::Debug for FnAuthentication {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 f.debug_struct("FnAuthentication").finish_non_exhaustive()
294 }
295}
296
297impl FnAuthentication {
298 /// Wrap an async closure as an `Authentication`. The closure
299 /// receives a cloned `HeaderMap` and returns `Option<Identity>`.
300 pub fn new<F, Fut>(f: F) -> Self
301 where
302 F: Fn(HeaderMap) -> Fut + Send + Sync + 'static,
303 Fut: std::future::Future<Output = Option<Identity>> + Send + 'static,
304 {
305 Self {
306 f: Arc::new(move |headers| Box::pin(f(headers))),
307 }
308 }
309}
310
311#[async_trait]
312impl Authentication for FnAuthentication {
313 async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
314 (self.f)(headers.clone()).await
315 }
316}
317
318// =========================================================================
319// Built-in: ChainAuthentication — first-success wins.
320// =========================================================================
321
322/// Try multiple authentications in order. The first one that returns
323/// `Some(Identity)` wins; if none succeed, the request is anonymous.
324///
325/// Common case: session-cookie for browsers, HTTP Basic Auth for
326/// curl-style API consumers. Build via [`Self::new`]:
327///
328/// ```ignore
329/// let auth = ChainAuthentication::new(vec![
330/// Arc::new(session_auth) as Arc<dyn Authentication>,
331/// Arc::new(basic_auth) as Arc<dyn Authentication>,
332/// ]);
333/// RestPlugin::default().authenticate(auth);
334/// ```
335#[derive(Clone)]
336pub struct ChainAuthentication {
337 backends: Vec<Arc<dyn Authentication>>,
338}
339
340impl std::fmt::Debug for ChainAuthentication {
341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342 f.debug_struct("ChainAuthentication")
343 .field("backends_count", &self.backends.len())
344 .finish()
345 }
346}
347
348impl ChainAuthentication {
349 /// Build a chain. Order matters — first to succeed wins.
350 pub fn new(backends: Vec<Arc<dyn Authentication>>) -> Self {
351 Self { backends }
352 }
353}
354
355#[async_trait]
356impl Authentication for ChainAuthentication {
357 async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
358 for backend in &self.backends {
359 if let Some(id) = backend.authenticate(headers).await {
360 return Some(id);
361 }
362 }
363 None
364 }
365
366 fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
367 // Returns the first child's contribution for callers that
368 // only want one. The full walk lives on
369 // `security_schemes_all` below — the OpenAPI plugin uses
370 // that path so the spec publishes every scheme the chain
371 // accepts.
372 self.backends.iter().find_map(|b| b.security_scheme())
373 }
374
375 fn security_schemes_all(&self) -> Vec<(String, serde_json::Value)> {
376 self.backends
377 .iter()
378 .flat_map(|b| b.security_schemes_all())
379 .collect()
380 }
381}
382
383// =========================================================================
384// Helper: HTTP Basic Auth credential extraction.
385// =========================================================================
386
387/// Parse a `Basic <base64(user:pass)>` Authorization header into
388/// `(username, password)`. Returns `None` if the header is missing,
389/// malformed, or not Basic.
390///
391/// Provided as a free function so user-supplied `FnAuthentication`
392/// closures (the recommended way to ship HTTP Basic Auth) can reach
393/// it without re-implementing the boring base64 + colon-split logic.
394pub fn parse_basic_credentials(headers: &HeaderMap) -> Option<(String, String)> {
395 let header = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
396 let encoded = header.strip_prefix("Basic ")?;
397 let decoded = base64::engine::general_purpose::STANDARD
398 .decode(encoded)
399 .ok()?;
400 let decoded = String::from_utf8(decoded).ok()?;
401 let (user, pass) = decoded.split_once(':')?;
402 Some((user.to_string(), pass.to_string()))
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::web::header::AUTHORIZATION;
409
410 fn headers_with(name: &str, value: &str) -> HeaderMap {
411 let mut h = HeaderMap::new();
412 h.insert(
413 crate::web::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
414 value.parse().unwrap(),
415 );
416 h
417 }
418
419 #[tokio::test]
420 async fn no_authentication_always_returns_none() {
421 let headers = HeaderMap::new();
422 assert!(NoAuthentication.authenticate(&headers).await.is_none());
423 }
424
425 #[tokio::test]
426 async fn fn_authentication_invokes_closure() {
427 let auth = FnAuthentication::new(|_headers| async move { Some(Identity::user(42)) });
428 let id = auth.authenticate(&HeaderMap::new()).await.unwrap();
429 assert_eq!(id.user_id, "42");
430 assert!(!id.is_staff);
431 }
432
433 #[tokio::test]
434 async fn chain_authentication_first_success_wins() {
435 let first = FnAuthentication::new(|_| async move { None });
436 let second = FnAuthentication::new(|_| async move { Some(Identity::user(7).staff()) });
437 let third = FnAuthentication::new(|_| async move { Some(Identity::user(99)) });
438 let chain = ChainAuthentication::new(vec![
439 Arc::new(first) as Arc<dyn Authentication>,
440 Arc::new(second) as Arc<dyn Authentication>,
441 Arc::new(third) as Arc<dyn Authentication>,
442 ]);
443 let id = chain.authenticate(&HeaderMap::new()).await.unwrap();
444 // Second wins, third never runs.
445 assert_eq!(id.user_id, "7");
446 assert!(id.is_staff);
447 }
448
449 #[tokio::test]
450 async fn chain_authentication_returns_none_when_all_fail() {
451 let chain = ChainAuthentication::new(vec![
452 Arc::new(NoAuthentication) as Arc<dyn Authentication>,
453 Arc::new(NoAuthentication) as Arc<dyn Authentication>,
454 ]);
455 assert!(chain.authenticate(&HeaderMap::new()).await.is_none());
456 }
457
458 #[test]
459 fn parse_basic_credentials_extracts_user_and_pass() {
460 // "alice:secret" base64-encoded
461 let headers = headers_with(AUTHORIZATION.as_str(), "Basic YWxpY2U6c2VjcmV0");
462 let (user, pass) = parse_basic_credentials(&headers).unwrap();
463 assert_eq!(user, "alice");
464 assert_eq!(pass, "secret");
465 }
466
467 #[test]
468 fn parse_basic_credentials_returns_none_for_missing_header() {
469 assert!(parse_basic_credentials(&HeaderMap::new()).is_none());
470 }
471
472 #[test]
473 fn parse_basic_credentials_returns_none_for_wrong_scheme() {
474 let headers = headers_with(AUTHORIZATION.as_str(), "Bearer abc");
475 assert!(parse_basic_credentials(&headers).is_none());
476 }
477
478 #[test]
479 fn parse_basic_credentials_returns_none_for_invalid_base64() {
480 let headers = headers_with(AUTHORIZATION.as_str(), "Basic !!!notbase64");
481 assert!(parse_basic_credentials(&headers).is_none());
482 }
483
484 #[test]
485 fn user_pk_parses_the_stringified_pk_into_the_requested_type() {
486 // i64 PK — the common case that consumers hand-parse today.
487 let id = Identity::user(42);
488 assert_eq!(id.user_pk::<i64>().expect("i64 pk"), 42);
489 // Non-i64 PK models (String / UUID codenames) ride the same FromStr path.
490 let named = Identity::user("codename-x");
491 assert_eq!(named.user_pk::<String>().expect("string pk"), "codename-x");
492 // A PK that can't parse into the requested type is an `Err`, never a panic.
493 assert!(Identity::user("not-a-number").user_pk::<i64>().is_err());
494 }
495}