1use std::{fmt::Display, hash::Hash};
12
13use base64::{engine, Engine};
14use smtp_proto::{
15 response::generate::BitToString, EhloResponse, AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN,
16 AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2,
17};
18use tokio::io::{AsyncRead, AsyncWrite};
19
20use crate::{Credentials, SmtpClient};
21
22impl<T: AsyncRead + AsyncWrite + Unpin> SmtpClient<T> {
23 pub async fn authenticate<U>(
24 &mut self,
25 credentials: impl AsRef<Credentials<U>>,
26 capabilities: impl AsRef<EhloResponse<String>>,
27 ) -> crate::Result<&mut Self>
28 where
29 U: AsRef<str> + PartialEq + Eq + Hash,
30 {
31 let credentials = credentials.as_ref();
32 let capabilities = capabilities.as_ref();
33 let mut available_mechanisms = match &credentials {
34 Credentials::Plain { .. } => AUTH_CRAM_MD5 | AUTH_DIGEST_MD5 | AUTH_LOGIN | AUTH_PLAIN,
35 Credentials::OAuthBearer { .. } => AUTH_OAUTHBEARER,
36 Credentials::XOauth2 { .. } => AUTH_XOAUTH2,
37 } & capabilities.auth_mechanisms;
38
39 let mut has_err = None;
41 let mut has_failed = false;
42
43 while available_mechanisms != 0 && !has_failed {
44 let mechanism = 1 << ((63 - available_mechanisms.leading_zeros()) as u64);
45 available_mechanisms ^= mechanism;
46 match self.auth(mechanism, credentials).await {
47 Ok(_) => {
48 return Ok(self);
49 }
50 Err(err) => match err {
51 crate::Error::UnexpectedReply(reply) => {
52 has_failed = reply.code() == 535;
53 has_err = reply.into();
54 }
55 crate::Error::UnsupportedAuthMechanism => (),
56 _ => return Err(err),
57 },
58 }
59 }
60
61 if let Some(has_err) = has_err {
62 Err(crate::Error::AuthenticationFailed(has_err))
63 } else {
64 Err(crate::Error::UnsupportedAuthMechanism)
65 }
66 }
67
68 pub(crate) async fn auth<U>(
69 &mut self,
70 mechanism: u64,
71 credentials: &Credentials<U>,
72 ) -> crate::Result<()>
73 where
74 U: AsRef<str> + PartialEq + Eq + Hash,
75 {
76 let mut reply = if (mechanism & (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER)) != 0 {
77 self.cmd(
78 format!(
79 "AUTH {} {}\r\n",
80 mechanism.to_mechanism(),
81 credentials.encode(mechanism, "")?,
82 )
83 .as_bytes(),
84 )
85 .await?
86 } else {
87 self.cmd(format!("AUTH {}\r\n", mechanism.to_mechanism()).as_bytes())
88 .await?
89 };
90
91 for _ in 0..3 {
92 match reply.code() {
93 334 => {
94 reply = self
95 .cmd(
96 format!("{}\r\n", credentials.encode(mechanism, reply.message())?)
97 .as_bytes(),
98 )
99 .await?;
100 }
101 235 => {
102 return Ok(());
103 }
104 _ => {
105 return Err(crate::Error::UnexpectedReply(reply));
106 }
107 }
108 }
109
110 Err(crate::Error::UnexpectedReply(reply))
111 }
112}
113
114#[derive(Debug, Clone)]
115pub enum Error {
116 InvalidChallenge,
117}
118
119impl<T: AsRef<str> + PartialEq + Eq + Hash> Credentials<T> {
120 pub fn new(username: T, secret: T) -> Credentials<T> {
122 Credentials::Plain { username, secret }
123 }
124
125 pub fn new_xoauth2(username: T, secret: T) -> Credentials<T> {
127 Credentials::XOauth2 { username, secret }
128 }
129
130 pub fn new_oauth(payload: T) -> Credentials<T> {
132 Credentials::OAuthBearer { token: payload }
133 }
134
135 pub fn new_oauth_from_token(token: T) -> Credentials<String> {
137 Credentials::OAuthBearer {
138 token: format!("auth=Bearer {}\x01\x01", token.as_ref()),
139 }
140 }
141
142 pub fn encode(&self, mechanism: u64, challenge: &str) -> crate::Result<String> {
143 Ok(engine::general_purpose::STANDARD.encode(
144 match (mechanism, self) {
145 (AUTH_PLAIN, Credentials::Plain { username, secret }) => {
146 format!("\u{0}{}\u{0}{}", username.as_ref(), secret.as_ref())
147 }
148
149 (AUTH_LOGIN, Credentials::Plain { username, secret }) => {
150 let challenge = engine::general_purpose::STANDARD.decode(challenge)?;
151 let username = username.as_ref();
152 let secret = secret.as_ref();
153
154 if b"user name"
155 .eq_ignore_ascii_case(challenge.get(0..9).ok_or(Error::InvalidChallenge)?)
156 || b"username".eq_ignore_ascii_case(
157 challenge.get(0..8).ok_or(Error::InvalidChallenge)?,
159 )
160 {
161 &username
162 } else if b"password"
163 .eq_ignore_ascii_case(challenge.get(0..8).ok_or(Error::InvalidChallenge)?)
164 {
165 &secret
166 } else {
167 return Err(Error::InvalidChallenge.into());
168 }
169 .to_string()
170 }
171
172 #[cfg(feature = "digest-md5")]
173 (AUTH_DIGEST_MD5, Credentials::Plain { username, secret }) => {
174 let mut buf = Vec::with_capacity(10);
175 let mut key = None;
176 let mut in_quote = false;
177 let mut values = std::collections::HashMap::new();
178 let challenge = engine::general_purpose::STANDARD.decode(challenge)?;
179 let challenge_len = challenge.len();
180 let username = username.as_ref();
181 let secret = secret.as_ref();
182
183 for (pos, byte) in challenge.into_iter().enumerate() {
184 let add_key = match byte {
185 b'=' if !in_quote => {
186 if key.is_none() && !buf.is_empty() {
187 key = String::from_utf8_lossy(&buf).into_owned().into();
188 buf.clear();
189 } else {
190 return Err(Error::InvalidChallenge.into());
191 }
192 false
193 }
194 b',' if !in_quote => true,
195 b'"' => {
196 in_quote = !in_quote;
197 false
198 }
199 _ => {
200 buf.push(byte);
201 false
202 }
203 };
204
205 if (add_key || pos == challenge_len - 1) && key.is_some() && !buf.is_empty()
206 {
207 values.insert(
208 key.take().unwrap(),
209 String::from_utf8_lossy(&buf).into_owned(),
210 );
211 buf.clear();
212 }
213 }
214
215 let (digest_uri, realm, realm_response) =
216 if let Some(realm) = values.get("realm") {
217 (
218 format!("smtp/{realm}"),
219 realm.as_str(),
220 format!(",realm=\"{realm}\""),
221 )
222 } else {
223 ("smtp/localhost".to_string(), "", "".to_string())
224 };
225
226 let credentials =
227 md5::compute(format!("{username}:{realm}:{secret}").as_bytes());
228
229 let a2 = md5::compute(
230 if values.get("qpop").is_some_and(|v| v == "auth") {
231 format!("AUTHENTICATE:{digest_uri}")
232 } else {
233 format!("AUTHENTICATE:{digest_uri}:00000000000000000000000000000000")
234 }
235 .as_bytes(),
236 );
237
238 #[allow(unused_variables)]
239 let cnonce = {
240 use rand::RngCore;
241 let mut buf = [0u8; 16];
242 rand::rng().fill_bytes(&mut buf);
243 engine::general_purpose::STANDARD.encode(buf)
244 };
245
246 #[cfg(test)]
247 let cnonce = "OA6MHXh6VqTrRk".to_string();
248 let nonce = values.remove("nonce").unwrap_or_default();
249 let qop = values.remove("qop").unwrap_or_default();
250 let charset = values
251 .remove("charset")
252 .unwrap_or_else(|| "utf-8".to_string());
253
254 format!(
255 concat!(
256 "charset={},username=\"{}\",realm=\"{}\",nonce=\"{}\",nc=00000001,",
257 "cnonce=\"{}\",digest-uri=\"{}\",response={:x},qop={}"
258 ),
259 charset,
260 username,
261 realm_response,
262 nonce,
263 cnonce,
264 digest_uri,
265 md5::compute(
266 format!("{credentials:x}:{nonce}:00000001:{cnonce}:{qop}:{a2:x}")
267 .as_bytes()
268 ),
269 qop
270 )
271 }
272
273 #[cfg(feature = "cram-md5")]
274 (AUTH_CRAM_MD5, Credentials::Plain { username, secret }) => {
275 let mut secret_opad: Vec<u8> = vec![0x5c; 64];
276 let mut secret_ipad: Vec<u8> = vec![0x36; 64];
277 let username = username.as_ref();
278 let secret = secret.as_ref();
279
280 if secret.len() < 64 {
281 for (pos, byte) in secret.as_bytes().iter().enumerate() {
282 secret_opad[pos] = *byte ^ 0x5c;
283 secret_ipad[pos] = *byte ^ 0x36;
284 }
285 } else {
286 for (pos, byte) in md5::compute(secret.as_bytes()).iter().enumerate() {
287 secret_opad[pos] = *byte ^ 0x5c;
288 secret_ipad[pos] = *byte ^ 0x36;
289 }
290 }
291
292 secret_ipad
293 .extend_from_slice(&engine::general_purpose::STANDARD.decode(challenge)?);
294 secret_opad.extend_from_slice(&md5::compute(&secret_ipad).0);
295
296 format!("{} {:x}", username, md5::compute(&secret_opad))
297 }
298
299 (AUTH_XOAUTH2, Credentials::XOauth2 { username, secret }) => format!(
300 "user={}\x01auth=Bearer {}\x01\x01",
301 username.as_ref(),
302 secret.as_ref()
303 ),
304 (AUTH_OAUTHBEARER, Credentials::OAuthBearer { token }) => {
305 token.as_ref().to_string()
306 }
307 _ => return Err(crate::Error::UnsupportedAuthMechanism),
308 }
309 .as_bytes(),
310 ))
311 }
312}
313
314impl<'x> From<(&'x str, &'x str)> for Credentials<&'x str> {
315 fn from(credentials: (&'x str, &'x str)) -> Self {
316 Credentials::Plain {
317 username: credentials.0,
318 secret: credentials.1,
319 }
320 }
321}
322
323impl From<(String, String)> for Credentials<String> {
324 fn from(credentials: (String, String)) -> Self {
325 Credentials::Plain {
326 username: credentials.0,
327 secret: credentials.1,
328 }
329 }
330}
331
332impl<U: AsRef<str> + PartialEq + Eq + Hash> AsRef<Credentials<U>> for Credentials<U> {
333 fn as_ref(&self) -> &Credentials<U> {
334 self
335 }
336}
337
338impl Display for Error {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 match self {
341 Error::InvalidChallenge => write!(f, "Invalid challenge received."),
342 }
343 }
344}
345
346#[cfg(test)]
347mod test {
348
349 use smtp_proto::{AUTH_CRAM_MD5, AUTH_DIGEST_MD5, AUTH_LOGIN, AUTH_PLAIN, AUTH_XOAUTH2};
350
351 use crate::smtp::auth::Credentials;
352
353 #[test]
354 fn auth_encode() {
355 #[cfg(feature = "digest-md5")]
357 assert_eq!(
358 Credentials::new("chris", "secret")
359 .encode(
360 AUTH_DIGEST_MD5,
361 concat!(
362 "cmVhbG09ImVsd29vZC5pbm5vc29mdC5jb20iLG5vbmNlPSJPQTZNRzl0",
363 "RVFHbTJoaCIscW9wPSJhdXRoIixhbGdvcml0aG09bWQ1LXNlc3MsY2hh",
364 "cnNldD11dGYtOA=="
365 ),
366 )
367 .unwrap(),
368 concat!(
369 "Y2hhcnNldD11dGYtOCx1c2VybmFtZT0iY2hyaXMiLHJlYWxtPSIscmVhbG0",
370 "9ImVsd29vZC5pbm5vc29mdC5jb20iIixub25jZT0iT0E2TUc5dEVRR20yaG",
371 "giLG5jPTAwMDAwMDAxLGNub25jZT0iT0E2TUhYaDZWcVRyUmsiLGRpZ2Vzd",
372 "C11cmk9InNtdHAvZWx3b29kLmlubm9zb2Z0LmNvbSIscmVzcG9uc2U9NDQ2",
373 "NjIxODg3MzlmYzcxOGNlYmYyZjA4MTk4MWI4ZDIscW9wPWF1dGg=",
374 )
375 );
376
377 #[cfg(feature = "cram-md5")]
379 assert_eq!(
380 Credentials::new("tim", "tanstaaftanstaaf")
381 .encode(
382 AUTH_CRAM_MD5,
383 "PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+",
384 )
385 .unwrap(),
386 "dGltIGI5MTNhNjAyYzdlZGE3YTQ5NWI0ZTZlNzMzNGQzODkw"
387 );
388
389 assert_eq!(
391 Credentials::XOauth2 {
392 username: "someuser@example.com",
393 secret: "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg"
394 }
395 .encode(AUTH_XOAUTH2, "",)
396 .unwrap(),
397 concat!(
398 "dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5Ln",
399 "ZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ=="
400 )
401 );
402
403 assert_eq!(
405 Credentials::new("tim", "tanstaaftanstaaf")
406 .encode(AUTH_LOGIN, "VXNlciBOYW1lAA==",)
407 .unwrap(),
408 "dGlt"
409 );
410 assert_eq!(
411 Credentials::new("tim", "tanstaaftanstaaf")
412 .encode(AUTH_LOGIN, "UGFzc3dvcmQA",)
413 .unwrap(),
414 "dGFuc3RhYWZ0YW5zdGFhZg=="
415 );
416
417 assert_eq!(
419 Credentials::new("tim", "tanstaaftanstaaf")
420 .encode(AUTH_PLAIN, "",)
421 .unwrap(),
422 "AHRpbQB0YW5zdGFhZnRhbnN0YWFm"
423 );
424 }
425}