Skip to main content

sip_header/
auth.rs

1//! SIP authentication value parser (RFC 3261 §20.7, §20.27, §20.28, §20.44).
2
3use std::fmt;
4
5/// Error type for SIP authentication value parsing.
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum SipAuthError {
9    /// The input string was empty.
10    Empty,
11    /// The input string had an invalid format.
12    InvalidFormat(String),
13}
14
15impl fmt::Display for SipAuthError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Empty => write!(f, "empty authentication value"),
19            Self::InvalidFormat(msg) => write!(f, "invalid authentication format: {}", msg),
20        }
21    }
22}
23
24impl std::error::Error for SipAuthError {}
25
26/// Parsed SIP authentication value.
27///
28/// Covers Authorization, Proxy-Authorization, WWW-Authenticate, and
29/// Proxy-Authenticate header field values.
30///
31/// Grammar: `scheme SP param=val *(COMMA param=val)`
32#[derive(Debug, Clone, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct SipAuthValue {
35    scheme: String,
36    params: Vec<(String, String)>,
37}
38
39impl SipAuthValue {
40    /// Returns the authentication scheme (e.g., "Digest", "Bearer").
41    pub fn scheme(&self) -> &str {
42        &self.scheme
43    }
44
45    /// Returns all authentication parameters as key-value pairs.
46    ///
47    /// Keys are lowercased. Values have quotes stripped.
48    pub fn params(&self) -> &[(String, String)] {
49        &self.params
50    }
51
52    /// Returns the value of a named parameter.
53    ///
54    /// Key lookup is case-insensitive.
55    pub fn param(&self, key: &str) -> Option<&str> {
56        let key_lower = key.to_ascii_lowercase();
57        self.params
58            .iter()
59            .find(|(k, _)| k == &key_lower)
60            .map(|(_, v)| v.as_str())
61    }
62
63    /// Returns the `realm` parameter value.
64    pub fn realm(&self) -> Option<&str> {
65        self.param("realm")
66    }
67
68    /// Returns the `nonce` parameter value.
69    pub fn nonce(&self) -> Option<&str> {
70        self.param("nonce")
71    }
72
73    /// Returns the `algorithm` parameter value.
74    pub fn algorithm(&self) -> Option<&str> {
75        self.param("algorithm")
76    }
77
78    /// Returns the `username` parameter value.
79    pub fn username(&self) -> Option<&str> {
80        self.param("username")
81    }
82
83    /// Returns the `opaque` parameter value.
84    pub fn opaque(&self) -> Option<&str> {
85        self.param("opaque")
86    }
87
88    /// Returns the `qop` parameter value.
89    pub fn qop(&self) -> Option<&str> {
90        self.param("qop")
91    }
92}
93
94impl SipAuthValue {
95    /// Parse an authentication header value.
96    pub fn parse(s: &str) -> Result<Self, SipAuthError> {
97        let s = s.trim();
98        if s.is_empty() {
99            return Err(SipAuthError::Empty);
100        }
101
102        // Find the first whitespace to split scheme from params
103        let (scheme, rest) = match s.split_once(|c: char| c.is_ascii_whitespace()) {
104            Some((scheme, rest)) => (scheme, rest.trim_start()),
105            None => {
106                // No params, just a scheme
107                return Ok(SipAuthValue {
108                    scheme: s.to_string(),
109                    params: Vec::new(),
110                });
111            }
112        };
113
114        let mut params = Vec::new();
115
116        for param_str in crate::split_comma_entries(rest) {
117            let param_str = param_str.trim();
118            if param_str.is_empty() {
119                continue;
120            }
121
122            // Split on '=' to get key and value
123            let (key, value) = param_str
124                .split_once('=')
125                .ok_or_else(|| {
126                    SipAuthError::InvalidFormat(format!("missing '=' in parameter: {}", param_str))
127                })?;
128
129            let key = key
130                .trim()
131                .to_ascii_lowercase();
132            let value = value.trim();
133
134            // Strip quotes and unescape quoted-pair sequences (RFC 3261 §25.1)
135            let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
136                crate::unescape_quoted_pair(&value[1..value.len() - 1])
137            } else {
138                value.to_string()
139            };
140
141            params.push((key, value));
142        }
143
144        Ok(SipAuthValue {
145            scheme: scheme.to_string(),
146            params,
147        })
148    }
149}
150
151impl_from_str_via_parse!(SipAuthValue, SipAuthError);
152
153/// RFC 2617 §3.2.1/§3.2.2 params that MUST use quoted-string on the wire.
154///
155/// `qop` is intentionally absent: RFC 2617 §3.2.2 specifies it as unquoted
156/// (`token`) in Authorization, while §3.2.1 quotes it in challenges. Since
157/// `SipAuthValue` serves both roles, we rely on the fallback condition to
158/// quote values containing commas (e.g. `auth,auth-int`) while leaving
159/// simple tokens like `auth` unquoted.
160const MUST_QUOTE_PARAMS: &[&str] = &[
161    "realm", "domain", "nonce", "opaque", "username", "uri", "response", "cnonce",
162];
163
164impl fmt::Display for SipAuthValue {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        write!(f, "{}", self.scheme)?;
167
168        if !self
169            .params
170            .is_empty()
171        {
172            write!(f, " ")?;
173            for (i, (key, value)) in self
174                .params
175                .iter()
176                .enumerate()
177            {
178                if i > 0 {
179                    write!(f, ", ")?;
180                }
181
182                if MUST_QUOTE_PARAMS.contains(&key.as_str())
183                    || value.contains(|c: char| c.is_ascii_whitespace() || c == ',' || c == '"')
184                    || value.is_empty()
185                {
186                    write!(f, "{key}=")?;
187                    crate::write_quoted_pair(f, value)?;
188                } else {
189                    write!(f, "{key}={value}")?;
190                }
191            }
192        }
193
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn parse_digest_full() {
204        let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b", uri="sip:example.com", response="6629f""#;
205        let auth: SipAuthValue = input
206            .parse()
207            .unwrap();
208
209        assert_eq!(auth.scheme(), "Digest");
210        assert_eq!(auth.username(), Some("alice"));
211        assert_eq!(auth.realm(), Some("example.com"));
212        assert_eq!(auth.nonce(), Some("dcd98b"));
213        assert_eq!(auth.param("uri"), Some("sip:example.com"));
214        assert_eq!(auth.param("response"), Some("6629f"));
215    }
216
217    #[test]
218    fn parse_digest_with_algorithm() {
219        let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5, qop="auth""#;
220        let auth: SipAuthValue = input
221            .parse()
222            .unwrap();
223
224        assert_eq!(auth.scheme(), "Digest");
225        assert_eq!(auth.realm(), Some("example.com"));
226        assert_eq!(auth.nonce(), Some("abc123"));
227        assert_eq!(auth.algorithm(), Some("MD5"));
228        assert_eq!(auth.qop(), Some("auth"));
229    }
230
231    #[test]
232    fn parse_bearer_no_params() {
233        let input = "Bearer";
234        let auth: SipAuthValue = input
235            .parse()
236            .unwrap();
237
238        assert_eq!(auth.scheme(), "Bearer");
239        assert_eq!(
240            auth.params()
241                .len(),
242            0
243        );
244    }
245
246    #[test]
247    fn parse_scheme_with_single_param() {
248        let input = "Bearer token=abc123";
249        let auth: SipAuthValue = input
250            .parse()
251            .unwrap();
252
253        assert_eq!(auth.scheme(), "Bearer");
254        assert_eq!(auth.param("token"), Some("abc123"));
255    }
256
257    #[test]
258    fn parse_empty_input() {
259        let result: Result<SipAuthValue, _> = "".parse();
260        assert_eq!(result, Err(SipAuthError::Empty));
261
262        let result: Result<SipAuthValue, _> = "   ".parse();
263        assert_eq!(result, Err(SipAuthError::Empty));
264    }
265
266    #[test]
267    fn parse_invalid_param() {
268        let input = "Digest username=alice, invalid";
269        let result: Result<SipAuthValue, _> = input.parse();
270        assert!(matches!(result, Err(SipAuthError::InvalidFormat(_))));
271    }
272
273    #[test]
274    fn display_roundtrip_quoted() {
275        let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b""#;
276        let auth: SipAuthValue = input
277            .parse()
278            .unwrap();
279        let output = auth.to_string();
280
281        // Parse it again to verify it's valid
282        let auth2: SipAuthValue = output
283            .parse()
284            .unwrap();
285        assert_eq!(auth, auth2);
286    }
287
288    #[test]
289    fn display_roundtrip_mixed() {
290        let input = r#"Digest realm="example.com", algorithm=MD5, qop="auth""#;
291        let auth: SipAuthValue = input
292            .parse()
293            .unwrap();
294        let output = auth.to_string();
295
296        let auth2: SipAuthValue = output
297            .parse()
298            .unwrap();
299        assert_eq!(auth, auth2);
300    }
301
302    #[test]
303    fn display_always_quotes_rfc_required_fields() {
304        let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5"#;
305        let auth: SipAuthValue = input
306            .parse()
307            .unwrap();
308        let output = auth.to_string();
309        assert!(output.contains(r#"realm="example.com""#));
310        assert!(output.contains(r#"nonce="abc123""#));
311        assert!(output.contains("algorithm=MD5"));
312    }
313
314    #[test]
315    fn display_quotes_opaque() {
316        let input = r#"Digest realm="example.com", opaque="5ccc""#;
317        let auth: SipAuthValue = input
318            .parse()
319            .unwrap();
320        let output = auth.to_string();
321        assert!(output.contains(r#"opaque="5ccc""#));
322    }
323
324    #[test]
325    fn param_lookup_case_insensitive() {
326        let input = r#"Digest Realm="example.com", NONCE="abc123""#;
327        let auth: SipAuthValue = input
328            .parse()
329            .unwrap();
330
331        assert_eq!(auth.param("realm"), Some("example.com"));
332        assert_eq!(auth.param("REALM"), Some("example.com"));
333        assert_eq!(auth.param("Realm"), Some("example.com"));
334        assert_eq!(auth.param("nonce"), Some("abc123"));
335        assert_eq!(auth.param("NONCE"), Some("abc123"));
336    }
337
338    #[test]
339    fn params_preserves_order() {
340        let input = r#"Digest username="alice", realm="example.com", nonce="test""#;
341        let auth: SipAuthValue = input
342            .parse()
343            .unwrap();
344
345        assert_eq!(
346            auth.params()
347                .len(),
348            3
349        );
350        assert_eq!(auth.params()[0].0, "username");
351        assert_eq!(auth.params()[1].0, "realm");
352        assert_eq!(auth.params()[2].0, "nonce");
353    }
354
355    #[test]
356    fn empty_param_value() {
357        let input = r#"Digest username="", realm="example.com""#;
358        let auth: SipAuthValue = input
359            .parse()
360            .unwrap();
361
362        assert_eq!(auth.username(), Some(""));
363        assert_eq!(auth.realm(), Some("example.com"));
364    }
365
366    #[test]
367    fn unquoted_param() {
368        let input = "Digest algorithm=MD5";
369        let auth: SipAuthValue = input
370            .parse()
371            .unwrap();
372
373        assert_eq!(auth.algorithm(), Some("MD5"));
374    }
375
376    #[test]
377    fn parse_digest_uri_with_comma() {
378        let input = r#"Digest uri="sip:example.com,transport=tcp", realm="test""#;
379        let auth: SipAuthValue = input
380            .parse()
381            .unwrap();
382        assert_eq!(auth.param("uri"), Some("sip:example.com,transport=tcp"));
383        assert_eq!(auth.realm(), Some("test"));
384    }
385
386    #[test]
387    fn parse_quoted_value_with_multiple_commas() {
388        let input = r#"Digest realm="a,b,c", nonce="test""#;
389        let auth: SipAuthValue = input
390            .parse()
391            .unwrap();
392        assert_eq!(auth.realm(), Some("a,b,c"));
393        assert_eq!(auth.nonce(), Some("test"));
394    }
395
396    #[test]
397    fn opaque_param() {
398        let input = r#"Digest realm="example.com", opaque="5ccc09c""#;
399        let auth: SipAuthValue = input
400            .parse()
401            .unwrap();
402
403        assert_eq!(auth.realm(), Some("example.com"));
404        assert_eq!(auth.opaque(), Some("5ccc09c"));
405    }
406
407    #[test]
408    fn unescape_quoted_pair_in_value() {
409        let input = r#"Digest realm="foo\"bar""#;
410        let auth: SipAuthValue = input
411            .parse()
412            .unwrap();
413        assert_eq!(auth.realm(), Some(r#"foo"bar"#));
414    }
415
416    #[test]
417    fn unescape_backslash_in_value() {
418        let input = r#"Digest realm="C:\\path""#;
419        let auth: SipAuthValue = input
420            .parse()
421            .unwrap();
422        assert_eq!(auth.realm(), Some(r#"C:\path"#));
423    }
424
425    #[test]
426    fn roundtrip_with_escaped_quotes() {
427        let input = r#"Digest realm="foo\"bar", nonce="test""#;
428        let auth: SipAuthValue = input
429            .parse()
430            .unwrap();
431        let output = auth.to_string();
432        let auth2: SipAuthValue = output
433            .parse()
434            .unwrap();
435        assert_eq!(auth, auth2);
436    }
437
438    #[test]
439    fn roundtrip_with_escaped_backslash() {
440        let input = r#"Digest realm="C:\\path", nonce="test""#;
441        let auth: SipAuthValue = input
442            .parse()
443            .unwrap();
444        let output = auth.to_string();
445        let auth2: SipAuthValue = output
446            .parse()
447            .unwrap();
448        assert_eq!(auth, auth2);
449    }
450
451    #[test]
452    fn qop_unquoted_in_display() {
453        let input = r#"Digest realm="example.com", qop="auth""#;
454        let auth: SipAuthValue = input
455            .parse()
456            .unwrap();
457        let output = auth.to_string();
458        assert!(output.contains("qop=auth"));
459        assert!(!output.contains("qop=\"auth\""));
460    }
461
462    #[test]
463    fn qop_quoted_when_contains_comma() {
464        let input = r#"Digest realm="example.com", qop="auth,auth-int""#;
465        let auth: SipAuthValue = input
466            .parse()
467            .unwrap();
468        let output = auth.to_string();
469        assert!(output.contains(r#"qop="auth,auth-int""#));
470    }
471}