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::types::{domain::Domain, ehlo_domain::EhloDomain},
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 = EhloDomain::Domain(Domain(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::Vec,
57};
58
59use base64::{Engine, engine::general_purpose::STANDARD as base64};
60use bounded_static::IntoBoundedStatic;
61use log::trace;
62use secrecy::{ExposeSecret, SecretBox, SecretString};
63use thiserror::Error;
64
65use crate::{
66    coroutine::*,
67    rfc4954::{auth::SmtpAuthCommand, auth_data::SmtpAuthData},
68    rfc5321::{
69        ehlo::{SmtpEhlo, SmtpEhloError},
70        types::{ehlo_domain::EhloDomain, reply_code::ReplyCode},
71    },
72    send::*,
73    smtp_try,
74};
75
76/// The SASL mechanism name as it appears on the wire.
77pub const XOAUTH2: &str = "XOAUTH2";
78
79/// Options for [`SmtpAuthXoauth2::new`].
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct SmtpAuthXoauth2Options {
82    /// `true` selects SASL-IR (inline credentials); `false` selects
83    /// the non-IR challenge-response flow.
84    pub initial_request: bool,
85    /// Refresh capabilities with an `EHLO` after a successful auth.
86    pub ensure_capabilities: bool,
87}
88
89impl Default for SmtpAuthXoauth2Options {
90    fn default() -> Self {
91        Self {
92            initial_request: true,
93            ensure_capabilities: true,
94        }
95    }
96}
97
98/// Failure causes during the SMTP AUTH XOAUTH2 exchange.
99#[derive(Debug, Error)]
100pub enum SmtpAuthXoauth2Error {
101    #[error("SMTP AUTH XOAUTH2 failed: rejected {code} {message}")]
102    Rejected { code: u16, message: String },
103    #[error("SMTP AUTH XOAUTH2 failed: server did not send the expected continuation request")]
104    ExpectedContinuationRequest,
105    #[error("SMTP AUTH XOAUTH2 failed: {0}")]
106    Send(#[from] SendSmtpCommandError),
107    #[error(transparent)]
108    Ehlo(#[from] SmtpEhloError),
109}
110
111/// I/O-free SMTP AUTH XOAUTH2 coroutine. The connection MUST be
112/// TLS-protected before calling this.
113pub struct SmtpAuthXoauth2 {
114    state: State,
115    domain: Option<EhloDomain<'static>>,
116    payload: Option<Vec<u8>>,
117    error_detail: Option<String>,
118    opts: SmtpAuthXoauth2Options,
119}
120
121impl SmtpAuthXoauth2 {
122    pub fn new(
123        username: &str,
124        token: &SecretString,
125        domain: EhloDomain<'_>,
126        opts: SmtpAuthXoauth2Options,
127    ) -> Self {
128        let payload = build_payload(username, token);
129
130        let state = if opts.initial_request {
131            let cmd = SmtpAuthCommand {
132                mechanism: Cow::Borrowed(XOAUTH2),
133                initial_response: Some(SecretBox::new(payload.clone().into_boxed_slice())),
134            };
135            State::Send(SendSmtpCommand::new(cmd))
136        } else {
137            let cmd = SmtpAuthCommand {
138                mechanism: Cow::Borrowed(XOAUTH2),
139                initial_response: None,
140            };
141            State::Send(SendSmtpCommand::new(cmd))
142        };
143
144        Self {
145            state,
146            domain: Some(domain.into_static()),
147            payload: Some(payload),
148            error_detail: None,
149            opts,
150        }
151    }
152}
153
154impl SmtpCoroutine for SmtpAuthXoauth2 {
155    type Yield = SmtpYield;
156    type Return = Result<(), SmtpAuthXoauth2Error>;
157
158    fn resume(&mut self, arg: Option<&[u8]>) -> SmtpCoroutineState<Self::Yield, Self::Return> {
159        loop {
160            trace!("auth xoauth2: {}", self.state);
161
162            match &mut self.state {
163                State::Send(send) => {
164                    let out = smtp_try!(send, arg);
165
166                    if out.response.code == ReplyCode::AUTH_SUCCESSFUL {
167                        if self.opts.initial_request {
168                            self.advance_after_auth();
169                            continue;
170                        }
171                        return SmtpCoroutineState::Complete(Err(
172                            SmtpAuthXoauth2Error::ExpectedContinuationRequest,
173                        ));
174                    }
175
176                    if out.response.code == ReplyCode::AUTH_CONTINUE {
177                        if self.opts.initial_request {
178                            // Server is asking us to ack the JSON
179                            // error detail (XOAUTH2 failure flow).
180                            let text = out.response.text().0.trim_start();
181                            if let Ok(detail_bytes) = base64.decode(text.as_bytes()) {
182                                self.error_detail = String::from_utf8(detail_bytes).ok();
183                            }
184
185                            let ack = SmtpAuthData::r#continue(vec![0x01u8]);
186                            self.state = State::AckError(SendSmtpCommand::new(ack));
187                            continue;
188                        }
189
190                        // Non-IR: send the credentials now.
191                        let payload = self.payload.take().expect("payload taken twice");
192                        let data = SmtpAuthData::r#continue(payload.into_boxed_slice());
193                        self.state = State::Continue(SendSmtpCommand::new(data));
194                        continue;
195                    }
196
197                    let code = out.response.code.code();
198                    let message = out.response.text().to_string();
199                    return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
200                        code,
201                        message,
202                    }));
203                }
204                State::Continue(send) => {
205                    let out = smtp_try!(send, arg);
206
207                    if out.response.code == ReplyCode::AUTH_SUCCESSFUL {
208                        self.advance_after_auth();
209                        continue;
210                    }
211
212                    if out.response.code == ReplyCode::AUTH_CONTINUE {
213                        let text = out.response.text().0.trim_start();
214                        if let Ok(detail_bytes) = base64.decode(text.as_bytes()) {
215                            self.error_detail = String::from_utf8(detail_bytes).ok();
216                        }
217
218                        let ack = SmtpAuthData::r#continue(vec![0x01u8]);
219                        self.state = State::AckError(SendSmtpCommand::new(ack));
220                        continue;
221                    }
222
223                    let code = out.response.code.code();
224                    let message = out.response.text().to_string();
225                    return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
226                        code,
227                        message,
228                    }));
229                }
230                State::AckError(send) => {
231                    let _ = smtp_try!(send, arg);
232
233                    let message = self
234                        .error_detail
235                        .take()
236                        .unwrap_or_else(|| "authentication failed".into());
237
238                    return SmtpCoroutineState::Complete(Err(SmtpAuthXoauth2Error::Rejected {
239                        code: 535,
240                        message,
241                    }));
242                }
243                State::Ehlo(ehlo) => {
244                    let _ = smtp_try!(ehlo, arg);
245                    return SmtpCoroutineState::Complete(Ok(()));
246                }
247                State::Done => return SmtpCoroutineState::Complete(Ok(())),
248            }
249        }
250    }
251}
252
253impl SmtpAuthXoauth2 {
254    fn advance_after_auth(&mut self) {
255        let _ = self.payload.take();
256        if self.opts.ensure_capabilities {
257            let domain = self.domain.take().expect("domain taken twice");
258            self.state = State::Ehlo(SmtpEhlo::new(domain));
259        } else {
260            self.state = State::Done;
261        }
262    }
263}
264
265enum State {
266    Send(SendSmtpCommand<SmtpAuthCommand<'static>>),
267    Continue(SendSmtpCommand<SmtpAuthData>),
268    AckError(SendSmtpCommand<SmtpAuthData>),
269    Ehlo(SmtpEhlo),
270    Done,
271}
272
273impl fmt::Display for State {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        match self {
276            Self::Send(_) => f.write_str("send auth xoauth2"),
277            Self::Continue(_) => f.write_str("send credentials"),
278            Self::AckError(_) => f.write_str("ack error detail"),
279            Self::Ehlo(_) => f.write_str("refresh capabilities"),
280            Self::Done => f.write_str("done"),
281        }
282    }
283}
284
285/// Build the XOAUTH2 wire payload:
286/// `user=<u>\x01auth=Bearer <t>\x01\x01`.
287fn build_payload(username: &str, token: &SecretString) -> Vec<u8> {
288    let mut payload = Vec::new();
289    payload.extend_from_slice(b"user=");
290    payload.extend_from_slice(username.as_bytes());
291    payload.push(0x01);
292    payload.extend_from_slice(b"auth=Bearer ");
293    payload.extend_from_slice(token.expose_secret().as_bytes());
294    payload.push(0x01);
295    payload.push(0x01);
296    payload
297}
298
299#[cfg(test)]
300mod tests {
301    use crate::rfc5321::types::domain::Domain;
302
303    use super::*;
304
305    fn domain() -> EhloDomain<'static> {
306        EhloDomain::Domain(Domain(Cow::Borrowed("example.com")))
307    }
308
309    fn token() -> SecretString {
310        SecretString::from("ya29.tokenvalue".to_string())
311    }
312
313    #[test]
314    fn ir_success_then_ehlo_returns_ok() {
315        let opts = SmtpAuthXoauth2Options::default();
316        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
317
318        let _ = expect_wants_write(&mut auth, None);
319        expect_wants_read(&mut auth);
320        let _ehlo = expect_wants_write(&mut auth, Some(b"235 OK\r\n"));
321        expect_wants_read(&mut auth);
322        expect_complete_ok(&mut auth, b"250 server.example.com\r\n");
323    }
324
325    #[test]
326    fn ir_success_without_ehlo_returns_ok() {
327        let opts = SmtpAuthXoauth2Options {
328            initial_request: true,
329            ensure_capabilities: false,
330        };
331        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
332        let _ = expect_wants_write(&mut auth, None);
333        expect_wants_read(&mut auth);
334        expect_complete_ok(&mut auth, b"235 OK\r\n");
335    }
336
337    #[test]
338    fn error_detail_returns_rejected() {
339        let opts = SmtpAuthXoauth2Options {
340            initial_request: true,
341            ensure_capabilities: false,
342        };
343        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
344        let _ = expect_wants_write(&mut auth, None);
345        expect_wants_read(&mut auth);
346
347        let challenge = b"334 eyJzdGF0dXMiOiI0MDEifQ==\r\n";
348        let _ack = expect_wants_write(&mut auth, Some(challenge));
349        expect_wants_read(&mut auth);
350
351        let err = expect_complete_err(&mut auth, b"535 authentication failed\r\n");
352        let SmtpAuthXoauth2Error::Rejected { code, message } = err else {
353            panic!("expected SmtpAuthXoauth2Error::Rejected, got {err:?}");
354        };
355        assert_eq!(code, 535);
356        assert!(message.contains("status") || message.contains("401"));
357    }
358
359    #[test]
360    fn rejected_returns_rejected_error() {
361        let opts = SmtpAuthXoauth2Options::default();
362        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
363        let _ = expect_wants_write(&mut auth, None);
364        expect_wants_read(&mut auth);
365
366        let err = expect_complete_err(&mut auth, b"504 mechanism disabled\r\n");
367        let SmtpAuthXoauth2Error::Rejected { code, message } = err else {
368            panic!("expected SmtpAuthXoauth2Error::Rejected, got {err:?}");
369        };
370        assert_eq!(code, 504);
371        assert_eq!(message, "mechanism disabled");
372    }
373
374    #[test]
375    fn eof_returns_eof_error() {
376        let opts = SmtpAuthXoauth2Options::default();
377        let mut auth = SmtpAuthXoauth2::new("alice@example.com", &token(), domain(), opts);
378        let _ = expect_wants_write(&mut auth, None);
379        expect_wants_read(&mut auth);
380
381        let err = expect_complete_err(&mut auth, b"");
382        assert!(matches!(
383            err,
384            SmtpAuthXoauth2Error::Send(SendSmtpCommandError::Eof)
385        ));
386    }
387
388    // --- utils
389
390    fn expect_wants_write(cor: &mut SmtpAuthXoauth2, arg: Option<&[u8]>) -> Vec<u8> {
391        match cor.resume(arg) {
392            SmtpCoroutineState::Yielded(SmtpYield::WantsWrite(bytes)) => bytes,
393            state => panic!("expected WantsWrite, got {state:?}"),
394        }
395    }
396
397    fn expect_wants_read(cor: &mut SmtpAuthXoauth2) {
398        match cor.resume(None) {
399            SmtpCoroutineState::Yielded(SmtpYield::WantsRead) => {}
400            state => panic!("expected WantsRead, got {state:?}"),
401        }
402    }
403
404    fn expect_complete_ok(cor: &mut SmtpAuthXoauth2, reply: &[u8]) {
405        match cor.resume(Some(reply)) {
406            SmtpCoroutineState::Complete(Ok(())) => {}
407            state => panic!("expected Complete(Ok), got {state:?}"),
408        }
409    }
410
411    fn expect_complete_err(cor: &mut SmtpAuthXoauth2, reply: &[u8]) -> SmtpAuthXoauth2Error {
412        match cor.resume(Some(reply)) {
413            SmtpCoroutineState::Complete(Err(err)) => err,
414            state => panic!("expected Complete(Err), got {state:?}"),
415        }
416    }
417}