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