Skip to main content

io_imap/sasl/
auth_xoauth2.rs

1//! IMAP SASL XOAUTH2 coroutine; supports both the non-IR and SASL-IR
2//! (RFC 4959) flows.
3//!
4//! XOAUTH2 is Google's pre-standard OAuth 2.0 mechanism, also
5//! accepted by Microsoft Exchange Online.
6//!
7//! The mechanism itself lives in io-sasl: this coroutine holds the IMAP
8//! half of the exchange, the `AUTHENTICATE XOAUTH2` command, the
9//! continuation requests, the tagged response and the post-auth
10//! follow-ups, and asks [`SaslXoauth2`] what to put in each response.
11//! The error dance is the mechanism's too: a challenge carrying the
12//! rejection JSON is answered with the empty response Google documents,
13//! and the JSON comes back out when the exchange is declared over.
14//!
15//! XOAUTH2: <https://developers.google.com/workspace/gmail/imap/xoauth2-protocol>
16//! SASL-IR: <https://www.rfc-editor.org/rfc/rfc4959>
17//!
18//! # Example
19//!
20//! ```rust,no_run
21//! use std::{
22//!     io::{Read, Write},
23//!     net::TcpStream,
24//! };
25//!
26//! use io_imap::{
27//!     codec::fragmentizer::Fragmentizer,
28//!     coroutine::{ImapCoroutine, ImapCoroutineState, ImapYield},
29//!     sasl::auth_xoauth2::{ImapAuthXoauth2, ImapAuthXoauth2Options},
30//! };
31//!
32//! // Ready stream needed (TCP-connected, TLS-negotiated)
33//! let mut stream = TcpStream::connect("localhost:143").unwrap();
34//!
35//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
36//! let mut buf = [0u8; 4096];
37//!
38//! let opts = ImapAuthXoauth2Options::default();
39//! let mut coroutine = ImapAuthXoauth2::new("alice@example.org", "oauth-token", opts);
40//! let mut arg = None;
41//!
42//! let capability = loop {
43//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
44//!         ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
45//!             stream.write_all(&bytes).unwrap();
46//!         }
47//!         ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
48//!             let n = stream.read(&mut buf).unwrap();
49//!             arg = Some(&buf[..n]);
50//!         }
51//!         ImapCoroutineState::Complete(Ok(capability)) => break capability,
52//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
53//!     }
54//! };
55//!
56//! println!("{capability:?}");
57//! ```
58
59use core::{fmt, mem};
60
61use alloc::{
62    string::{String, ToString},
63    vec::Vec,
64};
65
66use imap_codec::{
67    AuthenticateDataCodec, CommandCodec,
68    fragmentizer::Fragmentizer,
69    imap_types::{
70        auth::{AuthMechanism, AuthenticateData},
71        command::{Command, CommandBody},
72        core::{IString, NString, TagGenerator},
73        response::{
74            Capability, Code, CommandContinuationRequest, Data, StatusBody, StatusKind, Tagged,
75        },
76        secret::Secret,
77    },
78};
79use io_sasl::{
80    coroutine::*,
81    xoauth2::{SaslXoauth2, SaslXoauth2Creds, SaslXoauth2Error},
82};
83use log::{debug, trace};
84use secrecy::SecretString;
85use thiserror::Error;
86
87use crate::{coroutine::*, imap_try, rfc2971::id::*, rfc3501::capability::*, send::*};
88
89/// Failure causes during the SASL XOAUTH2 flow.
90#[derive(Clone, Debug, Error)]
91pub enum ImapAuthXoauth2Error {
92    /// The server rejected authentication with a tagged NO.
93    #[error("IMAP AUTHENTICATE XOAUTH2 failed: NO {0}")]
94    No(String),
95    /// The server rejected authentication with a tagged NO after
96    /// returning an error payload in a challenge.
97    #[error("IMAP AUTHENTICATE XOAUTH2 failed: NO {info} ({err})")]
98    NoWithError {
99        /// The tagged NO response text.
100        info: String,
101        /// The error payload extracted from the challenge.
102        err: String,
103    },
104    /// The server rejected the AUTHENTICATE command with a tagged BAD.
105    #[error("IMAP AUTHENTICATE XOAUTH2 failed: BAD {0}")]
106    Bad(String),
107    /// The server closed the connection with an untagged BYE.
108    #[error("IMAP AUTHENTICATE XOAUTH2 failed: BYE {0}")]
109    Bye(String),
110    /// The server never returned the final tagged response.
111    #[error("IMAP AUTHENTICATE XOAUTH2 failed: server did not return a tagged response")]
112    MissingTagged,
113    /// The server never sent the expected continuation request.
114    #[error(
115        "IMAP AUTHENTICATE XOAUTH2 failed: server did not send the expected continuation request"
116    )]
117    ExpectedContinuationRequest,
118    /// The server returned OK before the mechanism could complete.
119    #[error(
120        "IMAP AUTHENTICATE XOAUTH2 failed: server returned OK before the mechanism could complete"
121    )]
122    UnexpectedOk,
123    /// The mechanism refused the exchange.
124    ///
125    /// A rejected token whose exchange the server ended with something
126    /// other than a tagged NO lands here, carrying the JSON it sent,
127    /// as does a challenge arriving out of order.
128    #[error("IMAP AUTHENTICATE XOAUTH2 failed: {0}")]
129    Mechanism(#[from] SaslXoauth2Error),
130    /// The underlying send coroutine failed.
131    #[error("IMAP AUTHENTICATE XOAUTH2 failed: {0}")]
132    Send(#[from] ImapSendError),
133    /// The follow-up CAPABILITY command failed.
134    #[error(transparent)]
135    Capability(#[from] ImapCapabilityGetError),
136    /// The follow-up ID command failed.
137    #[error(transparent)]
138    ServerId(#[from] ImapServerIdError),
139}
140
141/// Options for [`ImapAuthXoauth2::new`].
142#[derive(Clone, Debug, Default, Eq, PartialEq)]
143pub struct ImapAuthXoauth2Options {
144    /// `true` selects SASL-IR (RFC 4959, inline credentials);
145    /// `false` selects the non-IR upload-after-challenge flow.
146    pub initial_request: bool,
147    /// Fetch CAPABILITY after authentication when the tagged response
148    /// carries no capability data. Defaults to `false`.
149    pub ensure_capabilities: bool,
150    /// Chain an RFC 2971 ID round-trip right after authentication, as
151    /// required by some providers.
152    ///
153    /// Defaults to `None` (no ID); an empty list sends ID NIL.
154    pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
155}
156
157/// I/O-free SASL XOAUTH2 coroutine.
158pub struct ImapAuthXoauth2 {
159    state: State,
160    mechanism: SaslXoauth2,
161    observed: Vec<Capability<'static>>,
162    opts: ImapAuthXoauth2Options,
163}
164
165impl ImapAuthXoauth2 {
166    /// Builds a SASL XOAUTH2 coroutine authenticating `user` with the
167    /// OAuth 2.0 bearer `token`.
168    ///
169    /// Depending on `opts.initial_request`, the credentials go inline
170    /// with the AUTHENTICATE command (SASL-IR) or are uploaded after
171    /// the server challenge.
172    pub fn new(
173        user: impl AsRef<str>,
174        token: impl AsRef<str>,
175        opts: ImapAuthXoauth2Options,
176    ) -> Self {
177        let mechanism = SaslXoauth2::new(SaslXoauth2Creds {
178            username: user.as_ref().to_string(),
179            token: SecretString::from(token.as_ref().to_string()),
180        });
181
182        Self {
183            state: State::Start,
184            mechanism,
185            observed: Vec::new(),
186            opts,
187        }
188    }
189
190    // helper that tells if the coroutine needs to fetch capability or not (in
191    // case found in data or untagged responses)
192    fn wants_capability(
193        &mut self,
194        code: Option<Code<'static>>,
195        data: Vec<Data<'static>>,
196        untagged: Vec<StatusBody<'static>>,
197    ) -> Option<State> {
198        let mut new_capability = None;
199
200        if let Some(Code::Capability(capability)) = code {
201            new_capability.replace(capability);
202        }
203
204        for data in data {
205            if let Data::Capability(capability) = data {
206                new_capability.replace(capability);
207            }
208        }
209
210        for StatusBody { code, .. } in untagged {
211            if let Some(Code::Capability(capability)) = code {
212                new_capability.replace(capability);
213            }
214        }
215
216        if let Some(capability) = new_capability {
217            self.observed = capability.into_iter().collect();
218        }
219
220        (self.opts.ensure_capabilities && self.observed.is_empty())
221            .then(|| State::Capability(ImapCapabilityGet::new()))
222    }
223
224    // helper that tells if the coroutine needs to exchange ID with server
225    fn wants_id(&mut self) -> Option<State> {
226        let params = self.opts.auto_id.take()?;
227        let wire = (!params.is_empty()).then_some(params);
228        Some(State::Id(ImapServerId::new(ImapServerIdOptions {
229            parameters: wire,
230        })))
231    }
232
233    // helper that tells if the coroutine needs to send continuation auth data
234    fn wants_continue(payload: Vec<u8>) -> State {
235        let auth = AuthenticateData::r#continue(payload);
236        let codec = AuthenticateDataCodec::new();
237        State::Continue(ImapSend::new(codec, auth))
238    }
239
240    // helper that resumes SASL coroutine
241    fn resume_sasl(&mut self, arg: SaslArg<'_>) -> Result<Option<Vec<u8>>, ImapAuthXoauth2Error> {
242        match self.mechanism.resume(arg) {
243            SaslCoroutineState::Yielded(SaslYield::WantsWrite(payload)) => Ok(Some(payload)),
244            SaslCoroutineState::Yielded(SaslYield::WantsRead) => Ok(None),
245            SaslCoroutineState::Complete(result) => result.map(|()| None).map_err(Into::into),
246        }
247    }
248
249    // helper that turns a tagged NO into the reason the mechanism holds, when
250    // it holds one: the JSON explaining a rejected token was sent in a
251    // challenge, and the mechanism gives it up once the exchange is over
252    fn no(&mut self, info: String) -> ImapAuthXoauth2Error {
253        match self.mechanism.resume(SaslArg::Done) {
254            SaslCoroutineState::Complete(Err(SaslXoauth2Error::Rejected(err))) => {
255                ImapAuthXoauth2Error::NoWithError { info, err }
256            }
257            _ => ImapAuthXoauth2Error::No(info),
258        }
259    }
260}
261
262impl ImapCoroutine for ImapAuthXoauth2 {
263    type Yield = ImapYield;
264    type Return = Result<Vec<Capability<'static>>, ImapAuthXoauth2Error>;
265
266    fn resume(
267        &mut self,
268        fragmentizer: &mut Fragmentizer,
269        arg: Option<&[u8]>,
270    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
271        loop {
272            match &mut self.state {
273                State::Start => {
274                    let payload = match self.resume_sasl(SaslArg::None) {
275                        Ok(payload) => payload,
276                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
277                    };
278
279                    // NOTE: the initial response travels inline only when the
280                    // server was found to support RFC 4959, which is a decision
281                    // taken before the exchange; otherwise it waits for the
282                    // empty challenge.
283                    let (initial_response, pending) = match payload {
284                        Some(payload) if self.opts.initial_request => {
285                            (Some(Secret::new(payload.into())), None)
286                        }
287                        payload => (None, payload),
288                    };
289
290                    let tag = TagGenerator::new().generate();
291                    let body = CommandBody::Authenticate {
292                        mechanism: AuthMechanism::XOAuth2,
293                        initial_response,
294                    };
295                    let cmd = Command { tag, body };
296                    trace!("send IMAP command {cmd:?}");
297
298                    self.state = State::Send {
299                        send: ImapSend::new(CommandCodec::new(), cmd),
300                        pending,
301                    };
302                    debug!("{}", self.state);
303                }
304                State::Send { send, pending } => {
305                    let out = imap_try!(send, fragmentizer, arg);
306
307                    if let Some(bye) = out.bye {
308                        let err = ImapAuthXoauth2Error::Bye(bye.text.to_string());
309                        return ImapCoroutineState::Complete(Err(err));
310                    }
311
312                    if let Some(cr) = out.continuation_request {
313                        // NOTE: with the credentials still held back this is
314                        // the empty challenge inviting them; with them already
315                        // inlined it carries the rejection JSON, which only
316                        // the mechanism reads and answers.
317                        let payload = match pending.take() {
318                            Some(payload) => payload,
319                            None => {
320                                match self.resume_sasl(SaslArg::Input(&extract_challenge(cr))) {
321                                    Ok(payload) => payload.unwrap_or_default(),
322                                    Err(err) => return ImapCoroutineState::Complete(Err(err)),
323                                }
324                            }
325                        };
326
327                        self.state = Self::wants_continue(payload);
328                        debug!("{}", self.state);
329                        continue;
330                    }
331
332                    // NOTE: with the credentials inlined there is nothing left
333                    // to send, so the tagged response ends the exchange here
334                    // rather than after a continuation. Without them, a server
335                    // finishing now never asked for what it is authenticating.
336                    let inlined = pending.is_none();
337
338                    let Some(Tagged { body, .. }) = out.tagged else {
339                        let err = ImapAuthXoauth2Error::ExpectedContinuationRequest;
340                        return ImapCoroutineState::Complete(Err(err));
341                    };
342
343                    let code = match body.kind {
344                        StatusKind::Ok if inlined => body.code,
345                        StatusKind::Ok => {
346                            let err = ImapAuthXoauth2Error::UnexpectedOk;
347                            return ImapCoroutineState::Complete(Err(err));
348                        }
349                        StatusKind::No => {
350                            let err = self.no(body.text.to_string());
351                            return ImapCoroutineState::Complete(Err(err));
352                        }
353                        StatusKind::Bad => {
354                            let err = ImapAuthXoauth2Error::Bad(body.text.to_string());
355                            return ImapCoroutineState::Complete(Err(err));
356                        }
357                    };
358
359                    if let Err(err) = self.resume_sasl(SaslArg::Done) {
360                        return ImapCoroutineState::Complete(Err(err));
361                    }
362
363                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
364                        self.state = next;
365                        debug!("{}", self.state);
366                        continue;
367                    }
368
369                    if let Some(next) = self.wants_id() {
370                        self.state = next;
371                        debug!("{}", self.state);
372                        continue;
373                    }
374
375                    let capability = mem::take(&mut self.observed);
376                    return ImapCoroutineState::Complete(Ok(capability));
377                }
378                State::Continue(send) => {
379                    let out = imap_try!(send, fragmentizer, arg);
380
381                    if let Some(bye) = out.bye {
382                        let err = ImapAuthXoauth2Error::Bye(bye.text.to_string());
383                        return ImapCoroutineState::Complete(Err(err));
384                    }
385
386                    if let Some(cr) = out.continuation_request {
387                        let payload = match self.resume_sasl(SaslArg::Input(&extract_challenge(cr)))
388                        {
389                            Ok(payload) => payload.unwrap_or_default(),
390                            Err(err) => return ImapCoroutineState::Complete(Err(err)),
391                        };
392
393                        self.state = Self::wants_continue(payload);
394                        debug!("{}", self.state);
395                        continue;
396                    }
397
398                    let Some(Tagged { body, .. }) = out.tagged else {
399                        let err = ImapAuthXoauth2Error::MissingTagged;
400                        return ImapCoroutineState::Complete(Err(err));
401                    };
402
403                    let code = match body.kind {
404                        StatusKind::Ok => body.code,
405                        StatusKind::No => {
406                            let err = self.no(body.text.to_string());
407                            return ImapCoroutineState::Complete(Err(err));
408                        }
409                        StatusKind::Bad => {
410                            let err = ImapAuthXoauth2Error::Bad(body.text.to_string());
411                            return ImapCoroutineState::Complete(Err(err));
412                        }
413                    };
414
415                    // NOTE: the tagged OK ends the exchange, and the mechanism
416                    // is told so rather than dropped: a token the server
417                    // rejected mid-exchange is reported here, with the JSON
418                    // that explained it, rather than read as a success.
419                    if let Err(err) = self.resume_sasl(SaslArg::Done) {
420                        return ImapCoroutineState::Complete(Err(err));
421                    }
422
423                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
424                        self.state = next;
425                        debug!("{}", self.state);
426                        continue;
427                    }
428
429                    if let Some(next) = self.wants_id() {
430                        self.state = next;
431                        debug!("{}", self.state);
432                        continue;
433                    }
434
435                    let capability = mem::take(&mut self.observed);
436                    return ImapCoroutineState::Complete(Ok(capability));
437                }
438                State::Capability(capability) => {
439                    self.observed = imap_try!(capability, fragmentizer, arg);
440
441                    if let Some(next) = self.wants_id() {
442                        self.state = next;
443                        debug!("{}", self.state);
444                        continue;
445                    }
446
447                    let capability = mem::take(&mut self.observed);
448                    return ImapCoroutineState::Complete(Ok(capability));
449                }
450                State::Id(id) => {
451                    imap_try!(id, fragmentizer, arg);
452                    let capability = mem::take(&mut self.observed);
453                    return ImapCoroutineState::Complete(Ok(capability));
454                }
455            }
456        }
457    }
458}
459
460enum State {
461    Start,
462    Send {
463        send: ImapSend<CommandCodec>,
464        pending: Option<Vec<u8>>,
465    },
466    Continue(ImapSend<AuthenticateDataCodec>),
467    Capability(ImapCapabilityGet),
468    Id(ImapServerId),
469}
470
471impl fmt::Display for State {
472    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473        match self {
474            Self::Start => f.write_str("start mechanism"),
475            Self::Send { pending, .. } if pending.is_some() => f.write_str("send auth"),
476            Self::Send { .. } => f.write_str("send auth with ir"),
477            Self::Continue(_) => f.write_str("send response"),
478            Self::Capability(_) => f.write_str("fetch capabilities"),
479            Self::Id(_) => f.write_str("send id"),
480        }
481    }
482}
483
484fn extract_challenge(cr: CommandContinuationRequest<'static>) -> Vec<u8> {
485    match cr {
486        CommandContinuationRequest::Basic(basic) => basic.text().to_string().into_bytes(),
487        CommandContinuationRequest::Base64(data) => data.as_ref().to_vec(),
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use core::str;
494
495    use alloc::format;
496
497    use crate::sasl::auth_xoauth2::*;
498
499    #[test]
500    fn ir_success_returns_ok() {
501        let opts = ImapAuthXoauth2Options {
502            initial_request: true,
503            ..Default::default()
504        };
505
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.contains("AUTHENTICATE XOAUTH2 "));
513
514        expect_wants_read(&mut auth, &mut frag);
515
516        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
517        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
518    }
519
520    #[test]
521    fn ir_invalid_token_returns_no_with_error() {
522        let opts = ImapAuthXoauth2Options {
523            initial_request: true,
524            ..Default::default()
525        };
526
527        let mut auth = ImapAuthXoauth2::new("user@example.org", "expired-token", opts);
528        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
529
530        let bytes = expect_wants_write(&mut auth, &mut frag, None);
531        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
532
533        expect_wants_read(&mut auth, &mut frag);
534
535        let (err_json_b64, err_json) = fake_json_error();
536        let challenge = format!("+ {err_json_b64}\r\n");
537        let ack = expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
538        assert_eq!(b"\r\n", &*ack);
539
540        expect_wants_read(&mut auth, &mut frag);
541
542        let reply = format!("{tag} NO SASL authentication failed\r\n");
543        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
544        let ImapAuthXoauth2Error::NoWithError { info, err } = err else {
545            panic!("expected ImapAuthXoauth2Error::NoWithError, got {err:?}");
546        };
547        assert_eq!(info, "SASL authentication failed");
548        assert_eq!(err, err_json);
549    }
550
551    #[test]
552    fn ir_tagged_bad_returns_bad_error() {
553        let opts = ImapAuthXoauth2Options {
554            initial_request: true,
555            ..Default::default()
556        };
557
558        let mut auth = ImapAuthXoauth2::new("user@example.org", "oauth-token", opts);
559        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
560
561        let bytes = expect_wants_write(&mut auth, &mut frag, None);
562        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
563
564        expect_wants_read(&mut auth, &mut frag);
565
566        let reply = format!("{tag} BAD AUTHENTICATE not enabled\r\n");
567        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
568        let ImapAuthXoauth2Error::Bad(text) = err else {
569            panic!("expected ImapAuthXoauth2Error::Bad, got {err:?}");
570        };
571        assert_eq!(text, "AUTHENTICATE not enabled");
572    }
573
574    #[test]
575    fn ir_rejected_token_acknowledged_then_ok_returns_mechanism_error() {
576        let opts = ImapAuthXoauth2Options {
577            initial_request: true,
578            ..Default::default()
579        };
580
581        let mut auth = ImapAuthXoauth2::new("user@example.org", "expired-token", opts);
582        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
583
584        let bytes = expect_wants_write(&mut auth, &mut frag, None);
585        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
586
587        expect_wants_read(&mut auth, &mut frag);
588
589        let (err_json_b64, err_json) = fake_json_error();
590        let challenge = format!("+ {err_json_b64}\r\n");
591        expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
592        expect_wants_read(&mut auth, &mut frag);
593
594        // NOTE: a server answering the acknowledgement with OK contradicts the
595        // rejection it just sent. The mechanism read that JSON and keeps it,
596        // so the failure is reported with the reason rather than as a success.
597        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
598        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
599        let ImapAuthXoauth2Error::Mechanism(SaslXoauth2Error::Rejected(json)) = err else {
600            panic!("expected ImapAuthXoauth2Error::Mechanism, got {err:?}");
601        };
602        assert_eq!(json, err_json);
603    }
604
605    #[test]
606    fn non_ir_success_returns_ok() {
607        let opts = ImapAuthXoauth2Options::default();
608        let mut auth = ImapAuthXoauth2::new("user@example.org", "oauth-token", opts);
609        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
610
611        let bytes = expect_wants_write(&mut auth, &mut frag, None);
612        let line = str::from_utf8(&bytes).expect("utf8 command");
613        let tag = first_word(line);
614        assert!(line.trim_end().ends_with("AUTHENTICATE XOAUTH2"));
615
616        expect_wants_read(&mut auth, &mut frag);
617
618        let creds = expect_wants_write(&mut auth, &mut frag, Some(b"+ \r\n"));
619        assert!(creds.ends_with(b"\r\n"));
620
621        expect_wants_read(&mut auth, &mut frag);
622
623        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
624        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
625    }
626
627    #[test]
628    fn non_ir_invalid_token_returns_no_with_error() {
629        let opts = ImapAuthXoauth2Options::default();
630        let mut auth = ImapAuthXoauth2::new("user@example.org", "expired-token", opts);
631        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
632
633        let bytes = expect_wants_write(&mut auth, &mut frag, None);
634        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
635
636        expect_wants_read(&mut auth, &mut frag);
637        expect_wants_write(&mut auth, &mut frag, Some(b"+ \r\n"));
638        expect_wants_read(&mut auth, &mut frag);
639
640        let (err_json_b64, err_json) = fake_json_error();
641        let challenge = format!("+ {err_json_b64}\r\n");
642        let ack = expect_wants_write(&mut auth, &mut frag, Some(challenge.as_bytes()));
643        assert_eq!(b"\r\n", &*ack);
644
645        expect_wants_read(&mut auth, &mut frag);
646
647        let reply = format!("{tag} NO SASL authentication failed\r\n");
648        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
649        let ImapAuthXoauth2Error::NoWithError { info, err } = err else {
650            panic!("expected ImapAuthXoauth2Error::NoWithError, got {err:?}");
651        };
652        assert_eq!(info, "SASL authentication failed");
653        assert_eq!(err, err_json);
654    }
655
656    fn expect_wants_write(
657        cor: &mut ImapAuthXoauth2,
658        frag: &mut Fragmentizer,
659        arg: Option<&[u8]>,
660    ) -> Vec<u8> {
661        match cor.resume(frag, arg) {
662            ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => bytes,
663            state => panic!("expected WantsWrite, got {state:?}"),
664        }
665    }
666
667    fn expect_wants_read(cor: &mut ImapAuthXoauth2, frag: &mut Fragmentizer) {
668        match cor.resume(frag, None) {
669            ImapCoroutineState::Yielded(ImapYield::WantsRead) => {}
670            state => panic!("expected WantsRead, got {state:?}"),
671        }
672    }
673
674    fn expect_complete_ok(cor: &mut ImapAuthXoauth2, frag: &mut Fragmentizer, reply: &[u8]) {
675        match cor.resume(frag, Some(reply)) {
676            ImapCoroutineState::Complete(Ok(_)) => {}
677            state => panic!("expected Complete(Ok), got {state:?}"),
678        }
679    }
680
681    fn expect_complete_err(
682        cor: &mut ImapAuthXoauth2,
683        frag: &mut Fragmentizer,
684        reply: &[u8],
685    ) -> ImapAuthXoauth2Error {
686        match cor.resume(frag, Some(reply)) {
687            ImapCoroutineState::Complete(Err(err)) => err,
688            state => panic!("expected Complete(Err), got {state:?}"),
689        }
690    }
691
692    fn first_word(line: &str) -> &str {
693        line.split_whitespace()
694            .next()
695            .expect("first whitespace-separated token")
696    }
697
698    fn fake_json_error() -> (&'static str, &'static str) {
699        (
700            "eyJzdGF0dXMiOiI0MDEiLCJzY2hlbWVzIjoiQmVhcmVyIiwic2NvcGUiOiJodHRwczovL21haWwuZ29vZ2xlLmNvbS8ifQ==",
701            "{\"status\":\"401\",\"schemes\":\"Bearer\",\"scope\":\"https://mail.google.com/\"}",
702        )
703    }
704}