use http::{HeaderMap, header::WWW_AUTHENTICATE};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Challenge {
pub scheme: String,
pub payload: ChallengePayload,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChallengePayload {
Params(Vec<(String, String)>),
Token68(String),
}
impl Challenge {
fn new(scheme: String) -> Self {
Self {
scheme,
payload: ChallengePayload::Params(Vec::new()),
}
}
fn push_param(&mut self, name: String, value: String) {
debug_assert!(matches!(self.payload, ChallengePayload::Params(_)));
if let ChallengePayload::Params(params) = &mut self.payload {
params.push((name, value));
}
}
#[must_use]
pub fn is_scheme(&self, scheme: &str) -> bool {
self.scheme.eq_ignore_ascii_case(scheme)
}
#[must_use]
pub fn params(&self) -> &[(String, String)] {
match &self.payload {
ChallengePayload::Params(params) => params,
ChallengePayload::Token68(_) => &[],
}
}
#[must_use]
pub fn token68(&self) -> Option<&str> {
match &self.payload {
ChallengePayload::Params(_) => None,
ChallengePayload::Token68(token68) => Some(token68),
}
}
#[must_use]
pub fn param(&self, name: &str) -> Option<&str> {
self.params()
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn error(&self) -> Option<&str> {
self.param("error")
}
}
#[must_use]
pub fn parse_challenges(headers: &HeaderMap) -> Vec<Challenge> {
let mut out = Vec::new();
for value in headers
.get_all(WWW_AUTHENTICATE)
.iter()
.filter_map(|value| value.to_str().ok())
{
parse_challenge_list(value, &mut out);
}
out
}
pub(crate) fn challenge_has_error(headers: &HeaderMap, code: &str) -> bool {
parse_challenges(headers)
.iter()
.any(|challenge| challenge.error() == Some(code))
}
fn is_tchar(c: u8) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
fn is_token68_char(c: u8) -> bool {
c.is_ascii_alphanumeric() || matches!(c, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
}
struct Cursor<'a> {
s: &'a [u8],
pos: usize,
}
impl Cursor<'_> {
fn peek(&self) -> Option<u8> {
self.s.get(self.pos).copied()
}
fn peek_at(&self, offset: usize) -> Option<u8> {
self.s.get(self.pos + offset).copied()
}
fn bump(&mut self) {
self.pos += 1;
}
fn at_end(&self) -> bool {
self.pos >= self.s.len()
}
fn skip_ows(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t')) {
self.bump();
}
}
fn skip_list_separators(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t' | b',')) {
self.bump();
}
}
fn lex_while(&mut self, pred: fn(u8) -> bool) -> Option<String> {
let start = self.pos;
while let Some(c) = self.peek() {
if pred(c) {
self.bump();
} else {
break;
}
}
(self.pos > start).then(|| String::from_utf8_lossy(&self.s[start..self.pos]).into_owned())
}
fn lex_token(&mut self) -> Option<String> {
self.lex_while(is_tchar)
}
fn lex_quoted_string(&mut self) -> Option<String> {
debug_assert_eq!(self.peek(), Some(b'"'));
self.bump();
let mut out = Vec::new();
loop {
match self.peek() {
None => return None,
Some(b'"') => {
self.bump();
return Some(String::from_utf8_lossy(&out).into_owned());
}
Some(b'\\') => {
self.bump();
let escaped = self.peek()?;
out.push(escaped);
self.bump();
}
Some(c) => {
out.push(c);
self.bump();
}
}
}
}
fn skip_to_comma(&mut self) {
while let Some(c) = self.peek() {
if c == b',' {
break;
}
self.bump();
}
}
fn at_param_equals(&self) -> bool {
self.peek() == Some(b'=') && self.peek_at(1) != Some(b'=')
}
fn continues_with_param(&self) -> bool {
let mut probe = Cursor {
s: self.s,
pos: self.pos,
};
probe.skip_ows();
if probe.peek() != Some(b',') {
return false;
}
probe.skip_list_separators();
probe.lex_token().is_some() && {
probe.skip_ows();
probe.at_param_equals()
}
}
fn lex_param_value(&mut self) -> Option<String> {
match self.peek() {
Some(b'"') => self.lex_quoted_string(),
Some(c) if is_tchar(c) => self.lex_token(),
_ => None,
}
}
}
fn parse_challenge_list(value: &str, out: &mut Vec<Challenge>) {
let mut cur = Cursor {
s: value.as_bytes(),
pos: 0,
};
let mut pending_scheme: Option<String> = None;
loop {
let scheme = if let Some(scheme) = pending_scheme.take() {
scheme
} else {
cur.skip_list_separators();
if cur.at_end() {
return;
}
let Some(token) = cur.lex_token() else {
cur.skip_to_comma();
continue;
};
token
};
let mut challenge = Challenge::new(scheme);
cur.skip_ows();
if matches!(cur.peek(), None | Some(b',')) {
out.push(challenge);
continue;
}
let Some(word) = cur.lex_while(|c| is_tchar(c) || is_token68_char(c)) else {
cur.skip_to_comma();
out.push(challenge);
continue;
};
cur.skip_ows();
if cur.at_param_equals() {
cur.bump(); cur.skip_ows();
match cur.lex_param_value() {
Some(value) => {
challenge.push_param(word, value);
parse_remaining_params(&mut cur, &mut challenge, &mut pending_scheme);
}
None if cur.continues_with_param() => {
challenge.push_param(word, String::new());
parse_remaining_params(&mut cur, &mut challenge, &mut pending_scheme);
}
None => challenge.payload = ChallengePayload::Token68(word + "="),
}
} else if cur.peek() == Some(b'=') {
let mut token68 = word;
while cur.peek() == Some(b'=') {
token68.push('=');
cur.bump();
}
challenge.payload = ChallengePayload::Token68(token68);
} else {
challenge.payload = ChallengePayload::Token68(word);
}
cur.skip_ows();
if !matches!(cur.peek(), None | Some(b',')) && pending_scheme.is_none() {
cur.skip_to_comma();
}
out.push(challenge);
}
}
fn parse_remaining_params(
cur: &mut Cursor,
challenge: &mut Challenge,
pending_scheme: &mut Option<String>,
) {
loop {
cur.skip_ows();
match cur.peek() {
None => return,
Some(b',') => {
cur.skip_list_separators();
if cur.at_end() {
return;
}
let Some(token) = cur.lex_token() else {
cur.skip_to_comma();
continue;
};
cur.skip_ows();
if cur.at_param_equals() {
cur.bump(); cur.skip_ows();
match cur.lex_param_value() {
Some(value) => challenge.push_param(token, value),
None if matches!(cur.peek(), None | Some(b',')) => {
challenge.push_param(token, String::new());
}
None => cur.skip_to_comma(),
}
} else {
*pending_scheme = Some(token);
return;
}
}
Some(_) => cur.skip_to_comma(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn headers(values: &[&str]) -> HeaderMap {
let mut headers = HeaderMap::new();
for value in values {
headers.append(WWW_AUTHENTICATE, value.parse().unwrap());
}
headers
}
fn challenge(scheme: &str, params: &[(&str, &str)]) -> Challenge {
Challenge {
scheme: scheme.to_owned(),
payload: ChallengePayload::Params(
params
.iter()
.map(|(n, v)| ((*n).to_owned(), (*v).to_owned()))
.collect(),
),
}
}
#[test]
fn rfc7235_example() {
let headers = headers(&[
r#"Newauth realm="apps", type=1, title="Login to \"apps\"", Basic realm="simple""#,
]);
assert_eq!(
parse_challenges(&headers),
[
challenge(
"Newauth",
&[
("realm", "apps"),
("type", "1"),
("title", r#"Login to "apps""#),
],
),
challenge("Basic", &[("realm", "simple")]),
]
);
}
#[test]
fn bearer_and_dpop_challenges() {
let headers = headers(&[
r#"Bearer realm="example", error="invalid_token", error_description="The access token expired""#,
r#"DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof""#,
]);
let challenges = parse_challenges(&headers);
assert_eq!(challenges.len(), 2);
assert!(challenges[0].is_scheme("bearer"));
assert_eq!(challenges[0].error(), Some("invalid_token"));
assert_eq!(
challenges[0].param("ERROR_DESCRIPTION"),
Some("The access token expired")
);
assert!(challenges[1].is_scheme("DPoP"));
assert_eq!(challenges[1].error(), Some("use_dpop_nonce"));
}
#[test]
fn error_inside_quoted_value_is_not_a_code() {
let headers = headers(&[
r#"Bearer error="invalid_request", error_description="try again, error=invalid_token happens sometimes""#,
]);
let challenges = parse_challenges(&headers);
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0].error(), Some("invalid_request"));
}
#[test]
fn error_inside_error_uri_is_not_a_code() {
let headers = headers(&[
r#"Bearer error="invalid_request", error_uri="https://as.example.com/doc?error=invalid_token""#,
]);
let challenges = parse_challenges(&headers);
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0].error(), Some("invalid_request"));
}
#[test]
fn token68_with_padding() {
let headers = headers(&["Basic dGVzdDoxMjM=, Bearer error=invalid_token"]);
let challenges = parse_challenges(&headers);
assert_eq!(challenges.len(), 2);
assert_eq!(challenges[0].scheme, "Basic");
assert_eq!(challenges[0].token68(), Some("dGVzdDoxMjM="));
assert!(challenges[0].params().is_empty());
assert_eq!(challenges[1].error(), Some("invalid_token"));
}
#[test]
fn token68_double_padding_and_slash() {
let headers = headers(&["Negotiate a/b+c==, Bearer error=x"]);
let challenges = parse_challenges(&headers);
assert_eq!(
challenges[0].payload,
ChallengePayload::Token68("a/b+c==".to_owned())
);
assert_eq!(challenges[1].error(), Some("x"));
}
#[test]
fn scheme_only_challenges() {
let headers = headers(&[r#"Negotiate, Bearer realm="api""#]);
let challenges = parse_challenges(&headers);
assert_eq!(challenges.len(), 2);
assert_eq!(challenges[0], challenge("Negotiate", &[]));
assert_eq!(challenges[1].param("realm"), Some("api"));
}
#[test]
fn unquoted_params_and_loose_whitespace() {
let headers = headers(&["Bearer error = invalid_token ,scope=read"]);
let challenges = parse_challenges(&headers);
assert_eq!(
challenges,
[challenge(
"Bearer",
&[("error", "invalid_token"), ("scope", "read")],
)]
);
}
#[test]
fn empty_list_elements_are_tolerated() {
let headers = headers(&[", , Bearer error=\"invalid_token\", , DPoP error=use_dpop_nonce"]);
let challenges = parse_challenges(&headers);
assert_eq!(challenges.len(), 2);
assert_eq!(challenges[0].error(), Some("invalid_token"));
assert_eq!(challenges[1].error(), Some("use_dpop_nonce"));
}
#[test]
fn unterminated_quote_drops_segment_without_error_codes() {
let headers = headers(&[r#"Bearer error="unterminated"#]);
assert!(
parse_challenges(&headers)
.iter()
.all(|challenge| challenge.error().is_none())
);
}
#[test]
fn empty_param_value_does_not_eat_following_params() {
let headers = headers(&[r#"Bearer error=, realm="foo""#]);
assert_eq!(
parse_challenges(&headers),
[challenge("Bearer", &[("error", ""), ("realm", "foo")])]
);
}
#[test]
fn empty_param_value_in_continuation_position() {
let headers = headers(&["Bearer a=1, b=, c=2"]);
assert_eq!(
parse_challenges(&headers),
[challenge("Bearer", &[("a", "1"), ("b", ""), ("c", "2")])]
);
}
#[test]
fn empty_quoted_value() {
let headers = headers(&[r#"Bearer realm="""#]);
assert_eq!(parse_challenges(&headers)[0].param("realm"), Some(""));
}
#[test]
fn no_challenges() {
assert!(parse_challenges(&headers(&[])).is_empty());
assert!(parse_challenges(&headers(&[" , "])).is_empty());
}
#[test]
fn first_param_lookahead_does_not_eat_next_scheme() {
let headers = headers(&[r#"Bearer realm="x", DPoP, Basic realm="y""#]);
let challenges = parse_challenges(&headers);
assert_eq!(challenges.len(), 3);
assert_eq!(challenges[1], challenge("DPoP", &[]));
assert_eq!(challenges[2].param("realm"), Some("y"));
}
#[test]
fn non_ascii_quoted_value_round_trips() {
let value = "über café — naïve 🦀";
let mut out = Vec::new();
parse_challenge_list(&format!(r#"Bearer error_description="{value}""#), &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].param("error_description"), Some(value));
}
}