1use crate::SyncError;
20use base64::Engine;
21use base64::engine::general_purpose::STANDARD;
22use sioul_core::config::{Account, AccountKind};
23
24pub fn xoauth2(user: &str, token: &str) -> String {
26 format!("user={user}\x01auth=Bearer {token}\x01\x01")
27}
28
29pub fn xoauth2_base64(user: &str, token: &str) -> String {
31 STANDARD.encode(xoauth2(user, token))
32}
33
34pub 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
48pub(crate) struct ImapXoauth2 {
51 response: String,
52 sent: bool,
53 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
76pub(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
96fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum OAuth {
108 Google,
110 Microsoft { personal: bool },
113}
114
115impl OAuth {
116 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 #[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 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 #[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}