Skip to main content

aria2_protocol/http/
auth.rs

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}