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