1use crate::SyncError;
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD;
20use sioul_core::config::{Account, AccountKind};
21
22pub fn xoauth2(user: &str, token: &str) -> String {
24 format!("user={user}\x01auth=Bearer {token}\x01\x01")
25}
26
27pub fn xoauth2_base64(user: &str, token: &str) -> String {
29 STANDARD.encode(xoauth2(user, token))
30}
31
32pub 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
46pub(crate) struct ImapXoauth2 {
49 response: String,
50 sent: bool,
51 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
74pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum OAuth {
98 Google,
100}
101
102impl OAuth {
103 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 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 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 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 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 #[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 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 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 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 #[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}