Skip to main content

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}