mildly-basic-auth 0.2.0

Basic auth with nicer UX.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! Startup configuration, validated once from the environment.
//!
//! `Config` is only constructible when every value is valid, so the rest
//! of the program never has to defend against a half-configured state.

use std::error::Error;
use std::fmt;
use std::net::SocketAddr;

use axum::http::Uri;
use blake3::Hash;

/// Default IP socket address the service listens on.
const DEFAULT_BIND_ADDRESS: &str = "0.0.0.0:8000";
/// Environment variable holding the IP socket address to listen on.
const ENV_ADDRESS: &str = "MBA_ADDRESS";
/// Environment variable holding the plain password (required, non-empty).
const ENV_PASSWORD: &str = "MBA_PASSWORD";
/// Environment variable holding the upstream URL (required, absolute
/// `http(s)://host[:port]`).
const ENV_UPSTREAM: &str = "MBA_UPSTREAM";

/// Immutable runtime configuration, built once at startup.
///
/// Cloned per request by the gate middleware (`from_fn_with_state`
/// requires the state to be `Clone`), which is why fields are cheap to
/// copy/clone (`Hash` and `SocketAddr` are `Copy`, `String` is `Clone`).
#[derive(Clone)]
pub struct Config {
    /// Validated IP socket address the service listens on.
    bind_address: SocketAddr,
    /// BLAKE3 digest of the configured password. The session cookie
    /// carries this same value as hex; a request authenticates by
    /// presenting a cookie that matches it. Storing the digest (not the
    /// password) keeps the plaintext out of memory after startup and out
    /// of the browser jar.
    session: Hash,
    /// Validated absolute `http(s)://host[:port]` upstream. Stored as a
    /// `String` (not `Uri`) because `ReverseProxy::new` takes one generic
    /// `S: Into<String>` for both arguments; a `&Uri` would not satisfy
    /// that bound.
    upstream: String,
}

impl Config {
    /// Build from the process environment. Delegates to the same pure
    /// validation as [`Config::from_values`] so tests do not have to mutate
    /// process-wide env (which is `unsafe` in Rust 2024).
    ///
    /// # Errors
    ///
    /// Returns [`ConfigError`] if `MBA_ADDRESS` is not a valid non-zero IP
    /// socket address, `MBA_PASSWORD` is empty, or `MBA_UPSTREAM` is not a
    /// valid absolute HTTP(S) URL.
    pub fn from_env() -> Result<Self, ConfigError> {
        let address = match std::env::var(ENV_ADDRESS) {
            Ok(address) => address,
            Err(std::env::VarError::NotPresent) => DEFAULT_BIND_ADDRESS.to_string(),
            Err(std::env::VarError::NotUnicode(address)) => {
                return Err(ConfigError::InvalidAddress {
                    value: address.to_string_lossy().into_owned(),
                    reason: "not valid Unicode",
                });
            }
        };
        let password = std::env::var(ENV_PASSWORD).unwrap_or_default();
        let upstream = std::env::var(ENV_UPSTREAM).unwrap_or_default();
        Self::from_values_and_address(&password, &upstream, &address)
    }

    /// Build and validate from explicit values (pure, env-free).
    ///
    /// # Examples
    ///
    /// ```
    /// # use mildly_basic_auth::Config;
    /// assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
    /// assert!(Config::from_values("", "http://app:2001").is_err()); // No password.
    /// assert!(Config::from_values("hunter2", "/relative").is_err()); // No host.
    /// ```
    ///
    /// # Errors
    ///
    /// Returns [`ConfigError::MissingPassword`] for an empty password, or
    /// [`ConfigError::MissingUpstream`] / [`ConfigError::InvalidUpstream`]
    /// if the upstream is not a valid absolute HTTP(S) URL. The bind
    /// address uses the default `0.0.0.0:8000`.
    pub fn from_values(password: &str, upstream: &str) -> Result<Self, ConfigError> {
        Self::from_values_and_address(password, upstream, DEFAULT_BIND_ADDRESS)
    }

    /// Validated IP socket address the service listens on.
    #[must_use]
    pub fn bind_address(&self) -> SocketAddr {
        self.bind_address
    }

    /// Build and validate the complete runtime configuration.
    fn from_values_and_address(
        password: &str,
        upstream: &str,
        address: &str,
    ) -> Result<Self, ConfigError> {
        if password.is_empty() {
            return Err(ConfigError::MissingPassword);
        }
        let upstream = validate_upstream(upstream)?;
        let address = validate_address(address)?;
        Ok(Self {
            bind_address: address,
            session: blake3::hash(password.as_bytes()),
            upstream,
        })
    }

    /// Expected session-cookie digest.
    pub(crate) fn session(&self) -> &Hash {
        &self.session
    }

    /// Validated upstream URL, ready for `ReverseProxy::new`.
    pub(crate) fn upstream(&self) -> &str {
        &self.upstream
    }
}

impl fmt::Debug for Config {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Never print the session digest: it is a password-equivalent
        // bearer token, so an accidental log must not leak it.
        f.debug_struct("Config")
            .field("bind_address", &self.bind_address)
            .field("session", &"<redacted>")
            .field("upstream", &self.upstream)
            .finish()
    }
}

/// Validate `address` as a non-zero IP socket address.
///
/// # Examples
///
/// ```ignore
/// assert!(validate_address("0.0.0.0:4630").is_ok());
/// assert!(validate_address("[::]:4630").is_ok());
/// assert!(validate_address("localhost:4630").is_err()); // Not an IP.
/// assert!(validate_address("0.0.0.0:0").is_err()); // Zero port.
/// ```
fn validate_address(address: &str) -> Result<SocketAddr, ConfigError> {
    let address = address.trim();
    let invalid = |reason: &'static str| ConfigError::InvalidAddress {
        value: address.to_string(),
        reason,
    };
    let address: SocketAddr = address
        .parse()
        .map_err(|_| invalid("expected an IP address and port"))?;
    if address.port() == 0 {
        return Err(invalid("port must not be zero"));
    }
    Ok(address)
}

/// Validate `upstream` as an absolute `http(s)://host[:port]` URL.
///
/// Strictness matters: `http::Uri` parses relative refs like `/foo` or
/// `example:8080` that carry no authority, and `axum-reverse-proxy` then
/// `.expect()`s an authority and **panics** at request time. We reject
/// anything that isn't an absolute HTTP(S) URL up front.
///
/// # Examples
///
/// ```ignore
/// assert!(validate_upstream("http://app:2001").is_ok());
/// assert!(validate_upstream("").is_err()); // Empty.
/// assert!(validate_upstream("/foo").is_err()); // No scheme/host.
/// assert!(validate_upstream("example:8080").is_err()); // No host.
/// assert!(validate_upstream("ftp://host").is_err()); // Not HTTP(S).
/// ```
fn validate_upstream(upstream: &str) -> Result<String, ConfigError> {
    let upstream = upstream.trim();
    if upstream.is_empty() {
        return Err(ConfigError::MissingUpstream);
    }

    let invalid = |reason: &'static str| ConfigError::InvalidUpstream {
        value: upstream.to_string(),
        reason,
    };

    let uri: Uri = upstream.parse().map_err(|_| invalid("not a valid URL"))?;

    if !matches!(uri.scheme_str(), Some("http" | "https")) {
        return Err(invalid("scheme must be http or https"));
    }
    let host = uri.host().unwrap_or_default();
    // An authority can parse with an empty host (`http://:8080`), which the
    // connector cannot use.
    if host.is_empty() {
        return Err(invalid("missing host"));
    }
    // Port 0 is never a real destination.
    if uri.port_u16() == Some(0) {
        return Err(invalid("invalid port"));
    }
    // `http::Uri` silently *drops* malformed detail: a bad port (`:abc`,
    // `:`, `:99999`) or junk after an IPv6 literal (`[::1]junk`) all parse
    // with that text discarded, so the connector would target the wrong
    // place (e.g. the scheme default port). Catch it by reconstructing the
    // canonical `host[:port]` from the parsed parts and requiring it to
    // equal the input authority (ignoring any `userinfo@`); a mismatch is
    // exactly the garbage that was dropped. The port is taken from
    // `port().as_str()` (its original text), not `port_u16()`, so a valid
    // leading-zero port like `:080` round-trips instead of collapsing to
    // `:80` and failing the comparison.
    let authority = uri.authority().map_or("", |a| a.as_str());
    let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
    let canonical = match uri.port() {
        Some(port) => format!("{host}:{}", port.as_str()),
        None => host.to_string(),
    };
    if host_port != canonical {
        return Err(invalid("invalid host or port"));
    }

    // Normalized form. The proxy trims any trailing slash when joining
    // paths, so the canonical `http://host:port/` cannot produce `//`.
    Ok(uri.to_string())
}

/// Why a `Config` could not be built. Surfaced to stderr at startup so a
/// misconfiguration fails fast with an actionable message.
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigError {
    /// `MBA_ADDRESS` was not a valid non-zero IP socket address.
    InvalidAddress { value: String, reason: &'static str },
    /// `MBA_PASSWORD` was unset or empty.
    MissingPassword,
    /// `MBA_UPSTREAM` was unset or empty.
    MissingUpstream,
    /// `MBA_UPSTREAM` was set but is not an absolute HTTP(S) URL.
    InvalidUpstream { value: String, reason: &'static str },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidAddress { value, reason } => write!(
                f,
                "`{ENV_ADDRESS}` is not a valid IP socket address ({reason}): {value:?}"
            ),
            Self::MissingPassword => {
                write!(f, "`{ENV_PASSWORD}` must be set to a non-empty value")
            }
            Self::MissingUpstream => write!(f, "`{ENV_UPSTREAM}` must be set"),
            Self::InvalidUpstream { value, reason } => write!(
                f,
                "`{ENV_UPSTREAM}` is not a valid absolute http(s) URL \
                 ({reason}): {value:?}"
            ),
        }
    }
}

impl Error for ConfigError {}

#[cfg(test)]
mod tests {
    use std::assert_matches;

    use super::*;

    #[test]
    fn valid_values_build_a_config() {
        assert!(Config::from_values("hunter2", "http://app:2001").is_ok());
    }

    #[test]
    fn values_use_the_default_address() {
        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
        assert_eq!(config.bind_address(), "0.0.0.0:8000".parse().unwrap());
    }

    #[test]
    fn custom_ipv4_address_is_accepted() {
        let config =
            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:4630")
                .unwrap();
        assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
    }

    #[test]
    fn custom_ipv6_address_is_accepted() {
        let config =
            Config::from_values_and_address("hunter2", "http://app:2001", "[::1]:4630").unwrap();
        assert_eq!(config.bind_address(), "[::1]:4630".parse().unwrap());
    }

    #[test]
    fn surrounding_whitespace_in_address_is_trimmed() {
        let config =
            Config::from_values_and_address("hunter2", "http://app:2001", "  127.0.0.1:4630  ")
                .unwrap();
        assert_eq!(config.bind_address(), "127.0.0.1:4630".parse().unwrap());
    }

    #[test]
    fn empty_address_is_rejected() {
        assert_matches!(
            Config::from_values_and_address("hunter2", "http://app:2001", ""),
            Err(ConfigError::InvalidAddress { .. })
        );
    }

    #[test]
    fn hostname_address_is_rejected() {
        assert_matches!(
            Config::from_values_and_address("hunter2", "http://app:2001", "localhost:4630"),
            Err(ConfigError::InvalidAddress { .. })
        );
    }

    #[test]
    fn address_without_port_is_rejected() {
        assert_matches!(
            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1"),
            Err(ConfigError::InvalidAddress { .. })
        );
    }

    #[test]
    fn address_with_non_numeric_port_is_rejected() {
        assert_matches!(
            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:abc"),
            Err(ConfigError::InvalidAddress { .. })
        );
    }

    #[test]
    fn address_with_out_of_range_port_is_rejected() {
        assert_matches!(
            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:99999"),
            Err(ConfigError::InvalidAddress { .. })
        );
    }

    #[test]
    fn address_with_zero_port_is_rejected() {
        assert_matches!(
            Config::from_values_and_address("hunter2", "http://app:2001", "127.0.0.1:0"),
            Err(ConfigError::InvalidAddress { .. })
        );
    }

    #[test]
    fn https_upstream_is_accepted() {
        assert!(Config::from_values("hunter2", "https://app.example.com").is_ok());
    }

    #[test]
    fn empty_password_is_rejected() {
        assert_matches!(
            Config::from_values("", "http://app:2001"),
            Err(ConfigError::MissingPassword)
        );
    }

    #[test]
    fn empty_upstream_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", ""),
            Err(ConfigError::MissingUpstream)
        );
    }

    #[test]
    fn relative_upstream_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "/foo"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn scheme_only_upstream_without_authority_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "example:8080"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn non_http_scheme_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "ftp://host"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn empty_host_upstream_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "http://:8080"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn out_of_range_port_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "http://host:99999"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn non_numeric_port_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "http://host:abc"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn empty_port_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "http://host:"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn zero_port_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "http://host:0"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn leading_zero_port_is_accepted() {
        // `:080` is a valid port (80); the connector resolves it fine.
        assert!(Config::from_values("hunter2", "http://host:080").is_ok());
    }

    #[test]
    fn all_zero_port_is_rejected() {
        // `:00` is still port 0.
        assert_matches!(
            Config::from_values("hunter2", "http://host:00"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn ipv6_upstream_is_accepted() {
        assert!(Config::from_values("hunter2", "http://[::1]:8080").is_ok());
    }

    #[test]
    fn ipv6_with_trailing_junk_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "http://[::1]junk"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn ipv6_junk_before_port_is_rejected() {
        assert_matches!(
            Config::from_values("hunter2", "http://[::1]junk:8080"),
            Err(ConfigError::InvalidUpstream { .. })
        );
    }

    #[test]
    fn session_digest_matches_blake3_of_password() {
        let config = Config::from_values("hunter2", "http://app:2001").unwrap();
        assert_eq!(config.session(), &blake3::hash(b"hunter2"));
    }

    #[test]
    fn surrounding_whitespace_in_upstream_is_trimmed() {
        let config = Config::from_values("hunter2", "  http://app:2001  ").unwrap();
        assert_eq!(config.upstream(), "http://app:2001/");
    }
}