Skip to main content

io_smtp/sasl/
auth_login.rs

1//! SMTP SASL LOGIN coroutine (legacy two-prompt mechanism,
2//! pre-IETF). Prefer [`auth_plain`] or [`auth_scram_sha_256`] when
3//! the server supports them.
4//!
5//! Background: <https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login>
6//!
7//! [`auth_plain`]: crate::sasl::auth_plain
8//! [`auth_scram_sha_256`]: crate::rfc7677::auth_scram_sha_256
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use std::{
14//!     borrow::Cow,
15//!     io::{Read, Write},
16//!     net::TcpStream,
17//! };
18//!
19//! use secrecy::SecretString;
20//!
21//! use io_smtp::{
22//!     coroutine::{SmtpCoroutine, SmtpCoroutineState, SmtpYield},
23//!     rfc5321::{SmtpDomain, SmtpEhloDomain},
24//!     sasl::auth_login::{SmtpAuthLogin, SmtpAuthLoginOptions},
25//! };
26//!
27//! // Ready stream needed (TCP-connected, TLS-negociated, EHLO consumed)
28//! let mut stream = TcpStream::connect("localhost:25").unwrap();
29//!
30//! let mut buf = [0u8; 4096];
31//!
32//! let password = SecretString::from("secret".to_string());
33//! let domain = SmtpEhloDomain::SmtpDomain(SmtpDomain(Cow::Borrowed("client.example.org")));
34//! let opts = SmtpAuthLoginOptions::default();
35//! let mut coroutine = SmtpAuthLogin::new("alice", &password, domain, opts);
36//! let mut arg = None;
37//!
38//! loop {
39//!     match coroutine.resume(arg.take()) {
40//!         SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => {
41//!             stream.write_all(&bytes).unwrap();
42//!         }
43//!         SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {
44//!             let n = stream.read(&mut buf).unwrap();
45//!             arg = Some(&buf[..n]);
46//!         }
47//!         SmtpCoroutineState::Complete(Ok(())) => break,
48//!         SmtpCoroutineState::Complete(Err(err)) => panic!("{err}"),
49//!     }
50//! }
51//! ```
52
53use core::fmt;
54
55use alloc::{
56    string::{String, ToString},
57    vec::Vec,
58};
59
60use bounded_static::IntoBoundedStatic;
61use log::debug;
62use secrecy::{ExposeSecret, SecretString};
63use thiserror::Error;
64
65use crate::{
66    coroutine::*,
67    rfc4954::auth_data::SmtpAuthData,
68    rfc5321::{
69        SmtpEhloDomain, SmtpReplyCode, SmtpText,
70        ehlo::{SmtpEhlo, SmtpEhloError},
71    },
72    send::*,
73    smtp_try,
74};
75
76/// The SASL mechanism name as it appears on the wire.
77pub const LOGIN: &str = "LOGIN";
78
79/// The AUTH LOGIN command (no formal RFC).
80pub struct SmtpAuthLoginCommand;
81
82impl From<SmtpAuthLoginCommand> for Vec<u8> {
83    fn from(_: SmtpAuthLoginCommand) -> Vec<u8> {
84        b"AUTH LOGIN\r\n".to_vec()
85    }
86}
87
88/// Options for [`SmtpAuthLogin::new`].
89#[derive(Clone, Debug, Default, Eq, PartialEq)]
90pub struct SmtpAuthLoginOptions {
91    /// Ignored (LOGIN has no SASL-IR variant); kept for option
92    /// surface parity with the other SASL coroutines.
93    pub initial_request: bool,
94    /// Whether to refresh capabilities with an `EHLO` after a successful auth.
95    /// Disabled by default because the mechanism does not add a security layer.
96    pub ensure_capabilities: bool,
97}
98
99/// Failure causes during the SMTP AUTH LOGIN exchange.
100#[derive(Debug, Error)]
101pub enum SmtpAuthLoginError {
102    /// The server rejected the authentication.
103    #[error("SMTP AUTH LOGIN failed: rejected {code} {message}")]
104    Rejected {
105        /// The reply code.
106        code: u16,
107        /// The reply text.
108        message: String,
109    },
110    /// The server accepted before the expected challenge.
111    #[error("SMTP AUTH LOGIN failed: server did not send the expected continuation request")]
112    ExpectedContinuationRequest,
113    /// The underlying command exchange failed.
114    #[error("SMTP AUTH LOGIN failed: {0}")]
115    Send(#[from] SmtpCommandSendError),
116    /// The post-authentication capability refresh failed.
117    #[error(transparent)]
118    Ehlo(#[from] SmtpEhloError),
119}
120
121/// I/O-free SMTP AUTH LOGIN coroutine.
122pub struct SmtpAuthLogin {
123    state: State,
124    username: Option<Vec<u8>>,
125    password: Option<Vec<u8>>,
126    domain: Option<SmtpEhloDomain<'static>>,
127    opts: SmtpAuthLoginOptions,
128}
129
130impl SmtpAuthLogin {
131    /// Creates the coroutine from the credentials and the client
132    /// identity used by the capability refresh.
133    pub fn new(
134        login: &str,
135        password: &SecretString,
136        domain: SmtpEhloDomain<'_>,
137        opts: SmtpAuthLoginOptions,
138    ) -> Self {
139        Self {
140            state: State::Command(SmtpCommandSend::new(SmtpAuthLoginCommand)),
141            username: Some(login.as_bytes().to_vec()),
142            password: Some(password.expose_secret().as_bytes().to_vec()),
143            domain: Some(domain.into_static()),
144            opts,
145        }
146    }
147}
148
149impl SmtpCoroutine for SmtpAuthLogin {
150    type Yield = SmtpYield;
151    type Return = Result<(), SmtpAuthLoginError>;
152
153    fn resume(&mut self, arg: Option<&[u8]>) -> SmtpCoroutineState<Self::Yield, Self::Return> {
154        loop {
155            match &mut self.state {
156                State::Command(send) => {
157                    let out = smtp_try!(send, arg);
158
159                    if out.response.code != SmtpReplyCode::AUTH_CONTINUE {
160                        return SmtpCoroutineState::Complete(Err(self
161                            .rejected_or_missing_challenge(
162                                out.response.code,
163                                out.response.text(),
164                            )));
165                    }
166
167                    let username = self.username.take().expect("username taken twice");
168                    let data = SmtpAuthData::r#continue(username.into_boxed_slice());
169                    self.state = State::Username(SmtpCommandSend::new(data));
170                    debug!("challenge received, sending username");
171                }
172                State::Username(send) => {
173                    let out = smtp_try!(send, arg);
174
175                    if out.response.code != SmtpReplyCode::AUTH_CONTINUE {
176                        return SmtpCoroutineState::Complete(Err(self
177                            .rejected_or_missing_challenge(
178                                out.response.code,
179                                out.response.text(),
180                            )));
181                    }
182
183                    let password = self.password.take().expect("password taken twice");
184                    let data = SmtpAuthData::r#continue(password.into_boxed_slice());
185                    self.state = State::Password(SmtpCommandSend::new(data));
186                    debug!("challenge received, sending password");
187                }
188                State::Password(send) => {
189                    let out = smtp_try!(send, arg);
190
191                    if out.response.code == SmtpReplyCode::AUTH_SUCCESSFUL {
192                        self.advance_after_auth();
193                        continue;
194                    }
195
196                    let code = out.response.code.code();
197                    let message = out.response.text().to_string();
198                    return SmtpCoroutineState::Complete(Err(SmtpAuthLoginError::Rejected {
199                        code,
200                        message,
201                    }));
202                }
203                State::Ehlo(ehlo) => {
204                    let _ = smtp_try!(ehlo, arg);
205                    debug!("capabilities refreshed");
206                    return SmtpCoroutineState::Complete(Ok(()));
207                }
208                State::Done => return SmtpCoroutineState::Complete(Ok(())),
209            }
210        }
211    }
212}
213
214impl SmtpAuthLogin {
215    fn advance_after_auth(&mut self) {
216        let _ = self.password.take();
217        debug!("authenticated");
218        if self.opts.ensure_capabilities {
219            let domain = self.domain.take().expect("domain taken twice");
220            self.state = State::Ehlo(SmtpEhlo::new(domain));
221        } else {
222            self.state = State::Done;
223        }
224    }
225
226    fn rejected_or_missing_challenge(
227        &self,
228        code: SmtpReplyCode,
229        text: &SmtpText<'_>,
230    ) -> SmtpAuthLoginError {
231        if code.is_success() {
232            // NOTE: 2xx where we expected 334 (would mean the server
233            // accepted before the challenge).
234            SmtpAuthLoginError::ExpectedContinuationRequest
235        } else {
236            SmtpAuthLoginError::Rejected {
237                code: code.code(),
238                message: text.to_string(),
239            }
240        }
241    }
242}
243
244enum State {
245    Command(SmtpCommandSend<SmtpAuthLoginCommand>),
246    Username(SmtpCommandSend<SmtpAuthData>),
247    Password(SmtpCommandSend<SmtpAuthData>),
248    Ehlo(SmtpEhlo),
249    Done,
250}
251
252impl fmt::Display for State {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        match self {
255            Self::Command(_) => f.write_str("send auth login"),
256            Self::Username(_) => f.write_str("send username"),
257            Self::Password(_) => f.write_str("send password"),
258            Self::Ehlo(_) => f.write_str("refresh capabilities"),
259            Self::Done => f.write_str("done"),
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use alloc::{borrow::Cow, string::ToString, vec::Vec};
267
268    use secrecy::SecretString;
269
270    use crate::{
271        coroutine::*,
272        rfc5321::{SmtpDomain, SmtpEhloDomain},
273        sasl::auth_login::*,
274        send::SmtpCommandSendError,
275    };
276
277    fn domain() -> SmtpEhloDomain<'static> {
278        SmtpEhloDomain::SmtpDomain(SmtpDomain(Cow::Borrowed("example.com")))
279    }
280
281    fn password() -> SecretString {
282        SecretString::from("secret".to_string())
283    }
284
285    #[test]
286    fn success_does_not_send_ehlo_by_default() {
287        let opts = SmtpAuthLoginOptions::default();
288        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
289
290        let bytes = expect_wants_write(&mut auth, None);
291        assert_eq!(bytes, b"AUTH LOGIN\r\n");
292
293        expect_wants_read(&mut auth);
294        let _username = expect_wants_write(&mut auth, Some(b"334 VXNlcm5hbWU6\r\n"));
295
296        expect_wants_read(&mut auth);
297        let _password = expect_wants_write(&mut auth, Some(b"334 UGFzc3dvcmQ6\r\n"));
298
299        expect_wants_read(&mut auth);
300        expect_complete_ok(&mut auth, b"235 OK\r\n");
301    }
302
303    #[test]
304    fn success_with_ehlo_returns_ok() {
305        let opts = SmtpAuthLoginOptions {
306            initial_request: false,
307            ensure_capabilities: true,
308        };
309        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
310
311        let _ = expect_wants_write(&mut auth, None);
312        expect_wants_read(&mut auth);
313        let _ = expect_wants_write(&mut auth, Some(b"334 VXNlcm5hbWU6\r\n"));
314        expect_wants_read(&mut auth);
315        let _ = expect_wants_write(&mut auth, Some(b"334 UGFzc3dvcmQ6\r\n"));
316        expect_wants_read(&mut auth);
317        let _ehlo = expect_wants_write(&mut auth, Some(b"235 OK\r\n"));
318        expect_wants_read(&mut auth);
319        expect_complete_ok(&mut auth, b"250 server.example.com\r\n");
320    }
321
322    #[test]
323    fn success_without_ehlo_returns_ok() {
324        let opts = SmtpAuthLoginOptions {
325            initial_request: false,
326            ensure_capabilities: false,
327        };
328        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
329        let _ = expect_wants_write(&mut auth, None);
330        expect_wants_read(&mut auth);
331        let _ = expect_wants_write(&mut auth, Some(b"334 VXNlcm5hbWU6\r\n"));
332        expect_wants_read(&mut auth);
333        let _ = expect_wants_write(&mut auth, Some(b"334 UGFzc3dvcmQ6\r\n"));
334        expect_wants_read(&mut auth);
335        expect_complete_ok(&mut auth, b"235 OK\r\n");
336    }
337
338    #[test]
339    fn rejected_returns_rejected_error() {
340        let opts = SmtpAuthLoginOptions::default();
341        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
342        let _ = expect_wants_write(&mut auth, None);
343        expect_wants_read(&mut auth);
344        let _ = expect_wants_write(&mut auth, Some(b"334 VXNlcm5hbWU6\r\n"));
345        expect_wants_read(&mut auth);
346        let _ = expect_wants_write(&mut auth, Some(b"334 UGFzc3dvcmQ6\r\n"));
347        expect_wants_read(&mut auth);
348
349        let err = expect_complete_err(&mut auth, b"535 bad credentials\r\n");
350        let SmtpAuthLoginError::Rejected { code, message } = err else {
351            panic!("expected SmtpAuthLoginError::Rejected, got {err:?}");
352        };
353        assert_eq!(code, 535);
354        assert_eq!(message, "bad credentials");
355    }
356
357    #[test]
358    fn missing_first_challenge_returns_rejected_error() {
359        let opts = SmtpAuthLoginOptions::default();
360        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
361        let _ = expect_wants_write(&mut auth, None);
362        expect_wants_read(&mut auth);
363
364        let err = expect_complete_err(&mut auth, b"504 AUTH LOGIN not enabled\r\n");
365        let SmtpAuthLoginError::Rejected { code, .. } = err else {
366            panic!("expected SmtpAuthLoginError::Rejected, got {err:?}");
367        };
368        assert_eq!(code, 504);
369    }
370
371    #[test]
372    fn eof_returns_eof_error() {
373        let opts = SmtpAuthLoginOptions::default();
374        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
375        let _ = expect_wants_write(&mut auth, None);
376        expect_wants_read(&mut auth);
377
378        let err = expect_complete_err(&mut auth, b"");
379        assert!(matches!(
380            err,
381            SmtpAuthLoginError::Send(SmtpCommandSendError::Eof)
382        ));
383    }
384
385    fn expect_wants_write(cor: &mut SmtpAuthLogin, arg: Option<&[u8]>) -> Vec<u8> {
386        match cor.resume(arg) {
387            SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => bytes,
388            state => panic!("expected WantsWrite, got {state:?}"),
389        }
390    }
391
392    fn expect_wants_read(cor: &mut SmtpAuthLogin) {
393        match cor.resume(None) {
394            SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {}
395            state => panic!("expected WantsRead, got {state:?}"),
396        }
397    }
398
399    fn expect_complete_ok(cor: &mut SmtpAuthLogin, reply: &[u8]) {
400        match cor.resume(Some(reply)) {
401            SmtpCoroutineState::Complete(Ok(())) => {}
402            state => panic!("expected Complete(Ok), got {state:?}"),
403        }
404    }
405
406    fn expect_complete_err(cor: &mut SmtpAuthLogin, reply: &[u8]) -> SmtpAuthLoginError {
407        match cor.resume(Some(reply)) {
408            SmtpCoroutineState::Complete(Err(err)) => err,
409            state => panic!("expected Complete(Err), got {state:?}"),
410        }
411    }
412}