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}