Skip to main content

io_smtp/sasl/
auth_xoauth2.rs

1//! SMTP SASL XOAUTH2 coroutine (Google's pre-standard OAuth 2.0
2//! mechanism, also accepted by Microsoft Exchange Online); supports
3//! both the non-IR and SASL-IR (RFC 4954 ยง4) flows. Prefer
4//! OAUTHBEARER (RFC 7628) on servers that support both.
5//!
6//! XOAUTH2: <https://developers.google.com/workspace/gmail/imap/xoauth2-protocol>
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use std::{
12//!     borrow::Cow,
13//!     io::{Read, Write},
14//!     net::TcpStream,
15//! };
16//!
17//! use secrecy::SecretString;
18//!
19//! use io_smtp::{
20//!     coroutine::{SmtpCoroutine, SmtpCoroutineState, SmtpYield},
21//!     rfc5321::{SmtpDomain, SmtpEhloDomain},
22//!     sasl::auth_xoauth2::{SmtpAuthXoauth2, SmtpAuthXoauth2Options},
23//! };
24//!
25//! // Ready stream needed (TCP-connected, TLS-negociated, EHLO consumed)
26//! let mut stream = TcpStream::connect("localhost:25").unwrap();
27//!
28//! let mut buf = [0u8; 4096];
29//!
30//! let token = SecretString::from("ya29.tokenvalue".to_string());
31//! let domain = SmtpEhloDomain::SmtpDomain(SmtpDomain(Cow::Borrowed("client.example.org")));
32//! let opts = SmtpAuthXoauth2Options::default();
33//! let mut coroutine = SmtpAuthXoauth2::new("alice@example.org", &token, domain, opts);
34//! let mut arg = None;
35//!
36//! loop {
37//!     match coroutine.resume(arg.take()) {
38//!         SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => {
39//!             stream.write_all(&bytes).unwrap();
40//!         }
41//!         SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {
42//!             let n = stream.read(&mut buf).unwrap();
43//!             arg = Some(&buf[..n]);
44//!         }
45//!         SmtpCoroutineState::Complete(Ok(())) => break,
46//!         SmtpCoroutineState::Complete(Err(err)) => panic!("{err}"),
47//!     }
48//! }
49//! ```
50
51use core::fmt;
52
53use alloc::{
54    borrow::Cow,
55    string::{String, ToString},
56    vec,
57    vec::Vec,
58};
59
60use base64::{Engine, engine::general_purpose::STANDARD as base64};
61use bounded_static::IntoBoundedStatic;
62use log::debug;
63use secrecy::{ExposeSecret, SecretBox, SecretString};
64use thiserror::Error;
65
66use crate::{
67    coroutine::*,
68    rfc4954::{auth::SmtpAuthCommand, auth_data::SmtpAuthData},
69    rfc5321::{
70        SmtpEhloDomain, SmtpReplyCode,
71        ehlo::{SmtpEhlo, SmtpEhloError},
72    },
73    send::*,
74    smtp_try,
75};
76
77/// The SASL mechanism name as it appears on the wire.
78pub const XOAUTH2: &str = "XOAUTH2";
79
80/// Options for [`SmtpAuthXoauth2::new`].
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct SmtpAuthXoauth2Options {
83    /// `true` selects SASL-IR (inline credentials); `false` selects
84    /// the non-IR challenge-response flow.
85    pub initial_request: bool,
86    /// Whether to refresh capabilities with an `EHLO` after a successful auth.
87    /// Disabled by default because the mechanism does not add a security layer.
88    pub ensure_capabilities: bool,
89}
90
91impl Default for SmtpAuthXoauth2Options {
92    fn default() -> Self {
93        Self {
94            initial_request: true,
95            ensure_capabilities: false,
96        }
97    }
98}
99
100/// Failure causes during the SMTP AUTH XOAUTH2 exchange.
101#[derive(Debug, Error)]
102pub enum SmtpAuthXoauth2Error {
103    /// The server rejected the authentication.
104    #[error("SMTP AUTH XOAUTH2 failed: rejected {code} {message}")]
105    Rejected {
106        /// The reply code.
107        code: u16,
108        /// The reply text, or the decoded error detail.
109        message: String,
110    },
111    /// The server accepted before the expected challenge.
112    #[error("SMTP AUTH XOAUTH2 failed: server did not send the expected continuation request")]
113    ExpectedContinuationRequest,
114    /// The underlying command exchange failed.
115    #[error("SMTP AUTH XOAUTH2 failed: {0}")]
116    Send(#[from] SmtpCommandSendError),
117    /// The post-authentication capability refresh failed.
118    #[error(transparent)]
119    Ehlo(#[from] SmtpEhloError),
120}
121
122/// I/O-free SMTP AUTH XOAUTH2 coroutine. The connection MUST be
123/// TLS-protected before calling this.
124pub struct SmtpAuthXoauth2 {
125    state: State,
126    domain: Option<SmtpEhloDomain<'static>>,
127    payload: Option<Vec<u8>>,
128    error_detail: Option<String>,
129    opts: SmtpAuthXoauth2Options,
130}
131
132impl SmtpAuthXoauth2 {
133    /// Creates the coroutine from the username, the bearer token and
134    /// the client identity used by the capability refresh.
135    pub fn new(
136        username: &str,
137        token: &SecretString,
138        domain: SmtpEhloDomain<'_>,
139        opts: SmtpAuthXoauth2Options,
140    ) -> Self {
141        let payload = build_payload(username, token);
142
143        let state = if opts.initial_request {
144            let cmd = SmtpAuthCommand {
145                mechanism: Cow::Borrowed(XOAUTH2),
146                initial_response: Some(SecretBox::new(payload.clone().into_boxed_slice())),
147            };
148            State::Send(SmtpCommandSend::new(cmd))
149        } else {
150            let cmd = SmtpAuthCommand {
151                mechanism: Cow::Borrowed(XOAUTH2),
152                initial_response: None,
153            };
154            State::Send(SmtpCommandSend::new(cmd))
155        };
156
157        Self {
158            state,
159            domain: Some(domain.into_static()),
160            payload: Some(payload),
161            error_detail: None,
162            opts,
163        }
164    }
165}
166
167impl SmtpCoroutine for SmtpAuthXoauth2 {
168    type Yield = SmtpYield;
169    type Return = Result<(), SmtpAuthXoauth2Error>;
170
171    fn resume(&mut self, arg: Option<&[u8]>) -> SmtpCoroutineState<Self::Yield, Self::Return> {
172        loop {
173            match &mut self.state {
174                State::Send(send) => {
175                    let out = smtp_try!(send, arg);
176
177                    if out.response.code == SmtpReplyCode::AUTH_SUCCESSFUL {
178                        if self.opts.initial_request {
179                            self.advance_after_auth();
180                            continue;
181                        }
182                        return SmtpCoroutineState::Complete(Err(
183                            SmtpAuthXoauth2Error::ExpectedContinuationRequest,
184                        ));
185                    }
186
187                    if out.response.code == SmtpReplyCode::AUTH_CONTINUE {
188                        if self.opts.initial_request {
189                            // NOTE: the server is asking us to ack
190                            // the JSON error detail (XOAUTH2 failure
191                            // flow).
192                            let text = out.response.text().0.trim_start();
193                            if let Ok(detail_bytes) = base64.decode(text.as_bytes()) {
194                                self.error_detail = String::from_utf8(detail_bytes).ok();
195                            }
196
197                            let ack = SmtpAuthData::r#continue(vec![0x01u8]);
198                            self.state = State::AckError(SmtpCommandSend::new(ack));
199                            debug!("error detail received, acknowledging");
200                            continue;
201                        }
202
203                        // NOTE: non-IR: send the credentials now.
204                        let payload = self.payload.take().expect("payload taken twice");
205                        let data = SmtpAuthData::r#continue(payload.into_boxed_slice());
206                        self.state = State::Continue(SmtpCommandSend::new(data));
207                        debug!("challenge received, sending credentials");
208                        continue;
209                    }
210
211                    let code = out.response.code.code();
212                    let message = out.response.text().to_string();
213                    return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
214                        code,
215                        message,
216                    }));
217                }
218                State::Continue(send) => {
219                    let out = smtp_try!(send, arg);
220
221                    if out.response.code == SmtpReplyCode::AUTH_SUCCESSFUL {
222                        self.advance_after_auth();
223                        continue;
224                    }
225
226                    if out.response.code == SmtpReplyCode::AUTH_CONTINUE {
227                        let text = out.response.text().0.trim_start();
228                        if let Ok(detail_bytes) = base64.decode(text.as_bytes()) {
229                            self.error_detail = String::from_utf8(detail_bytes).ok();
230                        }
231
232                        let ack = SmtpAuthData::r#continue(vec![0x01u8]);
233                        self.state = State::AckError(SmtpCommandSend::new(ack));
234                        debug!("error detail received, acknowledging");
235                        continue;
236                    }
237
238                    let code = out.response.code.code();
239                    let message = out.response.text().to_string();
240                    return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
241                        code,
242                        message,
243                    }));
244                }
245                State::AckError(send) => {
246                    let _ = smtp_try!(send, arg);
247
248                    let message = self
249                        .error_detail
250                        .take()
251                        .unwrap_or_else(|| "authentication failed".into());
252
253                    return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
254                        code: 535,
255                        message,
256                    }));
257                }
258                State::Ehlo(ehlo) => {
259                    let _ = smtp_try!(ehlo, arg);
260                    debug!("capabilities refreshed");
261                    return SmtpCoroutineState::Complete(Ok(()));
262                }
263                State::Done => return SmtpCoroutineState::Complete(Ok(())),
264            }
265        }
266    }
267}
268
269impl SmtpAuthXoauth2 {
270    fn advance_after_auth(&mut self) {
271        let _ = self.payload.take();
272        debug!("authenticated");
273        if self.opts.ensure_capabilities {
274            let domain = self.domain.take().expect("domain taken twice");
275            self.state = State::Ehlo(SmtpEhlo::new(domain));
276        } else {
277            self.state = State::Done;
278        }
279    }
280}
281
282enum State {
283    Send(SmtpCommandSend<SmtpAuthCommand<'static>>),
284    Continue(SmtpCommandSend<SmtpAuthData>),
285    AckError(SmtpCommandSend<SmtpAuthData>),
286    Ehlo(SmtpEhlo),
287    Done,
288}
289
290impl fmt::Display for State {
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        match self {
293            Self::Send(_) => f.write_str("send auth xoauth2"),
294            Self::Continue(_) => f.write_str("send credentials"),
295            Self::AckError(_) => f.write_str("ack error detail"),
296            Self::Ehlo(_) => f.write_str("refresh capabilities"),
297            Self::Done => f.write_str("done"),
298        }
299    }
300}
301
302/// Build the XOAUTH2 wire payload:
303/// `user=<u>\x01auth=Bearer <t>\x01\x01`.
304fn build_payload(username: &str, token: &SecretString) -> Vec<u8> {
305    let mut payload = Vec::new();
306    payload.extend_from_slice(b"user=");
307    payload.extend_from_slice(username.as_bytes());
308    payload.push(0x01);
309    payload.extend_from_slice(b"auth=Bearer ");
310    payload.extend_from_slice(token.expose_secret().as_bytes());
311    payload.push(0x01);
312    payload.push(0x01);
313    payload
314}
315
316#[cfg(test)]
317mod tests {
318    use alloc::{borrow::Cow, string::ToString, vec::Vec};
319
320    use secrecy::SecretString;
321
322    use crate::{
323        coroutine::*,
324        rfc5321::{SmtpDomain, SmtpEhloDomain},
325        sasl::auth_xoauth2::*,
326        send::SmtpCommandSendError,
327    };
328
329    fn domain() -> SmtpEhloDomain<'static> {
330        SmtpEhloDomain::SmtpDomain(SmtpDomain(Cow::Borrowed("example.com")))
331    }
332
333    fn token() -> SecretString {
334        SecretString::from("ya29.tokenvalue".to_string())
335    }
336
337    #[test]
338    fn ir_success_does_not_send_ehlo_by_default() {
339        let opts = SmtpAuthXoauth2Options::default();
340        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
341
342        let _ = expect_wants_write(&mut auth, None);
343        expect_wants_read(&mut auth);
344        expect_complete_ok(&mut auth, b"235 OK\r\n");
345    }
346
347    #[test]
348    fn ir_success_with_ehlo_returns_ok() {
349        let opts = SmtpAuthXoauth2Options {
350            initial_request: true,
351            ensure_capabilities: true,
352        };
353        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
354
355        let _ = expect_wants_write(&mut auth, None);
356        expect_wants_read(&mut auth);
357        let _ehlo = expect_wants_write(&mut auth, Some(b"235 OK\r\n"));
358        expect_wants_read(&mut auth);
359        expect_complete_ok(&mut auth, b"250 server.example.com\r\n");
360    }
361
362    #[test]
363    fn ir_success_without_ehlo_returns_ok() {
364        let opts = SmtpAuthXoauth2Options {
365            initial_request: true,
366            ensure_capabilities: false,
367        };
368        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
369        let _ = expect_wants_write(&mut auth, None);
370        expect_wants_read(&mut auth);
371        expect_complete_ok(&mut auth, b"235 OK\r\n");
372    }
373
374    #[test]
375    fn error_detail_returns_rejected() {
376        let opts = SmtpAuthXoauth2Options {
377            initial_request: true,
378            ensure_capabilities: false,
379        };
380        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
381        let _ = expect_wants_write(&mut auth, None);
382        expect_wants_read(&mut auth);
383
384        let challenge = b"334 eyJzdGF0dXMiOiI0MDEifQ==\r\n";
385        let _ack = expect_wants_write(&mut auth, Some(challenge));
386        expect_wants_read(&mut auth);
387
388        let err = expect_complete_err(&mut auth, b"535 authentication failed\r\n");
389        let SmtpAuthXoauth2Error::Rejected { code, message } = err else {
390            panic!("expected SmtpAuthXoauth2Error::Rejected, got {err:?}");
391        };
392        assert_eq!(code, 535);
393        assert!(message.contains("status") || message.contains("401"));
394    }
395
396    #[test]
397    fn rejected_returns_rejected_error() {
398        let opts = SmtpAuthXoauth2Options::default();
399        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
400        let _ = expect_wants_write(&mut auth, None);
401        expect_wants_read(&mut auth);
402
403        let err = expect_complete_err(&mut auth, b"504 mechanism disabled\r\n");
404        let SmtpAuthXoauth2Error::Rejected { code, message } = err else {
405            panic!("expected SmtpAuthXoauth2Error::Rejected, got {err:?}");
406        };
407        assert_eq!(code, 504);
408        assert_eq!(message, "mechanism disabled");
409    }
410
411    #[test]
412    fn eof_returns_eof_error() {
413        let opts = SmtpAuthXoauth2Options::default();
414        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
415        let _ = expect_wants_write(&mut auth, None);
416        expect_wants_read(&mut auth);
417
418        let err = expect_complete_err(&mut auth, b"");
419        assert!(matches!(
420            err,
421            SmtpAuthXoauth2Error::Send(SmtpCommandSendError::Eof)
422        ));
423    }
424
425    fn expect_wants_write(cor: &mut SmtpAuthXoauth2, arg: Option<&[u8]>) -> Vec<u8> {
426        match cor.resume(arg) {
427            SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => bytes,
428            state => panic!("expected WantsWrite, got {state:?}"),
429        }
430    }
431
432    fn expect_wants_read(cor: &mut SmtpAuthXoauth2) {
433        match cor.resume(None) {
434            SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {}
435            state => panic!("expected WantsRead, got {state:?}"),
436        }
437    }
438
439    fn expect_complete_ok(cor: &mut SmtpAuthXoauth2, reply: &[u8]) {
440        match cor.resume(Some(reply)) {
441            SmtpCoroutineState::Complete(Ok(())) => {}
442            state => panic!("expected Complete(Ok), got {state:?}"),
443        }
444    }
445
446    fn expect_complete_err(cor: &mut SmtpAuthXoauth2, reply: &[u8]) -> SmtpAuthXoauth2Error {
447        match cor.resume(Some(reply)) {
448            SmtpCoroutineState::Complete(Err(err)) => err,
449            state => panic!("expected Complete(Err), got {state:?}"),
450        }
451    }
452}