broadcast_auth/server.rs
1//! Server-side challenge + verify — the origin half of RFC 7235
2//! (`WWW-Authenticate`/`Authorization`), RFC 7617 (Basic), RFC 7616 (Digest),
3//! RFC 2326 §14 (RTSP's reuse of the same two schemes), and RFC 6750
4//! (Bearer).
5//!
6//! [`crate::Authenticator`]/[`crate::respond`] are the *client* half: answer
7//! a challenge. [`Verifier`] is the other side of the same handshake: an
8//! origin (multimux's shared output auth gating every `/{stream}/…` route,
9//! issue #663; or any other credentialed origin in this workspace) builds
10//! one from a configured [`Credentials`] + realm, calls [`Verifier::challenge`]
11//! for the `WWW-Authenticate` value to send on a `401`, and
12//! [`Verifier::verify`] to check an incoming `Authorization` header.
13//!
14//! Promoted from `multimux`'s test-only mock auth server
15//! (`multimux::testutil`, issue #663 "Finish client-side multi-scheme
16//! auth"): that module's Digest verification was already a real,
17//! independent RFC 7616 §3.4.1 computation (not a literal-string match)
18//! purely to drive multimux's own client-side tests against something that
19//! genuinely rejects wrong credentials. This module is that same
20//! computation, promoted into the shared crate so it is the *production*
21//! server-side verifier (multimux's output-auth middleware) rather than a
22//! test-only fixture, and so no crate hand-rolls a second copy.
23//!
24//! # Verification per scheme
25//!
26//! - **Basic** (RFC 7617 §2): the header's base64 payload is decoded and
27//! compared, in constant time, against `"{username}:{password}"`.
28//! - **Bearer** (RFC 6750 §2.1): the token is compared, in constant time,
29//! against the configured token.
30//! - **Digest** (RFC 7616 §3.4.1): `HA1 = MD5(username:realm:password)`,
31//! `HA2 = MD5(method:digest-uri-value)`, `response =
32//! MD5(HA1:nonce:nc:cnonce:qop:HA2)` — `qop=auth`/`algorithm=MD5` only (the
33//! one shape every client in this workspace answers) — recomputed and
34//! compared, in constant time, against the client's `response` field.
35//! `digest-uri-value` is the client's own claimed `uri` field (RFC 7616
36//! §3.4.1: HA2 is always computed over what the client actually hashed),
37//! not the server's request URI — the two need not be textually identical,
38//! only to refer to the same request-target (see below). The client's
39//! claimed `uri` field must also match the actual request URI (RFC 7616
40//! §3.4.1: the server "SHOULD check" this), not merely be internally
41//! consistent with its own `response` — but RFC 7230 §5.3 permits a
42//! request-target in either origin-form (`/path`) or absolute-form
43//! (`scheme://authority/path`), and a legitimate client may hash either;
44//! [`digest_uri_matches`] accepts both representations of the same target
45//! while still rejecting a genuinely different one.
46//! - **Forwarded** ([`Self::forwarded`], issue #663 extensibility wave part
47//! 1): not an RFC 7235 challenge scheme at all — trusts that a fronting
48//! reverse proxy has already authenticated the caller and forwards the
49//! authenticated username in a configured header (conventionally
50//! `X-Forwarded-User`). Authenticated iff that header is present and
51//! non-empty. **Safe ONLY behind a trusted reverse proxy that strips any
52//! client-supplied copies of that header (and of the forwarded-for header,
53//! if configured) before forwarding** — this crate performs no such
54//! stripping and trusts [`crate::RequestContext::headers`] completely; a
55//! direct or spoofed client could otherwise set the header itself and
56//! bypass authentication entirely. [`Self::challenge`] returns just the
57//! bare scheme name for diagnostics (there is no challenge/response
58//! round-trip a direct client could answer).
59//!
60//! # Nonce handling (replay caveat)
61//!
62//! A [`Verifier`] built for `Digest` generates one random nonce at
63//! construction time and reuses it for the verifier's entire lifetime — it
64//! does not rotate per-challenge or track consumed `(nonce, nc)` pairs. This
65//! is the "simple server nonce" the design spec calls out as acceptable: it
66//! is enough to stop a passive credential-sniffing attacker (the password
67//! itself is never sent), but — unlike a nonce-tracking implementation — it
68//! does **not** detect a replayed exact request (identical `nc`/`cnonce`)
69//! within the verifier's lifetime. Rebuild the `Verifier` (e.g. on process
70//! restart) to rotate the nonce.
71
72use base64::Engine;
73use md5::{Digest as _, Md5};
74
75use crate::credentials::Credentials;
76use crate::request::RequestContext;
77
78/// The outcome of [`Verifier::verify`].
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub enum AuthResult {
82 /// The `Authorization` header (or absence of one) satisfies the
83 /// verifier's configured credential.
84 Ok,
85 /// Missing, malformed, or wrong-credential `Authorization` — the caller
86 /// should respond `401` with [`Verifier::challenge`].
87 Unauthorized,
88}
89
90/// Per-scheme state a [`Verifier`] holds — mirrors [`Credentials`] but adds
91/// the realm (Basic/Digest) and the one server nonce (Digest) generated at
92/// construction (see the module docs' nonce-handling caveat).
93enum VerifierScheme {
94 Basic {
95 username: String,
96 password: String,
97 realm: String,
98 },
99 Digest {
100 username: String,
101 password: String,
102 realm: String,
103 nonce: String,
104 },
105 Bearer {
106 token: String,
107 },
108 /// Reverse-proxy forwarded-auth (see the module docs) — no
109 /// `Credentials`/realm/nonce at all, since there is no client-answered
110 /// challenge for this scheme.
111 Forwarded {
112 user_header: String,
113 forwarded_for_header: Option<String>,
114 },
115}
116
117/// Challenges + verifies incoming requests against one configured
118/// [`Credentials`] (RFC 7235 origin-side auth) — see the module docs.
119pub struct Verifier {
120 scheme: VerifierScheme,
121}
122
123impl Verifier {
124 /// Builds a verifier for `credentials`, using `realm` for the
125 /// `WWW-Authenticate` challenge (Basic/Digest only — RFC 6750 Bearer has
126 /// no realm parameter in this crate's minimal challenge, see
127 /// [`Self::challenge`]).
128 ///
129 /// For `Credentials::Digest`, a fresh random server nonce is generated
130 /// now and held for this verifier's whole lifetime (see the module
131 /// docs' nonce-handling caveat).
132 pub fn new(credentials: Credentials, realm: impl Into<String>) -> Self {
133 let realm = realm.into();
134 let scheme = match credentials {
135 Credentials::Basic { username, password } => VerifierScheme::Basic {
136 username,
137 password,
138 realm,
139 },
140 Credentials::Digest { username, password } => VerifierScheme::Digest {
141 username,
142 password,
143 realm,
144 nonce: generate_nonce(),
145 },
146 Credentials::Bearer { token } => VerifierScheme::Bearer { token },
147 };
148 Verifier { scheme }
149 }
150
151 /// Builds a verifier for the reverse-proxy forwarded-auth scheme (see the
152 /// module docs' trust assumption — read it before using this).
153 ///
154 /// `user_header` (conventionally `X-Forwarded-User`) is the header whose
155 /// presence (non-empty) [`Self::verify`] treats as "the proxy already
156 /// authenticated this caller". `forwarded_for_header` (conventionally
157 /// `Some("X-Forwarded-For".to_string())`), if configured, is read back by
158 /// [`Self::forwarded_for`] for observability only — this crate makes no
159 /// trust decision based on it.
160 pub fn forwarded(user_header: impl Into<String>, forwarded_for_header: Option<String>) -> Self {
161 Verifier {
162 scheme: VerifierScheme::Forwarded {
163 user_header: user_header.into(),
164 forwarded_for_header,
165 },
166 }
167 }
168
169 /// The `WWW-Authenticate` header value to send on a `401` in response to
170 /// a missing/failed [`Self::verify`] call.
171 ///
172 /// `Forwarded` (built via [`Self::forwarded`]) has no real RFC 7235
173 /// challenge (a direct client cannot answer it — see the module docs);
174 /// this just names the scheme for diagnostics.
175 pub fn challenge(&self) -> String {
176 match &self.scheme {
177 VerifierScheme::Basic { realm, .. } => format!("Basic realm=\"{realm}\""),
178 VerifierScheme::Digest { realm, nonce, .. } => {
179 format!("Digest realm=\"{realm}\", nonce=\"{nonce}\", qop=\"auth\", algorithm=MD5")
180 }
181 VerifierScheme::Bearer { .. } => "Bearer".to_string(),
182 VerifierScheme::Forwarded { .. } => "Forwarded".to_string(),
183 }
184 }
185
186 /// Verifies an incoming request against this verifier's configured
187 /// scheme.
188 ///
189 /// Basic/Digest/Bearer read `ctx`'s `Authorization` header
190 /// ([`RequestContext::header`], case-insensitive) — missing entirely is
191 /// `Unauthorized`, same as before this took a full [`RequestContext`].
192 /// `ctx.method` feeds Digest's `HA2` directly; `ctx.uri` is the request
193 /// URI the client's claimed `uri` field is matched against (RFC 7616
194 /// §3.4.1's SHOULD, accepting either origin-form or absolute-form —
195 /// unused for Basic/Bearer.
196 /// Forwarded reads `ctx`'s configured user header instead — see the
197 /// module docs.
198 ///
199 /// A pathologically large `Digest` `Authorization` header is rejected
200 /// outright rather than parsed (see `MAX_DIGEST_FIELDS`) — this bounds
201 /// the per-request allocation cost, but is not a substitute for a
202 /// transport-level cap on header size, which callers should also enforce.
203 pub fn verify(&self, ctx: &RequestContext<'_>) -> AuthResult {
204 let ok = match &self.scheme {
205 VerifierScheme::Basic {
206 username, password, ..
207 } => ctx
208 .header("authorization")
209 .is_some_and(|header| verify_basic(header, username, password)),
210 VerifierScheme::Bearer { token } => ctx
211 .header("authorization")
212 .is_some_and(|header| verify_bearer(header, token)),
213 VerifierScheme::Digest {
214 username,
215 password,
216 realm,
217 nonce,
218 } => ctx.header("authorization").is_some_and(|header| {
219 verify_digest(
220 header, username, password, realm, nonce, ctx.method, ctx.uri,
221 )
222 }),
223 VerifierScheme::Forwarded { user_header, .. } => verify_forwarded(ctx, user_header),
224 };
225 if ok {
226 AuthResult::Ok
227 } else {
228 AuthResult::Unauthorized
229 }
230 }
231
232 /// For a [`Self::forwarded`] verifier with a configured
233 /// `forwarded_for_header`, returns that header's value from `ctx` — for
234 /// tracing/observability only; this crate makes no trust decision with
235 /// it (the module docs' trust assumption is what actually matters).
236 /// `None` for any other verifier, or when no such header is
237 /// configured/present in `ctx`.
238 pub fn forwarded_for<'a>(&self, ctx: &RequestContext<'a>) -> Option<&'a str> {
239 match &self.scheme {
240 VerifierScheme::Forwarded {
241 forwarded_for_header: Some(header_name),
242 ..
243 } => ctx.header(header_name),
244 _ => None,
245 }
246 }
247}
248
249/// Manual `Debug` (rather than `#[derive(Debug)]`): every scheme carries a
250/// secret (`password`/`token`) that must never render verbatim.
251impl core::fmt::Debug for Verifier {
252 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
253 let scheme = match &self.scheme {
254 VerifierScheme::Basic { .. } => "Basic",
255 VerifierScheme::Digest { .. } => "Digest",
256 VerifierScheme::Bearer { .. } => "Bearer",
257 VerifierScheme::Forwarded { .. } => "Forwarded",
258 };
259 f.debug_struct("Verifier")
260 .field("scheme", &scheme)
261 .finish_non_exhaustive()
262 }
263}
264
265/// RFC 7617 §2: decode the base64 payload and compare, in constant time,
266/// against `"{username}:{password}"`.
267fn verify_basic(header: &str, username: &str, password: &str) -> bool {
268 let Some(encoded) = header.strip_prefix("Basic ") else {
269 return false;
270 };
271 let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded.trim()) else {
272 return false;
273 };
274 let expected = format!("{username}:{password}");
275 constant_time_eq(&decoded, expected.as_bytes())
276}
277
278/// RFC 6750 §2.1: compare the bearer token, in constant time.
279fn verify_bearer(header: &str, token: &str) -> bool {
280 let Some(sent) = header.strip_prefix("Bearer ") else {
281 return false;
282 };
283 constant_time_eq(sent.trim().as_bytes(), token.as_bytes())
284}
285
286/// A real Digest `Authorization` response (RFC 7616 §3.4.1) carries under 15
287/// `key=value` fields (`username`, `realm`, `nonce`, `uri`, `response`,
288/// `algorithm`, `cnonce`, `opaque`, `qop`, `nc`, plus a couple of optional
289/// extensions). Capping well above that bounds [`verify_digest`]'s
290/// `HashMap` allocation against a request carrying a pathologically large
291/// `Authorization` header (a huge field count forcing a huge per-request
292/// map) without rejecting any legitimate client.
293const MAX_DIGEST_FIELDS: usize = 64;
294
295/// RFC 7616 §3.4.1: parse the `Digest` `Authorization` header's
296/// `key=value`/`key="value"` fields, independently recompute the expected
297/// `response`, and compare in constant time — `qop=auth`/`algorithm=MD5`
298/// only (the one shape every client in this workspace answers).
299///
300/// `HA2` is computed over the client's own claimed `uri` field (the
301/// `digest-uri-value` RFC 7616 §3.4.1 defines HA2 over) — not `request_uri` —
302/// since that is what the client actually hashed into its `response`. The
303/// client's claimed `uri` is separately checked against `request_uri` (RFC
304/// 7616 §3.4.1's SHOULD) via [`digest_uri_matches`], which accepts either
305/// legal RFC 7230 request-target representation of the same target
306/// (origin-form or absolute-form) while still rejecting a genuinely
307/// different `uri`.
308///
309/// Rejects outright (without building the field map) a header carrying more
310/// than [`MAX_DIGEST_FIELDS`] comma-separated fields — see that constant's
311/// docs.
312fn verify_digest(
313 header: &str,
314 username: &str,
315 password: &str,
316 realm: &str,
317 nonce: &str,
318 method: &str,
319 request_uri: &str,
320) -> bool {
321 let Some(rest) = header.strip_prefix("Digest ") else {
322 return false;
323 };
324 if rest.split(',').count() > MAX_DIGEST_FIELDS {
325 return false;
326 }
327 let mut fields = std::collections::HashMap::new();
328 for part in rest.split(',') {
329 let part = part.trim();
330 let Some((key, value)) = part.split_once('=') else {
331 continue;
332 };
333 fields.insert(key.trim(), value.trim().trim_matches('"'));
334 }
335 let get = |k: &str| fields.get(k).copied().unwrap_or_default();
336
337 if get("username") != username || get("realm") != realm || get("nonce") != nonce {
338 return false;
339 }
340 let client_uri = get("uri");
341 if !digest_uri_matches(client_uri, request_uri) {
342 return false;
343 }
344 let nc = get("nc");
345 let cnonce = get("cnonce");
346 let qop = get("qop");
347 let client_response = get("response");
348 if nc.is_empty() || cnonce.is_empty() || client_response.is_empty() {
349 return false;
350 }
351
352 let ha1 = md5_hex(format!("{username}:{realm}:{password}"));
353 let ha2 = md5_hex(format!("{method}:{client_uri}"));
354 let expected_response = md5_hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}"));
355 constant_time_eq(expected_response.as_bytes(), client_response.as_bytes())
356}
357
358/// RFC 7616 §3.4.1's SHOULD-check: does the client's claimed Digest `uri`
359/// field refer to the same request-target as `request_uri` (the actual
360/// request the server is verifying against)?
361///
362/// RFC 7230 §5.3 permits a request-target in either **origin-form**
363/// (`/path[?query]`) or **absolute-form** (`scheme://authority/path[?query]`)
364/// — a legitimate client may hash either, and `request_uri` here is always
365/// whatever form the caller's own request line/context uses (in this
366/// workspace, always origin-form for HTTP). This accepts:
367/// - `client_uri == request_uri` verbatim (the origin-form case), or
368/// - `client_uri` in absolute-form whose path(+query) — everything from the
369/// first `/` after the `"://"` authority — is byte-identical to
370/// `request_uri`.
371///
372/// Anything else is rejected. This is a real substitution guard, not a
373/// prefix/suffix check: a `client_uri` that merely contains or is suffixed by
374/// `request_uri` (or vice versa) does NOT match.
375fn digest_uri_matches(client_uri: &str, request_uri: &str) -> bool {
376 if client_uri == request_uri {
377 return true;
378 }
379 if let Some((_scheme, after_scheme)) = client_uri.split_once("://") {
380 if let Some(slash) = after_scheme.find('/') {
381 return &after_scheme[slash..] == request_uri;
382 }
383 }
384 false
385}
386
387/// Reverse-proxy forwarded-auth (see the module docs): authenticated iff
388/// `user_header` is present in `ctx` and non-empty (after trimming) — the
389/// proxy having already verified the caller's identity. No credential/secret
390/// is compared here, so no constant-time comparison is needed.
391fn verify_forwarded(ctx: &RequestContext<'_>, user_header: &str) -> bool {
392 ctx.header(user_header)
393 .is_some_and(|v| !v.trim().is_empty())
394}
395
396/// Lowercase-hex MD5 digest of `input`.
397fn md5_hex(input: String) -> String {
398 let mut hasher = Md5::new();
399 hasher.update(input.as_bytes());
400 let digest = hasher.finalize();
401 digest.iter().map(|b| format!("{b:02x}")).collect()
402}
403
404/// Byte-equality that does not short-circuit on the first differing byte —
405/// only the *length* check short-circuits (an equal-length requirement is
406/// not itself the secret being protected). Guards against a timing
407/// side-channel on the password/token/digest-response comparison.
408fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
409 if a.len() != b.len() {
410 return false;
411 }
412 a.iter()
413 .zip(b.iter())
414 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
415 == 0
416}
417
418/// A fresh 128-bit random server nonce, lowercase-hex encoded — see the
419/// module docs' nonce-handling caveat.
420fn generate_nonce() -> String {
421 let bytes: [u8; 16] = rand::random();
422 bytes.iter().map(|b| format!("{b:02x}")).collect()
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::{Credentials, RequestContext, respond};
429
430 const REALM: &str = "cameras";
431
432 /// Test helper: builds a [`RequestContext`] carrying `authorization` (if
433 /// any) as the `Authorization` header, then verifies it — stands in for
434 /// the pre-#663-extensibility-wave-1 `Verifier::verify(Option<&str>,
435 /// &str, &str)` signature so the tests below read the same as before.
436 fn verify_auth(
437 v: &Verifier,
438 authorization: Option<&str>,
439 method: &str,
440 uri: &str,
441 ) -> AuthResult {
442 let auth_header = authorization.map(|h| [("authorization", h)]);
443 let headers: &[(&str, &str)] = match &auth_header {
444 Some(arr) => arr,
445 None => &[],
446 };
447 let ctx = RequestContext::new(method, uri).with_headers(headers);
448 v.verify(&ctx)
449 }
450
451 // --- challenge() shape ---
452
453 #[test]
454 fn basic_challenge_names_the_realm() {
455 let v = Verifier::new(
456 Credentials::Basic {
457 username: "admin".into(),
458 password: "12345".into(),
459 },
460 REALM,
461 );
462 assert_eq!(v.challenge(), "Basic realm=\"cameras\"");
463 }
464
465 #[test]
466 fn digest_challenge_carries_realm_nonce_qop_algorithm() {
467 let v = Verifier::new(
468 Credentials::Digest {
469 username: "admin".into(),
470 password: "12345".into(),
471 },
472 REALM,
473 );
474 let challenge = v.challenge();
475 assert!(challenge.starts_with("Digest "), "got: {challenge}");
476 for needle in [
477 "realm=\"cameras\"",
478 "nonce=",
479 "qop=\"auth\"",
480 "algorithm=MD5",
481 ] {
482 assert!(
483 challenge.contains(needle),
484 "missing {needle} in {challenge}"
485 );
486 }
487 }
488
489 #[test]
490 fn bearer_challenge_is_bare_scheme_name() {
491 let v = Verifier::new(Credentials::bearer("tok"), REALM);
492 assert_eq!(v.challenge(), "Bearer");
493 }
494
495 #[test]
496 fn digest_nonce_is_stable_across_repeated_challenge_calls() {
497 let v = Verifier::new(
498 Credentials::Digest {
499 username: "admin".into(),
500 password: "12345".into(),
501 },
502 REALM,
503 );
504 assert_eq!(
505 v.challenge(),
506 v.challenge(),
507 "nonce must not rotate per-call"
508 );
509 }
510
511 // --- round trip: a client's respond() to challenge() must verify() Ok ---
512
513 #[test]
514 fn basic_respond_to_challenge_verifies_ok() {
515 let v = Verifier::new(
516 Credentials::Basic {
517 username: "admin".into(),
518 password: "12345".into(),
519 },
520 REALM,
521 );
522 let header = respond(
523 &v.challenge(),
524 &RequestContext::new("GET", "/stream"),
525 Credentials::new("admin", "12345"),
526 )
527 .unwrap();
528 assert_eq!(
529 verify_auth(&v, Some(&header), "GET", "/stream"),
530 AuthResult::Ok
531 );
532 }
533
534 #[test]
535 fn digest_respond_to_challenge_verifies_ok() {
536 let v = Verifier::new(
537 Credentials::Digest {
538 username: "admin".into(),
539 password: "12345".into(),
540 },
541 REALM,
542 );
543 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
544 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
545 assert_eq!(
546 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
547 AuthResult::Ok
548 );
549 }
550
551 #[test]
552 fn bearer_respond_to_challenge_verifies_ok() {
553 let v = Verifier::new(Credentials::bearer("mytoken123"), REALM);
554 let header = respond(
555 &v.challenge(),
556 &RequestContext::new("GET", "/stream"),
557 Credentials::bearer("mytoken123"),
558 )
559 .unwrap();
560 assert_eq!(
561 verify_auth(&v, Some(&header), "GET", "/stream"),
562 AuthResult::Ok
563 );
564 }
565
566 // --- wrong credentials -> Unauthorized (must BITE) ---
567
568 #[test]
569 fn basic_wrong_password_is_unauthorized() {
570 let v = Verifier::new(
571 Credentials::Basic {
572 username: "admin".into(),
573 password: "12345".into(),
574 },
575 REALM,
576 );
577 let header = respond(
578 &v.challenge(),
579 &RequestContext::new("GET", "/stream"),
580 Credentials::new("admin", "WRONG"),
581 )
582 .unwrap();
583 assert_eq!(
584 verify_auth(&v, Some(&header), "GET", "/stream"),
585 AuthResult::Unauthorized
586 );
587 }
588
589 #[test]
590 fn digest_wrong_password_is_unauthorized() {
591 let v = Verifier::new(
592 Credentials::Digest {
593 username: "admin".into(),
594 password: "12345".into(),
595 },
596 REALM,
597 );
598 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
599 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "WRONG")).unwrap();
600 assert_eq!(
601 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/live"),
602 AuthResult::Unauthorized
603 );
604 }
605
606 #[test]
607 fn digest_mismatched_request_uri_is_unauthorized() {
608 // A digest response computed for one URI must not verify against a
609 // different URI the caller passes to `verify` (RFC 7616 SHOULD-check
610 // that the header's `uri` matches the actual request).
611 let v = Verifier::new(
612 Credentials::Digest {
613 username: "admin".into(),
614 password: "12345".into(),
615 },
616 REALM,
617 );
618 let ctx = RequestContext::new("DESCRIBE", "rtsp://cam/live");
619 let header = respond(&v.challenge(), &ctx, Credentials::new("admin", "12345")).unwrap();
620 assert_eq!(
621 verify_auth(&v, Some(&header), "DESCRIBE", "rtsp://cam/OTHER"),
622 AuthResult::Unauthorized
623 );
624 }
625
626 /// RFC 7230 §5.3.2: a client may legally answer a Digest challenge using
627 /// the absolute-form request-target instead of origin-form — e.g.
628 /// multimux's outbound HTTP client (`source::http_auth::authenticated_get`,
629 /// issue #724) sends the absolute URL as `uri`. The server here only ever
630 /// sees the request's path (origin-form) as its own request `uri`; RFC
631 /// 7616 §3.4.1 permits this because HA2 is computed over the CLIENT's
632 /// claimed `uri`, and the SHOULD uri-match ([`digest_uri_matches`])
633 /// accepts either representation of the same target. Built via the real
634 /// `respond()` round-trip (not a rigged expected string) so this exercises
635 /// the true client computation.
636 #[test]
637 fn digest_accepts_absolute_form_client_uri_matching_request_path() {
638 let v = Verifier::new(
639 Credentials::Digest {
640 username: "admin".into(),
641 password: "12345".into(),
642 },
643 REALM,
644 );
645 let client_ctx = RequestContext::new("GET", "http://cam.local/stream/media.m3u8");
646 let header = respond(
647 &v.challenge(),
648 &client_ctx,
649 Credentials::new("admin", "12345"),
650 )
651 .unwrap();
652 assert!(
653 header.contains("uri=\"http://cam.local/stream/media.m3u8\""),
654 "expected the client to hash the absolute-form uri, got: {header}"
655 );
656 assert_eq!(
657 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
658 AuthResult::Ok
659 );
660 }
661
662 /// Regression/mutation guard: an absolute-form `uri` whose PATH is
663 /// genuinely different from the request must still be rejected — the
664 /// SHOULD uri-match is a real substitution guard, not a rubber stamp for
665 /// any absolute-form uri. Note this also exercises the response-mismatch
666 /// path independently of the match check: because HA2 is computed over
667 /// the client's own claimed uri, the client here computes a
668 /// self-consistent (but wrong-target) response, so a neutered
669 /// `digest_uri_matches` (hardcoded `true`) would let this wrongly verify
670 /// — this test must fail if that guard is ever dropped.
671 #[test]
672 fn digest_rejects_absolute_form_uri_with_wrong_path() {
673 let v = Verifier::new(
674 Credentials::Digest {
675 username: "admin".into(),
676 password: "12345".into(),
677 },
678 REALM,
679 );
680 let client_ctx = RequestContext::new("GET", "http://cam.local/other/path");
681 let header = respond(
682 &v.challenge(),
683 &client_ctx,
684 Credentials::new("admin", "12345"),
685 )
686 .unwrap();
687 assert_eq!(
688 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
689 AuthResult::Unauthorized
690 );
691 }
692
693 /// Same substitution guard, origin-form vs. origin-form (no scheme at
694 /// all): a client claiming a different path outright must be rejected.
695 #[test]
696 fn digest_rejects_origin_form_uri_with_wrong_path() {
697 let v = Verifier::new(
698 Credentials::Digest {
699 username: "admin".into(),
700 password: "12345".into(),
701 },
702 REALM,
703 );
704 let client_ctx = RequestContext::new("GET", "/other/path");
705 let header = respond(
706 &v.challenge(),
707 &client_ctx,
708 Credentials::new("admin", "12345"),
709 )
710 .unwrap();
711 assert_eq!(
712 verify_auth(&v, Some(&header), "GET", "/stream/media.m3u8"),
713 AuthResult::Unauthorized
714 );
715 }
716
717 #[test]
718 fn digest_uri_matches_unit_cases() {
719 // Origin-form, identical.
720 assert!(digest_uri_matches("/a/b", "/a/b"));
721 // Absolute-form whose path matches.
722 assert!(digest_uri_matches("http://host/a/b", "/a/b"));
723 assert!(digest_uri_matches("https://host:8080/a/b?q=1", "/a/b?q=1"));
724 // Wrong path in either form.
725 assert!(!digest_uri_matches("/a/c", "/a/b"));
726 assert!(!digest_uri_matches("http://host/a/c", "/a/b"));
727 // Not a suffix/prefix rubber stamp.
728 assert!(!digest_uri_matches("http://host/x/a/b", "/a/b"));
729 assert!(!digest_uri_matches("/a/b/extra", "/a/b"));
730 // Absolute-form with no path at all never matches a non-empty path.
731 assert!(!digest_uri_matches("http://host", "/a/b"));
732 }
733
734 #[test]
735 fn bearer_wrong_token_is_unauthorized() {
736 let v = Verifier::new(Credentials::bearer("right-token"), REALM);
737 let header = respond(
738 &v.challenge(),
739 &RequestContext::new("GET", "/stream"),
740 Credentials::bearer("wrong-token"),
741 )
742 .unwrap();
743 assert_eq!(
744 verify_auth(&v, Some(&header), "GET", "/stream"),
745 AuthResult::Unauthorized
746 );
747 }
748
749 #[test]
750 fn missing_authorization_header_is_unauthorized() {
751 let v = Verifier::new(Credentials::bearer("tok"), REALM);
752 assert_eq!(
753 verify_auth(&v, None, "GET", "/stream"),
754 AuthResult::Unauthorized
755 );
756 }
757
758 #[test]
759 fn wrong_scheme_header_is_unauthorized() {
760 // A Basic-configured verifier must reject a Bearer-shaped header
761 // (and vice versa) rather than mis-parsing it as a match.
762 let v = Verifier::new(
763 Credentials::Basic {
764 username: "admin".into(),
765 password: "12345".into(),
766 },
767 REALM,
768 );
769 assert_eq!(
770 verify_auth(&v, Some("Bearer sometoken"), "GET", "/stream"),
771 AuthResult::Unauthorized
772 );
773 }
774
775 // --- Forwarded (reverse-proxy forwarded-auth, issue #663 extensibility
776 // wave part 1) ---
777
778 #[test]
779 fn forwarded_challenge_is_bare_scheme_name() {
780 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
781 assert_eq!(v.challenge(), "Forwarded");
782 }
783
784 /// Biting test: a request carrying the configured user header (non-empty)
785 /// must verify `Ok` — this is the whole trust mechanism, no secret is
786 /// ever compared.
787 #[test]
788 fn forwarded_with_user_header_present_is_ok() {
789 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
790 let headers: &[(&str, &str)] = &[("X-Forwarded-User", "alice")];
791 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
792 assert_eq!(v.verify(&ctx), AuthResult::Ok);
793 }
794
795 /// Biting test: a request with no user header at all must `Unauthorized`
796 /// — the whole point of the scheme is that only a trusted proxy having
797 /// authenticated the caller sets it.
798 #[test]
799 fn forwarded_without_user_header_is_unauthorized() {
800 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
801 let ctx = RequestContext::new("GET", "/stream");
802 assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
803 }
804
805 /// An empty (but present) user header must not count as authenticated —
806 /// otherwise a proxy bug forwarding an empty header would silently grant
807 /// access.
808 #[test]
809 fn forwarded_with_empty_user_header_is_unauthorized() {
810 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
811 let headers: &[(&str, &str)] = &[("X-Forwarded-User", "")];
812 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
813 assert_eq!(v.verify(&ctx), AuthResult::Unauthorized);
814 }
815
816 /// The user-header lookup is case-insensitive, matching real HTTP header
817 /// semantics (RFC 7230 §3.2) rather than a literal-string match.
818 #[test]
819 fn forwarded_user_header_lookup_is_case_insensitive() {
820 let v = Verifier::forwarded("X-Forwarded-User", None);
821 let headers: &[(&str, &str)] = &[("x-forwarded-user", "alice")];
822 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
823 assert_eq!(v.verify(&ctx), AuthResult::Ok);
824 }
825
826 /// Biting test: `forwarded_for` reads the configured header's value back
827 /// out of the request context — the mechanism the origin middleware uses
828 /// to surface the proxy-forwarded client IP to tracing.
829 #[test]
830 fn forwarded_for_reads_configured_header() {
831 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
832 let headers: &[(&str, &str)] = &[
833 ("X-Forwarded-User", "alice"),
834 ("X-Forwarded-For", "203.0.113.7"),
835 ];
836 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
837 assert_eq!(v.forwarded_for(&ctx), Some("203.0.113.7"));
838 }
839
840 /// With no `forwarded_for_header` configured, `forwarded_for` is always
841 /// `None`, even if an `X-Forwarded-For` header happens to be present.
842 #[test]
843 fn forwarded_for_is_none_when_not_configured() {
844 let v = Verifier::forwarded("X-Forwarded-User", None);
845 let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
846 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
847 assert_eq!(v.forwarded_for(&ctx), None);
848 }
849
850 /// `forwarded_for` is always `None` for a non-`Forwarded` verifier, even
851 /// if the request happens to carry an `X-Forwarded-For` header.
852 #[test]
853 fn forwarded_for_is_none_for_non_forwarded_verifier() {
854 let v = Verifier::new(Credentials::bearer("tok"), REALM);
855 let headers: &[(&str, &str)] = &[("X-Forwarded-For", "203.0.113.7")];
856 let ctx = RequestContext::new("GET", "/stream").with_headers(headers);
857 assert_eq!(v.forwarded_for(&ctx), None);
858 }
859
860 /// Debug must never need to redact anything for `Forwarded` (no secret is
861 /// involved), but must still not panic and must name the scheme.
862 #[test]
863 fn forwarded_debug_names_scheme() {
864 let v = Verifier::forwarded("X-Forwarded-User", Some("X-Forwarded-For".to_string()));
865 let debug = format!("{v:?}");
866 assert!(debug.contains("Forwarded"), "debug: {debug}");
867 }
868
869 #[test]
870 fn constant_time_eq_matches_naive_equality() {
871 assert!(constant_time_eq(b"same", b"same"));
872 assert!(!constant_time_eq(b"same", b"diff"));
873 assert!(!constant_time_eq(b"short", b"longer-string"));
874 assert!(constant_time_eq(b"", b""));
875 }
876
877 // Regression: an oversized Digest `Authorization` header (way more
878 // `key=value` fields than any real client sends) must be rejected
879 // outright rather than parsed into an unbounded `HashMap` — and must
880 // never panic. Must FAIL if the `MAX_DIGEST_FIELDS` cap in
881 // `verify_digest` is ever removed.
882 #[test]
883 fn oversized_digest_header_is_rejected_not_parsed() {
884 let v = Verifier::new(
885 Credentials::Digest {
886 username: "admin".into(),
887 password: "12345".into(),
888 },
889 REALM,
890 );
891 let mut huge = String::from("Digest ");
892 for i in 0..(MAX_DIGEST_FIELDS + 1) {
893 if i > 0 {
894 huge.push(',');
895 }
896 huge.push_str(&format!("k{i}=\"v{i}\""));
897 }
898 assert_eq!(
899 verify_auth(&v, Some(&huge), "DESCRIBE", "rtsp://cam/live"),
900 AuthResult::Unauthorized,
901 "oversized Digest header must not be accepted"
902 );
903 }
904
905 #[test]
906 fn debug_never_leaks_password_or_token() {
907 let v = Verifier::new(
908 Credentials::Digest {
909 username: "admin".into(),
910 password: "supersecret".into(),
911 },
912 REALM,
913 );
914 let debug = format!("{v:?}");
915 assert!(!debug.contains("supersecret"), "debug: {debug}");
916
917 let v = Verifier::new(Credentials::bearer("topsecrettoken"), REALM);
918 let debug = format!("{v:?}");
919 assert!(!debug.contains("topsecrettoken"), "debug: {debug}");
920 }
921}