io_http/rfc9110/
challenge.rs1use alloc::{
13 string::{String, ToString},
14 vec::Vec,
15};
16
17use crate::rfc9110::{headers::HTTP_WWW_AUTHENTICATE, response::HttpResponse};
18
19#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct HttpChallenge {
25 pub scheme: String,
27 pub params: Vec<(String, String)>,
29}
30
31impl HttpChallenge {
32 pub fn parse_all(value: &str) -> Vec<HttpChallenge> {
34 let mut challenges: Vec<HttpChallenge> = Vec::new();
35
36 for element in split_unquoted_commas(value) {
37 let element = element.trim();
38 if element.is_empty() {
39 continue;
40 }
41
42 match element.split_once(|c: char| c.is_ascii_whitespace()) {
43 Some((scheme, rest)) => {
46 challenges.push(HttpChallenge {
47 scheme: scheme.to_ascii_lowercase(),
48 params: Vec::new(),
49 });
50 push_param(&mut challenges, rest.trim_start());
51 }
52 None => {
56 if element.contains('=') {
57 push_param(&mut challenges, element);
58 } else {
59 challenges.push(HttpChallenge {
60 scheme: element.to_ascii_lowercase(),
61 params: Vec::new(),
62 });
63 }
64 }
65 }
66 }
67
68 challenges
69 }
70
71 pub fn param(&self, name: &str) -> Option<&str> {
73 self.params
74 .iter()
75 .find(|(k, _)| k.eq_ignore_ascii_case(name))
76 .map(|(_, v)| v.as_str())
77 }
78}
79
80impl HttpResponse {
81 pub fn challenges(&self) -> Vec<HttpChallenge> {
84 let mut challenges = Vec::new();
85
86 for (name, value) in &self.headers {
87 if name.eq_ignore_ascii_case(HTTP_WWW_AUTHENTICATE) {
88 challenges.extend(HttpChallenge::parse_all(value));
89 }
90 }
91
92 challenges
93 }
94}
95
96fn push_param(challenges: &mut [HttpChallenge], element: &str) {
100 let Some(challenge) = challenges.last_mut() else {
101 return;
102 };
103 if element.is_empty() {
104 return;
105 }
106
107 let stripped = element.trim_end_matches('=');
108 if !stripped.contains('=') {
109 return;
111 }
112
113 let Some((name, value)) = element.split_once('=') else {
114 return;
115 };
116 let name = name.trim().to_ascii_lowercase();
117 if name.is_empty() {
118 return;
119 }
120
121 challenge.params.push((name, unquote(value.trim())));
122}
123
124fn split_unquoted_commas(value: &str) -> Vec<&str> {
127 let mut elements = Vec::new();
128 let mut start = 0;
129 let mut quoted = false;
130 let mut escaped = false;
131
132 for (index, c) in value.char_indices() {
133 if escaped {
134 escaped = false;
135 continue;
136 }
137 match c {
138 '\\' if quoted => escaped = true,
139 '"' => quoted = !quoted,
140 ',' if !quoted => {
141 elements.push(&value[start..index]);
142 start = index + 1;
143 }
144 _ => (),
145 }
146 }
147 elements.push(&value[start..]);
148
149 elements
150}
151
152fn unquote(value: &str) -> String {
155 let Some(quoted) = value
156 .strip_prefix('"')
157 .and_then(|value| value.strip_suffix('"'))
158 else {
159 return value.to_string();
160 };
161
162 let mut unquoted = String::with_capacity(quoted.len());
163 let mut escaped = false;
164
165 for c in quoted.chars() {
166 if escaped {
167 unquoted.push(c);
168 escaped = false;
169 } else if c == '\\' {
170 escaped = true;
171 } else {
172 unquoted.push(c);
173 }
174 }
175
176 unquoted
177}
178
179#[cfg(test)]
180mod tests {
181
182 use crate::rfc9110::challenge::HttpChallenge;
183
184 #[test]
185 fn single_challenge_with_params() {
186 let challenges = HttpChallenge::parse_all(
187 r#"Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource""#,
188 );
189
190 assert_eq!(challenges.len(), 1);
191 assert_eq!(challenges[0].scheme, "bearer");
192 assert_eq!(
193 challenges[0].param("Resource_Metadata"),
194 Some("https://api.example.com/.well-known/oauth-protected-resource"),
195 );
196 }
197
198 #[test]
199 fn multiple_challenges_share_the_comma_separator() {
200 let challenges = HttpChallenge::parse_all(
201 r#"Basic realm="carddav.example.com", Bearer, Digest qop="auth,auth-int""#,
202 );
203
204 assert_eq!(challenges.len(), 3);
205 assert_eq!(challenges[0].scheme, "basic");
206 assert_eq!(challenges[0].param("realm"), Some("carddav.example.com"));
207 assert_eq!(challenges[1].scheme, "bearer");
208 assert!(challenges[1].params.is_empty());
209 assert_eq!(challenges[2].param("qop"), Some("auth,auth-int"));
211 }
212
213 #[test]
214 fn token68_blobs_are_not_parameters() {
215 let challenges = HttpChallenge::parse_all("Negotiate YWJjZGVmZw==, Basic realm=dav");
216
217 assert_eq!(challenges.len(), 2);
218 assert_eq!(challenges[0].scheme, "negotiate");
219 assert!(challenges[0].params.is_empty());
220 assert_eq!(challenges[1].param("realm"), Some("dav"));
221 }
222
223 #[test]
224 fn quoted_escapes_resolve() {
225 let challenges = HttpChallenge::parse_all(r#"Basic realm="a \"quoted\" realm""#);
226 assert_eq!(challenges[0].param("realm"), Some(r#"a "quoted" realm"#));
227 }
228}