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/// Who the request belongs to, after authentication.
43///
44/// The shape is intentionally narrow: `user_id`, `is_staff`, and
45/// `is_superuser` cover most permission checks. An `extras` map carries
46/// app-specific bits (role names, organisation id, scope strings) for
47/// custom permission impls.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Identity {
50    /// The authenticated user's primary key, stringified so the same
51    /// `Identity` shape works whether the active user model has an
52    /// `i64`, `String`, or UUID primary key. Permission checks that
53    /// need the typed PK back can parse on demand
54    /// (`identity.user_id.parse::<i64>()`); the framework's own
55    /// permissions plugin and session store already speak strings.
56    pub user_id: String,
57    /// Staff flag. Used by the
58    /// built-in `IsStaff` permission class in `umbral-rest`.
59    pub is_staff: bool,
60    /// Superuser flag. A
61    /// superuser bypasses all permission checks in the built-in
62    /// permission classes; custom permission impls can consult this
63    /// field to grant unconditional access.
64    #[serde(default)]
65    pub is_superuser: bool,
66    /// App-specific extras a permission check might want to consult.
67    /// `umbral-auth` doesn't populate this; user-defined auth backends
68    /// can stuff role names, organisation ids, etc. here.
69    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
70    pub extras: std::collections::HashMap<String, serde_json::Value>,
71}
72
73impl Identity {
74    /// Convenience constructor for a non-staff user. Accepts any
75    /// stringifiable PK — `Identity::user(42)`, `Identity::user("42")`,
76    /// or `Identity::user(uuid.to_string())` all work because the
77    /// argument is `impl ToString`.
78    pub fn user(user_id: impl ToString) -> Self {
79        Self {
80            user_id: user_id.to_string(),
81            is_staff: false,
82            is_superuser: false,
83            extras: Default::default(),
84        }
85    }
86
87    /// Promote to staff. Chainable.
88    pub fn staff(mut self) -> Self {
89        self.is_staff = true;
90        self
91    }
92
93    /// Set the staff flag explicitly. Chainable.
94    pub fn with_staff(mut self, is_staff: bool) -> Self {
95        self.is_staff = is_staff;
96        self
97    }
98
99    /// Set the superuser flag explicitly. Chainable.
100    pub fn with_superuser(mut self, is_superuser: bool) -> Self {
101        self.is_superuser = is_superuser;
102        self
103    }
104
105    /// Insert an extras entry. Chainable.
106    pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
107        self.extras.insert(key.into(), value);
108        self
109    }
110}
111
112/// The authentication contract. Inspect headers, return an `Identity`
113/// if recognised. Async because most real backends hit the DB.
114///
115/// Object-safe via `async-trait`'s `Pin<Box<...>>` desugaring; that's
116/// what makes `Arc<dyn Authentication>` work in `RestPlugin`.
117#[async_trait]
118pub trait Authentication: Send + Sync + 'static {
119    /// Try to identify the caller. `None` means "anonymous"; the
120    /// permission check decides whether to allow that.
121    ///
122    /// Returning an error isn't part of the contract — auth backends
123    /// should silently return `None` on invalid credentials and let
124    /// the permission check produce a 403. The alternative
125    /// (returning a typed error) leaks "which credential you tried"
126    /// information to the client.
127    async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity>;
128
129    /// OpenAPI `securitySchemes` entry this backend contributes —
130    /// `Some((name, scheme_value))` for documented schemes, `None`
131    /// to skip.
132    ///
133    /// `name` is the key under
134    /// `components.securitySchemes.<name>`; consumers also reference
135    /// it from operation-level `security: [{<name>: []}]` entries.
136    /// `scheme_value` is the [OpenAPI 3.0 Security Scheme Object][1]
137    /// serialised as a `serde_json::Value`.
138    ///
139    /// Default `None` — anonymous / no-auth backends contribute
140    /// nothing. Concrete classes can override when they want to
141    /// document their shape.
142    ///
143    /// [1]: https://spec.openapis.org/oas/v3.0.3#security-scheme-object
144    fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
145        None
146    }
147
148    /// All `securitySchemes` entries the backend (and any children
149    /// it might wrap) contributes. The default impl returns
150    /// `self.security_scheme().into_iter().collect()` — fine for
151    /// every leaf backend. `ChainAuthentication` overrides to walk
152    /// every child so the OpenAPI plugin can publish the full list.
153    fn security_schemes_all(&self) -> Vec<(String, serde_json::Value)> {
154        self.security_scheme().into_iter().collect()
155    }
156
157    /// True when this backend never identifies anyone — every request is
158    /// anonymous ([`NoAuthentication`]). Used only by the boot-time
159    /// security warning (WEB-1); defaults to `false` so a real backend is
160    /// never mistaken for the no-op.
161    fn is_anonymous(&self) -> bool {
162        false
163    }
164}
165
166// =========================================================================
167// Built-in: NoAuthentication — default. Always anonymous.
168// =========================================================================
169
170/// The do-nothing authenticator. Always returns `None`, so the
171/// permission check sees anonymous. Default for `RestPlugin`
172/// — opt in to real auth via `RestPlugin::authenticate`.
173#[derive(Debug, Default, Clone, Copy)]
174pub struct NoAuthentication;
175
176#[async_trait]
177impl Authentication for NoAuthentication {
178    async fn authenticate(&self, _headers: &HeaderMap) -> Option<Identity> {
179        None
180    }
181
182    fn is_anonymous(&self) -> bool {
183        true
184    }
185}
186
187// =========================================================================
188// Built-in: FnAuthentication — wrap any closure.
189// =========================================================================
190
191/// `Authentication` from a user-supplied async closure. Keeps the
192/// shape pluggable without dragging session / basic / JWT crates into
193/// `umbral-rest` itself.
194///
195/// ```ignore
196/// // Session-cookie auth via umbral-sessions:
197/// RestPlugin::default().authenticate(FnAuthentication::new(|headers| async move {
198///     let user = umbral_auth::current_user(&headers).await.ok().flatten()?;
199///     Some(Identity::user(user.id).with_staff(user.is_staff))
200/// }));
201///
202/// // HTTP Basic Auth against umbral-auth:
203/// RestPlugin::default().authenticate(FnAuthentication::new(|headers| async move {
204///     let (user, pass) = umbral::auth::parse_basic_credentials(&headers)?;
205///     let auth_user = umbral_auth::authenticate(&user, &pass).await.ok()?;
206///     Some(Identity::user(auth_user.id).with_staff(auth_user.is_staff))
207/// }));
208/// ```
209///
210/// The closure takes an owned `HeaderMap` (cheap, internal Bytes
211/// references). That lets the future capture the headers without
212/// fighting lifetimes.
213#[derive(Clone)]
214pub struct FnAuthentication {
215    f: Arc<
216        dyn Fn(HeaderMap) -> Pin<Box<dyn std::future::Future<Output = Option<Identity>> + Send>>
217            + Send
218            + Sync,
219    >,
220}
221
222impl std::fmt::Debug for FnAuthentication {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        f.debug_struct("FnAuthentication").finish_non_exhaustive()
225    }
226}
227
228impl FnAuthentication {
229    /// Wrap an async closure as an `Authentication`. The closure
230    /// receives a cloned `HeaderMap` and returns `Option<Identity>`.
231    pub fn new<F, Fut>(f: F) -> Self
232    where
233        F: Fn(HeaderMap) -> Fut + Send + Sync + 'static,
234        Fut: std::future::Future<Output = Option<Identity>> + Send + 'static,
235    {
236        Self {
237            f: Arc::new(move |headers| Box::pin(f(headers))),
238        }
239    }
240}
241
242#[async_trait]
243impl Authentication for FnAuthentication {
244    async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
245        (self.f)(headers.clone()).await
246    }
247}
248
249// =========================================================================
250// Built-in: ChainAuthentication — first-success wins.
251// =========================================================================
252
253/// Try multiple authentications in order. The first one that returns
254/// `Some(Identity)` wins; if none succeed, the request is anonymous.
255///
256/// Common case: session-cookie for browsers, HTTP Basic Auth for
257/// curl-style API consumers. Build via [`Self::new`]:
258///
259/// ```ignore
260/// let auth = ChainAuthentication::new(vec![
261///     Arc::new(session_auth) as Arc<dyn Authentication>,
262///     Arc::new(basic_auth)   as Arc<dyn Authentication>,
263/// ]);
264/// RestPlugin::default().authenticate(auth);
265/// ```
266#[derive(Clone)]
267pub struct ChainAuthentication {
268    backends: Vec<Arc<dyn Authentication>>,
269}
270
271impl std::fmt::Debug for ChainAuthentication {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        f.debug_struct("ChainAuthentication")
274            .field("backends_count", &self.backends.len())
275            .finish()
276    }
277}
278
279impl ChainAuthentication {
280    /// Build a chain. Order matters — first to succeed wins.
281    pub fn new(backends: Vec<Arc<dyn Authentication>>) -> Self {
282        Self { backends }
283    }
284}
285
286#[async_trait]
287impl Authentication for ChainAuthentication {
288    async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
289        for backend in &self.backends {
290            if let Some(id) = backend.authenticate(headers).await {
291                return Some(id);
292            }
293        }
294        None
295    }
296
297    fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
298        // Returns the first child's contribution for callers that
299        // only want one. The full walk lives on
300        // `security_schemes_all` below — the OpenAPI plugin uses
301        // that path so the spec publishes every scheme the chain
302        // accepts.
303        self.backends.iter().find_map(|b| b.security_scheme())
304    }
305
306    fn security_schemes_all(&self) -> Vec<(String, serde_json::Value)> {
307        self.backends
308            .iter()
309            .flat_map(|b| b.security_schemes_all())
310            .collect()
311    }
312}
313
314// =========================================================================
315// Helper: HTTP Basic Auth credential extraction.
316// =========================================================================
317
318/// Parse a `Basic <base64(user:pass)>` Authorization header into
319/// `(username, password)`. Returns `None` if the header is missing,
320/// malformed, or not Basic.
321///
322/// Provided as a free function so user-supplied `FnAuthentication`
323/// closures (the recommended way to ship HTTP Basic Auth) can reach
324/// it without re-implementing the boring base64 + colon-split logic.
325pub fn parse_basic_credentials(headers: &HeaderMap) -> Option<(String, String)> {
326    let header = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
327    let encoded = header.strip_prefix("Basic ")?;
328    let decoded = base64::engine::general_purpose::STANDARD
329        .decode(encoded)
330        .ok()?;
331    let decoded = String::from_utf8(decoded).ok()?;
332    let (user, pass) = decoded.split_once(':')?;
333    Some((user.to_string(), pass.to_string()))
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::web::header::AUTHORIZATION;
340
341    fn headers_with(name: &str, value: &str) -> HeaderMap {
342        let mut h = HeaderMap::new();
343        h.insert(
344            crate::web::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
345            value.parse().unwrap(),
346        );
347        h
348    }
349
350    #[tokio::test]
351    async fn no_authentication_always_returns_none() {
352        let headers = HeaderMap::new();
353        assert!(NoAuthentication.authenticate(&headers).await.is_none());
354    }
355
356    #[tokio::test]
357    async fn fn_authentication_invokes_closure() {
358        let auth = FnAuthentication::new(|_headers| async move { Some(Identity::user(42)) });
359        let id = auth.authenticate(&HeaderMap::new()).await.unwrap();
360        assert_eq!(id.user_id, "42");
361        assert!(!id.is_staff);
362    }
363
364    #[tokio::test]
365    async fn chain_authentication_first_success_wins() {
366        let first = FnAuthentication::new(|_| async move { None });
367        let second = FnAuthentication::new(|_| async move { Some(Identity::user(7).staff()) });
368        let third = FnAuthentication::new(|_| async move { Some(Identity::user(99)) });
369        let chain = ChainAuthentication::new(vec![
370            Arc::new(first) as Arc<dyn Authentication>,
371            Arc::new(second) as Arc<dyn Authentication>,
372            Arc::new(third) as Arc<dyn Authentication>,
373        ]);
374        let id = chain.authenticate(&HeaderMap::new()).await.unwrap();
375        // Second wins, third never runs.
376        assert_eq!(id.user_id, "7");
377        assert!(id.is_staff);
378    }
379
380    #[tokio::test]
381    async fn chain_authentication_returns_none_when_all_fail() {
382        let chain = ChainAuthentication::new(vec![
383            Arc::new(NoAuthentication) as Arc<dyn Authentication>,
384            Arc::new(NoAuthentication) as Arc<dyn Authentication>,
385        ]);
386        assert!(chain.authenticate(&HeaderMap::new()).await.is_none());
387    }
388
389    #[test]
390    fn parse_basic_credentials_extracts_user_and_pass() {
391        // "alice:secret" base64-encoded
392        let headers = headers_with(AUTHORIZATION.as_str(), "Basic YWxpY2U6c2VjcmV0");
393        let (user, pass) = parse_basic_credentials(&headers).unwrap();
394        assert_eq!(user, "alice");
395        assert_eq!(pass, "secret");
396    }
397
398    #[test]
399    fn parse_basic_credentials_returns_none_for_missing_header() {
400        assert!(parse_basic_credentials(&HeaderMap::new()).is_none());
401    }
402
403    #[test]
404    fn parse_basic_credentials_returns_none_for_wrong_scheme() {
405        let headers = headers_with(AUTHORIZATION.as_str(), "Bearer abc");
406        assert!(parse_basic_credentials(&headers).is_none());
407    }
408
409    #[test]
410    fn parse_basic_credentials_returns_none_for_invalid_base64() {
411        let headers = headers_with(AUTHORIZATION.as_str(), "Basic !!!notbase64");
412        assert!(parse_basic_credentials(&headers).is_none());
413    }
414}