Skip to main content

io_imap/sasl/
auth_xoauth2.rs

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