use std::fmt::{self, Write as _};
use sip_uri::{UriParse, UriRedact};
use crate::check::checked_token;
use crate::diagnostic::{Field, ParseWarning, Parsed, WarningCode};
use crate::error::{FaultCode, ParseError};
use crate::is_token;
use crate::params::{HeaderParams, Owner, ParamRule, ParamsMut};
use crate::redact::Redact;
use crate::span::Span;
use crate::traits::{sealed, HeaderParse};
#[derive(Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct SipAuthValue {
scheme: String,
params: HeaderParams,
token68: Option<String>,
}
const CREDENTIAL_PARAMS: &[&str] = &[
"response",
"nonce",
"cnonce",
"nextnonce",
"rspauth",
"auts",
];
impl SipAuthValue {
fn unchecked(scheme: String) -> Self {
SipAuthValue {
scheme,
params: HeaderParams::default(),
token68: None,
}
}
pub fn new(scheme: impl AsRef<str>) -> Result<Self, ParseError> {
checked_token(Field::Scheme, scheme.as_ref()).map(Self::unchecked)
}
pub fn from_token68(
scheme: impl AsRef<str>,
token68: impl AsRef<str>,
) -> Result<Self, ParseError> {
let token68 = token68.as_ref();
if token68.is_empty() {
return Err(ParseError::empty(Field::Credentials));
}
if !is_token68(token68) {
return Err(ParseError::malformed(
Field::Credentials,
FaultCode::InvalidChar,
None,
));
}
Ok(SipAuthValue {
token68: Some(token68.to_owned()),
..Self::new(scheme)?
})
}
pub fn params_mut(&mut self) -> ParamsMut<'_> {
ParamsMut::new(
&mut self.params,
ParamRule {
reserved: &[],
check: check_auth_param,
},
Owner::Token68(&mut self.token68),
)
}
pub fn with_param(
mut self,
key: impl AsRef<str>,
value: impl AsRef<str>,
) -> Result<Self, ParseError> {
self.params_mut()
.set(key, Some(value.as_ref()))?;
Ok(self)
}
pub fn with_quoted_param(
mut self,
key: impl AsRef<str>,
value: impl AsRef<str>,
) -> Result<Self, ParseError> {
self.params_mut()
.set_quoted(key, value)?;
Ok(self)
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn token68(&self) -> Option<&str> {
self.token68
.as_deref()
}
pub fn params(&self) -> &HeaderParams {
&self.params
}
pub fn param(&self, key: &str) -> Option<Option<&str>> {
self.params
.get(key)
}
fn valued(&self, key: &str) -> Option<&str> {
self.param(key)
.flatten()
}
pub fn realm(&self) -> Option<&str> {
self.valued("realm")
}
pub fn nonce(&self) -> Option<&str> {
self.valued("nonce")
}
pub fn algorithm(&self) -> Option<&str> {
self.valued("algorithm")
}
pub fn username(&self) -> Option<&str> {
self.valued("username")
}
pub fn opaque(&self) -> Option<&str> {
self.valued("opaque")
}
pub fn qop(&self) -> Option<&str> {
self.valued("qop")
}
}
const MUST_QUOTE_PARAMS: &[&str] = &[
"realm", "domain", "nonce", "opaque", "username", "uri", "response", "cnonce",
];
fn check_auth_param(key: &str, value: Option<&str>, quoted: bool) -> Result<bool, ParseError> {
let Some(value) = value else {
return Err(ParseError::malformed(
Field::Param,
FaultCode::Missing,
None,
));
};
Ok(quoted
|| !is_token(value)
|| MUST_QUOTE_PARAMS
.iter()
.any(|k| k.eq_ignore_ascii_case(key)))
}
impl fmt::Display for SipAuthValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.scheme)?;
if let Some(token68) = &self.token68 {
return write!(f, " {token68}");
}
if !self
.params
.is_empty()
{
f.write_char(' ')?;
self.params
.write_joined(f, ", ")?;
}
Ok(())
}
}
fn is_credential(name: &str) -> bool {
CREDENTIAL_PARAMS
.iter()
.any(|c| c.eq_ignore_ascii_case(name))
}
const MASK: &str = "***";
impl fmt::Debug for SipAuthValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
struct Params<'a>(&'a HeaderParams);
impl fmt::Debug for Params<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list()
.entries(
self.0
.iter()
.map(|(name, value)| {
(
name,
value.map(|v| {
if is_credential(name)
|| name.eq_ignore_ascii_case("username")
{
MASK
} else {
v
}
}),
)
}),
)
.finish()
}
}
f.debug_struct("SipAuthValue")
.field("scheme", &self.scheme)
.field("params", &Params(&self.params))
.field(
"token68",
&self
.token68
.as_ref()
.map(|_| MASK),
)
.finish()
}
}
impl Redact for SipAuthValue {
fn redacted<'a>(&'a self, how: &'a crate::redact::HeaderRedaction) -> impl fmt::Display + 'a {
RedactedAuth { auth: self, how }
}
}
struct RedactedAuth<'a> {
auth: &'a SipAuthValue,
how: &'a crate::redact::HeaderRedaction,
}
impl fmt::Display for RedactedAuth<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
&self
.auth
.scheme,
)?;
if self
.auth
.token68
.is_some()
{
return write!(f, " {MASK}");
}
let uri_how = self
.how
.uri();
let shows_user = uri_how.user_mask() == sip_uri::UserMask::Visible;
for (i, (name, value, quoted)) in self
.auth
.params
.iter_quoted()
.enumerate()
{
f.write_str(if i == 0 { " " } else { ", " })?;
f.write_str(name)?;
let Some(value) = value else {
continue;
};
f.write_char('=')?;
let masked = if is_credential(name)
|| (name == "username" && !shows_user)
|| self
.how
.masks_param(name)
{
MASK.to_string()
} else if name == "uri" {
sip_uri::Uri::parse(value).map_or_else(
|_| MASK.to_string(),
|uri| {
uri.redacted(uri_how)
.to_string()
},
)
} else {
value.to_string()
};
if quoted {
crate::write_quoted_pair(f, &masked)?;
} else {
f.write_str(&masked)?;
}
}
Ok(())
}
}
#[cfg(feature = "serde")]
serde_parts!(SipAuthValue, SipAuthValueParts);
#[cfg(feature = "serde")]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct SipAuthValueParts {
#[serde(deserialize_with = "crate::serde_parts::field::scheme")]
scheme: String,
#[serde(default, deserialize_with = "crate::params::deserialize_unchecked")]
params: HeaderParams,
#[serde(default, deserialize_with = "crate::serde_parts::field::token68")]
token68: Option<String>,
}
#[cfg(feature = "serde")]
impl SipAuthValueParts {
fn into_value(p: Self) -> Result<SipAuthValue, ParseError> {
let auth = SipAuthValue {
scheme: p.scheme,
params: p.params,
token68: p.token68,
};
crate::check::reads_back(auth, SipAuthValue::parse)
}
fn from_value(a: SipAuthValue) -> Self {
SipAuthValueParts {
scheme: a.scheme,
params: a.params,
token68: a.token68,
}
}
}
params_located!(SipAuthValue);
impl sealed::Sealed for SipAuthValue {}
impl HeaderParse for SipAuthValue {
fn parse_with_warnings(input: &str) -> Result<Parsed<Self>, ParseError> {
crate::scrub::parse_scrubbed_located(input, |input| {
let mut warnings = Vec::new();
parse_auth(input, &mut warnings).map(|v| Parsed::new(v, warnings))
})
}
}
fn parse_auth(input: &str, warnings: &mut Vec<ParseWarning>) -> Result<SipAuthValue, ParseError> {
let s = input.trim();
if s.is_empty() {
return Err(ParseError::empty(Field::Value));
}
let (raw_scheme, rest) = s
.split_once(|c: char| c.is_ascii_whitespace())
.map_or((s, ""), |(scheme, rest)| (scheme, rest.trim_start()));
let scheme = crate::token_field(input, raw_scheme, Field::Scheme, warnings);
if scheme.is_empty() {
return Err(ParseError::malformed(
Field::Scheme,
FaultCode::Missing,
Some(crate::offset_in(input, raw_scheme)),
));
}
if !is_token(&scheme) {
warnings.push(
ParseWarning::new(Field::Scheme, WarningCode::InvalidToken)
.at(crate::offset_in(input, raw_scheme)),
);
}
let mut auth = SipAuthValue::unchecked(scheme.into_owned());
if rest.is_empty() {
return Ok(auth);
}
if is_token68(rest) {
auth.token68 = Some(rest.to_string());
return Ok(auth);
}
let split = crate::split_entries(rest, crate::QuoteStart::AuthParam);
for raw_param in split.entries {
let param_str = raw_param.trim();
if param_str.is_empty() {
warnings.push(crate::empty_entry(
Field::Credentials,
crate::offset_in(input, raw_param),
));
continue;
}
let key_at = crate::offset_in(input, param_str);
let warn = |warnings: &mut Vec<ParseWarning>, code, at| {
warnings.push(ParseWarning::new(Field::Credentials, code).at(at));
};
let Some(eq) = param_str.find('=') else {
let key = crate::token_field(input, param_str, Field::Credentials, warnings);
if key.is_empty() {
continue;
}
if !is_token(&key) {
warn(warnings, WarningCode::InvalidToken, key_at);
}
warn(warnings, WarningCode::AuthParamFlag, key_at);
auth.params
.push_read(&key, None, Field::Credentials, key_at, warnings);
continue;
};
let key = crate::token_field(input, param_str[..eq].trim(), Field::Credentials, warnings);
let value = param_str[eq + 1..].trim();
let at = crate::offset_in(input, value);
let span = Span::within(input, value);
if !is_token(&key) {
warn(warnings, WarningCode::InvalidToken, key_at);
}
let unterminated = crate::opens_unterminated_quote(value);
let (value, quoted) = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2
{
let (unescaped, trailing_backslash) =
crate::unescape_quoted_pair_checked(&value[1..value.len() - 1]);
(unescaped, Some(trailing_backslash))
} else {
(value.to_string(), None)
};
auth.params
.push_read(
&key,
Some((value.clone(), quoted.is_some(), span)),
Field::Credentials,
key_at,
warnings,
);
if unterminated {
warn(warnings, WarningCode::UnterminatedQuote, at);
} else if quoted.is_none() && !is_token(&value) {
warn(warnings, WarningCode::InvalidToken, at);
}
if quoted == Some(true) {
let raw = param_str[eq + 1..].trim();
warn(warnings, WarningCode::TrailingBackslash, at + raw.len() - 2);
}
}
if split
.tail
.is_some()
{
warnings.push(
ParseWarning::new(Field::Credentials, WarningCode::TrailingComma)
.at(crate::offset_in(input, rest) + rest.len() - 1),
);
}
Ok(auth)
}
fn is_token68(s: &str) -> bool {
let body = s.trim_end_matches('=');
!body.is_empty()
&& body
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-._~+/".contains(&b))
}
#[cfg(test)]
mod tests {
use sip_uri::WarningKind;
use super::*;
use crate::diagnostic::WarningCode;
#[test]
fn parse_digest_full() {
let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b", uri="sip:example.com", response="6629f""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.scheme(), "Digest");
assert_eq!(auth.username(), Some("alice"));
assert_eq!(auth.realm(), Some("example.com"));
assert_eq!(auth.nonce(), Some("dcd98b"));
assert_eq!(auth.param("uri"), Some(Some("sip:example.com")));
assert_eq!(auth.param("response"), Some(Some("6629f")));
}
#[test]
fn parse_digest_with_algorithm() {
let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5, qop="auth""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.scheme(), "Digest");
assert_eq!(auth.realm(), Some("example.com"));
assert_eq!(auth.nonce(), Some("abc123"));
assert_eq!(auth.algorithm(), Some("MD5"));
assert_eq!(auth.qop(), Some("auth"));
}
#[test]
fn parse_bearer_no_params() {
let input = "Bearer";
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.scheme(), "Bearer");
assert_eq!(
auth.params()
.len(),
0
);
}
#[test]
fn parse_empty_input() {
let result = SipAuthValue::parse("");
assert_eq!(result, Err(ParseError::empty(Field::Value)));
let result = SipAuthValue::parse(" ");
assert_eq!(result, Err(ParseError::empty(Field::Value)));
}
#[test]
fn duplicate_auth_param_is_warned() {
let input = r#"Digest realm="a", Realm="b""#;
let parsed = SipAuthValue::parse_with_warnings(input).unwrap();
assert_eq!(
parsed
.value
.realm(),
Some("a")
);
let w = parsed.warnings[0];
assert_eq!(
(w.field, w.code, w.position),
(
Field::Credentials,
WarningCode::DuplicateParam,
input.find("Realm")
)
);
assert_eq!(
parsed
.value
.to_string(),
r#"Digest realm="a", realm="b""#
);
}
#[test]
fn bare_non_token_value_is_warned() {
let input = "Digest uri=sip:example.com";
let parsed = SipAuthValue::parse_with_warnings(input).unwrap();
let w = parsed.warnings[0];
assert_eq!(
(w.field, w.code, w.position),
(
Field::Credentials,
WarningCode::InvalidToken,
input.find("sip:")
)
);
assert_eq!(
parsed
.value
.to_string(),
r#"Digest uri="sip:example.com""#
);
}
#[test]
fn display_roundtrip_quoted() {
let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b""#;
let auth = SipAuthValue::parse(input).unwrap();
let output = auth.to_string();
let auth2 = SipAuthValue::parse(&output).unwrap();
assert_eq!(auth, auth2);
}
#[test]
fn display_roundtrip_mixed() {
let input = r#"Digest realm="example.com", algorithm=MD5, qop="auth""#;
let auth = SipAuthValue::parse(input).unwrap();
let output = auth.to_string();
let auth2 = SipAuthValue::parse(&output).unwrap();
assert_eq!(auth, auth2);
}
#[test]
fn display_always_quotes_rfc_required_fields() {
let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5"#;
let auth = SipAuthValue::parse(input).unwrap();
let output = auth.to_string();
assert!(output.contains(r#"realm="example.com""#));
assert!(output.contains(r#"nonce="abc123""#));
assert!(output.contains("algorithm=MD5"));
}
#[test]
fn display_quotes_opaque() {
let input = r#"Digest realm="example.com", opaque="5ccc""#;
let auth = SipAuthValue::parse(input).unwrap();
let output = auth.to_string();
assert!(output.contains(r#"opaque="5ccc""#));
}
#[test]
fn param_lookup_case_insensitive() {
let input = r#"Digest Realm="example.com", NONCE="abc123""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.param("realm"), Some(Some("example.com")));
assert_eq!(auth.param("REALM"), Some(Some("example.com")));
assert_eq!(auth.param("Realm"), Some(Some("example.com")));
assert_eq!(auth.param("nonce"), Some(Some("abc123")));
assert_eq!(auth.param("NONCE"), Some(Some("abc123")));
}
#[test]
fn params_preserves_order() {
let input = r#"Digest username="alice", realm="example.com", nonce="test""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(
auth.params()
.len(),
3
);
let names: Vec<_> = auth
.params()
.iter()
.map(|(name, _)| name)
.collect();
assert_eq!(names, ["username", "realm", "nonce"]);
}
#[test]
fn empty_param_value() {
let input = r#"Digest username="", realm="example.com""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.username(), Some(""));
assert_eq!(auth.realm(), Some("example.com"));
}
#[test]
fn parse_digest_uri_with_comma() {
let input = r#"Digest uri="sip:example.com,transport=tcp", realm="test""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(
auth.param("uri"),
Some(Some("sip:example.com,transport=tcp"))
);
assert_eq!(auth.realm(), Some("test"));
}
#[test]
fn parse_quoted_value_with_multiple_commas() {
let input = r#"Digest realm="a,b,c", nonce="test""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.realm(), Some("a,b,c"));
assert_eq!(auth.nonce(), Some("test"));
}
#[test]
fn opaque_param() {
let input = r#"Digest realm="example.com", opaque="5ccc09c""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.realm(), Some("example.com"));
assert_eq!(auth.opaque(), Some("5ccc09c"));
}
#[test]
fn unescape_quoted_pair_in_value() {
let input = r#"Digest realm="foo\"bar""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.realm(), Some(r#"foo"bar"#));
}
#[test]
fn unescape_backslash_in_value() {
let input = r#"Digest realm="C:\\path""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.realm(), Some(r#"C:\path"#));
}
#[test]
fn roundtrip_with_escaped_quotes() {
let input = r#"Digest realm="foo\"bar", nonce="test""#;
let auth = SipAuthValue::parse(input).unwrap();
let output = auth.to_string();
let auth2 = SipAuthValue::parse(&output).unwrap();
assert_eq!(auth, auth2);
}
#[test]
fn roundtrip_with_escaped_backslash() {
let input = r#"Digest realm="C:\\path", nonce="test""#;
let auth = SipAuthValue::parse(input).unwrap();
let output = auth.to_string();
let auth2 = SipAuthValue::parse(&output).unwrap();
assert_eq!(auth, auth2);
}
#[test]
fn challenge_qop_stays_quoted() {
let input = r#"Digest realm="example.com", qop="auth""#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.qop(), Some("auth"));
assert_eq!(auth.to_string(), input);
}
#[test]
fn credential_qop_stays_unquoted() {
let input = r#"Digest username="alice", realm="example.com", qop=auth, nc=00000001"#;
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.to_string(), input);
}
#[test]
fn qop_quoted_when_contains_comma() {
let input = r#"Digest realm="example.com", qop="auth,auth-int""#;
let auth = SipAuthValue::parse(input).unwrap();
let output = auth.to_string();
assert!(output.contains(r#"qop="auth,auth-int""#));
}
#[test]
fn bearer_token68() {
let input = "Bearer mF_9.B5f-4.1JqM";
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.scheme(), "Bearer");
assert_eq!(auth.token68(), Some("mF_9.B5f-4.1JqM"));
assert!(auth
.params()
.is_empty());
assert_eq!(auth.to_string(), input);
}
#[test]
fn bearer_token68_with_padding() {
let input = "Bearer abc==";
let auth = SipAuthValue::parse(input).unwrap();
assert_eq!(auth.token68(), Some("abc=="));
assert!(auth
.params()
.is_empty());
assert_eq!(auth.to_string(), input);
}
#[test]
fn token68_charset() {
let auth = SipAuthValue::parse("Bearer a-._~+/Z9=").unwrap();
assert_eq!(auth.token68(), Some("a-._~+/Z9="));
}
#[test]
fn auth_params_are_not_token68() {
let auth = SipAuthValue::parse(r#"Digest realm="x""#).unwrap();
assert_eq!(auth.token68(), None);
assert_eq!(auth.realm(), Some("x"));
let auth = SipAuthValue::parse("Bearer token=abc123").unwrap();
assert_eq!(auth.token68(), None);
assert_eq!(auth.param("token"), Some(Some("abc123")));
}
#[test]
fn scheme_only_has_no_token68() {
let auth = SipAuthValue::parse("Bearer").unwrap();
assert_eq!(auth.token68(), None);
}
#[test]
fn unterminated_quote_is_warned() {
let input = r#"Digest realm="example.com", nonce="abc"#;
let parsed = SipAuthValue::parse_with_warnings(input).unwrap();
assert_eq!(parsed.value, SipAuthValue::parse(input).unwrap());
assert_eq!(
parsed
.value
.nonce(),
Some(r#""abc"#)
);
let w = parsed.warnings[0];
assert_eq!(
parsed
.warnings
.len(),
1
);
assert_eq!(
(w.field, w.code, w.kind, w.position, w.entry),
(
Field::Credentials,
WarningCode::UnterminatedQuote,
WarningKind::Recovered,
input.find(r#""abc"#),
None
)
);
assert_eq!(
SipAuthValue::parse_strict(input),
Err(ParseError::NonConformant(w))
);
}
#[test]
fn trailing_backslash_is_warned() {
let input = r#"Digest realm="a\""#;
let parsed = SipAuthValue::parse_with_warnings(input).unwrap();
assert_eq!(
parsed
.value
.realm(),
Some("a")
);
let found: Vec<_> = parsed
.warnings
.iter()
.map(|w| (w.field, w.code, w.kind, w.position))
.collect();
assert_eq!(
found,
vec![
(
Field::Credentials,
WarningCode::UnterminatedQuote,
WarningKind::Recovered,
input.find('"')
),
(
Field::Credentials,
WarningCode::TrailingBackslash,
WarningKind::Lost,
input.find('\\')
),
]
);
assert_eq!(
SipAuthValue::parse_strict(input),
Err(ParseError::NonConformant(parsed.warnings[0]))
);
}
#[test]
fn conformant_quoted_params_have_no_warning() {
for input in [
r#"Digest realm="C:\\path", nonce="a\"b", qop=auth"#,
"Bearer mF_9.B5f-4.1JqM",
"Bearer",
] {
let parsed = SipAuthValue::parse_with_warnings(input).unwrap();
assert!(!parsed.has_warnings(), "{input}");
assert_eq!(SipAuthValue::parse_strict(input), Ok(parsed.value));
}
}
#[test]
fn error_display_omits_param_bytes() {
let err = SipAuthValue::parse_strict("Digest username=alice, secretvalue").unwrap_err();
assert!(!err
.to_string()
.contains("secretvalue"));
}
}