Skip to main content

mildly_basic_auth/
config.rs

1//! Startup configuration, validated once from the environment.
2//!
3//! `Config` is only constructible when every value is valid, so the rest
4//! of the program never has to defend against a half-configured state.
5
6use std::error::Error;
7use std::fmt;
8
9use axum::http::Uri;
10use blake3::Hash;
11
12/// Environment variable holding the plain password (required, non-empty).
13const ENV_PASSWORD: &str = "MBA_PASSWORD";
14/// Environment variable holding the upstream URL (required, absolute
15/// `http(s)://host[:port]`).
16const ENV_UPSTREAM: &str = "MBA_UPSTREAM";
17
18/// Immutable runtime configuration, built once at startup.
19///
20/// Cloned per request by the gate middleware (`from_fn_with_state`
21/// requires the state to be `Clone`), which is why fields are cheap to
22/// copy/clone (`Hash` is `Copy`, `String` is `Clone`).
23#[derive(Clone)]
24pub struct Config {
25    /// BLAKE3 digest of the configured password. The session cookie
26    /// carries this same value as hex; a request authenticates by
27    /// presenting a cookie that matches it. Storing the digest (not the
28    /// password) keeps the plaintext out of memory after startup and out
29    /// of the browser jar.
30    session: Hash,
31    /// Validated absolute `http(s)://host[:port]` upstream. Stored as a
32    /// `String` (not `Uri`) because `ReverseProxy::new` takes one generic
33    /// `S: Into<String>` for both arguments; a `&Uri` would not satisfy
34    /// that bound.
35    upstream: String,
36}
37
38impl Config {
39    /// Build from the process environment. Thin wrapper over
40    /// [`Config::from_values`]; kept separate so the validation logic
41    /// stays testable without mutating process-wide env (which is
42    /// `unsafe` in Rust 2024).
43    ///
44    /// # Errors
45    ///
46    /// Returns [`ConfigError`] if `MBA_PASSWORD` is empty or `MBA_UPSTREAM`
47    /// is not a valid absolute HTTP(S) URL.
48    pub fn from_env() -> Result<Self, ConfigError> {
49        let password = std::env::var(ENV_PASSWORD).unwrap_or_default();
50        let upstream = std::env::var(ENV_UPSTREAM).unwrap_or_default();
51        Self::from_values(&password, &upstream)
52    }
53
54    /// Build and validate from explicit values (pure, env-free).
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// # use mildly_basic_auth::Config;
60    /// assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
61    /// assert!(Config::from_values("", "http://app:2001").is_err()); // No password.
62    /// assert!(Config::from_values("hunter2", "/relative").is_err()); // No host.
63    /// ```
64    ///
65    /// # Errors
66    ///
67    /// Returns [`ConfigError::MissingPassword`] for an empty password, or
68    /// [`ConfigError::MissingUpstream`] / [`ConfigError::InvalidUpstream`]
69    /// if the upstream is not a valid absolute HTTP(S) URL.
70    pub fn from_values(password: &str, upstream: &str) -> Result<Self, ConfigError> {
71        if password.is_empty() {
72            return Err(ConfigError::MissingPassword);
73        }
74        let upstream = validate_upstream(upstream)?;
75        Ok(Self {
76            session: blake3::hash(password.as_bytes()),
77            upstream,
78        })
79    }
80
81    /// Expected session-cookie digest.
82    pub(crate) fn session(&self) -> &Hash {
83        &self.session
84    }
85
86    /// Validated upstream URL, ready for `ReverseProxy::new`.
87    pub(crate) fn upstream(&self) -> &str {
88        &self.upstream
89    }
90}
91
92impl fmt::Debug for Config {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        // Never print the session digest: it is a password-equivalent
95        // bearer token, so an accidental log must not leak it.
96        f.debug_struct("Config")
97            .field("session", &"<redacted>")
98            .field("upstream", &self.upstream)
99            .finish()
100    }
101}
102
103/// Validate `upstream` as an absolute `http(s)://host[:port]` URL.
104///
105/// Strictness matters: `http::Uri` parses relative refs like `/foo` or
106/// `example:8080` that carry no authority, and `axum-reverse-proxy` then
107/// `.expect()`s an authority and **panics** at request time. We reject
108/// anything that isn't an absolute HTTP(S) URL up front.
109///
110/// # Examples
111///
112/// ```ignore
113/// assert!(validate_upstream("http://app:2001").is_ok());
114/// assert!(validate_upstream("").is_err()); // Empty.
115/// assert!(validate_upstream("/foo").is_err()); // No scheme/host.
116/// assert!(validate_upstream("example:8080").is_err()); // No host.
117/// assert!(validate_upstream("ftp://host").is_err()); // Not HTTP(S).
118/// ```
119fn validate_upstream(upstream: &str) -> Result<String, ConfigError> {
120    let upstream = upstream.trim();
121    if upstream.is_empty() {
122        return Err(ConfigError::MissingUpstream);
123    }
124
125    let invalid = |reason: &'static str| ConfigError::InvalidUpstream {
126        value: upstream.to_string(),
127        reason,
128    };
129
130    let uri: Uri = upstream.parse().map_err(|_| invalid("not a valid URL"))?;
131
132    if !matches!(uri.scheme_str(), Some("http" | "https")) {
133        return Err(invalid("scheme must be http or https"));
134    }
135    let host = uri.host().unwrap_or_default();
136    // An authority can parse with an empty host (`http://:8080`), which the
137    // connector cannot use.
138    if host.is_empty() {
139        return Err(invalid("missing host"));
140    }
141    // Port 0 is never a real destination.
142    if uri.port_u16() == Some(0) {
143        return Err(invalid("invalid port"));
144    }
145    // `http::Uri` silently *drops* malformed detail: a bad port (`:abc`,
146    // `:`, `:99999`) or junk after an IPv6 literal (`[::1]junk`) all parse
147    // with that text discarded, so the connector would target the wrong
148    // place (e.g. the scheme default port). Catch it by reconstructing the
149    // canonical `host[:port]` from the parsed parts and requiring it to
150    // equal the input authority (ignoring any `userinfo@`); a mismatch is
151    // exactly the garbage that was dropped. The port is taken from
152    // `port().as_str()` (its original text), not `port_u16()`, so a valid
153    // leading-zero port like `:080` round-trips instead of collapsing to
154    // `:80` and failing the comparison.
155    let authority = uri.authority().map_or("", |a| a.as_str());
156    let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
157    let canonical = match uri.port() {
158        Some(port) => format!("{host}:{}", port.as_str()),
159        None => host.to_string(),
160    };
161    if host_port != canonical {
162        return Err(invalid("invalid host or port"));
163    }
164
165    // Normalized form. The proxy trims any trailing slash when joining
166    // paths, so the canonical `http://host:port/` cannot produce `//`.
167    Ok(uri.to_string())
168}
169
170/// Why a `Config` could not be built. Surfaced to stderr at startup so a
171/// misconfiguration fails fast with an actionable message.
172#[derive(Debug, PartialEq, Eq)]
173pub enum ConfigError {
174    /// `MBA_PASSWORD` was unset or empty.
175    MissingPassword,
176    /// `MBA_UPSTREAM` was unset or empty.
177    MissingUpstream,
178    /// `MBA_UPSTREAM` was set but is not an absolute HTTP(S) URL.
179    InvalidUpstream { value: String, reason: &'static str },
180}
181
182impl fmt::Display for ConfigError {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            Self::MissingPassword => {
186                write!(f, "`{ENV_PASSWORD}` must be set to a non-empty value")
187            }
188            Self::MissingUpstream => write!(f, "`{ENV_UPSTREAM}` must be set"),
189            Self::InvalidUpstream { value, reason } => write!(
190                f,
191                "`{ENV_UPSTREAM}` is not a valid absolute http(s) URL \
192                 ({reason}): {value:?}"
193            ),
194        }
195    }
196}
197
198impl Error for ConfigError {}
199
200#[cfg(test)]
201mod tests {
202    use std::assert_matches;
203
204    use super::*;
205
206    #[test]
207    fn valid_values_build_a_config() {
208        assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
209    }
210
211    #[test]
212    fn https_upstream_is_accepted() {
213        assert!(Config::from_values("hunter2", "https://app.example.com").is_ok());
214    }
215
216    #[test]
217    fn empty_password_is_rejected() {
218        assert_matches!(
219            Config::from_values("", "http://app:2001"),
220            Err(ConfigError::MissingPassword)
221        );
222    }
223
224    #[test]
225    fn empty_upstream_is_rejected() {
226        assert_matches!(
227            Config::from_values("hunter2", ""),
228            Err(ConfigError::MissingUpstream)
229        );
230    }
231
232    #[test]
233    fn relative_upstream_is_rejected() {
234        assert_matches!(
235            Config::from_values("hunter2", "/foo"),
236            Err(ConfigError::InvalidUpstream { .. })
237        );
238    }
239
240    #[test]
241    fn scheme_only_upstream_without_authority_is_rejected() {
242        assert_matches!(
243            Config::from_values("hunter2", "example:8080"),
244            Err(ConfigError::InvalidUpstream { .. })
245        );
246    }
247
248    #[test]
249    fn non_http_scheme_is_rejected() {
250        assert_matches!(
251            Config::from_values("hunter2", "ftp://host"),
252            Err(ConfigError::InvalidUpstream { .. })
253        );
254    }
255
256    #[test]
257    fn empty_host_upstream_is_rejected() {
258        assert_matches!(
259            Config::from_values("hunter2", "http://:8080"),
260            Err(ConfigError::InvalidUpstream { .. })
261        );
262    }
263
264    #[test]
265    fn out_of_range_port_is_rejected() {
266        assert_matches!(
267            Config::from_values("hunter2", "http://host:99999"),
268            Err(ConfigError::InvalidUpstream { .. })
269        );
270    }
271
272    #[test]
273    fn non_numeric_port_is_rejected() {
274        assert_matches!(
275            Config::from_values("hunter2", "http://host:abc"),
276            Err(ConfigError::InvalidUpstream { .. })
277        );
278    }
279
280    #[test]
281    fn empty_port_is_rejected() {
282        assert_matches!(
283            Config::from_values("hunter2", "http://host:"),
284            Err(ConfigError::InvalidUpstream { .. })
285        );
286    }
287
288    #[test]
289    fn zero_port_is_rejected() {
290        assert_matches!(
291            Config::from_values("hunter2", "http://host:0"),
292            Err(ConfigError::InvalidUpstream { .. })
293        );
294    }
295
296    #[test]
297    fn leading_zero_port_is_accepted() {
298        // `:080` is a valid port (80); the connector resolves it fine.
299        assert!(Config::from_values("hunter2", "http://host:080").is_ok());
300    }
301
302    #[test]
303    fn all_zero_port_is_rejected() {
304        // `:00` is still port 0.
305        assert_matches!(
306            Config::from_values("hunter2", "http://host:00"),
307            Err(ConfigError::InvalidUpstream { .. })
308        );
309    }
310
311    #[test]
312    fn ipv6_upstream_is_accepted() {
313        assert!(Config::from_values("hunter2", "http://[::1]:8080").is_ok());
314    }
315
316    #[test]
317    fn ipv6_with_trailing_junk_is_rejected() {
318        assert_matches!(
319            Config::from_values("hunter2", "http://[::1]junk"),
320            Err(ConfigError::InvalidUpstream { .. })
321        );
322    }
323
324    #[test]
325    fn ipv6_junk_before_port_is_rejected() {
326        assert_matches!(
327            Config::from_values("hunter2", "http://[::1]junk:8080"),
328            Err(ConfigError::InvalidUpstream { .. })
329        );
330    }
331
332    #[test]
333    fn session_digest_matches_blake3_of_password() {
334        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
335        assert_eq!(config.session(), &blake3::hash(b"hunter2"));
336    }
337
338    #[test]
339    fn surrounding_whitespace_in_upstream_is_trimmed() {
340        let config = Config::from_values("hunter2", "  http://app:2001  ").unwrap();
341        assert_eq!(config.upstream(), "http://app:2001/");
342    }
343}