Skip to main content

io_smtp/rfc7628/
auth_oauthbearer.rs

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