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 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 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}