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