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)`, or `scheme SP token68`
32/// (RFC 7235 §2.1).
33#[derive(Debug, Clone, PartialEq, Eq)]
34#[non_exhaustive]
35pub struct SipAuthValue {
36    scheme: String,
37    params: Vec<(String, String)>,
38    quoted: Vec<bool>,
39    token68: Option<String>,
40}
41
42impl SipAuthValue {
43    /// Returns the authentication scheme (e.g., "Digest", "Bearer").
44    pub fn scheme(&self) -> &str {
45        &self.scheme
46    }
47
48    /// Returns the `token68` credential (RFC 7235 §2.1), such as an RFC 8898
49    /// Bearer access token, when the value carries one instead of parameters.
50    pub fn token68(&self) -> Option<&str> {
51        self.token68
52            .as_deref()
53    }
54
55    /// Returns all authentication parameters as key-value pairs.
56    ///
57    /// Keys are lowercased. Values have quotes stripped.
58    pub fn params(&self) -> &[(String, String)] {
59        &self.params
60    }
61
62    /// Returns the value of a named parameter.
63    ///
64    /// Key lookup is case-insensitive.
65    pub fn param(&self, key: &str) -> Option<&str> {
66        let key_lower = key.to_ascii_lowercase();
67        self.params
68            .iter()
69            .find(|(k, _)| k == &key_lower)
70            .map(|(_, v)| v.as_str())
71    }
72
73    /// Returns the `realm` parameter value.
74    pub fn realm(&self) -> Option<&str> {
75        self.param("realm")
76    }
77
78    /// Returns the `nonce` parameter value.
79    pub fn nonce(&self) -> Option<&str> {
80        self.param("nonce")
81    }
82
83    /// Returns the `algorithm` parameter value.
84    pub fn algorithm(&self) -> Option<&str> {
85        self.param("algorithm")
86    }
87
88    /// Returns the `username` parameter value.
89    pub fn username(&self) -> Option<&str> {
90        self.param("username")
91    }
92
93    /// Returns the `opaque` parameter value.
94    pub fn opaque(&self) -> Option<&str> {
95        self.param("opaque")
96    }
97
98    /// Returns the `qop` parameter value.
99    pub fn qop(&self) -> Option<&str> {
100        self.param("qop")
101    }
102}
103
104impl SipAuthValue {
105    /// Parse an authentication header value.
106    pub fn parse(s: &str) -> Result<Self, SipAuthError> {
107        let s = s.trim();
108        if s.is_empty() {
109            return Err(SipAuthError::Empty);
110        }
111
112        // Find the first whitespace to split scheme from params
113        let (scheme, rest) = match s.split_once(|c: char| c.is_ascii_whitespace()) {
114            Some((scheme, rest)) => (scheme, rest.trim_start()),
115            None => {
116                return Ok(SipAuthValue {
117                    scheme: s.to_string(),
118                    params: Vec::new(),
119                    quoted: Vec::new(),
120                    token68: None,
121                });
122            }
123        };
124
125        if is_token68(rest) {
126            return Ok(SipAuthValue {
127                scheme: scheme.to_string(),
128                params: Vec::new(),
129                quoted: Vec::new(),
130                token68: Some(rest.to_string()),
131            });
132        }
133
134        let mut params = Vec::new();
135        let mut quoted = Vec::new();
136
137        for (i, param_str) in crate::split_comma_entries(rest)
138            .into_iter()
139            .enumerate()
140        {
141            let param_str = param_str.trim();
142            if param_str.is_empty() {
143                continue;
144            }
145
146            let (key, value) = param_str
147                .split_once('=')
148                .ok_or_else(|| {
149                    SipAuthError::InvalidFormat(format!("missing '=' in parameter {}", i + 1))
150                })?;
151
152            let key = key
153                .trim()
154                .to_ascii_lowercase();
155            let value = value.trim();
156
157            let (value, was_quoted) =
158                if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
159                    (
160                        crate::unescape_quoted_pair(&value[1..value.len() - 1]),
161                        true,
162                    )
163                } else {
164                    (value.to_string(), false)
165                };
166
167            params.push((key, value));
168            quoted.push(was_quoted);
169        }
170
171        Ok(SipAuthValue {
172            scheme: scheme.to_string(),
173            params,
174            quoted,
175            token68: None,
176        })
177    }
178}
179
180/// `token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="`
181/// (RFC 7235 §2.1).
182fn is_token68(s: &str) -> bool {
183    let body = s.trim_end_matches('=');
184    !body.is_empty()
185        && body
186            .bytes()
187            .all(|b| b.is_ascii_alphanumeric() || b"-._~+/".contains(&b))
188}
189
190impl_from_str_via_parse!(SipAuthValue, SipAuthError);
191
192/// RFC 2617 §3.2.1/§3.2.2 params that MUST use quoted-string on the wire.
193///
194/// `qop` is absent: a challenge quotes it (§3.2.1) and a credential does not
195/// (§3.2.2), so its quoting follows the form it was parsed from.
196const MUST_QUOTE_PARAMS: &[&str] = &[
197    "realm", "domain", "nonce", "opaque", "username", "uri", "response", "cnonce",
198];
199
200impl fmt::Display for SipAuthValue {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(f, "{}", self.scheme)?;
203
204        if let Some(token68) = &self.token68 {
205            return write!(f, " {token68}");
206        }
207
208        if !self
209            .params
210            .is_empty()
211        {
212            write!(f, " ")?;
213            for (i, ((key, value), &was_quoted)) in self
214                .params
215                .iter()
216                .zip(&self.quoted)
217                .enumerate()
218            {
219                if i > 0 {
220                    write!(f, ", ")?;
221                }
222
223                if was_quoted
224                    || MUST_QUOTE_PARAMS.contains(&key.as_str())
225                    || value.contains(|c: char| c.is_ascii_whitespace() || c == ',' || c == '"')
226                    || value.is_empty()
227                {
228                    write!(f, "{key}=")?;
229                    crate::write_quoted_pair(f, value)?;
230                } else {
231                    write!(f, "{key}={value}")?;
232                }
233            }
234        }
235
236        Ok(())
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn parse_digest_full() {
246        let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b", uri="sip:example.com", response="6629f""#;
247        let auth: SipAuthValue = input
248            .parse()
249            .unwrap();
250
251        assert_eq!(auth.scheme(), "Digest");
252        assert_eq!(auth.username(), Some("alice"));
253        assert_eq!(auth.realm(), Some("example.com"));
254        assert_eq!(auth.nonce(), Some("dcd98b"));
255        assert_eq!(auth.param("uri"), Some("sip:example.com"));
256        assert_eq!(auth.param("response"), Some("6629f"));
257    }
258
259    #[test]
260    fn parse_digest_with_algorithm() {
261        let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5, qop="auth""#;
262        let auth: SipAuthValue = input
263            .parse()
264            .unwrap();
265
266        assert_eq!(auth.scheme(), "Digest");
267        assert_eq!(auth.realm(), Some("example.com"));
268        assert_eq!(auth.nonce(), Some("abc123"));
269        assert_eq!(auth.algorithm(), Some("MD5"));
270        assert_eq!(auth.qop(), Some("auth"));
271    }
272
273    #[test]
274    fn parse_bearer_no_params() {
275        let input = "Bearer";
276        let auth: SipAuthValue = input
277            .parse()
278            .unwrap();
279
280        assert_eq!(auth.scheme(), "Bearer");
281        assert_eq!(
282            auth.params()
283                .len(),
284            0
285        );
286    }
287
288    #[test]
289    fn parse_scheme_with_single_param() {
290        let input = "Bearer token=abc123";
291        let auth: SipAuthValue = input
292            .parse()
293            .unwrap();
294
295        assert_eq!(auth.scheme(), "Bearer");
296        assert_eq!(auth.param("token"), Some("abc123"));
297    }
298
299    #[test]
300    fn parse_empty_input() {
301        let result: Result<SipAuthValue, _> = "".parse();
302        assert_eq!(result, Err(SipAuthError::Empty));
303
304        let result: Result<SipAuthValue, _> = "   ".parse();
305        assert_eq!(result, Err(SipAuthError::Empty));
306    }
307
308    #[test]
309    fn parse_invalid_param() {
310        let input = "Digest username=alice, invalid";
311        let result: Result<SipAuthValue, _> = input.parse();
312        assert!(matches!(result, Err(SipAuthError::InvalidFormat(_))));
313    }
314
315    #[test]
316    fn display_roundtrip_quoted() {
317        let input = r#"Digest username="alice", realm="example.com", nonce="dcd98b""#;
318        let auth: SipAuthValue = input
319            .parse()
320            .unwrap();
321        let output = auth.to_string();
322
323        // Parse it again to verify it's valid
324        let auth2: SipAuthValue = output
325            .parse()
326            .unwrap();
327        assert_eq!(auth, auth2);
328    }
329
330    #[test]
331    fn display_roundtrip_mixed() {
332        let input = r#"Digest realm="example.com", algorithm=MD5, qop="auth""#;
333        let auth: SipAuthValue = input
334            .parse()
335            .unwrap();
336        let output = auth.to_string();
337
338        let auth2: SipAuthValue = output
339            .parse()
340            .unwrap();
341        assert_eq!(auth, auth2);
342    }
343
344    #[test]
345    fn display_always_quotes_rfc_required_fields() {
346        let input = r#"Digest realm="example.com", nonce="abc123", algorithm=MD5"#;
347        let auth: SipAuthValue = input
348            .parse()
349            .unwrap();
350        let output = auth.to_string();
351        assert!(output.contains(r#"realm="example.com""#));
352        assert!(output.contains(r#"nonce="abc123""#));
353        assert!(output.contains("algorithm=MD5"));
354    }
355
356    #[test]
357    fn display_quotes_opaque() {
358        let input = r#"Digest realm="example.com", opaque="5ccc""#;
359        let auth: SipAuthValue = input
360            .parse()
361            .unwrap();
362        let output = auth.to_string();
363        assert!(output.contains(r#"opaque="5ccc""#));
364    }
365
366    #[test]
367    fn param_lookup_case_insensitive() {
368        let input = r#"Digest Realm="example.com", NONCE="abc123""#;
369        let auth: SipAuthValue = input
370            .parse()
371            .unwrap();
372
373        assert_eq!(auth.param("realm"), Some("example.com"));
374        assert_eq!(auth.param("REALM"), Some("example.com"));
375        assert_eq!(auth.param("Realm"), Some("example.com"));
376        assert_eq!(auth.param("nonce"), Some("abc123"));
377        assert_eq!(auth.param("NONCE"), Some("abc123"));
378    }
379
380    #[test]
381    fn params_preserves_order() {
382        let input = r#"Digest username="alice", realm="example.com", nonce="test""#;
383        let auth: SipAuthValue = input
384            .parse()
385            .unwrap();
386
387        assert_eq!(
388            auth.params()
389                .len(),
390            3
391        );
392        assert_eq!(auth.params()[0].0, "username");
393        assert_eq!(auth.params()[1].0, "realm");
394        assert_eq!(auth.params()[2].0, "nonce");
395    }
396
397    #[test]
398    fn empty_param_value() {
399        let input = r#"Digest username="", realm="example.com""#;
400        let auth: SipAuthValue = input
401            .parse()
402            .unwrap();
403
404        assert_eq!(auth.username(), Some(""));
405        assert_eq!(auth.realm(), Some("example.com"));
406    }
407
408    #[test]
409    fn unquoted_param() {
410        let input = "Digest algorithm=MD5";
411        let auth: SipAuthValue = input
412            .parse()
413            .unwrap();
414
415        assert_eq!(auth.algorithm(), Some("MD5"));
416    }
417
418    #[test]
419    fn parse_digest_uri_with_comma() {
420        let input = r#"Digest uri="sip:example.com,transport=tcp", realm="test""#;
421        let auth: SipAuthValue = input
422            .parse()
423            .unwrap();
424        assert_eq!(auth.param("uri"), Some("sip:example.com,transport=tcp"));
425        assert_eq!(auth.realm(), Some("test"));
426    }
427
428    #[test]
429    fn parse_quoted_value_with_multiple_commas() {
430        let input = r#"Digest realm="a,b,c", nonce="test""#;
431        let auth: SipAuthValue = input
432            .parse()
433            .unwrap();
434        assert_eq!(auth.realm(), Some("a,b,c"));
435        assert_eq!(auth.nonce(), Some("test"));
436    }
437
438    #[test]
439    fn opaque_param() {
440        let input = r#"Digest realm="example.com", opaque="5ccc09c""#;
441        let auth: SipAuthValue = input
442            .parse()
443            .unwrap();
444
445        assert_eq!(auth.realm(), Some("example.com"));
446        assert_eq!(auth.opaque(), Some("5ccc09c"));
447    }
448
449    #[test]
450    fn unescape_quoted_pair_in_value() {
451        let input = r#"Digest realm="foo\"bar""#;
452        let auth: SipAuthValue = input
453            .parse()
454            .unwrap();
455        assert_eq!(auth.realm(), Some(r#"foo"bar"#));
456    }
457
458    #[test]
459    fn unescape_backslash_in_value() {
460        let input = r#"Digest realm="C:\\path""#;
461        let auth: SipAuthValue = input
462            .parse()
463            .unwrap();
464        assert_eq!(auth.realm(), Some(r#"C:\path"#));
465    }
466
467    #[test]
468    fn roundtrip_with_escaped_quotes() {
469        let input = r#"Digest realm="foo\"bar", nonce="test""#;
470        let auth: SipAuthValue = input
471            .parse()
472            .unwrap();
473        let output = auth.to_string();
474        let auth2: SipAuthValue = output
475            .parse()
476            .unwrap();
477        assert_eq!(auth, auth2);
478    }
479
480    #[test]
481    fn roundtrip_with_escaped_backslash() {
482        let input = r#"Digest realm="C:\\path", nonce="test""#;
483        let auth: SipAuthValue = input
484            .parse()
485            .unwrap();
486        let output = auth.to_string();
487        let auth2: SipAuthValue = output
488            .parse()
489            .unwrap();
490        assert_eq!(auth, auth2);
491    }
492
493    #[test]
494    fn challenge_qop_stays_quoted() {
495        let input = r#"Digest realm="example.com", qop="auth""#;
496        let auth: SipAuthValue = input
497            .parse()
498            .unwrap();
499        assert_eq!(auth.qop(), Some("auth"));
500        assert_eq!(auth.to_string(), input);
501    }
502
503    #[test]
504    fn credential_qop_stays_unquoted() {
505        let input = r#"Digest username="alice", realm="example.com", qop=auth, nc=00000001"#;
506        let auth: SipAuthValue = input
507            .parse()
508            .unwrap();
509        assert_eq!(auth.to_string(), input);
510    }
511
512    #[test]
513    fn qop_quoted_when_contains_comma() {
514        let input = r#"Digest realm="example.com", qop="auth,auth-int""#;
515        let auth: SipAuthValue = input
516            .parse()
517            .unwrap();
518        let output = auth.to_string();
519        assert!(output.contains(r#"qop="auth,auth-int""#));
520    }
521
522    #[test]
523    fn bearer_token68() {
524        let input = "Bearer mF_9.B5f-4.1JqM";
525        let auth: SipAuthValue = input
526            .parse()
527            .unwrap();
528        assert_eq!(auth.scheme(), "Bearer");
529        assert_eq!(auth.token68(), Some("mF_9.B5f-4.1JqM"));
530        assert!(auth
531            .params()
532            .is_empty());
533        assert_eq!(auth.to_string(), input);
534    }
535
536    #[test]
537    fn bearer_token68_with_padding() {
538        let input = "Bearer abc==";
539        let auth: SipAuthValue = input
540            .parse()
541            .unwrap();
542        assert_eq!(auth.token68(), Some("abc=="));
543        assert!(auth
544            .params()
545            .is_empty());
546        assert_eq!(auth.to_string(), input);
547    }
548
549    #[test]
550    fn token68_charset() {
551        let auth: SipAuthValue = "Bearer a-._~+/Z9="
552            .parse()
553            .unwrap();
554        assert_eq!(auth.token68(), Some("a-._~+/Z9="));
555    }
556
557    #[test]
558    fn auth_params_are_not_token68() {
559        let auth: SipAuthValue = r#"Digest realm="x""#
560            .parse()
561            .unwrap();
562        assert_eq!(auth.token68(), None);
563        assert_eq!(auth.realm(), Some("x"));
564
565        let auth: SipAuthValue = "Bearer token=abc123"
566            .parse()
567            .unwrap();
568        assert_eq!(auth.token68(), None);
569        assert_eq!(auth.param("token"), Some("abc123"));
570    }
571
572    #[test]
573    fn scheme_only_has_no_token68() {
574        let auth: SipAuthValue = "Bearer"
575            .parse()
576            .unwrap();
577        assert_eq!(auth.token68(), None);
578    }
579
580    #[test]
581    fn error_display_omits_param_bytes() {
582        let err = "Digest username=alice, secretvalue"
583            .parse::<SipAuthValue>()
584            .unwrap_err();
585        let msg = err.to_string();
586        assert!(!msg.contains("secretvalue"));
587        assert!(msg.contains('2'));
588    }
589}