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 only, for now. Nothing here is
14//! Google's but that choice, so that another provider's sign-in is one more
15//! arm of it.
16
17use crate::SyncError;
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD;
20use sioul_core::config::{Account, AccountKind};
21
22/// The initial client response, before base64 (the IMAP and SMTP clients encode it).
23pub fn xoauth2(user: &str, token: &str) -> String {
24    format!("user={user}\x01auth=Bearer {token}\x01\x01")
25}
26
27/// The same, as sent on the wire.
28pub fn xoauth2_base64(user: &str, token: &str) -> String {
29    STANDARD.encode(xoauth2(user, token))
30}
31
32/// What a refusal's challenge says, read for a person: Google's JSON status
33/// and scope, else the text as it came.
34pub fn reason(challenge: &[u8]) -> String {
35    let text = String::from_utf8_lossy(challenge).trim().to_string();
36    match serde_json::from_str::<serde_json::Value>(&text) {
37        Ok(json) => {
38            let status = json["status"].as_str().unwrap_or("");
39            let scope = json["scope"].as_str().unwrap_or("");
40            [status, scope].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ")
41        }
42        Err(_) => text,
43    }
44}
45
46/// XOAUTH2 for async-imap's `AUTHENTICATE`: the token at the first
47/// continuation, an empty line at the next one (the refusal's reason, kept).
48pub(crate) struct ImapXoauth2 {
49    response: String,
50    sent: bool,
51    /// The server's reason, when it refused.
52    pub refusal: Option<String>,
53}
54
55impl ImapXoauth2 {
56    pub fn new(user: &str, token: &str) -> ImapXoauth2 {
57        ImapXoauth2 { response: xoauth2(user, token), sent: false, refusal: None }
58    }
59}
60
61impl async_imap::Authenticator for &mut ImapXoauth2 {
62    type Response = Vec<u8>;
63
64    fn process(&mut self, challenge: &[u8]) -> Vec<u8> {
65        if self.sent {
66            self.refusal = Some(reason(challenge));
67            return Vec::new();
68        }
69        self.sent = true;
70        self.response.clone().into_bytes()
71    }
72}
73
74/// `AUTH XOAUTH2` on an SMTP connection already encrypted and greeted: Ok
75/// when accepted, the refusal's words otherwise (after the empty line its
76/// challenge asks). mail-send's own XOAUTH2 answers that challenge with the
77/// token again, not with the empty line Google's page asks: done here instead.
78pub(crate) async fn smtp_xoauth2<T>(client: &mut mail_send::SmtpClient<T>, user: &str, token: &str) -> Result<Result<(), String>, mail_send::Error>
79where
80    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
81{
82    let reply = client.cmd(format!("AUTH XOAUTH2 {}\r\n", xoauth2_base64(user, token))).await?;
83    match reply.code() {
84        235 => Ok(Ok(())),
85        334 => {
86            let why = STANDARD.decode(reply.message().trim()).map(|b| reason(&b)).unwrap_or_default();
87            let end = client.cmd(b"\r\n").await?;
88            Ok(Err(format!("{} {} ({why})", end.code(), end.message()).trim().to_string()))
89        }
90        code => Ok(Err(format!("{code} {}", reply.message()))),
91    }
92}
93
94/// Who gives an account its access tokens, when it signs in with OAuth
95/// rather than a password.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum OAuth {
98    /// Google's sign-in, the mail grant kept in the keyring (`google::Purpose::Mail`).
99    Google,
100}
101
102impl OAuth {
103    /// The provider of a mail account that signs in with one (`auth = "google"`);
104    /// None: a password. Calendars signed in with Google are not mail: their
105    /// token is asked by the DAV client.
106    pub fn of(account: &Account) -> Option<OAuth> {
107        match (account.auth.as_deref(), account.kind) {
108            (Some("google"), AccountKind::Imap) => Some(OAuth::Google),
109            _ => None,
110        }
111    }
112
113    /// An access token for `login`'s mail: kept in memory while it lasts,
114    /// else asked with the refresh token; `fresh` asks a new one (after a refusal).
115    pub fn token(self, login: &str, fresh: bool) -> Result<String, SyncError> {
116        match self {
117            OAuth::Google => crate::google::access_token_for(crate::google::Purpose::Mail, login, fresh),
118        }
119    }
120
121    /// The same, from async code: the token is asked over blocking HTTP.
122    pub(crate) async fn token_async(self, login: &str, fresh: bool) -> Result<String, SyncError> {
123        let login = login.to_string();
124        tokio::task::spawn_blocking(move || self.token(&login, fresh)).await.map_err(|e| SyncError::Network(e.to_string()))?
125    }
126
127    /// What a token refused even fresh says: the access must be given again
128    /// (it was withdrawn, or the mail was not ticked on the provider's page).
129    pub(crate) fn refused(self, detail: &str) -> SyncError {
130        let _ = detail;
131        match self {
132            OAuth::Google => SyncError::Login(crate::google::SIGN_IN_AGAIN.into()),
133        }
134    }
135
136    /// What stands for the account's password, so that a watcher parked
137    /// after a refusal sees a new sign-in as a new credential (`fetch::Parked`):
138    /// the grant's mark. None kept: the sign-in must be done again.
139    pub(crate) fn mark(self, login: &str) -> Result<String, SyncError> {
140        match self {
141            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())),
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
150    use tokio::net::TcpListener;
151
152    /// Google's own example, character for character.
153    #[test]
154    fn xoauth2_as_google_writes_it() {
155        let raw = xoauth2("someuser@example.com", "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg");
156        assert_eq!(raw, "user=someuser@example.com\u{1}auth=Bearer ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg\u{1}\u{1}");
157        assert_eq!(
158            xoauth2_base64("someuser@example.com", "ya29.vF9dft4qmTc2Nvb3RlckBhdHRhdmlzdGEuY29tCg"),
159            "dXNlcj1zb21ldXNlckBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5LnZGOWRmdDRxbVRjMk52YjNSbGNrQmhkSFJoZG1semRHRXVZMjl0Q2cBAQ=="
160        );
161        // Its refusal, decoded: the status and the scope it wanted.
162        let challenge = STANDARD.decode("eyJzdGF0dXMiOiI0MDEiLCJzY2hlbWVzIjoiYmVhcmVyIG1hYyIsInNjb3BlIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vIn0K").unwrap();
163        assert_eq!(reason(&challenge), "401 https://mail.google.com/");
164        assert_eq!(reason(b"no JSON here"), "no JSON here");
165    }
166
167    #[test]
168    fn only_mail_accounts_signed_in_with_google_use_tokens() {
169        let mut mail = Account::imap("g", "you@gmail.com", "imap.gmail.com", 993, sioul_core::config::Security::Tls, None);
170        assert_eq!(OAuth::of(&mail), None, "a password, by default");
171        mail.auth = Some("google".into());
172        assert_eq!(OAuth::of(&mail), Some(OAuth::Google));
173        let mut calendars = Account::dav("c", "you@gmail.com", "apidata.googleusercontent.com", None, None);
174        calendars.auth = Some("google".into());
175        assert_eq!(OAuth::of(&calendars), None, "calendars ask their token themselves");
176    }
177
178    /// An IMAP server standing in for Gmail's, without TLS: it takes one
179    /// token and refuses any other the way Google does (a JSON challenge,
180    /// the empty line awaited, then NO). What the client sent is returned.
181    async fn imap_stand_in(listener: TcpListener, accepted: &'static str, connections: usize) -> Vec<String> {
182        let mut heard = Vec::new();
183        for _ in 0..connections {
184            let (stream, _) = listener.accept().await.unwrap();
185            let (read, mut write) = stream.into_split();
186            let mut lines = BufReader::new(read).lines();
187            write.write_all(b"* OK [CAPABILITY IMAP4rev1 AUTH=XOAUTH2 AUTH=PLAIN] Stand-in ready\r\n").await.unwrap();
188            let command = lines.next_line().await.unwrap().unwrap();
189            heard.push(command.clone());
190            let tag = command.split(' ').next().unwrap().to_string();
191            assert!(command.ends_with("AUTHENTICATE XOAUTH2"), "{command}");
192            write.write_all(b"+ \r\n").await.unwrap();
193            let response = lines.next_line().await.unwrap().unwrap();
194            heard.push(response.clone());
195            if response == xoauth2_base64("you@example.org", accepted) {
196                write.write_all(format!("{tag} OK Success\r\n").as_bytes()).await.unwrap();
197                let logout = lines.next_line().await.unwrap().unwrap();
198                let logout_tag = logout.split(' ').next().unwrap().to_string();
199                write.write_all(format!("* BYE\r\n{logout_tag} OK\r\n").as_bytes()).await.unwrap();
200            } else {
201                write.write_all(b"+ eyJzdGF0dXMiOiI0MDEiLCJzY2hlbWVzIjoiYmVhcmVyIG1hYyIsInNjb3BlIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vIn0K\r\n").await.unwrap();
202                let empty = lines.next_line().await.unwrap().unwrap();
203                heard.push(format!("[{empty}]"));
204                write.write_all(format!("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)\r\n").as_bytes()).await.unwrap();
205            }
206        }
207        heard
208    }
209
210    #[test]
211    fn imap_authenticate_xoauth2_with_a_fresh_token_after_a_refusal() {
212        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
213        runtime.block_on(async {
214            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
215            let port = listener.local_addr().unwrap().port();
216            let server = tokio::spawn(imap_stand_in(listener, "fresh-token", 2));
217            let connect = || async move {
218                let tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| SyncError::Network(e.to_string()))?;
219                let mut client = async_imap::Client::new(tcp);
220                client.read_response().await.map_err(|e| SyncError::Network(e.to_string()))?;
221                Ok(client)
222            };
223            let asked = std::sync::Mutex::new(Vec::new());
224            let token = |fresh: bool| {
225                asked.lock().unwrap().push(fresh);
226                async move { Ok::<_, SyncError>(if fresh { "fresh-token".to_string() } else { "stale-token".to_string() }) }
227            };
228            let mut session = crate::imap::sign_in_with_tokens("you@example.org", connect, token, OAuth::Google).await.unwrap();
229            session.logout().await.unwrap();
230            let heard = server.await.unwrap();
231            assert_eq!(*asked.lock().unwrap(), vec![false, true], "the kept token, then a fresh one, once");
232            assert_eq!(heard[1], xoauth2_base64("you@example.org", "stale-token"));
233            assert_eq!(heard[2], "[]", "Google's challenge answered with an empty line");
234            assert_eq!(heard[4], xoauth2_base64("you@example.org", "fresh-token"));
235        });
236    }
237
238    #[test]
239    fn imap_refusing_even_a_fresh_token_asks_to_sign_in_again() {
240        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
241        runtime.block_on(async {
242            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
243            let port = listener.local_addr().unwrap().port();
244            let server = tokio::spawn(imap_stand_in(listener, "never-given", 2));
245            let connect = || async move {
246                let tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.map_err(|e| SyncError::Network(e.to_string()))?;
247                let mut client = async_imap::Client::new(tcp);
248                client.read_response().await.map_err(|e| SyncError::Network(e.to_string()))?;
249                Ok(client)
250            };
251            let token = |fresh: bool| async move { Ok::<_, SyncError>(format!("token-{fresh}")) };
252            let refused = crate::imap::sign_in_with_tokens("you@example.org", connect, token, OAuth::Google).await.err();
253            assert_eq!(refused, Some(SyncError::Login(crate::google::SIGN_IN_AGAIN.into())));
254            assert_eq!(server.await.unwrap().len(), 6, "two tries, no third");
255            // No token at all (no grant here): said as is, nothing sent.
256            let none = |_: bool| async move { Err::<String, _>(SyncError::Login(crate::google::SIGN_IN_AGAIN.into())) };
257            let never = || async move { Err::<async_imap::Client<tokio::net::TcpStream>, _>(SyncError::Network("not reached".into())) };
258            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())));
259        });
260    }
261
262    /// An SMTP server standing in for Gmail's, without TLS: the first token
263    /// refused as Google does (334 with its reason, the empty line, 535), the
264    /// second accepted. What the client sent is returned.
265    #[test]
266    fn smtp_auth_xoauth2_as_google_answers() {
267        let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
268        runtime.block_on(async {
269            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
270            let port = listener.local_addr().unwrap().port();
271            let server = tokio::spawn(async move {
272                let (stream, _) = listener.accept().await.unwrap();
273                let (read, mut write) = stream.into_split();
274                let mut lines = BufReader::new(read).lines();
275                let mut heard = Vec::new();
276                write.write_all(b"220 stand-in ESMTP\r\n").await.unwrap();
277                let ehlo = lines.next_line().await.unwrap().unwrap();
278                heard.push(ehlo);
279                write.write_all(b"250-stand-in\r\n250-AUTH LOGIN PLAIN XOAUTH2 OAUTHBEARER\r\n250 8BITMIME\r\n").await.unwrap();
280                for accepted in [false, true] {
281                    let auth = lines.next_line().await.unwrap().unwrap();
282                    heard.push(auth);
283                    if accepted {
284                        write.write_all(b"235 2.7.0 Accepted\r\n").await.unwrap();
285                    } else {
286                        write.write_all(b"334 eyJzdGF0dXMiOiI0MDEiLCJzY2hlbWVzIjoiYmVhcmVyIG1hYyIsInNjb3BlIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vIn0K\r\n").await.unwrap();
287                        let empty = lines.next_line().await.unwrap().unwrap();
288                        heard.push(format!("[{empty}]"));
289                        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();
290                    }
291                }
292                heard
293            });
294            let builder = mail_send::SmtpClientBuilder::new("127.0.0.1", port).unwrap().helo_host("[127.0.0.1]");
295            let mut client = builder.connect_plain().await.unwrap();
296            let refused = smtp_xoauth2(&mut client, "you@example.org", "stale-token").await.unwrap();
297            assert!(refused.as_ref().is_err_and(|why| why.starts_with("535") && why.ends_with("(401 https://mail.google.com/)")), "{refused:?}");
298            assert_eq!(smtp_xoauth2(&mut client, "you@example.org", "fresh-token").await.unwrap(), Ok(()));
299            let heard = server.await.unwrap();
300            assert_eq!(heard[1], format!("AUTH XOAUTH2 {}", xoauth2_base64("you@example.org", "stale-token")));
301            assert_eq!(heard[2], "[]", "Google's challenge answered with an empty line");
302            assert_eq!(heard[3], format!("AUTH XOAUTH2 {}", xoauth2_base64("you@example.org", "fresh-token")));
303        });
304    }
305}