Skip to main content

ullun/
auth.rs

1//! API-key and HTTP Basic authentication middleware
2//!
3//! Small, dependency-free authentication for service-to-service calls, admin
4//! endpoints and internal tools. Secrets are compared in constant time against
5//! every configured entry (no early exit that would show which key was close),
6//! failures answer `401` with `WWW-Authenticate`, and the name of the
7//! authenticated caller is stored for handlers:
8//!
9//! ```no_run
10//! use ullun::auth::{ApiKeyAuth, BasicAuth};
11//! use ullun::prelude::*;
12//!
13//! # async fn run() {
14//! App::new()
15//!     .group("/internal", |g| {
16//!         g.middleware(ApiKeyAuth::new().key("billing-service", "k_live_9c1e7a"))
17//!             .get("/who", |req: Request| async move {
18//!                 Ok(Response::text(req.principal().unwrap_or("nobody").to_string()))
19//!             })
20//!     })
21//!     .group("/admin", |g| {
22//!         g.middleware(BasicAuth::new("admin area").user("ops", "correct horse battery staple"))
23//!             .get("/", |_req: Request| async { Ok(Response::text("welcome")) })
24//!     })
25//!     .run("127.0.0.1:3000")
26//!     .await
27//!     .unwrap();
28//! # }
29//! ```
30//!
31//! Keys and passwords live in memory as given: load them from the environment
32//! or a secret store, never hard-code real ones. Basic credentials are only
33//! protected by TLS. For per-user passwords stored in a database use
34//! [`BasicAuth::verify_with`] and a password hash (argon2, bcrypt) of your choice.
35//! Failed attempts are not rate limited here: put a
36//! [`RateLimiter`](crate::rate_limit::RateLimiter) in front.
37
38use crate::error::Result;
39use crate::handler::BoxFuture;
40use crate::middleware::{Middleware, Next};
41use crate::request::Request;
42use crate::response::Response;
43use crate::security::constant_time_eq;
44use async_trait::async_trait;
45use std::sync::Arc;
46
47/// The authenticated caller, stored in `Request::extensions`
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Principal(pub String);
50
51impl Request {
52    /// Name of the caller authenticated by [`ApiKeyAuth`] or [`BasicAuth`]
53    pub fn principal(&self) -> Option<&str> {
54        self.extensions.get::<Principal>().map(|p| p.0.as_str())
55    }
56}
57
58fn unauthorized(challenge: &str, message: &str) -> Response {
59    Response::from_error(crate::error::Error::unauthorized(message))
60        .with_header("www-authenticate", challenge)
61        .with_header("cache-control", "no-store")
62}
63
64fn exempt(paths: &[String], req: &Request) -> bool {
65    paths.iter().any(|p| p == req.uri.path())
66}
67
68// ---- API keys ----
69
70/// Authenticate callers by a secret in a request header
71///
72/// Several keys can be active at once (one per caller, or old and new during a
73/// rotation); the matching entry's name becomes the [`Principal`].
74#[derive(Clone)]
75pub struct ApiKeyAuth {
76    header: String,
77    keys: Vec<(String, String)>,
78    bearer: bool,
79    except: Vec<String>,
80}
81
82impl Default for ApiKeyAuth {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl ApiKeyAuth {
89    /// Reads the key from `X-API-Key`
90    pub fn new() -> Self {
91        Self {
92            header: "x-api-key".into(),
93            keys: Vec::new(),
94            bearer: false,
95            except: Vec::new(),
96        }
97    }
98
99    /// Header carrying the key
100    pub fn header(mut self, name: &str) -> Self {
101        self.header = name.to_ascii_lowercase();
102        self
103    }
104
105    /// Also accept the key as `Authorization: Bearer <key>`
106    pub fn allow_bearer(mut self) -> Self {
107        self.bearer = true;
108        self
109    }
110
111    /// Accept `secret` and report its owner as `name`
112    pub fn key(mut self, name: &str, secret: &str) -> Self {
113        self.keys.push((name.to_string(), secret.to_string()));
114        self
115    }
116
117    /// Paths served without a key (health checks)
118    pub fn except_paths<I, S>(mut self, paths: I) -> Self
119    where
120        I: IntoIterator<Item = S>,
121        S: Into<String>,
122    {
123        self.except = paths.into_iter().map(Into::into).collect();
124        self
125    }
126
127    fn lookup(&self, presented: &str) -> Option<&str> {
128        // visit every entry so the time does not depend on which one matched
129        let mut found = None;
130        for (name, secret) in &self.keys {
131            if constant_time_eq(secret.as_bytes(), presented.as_bytes()) && found.is_none() {
132                found = Some(name.as_str());
133            }
134        }
135        found
136    }
137}
138
139#[async_trait]
140impl Middleware for ApiKeyAuth {
141    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
142        if exempt(&self.except, &req) {
143            return next.run(req).await;
144        }
145        let presented = req
146            .headers
147            .get(self.header.as_str())
148            .and_then(|v| v.to_str().ok())
149            .map(str::to_string)
150            .or_else(|| {
151                self.bearer
152                    .then(|| {
153                        req.headers
154                            .get(http::header::AUTHORIZATION)
155                            .and_then(|v| v.to_str().ok())
156                            .and_then(|v| v.strip_prefix("Bearer "))
157                            .map(str::to_string)
158                    })
159                    .flatten()
160            });
161        let challenge = if self.bearer { "Bearer" } else { "ApiKey" };
162        let Some(presented) = presented else {
163            return Ok(unauthorized(challenge, "API key required"));
164        };
165        match self.lookup(&presented) {
166            Some(name) => {
167                req.extensions.insert(Principal(name.to_string()));
168                next.run(req).await
169            }
170            None => Ok(unauthorized(challenge, "Invalid API key")),
171        }
172    }
173}
174
175// ---- Basic ----
176
177type Verifier = dyn Fn(String, String) -> BoxFuture<'static, bool> + Send + Sync;
178
179/// HTTP Basic authentication (RFC 7617)
180#[derive(Clone)]
181pub struct BasicAuth {
182    realm: String,
183    users: Vec<(String, String)>,
184    verifier: Option<Arc<Verifier>>,
185    except: Vec<String>,
186}
187
188impl BasicAuth {
189    /// `realm` is shown in the browser's login dialog
190    pub fn new(realm: &str) -> Self {
191        Self {
192            realm: realm.replace(['"', '\\', '\r', '\n'], ""),
193            users: Vec::new(),
194            verifier: None,
195            except: Vec::new(),
196        }
197    }
198
199    /// Accept this user and password
200    pub fn user(mut self, name: &str, password: &str) -> Self {
201        self.users.push((name.to_string(), password.to_string()));
202        self
203    }
204
205    /// Check credentials yourself, e.g. against password hashes in a database.
206    /// Replaces the built-in users.
207    pub fn verify_with<F, Fut>(mut self, f: F) -> Self
208    where
209        F: Fn(String, String) -> Fut + Send + Sync + 'static,
210        Fut: std::future::Future<Output = bool> + Send + 'static,
211    {
212        self.verifier = Some(Arc::new(move |user, pass| Box::pin(f(user, pass))));
213        self
214    }
215
216    /// Paths served without credentials
217    pub fn except_paths<I, S>(mut self, paths: I) -> Self
218    where
219        I: IntoIterator<Item = S>,
220        S: Into<String>,
221    {
222        self.except = paths.into_iter().map(Into::into).collect();
223        self
224    }
225
226    fn challenge(&self) -> String {
227        format!("Basic realm=\"{}\", charset=\"UTF-8\"", self.realm)
228    }
229
230    fn check_builtin(&self, user: &str, password: &str) -> bool {
231        // every entry is compared, name and password both, without early exit
232        let mut ok = false;
233        for (name, secret) in &self.users {
234            let name_ok = constant_time_eq(name.as_bytes(), user.as_bytes());
235            let pass_ok = constant_time_eq(secret.as_bytes(), password.as_bytes());
236            ok |= name_ok & pass_ok;
237        }
238        ok
239    }
240}
241
242/// Decode standard or URL-safe base64 with optional padding
243fn base64_decode(input: &str) -> Option<Vec<u8>> {
244    let mut out = Vec::with_capacity(input.len() * 3 / 4);
245    let (mut buf, mut bits) = (0u32, 0u32);
246    for c in input.trim_end_matches('=').bytes() {
247        let v = match c {
248            b'A'..=b'Z' => c - b'A',
249            b'a'..=b'z' => c - b'a' + 26,
250            b'0'..=b'9' => c - b'0' + 52,
251            b'+' | b'-' => 62,
252            b'/' | b'_' => 63,
253            _ => return None,
254        };
255        buf = (buf << 6) | u32::from(v);
256        bits += 6;
257        if bits >= 8 {
258            bits -= 8;
259            out.push((buf >> bits) as u8);
260            buf &= (1 << bits) - 1;
261        }
262    }
263    Some(out)
264}
265
266#[async_trait]
267impl Middleware for BasicAuth {
268    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
269        if exempt(&self.except, &req) {
270            return next.run(req).await;
271        }
272        let credentials = req
273            .headers
274            .get(http::header::AUTHORIZATION)
275            .and_then(|v| v.to_str().ok())
276            .and_then(|v| {
277                let (scheme, rest) = v.split_once(' ')?;
278                scheme.eq_ignore_ascii_case("basic").then_some(rest.trim())
279            })
280            .and_then(base64_decode)
281            .and_then(|raw| String::from_utf8(raw).ok())
282            .and_then(|text| {
283                let (user, pass) = text.split_once(':')?;
284                Some((user.to_string(), pass.to_string()))
285            });
286        let Some((user, pass)) = credentials else {
287            return Ok(unauthorized(&self.challenge(), "Credentials required"));
288        };
289        let ok = match &self.verifier {
290            Some(verify) => verify(user.clone(), pass).await,
291            None => self.check_builtin(&user, &pass),
292        };
293        if !ok {
294            return Ok(unauthorized(&self.challenge(), "Invalid credentials"));
295        }
296        req.extensions.insert(Principal(user));
297        next.run(req).await
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn base64_decoding() {
307        assert_eq!(base64_decode("dXNlcjpwYXNz").unwrap(), b"user:pass");
308        assert_eq!(base64_decode("dXNlcjpwYXNzMQ==").unwrap(), b"user:pass1");
309        assert_eq!(base64_decode("dXNlcjpwYXNzMQ").unwrap(), b"user:pass1");
310        // url-safe alphabet
311        assert_eq!(base64_decode("_-8").unwrap(), vec![0xff, 0xef]);
312        assert_eq!(base64_decode("").unwrap(), Vec::<u8>::new());
313        assert!(base64_decode("not base64!").is_none());
314    }
315
316    #[test]
317    fn realm_cannot_break_the_header() {
318        let auth = BasicAuth::new("a\"b\r\nX-Evil: 1");
319        assert_eq!(
320            auth.challenge(),
321            "Basic realm=\"abX-Evil: 1\", charset=\"UTF-8\""
322        );
323    }
324
325    #[test]
326    fn api_key_lookup_returns_the_owner() {
327        let a = ApiKeyAuth::new().key("a", "secret-a").key("b", "secret-b");
328        assert_eq!(a.lookup("secret-b"), Some("b"));
329        assert_eq!(a.lookup("secret-"), None);
330        assert_eq!(a.lookup(""), None);
331    }
332}