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}