Skip to main content

io_imap/rfc7628/
auth_oauthbearer.rs

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