Skip to main content

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}