Skip to main content

io_imap/sasl/
auth_login.rs

1//! IMAP SASL LOGIN coroutine (legacy two-prompt mechanism, pre-IETF);
2//! supports both the non-IR and SASL-IR (RFC 4959) flows.
3//!
4//! Background: <https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login>
5//! SASL-IR: <https://www.rfc-editor.org/rfc/rfc4959>
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use std::{
11//!     io::{Read, Write},
12//!     net::TcpStream,
13//! };
14//!
15//! use io_imap::{
16//!     codec::fragmentizer::Fragmentizer,
17//!     coroutine::{ImapCoroutine, ImapCoroutineState, ImapYield},
18//!     sasl::auth_login::{ImapAuthLogin, ImapAuthLoginOptions},
19//! };
20//!
21//! // Ready stream needed (TCP-connected, TLS-negotiated)
22//! let mut stream = TcpStream::connect("localhost:143").unwrap();
23//!
24//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
25//! let mut buf = [0u8; 4096];
26//!
27//! let opts = ImapAuthLoginOptions::default();
28//! let mut coroutine = ImapAuthLogin::new("alice", "secret", opts);
29//! let mut arg = None;
30//!
31//! let capability = loop {
32//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
33//!         ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
34//!             stream.write_all(&bytes).unwrap();
35//!         }
36//!         ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
37//!             let n = stream.read(&mut buf).unwrap();
38//!             arg = Some(&buf[..n]);
39//!         }
40//!         ImapCoroutineState::Complete(Ok(capability)) => break capability,
41//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
42//!     }
43//! };
44//!
45//! println!("{capability:?}");
46//! ```
47
48use core::{fmt, mem};
49
50use alloc::{
51    string::{String, ToString},
52    vec::Vec,
53};
54
55use imap_codec::{
56    AuthenticateDataCodec, CommandCodec,
57    fragmentizer::Fragmentizer,
58    imap_types::{
59        auth::{AuthMechanism, AuthenticateData},
60        command::{Command, CommandBody},
61        core::{IString, NString, TagGenerator},
62        response::{Capability, Code, Data, StatusBody, StatusKind, Tagged},
63        secret::Secret,
64    },
65};
66use log::{debug, trace};
67use thiserror::Error;
68
69use crate::{coroutine::*, imap_try, rfc2971::id::*, rfc3501::capability::*, send::*};
70
71/// Failure causes during the SASL LOGIN flow.
72#[derive(Clone, Debug, Error)]
73pub enum ImapAuthLoginError {
74    /// The server rejected authentication with a tagged NO.
75    #[error("IMAP AUTHENTICATE LOGIN failed: NO {0}")]
76    No(String),
77    /// The server rejected the AUTHENTICATE command with a tagged BAD.
78    #[error("IMAP AUTHENTICATE LOGIN failed: BAD {0}")]
79    Bad(String),
80    /// The server closed the connection with an untagged BYE.
81    #[error("IMAP AUTHENTICATE LOGIN failed: BYE {0}")]
82    Bye(String),
83    /// The server never returned the final tagged response.
84    #[error("IMAP AUTHENTICATE LOGIN failed: server did not return a tagged response")]
85    MissingTagged,
86    /// The server never sent the expected continuation request.
87    #[error(
88        "IMAP AUTHENTICATE LOGIN failed: server did not send the expected continuation request"
89    )]
90    ExpectedContinuationRequest,
91    /// The server sent a continuation request after the exchange ended.
92    #[error("IMAP AUTHENTICATE LOGIN failed: server sent an unexpected continuation request")]
93    UnexpectedContinuationRequest,
94    /// The server returned OK before the mechanism could complete.
95    #[error(
96        "IMAP AUTHENTICATE LOGIN failed: server returned OK before the mechanism could complete"
97    )]
98    UnexpectedOk,
99    /// The underlying send coroutine failed.
100    #[error("IMAP AUTHENTICATE LOGIN failed: {0}")]
101    Send(#[from] ImapSendError),
102    /// The follow-up CAPABILITY command failed.
103    #[error(transparent)]
104    Capability(#[from] ImapCapabilityGetError),
105    /// The follow-up ID command failed.
106    #[error(transparent)]
107    ServerId(#[from] ImapServerIdError),
108}
109
110/// Options for [`ImapAuthLogin::new`].
111#[derive(Clone, Debug, Default, Eq, PartialEq)]
112pub struct ImapAuthLoginOptions {
113    /// `true` selects SASL-IR (RFC 4959, inline username);
114    /// `false` selects the non-IR two-prompt flow.
115    pub initial_request: bool,
116    /// Fetch CAPABILITY after authentication when the tagged response
117    /// carries no capability data. Defaults to `false`.
118    pub ensure_capabilities: bool,
119    /// Chain an RFC 2971 ID round-trip right after authentication, as
120    /// required by some providers.
121    ///
122    /// Defaults to `None` (no ID); an empty list sends ID NIL.
123    pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
124}
125
126/// I/O-free SASL LOGIN coroutine.
127pub struct ImapAuthLogin {
128    state: State,
129    password: String,
130    observed: Vec<Capability<'static>>,
131    opts: ImapAuthLoginOptions,
132}
133
134impl ImapAuthLogin {
135    /// Builds a SASL LOGIN coroutine authenticating `user` with
136    /// `password`.
137    ///
138    /// Depending on `opts.initial_request`, the username goes inline
139    /// with the AUTHENTICATE command (SASL-IR) or is uploaded after
140    /// the first server prompt; the password always follows a prompt.
141    pub fn new(
142        user: impl AsRef<str>,
143        password: impl AsRef<str>,
144        opts: ImapAuthLoginOptions,
145    ) -> Self {
146        let user = user.as_ref();
147        let password = password.as_ref().to_string();
148        let tag = TagGenerator::new().generate();
149
150        let state = if opts.initial_request {
151            let body = CommandBody::Authenticate {
152                mechanism: AuthMechanism::Login,
153                initial_response: Some(Secret::new(user.as_bytes().to_vec().into())),
154            };
155            let cmd = Command { tag, body };
156            trace!("send IMAP command {cmd:?}");
157            State::SendIr(ImapSend::new(CommandCodec::new(), cmd))
158        } else {
159            let body = CommandBody::Authenticate {
160                mechanism: AuthMechanism::Login,
161                initial_response: None,
162            };
163            let cmd = Command { tag, body };
164            trace!("send IMAP command {cmd:?}");
165            State::Send {
166                send: ImapSend::new(CommandCodec::new(), cmd),
167                user: user.to_string(),
168            }
169        };
170
171        Self {
172            state,
173            password,
174            observed: Vec::new(),
175            opts,
176        }
177    }
178
179    fn wants_capability(
180        &mut self,
181        code: Option<Code<'static>>,
182        data: Vec<Data<'static>>,
183        untagged: Vec<StatusBody<'static>>,
184    ) -> Option<State> {
185        let mut new_capability = None;
186
187        if let Some(Code::Capability(capability)) = code {
188            new_capability.replace(capability);
189        }
190
191        for data in data {
192            if let Data::Capability(capability) = data {
193                new_capability.replace(capability);
194            }
195        }
196
197        for StatusBody { code, .. } in untagged {
198            if let Some(Code::Capability(capability)) = code {
199                new_capability.replace(capability);
200            }
201        }
202
203        if let Some(capability) = new_capability {
204            self.observed = capability.into_iter().collect();
205        }
206
207        (self.opts.ensure_capabilities && self.observed.is_empty())
208            .then(|| State::Capability(ImapCapabilityGet::new()))
209    }
210
211    fn wants_id(&mut self) -> Option<State> {
212        let params = self.opts.auto_id.take()?;
213        let wire = (!params.is_empty()).then_some(params);
214        Some(State::Id(ImapServerId::new(ImapServerIdOptions {
215            parameters: wire,
216        })))
217    }
218
219    fn next_continue_password(&mut self) -> State {
220        let password = mem::take(&mut self.password).into_bytes();
221        let auth = AuthenticateData::r#continue(password);
222        let codec = AuthenticateDataCodec::new();
223        State::ContinuePassword(ImapSend::new(codec, auth))
224    }
225}
226
227impl ImapCoroutine for ImapAuthLogin {
228    type Yield = ImapYield;
229    type Return = Result<Vec<Capability<'static>>, ImapAuthLoginError>;
230
231    fn resume(
232        &mut self,
233        fragmentizer: &mut Fragmentizer,
234        arg: Option<&[u8]>,
235    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
236        loop {
237            match &mut self.state {
238                State::Send { send, user } => {
239                    let out = imap_try!(send, fragmentizer, arg);
240
241                    if let Some(bye) = out.bye {
242                        let err = ImapAuthLoginError::Bye(bye.text.to_string());
243                        return ImapCoroutineState::Complete(Err(err));
244                    }
245
246                    if out.continuation_request.is_some() {
247                        let user = mem::take(user).into_bytes();
248                        let auth = AuthenticateData::r#continue(user);
249                        let codec = AuthenticateDataCodec::new();
250                        self.state = State::ContinueUsername(ImapSend::new(codec, auth));
251                        debug!("{}", self.state);
252                        continue;
253                    }
254
255                    if let Some(Tagged { body, .. }) = out.tagged {
256                        let err = match body.kind {
257                            StatusKind::Ok => ImapAuthLoginError::UnexpectedOk,
258                            StatusKind::No => ImapAuthLoginError::No(body.text.to_string()),
259                            StatusKind::Bad => ImapAuthLoginError::Bad(body.text.to_string()),
260                        };
261
262                        return ImapCoroutineState::Complete(Err(err));
263                    }
264
265                    let err = ImapAuthLoginError::ExpectedContinuationRequest;
266                    return ImapCoroutineState::Complete(Err(err));
267                }
268                State::SendIr(send) => {
269                    let out = imap_try!(send, fragmentizer, arg);
270
271                    if let Some(bye) = out.bye {
272                        let err = ImapAuthLoginError::Bye(bye.text.to_string());
273                        return ImapCoroutineState::Complete(Err(err));
274                    }
275
276                    if out.continuation_request.is_some() {
277                        self.state = self.next_continue_password();
278                        debug!("{}", self.state);
279                        continue;
280                    }
281
282                    if let Some(Tagged { body, .. }) = out.tagged {
283                        let err = match body.kind {
284                            StatusKind::Ok => ImapAuthLoginError::UnexpectedOk,
285                            StatusKind::No => ImapAuthLoginError::No(body.text.to_string()),
286                            StatusKind::Bad => ImapAuthLoginError::Bad(body.text.to_string()),
287                        };
288
289                        return ImapCoroutineState::Complete(Err(err));
290                    }
291
292                    let err = ImapAuthLoginError::ExpectedContinuationRequest;
293                    return ImapCoroutineState::Complete(Err(err));
294                }
295                State::ContinueUsername(send) => {
296                    let out = imap_try!(send, fragmentizer, arg);
297
298                    if let Some(bye) = out.bye {
299                        let err = ImapAuthLoginError::Bye(bye.text.to_string());
300                        return ImapCoroutineState::Complete(Err(err));
301                    }
302
303                    if out.continuation_request.is_some() {
304                        self.state = self.next_continue_password();
305                        debug!("{}", self.state);
306                        continue;
307                    }
308
309                    if let Some(Tagged { body, .. }) = out.tagged {
310                        let err = match body.kind {
311                            StatusKind::Ok => ImapAuthLoginError::UnexpectedOk,
312                            StatusKind::No => ImapAuthLoginError::No(body.text.to_string()),
313                            StatusKind::Bad => ImapAuthLoginError::Bad(body.text.to_string()),
314                        };
315
316                        return ImapCoroutineState::Complete(Err(err));
317                    }
318
319                    let err = ImapAuthLoginError::ExpectedContinuationRequest;
320                    return ImapCoroutineState::Complete(Err(err));
321                }
322                State::ContinuePassword(send) => {
323                    let out = imap_try!(send, fragmentizer, arg);
324
325                    if let Some(bye) = out.bye {
326                        let err = ImapAuthLoginError::Bye(bye.text.to_string());
327                        return ImapCoroutineState::Complete(Err(err));
328                    }
329
330                    if out.continuation_request.is_some() {
331                        let err = ImapAuthLoginError::UnexpectedContinuationRequest;
332                        return ImapCoroutineState::Complete(Err(err));
333                    }
334
335                    let Some(Tagged { body, .. }) = out.tagged else {
336                        let err = ImapAuthLoginError::MissingTagged;
337                        return ImapCoroutineState::Complete(Err(err));
338                    };
339
340                    let code = match body.kind {
341                        StatusKind::Ok => body.code,
342                        StatusKind::No => {
343                            let err = ImapAuthLoginError::No(body.text.to_string());
344                            return ImapCoroutineState::Complete(Err(err));
345                        }
346                        StatusKind::Bad => {
347                            let err = ImapAuthLoginError::Bad(body.text.to_string());
348                            return ImapCoroutineState::Complete(Err(err));
349                        }
350                    };
351
352                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
353                        self.state = next;
354                        debug!("{}", self.state);
355                        continue;
356                    }
357
358                    if let Some(next) = self.wants_id() {
359                        self.state = next;
360                        debug!("{}", self.state);
361                        continue;
362                    }
363
364                    let capability = mem::take(&mut self.observed);
365                    return ImapCoroutineState::Complete(Ok(capability));
366                }
367                State::Capability(capability) => {
368                    self.observed = imap_try!(capability, fragmentizer, arg);
369
370                    if let Some(next) = self.wants_id() {
371                        self.state = next;
372                        debug!("{}", self.state);
373                        continue;
374                    }
375
376                    let capability = mem::take(&mut self.observed);
377                    return ImapCoroutineState::Complete(Ok(capability));
378                }
379                State::Id(id) => {
380                    imap_try!(id, fragmentizer, arg);
381                    let capability = mem::take(&mut self.observed);
382                    return ImapCoroutineState::Complete(Ok(capability));
383                }
384            }
385        }
386    }
387}
388
389enum State {
390    Send {
391        send: ImapSend<CommandCodec>,
392        user: String,
393    },
394    SendIr(ImapSend<CommandCodec>),
395    ContinueUsername(ImapSend<AuthenticateDataCodec>),
396    ContinuePassword(ImapSend<AuthenticateDataCodec>),
397    Capability(ImapCapabilityGet),
398    Id(ImapServerId),
399}
400
401impl fmt::Display for State {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        match self {
404            Self::Send { .. } => f.write_str("send auth"),
405            Self::SendIr(_) => f.write_str("send auth with ir"),
406            Self::ContinueUsername(_) => f.write_str("send username"),
407            Self::ContinuePassword(_) => f.write_str("send password"),
408            Self::Capability(_) => f.write_str("fetch capabilities"),
409            Self::Id(_) => f.write_str("send id"),
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use core::str;
417
418    use alloc::format;
419
420    use crate::sasl::auth_login::*;
421
422    #[test]
423    fn ir_success_returns_ok() {
424        let opts = ImapAuthLoginOptions {
425            initial_request: true,
426            ..Default::default()
427        };
428
429        let mut auth = ImapAuthLogin::new("alice", "secret", opts);
430        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
431
432        let bytes = expect_wants_write(&mut auth, &mut frag, None);
433        let line = str::from_utf8(&bytes).expect("utf8 command");
434        let tag = first_word(line);
435        assert!(line.contains("AUTHENTICATE LOGIN "));
436
437        expect_wants_read(&mut auth, &mut frag);
438
439        // NOTE: "Password:" base64 = "UGFzc3dvcmQ6".
440        let pass = expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
441        assert!(pass.ends_with(b"\r\n"));
442
443        expect_wants_read(&mut auth, &mut frag);
444
445        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
446        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
447    }
448
449    #[test]
450    fn ir_invalid_password_returns_no_error() {
451        let opts = ImapAuthLoginOptions {
452            initial_request: true,
453            ..Default::default()
454        };
455
456        let mut auth = ImapAuthLogin::new("alice", "wrong", opts);
457        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
458
459        let bytes = expect_wants_write(&mut auth, &mut frag, None);
460        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
461
462        expect_wants_read(&mut auth, &mut frag);
463        expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
464        expect_wants_read(&mut auth, &mut frag);
465
466        let reply = format!("{tag} NO authentication failed\r\n");
467        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
468        let ImapAuthLoginError::No(text) = err else {
469            panic!("expected ImapAuthLoginError::No, got {err:?}");
470        };
471        assert_eq!(text, "authentication failed");
472    }
473
474    #[test]
475    fn ir_tagged_bad_returns_bad_error() {
476        let opts = ImapAuthLoginOptions {
477            initial_request: true,
478            ..Default::default()
479        };
480
481        let mut auth = ImapAuthLogin::new("alice", "secret", opts);
482        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
483
484        let bytes = expect_wants_write(&mut auth, &mut frag, None);
485        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
486
487        expect_wants_read(&mut auth, &mut frag);
488
489        let reply = format!("{tag} BAD AUTHENTICATE not enabled\r\n");
490        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
491        let ImapAuthLoginError::Bad(text) = err else {
492            panic!("expected ImapAuthLoginError::Bad, got {err:?}");
493        };
494        assert_eq!(text, "AUTHENTICATE not enabled");
495    }
496
497    #[test]
498    fn non_ir_success_returns_ok() {
499        let opts = ImapAuthLoginOptions::default();
500        let mut auth = ImapAuthLogin::new("alice", "secret", opts);
501        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
502
503        let bytes = expect_wants_write(&mut auth, &mut frag, None);
504        let line = str::from_utf8(&bytes).expect("utf8 command");
505        let tag = first_word(line);
506        assert!(line.trim_end().ends_with("AUTHENTICATE LOGIN"));
507
508        expect_wants_read(&mut auth, &mut frag);
509
510        // NOTE: "Username:" base64 = "VXNlcm5hbWU6".
511        let user = expect_wants_write(&mut auth, &mut frag, Some(b"+ VXNlcm5hbWU6\r\n"));
512        assert!(user.ends_with(b"\r\n"));
513
514        expect_wants_read(&mut auth, &mut frag);
515
516        // NOTE: "Password:" base64 = "UGFzc3dvcmQ6".
517        let pass = expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
518        assert!(pass.ends_with(b"\r\n"));
519
520        expect_wants_read(&mut auth, &mut frag);
521
522        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
523        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
524    }
525
526    #[test]
527    fn non_ir_invalid_password_returns_no_error() {
528        let opts = ImapAuthLoginOptions::default();
529        let mut auth = ImapAuthLogin::new("alice", "wrong", opts);
530        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
531
532        let bytes = expect_wants_write(&mut auth, &mut frag, None);
533        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
534
535        expect_wants_read(&mut auth, &mut frag);
536        expect_wants_write(&mut auth, &mut frag, Some(b"+ VXNlcm5hbWU6\r\n"));
537        expect_wants_read(&mut auth, &mut frag);
538        expect_wants_write(&mut auth, &mut frag, Some(b"+ UGFzc3dvcmQ6\r\n"));
539        expect_wants_read(&mut auth, &mut frag);
540
541        let reply = format!("{tag} NO authentication failed\r\n");
542        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
543        let ImapAuthLoginError::No(text) = err else {
544            panic!("expected ImapAuthLoginError::No, got {err:?}");
545        };
546        assert_eq!(text, "authentication failed");
547    }
548
549    fn expect_wants_write(
550        cor: &mut ImapAuthLogin,
551        frag: &mut Fragmentizer,
552        arg: Option<&[u8]>,
553    ) -> Vec<u8> {
554        match cor.resume(frag, arg) {
555            ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => bytes,
556            state => panic!("expected WantsWrite, got {state:?}"),
557        }
558    }
559
560    fn expect_wants_read(cor: &mut ImapAuthLogin, frag: &mut Fragmentizer) {
561        match cor.resume(frag, None) {
562            ImapCoroutineState::Yielded(ImapYield::WantsRead) => {}
563            state => panic!("expected WantsRead, got {state:?}"),
564        }
565    }
566
567    fn expect_complete_ok(cor: &mut ImapAuthLogin, frag: &mut Fragmentizer, reply: &[u8]) {
568        match cor.resume(frag, Some(reply)) {
569            ImapCoroutineState::Complete(Ok(_)) => {}
570            state => panic!("expected Complete(Ok), got {state:?}"),
571        }
572    }
573
574    fn expect_complete_err(
575        cor: &mut ImapAuthLogin,
576        frag: &mut Fragmentizer,
577        reply: &[u8],
578    ) -> ImapAuthLoginError {
579        match cor.resume(frag, Some(reply)) {
580            ImapCoroutineState::Complete(Err(err)) => err,
581            state => panic!("expected Complete(Err), got {state:?}"),
582        }
583    }
584
585    fn first_word(line: &str) -> &str {
586        line.split_whitespace()
587            .next()
588            .expect("first whitespace-separated token")
589    }
590}