use alloc::{
string::{String, ToString},
vec::Vec,
};
use crate::rfc9110::{headers::HTTP_WWW_AUTHENTICATE, response::HttpResponse};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpChallenge {
pub scheme: String,
pub params: Vec<(String, String)>,
}
impl HttpChallenge {
pub fn parse_all(value: &str) -> Vec<HttpChallenge> {
let mut challenges: Vec<HttpChallenge> = Vec::new();
for element in split_unquoted_commas(value) {
let element = element.trim();
if element.is_empty() {
continue;
}
match element.split_once(|c: char| c.is_ascii_whitespace()) {
Some((scheme, rest)) => {
challenges.push(HttpChallenge {
scheme: scheme.to_ascii_lowercase(),
params: Vec::new(),
});
push_param(&mut challenges, rest.trim_start());
}
None => {
if element.contains('=') {
push_param(&mut challenges, element);
} else {
challenges.push(HttpChallenge {
scheme: element.to_ascii_lowercase(),
params: Vec::new(),
});
}
}
}
}
challenges
}
pub fn param(&self, name: &str) -> Option<&str> {
self.params
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
impl HttpResponse {
pub fn challenges(&self) -> Vec<HttpChallenge> {
let mut challenges = Vec::new();
for (name, value) in &self.headers {
if name.eq_ignore_ascii_case(HTTP_WWW_AUTHENTICATE) {
challenges.extend(HttpChallenge::parse_all(value));
}
}
challenges
}
}
fn push_param(challenges: &mut [HttpChallenge], element: &str) {
let Some(challenge) = challenges.last_mut() else {
return;
};
if element.is_empty() {
return;
}
let stripped = element.trim_end_matches('=');
if !stripped.contains('=') {
return;
}
let Some((name, value)) = element.split_once('=') else {
return;
};
let name = name.trim().to_ascii_lowercase();
if name.is_empty() {
return;
}
challenge.params.push((name, unquote(value.trim())));
}
fn split_unquoted_commas(value: &str) -> Vec<&str> {
let mut elements = Vec::new();
let mut start = 0;
let mut quoted = false;
let mut escaped = false;
for (index, c) in value.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if quoted => escaped = true,
'"' => quoted = !quoted,
',' if !quoted => {
elements.push(&value[start..index]);
start = index + 1;
}
_ => (),
}
}
elements.push(&value[start..]);
elements
}
fn unquote(value: &str) -> String {
let Some(quoted) = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
else {
return value.to_string();
};
let mut unquoted = String::with_capacity(quoted.len());
let mut escaped = false;
for c in quoted.chars() {
if escaped {
unquoted.push(c);
escaped = false;
} else if c == '\\' {
escaped = true;
} else {
unquoted.push(c);
}
}
unquoted
}
#[cfg(test)]
mod tests {
use crate::rfc9110::challenge::HttpChallenge;
#[test]
fn single_challenge_with_params() {
let challenges = HttpChallenge::parse_all(
r#"Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource""#,
);
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0].scheme, "bearer");
assert_eq!(
challenges[0].param("Resource_Metadata"),
Some("https://api.example.com/.well-known/oauth-protected-resource"),
);
}
#[test]
fn multiple_challenges_share_the_comma_separator() {
let challenges = HttpChallenge::parse_all(
r#"Basic realm="carddav.example.com", Bearer, Digest qop="auth,auth-int""#,
);
assert_eq!(challenges.len(), 3);
assert_eq!(challenges[0].scheme, "basic");
assert_eq!(challenges[0].param("realm"), Some("carddav.example.com"));
assert_eq!(challenges[1].scheme, "bearer");
assert!(challenges[1].params.is_empty());
assert_eq!(challenges[2].param("qop"), Some("auth,auth-int"));
}
#[test]
fn token68_blobs_are_not_parameters() {
let challenges = HttpChallenge::parse_all("Negotiate YWJjZGVmZw==, Basic realm=dav");
assert_eq!(challenges.len(), 2);
assert_eq!(challenges[0].scheme, "negotiate");
assert!(challenges[0].params.is_empty());
assert_eq!(challenges[1].param("realm"), Some("dav"));
}
#[test]
fn quoted_escapes_resolve() {
let challenges = HttpChallenge::parse_all(r#"Basic realm="a \"quoted\" realm""#);
assert_eq!(challenges[0].param("realm"), Some(r#"a "quoted" realm"#));
}
}