daaki_smtp/connection/auth.rs
1//! Authentication: PLAIN, LOGIN, XOAUTH2, OAUTHBEARER.
2//!
3//! RFC 4954 (SMTP AUTH), RFC 4616 (SASL PLAIN), RFC 7628 (OAUTHBEARER),
4//! draft-murchison-sasl-login (AUTH LOGIN).
5
6#[allow(clippy::wildcard_imports)]
7use super::*;
8
9impl SmtpConnection {
10 // -----------------------------------------------------------------------
11 // Authentication
12 // -----------------------------------------------------------------------
13
14 /// Handle the server response after an AUTH command.
15 ///
16 /// RFC 4954 Section 6: if the server sends a 334 challenge that the
17 /// client does not wish to answer, the client cancels the SASL exchange
18 /// by sending a line containing a single `*`. Without this
19 /// cancellation the session remains stuck in AUTH state and subsequent
20 /// commands are misinterpreted as continuation data.
21 ///
22 /// The `cancel_payload` parameter controls what is sent on a 334
23 /// challenge. Most SASL mechanisms use `b"*\r\n"` (RFC 4954 Section 6),
24 /// but OAUTHBEARER requires `b"AQ==\r\n"` — the base64-encoded SOH
25 /// byte — per RFC 7628 Section 3.2.3.
26 ///
27 /// Caller must pass the already-locked inner guard.
28 pub(super) async fn handle_auth_response(
29 inner: &mut tokio::sync::MutexGuard<'_, SmtpInner>,
30 cancel_payload: &[u8],
31 ) -> Result<(), Error> {
32 let resp = inner.read_response().await?;
33 // RFC 4954 Section 6: the only valid AUTH success code is 235.
34 // Any other 2xx code is a protocol violation and must not be
35 // treated as successful authentication.
36 if resp.code == 235 {
37 // RFC 4954 Section 3: "After an AUTH command has been successfully
38 // completed, no more AUTH commands may be issued in the same
39 // session." Set the flag centrally so every auth method benefits
40 // without each caller needing to remember this invariant.
41 inner.authenticated = true;
42 Ok(())
43 } else if resp.code == 334 {
44 // RFC 4954 Section 6 / RFC 7628 Section 3.2.3: send the
45 // mechanism-appropriate cancellation or error acknowledgment.
46 inner.write_all(cancel_payload).await?;
47 let final_resp = inner.read_response().await?;
48 if final_resp.is_success() {
49 return Err(Error::Protocol(format!(
50 "server sent success code {} after AUTH cancellation; \
51 a cancelled AUTH exchange must be rejected, typically \
52 with 501 (RFC 4954 Section 6)",
53 final_resp.code
54 )));
55 }
56 let message = final_resp.text();
57 Err(Error::Auth {
58 message,
59 response: final_resp,
60 })
61 } else if resp.is_success() {
62 // RFC 4954 Section 6: "The only valid positive response to AUTH
63 // is 235." A non-235 2xx is a server protocol violation, not an
64 // authentication failure — do not set `authenticated = true`.
65 Err(Error::Protocol(format!(
66 "server sent non-235 success code {} for AUTH \
67 (RFC 4954 Section 6)",
68 resp.code,
69 )))
70 } else {
71 let message = resp.text();
72 Err(Error::Auth {
73 message,
74 response: resp,
75 })
76 }
77 }
78
79 /// Authenticate with PLAIN mechanism (RFC 4616), omitting `authzid`
80 /// so the server derives the authorization identity from `authcid`.
81 ///
82 /// On failure, returns [`Error::Auth`] with the full [`SmtpResponse`]
83 /// so callers can distinguish transient (454) from permanent (535)
84 /// failures (RFC 4954 Section 4).
85 ///
86 /// If the server responds with a 334 challenge, the exchange is
87 /// cancelled per RFC 4954 Section 6.
88 ///
89 /// RFC 4954 Section 4 defines `AUTH mechanism [initial-response]`, so
90 /// PLAIN may include its initial response directly on the AUTH command
91 /// line whenever it fits within SMTP's command line limit. The client
92 /// falls back to the two-step exchange only when the command would
93 /// exceed the SMTP line-length limit (RFC 5321 Section 4.5.3.1.4).
94 // Clippy wants us to drop the MutexGuard earlier, but capabilities are
95 // checked and then acted on within the same lock scope. Splitting into
96 // two acquisitions would introduce a TOCTOU race (e.g. a concurrent
97 // reconnect could change capabilities between check and I/O).
98 #[allow(clippy::significant_drop_tightening)]
99 pub async fn auth_plain(&self, user: &str, pass: &str, timeout: Duration) -> Result<(), Error> {
100 self.auth_plain_internal(None, user, pass, timeout).await
101 }
102
103 /// Authenticate with PLAIN mechanism (RFC 4616) using an explicit
104 /// authorization identity (`authzid`).
105 ///
106 /// RFC 4616 Section 2: `message = [authzid] UTF8NUL authcid UTF8NUL passwd`.
107 /// Use this when the client needs to authenticate as one identity
108 /// (`authcid`) while requesting authorization as another (`authzid`).
109 ///
110 /// The provided `authzid` must be non-empty and must not contain NUL,
111 /// because RFC 4616 defines `authzid = 1*SAFE` and uses NUL as the
112 /// field delimiter.
113 #[allow(clippy::significant_drop_tightening)]
114 pub async fn auth_plain_with_authzid(
115 &self,
116 authzid: &str,
117 user: &str,
118 pass: &str,
119 timeout: Duration,
120 ) -> Result<(), Error> {
121 self.auth_plain_internal(Some(authzid), user, pass, timeout)
122 .await
123 }
124
125 /// Shared RFC 4616 PLAIN authentication flow for both derived-authzid
126 /// and explicit-authzid variants.
127 #[allow(clippy::significant_drop_tightening)]
128 async fn auth_plain_internal(
129 &self,
130 authzid: Option<&str>,
131 user: &str,
132 pass: &str,
133 timeout: Duration,
134 ) -> Result<(), Error> {
135 let encoded_creds = Self::build_plain_credentials(authzid, user, pass)?;
136
137 let mut inner = self.inner.lock().await;
138 Self::ensure_not_shutting_down(&inner)?;
139 // RFC 4954 Section 3: "After an AUTH command has been successfully
140 // completed, no more AUTH commands may be issued in the same session."
141 if inner.authenticated {
142 return Err(Error::Protocol(
143 "already authenticated in this session; \
144 RFC 4954 Section 3 prohibits issuing another AUTH command"
145 .into(),
146 ));
147 }
148 // RFC 4954 Section 3: "An SMTP client MUST NOT use an AUTH
149 // mechanism unless the name of the SASL mechanism has been
150 // advertised to the client."
151 if !inner
152 .capabilities
153 .supports_auth(&crate::types::AuthMechanism::Plain)
154 {
155 return Err(Error::Protocol(
156 "AUTH PLAIN requires the server to advertise PLAIN in its \
157 AUTH extension (RFC 4954 Section 3)"
158 .into(),
159 ));
160 }
161 let result = tokio::time::timeout(timeout, async {
162 Self::auth_send_with_initial_response(&mut inner, "PLAIN", &encoded_creds, b"*\r\n")
163 .await
164 })
165 .await
166 .map_err(|_| Error::Timeout)?;
167 result
168 }
169
170 /// Build the base64-encoded RFC 4616 PLAIN credential triplet.
171 ///
172 /// RFC 4616 Section 2:
173 /// `message = [authzid] UTF8NUL authcid UTF8NUL passwd`
174 ///
175 /// Returns the base64-encoded payload suitable for SMTP AUTH PLAIN
176 /// (RFC 4954 Section 4).
177 pub(super) fn build_plain_credentials(
178 authzid: Option<&str>,
179 user: &str,
180 pass: &str,
181 ) -> Result<String, Error> {
182 use base64::Engine;
183
184 // RFC 4616 Section 2: authcid = 1*SAFE, passwd = 1*SAFE.
185 if user.is_empty() {
186 return Err(Error::Protocol(
187 "AUTH PLAIN username must not be empty \
188 (RFC 4616 Section 2: authcid = 1*SAFE)"
189 .into(),
190 ));
191 }
192 if pass.is_empty() {
193 return Err(Error::Protocol(
194 "AUTH PLAIN password must not be empty \
195 (RFC 4616 Section 2: passwd = 1*SAFE)"
196 .into(),
197 ));
198 }
199 if authzid.is_some_and(str::is_empty) {
200 return Err(Error::Protocol(
201 "AUTH PLAIN authzid must not be empty when explicitly supplied \
202 (RFC 4616 Section 2: authzid = 1*SAFE)"
203 .into(),
204 ));
205 }
206
207 // RFC 4616 Section 2 / Section 2 ABNF: SAFE excludes NUL because
208 // UTF8NUL is the field delimiter.
209 if authzid.is_some_and(|s| s.as_bytes().contains(&0x00)) {
210 return Err(Error::Protocol(
211 "AUTH PLAIN authzid must not contain NUL (0x00); \
212 NUL is the SASL PLAIN delimiter \
213 (RFC 4616 Section 2)"
214 .into(),
215 ));
216 }
217 if user.as_bytes().contains(&0x00) {
218 return Err(Error::Protocol(
219 "AUTH PLAIN username must not contain NUL (0x00); \
220 NUL is the SASL PLAIN delimiter \
221 (RFC 4616 Section 2)"
222 .into(),
223 ));
224 }
225 if pass.as_bytes().contains(&0x00) {
226 return Err(Error::Protocol(
227 "AUTH PLAIN password must not contain NUL (0x00); \
228 NUL is the SASL PLAIN delimiter \
229 (RFC 4616 Section 2)"
230 .into(),
231 ));
232 }
233 // RFC 4616 Section 2 transfers authcid/passwd as UTF-8 strings and
234 // relies on SASL string preparation before verification. RFC 4616
235 // Appendix A clarifies that control characters are prohibited in the
236 // authcid and passwd productions, so reject them client-side instead
237 // of emitting credentials the server is expected to refuse.
238 if user.chars().any(char::is_control) {
239 return Err(Error::Protocol(
240 "AUTH PLAIN username must not contain control characters \
241 (RFC 4616 Section 2 / Appendix A)"
242 .into(),
243 ));
244 }
245 if pass.chars().any(char::is_control) {
246 return Err(Error::Protocol(
247 "AUTH PLAIN password must not contain control characters \
248 (RFC 4616 Section 2 / Appendix A)"
249 .into(),
250 ));
251 }
252
253 let authzid_len = authzid.map_or(0, str::len);
254 let mut credentials = Vec::with_capacity(authzid_len + 1 + user.len() + 1 + pass.len());
255 if let Some(authzid) = authzid {
256 credentials.extend_from_slice(authzid.as_bytes());
257 }
258 credentials.push(0);
259 credentials.extend_from_slice(user.as_bytes());
260 credentials.push(0);
261 credentials.extend_from_slice(pass.as_bytes());
262
263 Ok(base64::engine::general_purpose::STANDARD.encode(&credentials))
264 }
265
266 /// Authenticate with XOAUTH2 mechanism.
267 ///
268 /// On failure, returns [`Error::Auth`] with the full [`SmtpResponse`]
269 /// so callers can distinguish transient (454) from permanent (535)
270 /// failures (RFC 4954 Section 4).
271 ///
272 /// If the server responds with a 334 challenge, the exchange is
273 /// cancelled per RFC 4954 Section 6.
274 ///
275 /// RFC 4954 Section 4 defines `AUTH mechanism [initial-response]`, so
276 /// XOAUTH2 may include its initial response directly on the AUTH command
277 /// line whenever it fits within SMTP's command line limit. The client
278 /// falls back to the two-step exchange only when the command would
279 /// exceed the SMTP line-length limit (RFC 5321 Section 4.5.3.1.4).
280 // Clippy wants us to drop the MutexGuard earlier, but capabilities are
281 // checked and then acted on within the same lock scope. Splitting into
282 // two acquisitions would introduce a TOCTOU race (e.g. a concurrent
283 // reconnect could change capabilities between check and I/O).
284 #[allow(clippy::significant_drop_tightening)]
285 pub async fn auth_xoauth2(
286 &self,
287 user: &str,
288 token: &str,
289 timeout: Duration,
290 ) -> Result<(), Error> {
291 use base64::Engine;
292
293 // Google XOAUTH2 uses SOH (\x01) as the SASL string delimiter:
294 // "user=<user>\x01auth=Bearer <token>\x01\x01". An embedded
295 // SOH byte in the username or token would corrupt the SASL
296 // encoding by introducing a spurious field boundary.
297 if user.as_bytes().contains(&0x01) {
298 return Err(Error::Protocol(
299 "AUTH XOAUTH2 username must not contain SOH (0x01); \
300 SOH is the XOAUTH2 SASL delimiter"
301 .into(),
302 ));
303 }
304 if token.as_bytes().contains(&0x01) {
305 return Err(Error::Protocol(
306 "AUTH XOAUTH2 token must not contain SOH (0x01); \
307 SOH is the XOAUTH2 SASL delimiter"
308 .into(),
309 ));
310 }
311
312 // Google XOAUTH2: user=<user>\x01auth=Bearer <token>\x01\x01
313 let sasl_string = format!("user={user}\x01auth=Bearer {token}\x01\x01");
314 let encoded_creds =
315 base64::engine::general_purpose::STANDARD.encode(sasl_string.as_bytes());
316
317 let mut inner = self.inner.lock().await;
318 Self::ensure_not_shutting_down(&inner)?;
319 // RFC 4954 Section 3: "After an AUTH command has been successfully
320 // completed, no more AUTH commands may be issued in the same session."
321 if inner.authenticated {
322 return Err(Error::Protocol(
323 "already authenticated in this session; \
324 RFC 4954 Section 3 prohibits issuing another AUTH command"
325 .into(),
326 ));
327 }
328 // RFC 4954 Section 3: "An SMTP client MUST NOT use an AUTH
329 // mechanism unless the name of the SASL mechanism has been
330 // advertised to the client."
331 if !inner
332 .capabilities
333 .supports_auth(&crate::types::AuthMechanism::XOAuth2)
334 {
335 return Err(Error::Protocol(
336 "AUTH XOAUTH2 requires the server to advertise XOAUTH2 in its \
337 AUTH extension (RFC 4954 Section 3)"
338 .into(),
339 ));
340 }
341 let result = tokio::time::timeout(timeout, async {
342 Self::auth_send_with_initial_response(&mut inner, "XOAUTH2", &encoded_creds, b"*\r\n")
343 .await
344 })
345 .await
346 .map_err(|_| Error::Timeout)?;
347 result
348 }
349
350 /// Authenticate with LOGIN mechanism (draft-murchison-sasl-login).
351 ///
352 /// AUTH LOGIN is a de-facto standard two-step challenge-response mechanism
353 /// widely deployed by corporate and legacy servers. The SASL exchange
354 /// follows the pattern in RFC 4954 Section 4:
355 ///
356 /// 1. Client sends `AUTH LOGIN\r\n`
357 /// 2. Server sends `334 VXNlcm5hbWU6\r\n` (base64 "Username:")
358 /// 3. Client sends `<base64(user)>\r\n`
359 /// 4. Server sends `334 UGFzc3dvcmQ6\r\n` (base64 "Password:")
360 /// 5. Client sends `<base64(pass)>\r\n`
361 /// 6. Server sends `235` (success) or `535` (failure)
362 ///
363 /// On failure, returns [`Error::Auth`] with the full [`SmtpResponse`]
364 /// so callers can distinguish transient (454) from permanent (535)
365 /// failures (RFC 4954 Section 4).
366 // Clippy wants us to drop the MutexGuard earlier, but capabilities are
367 // checked and then acted on within the same lock scope. Splitting into
368 // two acquisitions would introduce a TOCTOU race (e.g. a concurrent
369 // reconnect could change capabilities between check and I/O).
370 #[allow(clippy::significant_drop_tightening, clippy::too_many_lines)]
371 pub async fn auth_login(&self, user: &str, pass: &str, timeout: Duration) -> Result<(), Error> {
372 use base64::Engine;
373
374 // draft-murchison-sasl-login Section 2: the server challenges
375 // for a username and password; both must be non-empty to produce
376 // valid base64-encoded responses. This mirrors the AUTH PLAIN
377 // requirement (RFC 4616 Section 2: authcid = 1*SAFE, passwd = 1*SAFE).
378 if user.is_empty() {
379 return Err(Error::Protocol(
380 "AUTH LOGIN username must not be empty \
381 (draft-murchison-sasl-login; cf. RFC 4616 Section 2)"
382 .into(),
383 ));
384 }
385 if pass.is_empty() {
386 return Err(Error::Protocol(
387 "AUTH LOGIN password must not be empty \
388 (draft-murchison-sasl-login; cf. RFC 4616 Section 2)"
389 .into(),
390 ));
391 }
392 // draft-murchison-sasl-login; cf. RFC 4616 Section 2:
393 // NUL (0x00) is never valid in credentials. While AUTH LOGIN does
394 // not use NUL as a delimiter (unlike SASL PLAIN), embedded NUL
395 // bytes can cause truncation on servers with C-style string
396 // handling, leading to authentication with a wrong identity.
397 // Reject for consistency with auth_plain and defense-in-depth.
398 if user.as_bytes().contains(&0x00) {
399 return Err(Error::Protocol(
400 "AUTH LOGIN username must not contain NUL (0x00); \
401 NUL bytes in credentials cause undefined server behavior \
402 (draft-murchison-sasl-login; cf. RFC 4616 Section 2)"
403 .into(),
404 ));
405 }
406 if pass.as_bytes().contains(&0x00) {
407 return Err(Error::Protocol(
408 "AUTH LOGIN password must not contain NUL (0x00); \
409 NUL bytes in credentials cause undefined server behavior \
410 (draft-murchison-sasl-login; cf. RFC 4616 Section 2)"
411 .into(),
412 ));
413 }
414
415 let mut inner = self.inner.lock().await;
416 Self::ensure_not_shutting_down(&inner)?;
417 // RFC 4954 Section 3: "After an AUTH command has been successfully
418 // completed, no more AUTH commands may be issued in the same session."
419 if inner.authenticated {
420 return Err(Error::Protocol(
421 "already authenticated in this session; \
422 RFC 4954 Section 3 prohibits issuing another AUTH command"
423 .into(),
424 ));
425 }
426 // RFC 4954 Section 3: "An SMTP client MUST NOT use an AUTH
427 // mechanism unless the name of the SASL mechanism has been
428 // advertised to the client."
429 if !inner
430 .capabilities
431 .supports_auth(&crate::types::AuthMechanism::Login)
432 {
433 return Err(Error::Protocol(
434 "AUTH LOGIN requires the server to advertise LOGIN in its \
435 AUTH extension (RFC 4954 Section 3)"
436 .into(),
437 ));
438 }
439 let result = tokio::time::timeout(timeout, async {
440 // Step 1: Send AUTH LOGIN (draft-murchison-sasl-login).
441 inner.write_all(b"AUTH LOGIN\r\n").await?;
442
443 // Step 2: Read 334 challenge for username.
444 // Server should send "334 VXNlcm5hbWU6" (base64 "Username:").
445 // Per Postel's law, we accept any 334 response — the challenge
446 // text is informational and varies across implementations.
447 let resp = inner.read_response().await?;
448 if resp.code != 334 {
449 let message = resp.text();
450 return Err(Error::Auth {
451 message,
452 response: resp,
453 });
454 }
455
456 // Step 3: Send base64-encoded username.
457 let encoded_user = base64::engine::general_purpose::STANDARD.encode(user.as_bytes());
458 // RFC 4954 Section 12: auth-response has a maximum of 12288
459 // octets excluding the terminating CRLF.
460 if encoded_user.len() > Self::SMTP_MAX_AUTH_RESPONSE {
461 // Cancel the SASL exchange so the session doesn't remain
462 // stuck in AUTH state (RFC 4954 Section 6).
463 inner.write_all(b"*\r\n").await?;
464 let _ = inner.read_response().await;
465 return Err(Error::Protocol(format!(
466 "AUTH LOGIN auth-response (username) exceeds {}-octet limit ({} octets) \
467 (RFC 4954 Section 12)",
468 Self::SMTP_MAX_AUTH_RESPONSE,
469 encoded_user.len()
470 )));
471 }
472 let mut line = BytesMut::with_capacity(encoded_user.len() + 2);
473 line.extend_from_slice(encoded_user.as_bytes());
474 line.extend_from_slice(b"\r\n");
475 inner.write_all(&line).await?;
476
477 // Step 4: Read 334 challenge for password.
478 // Server should send "334 UGFzc3dvcmQ6" (base64 "Password:").
479 let resp = inner.read_response().await?;
480 if resp.code != 334 {
481 let message = resp.text();
482 return Err(Error::Auth {
483 message,
484 response: resp,
485 });
486 }
487
488 // Step 5: Send base64-encoded password.
489 let encoded_pass = base64::engine::general_purpose::STANDARD.encode(pass.as_bytes());
490 // RFC 4954 Section 12: auth-response has a maximum of 12288
491 // octets excluding the terminating CRLF.
492 if encoded_pass.len() > Self::SMTP_MAX_AUTH_RESPONSE {
493 // Cancel the SASL exchange so the session doesn't remain
494 // stuck in AUTH state (RFC 4954 Section 6).
495 inner.write_all(b"*\r\n").await?;
496 let _ = inner.read_response().await;
497 return Err(Error::Protocol(format!(
498 "AUTH LOGIN auth-response (password) exceeds {}-octet limit ({} octets) \
499 (RFC 4954 Section 12)",
500 Self::SMTP_MAX_AUTH_RESPONSE,
501 encoded_pass.len()
502 )));
503 }
504 let mut line = BytesMut::with_capacity(encoded_pass.len() + 2);
505 line.extend_from_slice(encoded_pass.as_bytes());
506 line.extend_from_slice(b"\r\n");
507 inner.write_all(&line).await?;
508
509 // Step 6: Read final response (235 success or error).
510 // RFC 4954 Section 6: if the server sends a 334 challenge at
511 // this point, cancel the SASL exchange with "*\r\n".
512 Self::handle_auth_response(&mut inner, b"*\r\n").await
513 })
514 .await
515 .map_err(|_| Error::Timeout)?;
516 result
517 }
518
519 /// Authenticate with OAUTHBEARER mechanism (RFC 7628 Section 3.1).
520 ///
521 /// OAUTHBEARER is the modern standard OAuth 2.0 bearer token SASL
522 /// mechanism, used by Microsoft 365 and other providers.
523 ///
524 /// SASL payload: `n,,\x01auth=Bearer <token>\x01\x01`
525 ///
526 /// On failure, returns [`Error::Auth`] with the full [`SmtpResponse`]
527 /// so callers can distinguish transient (454) from permanent (535)
528 /// failures (RFC 4954 Section 4).
529 ///
530 /// RFC 4954 Section 4 defines `AUTH mechanism [initial-response]`, so
531 /// OAUTHBEARER may include its initial response directly on the AUTH
532 /// command line whenever it fits within SMTP's command line limit. The
533 /// client falls back to the two-step exchange only when the command
534 /// would exceed the SMTP line-length limit (RFC 5321 Section 4.5.3.1.4).
535 // Clippy wants us to drop the MutexGuard earlier, but capabilities are
536 // checked and then acted on within the same lock scope. Splitting into
537 // two acquisitions would introduce a TOCTOU race (e.g. a concurrent
538 // reconnect could change capabilities between check and I/O).
539 #[allow(clippy::significant_drop_tightening)]
540 pub async fn auth_oauthbearer(&self, token: &str, timeout: Duration) -> Result<(), Error> {
541 use base64::Engine;
542
543 // RFC 7628 Section 3.1: the OAUTHBEARER SASL payload includes
544 // SOH (\x01) as a delimiter. An embedded SOH in the token would
545 // corrupt the SASL encoding.
546 if token.as_bytes().contains(&0x01) {
547 return Err(Error::Protocol(
548 "AUTH OAUTHBEARER token must not contain SOH (0x01); \
549 SOH is the OAUTHBEARER SASL delimiter"
550 .into(),
551 ));
552 }
553
554 // RFC 7628 Section 3.1: gs2-header is "n,," (no channel binding,
555 // no authzid) followed by key-value pairs separated by SOH.
556 let sasl_string = format!("n,,\x01auth=Bearer {token}\x01\x01");
557 let encoded_creds =
558 base64::engine::general_purpose::STANDARD.encode(sasl_string.as_bytes());
559
560 let mut inner = self.inner.lock().await;
561 Self::ensure_not_shutting_down(&inner)?;
562 // RFC 4954 Section 3: "After an AUTH command has been successfully
563 // completed, no more AUTH commands may be issued in the same session."
564 if inner.authenticated {
565 return Err(Error::Protocol(
566 "already authenticated in this session; \
567 RFC 4954 Section 3 prohibits issuing another AUTH command"
568 .into(),
569 ));
570 }
571 // RFC 4954 Section 3: "An SMTP client MUST NOT use an AUTH
572 // mechanism unless the name of the SASL mechanism has been
573 // advertised to the client."
574 if !inner
575 .capabilities
576 .supports_auth(&crate::types::AuthMechanism::OAuthBearer)
577 {
578 return Err(Error::Protocol(
579 "AUTH OAUTHBEARER requires the server to advertise OAUTHBEARER in its \
580 AUTH extension (RFC 4954 Section 3)"
581 .into(),
582 ));
583 }
584 let result = tokio::time::timeout(timeout, async {
585 // RFC 7628 Section 3.2.3: OAUTHBEARER error acknowledgment
586 // is the base64-encoded SOH byte ("AQ=="), not the generic
587 // SASL abort ("*") from RFC 4954 Section 6.
588 Self::auth_send_with_initial_response(
589 &mut inner,
590 "OAUTHBEARER",
591 &encoded_creds,
592 b"AQ==\r\n",
593 )
594 .await
595 })
596 .await
597 .map_err(|_| Error::Timeout)?;
598 result
599 }
600
601 /// Send an AUTH command with an initial response when it fits, falling
602 /// back to the two-step exchange only when the command would exceed the
603 /// SMTP line-length limit.
604 ///
605 /// Builds the one-line command (`AUTH <mechanism> <credentials>\r\n`)
606 /// internally from the mechanism name and base64-encoded credentials,
607 /// so callers only need to compute the credentials once.
608 ///
609 /// The `cancel_payload` controls what is sent when the server responds
610 /// with a 334 challenge: most mechanisms use `b"*\r\n"` (RFC 4954
611 /// Section 6), but OAUTHBEARER uses `b"AQ==\r\n"` (RFC 7628
612 /// Section 3.2.3).
613 ///
614 /// RFC 4954 Section 4 defines `AUTH mechanism [initial-response]`.
615 /// If the command would exceed the 512-octet limit from RFC 5321
616 /// Section 4.5.3.1.4, this falls back to the two-step exchange via
617 /// [`auth_two_step`].
618 pub(super) async fn auth_send_with_initial_response(
619 inner: &mut tokio::sync::MutexGuard<'_, SmtpInner>,
620 mechanism: &str,
621 encoded_credentials: &str,
622 cancel_payload: &[u8],
623 ) -> Result<(), Error> {
624 // Build the one-line AUTH command with an initial response.
625 // RFC 4954 Section 4: "If the client is transmitting an initial
626 // response of zero length, it MUST instead transmit the response
627 // as a single equals sign ('=')."
628 let ir_payload = if encoded_credentials.is_empty() {
629 "="
630 } else {
631 encoded_credentials
632 };
633 let initial_cmd = format!("AUTH {mechanism} {ir_payload}\r\n");
634
635 // RFC 4954 Section 4: fall back to two-step if the command line
636 // would exceed the SMTP limit (RFC 5321 Section 4.5.3.1.4).
637 if initial_cmd.len() > Self::SMTP_MAX_COMMAND_LINE {
638 Self::auth_two_step(inner, mechanism, encoded_credentials, cancel_payload).await
639 } else {
640 inner.write_all(initial_cmd.as_bytes()).await?;
641 Self::handle_auth_response(inner, cancel_payload).await
642 }
643 }
644
645 /// Shared two-step AUTH exchange (RFC 4954 Section 4).
646 ///
647 /// Sends `AUTH <mechanism>\r\n`, waits for the 334 server challenge,
648 /// validates the base64-encoded `credentials` against the auth-response
649 /// length limit (RFC 4954 Section 12), sends them, and reads the final
650 /// server response.
651 ///
652 /// The `cancel_payload` controls what is sent when the server responds
653 /// with a 334 challenge instead of a final reply: most mechanisms use
654 /// `b"*\r\n"` (RFC 4954 Section 6), but OAUTHBEARER uses `b"AQ==\r\n"`
655 /// (RFC 7628 Section 3.2.3).
656 ///
657 /// Used by both [`auth_plain_two_step`] and [`auth_xoauth2_two_step`]
658 /// after each method has computed its mechanism-specific base64 payload.
659 pub(super) async fn auth_two_step(
660 inner: &mut tokio::sync::MutexGuard<'_, SmtpInner>,
661 mechanism: &str,
662 encoded_credentials: &str,
663 cancel_payload: &[u8],
664 ) -> Result<(), Error> {
665 // Step 1: Send AUTH <mechanism> without initial response.
666 let cmd = format!("AUTH {mechanism}\r\n");
667 inner.write_all(cmd.as_bytes()).await?;
668
669 // Step 2: Wait for 334 server challenge (RFC 4954 Section 5).
670 let resp = inner.read_response().await?;
671 if resp.code != 334 {
672 let message = resp.text();
673 return Err(Error::Auth {
674 message,
675 response: resp,
676 });
677 }
678
679 // Step 3: Validate length and send base64-encoded credentials.
680 // RFC 4954 Section 12: auth-response has a maximum of 12288
681 // octets excluding the terminating CRLF.
682 if encoded_credentials.len() > Self::SMTP_MAX_AUTH_RESPONSE {
683 // Cancel the SASL exchange before returning the error so
684 // the session doesn't remain stuck in AUTH state.
685 // RFC 7628 Section 3.2.3: use the mechanism-specific
686 // cancel_payload (e.g., "AQ==\r\n" for OAUTHBEARER).
687 inner.write_all(cancel_payload).await?;
688 let _ = inner.read_response().await;
689 return Err(Error::Protocol(format!(
690 "AUTH {mechanism} auth-response exceeds {}-octet limit ({} octets) \
691 (RFC 4954 Section 12)",
692 Self::SMTP_MAX_AUTH_RESPONSE,
693 encoded_credentials.len()
694 )));
695 }
696
697 // RFC 4954 Section 4: "Note that the [BASE64] encoding of a
698 // zero-length client answer is '='." An empty continuation
699 // response must be sent as "=\r\n", not "\r\n".
700 let payload = if encoded_credentials.is_empty() {
701 "="
702 } else {
703 encoded_credentials
704 };
705 let mut line = BytesMut::with_capacity(payload.len() + 2);
706 line.extend_from_slice(payload.as_bytes());
707 line.extend_from_slice(b"\r\n");
708 inner.write_all(&line).await?;
709
710 // Step 4: Read final response (235 success or error).
711 // RFC 4954 Section 6 / RFC 7628 Section 3.2.3: if the server
712 // sends a 334 challenge at this point, respond with the
713 // mechanism-appropriate cancellation payload before returning.
714 Self::handle_auth_response(inner, cancel_payload).await
715 }
716}