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}