Skip to main content

sioul_sync/
sasl.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Signing in to IMAP and SMTP with an OAuth 2.0 access token instead of a
5//! password: SASL XOAUTH2, as Google defines it
6//! (<https://developers.google.com/workspace/gmail/imap/xoauth2-protocol>), and
7//! as Microsoft takes it too. The client's first answer is
8//! `base64("user=" user "\x01auth=Bearer " token "\x01\x01")`; a refused
9//! token brings a last challenge, the reason as base64 JSON
10//! (`{"status":"401","schemes":"bearer mac","scope":"https://mail.google.com/"}`),
11//! answered with an empty line, then the refusal (`NO`, `535`).
12//!
13//! Who gives the tokens is [`OAuth`]: Google, or Microsoft, whose refusal
14//! comes without a challenge (`NO AUTHENTICATE failed.`, `535 5.7.3`), and
15//! whose refusals that no token changes (a mailbox closed to IMAP, sending
16//! switched off) are said apart (`not_connected`, `smtp_refused`). Nothing
17//! else here is either provider's.
18
19use crate::SyncError;
20use base64::Engine;
21use base64::engine::general_purpose::STANDARD;
22use sioul_core::config::{Account, AccountKind};
23
24/// The initial client response, before base64 (the IMAP and SMTP clients encode it).
25pub fn xoauth2(user: &str, token: &str) -> String {
26    format!("user={user}\x01auth=Bearer {token}\x01\x01")
27}
28
29/// The same, as sent on the wire.
30pub fn xoauth2_base64(user: &str, token: &str) -> String {
31    STANDARD.encode(xoauth2(user, token))
32}
33
34/// What a refusal's challenge says, read for a person: Google's JSON status
35/// and scope, else the text as it came.
36pub fn reason(challenge: &[u8]) -> String {
37    let text = String::from_utf8_lossy(challenge).trim().to_string();
38    match serde_json::from_str::<serde_json::Value>(&text) {
39        Ok(json) => {
40            let status = json["status"].as_str().unwrap_or("");
41            let scope = json["scope"].as_str().unwrap_or("");
42            [status, scope].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ")
43        }
44        Err(_) => text,
45    }
46}
47
48/// XOAUTH2 for async-imap's `AUTHENTICATE`: the token at the first
49/// continuation, an empty line at the next one (the refusal's reason, kept).
50pub(crate) struct ImapXoauth2 {
51    response: String,
52    sent: bool,
53    /// The server's reason, when it refused.
54    pub refusal: Option<String>,
55}
56
57impl ImapXoauth2 {
58    pub fn new(user: &str, token: &str) -> ImapXoauth2 {
59        ImapXoauth2 { response: xoauth2(user, token), sent: false, refusal: None }
60    }
61}
62
63impl async_imap::Authenticator for &mut ImapXoauth2 {
64    type Response = Vec<u8>;
65
66    fn process(&mut self, challenge: &[u8]) -> Vec<u8> {
67        if self.sent {
68            self.refusal = Some(reason(challenge));
69            return Vec::new();
70        }
71        self.sent = true;
72        self.response.clone().into_bytes()
73    }
74}
75
76/// `AUTH XOAUTH2` on an SMTP connection already encrypted and greeted: Ok
77/// when accepted, the refusal's words otherwise (after the empty line its
78/// challenge asks). mail-send's own XOAUTH2 answers that challenge with the
79/// token again, not with the empty line Google's page asks: done here instead.
80pub(crate) async fn smtp_xoauth2<T>(client: &mut mail_send::SmtpClient<T>, user: &str, token: &str) -> Result<Result<(), String>, mail_send::Error>
81where
82    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
83{
84    let reply = client.cmd(format!("AUTH XOAUTH2 {}\r\n", xoauth2_base64(user, token))).await?;
85    match reply.code() {
86        235 => Ok(Ok(())),
87        334 => {
88            let why = STANDARD.decode(reply.message().trim()).map(|b| reason(&b)).unwrap_or_default();
89            let end = client.cmd(b"\r\n").await?;
90            Ok(Err(format!("{} ({why})", said(end.code(), end.esc, end.message())).trim().to_string()))
91        }
92        _ => Ok(Err(said(reply.code(), reply.esc, reply.message()))),
93    }
94}
95
96/// A reply as the server wrote it: its code, its enhanced status code
97/// (RFC 3463, "5.7.139", which the parser keeps apart), its words.
98fn said(code: u16, esc: [u8; 3], message: &str) -> String {
99    let [class, subject, detail] = esc;
100    let enhanced = if class == 0 { String::new() } else { format!(" {class}.{subject}.{detail}") };
101    format!("{code}{enhanced} {message}").trim().to_string()
102}
103
104/// Who gives an account its access tokens, when it signs in with OAuth
105/// rather than a password.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum OAuth {
108    /// Google's sign-in, the mail grant kept in the keyring (`google::Purpose::Mail`).
109    Google,
110    /// Microsoft's sign-in (`microsoft.rs`); `personal`: an Outlook.com
111    /// account (its `microsoft_kind` says so), else a work or school one.
112    Microsoft { personal: bool },
113}
114
115impl OAuth {
116    /// The provider of a mail account that signs in with one (`auth =
117    /// "google"`, `auth = "microsoft"`); None: a password. Calendars signed
118    /// in with Google are not mail: their token is asked by the DAV client.
119    pub fn of(account: &Account) -> Option<OAuth> {
120        match (account.auth.as_deref(), account.kind) {
121            (Some("google"), AccountKind::Imap) => Some(OAuth::Google),
122            (Some("microsoft"), AccountKind::Imap) => Some(OAuth::Microsoft { personal: !account.is_microsoft_work() }),
123            _ => None,
124        }
125    }
126
127    /// An access token for `login`'s mail: kept in memory while it lasts,
128    /// else asked with the refresh token; `fresh` asks a new one (after a refusal).
129    pub fn token(self, login: &str, fresh: bool) -> Result<String, SyncError> {
130        match self {
131            OAuth::Google => crate::google::access_token_for(crate::google::Purpose::Mail, login, fresh),
132            OAuth::Microsoft { .. } => crate::microsoft::access_token(login, fresh),
133        }
134    }
135
136    /// The same, from async code: the token is asked over blocking HTTP.
137    pub(crate) async fn token_async(self, login: &str, fresh: bool) -> Result<String, SyncError> {
138        let login = login.to_string();
139        tokio::task::spawn_blocking(move || self.token(&login, fresh)).await.map_err(|e| SyncError::Network(e.to_string()))?
140    }
141
142    /// Whether a server may be given this provider's token: Microsoft's
143    /// only to Microsoft's own servers, named exactly (a configuration that
144    /// names another is refused before any token is asked). Google's as before.
145    pub(crate) fn may_reach(self, host: &str, smtp: bool) -> Result<(), SyncError> {
146        match self {
147            OAuth::Google => Ok(()),
148            OAuth::Microsoft { .. } if (smtp && crate::microsoft::is_smtp_host(host)) || (!smtp && crate::microsoft::is_imap_host(host)) => Ok(()),
149            // A test build's stand-in token, for a local server (never in Sioul's builds).
150            OAuth::Microsoft { .. } if crate::microsoft::test_token().is_some() => Ok(()),
151            OAuth::Microsoft { .. } => Err(SyncError::Said(crate::microsoft::OTHER_HOST, host.to_string())),
152        }
153    }
154
155    /// What a token refused even fresh says: the access must be given again
156    /// (it was withdrawn, or the mail was not ticked on the provider's page).
157    pub(crate) fn refused(self, detail: &str) -> SyncError {
158        let _ = detail;
159        match self {
160            OAuth::Google => SyncError::Login(crate::google::SIGN_IN_AGAIN.into()),
161            OAuth::Microsoft { .. } => SyncError::Login(crate::microsoft::AGAIN.into()),
162        }
163    }
164
165    /// An IMAP refusal that a fresh token does not change, said for what it
166    /// is: Microsoft's "User is authenticated but not connected" (IMAP off for
167    /// the mailbox). None: try a fresh token.
168    pub(crate) fn not_connected(self, refusal: &str) -> Option<SyncError> {
169        match self {
170            OAuth::Google => None,
171            OAuth::Microsoft { personal } => crate::microsoft::not_connected(personal, refusal),
172        }
173    }
174
175    /// An SMTP refusal that a fresh token does not change: Microsoft's
176    /// Authenticated SMTP switched off (`5.7.139`). None: try a fresh token.
177    pub(crate) fn smtp_refused(self, refusal: &str) -> Option<SyncError> {
178        match self {
179            OAuth::Google => None,
180            OAuth::Microsoft { .. } => crate::microsoft::smtp_refused(refusal),
181        }
182    }
183
184    /// What stands for the account's password, so that a watcher parked
185    /// after a refusal sees a new sign-in as a new credential (`fetch::Parked`):
186    /// the grant's mark. None kept: the sign-in must be done again.
187    pub(crate) fn mark(self, login: &str) -> Result<String, SyncError> {
188        match self {
189            OAuth::Google => crate::google::grant_for(crate::google::Purpose::Mail, login).map(|g| format!("google:{}", g.signed_in)).ok_or_else(|| SyncError::Login(crate::google::SIGN_IN_AGAIN.into())),
190            // The time of the sign-in on Microsoft's page: a refresh, which renews the token, does not change it.
191            OAuth::Microsoft { .. } => crate::microsoft::grant(login).map(|g| format!("microsoft:{}", g.signed_in)).ok_or_else(|| SyncError::Login(crate::microsoft::AGAIN.into())),
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
200    use tokio::net::TcpListener;
201
202    /// Google's own example, character for character.
203    #[test]
204    fn xoauth2_as_google_writes_it() {
205        let raw = xoauth2("someuser@example.com", "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg");
206        assert_eq!(raw, "user=someuser@example.com\u{1}auth=Bearer ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg\u{1}\u{1}");
207        assert_eq!(
208            xoauth2_base64("someuser@example.com", "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg"),
209            "dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ=="
210        );
211        // Its refusal, decoded: the status and the scope it wanted.
212        let challenge = STANDARD.decode("eyJzdGF0dXMiOiI0MDEiLCJzY2hlbWVzIjoiYmVhcmVyIG1hYyIsInNjb3BlIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vIn0K").unwrap();
213        assert_eq!(reason(&challenge), "401 https://mail.google.com/");
214        assert_eq!(reason(b"no JSON here"), "no JSON here");
215    }
216
217    #[test]
218    fn only_mail_accounts_signed_in_with_google_use_tokens() {
219        let mut mail = Account::imap("g", "you@gmail.com", "imap.gmail.com", 993, sioul_core::config::Security::Tls, None);
220        assert_eq!(OAuth::of(&mail), None, "a password, by default");
221        mail.auth = Some("google".into());
222        assert_eq!(OAuth::of(&mail), Some(OAuth::Google));
223        let mut calendars = Account::dav("c", "you@gmail.com", "apidata.googleusercontent.com", None, None);
224        calendars.auth = Some("google".into());
225        assert_eq!(OAuth::of(&calendars), None, "calendars ask their token themselves");
226    }
227
228    /// An IMAP server standing in for Gmail's, without TLS: it takes one
229    /// token and refuses any other the way Google does (a JSON challenge,
230    /// the empty line awaited, then NO). What the client sent is returned.
231    async fn imap_stand_in(listener: TcpListener, accepted: &'static str, connections: usize) -> Vec<String> {
232        let mut heard = Vec::new();
233        for _ in 0..connections {
234            let (stream, _) = listener.accept().await.unwrap();
235            let (read, mut write) = stream.into_split();
236            let mut lines = BufReader::new(read).lines();
237            write.write_all(b"* OK [CAPABILITY IMAP4rev1 AUTH=XOAUTH2 AUTH=PLAIN] Stand-in ready\r\n").await.unwrap();
238            let command = lines.next_line().await.unwrap().unwrap();
239            heard.push(command.clone());
240            let tag = command.split(' ').next().unwrap().to_string();
241            assert!(command.ends_with("AUTHENTICATE XOAUTH2"), "{command}");
242            write.write_all(b"+ \r\n").await.unwrap();
243            let response = lines.next_line().await.unwrap().unwrap();
244            heard.push(response.clone());
245            if response == xoauth2_base64("you@example.org", accepted) {
246                write.write_all(format!("{tag} OK Success\r\n").as_bytes()).await.unwrap();
247                let logout = lines.next_line().await.unwrap().unwrap();
248                let logout_tag = logout.split(' ').next().unwrap().to_string();
249                write.write_all(format!("* BYE\r\n{logout_tag} OK\r\n").as_bytes()).await.unwrap();
250            } else {
251                write.write_all(b"+ eyJzdGF0dXMiOiI0MDEiLCJzY2hlbWVzIjoiYmVhcmVyIG1hYyIsInNjb3BlIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vIn0K\r\n").await.unwrap();
252                let empty = lines.next_line().await.unwrap().unwrap();
253                heard.push(format!("[{empty}]"));
254                write.write_all(format!("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)\r\n").as_bytes()).await.unwrap();
255            }
256        }
257        heard
258    }
259
260    #[test]
261    fn imap_authenticate_xoauth2_with_a_fresh_token_after_a_refusal() {
262        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
263        runtime.block_on(async {
264            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
265            let port = listener.local_addr().unwrap().port();
266            let server = tokio::spawn(imap_stand_in(listener, "fresh-token", 2));
267            let connect = || async move {
268                let tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| SyncError::Network(e.to_string()))?;
269                let mut client = async_imap::Client::new(tcp);
270                client.read_response().await.map_err(|e| SyncError::Network(e.to_string()))?;
271                Ok(client)
272            };
273            let asked = std::sync::Mutex::new(Vec::new());
274            let token = |fresh: bool| {
275                asked.lock().unwrap().push(fresh);
276                async move { Ok::<_, SyncError>(if fresh { "fresh-token".to_string() } else { "stale-token".to_string() }) }
277            };
278            let mut session = crate::imap::sign_in_with_tokens("you@example.org", connect, token, OAuth::Google).await.unwrap();
279            session.logout().await.unwrap();
280            let heard = server.await.unwrap();
281            assert_eq!(*asked.lock().unwrap(), vec![false, true], "the kept token, then a fresh one, once");
282            assert_eq!(heard[1], xoauth2_base64("you@example.org", "stale-token"));
283            assert_eq!(heard[2], "[]", "Google's challenge answered with an empty line");
284            assert_eq!(heard[4], xoauth2_base64("you@example.org", "fresh-token"));
285        });
286    }
287
288    #[test]
289    fn imap_refusing_even_a_fresh_token_asks_to_sign_in_again() {
290        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
291        runtime.block_on(async {
292            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
293            let port = listener.local_addr().unwrap().port();
294            let server = tokio::spawn(imap_stand_in(listener, "never-given", 2));
295            let connect = || async move {
296                let tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| SyncError::Network(e.to_string()))?;
297                let mut client = async_imap::Client::new(tcp);
298                client.read_response().await.map_err(|e| SyncError::Network(e.to_string()))?;
299                Ok(client)
300            };
301            let token = |fresh: bool| async move { Ok::<_, SyncError>(format!("token-{fresh}")) };
302            let refused = crate::imap::sign_in_with_tokens("you@example.org", connect, token, OAuth::Google).await.err();
303            assert_eq!(refused, Some(SyncError::Login(crate::google::SIGN_IN_AGAIN.into())));
304            assert_eq!(server.await.unwrap().len(), 6, "two tries, no third");
305            // No token at all (no grant here): said as is, nothing sent.
306            let none = |_: bool| async move { Err::<String, _>(SyncError::Login(crate::google::SIGN_IN_AGAIN.into())) };
307            let never = || async move { Err::<async_imap::Client<tokio::net::TcpStream>, _>(SyncError::Network("not reached".into())) };
308            assert_eq!(crate::imap::sign_in_with_tokens("you@example.org", never, none, OAuth::Google).await.err(), Some(SyncError::Login(crate::google::SIGN_IN_AGAIN.into())));
309        });
310    }
311
312    /// Part 8, 8: an IMAP server standing in for Microsoft's (Exchange
313    /// Online), without TLS. A refused token gets `NO AUTHENTICATE failed.`,
314    /// no challenge before it: a fresh token, once, then sign in again. A
315    /// mailbox closed to IMAP answers `BAD User is authenticated but not
316    /// connected.`: said at once, no second token asked (it would change nothing).
317    #[test]
318    fn exchange_refusals_over_imap() {
319        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
320        runtime.block_on(async {
321            let cases = [
322                ("NO AUTHENTICATE failed.", 2, SyncError::Login(crate::microsoft::AGAIN.into()), vec![false, true]),
323                ("BAD User is authenticated but not connected.", 1, SyncError::Said(crate::microsoft::NOT_CONNECTED_PERSONAL, "User is authenticated but not connected.".into()), vec![false]),
324            ];
325            for (answer, connections, expected, tokens) in cases {
326                let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
327                let port = listener.local_addr().unwrap().port();
328                let server = tokio::spawn(async move {
329                    let mut heard = Vec::new();
330                    for _ in 0..connections {
331                        let (stream, _) = listener.accept().await.unwrap();
332                        let (read, mut write) = stream.into_split();
333                        let mut lines = BufReader::new(read).lines();
334                        write.write_all(b"* OK The Microsoft Exchange IMAP4 service is ready.\r\n").await.unwrap();
335                        let command = lines.next_line().await.unwrap().unwrap();
336                        let tag = command.split(' ').next().unwrap().to_string();
337                        write.write_all(b"+ \r\n").await.unwrap();
338                        heard.push(lines.next_line().await.unwrap().unwrap());
339                        write.write_all(format!("{tag} {answer}\r\n").as_bytes()).await.unwrap();
340                    }
341                    heard
342                });
343                let connect = || async move {
344                    let tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| SyncError::Network(e.to_string()))?;
345                    let mut client = async_imap::Client::new(tcp);
346                    client.read_response().await.map_err(|e| SyncError::Network(e.to_string()))?;
347                    Ok(client)
348                };
349                let asked = std::sync::Mutex::new(Vec::new());
350                let token = |fresh: bool| {
351                    asked.lock().unwrap().push(fresh);
352                    async move { Ok::<_, SyncError>(if fresh { "fresh".to_string() } else { "kept".to_string() }) }
353                };
354                let refused = crate::imap::sign_in_with_tokens("you@outlook.com", connect, token, OAuth::Microsoft { personal: true }).await.err();
355                assert_eq!(refused, Some(expected), "{answer}");
356                assert_eq!(*asked.lock().unwrap(), tokens, "{answer}");
357                let heard = server.await.unwrap();
358                assert_eq!(heard[0], xoauth2_base64("you@outlook.com", "kept"), "Microsoft's format, Google's: one and the same");
359            }
360        });
361        // Mail signed in with Microsoft uses tokens; its kind is its own (`microsoft_kind`).
362        let mut mail = Account::imap("o", "you@outlook.com", "outlook.office365.com", 993, sioul_core::config::Security::Tls, None);
363        mail.auth = Some("microsoft".into());
364        assert_eq!(OAuth::of(&mail), Some(OAuth::Microsoft { personal: false }), "not known yet: an organisation's, the cautious side");
365        mail.smtp_host = Some("smtp-mail.outlook.com".into());
366        assert_eq!(OAuth::of(&mail), Some(OAuth::Microsoft { personal: false }), "the sending server says nothing of it");
367        mail.microsoft_kind = Some("personal".into());
368        assert_eq!(OAuth::of(&mail), Some(OAuth::Microsoft { personal: true }));
369    }
370
371    /// An SMTP server standing in for Gmail's, without TLS: the first token
372    /// refused as Google does (334 with its reason, the empty line, 535), the
373    /// second accepted. What the client sent is returned.
374    #[test]
375    fn smtp_auth_xoauth2_as_google_answers() {
376        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
377        runtime.block_on(async {
378            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
379            let port = listener.local_addr().unwrap().port();
380            let server = tokio::spawn(async move {
381                let (stream, _) = listener.accept().await.unwrap();
382                let (read, mut write) = stream.into_split();
383                let mut lines = BufReader::new(read).lines();
384                let mut heard = Vec::new();
385                write.write_all(b"220 stand-in ESMTP\r\n").await.unwrap();
386                let ehlo = lines.next_line().await.unwrap().unwrap();
387                heard.push(ehlo);
388                write.write_all(b"250-stand-in\r\n250-AUTH LOGIN PLAIN XOAUTH2 OAUTHBEARER\r\n250 8BITMIME\r\n").await.unwrap();
389                for accepted in [false, true] {
390                    let auth = lines.next_line().await.unwrap().unwrap();
391                    heard.push(auth);
392                    if accepted {
393                        write.write_all(b"235 2.7.0 Accepted\r\n").await.unwrap();
394                    } else {
395                        write.write_all(b"334 eyJzdGF0dXMiOiI0MDEiLCJzY2hlbWVzIjoiYmVhcmVyIG1hYyIsInNjb3BlIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vIn0K\r\n").await.unwrap();
396                        let empty = lines.next_line().await.unwrap().unwrap();
397                        heard.push(format!("[{empty}]"));
398                        write.write_all(b"535-5.7.8 Username and Password not accepted.\r\n535 5.7.8 https://support.google.com/mail/?p=BadCredentials\r\n").await.unwrap();
399                    }
400                }
401                heard
402            });
403            let builder = mail_send::SmtpClientBuilder::new("127.0.0.1", port).unwrap().helo_host("[127.0.0.1]");
404            let mut client = builder.connect_plain().await.unwrap();
405            let refused = smtp_xoauth2(&mut client, "you@example.org", "stale-token").await.unwrap();
406            assert!(refused.as_ref().is_err_and(|why| why.starts_with("535") && why.ends_with("(401 https://mail.google.com/)")), "{refused:?}");
407            assert_eq!(smtp_xoauth2(&mut client, "you@example.org", "fresh-token").await.unwrap(), Ok(()));
408            let heard = server.await.unwrap();
409            assert_eq!(heard[1], format!("AUTH XOAUTH2 {}", xoauth2_base64("you@example.org", "stale-token")));
410            assert_eq!(heard[2], "[]", "Google's challenge answered with an empty line");
411            assert_eq!(heard[3], format!("AUTH XOAUTH2 {}", xoauth2_base64("you@example.org", "fresh-token")));
412        });
413    }
414}