1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone)]
5pub enum AuthScheme {
6 Basic,
7 Digest,
8}
9
10#[derive(Debug, Clone)]
11pub struct AuthCredentials {
12 pub username: String,
13 pub password: String,
14 pub scheme: AuthScheme,
15}
16
17impl AuthCredentials {
18 pub fn new_basic(username: &str, password: &str) -> Self {
19 Self {
20 username: username.to_string(),
21 password: password.to_string(),
22 scheme: AuthScheme::Basic,
23 }
24 }
25
26 pub fn new_digest(username: &str, password: &str) -> Self {
27 Self {
28 username: username.to_string(),
29 password: password.to_string(),
30 scheme: AuthScheme::Digest,
31 }
32 }
33
34 pub fn basic_auth_header(&self) -> String {
35 let credentials = format!("{}:{}", self.username, self.password);
36 let encoded = STANDARD.encode(credentials);
37 format!("Basic {}", encoded)
38 }
39}
40
41pub struct HttpAuth;
42
43impl HttpAuth {
44 pub fn apply_auth(
45 request: crate::http::request::HttpRequest,
46 credentials: &AuthCredentials,
47 ) -> crate::http::request::HttpRequest {
48 match credentials.scheme {
49 AuthScheme::Basic => {
50 request.with_header("Authorization", &credentials.basic_auth_header())
51 }
52 AuthScheme::Digest => request,
53 }
54 }
55
56 pub fn parse_www_authenticate(header_value: &str) -> Option<AuthChallenge> {
57 let header = header_value.trim();
58 if header.eq_ignore_ascii_case("basic") || header.starts_with("Basic ") {
59 return Some(AuthChallenge::Basic);
60 }
61
62 if !header.starts_with("Digest ") {
63 return None;
64 }
65
66 let params_str = &header[7..];
67 let mut params = HashMap::new();
68 for part in params_str.split(',') {
69 let part = part.trim();
70 if let Some((key, value)) = part.split_once('=') {
71 let key = key.trim().to_lowercase();
72 let mut value = value.trim();
73 if value.starts_with('"') && value.ends_with('"') {
74 value = &value[1..value.len() - 1];
75 }
76 params.insert(key, value.to_string());
77 }
78
79 if let Some((key, _)) = part.split_once('=') {
80 let key = key.trim().to_lowercase();
81 params.entry(key).or_insert_with(String::new);
82 }
83 }
84
85 Some(AuthChallenge::Digest(DigestChallenge {
86 realm: params.get("realm").cloned().unwrap_or_default(),
87 nonce: params.get("nonce").cloned().unwrap_or_default(),
88 qop: params.get("qop").cloned().unwrap_or_default(),
89 algorithm: params
90 .get("algorithm")
91 .cloned()
92 .unwrap_or_else(|| "MD5".to_string()),
93 opaque: params.get("opaque").cloned().unwrap_or_default(),
94 stale: params
95 .get("stale")
96 .map(|v: &String| v.eq_ignore_ascii_case("true"))
97 .unwrap_or(false),
98 }))
99 }
100
101 pub fn build_digest_response(
102 challenge: &DigestChallenge,
103 credentials: &AuthCredentials,
104 method: &str,
105 uri: &str,
106 nc: u32,
107 cnonce: &str,
108 ) -> String {
109 let ha1 = Self::compute_ha1(
110 &credentials.username,
111 &challenge.realm,
112 &credentials.password,
113 &challenge.algorithm,
114 );
115
116 let ha2 = Self::compute_ha2(method, uri);
117
118 let response = if challenge.qop.is_empty() {
119 format!(
120 "{:x}",
121 md5::compute(format!("{}:{}{}", ha1, challenge.nonce, ha2))
122 )
123 } else {
124 format!(
125 "{:x}",
126 md5::compute(format!(
127 "{}:{}:{:08x}:{}:{}:{}",
128 ha1, challenge.nonce, nc, cnonce, challenge.qop, ha2
129 ))
130 )
131 };
132
133 let mut parts = vec![
134 format!("username=\"{}\"", credentials.username),
135 format!("realm=\"{}\"", challenge.realm),
136 format!("nonce=\"{}\"", challenge.nonce),
137 format!("uri=\"{}\"", uri),
138 format!("response=\"{}\"", response),
139 format!("algorithm={}", challenge.algorithm),
140 ];
141
142 if !challenge.qop.is_empty() {
143 parts.push(format!("qop={}", challenge.qop));
144 parts.push(format!("nc={:08x}", nc));
145 parts.push(format!("cnonce=\"{}\"", cnonce));
146 }
147
148 if !challenge.opaque.is_empty() {
149 parts.push(format!("opaque=\"{}\"", challenge.opaque));
150 }
151
152 format!("Digest {}", parts.join(", "))
153 }
154
155 fn compute_ha1(username: &str, realm: &str, password: &str, _algorithm: &str) -> String {
156 let a1 = format!("{}:{}:{}", username, realm, password);
157 format!("{:x}", md5::compute(a1))
158 }
159
160 fn compute_ha2(method: &str, uri: &str) -> String {
161 format!("{:x}", md5::compute(format!("{}:{}", method, uri)))
162 }
163}
164
165#[derive(Debug, Clone)]
166pub enum AuthChallenge {
167 Basic,
168 Digest(DigestChallenge),
169}
170
171#[derive(Debug, Clone)]
172pub struct DigestChallenge {
173 pub realm: String,
174 pub nonce: String,
175 pub qop: String,
176 pub algorithm: String,
177 pub opaque: String,
178 pub stale: bool,
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn test_basic_auth_header() {
187 let creds = AuthCredentials::new_basic("admin", "secret123");
188 let header = creds.basic_auth_header();
189 assert!(header.starts_with("Basic "));
190 assert!(!header.contains("admin"));
191 assert!(!header.contains("secret123"));
192 }
193
194 #[test]
195 fn test_parse_basic_challenge() {
196 let result = HttpAuth::parse_www_authenticate("Basic");
197 assert!(matches!(result, Some(AuthChallenge::Basic)));
198 }
199
200 #[test]
201 fn test_parse_digest_challenge() {
202 let header = r#"Digest realm="testrealm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", qop="auth", algorithm=MD5"#;
203 let result = HttpAuth::parse_www_authenticate(header);
204 assert!(matches!(result, Some(AuthChallenge::Digest(_))));
205 if let Some(AuthChallenge::Digest(d)) = result {
206 assert_eq!(d.realm, "testrealm@host.com");
207 assert_eq!(d.nonce, "dcd98b7102dd2f0e8b11d0f600bfb0c093");
208 assert_eq!(d.qop, "auth");
209 }
210 }
211
212 #[test]
213 fn test_build_digest_response() {
214 let challenge = DigestChallenge {
215 realm: "testrealm".to_string(),
216 nonce: "abcdef123456".to_string(),
217 qop: "auth".to_string(),
218 algorithm: "MD5".to_string(),
219 opaque: "".to_string(),
220 stale: false,
221 };
222 let creds = AuthCredentials::new_digest("user", "pass");
223 let response =
224 HttpAuth::build_digest_response(&challenge, &creds, "GET", "/path", 1, "abc");
225 assert!(response.starts_with("Digest "));
226 assert!(response.contains("username=\"user\""));
227 assert!(response.contains("realm=\"testrealm\""));
228 }
229}