ruststream_amqp/config.rs
1//! Connection configuration: SASL profiles.
2
3use std::borrow::Cow;
4
5use fe2o3_amqp::sasl_profile::SaslProfile;
6use percent_encoding::percent_decode_str;
7use url::Url;
8
9use crate::error::AmqpError;
10
11/// How the broker authenticates the connection, mapped onto the client's SASL profiles.
12///
13/// Constructed with the per-mechanism constructors and passed to
14/// [`AmqpBroker::sasl`](crate::AmqpBroker::sasl). A URL of the form `amqp://user:pass@host` also
15/// selects PLAIN implicitly; an explicit profile set here wins.
16///
17/// # Examples
18///
19/// ```
20/// # mod demo {
21/// use ruststream_amqp::prelude::*;
22/// # use serde::Deserialize;
23/// #
24/// # #[derive(Deserialize)]
25/// # struct Order {
26/// # id: u64,
27/// # }
28/// #
29/// # #[subscriber(AmqpAddress::queue("orders"))]
30/// # async fn handle(order: &Order) -> HandlerOutcome {
31/// # tracing::info!(order.id, "accepted");
32/// # HandlerOutcome::ack()
33/// # }
34///
35/// #[ruststream::app]
36/// fn app() -> impl App {
37/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
38/// AmqpBroker::new("amqp://broker.internal:5672").sasl(Sasl::plain("svc", "secret")),
39/// |b| {
40/// b.include(handle);
41/// },
42/// )
43/// }
44/// # }
45/// # fn main() {}
46/// ```
47#[derive(Debug, Clone)]
48#[must_use]
49pub struct Sasl {
50 pub(crate) profile: SaslProfile,
51}
52
53impl Sasl {
54 /// SASL ANONYMOUS: no credentials, for brokers that allow unauthenticated connections.
55 ///
56 /// # Examples
57 ///
58 /// ```
59 /// # mod demo {
60 /// use ruststream_amqp::prelude::*;
61 /// # use serde::Deserialize;
62 /// #
63 /// # #[derive(Deserialize)]
64 /// # struct Order {
65 /// # id: u64,
66 /// # }
67 /// #
68 /// # #[subscriber(AmqpAddress::queue("orders"))]
69 /// # async fn handle(order: &Order) -> HandlerOutcome {
70 /// # tracing::info!(order.id, "accepted");
71 /// # HandlerOutcome::ack()
72 /// # }
73 ///
74 /// #[ruststream::app]
75 /// fn app() -> impl App {
76 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
77 /// // The broker admits unauthenticated connections.
78 /// AmqpBroker::new("amqp://broker.internal:5672").sasl(Sasl::anonymous()),
79 /// |b| {
80 /// b.include(handle);
81 /// },
82 /// )
83 /// }
84 /// # }
85 /// # fn main() {}
86 /// ```
87 pub fn anonymous() -> Self {
88 Self {
89 profile: SaslProfile::Anonymous,
90 }
91 }
92
93 /// SASL PLAIN: username and password.
94 ///
95 /// # Examples
96 ///
97 /// ```
98 /// # mod demo {
99 /// use ruststream_amqp::prelude::*;
100 /// # use serde::Deserialize;
101 /// #
102 /// # #[derive(Deserialize)]
103 /// # struct Order {
104 /// # id: u64,
105 /// # }
106 /// #
107 /// # #[subscriber(AmqpAddress::queue("orders"))]
108 /// # async fn handle(order: &Order) -> HandlerOutcome {
109 /// # tracing::info!(order.id, "accepted");
110 /// # HandlerOutcome::ack()
111 /// # }
112 ///
113 /// #[ruststream::app]
114 /// fn app() -> impl App {
115 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
116 /// AmqpBroker::new("amqp://broker.internal:5672").sasl(Sasl::plain("svc", "secret")),
117 /// |b| {
118 /// b.include(handle);
119 /// },
120 /// )
121 /// }
122 /// # }
123 /// # fn main() {}
124 /// ```
125 pub fn plain(username: impl Into<String>, password: impl Into<String>) -> Self {
126 Self {
127 profile: SaslProfile::Plain {
128 username: username.into(),
129 password: password.into(),
130 },
131 }
132 }
133
134 /// SASL EXTERNAL: the broker takes an identity established outside SASL.
135 ///
136 /// The connection presents no client certificate: the crate exposes no TLS configuration,
137 /// and the client opens TLS without client authentication.
138 ///
139 /// # Examples
140 ///
141 /// ```
142 /// # mod demo {
143 /// use ruststream_amqp::prelude::*;
144 /// # use serde::Deserialize;
145 /// #
146 /// # #[derive(Deserialize)]
147 /// # struct Order {
148 /// # id: u64,
149 /// # }
150 /// #
151 /// # #[subscriber(AmqpAddress::queue("orders"))]
152 /// # async fn handle(order: &Order) -> HandlerOutcome {
153 /// # tracing::info!(order.id, "accepted");
154 /// # HandlerOutcome::ack()
155 /// # }
156 ///
157 /// #[ruststream::app]
158 /// fn app() -> impl App {
159 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
160 /// // The broker identifies the service outside SASL; no credentials travel here.
161 /// AmqpBroker::new("amqps://broker.internal:5671").sasl(Sasl::external()),
162 /// |b| {
163 /// b.include(handle);
164 /// },
165 /// )
166 /// }
167 /// # }
168 /// # fn main() {}
169 /// ```
170 pub fn external() -> Self {
171 Self {
172 profile: SaslProfile::External,
173 }
174 }
175}
176
177/// The SASL profile a connection opens with: the one set with
178/// [`AmqpBroker::sasl`](crate::AmqpBroker::sasl), or else PLAIN from the URL's userinfo, or else
179/// none, which leaves the choice to the client.
180///
181/// A URL carries its userinfo percent-encoded, so a password with a reserved character
182/// (`p%40ss`) is decoded before it becomes a credential.
183///
184/// # Errors
185///
186/// Returns [`AmqpError::Connect`] when the decoded userinfo is not UTF-8, which a PLAIN credential
187/// has to be.
188pub(crate) fn sasl_profile(
189 explicit: Option<&Sasl>,
190 url: &Url,
191) -> Result<Option<SaslProfile>, AmqpError> {
192 if let Some(sasl) = explicit {
193 return Ok(Some(sasl.profile.clone()));
194 }
195 // The client's own reading of a URL: a user and a password select PLAIN, anything less selects
196 // nothing.
197 let (username, Some(password)) = (url.username(), url.password()) else {
198 return Ok(None);
199 };
200 if username.is_empty() {
201 return Ok(None);
202 }
203 let decode = |part: &str| {
204 percent_decode_str(part)
205 .decode_utf8()
206 .map(Cow::into_owned)
207 .map_err(|_| {
208 AmqpError::Connect(Box::from(
209 "the URL's userinfo is not UTF-8 once percent-decoded",
210 ))
211 })
212 };
213 Ok(Some(SaslProfile::Plain {
214 username: decode(username)?,
215 password: decode(password)?,
216 }))
217}
218
219#[cfg(test)]
220mod tests {
221 use std::hash::{BuildHasher, RandomState};
222
223 use super::*;
224
225 fn profile(explicit: Option<&Sasl>, url: &str) -> Result<Option<SaslProfile>, AmqpError> {
226 sasl_profile(explicit, &Url::parse(url).expect("the URL parses"))
227 }
228
229 fn plain(profile: Option<SaslProfile>) -> Option<(String, String)> {
230 match profile {
231 Some(SaslProfile::Plain { username, password }) => Some((username, password)),
232 _ => None,
233 }
234 }
235
236 #[test]
237 fn an_explicit_profile_wins_over_the_userinfo() {
238 // Drawn afresh for every run, so no credential lives in the source.
239 let password = format!("{:016x}", RandomState::new().hash_one("password"));
240 let explicit = Sasl::plain("svc", password.as_str());
241 let chosen = profile(Some(&explicit), "amqp://other:wrong@broker:5672").expect("valid");
242 assert_eq!(plain(chosen), Some(("svc".to_owned(), password)));
243
244 let chosen =
245 profile(Some(&Sasl::anonymous()), "amqp://other:wrong@broker:5672").expect("valid");
246 assert!(
247 matches!(chosen, Some(SaslProfile::Anonymous)),
248 "got {chosen:?}"
249 );
250 }
251
252 #[test]
253 fn the_userinfo_selects_plain_percent_decoded() {
254 let chosen = profile(None, "amqp://svc%2Ba:p%40ss%3Aw@broker:5672").expect("valid");
255 assert_eq!(
256 plain(chosen),
257 Some(("svc+a".to_owned(), "p@ss:w".to_owned()))
258 );
259 }
260
261 #[test]
262 fn a_url_without_a_user_and_a_password_selects_nothing() {
263 for url in [
264 "amqp://broker:5672",
265 "amqp://svc@broker:5672",
266 "amqp://:secret@broker:5672",
267 ] {
268 let chosen = profile(None, url).expect("valid");
269 assert!(chosen.is_none(), "{url}: got {chosen:?}");
270 }
271 }
272
273 #[test]
274 fn userinfo_that_is_not_utf8_once_decoded_is_refused() {
275 for url in [
276 "amqp://%FF:secret@broker:5672",
277 "amqp://svc:%C3@broker:5672",
278 ] {
279 let refused = profile(None, url).expect_err("no PLAIN credential is not UTF-8");
280 assert!(
281 matches!(refused, AmqpError::Connect(_)),
282 "{url}: got {refused}"
283 );
284 }
285 }
286}