Skip to main content

daaki_imap/connection/
auth.rs

1#![allow(clippy::wildcard_imports)]
2use super::*;
3
4impl ImapConnection {
5    // -----------------------------------------------------------------------
6    // Authentication
7    // -----------------------------------------------------------------------
8
9    /// Authenticate with LOGIN command (RFC 3501 Section 6.2.3).
10    ///
11    /// **Note:** LOGIN is deprecated in `IMAP4rev2` (RFC 9051 Section 2.2).
12    /// Prefer [`authenticate_plain`](Self::authenticate_plain) when the server
13    /// advertises `AUTH=PLAIN`, which is the standard SASL mechanism and works
14    /// with all servers including those that don't support LOGIN (e.g. Stalwart).
15    pub async fn login(&self, user: &str, pass: &str, timeout: Duration) -> Result<(), Error> {
16        use super::dispatch::LoginConsumer;
17
18        // Validate state and capabilities from the driver's snapshot.
19        {
20            let snap = self.state_rx.borrow();
21            // RFC 3501 Section 6.2.3: LOGIN is only valid in
22            // NotAuthenticated state.
23            if snap.session_state != SessionState::NotAuthenticated {
24                return Err(Error::Protocol(format!(
25                    "command not valid in {:?} state (expected one of \
26                     [{:?}])",
27                    snap.session_state,
28                    SessionState::NotAuthenticated,
29                )));
30            }
31            // RFC 3501 Section 6.2.3: "If the server advertises the
32            // LOGINDISABLED capability [...] the LOGIN command MUST NOT
33            // be used."
34            if snap
35                .capabilities
36                .iter()
37                .any(|c| matches!(c, Capability::LoginDisabled))
38            {
39                return Err(Error::Protocol(
40                    "LOGIN disabled by server (LOGINDISABLED capability \
41                     advertised, RFC 3501 Section 6.2.3)"
42                        .into(),
43                ));
44            }
45
46            // RFC 9051 Section 2.2: warn when a better alternative
47            // exists. Use the broader check (raw capability presence)
48            // rather than is_rev2 so that dual-mode servers that
49            // advertise rev2 but haven't had ENABLE issued yet still
50            // trigger the warning.
51            let has_rev2 = is_rev2_from_snapshot(&snap)
52                || snap
53                    .capabilities
54                    .iter()
55                    .any(|c| matches!(c, Capability::Imap4Rev2));
56            let has_auth_plain = snap
57                .capabilities
58                .iter()
59                .any(|c| matches!(c, Capability::Auth(ref m) if m.eq_ignore_ascii_case("PLAIN")));
60            drop(snap);
61            if has_rev2 && has_auth_plain {
62                warn!(
63                    "LOGIN is deprecated on IMAP4rev2 servers \
64                     (RFC 9051 Section 2.2); prefer \
65                     authenticate_plain() — the server advertises \
66                     AUTH=PLAIN"
67                );
68            }
69        }
70
71        // RFC 6855 Section 5: LOGIN must not be used for non-ASCII
72        // credentials. Clients must use AUTHENTICATE instead.
73        if !user.is_ascii() || !pass.is_ascii() {
74            return Err(Error::Protocol(
75                "LOGIN does not support non-ASCII credentials; \
76                 use AUTHENTICATE (RFC 6855 Section 5)"
77                    .into(),
78            ));
79        }
80
81        let deadline = tokio::time::Instant::now() + timeout;
82        let cmd = Command::Login {
83            user: user.to_owned(),
84            pass: pass.to_owned(),
85        };
86
87        let caps_provided =
88            tokio::time::timeout(timeout, self.submit_regular(cmd, LoginConsumer::default()))
89                .await
90                .map_err(|_| Error::Timeout)??;
91
92        self.complete_auth(caps_provided, deadline).await
93    }
94
95    /// Authenticate with SASL PLAIN mechanism (RFC 4616).
96    ///
97    /// Constructs the PLAIN payload (`\0user\0pass`) and sends it via
98    /// AUTHENTICATE PLAIN, using SASL-IR (RFC 4959) when available.
99    pub async fn authenticate_plain(
100        &self,
101        user: &str,
102        pass: &str,
103        timeout: Duration,
104    ) -> Result<(), Error> {
105        use super::dispatch::AuthenticatePlainConsumer;
106        use base64::Engine;
107
108        // Validate state and build SASL payload from the snapshot.
109        let (encoded, has_sasl_ir) = {
110            let snap = self.state_rx.borrow();
111            if snap.session_state != SessionState::NotAuthenticated {
112                return Err(Error::Protocol(format!(
113                    "command not valid in {:?} state (expected one of \
114                     [{:?}])",
115                    snap.session_state,
116                    SessionState::NotAuthenticated,
117                )));
118            }
119            // RFC 3501 Section 6.2.2: verify the server advertises
120            // AUTH=PLAIN before sending credentials.
121            if !snap
122                .capabilities
123                .contains(&Capability::Auth("PLAIN".into()))
124            {
125                return Err(Error::MissingCapability("AUTH=PLAIN".into()));
126            }
127
128            // PLAIN payload per RFC 4616 Section 2:
129            // [authzid] NUL authcid NUL passwd — authzid is empty.
130            let mut payload = Vec::with_capacity(1 + user.len() + 1 + pass.len());
131            payload.push(b'\0');
132            payload.extend_from_slice(user.as_bytes());
133            payload.push(b'\0');
134            payload.extend_from_slice(pass.as_bytes());
135            let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
136
137            let has_sasl_ir =
138                snap.capabilities.contains(&Capability::SaslIr) || is_rev2_from_snapshot(&snap);
139            drop(snap);
140            (encoded, has_sasl_ir)
141        };
142
143        let cmd = Command::Authenticate {
144            mechanism: "PLAIN".to_owned(),
145            initial_response: if has_sasl_ir {
146                Some(encoded.clone())
147            } else {
148                None
149            },
150        };
151
152        let consumer = AuthenticatePlainConsumer::new(encoded, has_sasl_ir);
153        let deadline = tokio::time::Instant::now() + timeout;
154        let caps_provided =
155            tokio::time::timeout(timeout, self.submit_with_continuations(cmd, consumer))
156                .await
157                .map_err(|_| Error::Timeout)??;
158
159        self.complete_auth(caps_provided, deadline).await
160    }
161
162    /// Authenticate with XOAUTH2 SASL mechanism (Google-defined, not an
163    /// IETF RFC).
164    ///
165    /// Uses SASL-IR (RFC 4959 Section 3) if the server advertises it.
166    pub async fn authenticate_xoauth2(
167        &self,
168        user: &str,
169        token: &str,
170        timeout: Duration,
171    ) -> Result<(), Error> {
172        use super::dispatch::AuthenticateXoauth2Consumer;
173        use base64::Engine;
174
175        // Validate state and build XOAUTH2 payload from the snapshot.
176        let (encoded, has_sasl_ir) = {
177            let snap = self.state_rx.borrow();
178            if snap.session_state != SessionState::NotAuthenticated {
179                return Err(Error::Protocol(format!(
180                    "command not valid in {:?} state (expected one of \
181                     [{:?}])",
182                    snap.session_state,
183                    SessionState::NotAuthenticated,
184                )));
185            }
186            // RFC 3501 Section 6.2.2: verify the server advertises
187            // AUTH=XOAUTH2 before sending credentials.
188            if !snap
189                .capabilities
190                .contains(&Capability::Auth("XOAUTH2".into()))
191            {
192                return Err(Error::MissingCapability("AUTH=XOAUTH2".into()));
193            }
194
195            // Build XOAUTH2 payload:
196            // "user=<user>\x01auth=Bearer <token>\x01\x01"
197            let payload = format!("user={user}\x01auth=Bearer {token}\x01\x01");
198            let encoded = base64::engine::general_purpose::STANDARD.encode(payload.as_bytes());
199
200            let has_sasl_ir =
201                snap.capabilities.contains(&Capability::SaslIr) || is_rev2_from_snapshot(&snap);
202            drop(snap);
203            (encoded, has_sasl_ir)
204        };
205
206        let cmd = Command::Authenticate {
207            mechanism: "XOAUTH2".to_owned(),
208            initial_response: if has_sasl_ir {
209                Some(encoded.clone())
210            } else {
211                None
212            },
213        };
214
215        let consumer = AuthenticateXoauth2Consumer::new(encoded, has_sasl_ir);
216        let deadline = tokio::time::Instant::now() + timeout;
217        let caps_provided =
218            tokio::time::timeout(timeout, self.submit_with_continuations(cmd, consumer))
219                .await
220                .map_err(|_| Error::Timeout)??;
221
222        self.complete_auth(caps_provided, deadline).await
223    }
224
225    /// Finalize authentication: refresh capabilities if the server did
226    /// not provide them inline.
227    ///
228    /// State transition to `Authenticated` is handled by the driver task
229    /// via the `in_auth` flag in `ProtocolState::apply_tagged` — the
230    /// driver sets `in_auth` when it sees `Command::Login` or
231    /// `Command::Authenticate`, and `apply_tagged` transitions on tagged
232    /// OK (RFC 3501 §6.2.2, §6.2.3).
233    ///
234    /// If the server did not include updated capabilities in its response,
235    /// issue an explicit CAPABILITY command (RFC 3501 §6.2.2 / §6.2.3).
236    async fn complete_auth(
237        &self,
238        caps_provided: bool,
239        deadline: tokio::time::Instant,
240    ) -> Result<(), Error> {
241        if !caps_provided {
242            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
243            if remaining.is_zero() {
244                return Err(Error::Timeout);
245            }
246            // Send CAPABILITY via the driver. apply_side_effects inside
247            // the driver updates the cached capabilities automatically
248            // when it processes the untagged CAPABILITY or tagged
249            // [CAPABILITY] response code (RFC 3501 §7.2.1).
250            tokio::time::timeout(remaining, self.fetch_capabilities_via_driver())
251                .await
252                .map_err(|_| Error::Timeout)??;
253        }
254        Ok(())
255    }
256
257    /// Send CAPABILITY via the driver task and wait for completion.
258    ///
259    /// The driver's `apply_side_effects` updates the cached capability set
260    /// automatically. The consumer output is ignored — the canonical state
261    /// lives in `ProtocolState` inside the driver.
262    async fn fetch_capabilities_via_driver(&self) -> Result<(), Error> {
263        use super::dispatch::CapabilityConsumer;
264
265        let _caps: Vec<Capability> = self
266            .submit_regular(Command::Capability, CapabilityConsumer::default())
267            .await?;
268        Ok(())
269    }
270
271    /// UNAUTHENTICATE — reset to unauthenticated state (RFC 8437 Section 2).
272    ///
273    /// Resets the IMAP session to Not Authenticated without closing the
274    /// TLS connection. After success, the client can LOGIN or AUTHENTICATE
275    /// as a different user — enabling connection pooling across users.
276    ///
277    /// Clears all per-user state: selected mailbox, NOTIFY registrations,
278    /// and ENABLE'd extensions. The TLS layer remains intact.
279    ///
280    /// After unauthentication, issues an explicit CAPABILITY command to
281    /// refresh the cached capabilities, since the server's advertised
282    /// capabilities may differ between authenticated and unauthenticated
283    /// states (RFC 8437 Section 2).
284    ///
285    /// # Stale events
286    ///
287    /// Events buffered before this call may contain stale data from the
288    /// previous session (e.g., EXISTS, EXPUNGE for a previously selected
289    /// mailbox). Callers should drain the event channel before
290    /// re-authenticating.
291    ///
292    /// # Errors
293    ///
294    /// - [`Error::MissingCapability`] if `UNAUTHENTICATE` is not advertised.
295    /// - [`Error::Protocol`] if called in Not Authenticated state.
296    pub async fn unauthenticate(&self, timeout: Duration) -> Result<(), Error> {
297        // RFC 8437 §2: valid in Authenticated or Selected state.
298        self.require_state(&[SessionState::Authenticated, SessionState::Selected])?;
299
300        // Connection-level capability check.
301        {
302            let snap = self.state_rx.borrow();
303            if !snap.capabilities.contains(&Capability::Unauthenticate) {
304                return Err(Error::MissingCapability("UNAUTHENTICATE".into()));
305            }
306        }
307
308        let deadline = tokio::time::Instant::now() + timeout;
309
310        tokio::time::timeout(
311            timeout,
312            self.submit_regular(
313                Command::Unauthenticate,
314                super::dispatch::TaggedOkConsumer::default(),
315            ),
316        )
317        .await
318        .map_err(|_| Error::Timeout)??;
319
320        // RFC 8437 §2: capabilities may change after unauthentication.
321        // Unconditional refresh — the server SHOULD send caps but is not
322        // required to.
323        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
324        if remaining.is_zero() {
325            return Err(Error::Timeout);
326        }
327        tokio::time::timeout(remaining, self.fetch_capabilities_via_driver())
328            .await
329            .map_err(|_| Error::Timeout)??;
330
331        Ok(())
332    }
333
334    /// Graceful logout (RFC 3501 Section 6.1.3 / RFC 9051 Section 6.1.3).
335    ///
336    /// Sends LOGOUT via the driver task and validates the required
337    /// response sequence: the server MUST send `* BYE` followed by a
338    /// tagged OK.
339    ///
340    /// State transitions are handled by `apply_side_effects` inside the
341    /// driver task:
342    /// - `* BYE` → `Logout` (via `apply_untagged`)
343    /// - Tagged OK with `in_logout` → `Logout` (via `apply_tagged`)
344    ///
345    /// The driver sets `in_logout` automatically when it sees
346    /// `Command::Logout`.
347    pub async fn logout(&self) -> Result<(), Error> {
348        use super::dispatch::LogoutConsumer;
349
350        // RFC 3501 Section 3.4: already in Logout state — no-op.
351        if self.state_rx.borrow().session_state == SessionState::Logout {
352            return Ok(());
353        }
354
355        let _: () = self
356            .submit_regular(Command::Logout, LogoutConsumer::default())
357            .await?;
358        Ok(())
359    }
360
361    // -----------------------------------------------------------------------
362    // Submit helpers
363    // -----------------------------------------------------------------------
364
365    /// Submit a regular (non-continuation) command to the driver task
366    /// and await the typed result.
367    pub(super) async fn submit_regular<C: super::dispatch::Consumer + 'static>(
368        &self,
369        cmd: Command,
370        consumer: C,
371    ) -> Result<C::Output, Error>
372    where
373        C::Output: 'static,
374    {
375        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
376        let dcmd = driver::DriverCommand::Run {
377            payload: driver::DriverCommandPayload::Standard(cmd),
378            consumer: driver::DriverConsumer::Regular(
379                Box::new(consumer) as Box<dyn driver::ConsumerErased>
380            ),
381            result_tx,
382        };
383        if self.cmd_tx.send(dcmd).await.is_err() {
384            return Err(self.observe_driver_panic().await);
385        }
386        let result = match result_rx.await {
387            Ok(inner) => inner?,
388            Err(_) => return Err(self.observe_driver_panic().await),
389        };
390        let output = *result
391            .downcast::<C::Output>()
392            .map_err(|_| Error::Internal("type mismatch in driver result".into()))?;
393        Ok(output)
394    }
395
396    /// Submit a continuation-aware command to the driver task and await
397    /// the typed result.
398    ///
399    /// Used by AUTHENTICATE (SASL PLAIN, XOAUTH2) which expect `+`
400    /// continuations during the response loop (RFC 3501 §7.5).
401    pub(super) async fn submit_with_continuations<
402        C: super::dispatch::ContinuationConsumer + 'static,
403    >(
404        &self,
405        cmd: Command,
406        consumer: C,
407    ) -> Result<C::Output, Error>
408    where
409        C::Output: 'static,
410    {
411        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
412        let dcmd = driver::DriverCommand::Run {
413            payload: driver::DriverCommandPayload::Standard(cmd),
414            consumer: driver::DriverConsumer::WithContinuations(
415                Box::new(consumer) as Box<dyn driver::ContinuationConsumerErased>
416            ),
417            result_tx,
418        };
419        if self.cmd_tx.send(dcmd).await.is_err() {
420            return Err(self.observe_driver_panic().await);
421        }
422        let result = match result_rx.await {
423            Ok(inner) => inner?,
424            Err(_) => return Err(self.observe_driver_panic().await),
425        };
426        let output = *result
427            .downcast::<C::Output>()
428            .map_err(|_| Error::Internal("type mismatch in driver result".into()))?;
429        Ok(output)
430    }
431
432    /// Submit a pre-built command (APPEND/MULTIAPPEND) to the driver task.
433    ///
434    /// The handle builds the complete wire bytes (including tag) and
435    /// provides them along with the classification metadata. The driver
436    /// sends the bytes with literal synchronization and runs the response
437    /// classification loop.
438    pub(super) async fn submit_prebuilt<C: super::dispatch::Consumer + 'static>(
439        &self,
440        wire_bytes: bytes::BytesMut,
441        tag: String,
442        cmd_kind: crate::types::CommandKind,
443        cmd_target: Option<crate::types::validated::MailboxName>,
444        consumer: C,
445    ) -> Result<C::Output, Error>
446    where
447        C::Output: 'static,
448    {
449        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
450        let dcmd = driver::DriverCommand::Run {
451            payload: driver::DriverCommandPayload::PreBuilt {
452                wire_bytes,
453                tag,
454                cmd_kind,
455                cmd_target,
456            },
457            consumer: driver::DriverConsumer::Regular(
458                Box::new(consumer) as Box<dyn driver::ConsumerErased>
459            ),
460            result_tx,
461        };
462        if self.cmd_tx.send(dcmd).await.is_err() {
463            return Err(self.observe_driver_panic().await);
464        }
465        let result = match result_rx.await {
466            Ok(inner) => inner?,
467            Err(_) => return Err(self.observe_driver_panic().await),
468        };
469        let output = *result
470            .downcast::<C::Output>()
471            .map_err(|_| Error::Internal("type mismatch in driver result".into()))?;
472        Ok(output)
473    }
474
475    /// Submit a stream upgrade (STARTTLS / COMPRESS) to the driver task.
476    ///
477    /// The driver handles the entire upgrade sequence atomically: sends
478    /// the protocol command, awaits the tagged OK, then swaps the stream
479    /// using the `Poisoned` sentinel (I9, I10). No consumer is needed.
480    pub(super) async fn submit_upgrade(
481        &self,
482        payload: driver::UpgradePayload,
483    ) -> Result<(), Error> {
484        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
485        let dcmd = driver::DriverCommand::Upgrade { payload, result_tx };
486        if self.cmd_tx.send(dcmd).await.is_err() {
487            return Err(self.observe_driver_panic().await);
488        }
489        match result_rx.await {
490            Ok(inner) => {
491                inner?;
492                Ok(())
493            }
494            Err(_) => Err(self.observe_driver_panic().await),
495        }
496    }
497}
498
499/// Check if `IMAP4rev2` behavior is active from a
500/// [`ConnectionStateSnapshot`](driver::ConnectionStateSnapshot).
501///
502/// Mirrors `ImapConnection::is_rev2` but operates on the snapshot so it
503/// can be used while the borrow is held (RFC 9051 §6.3.1).
504pub(super) fn is_rev2_from_snapshot(snap: &driver::ConnectionStateSnapshot) -> bool {
505    let has_rev2 = snap.capabilities.contains(&Capability::Imap4Rev2);
506    let has_rev1 = snap.capabilities.contains(&Capability::Imap4Rev1);
507    if has_rev2 && has_rev1 {
508        // Dual-mode server: rev2 requires explicit ENABLE
509        // (RFC 9051 Section 6.3.1).
510        snap.enabled
511            .iter()
512            .any(|e| e.eq_ignore_ascii_case("IMAP4rev2"))
513    } else {
514        has_rev2
515    }
516}