io_http/rfc9110/
challenge.rs1use alloc::{
13 string::{String, ToString},
14 vec::Vec,
15};
16
17use crate::rfc9110::{headers::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 param(&self, name: &str) -> Option<&str> {
34 self.params
35 .iter()
36 .find(|(k, _)| k.eq_ignore_ascii_case(name))
37 .map(|(_, v)| v.as_str())
38 }
39}
40
41impl HttpResponse {
42 pub fn challenges(&self) -> Vec<HttpChallenge> {
45 let mut challenges = Vec::new();
46
47 for (name, value) in &self.headers {
48 if name.eq_ignore_ascii_case(WWW_AUTHENTICATE) {
49 challenges.extend(parse_challenges(value));
50 }
51 }
52
53 challenges
54 }
55}
56
57pub fn parse_challenges(value: &str) -> Vec<HttpChallenge> {
59 let mut challenges: Vec<HttpChallenge> = Vec::new();
60
61 for element in split_unquoted_commas(value) {
62 let element = element.trim();
63 if element.is_empty() {
64 continue;
65 }
66
67 match element.split_once(|c: char| c.is_ascii_whitespace()) {
68 Some((scheme, rest)) => {
71 challenges.push(HttpChallenge {
72 scheme: scheme.to_ascii_lowercase(),
73 params: Vec::new(),
74 });
75 push_param(&mut challenges, rest.trim_start());
76 }
77 None => {
81 if element.contains('=') {
82 push_param(&mut challenges, element);
83 } else {
84 challenges.push(HttpChallenge {
85 scheme: element.to_ascii_lowercase(),
86 params: Vec::new(),
87 });
88 }
89 }
90 }
91 }
92
93 challenges
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::parse_challenges;
183
184 #[test]
185 fn single_challenge_with_params() {
186 let challenges = parse_challenges(
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 = parse_challenges(
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 = parse_challenges("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 = parse_challenges(r#"Basic realm="a \"quoted\" realm""#);
226 assert_eq!(challenges[0].param("realm"), Some(r#"a "quoted" realm"#));
227 }
228}