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