Skip to main content

broadcast_auth/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4
5mod authenticator;
6pub mod challenge;
7mod constant_time;
8mod credentials;
9mod error;
10mod request;
11mod server;
12pub mod signed_url;
13mod userinfo;
14
15pub use authenticator::{Authenticator, respond};
16pub use challenge::{BearerError, Challenge, ParsedChallenges, Scheme, parse_challenges};
17pub use constant_time::constant_time_eq;
18pub use credentials::Credentials;
19pub use error::{Error, Result};
20pub use request::RequestContext;
21pub use server::{AuthResult, DIGEST_NC_TRACK_CAP, DIGEST_NONCE_LIFETIME, NC_WINDOW, Verifier};
22pub use signed_url::SignedUrlKeySet;
23pub use userinfo::{credentials_from_url, strip_userinfo};
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28
29    // The exact nonce rtsp-runtime's own digest test vector uses
30    // (`rtsp-runtime/tests/it/io_loopback.rs::digest_auth_over_loopback` and
31    // `rtsp-runtime/src/auth.rs` unit tests), reused here so both crates are
32    // known to agree on the same wire bytes.
33    const DIGEST_CHALLENGE: &str = "Digest realm=\"IP Camera\",\
34        nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\",qop=\"auth\",algorithm=MD5";
35
36    #[test]
37    fn basic_header_value_is_base64_of_user_colon_pass() {
38        let value = respond(
39            "Basic realm=\"IP Camera\"",
40            &RequestContext::new("DESCRIBE", "rtsp://c/live"),
41            Credentials::new("admin", "12345"),
42        )
43        .unwrap();
44        // base64("admin:12345")
45        assert_eq!(value, "Basic YWRtaW46MTIzNDU=");
46    }
47
48    #[test]
49    fn digest_response_matches_known_challenge_shape() {
50        let mut auth =
51            Authenticator::from_challenge(DIGEST_CHALLENGE, Credentials::new("admin", "12345"))
52                .unwrap();
53        let value = auth
54            .authorization(&RequestContext::new(
55                "DESCRIBE",
56                "rtsp://camera.example.com/live",
57            ))
58            .unwrap();
59        assert!(value.starts_with("Digest "), "got: {value}");
60        for needle in ["response=", "realm=", "nonce=", "uri=", "cnonce=", "nc="] {
61            assert!(value.contains(needle), "missing {needle} in {value}");
62        }
63        assert!(value.contains("uri=\"rtsp://camera.example.com/live\""));
64        assert!(value.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""));
65    }
66
67    #[test]
68    fn digest_nc_advances_across_calls_on_the_same_authenticator() {
69        let mut auth =
70            Authenticator::from_challenge(DIGEST_CHALLENGE, Credentials::new("admin", "12345"))
71                .unwrap();
72        let ctx = RequestContext::new("DESCRIBE", "rtsp://camera.example.com/live");
73        let first = auth.authorization(&ctx).unwrap();
74        let second = auth.authorization(&ctx).unwrap();
75        assert_ne!(first, second, "nc=00000001 vs nc=00000002 must differ");
76        assert!(first.contains("nc=00000001"), "got: {first}");
77        assert!(second.contains("nc=00000002"), "got: {second}");
78    }
79
80    #[test]
81    fn bearer_header_value_is_bearer_token_no_challenge_needed() {
82        let mut auth =
83            Authenticator::from_challenge("", Credentials::bearer("mytoken123")).unwrap();
84        let value = auth
85            .authorization(&RequestContext::new("GET", "/stream"))
86            .unwrap();
87        assert_eq!(value, "Bearer mytoken123");
88    }
89
90    #[test]
91    fn bearer_via_one_shot_respond_ignores_challenge_value() {
92        let value = respond(
93            "Basic realm=\"irrelevant, bearer never challenges\"",
94            &RequestContext::new("GET", "/stream"),
95            Credentials::bearer("t"),
96        )
97        .unwrap();
98        assert_eq!(value, "Bearer t");
99    }
100
101    #[test]
102    fn credentials_new_is_answered_as_whichever_scheme_the_challenge_advertises() {
103        let creds = Credentials::new("admin", "12345");
104        let basic = respond(
105            "Basic realm=\"r\"",
106            &RequestContext::new("GET", "/x"),
107            creds.clone(),
108        )
109        .unwrap();
110        assert!(basic.starts_with("Basic "));
111
112        let digest = respond(DIGEST_CHALLENGE, &RequestContext::new("GET", "/x"), creds).unwrap();
113        assert!(digest.starts_with("Digest "));
114    }
115
116    #[test]
117    fn unparseable_challenge_is_a_structured_error() {
118        let err = respond(
119            "not a real challenge",
120            &RequestContext::new("GET", "/x"),
121            Credentials::new("u", "p"),
122        )
123        .unwrap_err();
124        assert!(matches!(err, Error::ChallengeParse(_)));
125    }
126}