Skip to main content

siderite_core/security/
mod.rs

1//! Security schemes as self-documenting extractors: HTTP Bearer, HTTP
2//! Basic, API keys and the OAuth2 password flow.
3//!
4//! Every scheme is a [`FromRequestParts`] extractor. Besides reading the
5//! credentials, it documents itself: it registers an entry under
6//! `components.securitySchemes` and adds a security requirement to the
7//! operation, so the OpenAPI document mirrors the handler signature.
8//!
9//! Missing or malformed credentials yield `401 Unauthorized` with a
10//! `WWW-Authenticate` challenge (`Bearer`, or `Basic realm="api"`). API keys
11//! have no standard challenge, so they answer `401` without one.
12//!
13//! # Verifying credentials
14//!
15//! The extractors only *read* credentials; they never decide whether they
16//! are valid. Implement [`Authenticate`] to turn credentials into a
17//! principal and use [`Security`] to run it. When comparing secrets (API
18//! keys, passwords, tokens) use a constant-time comparison such as the
19//! `subtle` crate: `==` on strings leaks timing information. That is the
20//! application's responsibility, not this module's.
21//!
22//! ```
23//! use siderite_core::security::{Authenticate, HttpBearer, Security, check_scopes};
24//! use siderite_core::{ApiError, scopes};
25//! use http::StatusCode;
26//! use http::request::Parts;
27//!
28//! struct CurrentUser(String);
29//!
30//! impl Authenticate for CurrentUser {
31//!     type Credentials = HttpBearer;
32//!
33//!     async fn authenticate(
34//!         credentials: HttpBearer,
35//!         required: &[&'static str],
36//!         _parts: &Parts,
37//!     ) -> Result<Self, ApiError> {
38//!         // Look the token up in your own store here.
39//!         let granted = ["read"];
40//!         check_scopes(required, granted)?;
41//!         Ok(CurrentUser(credentials.token))
42//!     }
43//! }
44//!
45//! scopes!(ReadScopes = ["read"]);
46//!
47//! async fn me(Security(user, _): Security<CurrentUser, ReadScopes>) -> String {
48//!     user.0
49//! }
50//! # let _ = me;
51//! ```
52//!
53//! Password-flow types are legacy compatibility. New OAuth applications use
54//! authorization code with PKCE; extraction does not validate access tokens.
55
56mod api_key;
57mod http_auth;
58mod oauth2;
59
60pub use api_key::{ApiKey, ApiKeyLocation, ApiKeySpec};
61pub use http_auth::{HttpBasic, HttpBearer};
62pub use oauth2::{OAuth2PasswordBearer, OAuth2PasswordRequestForm, OAuth2Spec};
63
64use crate::error::ApiError;
65use crate::extract::FromRequestParts;
66use http::header::{AUTHORIZATION, WWW_AUTHENTICATE};
67use http::request::Parts;
68use http::{HeaderValue, StatusCode};
69use serde_json::Value;
70use siderite_openapi::{Operation, SchemaRegistry};
71use std::collections::BTreeMap;
72use std::fmt;
73use std::future::Future;
74use std::marker::PhantomData;
75
76const NOT_AUTHENTICATED: &str = "Not authenticated.";
77
78/// `401 Unauthorized` carrying a `WWW-Authenticate` challenge.
79pub(crate) fn unauthorized(challenge: &'static str, detail: &'static str) -> ApiError {
80    ApiError::new(StatusCode::UNAUTHORIZED, detail)
81        .with_header(WWW_AUTHENTICATE, HeaderValue::from_static(challenge))
82}
83
84/// The `Authorization` header value after `scheme` (case-insensitive) and
85/// its separating spaces, when present and non-empty.
86pub(crate) fn authorization_credentials<'a>(parts: &'a Parts, scheme: &str) -> Option<&'a str> {
87    let value = parts.headers.get(AUTHORIZATION)?.to_str().ok()?;
88    let (name, rest) = value.split_once(' ')?;
89    let credentials = rest.trim_start_matches(' ');
90    (name.eq_ignore_ascii_case(scheme) && !credentials.is_empty()).then_some(credentials)
91}
92
93/// Bearer token from the `Authorization` header.
94///
95/// # Errors
96/// `401` with a `Bearer` challenge when the header is missing, uses another
97/// scheme, or the token is empty or contains whitespace.
98pub(crate) fn bearer_token(parts: &Parts) -> Result<String, ApiError> {
99    match authorization_credentials(parts, "Bearer") {
100        Some(token) if !token.contains(char::is_whitespace) => Ok(token.to_owned()),
101        Some(_) => Err(unauthorized("Bearer", "Invalid bearer token.")),
102        None => Err(unauthorized("Bearer", NOT_AUTHENTICATED).absent()),
103    }
104}
105
106/// Register `definition` under `name` and require it on `op` with `scopes`.
107///
108/// The requirement is skipped when an identical one is already present.
109pub(crate) fn document_scheme(
110    op: &mut Operation,
111    registry: &mut SchemaRegistry,
112    name: &str,
113    definition: Value,
114    scopes: &[&str],
115) {
116    registry.add_security_scheme(name, definition);
117    push_requirement(op, name, scopes);
118}
119
120/// Require `{name: scopes}` on `op`.
121///
122/// OpenAPI lists security requirement objects as *alternatives* and the
123/// schemes inside one object as *all required*. Extractors in one handler
124/// are all required, so the scheme is added to every existing alternative
125/// (or becomes the first one).
126pub(crate) fn push_requirement(op: &mut Operation, name: &str, scopes: &[&str]) {
127    let scopes: Vec<String> = scopes.iter().map(|s| (*s).to_owned()).collect();
128    if op.security.is_empty() {
129        op.security.push(BTreeMap::new());
130    }
131    for alternative in &mut op.security {
132        alternative.insert(name.to_owned(), scopes.clone());
133    }
134}
135
136/// Build the `403 Forbidden` error for a principal lacking scopes.
137///
138/// Returns `Ok(())` when every scope in `required` appears in `granted`.
139///
140/// # Errors
141/// `403` naming the missing scopes (scope names are not secret).
142///
143/// # Examples
144/// ```
145/// use siderite_core::security::check_scopes;
146/// assert!(check_scopes(&["read"], ["read", "write"]).is_ok());
147/// assert_eq!(check_scopes(&["admin"], ["read"]).unwrap_err().status(), 403);
148/// ```
149pub fn check_scopes<'a>(
150    required: &[&str],
151    granted: impl IntoIterator<Item = &'a str>,
152) -> Result<(), ApiError> {
153    let granted: Vec<&str> = granted.into_iter().collect();
154    let missing: Vec<&str> = required
155        .iter()
156        .copied()
157        .filter(|scope| !granted.contains(scope))
158        .collect();
159    if missing.is_empty() {
160        Ok(())
161    } else {
162        Err(ApiError::new(
163            StatusCode::FORBIDDEN,
164            format!("Missing required scopes: {}.", missing.join(", ")),
165        ))
166    }
167}
168
169/// Required scopes, as a marker type (const `&str` generics are unstable).
170///
171/// Declare one with the [`scopes!`](crate::scopes) macro.
172pub trait Scopes: Send + Sync + 'static {
173    /// The scopes a request must hold.
174    const SCOPES: &'static [&'static str];
175}
176
177/// No required scopes (the default for [`Security`]).
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
179pub struct NoScopes;
180
181impl Scopes for NoScopes {
182    const SCOPES: &'static [&'static str] = &[];
183}
184
185/// Declare a [`Scopes`] marker type.
186///
187/// ```
188/// use siderite_core::security::Scopes;
189/// siderite_core::scopes!(pub AdminScopes = ["users:read", "users:write"]);
190/// assert_eq!(AdminScopes::SCOPES, ["users:read", "users:write"]);
191/// ```
192#[macro_export]
193macro_rules! scopes {
194    ($(#[$meta:meta])* $vis:vis $name:ident = [$($scope:literal),* $(,)?]) => {
195        $(#[$meta])*
196        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
197        $vis struct $name;
198
199        impl $crate::security::Scopes for $name {
200            const SCOPES: &'static [&'static str] = &[$($scope),*];
201        }
202    };
203}
204
205/// User-defined principal built from a scheme's credentials.
206///
207/// `authenticate` receives the scopes the endpoint requires and the request
208/// [`Parts`]; application state set with `App::with_state` is reachable
209/// through `parts.extensions`. Return `401` for invalid credentials and
210/// `403` (see [`check_scopes`]) for missing scopes.
211pub trait Authenticate: Sized + Send + 'static {
212    /// The scheme that supplies the credentials.
213    type Credentials: FromRequestParts;
214
215    /// Validate `credentials` and build the principal.
216    ///
217    /// # Errors
218    /// An [`ApiError`], typically `401` or `403`.
219    fn authenticate(
220        credentials: Self::Credentials,
221        required: &[&'static str],
222        parts: &Parts,
223    ) -> impl Future<Output = Result<Self, ApiError>> + Send;
224}
225
226/// `Security<CurrentUser, AdminScopes>`: extracts credentials, runs
227/// [`Authenticate::authenticate`] (`403` on missing scopes), and documents
228/// the scheme together with the required scopes.
229///
230/// The second field is a type marker; destructure with
231/// `Security(user, _)`.
232pub struct Security<T: Authenticate, S: Scopes = NoScopes>(pub T, pub PhantomData<fn() -> S>);
233
234impl<T: Authenticate + fmt::Debug, S: Scopes> fmt::Debug for Security<T, S> {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        f.debug_tuple("Security").field(&self.0).finish()
237    }
238}
239
240impl<T: Authenticate, S: Scopes> FromRequestParts for Security<T, S> {
241    async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
242        let credentials = T::Credentials::from_request_parts(parts).await?;
243        let principal = T::authenticate(credentials, S::SCOPES, parts).await?;
244        Ok(Self(principal, PhantomData))
245    }
246
247    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
248        // Let the scheme document itself, then re-emit what it added with
249        // this endpoint's scopes.
250        let existing = std::mem::take(&mut op.security);
251        <T::Credentials as FromRequestParts>::describe(op, registry);
252        let added = std::mem::replace(&mut op.security, existing);
253        let names: std::collections::BTreeSet<String> =
254            added.into_iter().flat_map(BTreeMap::into_keys).collect();
255        for name in names {
256            push_requirement(op, &name, S::SCOPES);
257        }
258    }
259}