Skip to main content

rtsp_runtime/
auth.rs

1//! Authentication wiring — RFC 2326 §14 (RTSP reuses HTTP auth).
2//!
3//! RTSP shares HTTP's Basic and Digest schemes verbatim (RFC 2326 §16); see
4//! [`docs/auth.md`](../docs/auth.md). The heavy lifting — challenge parsing and
5//! the digest response computation — is delegated to the [`http_auth`] crate.
6//! This module holds the client [`Credentials`] and an [`Authenticator`] that
7//! owns the negotiated [`http_auth::PasswordClient`] and computes an
8//! `Authorization` header value for each outgoing request.
9//!
10//! The one RTSP-specific rule: the `uri` used in the digest computation is the
11//! RTSP request URI (e.g. `rtsp://host/stream`), not an HTTP URL (RFC 2326 §14).
12
13use crate::error::{Error, Result};
14use http_auth::{PasswordClient, PasswordParams};
15
16/// Username/password credentials for RTSP authentication (RFC 2326 §14).
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Credentials {
19    /// Account username.
20    pub username: String,
21    /// Account password.
22    pub password: String,
23}
24
25impl Credentials {
26    /// Creates credentials from a username and password.
27    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
28        Credentials {
29            username: username.into(),
30            password: password.into(),
31        }
32    }
33}
34
35/// Holds the negotiated password client for an authenticated RTSP session.
36///
37/// Constructed from a `WWW-Authenticate` challenge on a `401` response; then
38/// [`Authenticator::authorization`] is called for every subsequent request so
39/// that Digest `nc`/`cnonce`/`response` advance correctly (RFC 2326 §14 / see
40/// [`docs/auth.md`](../docs/auth.md)).
41pub struct Authenticator {
42    credentials: Credentials,
43    client: PasswordClient,
44}
45
46impl Authenticator {
47    /// Builds an authenticator from the `WWW-Authenticate` challenge value and
48    /// the client's credentials.
49    ///
50    /// Call this again with the fresh challenge when the server re-challenges
51    /// with `stale=true`, to pick up the new nonce (RFC 2326 §14).
52    pub fn from_challenge(www_authenticate: &str, credentials: Credentials) -> Result<Self> {
53        let client = PasswordClient::try_from(www_authenticate)
54            .map_err(|e| Error::Auth(format!("parse WWW-Authenticate: {e}")))?;
55        Ok(Authenticator {
56            credentials,
57            client,
58        })
59    }
60
61    /// Computes the `Authorization` header value for a request to `uri` using
62    /// `method`.
63    ///
64    /// The `uri` MUST be the RTSP request URI (RFC 2326 §14). For Digest, each
65    /// call advances the nonce count; the resulting value carries `response=`,
66    /// `realm=`, `nonce=`, `uri=`, and (when `qop` is present) `cnonce=`/`nc=`.
67    pub fn authorization(&mut self, method: &str, uri: &str) -> Result<String> {
68        self.client
69            .respond(&PasswordParams {
70                username: &self.credentials.username,
71                password: &self.credentials.password,
72                uri,
73                method,
74                body: Some(&[]),
75            })
76            .map_err(|e| Error::Auth(format!("compute Authorization: {e}")))
77    }
78}
79
80impl core::fmt::Debug for Authenticator {
81    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
82        // PasswordClient is not Debug; avoid leaking the password.
83        f.debug_struct("Authenticator")
84            .field("username", &self.credentials.username)
85            .finish_non_exhaustive()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    const CHALLENGE: &str = "Digest realm=\"IP Camera\",nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\",qop=\"auth\",algorithm=MD5";
94
95    #[test]
96    fn digest_authorization_contains_required_fields() {
97        let mut auth =
98            Authenticator::from_challenge(CHALLENGE, Credentials::new("admin", "12345")).unwrap();
99        let value = auth
100            .authorization("DESCRIBE", "rtsp://camera.example.com/live")
101            .unwrap();
102        assert!(value.starts_with("Digest "), "got: {value}");
103        for needle in ["response=", "realm=", "nonce=", "uri=", "cnonce=", "nc="] {
104            assert!(value.contains(needle), "missing {needle} in {value}");
105        }
106        assert!(value.contains("uri=\"rtsp://camera.example.com/live\""));
107    }
108
109    #[test]
110    fn basic_authorization_is_computed() {
111        let mut auth = Authenticator::from_challenge(
112            "Basic realm=\"IP Camera\"",
113            Credentials::new("admin", "12345"),
114        )
115        .unwrap();
116        let value = auth.authorization("DESCRIBE", "rtsp://c/live").unwrap();
117        assert!(value.starts_with("Basic "));
118        assert_ne!(value, "Basic ");
119    }
120}