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