Skip to main content

io_http/rfc9110/
challenge.rs

1//! HTTP authentication challenges ([RFC 9110 §11.6.1]).
2//!
3//! Parses `WWW-Authenticate` (and `Proxy-Authenticate`) header values
4//! into their challenge list: each challenge names a scheme and
5//! carries auth parameters. Challenges and their parameters share the
6//! comma separator, so an element counts as a new challenge when it
7//! does not read as a `name=value` parameter of the current one;
8//! `token68` blobs are recognized and skipped.
9//!
10//! [RFC 9110 §11.6.1]: https://www.rfc-editor.org/rfc/rfc9110#section-11.6.1
11
12use alloc::{
13    string::{String, ToString},
14    vec::Vec,
15};
16
17use crate::rfc9110::{headers::HTTP_WWW_AUTHENTICATE, response::HttpResponse};
18
19/// One authentication challenge: its scheme and auth parameters.
20///
21/// Scheme and parameter names are lowercased (both compare
22/// case-insensitively on the wire); parameter values are unquoted.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct HttpChallenge {
25    /// The lowercased authentication scheme (`basic`, `bearer`, ...).
26    pub scheme: String,
27    /// The auth parameters, names lowercased and values unquoted.
28    pub params: Vec<(String, String)>,
29}
30
31impl HttpChallenge {
32    /// Parses one header value into its challenge list.
33    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                // NOTE: a scheme followed by its first parameter or a
44                // token68 blob starts a new challenge.
45                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                // NOTE: a lone element is a parameter of the current
53                // challenge when it reads as `name=value`, a new bare
54                // challenge otherwise.
55                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    /// Returns the first parameter matching `name` (case-insensitive).
72    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    /// Parses the challenges of every `WWW-Authenticate` header of the
82    /// response, in order.
83    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
96/// Attaches one `name=value` element to the last challenge; a
97/// `token68` blob (whose only `=` signs are trailing padding) and a
98/// parameter preceding any scheme are skipped.
99fn 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        // NOTE: a bare token or a token68 blob, not a parameter.
110        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
124/// Splits on the commas outside quoted strings (a quoted parameter
125/// value may itself contain commas).
126fn 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
152/// Strips the surrounding quotes of a quoted-string value and resolves
153/// its escape sequences; a plain token passes through.
154fn 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        // NOTE: the quoted comma does not split the digest parameter.
210        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}