Skip to main content

wasm_smtp/client/
auth.rs

1//! Authentication methods for [`super::SmtpClient`].
2//!
3//! All `login_*` and `run_auth_*` methods live here. This is a private
4//! child module of `client`; child modules may access private fields of
5//! the parent's types (Rust visibility rules for descendant modules).
6
7use crate::error::{AuthError, InvalidInputError, ProtocolError, SmtpError, SmtpOp};
8use crate::protocol::{
9    self, AuthMechanism,
10    build_auth_plain_initial_response,
11    ehlo_advertises_auth,
12    select_auth_mechanism,
13};
14use crate::session::SessionState;
15use crate::tracing_helpers::{smtp_debug, smtp_warn};
16use crate::transport::Transport;
17use super::{SmtpClient, convert_auth};
18
19impl<T: Transport> SmtpClient<T> {
20
21    /// Authenticate using the best `AUTH` mechanism the server advertised.
22    ///
23    /// `PLAIN` is preferred over `LOGIN` when both are advertised, because
24    /// it completes in a single round-trip and is the IETF-standard SASL
25    /// mechanism. `LOGIN` is used as a fallback for older servers that
26    /// only advertise it. Callers that need to lock in a specific
27    /// mechanism (for testing, or for known-broken servers) should call
28    /// [`Self::login_with`] instead.
29    ///
30    /// Returns [`AuthError::UnsupportedMechanism`] if the server's `EHLO`
31    /// reply did not advertise either `PLAIN` or `LOGIN`. Returns
32    /// [`AuthError::Rejected`] if the server rejects the credentials.
33    ///
34    /// May only be called immediately after [`Self::connect`]. Calling it
35    /// a second time, or after [`Self::send_mail`], returns
36    /// [`InvalidInputError`].
37    ///
38    /// # Credential lifetime and zeroization
39    ///
40    /// `wasm-smtp` does not retain copies of `user` or `pass` after
41    /// this call returns: the credentials are passed by reference, used
42    /// once to build a base64-encoded SASL payload, and dropped together
43    /// with that payload at the end of the call. The crate also never
44    /// includes credentials in [`Debug`](core::fmt::Debug) output, error
45    /// messages, or [`Display`](core::fmt::Display) text.
46    ///
47    /// What the crate cannot do is securely erase the bytes the caller
48    /// supplied — that storage belongs to the caller. If your threat
49    /// model includes memory disclosure (a process dump, a debugger
50    /// attached to the running Worker, etc.), wrap the password in a
51    /// type that zeroes its backing memory on drop (the `zeroize` crate
52    /// is the conventional choice) and pass `&z.expose_secret()` only at
53    /// the call site. Concretely, avoid pulling the password out of an
54    /// environment variable into a long-lived `String`.
55    pub async fn login(&mut self, user: &str, pass: &str) -> Result<(), SmtpError> {
56        if let Some(mech) = select_auth_mechanism(&self.capabilities) {
57            smtp_debug!(mechanism = mech.name(), "AUTH: auto-selected mechanism");
58            self.login_with(mech, user, pass).await
59        } else {
60            smtp_warn!(
61                "AUTH: no supported mechanism advertised; failing with UnsupportedMechanism"
62            );
63            // Validate inputs first so the caller still gets a clean
64            // InvalidInputError on empty credentials, even if the
65            // server would have refused us anyway.
66            protocol::validate_plain_username(user)?;
67            protocol::validate_plain_password(pass)?;
68            self.assert_state_in(&[SessionState::Authentication])?;
69            self.mark_closed_on_logical_failure();
70            Err(AuthError::UnsupportedMechanism.into())
71        }
72    }
73
74    /// Authenticate using a specific `AUTH` mechanism.
75    ///
76    /// Use this when [`Self::login`]'s auto-selection is not what you
77    /// want — for example, when reproducing a production failure that
78    /// is specific to one mechanism, or when testing against a server
79    /// whose advertisement is known to be inaccurate.
80    ///
81    /// `credential` is the secret material whose meaning depends on the
82    /// mechanism: a static password for `Plain` and `Login`, or an
83    /// OAuth 2.0 access token for `XOAuth2` (the latter requires the
84    /// `xoauth2` cargo feature). The `user` parameter is validated
85    /// against rules appropriate to the mechanism (NUL bytes rejected
86    /// for SASL framing in `Plain` / `Login`, additional control bytes
87    /// rejected for `XOAuth2`).
88    ///
89    /// Returns [`AuthError::UnsupportedMechanism`] if `mechanism` was not
90    /// advertised by the server. Returns [`AuthError::Rejected`] if the
91    /// server rejects the credentials.
92    ///
93    /// When the `xoauth2` feature is disabled and the caller passes
94    /// [`AuthMechanism::XOAuth2`], this returns
95    /// [`InvalidInputError`] without performing any I/O — the variant
96    /// remains in the public enum (it is `non_exhaustive`) but the
97    /// code path is removed.
98    pub async fn login_with(
99        &mut self,
100        mechanism: AuthMechanism,
101        user: &str,
102        credential: &str,
103    ) -> Result<(), SmtpError> {
104        match mechanism {
105            AuthMechanism::Plain | AuthMechanism::Login => {
106                protocol::validate_plain_username(user)?;
107                protocol::validate_plain_password(credential)?;
108            }
109            #[cfg(feature = "xoauth2")]
110            AuthMechanism::XOAuth2 => {
111                protocol::validate_xoauth2_user(user)?;
112                protocol::validate_oauth2_token(credential)?;
113            }
114            #[cfg(feature = "oauthbearer")]
115            AuthMechanism::OAuthBearer => {
116                // user is the authzid (may be empty); credential is the Bearer token.
117                protocol::validate_oauth2_token(credential)?;
118            }
119            #[cfg(feature = "scram-sha-256")]
120            AuthMechanism::ScramSha256 => {
121                protocol::validate_plain_username(user)?;
122                protocol::validate_plain_password(credential)?;
123            }
124            #[cfg(not(any(feature = "xoauth2", feature = "oauthbearer", feature = "scram-sha-256")))]
125            _ => {
126                return Err(InvalidInputError::new(
127                    "the requested AUTH mechanism is not compiled in",
128                )
129                .into());
130            }
131            #[allow(unreachable_patterns)]
132            _ => {
133                return Err(InvalidInputError::new(
134                    "the requested AUTH mechanism is not compiled in",
135                )
136                .into());
137            }
138        }
139        self.assert_state_in(&[SessionState::Authentication])?;
140
141        if !ehlo_advertises_auth(&self.capabilities, mechanism.name()) {
142            self.mark_closed_on_logical_failure();
143            return Err(AuthError::UnsupportedMechanism.into());
144        }
145
146        match mechanism {
147            AuthMechanism::Plain => self.run_auth_plain(user, credential).await?,
148            AuthMechanism::Login => self.run_auth_login(user, credential).await?,
149            #[cfg(feature = "xoauth2")]
150            AuthMechanism::XOAuth2 => self.run_auth_xoauth2(user, credential).await?,
151            #[cfg(feature = "oauthbearer")]
152            AuthMechanism::OAuthBearer => self.run_auth_oauthbearer(user, credential).await?,
153            #[cfg(feature = "scram-sha-256")]
154            AuthMechanism::ScramSha256 => self.run_auth_scram_sha256(user, credential).await?,
155            #[allow(unreachable_patterns)]
156            _ => unreachable!("variants screened out above when feature is disabled"),
157        }
158
159        self.transition(SessionState::MailFrom)?;
160        smtp_debug!(mechanism = mechanism.name(), "AUTH: succeeded");
161        self.audit.on_event(&crate::audit::SmtpAuditEvent::AuthCompleted {
162            mechanism: mechanism.name(),
163        });
164        Ok(())
165    }
166
167    /// Authenticate with `XOAUTH2`, the Google / Microsoft OAuth 2.0
168    /// SASL profile.
169    ///
170    /// `user` is the email address of the account, `access_token` is a
171    /// short-lived OAuth 2.0 bearer token obtained via the OAuth flow
172    /// for that account. This crate does not perform the OAuth dance
173    /// itself — token acquisition, refresh, and storage are the
174    /// caller's responsibility.
175    ///
176    /// Convenience wrapper for
177    /// `login_with(AuthMechanism::XOAuth2, user, access_token)`. Note
178    /// that [`Self::login`] (the auto-selecting variant) deliberately
179    /// does not pick `XOAUTH2` even when the server advertises it,
180    /// because the credential semantics are different from a static
181    /// password.
182    ///
183    /// # Errors
184    ///
185    /// - [`AuthError::UnsupportedMechanism`] if the server did not
186    ///   advertise `AUTH XOAUTH2`.
187    /// - [`AuthError::Rejected`] if the server rejected the token.
188    ///   Google and Microsoft typically return a 535 with a base64-
189    ///   encoded JSON `{"status":"401","schemes":"Bearer","scope":"..."}`
190    ///   in the message; the parsed text is preserved in the error.
191    ///
192    /// Available only with the `xoauth2` cargo feature enabled
193    /// (default-on).
194    #[cfg(feature = "xoauth2")]
195    pub async fn login_xoauth2(&mut self, user: &str, access_token: &str) -> Result<(), SmtpError> {
196        self.login_with(AuthMechanism::XOAuth2, user, access_token)
197            .await
198    }
199
200    /// Authenticate with `OAUTHBEARER` (RFC 7628), the IETF-standard
201    /// OAuth 2.0 SASL mechanism.
202    ///
203    /// `user` is the authorization identity (typically the account email
204    /// address); `access_token` is a short-lived OAuth 2.0 bearer token.
205    ///
206    /// Unlike `XOAUTH2`, `OAUTHBEARER` follows the GS2 framing from RFC
207    /// 5801, making it interoperable with any compliant SASL library.
208    ///
209    /// Convenience wrapper for
210    /// `login_with(AuthMechanism::OAuthBearer, user, access_token)`.
211    ///
212    /// # Errors
213    ///
214    /// - [`AuthError::UnsupportedMechanism`] if the server did not
215    ///   advertise `AUTH OAUTHBEARER`.
216    /// - [`AuthError::Rejected`] if the server rejected the token with a
217    ///   `334` error challenge followed by a `535`.
218    ///
219    /// Available only with the `oauthbearer` cargo feature (default-on).
220    #[cfg(feature = "oauthbearer")]
221    pub async fn login_oauthbearer(
222        &mut self,
223        user: &str,
224        access_token: &str,
225    ) -> Result<(), SmtpError> {
226        self.login_with(AuthMechanism::OAuthBearer, user, access_token)
227            .await
228    }
229
230    /// SASL `PLAIN` exchange (RFC 4616) using the initial-response form.
231    ///
232    /// One round-trip:
233    /// `C: AUTH PLAIN <b64(\0user\0pass)>` → `S: 235`.
234    async fn run_auth_plain(&mut self, user: &str, pass: &str) -> Result<(), SmtpError> {
235        let response = build_auth_plain_initial_response(user, pass);
236        let mut cmd = String::with_capacity(11 + response.len() + 2);
237        cmd.push_str("AUTH PLAIN ");
238        cmd.push_str(&response);
239        cmd.push_str("\r\n");
240        self.write_all(cmd.as_bytes()).await?;
241        self.expect_code(235, SmtpOp::AuthPlain)
242            .await
243            .map_err(convert_auth)?;
244        Ok(())
245    }
246
247    /// `AUTH LOGIN` exchange (legacy, two round-trips).
248    ///
249    /// `C: AUTH LOGIN` → `S: 334` → `C: b64(user)` → `S: 334` →
250    /// `C: b64(pass)` → `S: 235`.
251    async fn run_auth_login(&mut self, user: &str, pass: &str) -> Result<(), SmtpError> {
252        self.write_all(b"AUTH LOGIN\r\n").await?;
253        self.expect_code(334, SmtpOp::AuthLogin)
254            .await
255            .map_err(convert_auth)?;
256
257        let mut user_b64 = protocol::base64_encode(user.as_bytes());
258        user_b64.push_str("\r\n");
259        self.write_all(user_b64.as_bytes()).await?;
260        self.expect_code(334, SmtpOp::AuthLogin)
261            .await
262            .map_err(convert_auth)?;
263
264        let mut pass_b64 = protocol::base64_encode(pass.as_bytes());
265        pass_b64.push_str("\r\n");
266        self.write_all(pass_b64.as_bytes()).await?;
267        self.expect_code(235, SmtpOp::AuthLogin)
268            .await
269            .map_err(convert_auth)?;
270        Ok(())
271    }
272
273    /// `AUTH XOAUTH2` exchange (Google / Microsoft).
274    ///
275    /// Wire form:
276    /// `C: AUTH XOAUTH2 <b64("user="user SOH "auth=Bearer "token SOH SOH)>`
277    /// → `S: 235` on success.
278    ///
279    /// On failure, RFC 7628-style providers send `334 <b64(json)>` first
280    /// and expect the client to reply with an empty line; the server
281    /// then sends the final 5xx. We follow that protocol so the JSON
282    /// error detail (containing `scope`, `error`, etc.) ends up in the
283    /// final reply text and is preserved in [`AuthError::Rejected`].
284    #[cfg(feature = "xoauth2")]
285    async fn run_auth_xoauth2(&mut self, user: &str, token: &str) -> Result<(), SmtpError> {
286        let response = protocol::build_xoauth2_initial_response(user, token);
287        let mut cmd = String::with_capacity(13 + response.len() + 2);
288        cmd.push_str("AUTH XOAUTH2 ");
289        cmd.push_str(&response);
290        cmd.push_str("\r\n");
291        self.write_all(cmd.as_bytes()).await?;
292
293        // Read the first reply. 235 is direct success; 334 indicates the
294        // provider is sending JSON error details and expects an empty
295        // continuation line, after which a final 5xx arrives.
296        let reply = self.read_reply().await?;
297        match reply.code {
298            235 => Ok(()),
299            334 => {
300                // Provider-supplied error detail. Send an empty continuation
301                // line so the provider can finalize with a proper 5xx.
302                self.write_all(b"\r\n").await?;
303                let final_reply = self.read_reply().await?;
304                self.mark_closed_on_logical_failure();
305                Err(SmtpError::Auth(AuthError::Rejected {
306                    code: final_reply.code,
307                    enhanced: final_reply.enhanced(),
308                    message: final_reply.joined_text(),
309                }))
310            }
311            other => {
312                self.mark_closed_on_logical_failure();
313                Err(if (500..600).contains(&other) {
314                    SmtpError::Auth(AuthError::Rejected {
315                        code: other,
316                        enhanced: reply.enhanced(),
317                        message: reply.joined_text(),
318                    })
319                } else {
320                    SmtpError::Protocol(ProtocolError::UnexpectedCode {
321                        during: SmtpOp::AuthXOAuth2,
322                        expected_class: 2,
323                        actual: other,
324                        enhanced: reply.enhanced(),
325                        message: reply.joined_text(),
326                    })
327                })
328            }
329        }
330    }
331
332    /// `AUTH OAUTHBEARER` exchange (RFC 7628).
333    ///
334    /// Wire form:
335    /// `C: AUTH OAUTHBEARER <b64("n,a="user","SOH"auth=Bearer "token SOH SOH)>`
336    /// → `S: 235` on success.
337    ///
338    /// On failure, the server sends `334 <b64(json-error)>`, and the
339    /// client must reply `\x01` to abort. The server then responds with
340    /// a final `535`. The JSON error detail is preserved in
341    /// [`AuthError::Rejected`].
342    #[cfg(feature = "oauthbearer")]
343    async fn run_auth_oauthbearer(
344        &mut self,
345        user: &str,
346        token: &str,
347    ) -> Result<(), SmtpError> {
348        let response = protocol::build_oauthbearer_initial_response(user, token);
349        let mut cmd = String::with_capacity(17 + response.len() + 2);
350        cmd.push_str("AUTH OAUTHBEARER ");
351        cmd.push_str(&response);
352        cmd.push_str("\r\n");
353        self.write_all(cmd.as_bytes()).await?;
354
355        let reply = self.read_reply().await?;
356        match reply.code {
357            235 => Ok(()),
358            334 => {
359                // Server sent a JSON error challenge (RFC 7628 §3.2.2).
360                // Client must reply with a single \x01 to abort; the
361                // server then closes with a 5xx.
362                self.write_all(b"\x01\r\n").await?;
363                let final_reply = self.read_reply().await?;
364                self.mark_closed_on_logical_failure();
365                Err(SmtpError::Auth(AuthError::Rejected {
366                    code: final_reply.code,
367                    enhanced: final_reply.enhanced(),
368                    message: final_reply.joined_text(),
369                }))
370            }
371            other => {
372                self.mark_closed_on_logical_failure();
373                Err(if (500..600).contains(&other) {
374                    SmtpError::Auth(AuthError::Rejected {
375                        code: other,
376                        enhanced: reply.enhanced(),
377                        message: reply.joined_text(),
378                    })
379                } else {
380                    SmtpError::Protocol(ProtocolError::UnexpectedCode {
381                        during: SmtpOp::AuthOAuthBearer,
382                        expected_class: 2,
383                        actual: other,
384                        enhanced: reply.enhanced(),
385                        message: reply.joined_text(),
386                    })
387                })
388            }
389        }
390    }
391
392    /// `AUTH SCRAM-SHA-256` exchange (RFC 5802 / RFC 7677).
393    ///
394    /// Wire form:
395    /// 1. `C: AUTH SCRAM-SHA-256 <b64(client-first)>`
396    /// 2. `S: 334 <b64(server-first)>`
397    /// 3. `C: <b64(client-final-with-proof)>`
398    /// 4. `S: 334 <b64(server-final)>` then `S: 235 <ok>`
399    ///    (or `S: 535` if the proof failed verification on the
400    ///    server side).
401    ///
402    /// Note that step 4 has the server returning `334` *with* the
403    /// signature, not directly `235`. The client must verify the
404    /// server's signature locally (mutual authentication) and then
405    /// reply with an empty continuation. The `235` confirms the
406    /// session is authenticated.
407    #[cfg(feature = "scram-sha-256")]
408    async fn run_auth_scram_sha256(&mut self, user: &str, password: &str) -> Result<(), SmtpError> {
409        // Step 1: client-first.
410        let client_nonce = crate::scram::generate_client_nonce().map_err(SmtpError::Auth)?;
411        let client_first = crate::scram::build_client_first(user, &client_nonce);
412        let client_first_b64 = protocol::base64_encode(client_first.as_bytes());
413
414        let mut cmd = String::with_capacity(20 + client_first_b64.len() + 2);
415        cmd.push_str("AUTH SCRAM-SHA-256 ");
416        cmd.push_str(&client_first_b64);
417        cmd.push_str("\r\n");
418        self.write_all(cmd.as_bytes()).await?;
419
420        // Step 2: read 334 with server-first.
421        let reply = self.read_reply().await?;
422        if reply.code != 334 {
423            self.mark_closed_on_logical_failure();
424            return Err(if (500..600).contains(&reply.code) {
425                SmtpError::Auth(AuthError::Rejected {
426                    code: reply.code,
427                    enhanced: reply.enhanced(),
428                    message: reply.joined_text(),
429                })
430            } else {
431                SmtpError::Protocol(ProtocolError::UnexpectedCode {
432                    during: SmtpOp::AuthScramSha256,
433                    expected_class: 3,
434                    actual: reply.code,
435                    enhanced: reply.enhanced(),
436                    message: reply.joined_text(),
437                })
438            });
439        }
440
441        // The 334 reply text is the base64 of the server-first message.
442        let server_first_b64 = reply.joined_text();
443        let server_first_bytes = protocol::base64_decode(&server_first_b64).map_err(|_| {
444            self.mark_closed_on_logical_failure();
445            SmtpError::Auth(AuthError::MalformedChallenge(
446                "SCRAM server-first not valid base64".into(),
447            ))
448        })?;
449        let server_first_str = std::str::from_utf8(&server_first_bytes).map_err(|_| {
450            self.mark_closed_on_logical_failure();
451            SmtpError::Auth(AuthError::MalformedChallenge(
452                "SCRAM server-first not valid UTF-8".into(),
453            ))
454        })?;
455
456        let server_first = crate::scram::parse_server_first(server_first_str, &client_nonce)
457            .map_err(|e| {
458                self.mark_closed_on_logical_failure();
459                SmtpError::Auth(e)
460            })?;
461
462        // Step 3: compute and send client-final.
463        let cf = crate::scram::compute_client_final(
464            user,
465            password,
466            &client_nonce,
467            &server_first,
468            server_first_str,
469        );
470        let client_final_b64 = protocol::base64_encode(cf.message.as_bytes());
471        let mut cmd = String::with_capacity(client_final_b64.len() + 2);
472        cmd.push_str(&client_final_b64);
473        cmd.push_str("\r\n");
474        self.write_all(cmd.as_bytes()).await?;
475
476        // Step 4: server-final + confirmation.
477        let reply = self.read_reply().await?;
478        match reply.code {
479            334 => {
480                // Server is sending its signature as a challenge; verify
481                // and continue with empty response.
482                self.scram_verify_server_final(
483                    &reply.joined_text(),
484                    &cf.expected_server_signature,
485                )?;
486                // Send empty continuation.
487                self.write_all(b"\r\n").await?;
488                // Now expect 235.
489                self.expect_code(235, SmtpOp::AuthScramSha256)
490                    .await
491                    .map_err(convert_auth)?;
492                Ok(())
493            }
494            235 => {
495                // Some servers (Stalwart in some configurations) return
496                // the server-final embedded in the 235 line directly,
497                // skipping the 334-then-235 dance. RFC 5802 §5.1 allows
498                // this. We still verify the signature.
499                self.scram_verify_server_final(
500                    &reply.joined_text(),
501                    &cf.expected_server_signature,
502                )?;
503
504                Ok(())
505            }
506            other => {
507                self.mark_closed_on_logical_failure();
508                Err(if (500..600).contains(&other) {
509                    SmtpError::Auth(AuthError::Rejected {
510                        code: other,
511                        enhanced: reply.enhanced(),
512                        message: reply.joined_text(),
513                    })
514                } else {
515                    SmtpError::Protocol(ProtocolError::UnexpectedCode {
516                        during: SmtpOp::AuthScramSha256,
517                        expected_class: 2,
518                        actual: other,
519                        enhanced: reply.enhanced(),
520                        message: reply.joined_text(),
521                    })
522                })
523            }
524        }
525    }
526
527    /// Helper for [`Self::run_auth_scram_sha256`]: base64-decode and
528    /// UTF-8-decode a `server-final` payload, then verify it against
529    /// the expected `ServerSignature`. Marks the session closed and
530    /// returns an [`AuthError`] on any failure.
531    #[cfg(feature = "scram-sha-256")]
532    fn scram_verify_server_final(
533        &mut self,
534        server_final_b64: &str,
535        expected_signature: &[u8; 32],
536    ) -> Result<(), SmtpError> {
537        let server_final_bytes = protocol::base64_decode(server_final_b64).map_err(|_| {
538            self.mark_closed_on_logical_failure();
539            SmtpError::Auth(AuthError::MalformedChallenge(
540                "SCRAM server-final not valid base64".into(),
541            ))
542        })?;
543        let server_final_str = std::str::from_utf8(&server_final_bytes).map_err(|_| {
544            self.mark_closed_on_logical_failure();
545            SmtpError::Auth(AuthError::MalformedChallenge(
546                "SCRAM server-final not valid UTF-8".into(),
547            ))
548        })?;
549        crate::scram::verify_server_final(server_final_str, expected_signature).map_err(|e| {
550            self.mark_closed_on_logical_failure();
551            SmtpError::Auth(e)
552        })?;
553        Ok(())
554    }
555}