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