Skip to main content

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    /// **Deprecated: use [`pk`](Self::pk).** `user_pk` and `pk` do the same
170    /// parse; they differ only in the error type. `user_pk` returns the bare
171    /// `T::Err`, which carries no context about *what* failed to parse, while
172    /// [`pk`](Self::pk) returns a structured [`IdentityPkError`] that names the
173    /// unparseable value and the target type. Two methods for one operation is
174    /// the confusion this collapses — `pk` is canonical; `user_pk` is retained
175    /// only so existing callers keep compiling and is slated for removal.
176    ///
177    /// `Identity::user_id` is a `String` — the lowest common denominator across
178    /// `i64` / `String` / UUID user models. Generic over any `T: FromStr`, so it
179    /// works for numeric, string, and UUID keys alike.
180    #[deprecated(note = "use `Identity::pk` for a structured `IdentityPkError`")]
181    pub fn user_pk<T: std::str::FromStr>(&self) -> Result<T, T::Err> {
182        self.user_id.parse()
183    }
184}
185
186/// The authentication contract. Inspect headers, return an `Identity`
187/// if recognised. Async because most real backends hit the DB.
188///
189/// Object-safe via `async-trait`'s `Pin<Box<...>>` desugaring; that's
190/// what makes `Arc<dyn Authentication>` work in `RestPlugin`.
191#[async_trait]
192pub trait Authentication: Send + Sync + 'static {
193    /// Try to identify the caller. `None` means "anonymous"; the
194    /// permission check decides whether to allow that.
195    ///
196    /// Returning an error isn't part of the contract — auth backends
197    /// should silently return `None` on invalid credentials and let
198    /// the permission check produce a 403. The alternative
199    /// (returning a typed error) leaks "which credential you tried"
200    /// information to the client.
201    async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity>;
202
203    /// OpenAPI `securitySchemes` entry this backend contributes —
204    /// `Some((name, scheme_value))` for documented schemes, `None`
205    /// to skip.
206    ///
207    /// `name` is the key under
208    /// `components.securitySchemes.<name>`; consumers also reference
209    /// it from operation-level `security: [{<name>: []}]` entries.
210    /// `scheme_value` is the [OpenAPI 3.0 Security Scheme Object][1]
211    /// serialised as a `serde_json::Value`.
212    ///
213    /// Default `None` — anonymous / no-auth backends contribute
214    /// nothing. Concrete classes can override when they want to
215    /// document their shape.
216    ///
217    /// [1]: https://spec.openapis.org/oas/v3.0.3#security-scheme-object
218    fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
219        None
220    }
221
222    /// All `securitySchemes` entries the backend (and any children
223    /// it might wrap) contributes. The default impl returns
224    /// `self.security_scheme().into_iter().collect()` — fine for
225    /// every leaf backend. `ChainAuthentication` overrides to walk
226    /// every child so the OpenAPI plugin can publish the full list.
227    fn security_schemes_all(&self) -> Vec<(String, serde_json::Value)> {
228        self.security_scheme().into_iter().collect()
229    }
230
231    /// True when this backend never identifies anyone — every request is
232    /// anonymous ([`NoAuthentication`]). Used only by the boot-time
233    /// security warning (WEB-1); defaults to `false` so a real backend is
234    /// never mistaken for the no-op.
235    fn is_anonymous(&self) -> bool {
236        false
237    }
238}
239
240// =========================================================================
241// Built-in: NoAuthentication — default. Always anonymous.
242// =========================================================================
243
244/// The do-nothing authenticator. Always returns `None`, so the
245/// permission check sees anonymous. Default for `RestPlugin`
246/// — opt in to real auth via `RestPlugin::authenticate`.
247#[derive(Debug, Default, Clone, Copy)]
248pub struct NoAuthentication;
249
250#[async_trait]
251impl Authentication for NoAuthentication {
252    async fn authenticate(&self, _headers: &HeaderMap) -> Option<Identity> {
253        None
254    }
255
256    fn is_anonymous(&self) -> bool {
257        true
258    }
259}
260
261// =========================================================================
262// Built-in: FnAuthentication — wrap any closure.
263// =========================================================================
264
265/// `Authentication` from a user-supplied async closure. Keeps the
266/// shape pluggable without dragging session / basic / JWT crates into
267/// `umbral-rest` itself.
268///
269/// ```ignore
270/// // Session-cookie auth via umbral-sessions:
271/// RestPlugin::default().authenticate(FnAuthentication::new(|headers| async move {
272///     let user = umbral_auth::current_user(&headers).await.ok().flatten()?;
273///     Some(Identity::user(user.id).with_staff(user.is_staff))
274/// }));
275///
276/// // HTTP Basic Auth against umbral-auth:
277/// RestPlugin::default().authenticate(FnAuthentication::new(|headers| async move {
278///     let (user, pass) = umbral::auth::parse_basic_credentials(&headers)?;
279///     let auth_user = umbral_auth::authenticate(&user, &pass).await.ok()?;
280///     Some(Identity::user(auth_user.id).with_staff(auth_user.is_staff))
281/// }));
282/// ```
283///
284/// The closure takes an owned `HeaderMap` (cheap, internal Bytes
285/// references). That lets the future capture the headers without
286/// fighting lifetimes.
287#[derive(Clone)]
288pub struct FnAuthentication {
289    f: Arc<
290        dyn Fn(HeaderMap) -> Pin<Box<dyn std::future::Future<Output = Option<Identity>> + Send>>
291            + Send
292            + Sync,
293    >,
294}
295
296impl std::fmt::Debug for FnAuthentication {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        f.debug_struct("FnAuthentication").finish_non_exhaustive()
299    }
300}
301
302impl FnAuthentication {
303    /// Wrap an async closure as an `Authentication`. The closure
304    /// receives a cloned `HeaderMap` and returns `Option<Identity>`.
305    pub fn new<F, Fut>(f: F) -> Self
306    where
307        F: Fn(HeaderMap) -> Fut + Send + Sync + 'static,
308        Fut: std::future::Future<Output = Option<Identity>> + Send + 'static,
309    {
310        Self {
311            f: Arc::new(move |headers| Box::pin(f(headers))),
312        }
313    }
314}
315
316#[async_trait]
317impl Authentication for FnAuthentication {
318    async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
319        (self.f)(headers.clone()).await
320    }
321}
322
323// =========================================================================
324// Built-in: ChainAuthentication — first-success wins.
325// =========================================================================
326
327/// Try multiple authentications in order. The first one that returns
328/// `Some(Identity)` wins; if none succeed, the request is anonymous.
329///
330/// Common case: session-cookie for browsers, HTTP Basic Auth for
331/// curl-style API consumers. Build via [`Self::new`]:
332///
333/// ```ignore
334/// let auth = ChainAuthentication::new(vec![
335///     Arc::new(session_auth) as Arc<dyn Authentication>,
336///     Arc::new(basic_auth)   as Arc<dyn Authentication>,
337/// ]);
338/// RestPlugin::default().authenticate(auth);
339/// ```
340#[derive(Clone)]
341pub struct ChainAuthentication {
342    backends: Vec<Arc<dyn Authentication>>,
343}
344
345impl std::fmt::Debug for ChainAuthentication {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        f.debug_struct("ChainAuthentication")
348            .field("backends_count", &self.backends.len())
349            .finish()
350    }
351}
352
353impl ChainAuthentication {
354    /// Build a chain. Order matters — first to succeed wins.
355    pub fn new(backends: Vec<Arc<dyn Authentication>>) -> Self {
356        Self { backends }
357    }
358}
359
360#[async_trait]
361impl Authentication for ChainAuthentication {
362    async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
363        for backend in &self.backends {
364            if let Some(id) = backend.authenticate(headers).await {
365                return Some(id);
366            }
367        }
368        None
369    }
370
371    fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
372        // Returns the first child's contribution for callers that
373        // only want one. The full walk lives on
374        // `security_schemes_all` below — the OpenAPI plugin uses
375        // that path so the spec publishes every scheme the chain
376        // accepts.
377        self.backends.iter().find_map(|b| b.security_scheme())
378    }
379
380    fn security_schemes_all(&self) -> Vec<(String, serde_json::Value)> {
381        self.backends
382            .iter()
383            .flat_map(|b| b.security_schemes_all())
384            .collect()
385    }
386}
387
388// =========================================================================
389// The app-wide default authentication backend (gaps4 #42).
390// =========================================================================
391
392/// The backend [`AppBuilder::authentication`](crate::app::AppBuilder)
393/// published at build time, if any.
394static DEFAULT_AUTH: std::sync::OnceLock<Arc<dyn Authentication>> = std::sync::OnceLock::new();
395
396/// Publish the app-wide default [`Authentication`] backend. Called once by
397/// `App::build()` (Phase 3, before plugin routes are collected) when the
398/// app used `AppBuilder::authentication`. Second calls are ignored with a
399/// warning — one app, one default.
400pub fn set_default_authentication(auth: Arc<dyn Authentication>) {
401    if DEFAULT_AUTH.set(auth).is_err() {
402        tracing::warn!(
403            "umbral: a default authentication backend is already installed; \
404             ignoring this one (AppBuilder::authentication may only be used once)"
405        );
406    }
407}
408
409/// The app-wide default [`Authentication`] backend, if the app installed
410/// one via `AppBuilder::authentication` (gaps4 #42).
411///
412/// Plugins that authenticate requests (REST, GraphQL, realtime) fall back
413/// to this when no per-plugin backend was configured, so ONE builder line
414/// serves every surface — the alternative was pasting the same
415/// `ChainAuthentication` block into each plugin, where forgetting one copy
416/// silently made that surface anonymous. A per-plugin `.authenticate(...)`
417/// still overrides it.
418pub fn default_authentication() -> Option<Arc<dyn Authentication>> {
419    DEFAULT_AUTH.get().cloned()
420}
421
422// =========================================================================
423// Helper: HTTP Basic Auth credential extraction.
424// =========================================================================
425
426/// Parse a `Basic <base64(user:pass)>` Authorization header into
427/// `(username, password)`. Returns `None` if the header is missing,
428/// malformed, or not Basic.
429///
430/// Provided as a free function so user-supplied `FnAuthentication`
431/// closures (the recommended way to ship HTTP Basic Auth) can reach
432/// it without re-implementing the boring base64 + colon-split logic.
433pub fn parse_basic_credentials(headers: &HeaderMap) -> Option<(String, String)> {
434    let header = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
435    let encoded = header.strip_prefix("Basic ")?;
436    let decoded = base64::engine::general_purpose::STANDARD
437        .decode(encoded)
438        .ok()?;
439    let decoded = String::from_utf8(decoded).ok()?;
440    let (user, pass) = decoded.split_once(':')?;
441    Some((user.to_string(), pass.to_string()))
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::web::header::AUTHORIZATION;
448
449    fn headers_with(name: &str, value: &str) -> HeaderMap {
450        let mut h = HeaderMap::new();
451        h.insert(
452            crate::web::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
453            value.parse().unwrap(),
454        );
455        h
456    }
457
458    #[tokio::test]
459    async fn no_authentication_always_returns_none() {
460        let headers = HeaderMap::new();
461        assert!(NoAuthentication.authenticate(&headers).await.is_none());
462    }
463
464    #[tokio::test]
465    async fn fn_authentication_invokes_closure() {
466        let auth = FnAuthentication::new(|_headers| async move { Some(Identity::user(42)) });
467        let id = auth.authenticate(&HeaderMap::new()).await.unwrap();
468        assert_eq!(id.user_id, "42");
469        assert!(!id.is_staff);
470    }
471
472    #[tokio::test]
473    async fn chain_authentication_first_success_wins() {
474        let first = FnAuthentication::new(|_| async move { None });
475        let second = FnAuthentication::new(|_| async move { Some(Identity::user(7).staff()) });
476        let third = FnAuthentication::new(|_| async move { Some(Identity::user(99)) });
477        let chain = ChainAuthentication::new(vec![
478            Arc::new(first) as Arc<dyn Authentication>,
479            Arc::new(second) as Arc<dyn Authentication>,
480            Arc::new(third) as Arc<dyn Authentication>,
481        ]);
482        let id = chain.authenticate(&HeaderMap::new()).await.unwrap();
483        // Second wins, third never runs.
484        assert_eq!(id.user_id, "7");
485        assert!(id.is_staff);
486    }
487
488    #[tokio::test]
489    async fn chain_authentication_returns_none_when_all_fail() {
490        let chain = ChainAuthentication::new(vec![
491            Arc::new(NoAuthentication) as Arc<dyn Authentication>,
492            Arc::new(NoAuthentication) as Arc<dyn Authentication>,
493        ]);
494        assert!(chain.authenticate(&HeaderMap::new()).await.is_none());
495    }
496
497    #[test]
498    fn parse_basic_credentials_extracts_user_and_pass() {
499        // "alice:secret" base64-encoded
500        let headers = headers_with(AUTHORIZATION.as_str(), "Basic YWxpY2U6c2VjcmV0");
501        let (user, pass) = parse_basic_credentials(&headers).unwrap();
502        assert_eq!(user, "alice");
503        assert_eq!(pass, "secret");
504    }
505
506    #[test]
507    fn parse_basic_credentials_returns_none_for_missing_header() {
508        assert!(parse_basic_credentials(&HeaderMap::new()).is_none());
509    }
510
511    #[test]
512    fn parse_basic_credentials_returns_none_for_wrong_scheme() {
513        let headers = headers_with(AUTHORIZATION.as_str(), "Bearer abc");
514        assert!(parse_basic_credentials(&headers).is_none());
515    }
516
517    #[test]
518    fn parse_basic_credentials_returns_none_for_invalid_base64() {
519        let headers = headers_with(AUTHORIZATION.as_str(), "Basic !!!notbase64");
520        assert!(parse_basic_credentials(&headers).is_none());
521    }
522
523    #[test]
524    #[allow(deprecated)] // exercising `user_pk` specifically; `pk` is the canonical replacement.
525    fn user_pk_parses_the_stringified_pk_into_the_requested_type() {
526        // i64 PK — the common case that consumers hand-parse today.
527        let id = Identity::user(42);
528        assert_eq!(id.user_pk::<i64>().expect("i64 pk"), 42);
529        // Non-i64 PK models (String / UUID codenames) ride the same FromStr path.
530        let named = Identity::user("codename-x");
531        assert_eq!(named.user_pk::<String>().expect("string pk"), "codename-x");
532        // A PK that can't parse into the requested type is an `Err`, never a panic.
533        assert!(Identity::user("not-a-number").user_pk::<i64>().is_err());
534    }
535}