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}