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