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