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::types::{domain::Domain, ehlo_domain::EhloDomain},
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 = EhloDomain::Domain(Domain(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::trace;
62use secrecy::{ExposeSecret, SecretString};
63use thiserror::Error;
64
65use crate::{
66    coroutine::*,
67    rfc4954::auth_data::SmtpAuthData,
68    rfc5321::{
69        ehlo::{SmtpEhlo, SmtpEhloError},
70        types::{ehlo_domain::EhloDomain, reply_code::ReplyCode, text::Text},
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, 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    /// Refresh capabilities with an `EHLO` after a successful auth.
95    pub ensure_capabilities: bool,
96}
97
98impl Default for SmtpAuthLoginOptions {
99    fn default() -> Self {
100        Self {
101            initial_request: false,
102            ensure_capabilities: true,
103        }
104    }
105}
106
107/// Failure causes during the SMTP AUTH LOGIN exchange.
108#[derive(Debug, Error)]
109pub enum SmtpAuthLoginError {
110    #[error("SMTP AUTH LOGIN failed: rejected {code} {message}")]
111    Rejected { code: u16, message: String },
112    #[error("SMTP AUTH LOGIN failed: server did not send the expected continuation request")]
113    ExpectedContinuationRequest,
114    #[error("SMTP AUTH LOGIN failed: {0}")]
115    Send(#[from] SendSmtpCommandError),
116    #[error(transparent)]
117    Ehlo(#[from] SmtpEhloError),
118}
119
120/// I/O-free SMTP AUTH LOGIN coroutine.
121pub struct SmtpAuthLogin {
122    state: State,
123    username: Option<Vec<u8>>,
124    password: Option<Vec<u8>>,
125    domain: Option<EhloDomain<'static>>,
126    opts: SmtpAuthLoginOptions,
127}
128
129impl SmtpAuthLogin {
130    pub fn new(
131        login: &str,
132        password: &SecretString,
133        domain: EhloDomain<'_>,
134        opts: SmtpAuthLoginOptions,
135    ) -> Self {
136        Self {
137            state: State::Command(SendSmtpCommand::new(SmtpAuthLoginCommand)),
138            username: Some(login.as_bytes().to_vec()),
139            password: Some(password.expose_secret().as_bytes().to_vec()),
140            domain: Some(domain.into_static()),
141            opts,
142        }
143    }
144}
145
146impl SmtpCoroutine for SmtpAuthLogin {
147    type Yield = SmtpYield;
148    type Return = Result<(), SmtpAuthLoginError>;
149
150    fn resume(&mut self, arg: Option<&[u8]>) -> SmtpCoroutineState<Self::Yield, Self::Return> {
151        loop {
152            trace!("auth login: {}", self.state);
153
154            match &mut self.state {
155                State::Command(send) => {
156                    let out = smtp_try!(send, arg);
157
158                    if out.response.code != ReplyCode::AUTH_CONTINUE {
159                        return SmtpCoroutineState::Complete(Err(self
160                            .rejected_or_missing_challenge(
161                                out.response.code,
162                                out.response.text(),
163                            )));
164                    }
165
166                    let username = self.username.take().expect("username taken twice");
167                    let data = SmtpAuthData::r#continue(username.into_boxed_slice());
168                    self.state = State::Username(SendSmtpCommand::new(data));
169                }
170                State::Username(send) => {
171                    let out = smtp_try!(send, arg);
172
173                    if out.response.code != ReplyCode::AUTH_CONTINUE {
174                        return SmtpCoroutineState::Complete(Err(self
175                            .rejected_or_missing_challenge(
176                                out.response.code,
177                                out.response.text(),
178                            )));
179                    }
180
181                    let password = self.password.take().expect("password taken twice");
182                    let data = SmtpAuthData::r#continue(password.into_boxed_slice());
183                    self.state = State::Password(SendSmtpCommand::new(data));
184                }
185                State::Password(send) => {
186                    let out = smtp_try!(send, arg);
187
188                    if out.response.code == ReplyCode::AUTH_SUCCESSFUL {
189                        self.advance_after_auth();
190                        continue;
191                    }
192
193                    let code = out.response.code.code();
194                    let message = out.response.text().to_string();
195                    return SmtpCoroutineState::Complete(Err(SmtpAuthLoginError::Rejected {
196                        code,
197                        message,
198                    }));
199                }
200                State::Ehlo(ehlo) => {
201                    let _ = smtp_try!(ehlo, arg);
202                    return SmtpCoroutineState::Complete(Ok(()));
203                }
204                State::Done => return SmtpCoroutineState::Complete(Ok(())),
205            }
206        }
207    }
208}
209
210impl SmtpAuthLogin {
211    fn advance_after_auth(&mut self) {
212        let _ = self.password.take();
213        if self.opts.ensure_capabilities {
214            let domain = self.domain.take().expect("domain taken twice");
215            self.state = State::Ehlo(SmtpEhlo::new(domain));
216        } else {
217            self.state = State::Done;
218        }
219    }
220
221    fn rejected_or_missing_challenge(
222        &self,
223        code: ReplyCode,
224        text: &Text<'_>,
225    ) -> SmtpAuthLoginError {
226        if code.is_success() {
227            // 2xx where we expected 334 (would mean the server
228            // accepted before the challenge).
229            SmtpAuthLoginError::ExpectedContinuationRequest
230        } else {
231            SmtpAuthLoginError::Rejected {
232                code: code.code(),
233                message: text.to_string(),
234            }
235        }
236    }
237}
238
239enum State {
240    Command(SendSmtpCommand<SmtpAuthLoginCommand>),
241    Username(SendSmtpCommand<SmtpAuthData>),
242    Password(SendSmtpCommand<SmtpAuthData>),
243    Ehlo(SmtpEhlo),
244    Done,
245}
246
247impl fmt::Display for State {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        match self {
250            Self::Command(_) => f.write_str("send auth login"),
251            Self::Username(_) => f.write_str("send username"),
252            Self::Password(_) => f.write_str("send password"),
253            Self::Ehlo(_) => f.write_str("refresh capabilities"),
254            Self::Done => f.write_str("done"),
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use alloc::borrow::Cow;
262
263    use crate::rfc5321::types::domain::Domain;
264
265    use super::*;
266
267    fn domain() -> EhloDomain<'static> {
268        EhloDomain::Domain(Domain(Cow::Borrowed("example.com")))
269    }
270
271    fn password() -> SecretString {
272        SecretString::from("secret".to_string())
273    }
274
275    #[test]
276    fn success_then_ehlo_returns_ok() {
277        let opts = SmtpAuthLoginOptions::default();
278        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
279
280        let bytes = expect_wants_write(&mut auth, None);
281        assert_eq!(bytes, b"AUTH LOGIN\r\n");
282
283        expect_wants_read(&mut auth);
284        let _username = expect_wants_write(&mut auth, Some(b"334 VXNlcm5hbWU6\r\n"));
285
286        expect_wants_read(&mut auth);
287        let _password = expect_wants_write(&mut auth, Some(b"334 UGFzc3dvcmQ6\r\n"));
288
289        expect_wants_read(&mut auth);
290        let _ehlo = expect_wants_write(&mut auth, Some(b"235 OK\r\n"));
291
292        expect_wants_read(&mut auth);
293        expect_complete_ok(&mut auth, b"250 server.example.com\r\n");
294    }
295
296    #[test]
297    fn success_without_ehlo_returns_ok() {
298        let opts = SmtpAuthLoginOptions {
299            initial_request: false,
300            ensure_capabilities: false,
301        };
302        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
303        let _ = expect_wants_write(&mut auth, None);
304        expect_wants_read(&mut auth);
305        let _ = expect_wants_write(&mut auth, Some(b"334 VXNlcm5hbWU6\r\n"));
306        expect_wants_read(&mut auth);
307        let _ = expect_wants_write(&mut auth, Some(b"334 UGFzc3dvcmQ6\r\n"));
308        expect_wants_read(&mut auth);
309        expect_complete_ok(&mut auth, b"235 OK\r\n");
310    }
311
312    #[test]
313    fn rejected_returns_rejected_error() {
314        let opts = SmtpAuthLoginOptions::default();
315        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
316        let _ = expect_wants_write(&mut auth, None);
317        expect_wants_read(&mut auth);
318        let _ = expect_wants_write(&mut auth, Some(b"334 VXNlcm5hbWU6\r\n"));
319        expect_wants_read(&mut auth);
320        let _ = expect_wants_write(&mut auth, Some(b"334 UGFzc3dvcmQ6\r\n"));
321        expect_wants_read(&mut auth);
322
323        let err = expect_complete_err(&mut auth, b"535 bad credentials\r\n");
324        let SmtpAuthLoginError::Rejected { code, message } = err else {
325            panic!("expected SmtpAuthLoginError::Rejected, got {err:?}");
326        };
327        assert_eq!(code, 535);
328        assert_eq!(message, "bad credentials");
329    }
330
331    #[test]
332    fn missing_first_challenge_returns_rejected_error() {
333        let opts = SmtpAuthLoginOptions::default();
334        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
335        let _ = expect_wants_write(&mut auth, None);
336        expect_wants_read(&mut auth);
337
338        let err = expect_complete_err(&mut auth, b"504 AUTH LOGIN not enabled\r\n");
339        let SmtpAuthLoginError::Rejected { code, .. } = err else {
340            panic!("expected SmtpAuthLoginError::Rejected, got {err:?}");
341        };
342        assert_eq!(code, 504);
343    }
344
345    #[test]
346    fn eof_returns_eof_error() {
347        let opts = SmtpAuthLoginOptions::default();
348        let mut auth = SmtpAuthLogin::new("alice", &password(), domain(), opts);
349        let _ = expect_wants_write(&mut auth, None);
350        expect_wants_read(&mut auth);
351
352        let err = expect_complete_err(&mut auth, b"");
353        assert!(matches!(
354            err,
355            SmtpAuthLoginError::Send(SendSmtpCommandError::Eof)
356        ));
357    }
358
359    // --- utils
360
361    fn expect_wants_write(cor: &mut SmtpAuthLogin, arg: Option<&[u8]>) -> Vec<u8> {
362        match cor.resume(arg) {
363            SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => bytes,
364            state => panic!("expected WantsWrite, got {state:?}"),
365        }
366    }
367
368    fn expect_wants_read(cor: &mut SmtpAuthLogin) {
369        match cor.resume(None) {
370            SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {}
371            state => panic!("expected WantsRead, got {state:?}"),
372        }
373    }
374
375    fn expect_complete_ok(cor: &mut SmtpAuthLogin, reply: &[u8]) {
376        match cor.resume(Some(reply)) {
377            SmtpCoroutineState::Complete(Ok(())) => {}
378            state => panic!("expected Complete(Ok), got {state:?}"),
379        }
380    }
381
382    fn expect_complete_err(cor: &mut SmtpAuthLogin, reply: &[u8]) -> SmtpAuthLoginError {
383        match cor.resume(Some(reply)) {
384            SmtpCoroutineState::Complete(Err(err)) => err,
385            state => panic!("expected Complete(Err), got {state:?}"),
386        }
387    }
388}