Skip to main content

broadcast_auth/
lib.rs

1//! Shared multi-scheme authentication for RTSP and HTTP clients *and*
2//! servers.
3//!
4//! Auth is not transport-specific: RTSP, TS-over-HTTP, HLS-pull, and any other
5//! credentialed origin all face the same handful of schemes. This crate holds
6//! **one** [`Credentials`] model, **one** client-side challenge->response
7//! helper ([`respond`]/[`Authenticator`]), and **one** server-side
8//! challenge+verify type ([`Verifier`]), so `rtsp-runtime`, `multimux`'s HTTP
9//! input adapters, and `multimux`'s own shared output-auth middleware all
10//! answer/issue `WWW-Authenticate` challenges through the same code instead
11//! of re-implementing it per client or per origin.
12//!
13//! # Client vs. server
14//!
15//! - **Client** ([`respond`]/[`Authenticator`]): given a `WWW-Authenticate`
16//!   challenge received from a server, compute the `Authorization` value to
17//!   answer it.
18//! - **Server** ([`Verifier`]): given a configured credential, produce the
19//!   `WWW-Authenticate` challenge to send on a `401`
20//!   ([`Verifier::challenge`]), and check an incoming `Authorization` header
21//!   against it ([`Verifier::verify`]).
22//!
23//! # Schemes
24//!
25//! - **Basic** (RFC 7617) and **Digest** (RFC 7616) — the challenge-parse and
26//!   response computation is delegated to the mature [`http_auth`] crate.
27//!   RTSP reuses these verbatim (RFC 2326 §14/§16): only the `uri` differs
28//!   (the RTSP request URI, not an HTTP URL).
29//! - **Bearer** (RFC 6750) — no challenge round-trip is required; the
30//!   `Authorization` value is always `Bearer <token>`.
31//! - **Forwarded** (server-side only, `Verifier::forwarded`) — trusts a
32//!   fronting reverse proxy that has already authenticated the caller and
33//!   forwards the authenticated username in a configured header. No
34//!   `Credentials`/challenge-response round-trip. See the [`Verifier`]
35//!   module docs for the trust assumption.
36//! - **SignedUrl** (server-side only, `Verifier::signed_url`, issue #747) —
37//!   CDN-style HMAC-SHA256 signed query-string tokens (`exp`/`kid`/`sig`[/
38//!   `ip`]), so a player can fetch segments with no credential header at
39//!   all. Key rotation via multiple simultaneously-valid `kid`s, optional
40//!   IP scoping, constant-time signature compare. See [`signed_url`] for the
41//!   wire form and canonical string.
42//!
43//! # Usage
44//!
45//! For a single request:
46//!
47//! ```
48//! use broadcast_auth::{respond, Credentials, RequestContext};
49//!
50//! let value = respond(
51//!     "Basic realm=\"cameras\"",
52//!     &RequestContext::new("GET", "/stream"),
53//!     Credentials::new("admin", "12345"),
54//! )
55//! .unwrap();
56//! assert!(value.starts_with("Basic "));
57//! ```
58//!
59//! Across a session (Digest's `nc` must advance on every request — keep the
60//! [`Authenticator`] alive, don't call [`respond`] per-request):
61//!
62//! ```
63//! use broadcast_auth::{Authenticator, Credentials, RequestContext};
64//!
65//! let mut auth = Authenticator::from_challenge(
66//!     "Digest realm=\"cameras\", nonce=\"abc123\", qop=\"auth\"",
67//!     Credentials::new("admin", "12345"),
68//! )
69//! .unwrap();
70//! let first = auth
71//!     .authorization(&RequestContext::new("DESCRIBE", "rtsp://cam/stream"))
72//!     .unwrap();
73//! let second = auth
74//!     .authorization(&RequestContext::new("PLAY", "rtsp://cam/stream"))
75//!     .unwrap();
76//! assert_ne!(first, second, "nc must advance between requests");
77//! ```
78//!
79//! Bearer needs no challenge at all:
80//!
81//! ```
82//! use broadcast_auth::Credentials;
83//!
84//! let creds = Credentials::bearer("mytoken");
85//! assert_eq!(creds, Credentials::Bearer { token: "mytoken".into() });
86//! ```
87//!
88//! Signed URLs (issue #747) mint and verify through the same
89//! [`SignedUrlKeySet`], no `Authorization` header involved:
90//!
91//! ```
92//! use broadcast_auth::{AuthResult, RequestContext, SignedUrlKeySet, Verifier};
93//!
94//! let keys = SignedUrlKeySet::new([("key-1".to_string(), vec![0u8; 32])]).unwrap();
95//! let exp = 4_000_000_000; // far future
96//! let query = keys.sign("key-1", "/stream/media.m3u8", exp, None).unwrap();
97//!
98//! let verifier = Verifier::signed_url(keys);
99//! let uri = format!("/stream/media.m3u8?{query}");
100//! assert_eq!(verifier.verify(&RequestContext::new("GET", &uri)), AuthResult::Ok);
101//! ```
102
103#![forbid(unsafe_code)]
104
105mod authenticator;
106mod credentials;
107mod error;
108mod request;
109mod server;
110pub mod signed_url;
111
112pub use authenticator::{Authenticator, respond};
113pub use credentials::Credentials;
114pub use error::{Error, Result};
115pub use request::RequestContext;
116pub use server::{AuthResult, Verifier};
117pub use signed_url::SignedUrlKeySet;
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    // The exact nonce rtsp-runtime's own digest test vector uses
124    // (`rtsp-runtime/tests/io_loopback.rs::digest_auth_over_loopback` and
125    // `rtsp-runtime/src/auth.rs` unit tests), reused here so both crates are
126    // known to agree on the same wire bytes.
127    const DIGEST_CHALLENGE: &str = "Digest realm=\"IP Camera\",\
128        nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\",qop=\"auth\",algorithm=MD5";
129
130    #[test]
131    fn basic_header_value_is_base64_of_user_colon_pass() {
132        let value = respond(
133            "Basic realm=\"IP Camera\"",
134            &RequestContext::new("DESCRIBE", "rtsp://c/live"),
135            Credentials::new("admin", "12345"),
136        )
137        .unwrap();
138        // base64("admin:12345")
139        assert_eq!(value, "Basic YWRtaW46MTIzNDU=");
140    }
141
142    #[test]
143    fn digest_response_matches_known_challenge_shape() {
144        let mut auth =
145            Authenticator::from_challenge(DIGEST_CHALLENGE, Credentials::new("admin", "12345"))
146                .unwrap();
147        let value = auth
148            .authorization(&RequestContext::new(
149                "DESCRIBE",
150                "rtsp://camera.example.com/live",
151            ))
152            .unwrap();
153        assert!(value.starts_with("Digest "), "got: {value}");
154        for needle in ["response=", "realm=", "nonce=", "uri=", "cnonce=", "nc="] {
155            assert!(value.contains(needle), "missing {needle} in {value}");
156        }
157        assert!(value.contains("uri=\"rtsp://camera.example.com/live\""));
158        assert!(value.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""));
159    }
160
161    #[test]
162    fn digest_nc_advances_across_calls_on_the_same_authenticator() {
163        let mut auth =
164            Authenticator::from_challenge(DIGEST_CHALLENGE, Credentials::new("admin", "12345"))
165                .unwrap();
166        let ctx = RequestContext::new("DESCRIBE", "rtsp://camera.example.com/live");
167        let first = auth.authorization(&ctx).unwrap();
168        let second = auth.authorization(&ctx).unwrap();
169        assert_ne!(first, second, "nc=00000001 vs nc=00000002 must differ");
170        assert!(first.contains("nc=00000001"), "got: {first}");
171        assert!(second.contains("nc=00000002"), "got: {second}");
172    }
173
174    #[test]
175    fn bearer_header_value_is_bearer_token_no_challenge_needed() {
176        let mut auth =
177            Authenticator::from_challenge("", Credentials::bearer("mytoken123")).unwrap();
178        let value = auth
179            .authorization(&RequestContext::new("GET", "/stream"))
180            .unwrap();
181        assert_eq!(value, "Bearer mytoken123");
182    }
183
184    #[test]
185    fn bearer_via_one_shot_respond_ignores_challenge_value() {
186        let value = respond(
187            "Basic realm=\"irrelevant, bearer never challenges\"",
188            &RequestContext::new("GET", "/stream"),
189            Credentials::bearer("t"),
190        )
191        .unwrap();
192        assert_eq!(value, "Bearer t");
193    }
194
195    #[test]
196    fn credentials_new_is_answered_as_whichever_scheme_the_challenge_advertises() {
197        let creds = Credentials::new("admin", "12345");
198        let basic = respond(
199            "Basic realm=\"r\"",
200            &RequestContext::new("GET", "/x"),
201            creds.clone(),
202        )
203        .unwrap();
204        assert!(basic.starts_with("Basic "));
205
206        let digest = respond(DIGEST_CHALLENGE, &RequestContext::new("GET", "/x"), creds).unwrap();
207        assert!(digest.starts_with("Digest "));
208    }
209
210    #[test]
211    fn unparseable_challenge_is_a_structured_error() {
212        let err = respond(
213            "not a real challenge",
214            &RequestContext::new("GET", "/x"),
215            Credentials::new("u", "p"),
216        )
217        .unwrap_err();
218        assert!(matches!(err, Error::ChallengeParse(_)));
219    }
220}