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//!
37//! # Usage
38//!
39//! For a single request:
40//!
41//! ```
42//! use broadcast_auth::{respond, Credentials, RequestContext};
43//!
44//! let value = respond(
45//!     "Basic realm=\"cameras\"",
46//!     &RequestContext::new("GET", "/stream"),
47//!     Credentials::new("admin", "12345"),
48//! )
49//! .unwrap();
50//! assert!(value.starts_with("Basic "));
51//! ```
52//!
53//! Across a session (Digest's `nc` must advance on every request — keep the
54//! [`Authenticator`] alive, don't call [`respond`] per-request):
55//!
56//! ```
57//! use broadcast_auth::{Authenticator, Credentials, RequestContext};
58//!
59//! let mut auth = Authenticator::from_challenge(
60//!     "Digest realm=\"cameras\", nonce=\"abc123\", qop=\"auth\"",
61//!     Credentials::new("admin", "12345"),
62//! )
63//! .unwrap();
64//! let first = auth
65//!     .authorization(&RequestContext::new("DESCRIBE", "rtsp://cam/stream"))
66//!     .unwrap();
67//! let second = auth
68//!     .authorization(&RequestContext::new("PLAY", "rtsp://cam/stream"))
69//!     .unwrap();
70//! assert_ne!(first, second, "nc must advance between requests");
71//! ```
72//!
73//! Bearer needs no challenge at all:
74//!
75//! ```
76//! use broadcast_auth::Credentials;
77//!
78//! let creds = Credentials::bearer("mytoken");
79//! assert_eq!(creds, Credentials::Bearer { token: "mytoken".into() });
80//! ```
81
82#![forbid(unsafe_code)]
83
84mod authenticator;
85mod credentials;
86mod error;
87mod request;
88mod server;
89
90pub use authenticator::{Authenticator, respond};
91pub use credentials::Credentials;
92pub use error::{Error, Result};
93pub use request::RequestContext;
94pub use server::{AuthResult, Verifier};
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    // The exact nonce rtsp-runtime's own digest test vector uses
101    // (`rtsp-runtime/tests/io_loopback.rs::digest_auth_over_loopback` and
102    // `rtsp-runtime/src/auth.rs` unit tests), reused here so both crates are
103    // known to agree on the same wire bytes.
104    const DIGEST_CHALLENGE: &str = "Digest realm=\"IP Camera\",\
105        nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\",qop=\"auth\",algorithm=MD5";
106
107    #[test]
108    fn basic_header_value_is_base64_of_user_colon_pass() {
109        let value = respond(
110            "Basic realm=\"IP Camera\"",
111            &RequestContext::new("DESCRIBE", "rtsp://c/live"),
112            Credentials::new("admin", "12345"),
113        )
114        .unwrap();
115        // base64("admin:12345")
116        assert_eq!(value, "Basic YWRtaW46MTIzNDU=");
117    }
118
119    #[test]
120    fn digest_response_matches_known_challenge_shape() {
121        let mut auth =
122            Authenticator::from_challenge(DIGEST_CHALLENGE, Credentials::new("admin", "12345"))
123                .unwrap();
124        let value = auth
125            .authorization(&RequestContext::new(
126                "DESCRIBE",
127                "rtsp://camera.example.com/live",
128            ))
129            .unwrap();
130        assert!(value.starts_with("Digest "), "got: {value}");
131        for needle in ["response=", "realm=", "nonce=", "uri=", "cnonce=", "nc="] {
132            assert!(value.contains(needle), "missing {needle} in {value}");
133        }
134        assert!(value.contains("uri=\"rtsp://camera.example.com/live\""));
135        assert!(value.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""));
136    }
137
138    #[test]
139    fn digest_nc_advances_across_calls_on_the_same_authenticator() {
140        let mut auth =
141            Authenticator::from_challenge(DIGEST_CHALLENGE, Credentials::new("admin", "12345"))
142                .unwrap();
143        let ctx = RequestContext::new("DESCRIBE", "rtsp://camera.example.com/live");
144        let first = auth.authorization(&ctx).unwrap();
145        let second = auth.authorization(&ctx).unwrap();
146        assert_ne!(first, second, "nc=00000001 vs nc=00000002 must differ");
147        assert!(first.contains("nc=00000001"), "got: {first}");
148        assert!(second.contains("nc=00000002"), "got: {second}");
149    }
150
151    #[test]
152    fn bearer_header_value_is_bearer_token_no_challenge_needed() {
153        let mut auth =
154            Authenticator::from_challenge("", Credentials::bearer("mytoken123")).unwrap();
155        let value = auth
156            .authorization(&RequestContext::new("GET", "/stream"))
157            .unwrap();
158        assert_eq!(value, "Bearer mytoken123");
159    }
160
161    #[test]
162    fn bearer_via_one_shot_respond_ignores_challenge_value() {
163        let value = respond(
164            "Basic realm=\"irrelevant, bearer never challenges\"",
165            &RequestContext::new("GET", "/stream"),
166            Credentials::bearer("t"),
167        )
168        .unwrap();
169        assert_eq!(value, "Bearer t");
170    }
171
172    #[test]
173    fn credentials_new_is_answered_as_whichever_scheme_the_challenge_advertises() {
174        let creds = Credentials::new("admin", "12345");
175        let basic = respond(
176            "Basic realm=\"r\"",
177            &RequestContext::new("GET", "/x"),
178            creds.clone(),
179        )
180        .unwrap();
181        assert!(basic.starts_with("Basic "));
182
183        let digest = respond(DIGEST_CHALLENGE, &RequestContext::new("GET", "/x"), creds).unwrap();
184        assert!(digest.starts_with("Digest "));
185    }
186
187    #[test]
188    fn unparseable_challenge_is_a_structured_error() {
189        let err = respond(
190            "not a real challenge",
191            &RequestContext::new("GET", "/x"),
192            Credentials::new("u", "p"),
193        )
194        .unwrap_err();
195        assert!(matches!(err, Error::ChallengeParse(_)));
196    }
197}