Skip to main content

trillium_basic_auth/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(
3    clippy::dbg_macro,
4    missing_copy_implementations,
5    rustdoc::missing_crate_level_docs,
6    missing_debug_implementations,
7    missing_docs,
8    nonstandard_style,
9    unused_qualifications
10)]
11
12//! Basic authentication for trillium.rs
13//!
14//! ```rust,no_run
15//! use trillium_basic_auth::BasicAuth;
16//! trillium_smol::run((
17//!     BasicAuth::new("trillium", "7r1ll1um").with_realm("rust"),
18//!     |conn: trillium::Conn| async move { conn.ok("authenticated") },
19//! ));
20//! ```
21//!
22//! Requests that do not carry acceptable credentials are halted with a `401 Unauthorized` and a
23//! `WWW-Authenticate` challenge, so handlers placed after [`BasicAuth`] only run for
24//! authenticated requests. The authenticated username is available downstream through
25//! [`BasicAuthConnExt::basic_auth_username`].
26//!
27//! Credentials can be checked against a single configured username and password
28//! ([`BasicAuth::new`]) or against a predicate of your own ([`BasicAuth::validate_fn`],
29//! [`BasicAuth::validate_async_fn`]).
30//!
31//! Because HTTP Basic transmits the password in a reversible encoding on every request, it is
32//! only as confidential as the transport underneath it. Use it over https.
33
34#[cfg(test)]
35#[doc = include_str!("../README.md")]
36mod readme {}
37
38use base64::{
39    Engine,
40    engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as BASE64_NO_PAD},
41};
42use sha2::{Digest, Sha256};
43use std::{
44    fmt::{self, Debug, Formatter},
45    future::Future,
46    pin::Pin,
47};
48use subtle::ConstantTimeEq;
49use trillium::{
50    Conn, Handler,
51    KnownHeaderName::{Authorization, WwwAuthenticate},
52    Status,
53};
54
55const SCHEME: &str = "Basic ";
56
57/// basic auth handler
58#[derive(Debug)]
59pub struct BasicAuth {
60    validation: Validation,
61    realm: Option<String>,
62    www_authenticate: String,
63}
64
65enum Validation {
66    /// sha256 of a single configured credential, compared in constant time
67    Digest([u8; 32]),
68    Predicate(PredicateFn),
69    AsyncPredicate(AsyncPredicateFn),
70}
71
72impl Debug for Validation {
73    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
74        let name = match self {
75            Self::Digest(_) => "Digest",
76            Self::Predicate(_) => "Predicate",
77            Self::AsyncPredicate(_) => "AsyncPredicate",
78        };
79        f.debug_tuple(name).field(&format_args!("..")).finish()
80    }
81}
82
83struct PredicateFn(Box<dyn Fn(&Credentials) -> bool + Send + Sync + 'static>);
84
85type BoxFuture = Pin<Box<dyn Future<Output = bool> + Send + 'static>>;
86struct AsyncPredicateFn(Box<dyn Fn(Credentials) -> BoxFuture + Send + Sync + 'static>);
87
88/// basic auth username-password credentials
89#[derive(Clone, PartialEq, Eq, fieldwork::Fieldwork)]
90#[fieldwork(get)]
91pub struct Credentials {
92    /// username
93    username: String,
94
95    /// password
96    password: String,
97}
98
99impl Debug for Credentials {
100    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
101        f.debug_struct("Credentials")
102            .field("username", &self.username)
103            .field("password", &"<<secret>>")
104            .finish()
105    }
106}
107
108impl Credentials {
109    /// build credentials from a username and password
110    ///
111    /// A username that contains a colon can never be sent by a client, because HTTP Basic
112    /// separates the two with the first colon.
113    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
114        Self {
115            username: username.into(),
116            password: password.into(),
117        }
118    }
119
120    /// Extract and decode the credentials from a conn's `Authorization` header, if it carries
121    /// well-formed Basic credentials.
122    ///
123    /// This performs no validation whatsoever — it is the parsing half of this crate, for
124    /// applications that need the password itself, such as the convention of sending an api key
125    /// as the password with a placeholder username.
126    pub fn from_conn(conn: &Conn) -> Option<Self> {
127        Self::from_header(conn.request_headers().get_str(Authorization)?)
128    }
129
130    fn from_header(header: &str) -> Option<Self> {
131        let token = header
132            .get(..SCHEME.len())
133            .filter(|scheme| scheme.eq_ignore_ascii_case(SCHEME))
134            .map(|scheme| &header[scheme.len()..])?;
135
136        let decoded = BASE64
137            .decode(token)
138            .or_else(|_| BASE64_NO_PAD.decode(token))
139            .ok()?;
140        let decoded = String::from_utf8(decoded).ok()?;
141        let (username, password) = decoded.split_once(':')?;
142        Some(Self::new(username, password))
143    }
144
145    /// The digest is over a length-prefixed username so that no two distinct credentials share
146    /// one, even though `:` cannot appear in a username received from a client.
147    fn digest(&self) -> [u8; 32] {
148        Sha256::new()
149            .chain_update(
150                u64::try_from(self.username.len())
151                    .unwrap_or(u64::MAX)
152                    .to_le_bytes(),
153            )
154            .chain_update(&self.username)
155            .chain_update(&self.password)
156            .finalize()
157            .into()
158    }
159}
160
161impl BasicAuth {
162    /// build a new basic auth handler that accepts exactly this username and password
163    ///
164    /// Only a digest of the credentials is retained, and it is compared in constant time.
165    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
166        Self::from_validation(Validation::Digest(
167            Credentials::new(username, password).digest(),
168        ))
169    }
170
171    /// build a new basic auth handler that accepts any credentials for which the provided
172    /// predicate returns true
173    ///
174    /// ```
175    /// # use trillium_basic_auth::BasicAuth;
176    /// let basic_auth = BasicAuth::validate_fn(|credentials| {
177    ///     credentials.username().starts_with("admin-")
178    ///         && credentials.password() == std::env::var("ADMIN_PASSWORD").unwrap()
179    /// });
180    /// ```
181    ///
182    /// A predicate that compares secrets should do so in constant time, as [`BasicAuth::new`]
183    /// does.
184    pub fn validate_fn<F>(predicate: F) -> Self
185    where
186        F: Fn(&Credentials) -> bool + Send + Sync + 'static,
187    {
188        Self::from_validation(Validation::Predicate(PredicateFn(Box::new(predicate))))
189    }
190
191    /// build a new basic auth handler that accepts any credentials for which the provided async
192    /// predicate returns true, such as a database lookup and a password hash comparison
193    ///
194    /// ```
195    /// # use trillium_basic_auth::BasicAuth;
196    /// # async fn look_up(username: &str) -> Option<String> { None }
197    /// let basic_auth = BasicAuth::validate_async_fn(|credentials| async move {
198    ///     match look_up(credentials.username()).await {
199    ///         Some(hash) => verify(credentials.password(), &hash),
200    ///         None => false,
201    ///     }
202    /// });
203    /// # fn verify(password: &str, hash: &str) -> bool { false }
204    /// ```
205    pub fn validate_async_fn<F, Fut>(predicate: F) -> Self
206    where
207        F: Fn(Credentials) -> Fut + Send + Sync + 'static,
208        Fut: Future<Output = bool> + Send + 'static,
209    {
210        Self::from_validation(Validation::AsyncPredicate(AsyncPredicateFn(Box::new(
211            move |credentials| Box::pin(predicate(credentials)),
212        ))))
213    }
214
215    fn from_validation(validation: Validation) -> Self {
216        Self {
217            validation,
218            realm: None,
219            www_authenticate: String::from("Basic"),
220        }
221    }
222
223    /// provide a realm for the www-authenticate response sent by this handler
224    pub fn with_realm(mut self, realm: &str) -> Self {
225        self.www_authenticate = format!("Basic realm=\"{}\"", realm.replace('\"', "\\\""));
226        self.realm = Some(String::from(realm));
227        self
228    }
229
230    /// the realm provided to [`BasicAuth::with_realm`], if any
231    pub fn realm(&self) -> Option<&str> {
232        self.realm.as_deref()
233    }
234
235    async fn is_allowed(&self, credentials: &Credentials) -> bool {
236        match &self.validation {
237            Validation::Digest(expected) => credentials.digest().ct_eq(expected).into(),
238            Validation::Predicate(PredicateFn(predicate)) => predicate(credentials),
239            Validation::AsyncPredicate(AsyncPredicateFn(predicate)) => {
240                predicate(credentials.clone()).await
241            }
242        }
243    }
244
245    fn deny(&self, conn: Conn) -> Conn {
246        conn.with_status(Status::Unauthorized)
247            .with_response_header(WwwAuthenticate, self.www_authenticate.clone())
248            .halt()
249    }
250}
251
252struct AuthenticatedUsername(String);
253
254/// extension trait for reading the authenticated username
255pub trait BasicAuthConnExt {
256    /// the username that [`BasicAuth`] accepted for this conn, if any
257    fn basic_auth_username(&self) -> Option<&str>;
258}
259
260impl BasicAuthConnExt for Conn {
261    fn basic_auth_username(&self) -> Option<&str> {
262        self.state::<AuthenticatedUsername>()
263            .map(|AuthenticatedUsername(username)| &**username)
264    }
265}
266
267impl Handler for BasicAuth {
268    async fn run(&self, conn: Conn) -> Conn {
269        let Some(credentials) = Credentials::from_conn(&conn) else {
270            return self.deny(conn);
271        };
272
273        if self.is_allowed(&credentials).await {
274            conn.with_state(AuthenticatedUsername(credentials.username))
275        } else {
276            self.deny(conn)
277        }
278    }
279}