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: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}