1use crate::SyncError;
20use base64::Engine;
21use base64::engine::general_purpose::URL_SAFE_NO_PAD;
22use sha2::{Digest, Sha256};
23use std::collections::HashMap;
24use std::io::{BufRead, BufReader, Write};
25use std::net::TcpListener;
26use std::sync::Mutex;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::time::{Duration, Instant};
29
30pub const SCOPES: &str = "openid email https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav https://www.googleapis.com/auth/tasks";
33pub const MAIL_SCOPE: &str = "https://mail.google.com/";
35pub const MAIL_SCOPES: &str = "openid email https://mail.google.com/";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Purpose {
43 Pim,
45 Mail,
47}
48
49impl Purpose {
50 pub fn scopes(self) -> &'static str {
52 match self {
53 Purpose::Pim => SCOPES,
54 Purpose::Mail => MAIL_SCOPES,
55 }
56 }
57
58 fn other(self) -> Purpose {
60 match self {
61 Purpose::Pim => Purpose::Mail,
62 Purpose::Mail => Purpose::Pim,
63 }
64 }
65
66 fn keyring_name(self, email: &str) -> String {
68 let email = email.trim().to_lowercase();
69 match self {
70 Purpose::Pim => format!("google:{email}"),
71 Purpose::Mail => format!("google-mail:{email}"),
72 }
73 }
74}
75const AUTHORIZE: &str = "https://accounts.google.com/o/oauth2/v2/auth";
76const TOKEN: &str = "https://oauth2.googleapis.com/token";
77const REVOKE: &str = "https://oauth2.googleapis.com/revoke";
78pub const SIGN_IN_AGAIN: &str = "google-again";
80pub const IN_TESTING: &str = "google-testing";
83
84pub fn built_in() -> Option<(&'static str, &'static str)> {
88 match (option_env!("SIOUL_GOOGLE_CLIENT_ID"), option_env!("SIOUL_GOOGLE_CLIENT_SECRET")) {
89 (Some(id), Some(secret)) if !id.trim().is_empty() => Some((id, secret)),
90 _ => None,
91 }
92}
93pub fn caldav_start(email: &str) -> String {
95 format!("https://apidata.googleusercontent.com/caldav/v2/{}/user", email.trim())
96}
97pub const CARDDAV_START: &str = "https://www.googleapis.com/.well-known/carddav";
98
99#[derive(Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
101pub struct Grant {
102 pub client_id: String,
103 pub client_secret: String,
104 pub refresh_token: String,
105 pub scopes: Vec<String>,
106 #[serde(default)]
108 pub signed_in: i64,
109}
110
111impl std::fmt::Debug for Grant {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("Grant").field("client_id", &self.client_id).field("scopes", &self.scopes).field("signed_in", &self.signed_in).finish_non_exhaustive()
115 }
116}
117
118pub fn grant(email: &str) -> Option<Grant> {
120 grant_for(Purpose::Pim, email)
121}
122
123pub fn grant_for(purpose: Purpose, email: &str) -> Option<Grant> {
125 crate::secret::named(&purpose.keyring_name(email)).and_then(|t| serde_json::from_str(&t).ok())
126}
127
128pub fn own_key(email: &str) -> Option<(String, String)> {
132 let built_in = built_in().map(|(id, _)| id);
133 [Purpose::Mail, Purpose::Pim]
134 .into_iter()
135 .filter_map(|purpose| grant_for(purpose, email))
136 .find(|g| !g.client_id.is_empty() && Some(g.client_id.as_str()) != built_in)
137 .map(|g| (g.client_id, g.client_secret))
138}
139
140fn random(bytes: usize) -> String {
141 let mut out = Vec::with_capacity(bytes);
142 while out.len() < bytes {
143 out.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
144 }
145 out.truncate(bytes);
146 URL_SAFE_NO_PAD.encode(out)
147}
148
149pub struct SignIn {
151 listener: TcpListener,
152 verifier: String,
153 state: String,
154 redirect: String,
155 purpose: Purpose,
156 pub url: String,
158}
159
160fn query(pairs: &[(&str, &str)]) -> String {
161 pairs.iter().map(|(k, v)| format!("{k}={}", encode(v))).collect::<Vec<_>>().join("&")
162}
163
164pub(crate) fn encode(text: &str) -> String {
165 text.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
166}
167
168fn decode(text: &str) -> String {
169 let bytes = text.as_bytes();
170 let mut out = Vec::with_capacity(bytes.len());
171 let mut i = 0;
172 while i < bytes.len() {
173 match bytes[i] {
174 b'%' if i + 2 < bytes.len() => {
175 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
176 match hex {
177 Some(b) => {
178 out.push(b);
179 i += 3;
180 }
181 None => {
182 out.push(b'%');
183 i += 1;
184 }
185 }
186 }
187 b'+' => {
188 out.push(b' ');
189 i += 1;
190 }
191 b => {
192 out.push(b);
193 i += 1;
194 }
195 }
196 }
197 String::from_utf8_lossy(&out).to_string()
198}
199
200pub fn begin(client_id: &str, email: &str) -> Result<SignIn, SyncError> {
203 begin_for(Purpose::Pim, client_id, email)
204}
205
206pub fn begin_for(purpose: Purpose, client_id: &str, email: &str) -> Result<SignIn, SyncError> {
208 let listener = TcpListener::bind("127.0.0.1:0").or_else(|_| TcpListener::bind("[::1]:0")).map_err(|e| SyncError::Network(e.to_string()))?;
209 let address = listener.local_addr().map_err(|e| SyncError::Network(e.to_string()))?;
210 let redirect = if address.is_ipv6() { format!("http://[::1]:{}", address.port()) } else { format!("http://127.0.0.1:{}", address.port()) };
211 let verifier = random(48);
212 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
213 let state = random(16);
214 let url = format!(
215 "{AUTHORIZE}?{}",
216 query(&[
217 ("client_id", client_id.trim()),
218 ("redirect_uri", &redirect),
219 ("response_type", "code"),
220 ("scope", purpose.scopes()),
221 ("code_challenge", &challenge),
222 ("code_challenge_method", "S256"),
223 ("state", &state),
224 ("login_hint", email.trim()),
225 ])
226 );
227 Ok(SignIn { listener, verifier, state, redirect, purpose, url })
228}
229
230fn wait_for_code(sign_in: &SignIn, deadline: Duration, stop: &AtomicBool) -> Result<String, SyncError> {
233 sign_in.listener.set_nonblocking(true).map_err(|e| SyncError::Network(e.to_string()))?;
234 let until = Instant::now() + deadline;
235 loop {
236 if stop.load(Ordering::Relaxed) {
237 return Err(SyncError::Message("google-cancelled".into()));
238 }
239 let (mut stream, _) = match sign_in.listener.accept() {
243 Ok(accepted) => accepted,
244 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
245 if Instant::now() > until {
246 return Err(SyncError::Message("google-timeout".into()));
247 }
248 std::thread::sleep(Duration::from_millis(200));
249 continue;
250 }
251 Err(e) => return Err(SyncError::Network(e.to_string())),
252 };
253 let _ = stream.set_nonblocking(false);
254 let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
255 let mut line = String::new();
256 if BufReader::new(&stream).read_line(&mut line).is_err() {
257 continue;
258 }
259 let target = line.split_whitespace().nth(1).unwrap_or("");
261 let Some(params) = target.strip_prefix("/?") else {
262 let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
263 continue;
264 };
265 let params: HashMap<String, String> = params.split('&').filter_map(|p| p.split_once('=')).map(|(k, v)| (k.to_string(), decode(v))).collect();
266 let (said, result) = match (params.get("state"), params.get("code"), params.get("error")) {
267 (Some(state), Some(code), _) if subtle::ConstantTimeEq::ct_eq(state.as_bytes(), sign_in.state.as_bytes()).into() => ("google-page-granted", Ok(code.clone())),
268 (_, _, Some(_)) => ("google-page-denied", Err(SyncError::Message("google-denied".into()))),
269 _ => ("google-page-foreign", Err(SyncError::Message("google-state".into()))),
270 };
271 let said = if cfg!(target_os = "android") && said == "google-page-granted" { "google-page-granted-phone" } else { said };
274 let body = crate::translator().text(said, None).replace('&', "&").replace('<', "<");
275 let page = format!("<!doctype html><meta charset=utf-8><title>Sioul</title><p style=\"font:1.2em sans-serif;margin:3em\">{body}</p>");
276 let _ = stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len()).as_bytes());
277 return result;
278 }
279}
280
281fn post(url: &str, form: &[(&str, &str)]) -> Result<serde_json::Value, SyncError> {
282 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into();
283 let mut response = agent.post(url).header("Content-Type", "application/x-www-form-urlencoded").send(query(form)).map_err(|e| SyncError::Network(e.to_string()))?;
284 let status = response.status().as_u16();
285 let body: serde_json::Value = serde_json::from_str(&response.body_mut().read_to_string().unwrap_or_default()).unwrap_or_default();
286 if status == 200 {
287 return Ok(body);
288 }
289 let error = body["error"].as_str().unwrap_or("");
290 Err(if error == "invalid_grant" { SyncError::Login(SIGN_IN_AGAIN.into()) } else { SyncError::Server(format!("Google: {status} {error}")) })
291}
292
293fn email_of(id_token: &str) -> Option<String> {
295 let payload = id_token.split('.').nth(1)?;
296 let json: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?).ok()?;
297 json["email"].as_str().map(str::to_lowercase)
298}
299
300pub fn finish(sign_in: SignIn, client_id: &str, client_secret: &str, email: &str, stop: &AtomicBool) -> Result<Grant, SyncError> {
305 let purpose = sign_in.purpose;
306 let code = wait_for_code(&sign_in, Duration::from_secs(300), stop)?;
307 let answer = post(
308 TOKEN,
309 &[("client_id", client_id.trim()), ("client_secret", client_secret.trim()), ("code", &code), ("code_verifier", &sign_in.verifier), ("grant_type", "authorization_code"), ("redirect_uri", &sign_in.redirect)],
310 )?;
311 if let Some(signed_in) = answer["id_token"].as_str().and_then(email_of)
313 && signed_in != email.trim().to_lowercase()
314 {
315 if let Some(token) = answer["refresh_token"].as_str().or(answer["access_token"].as_str()) {
316 let _ = post(REVOKE, &[("token", token)]);
317 }
318 return Err(SyncError::Message(format!("google-other:{signed_in}")));
319 }
320 let refresh_token = answer["refresh_token"].as_str().ok_or_else(|| SyncError::Server("Google gave no refresh token".into()))?.to_string();
321 let scopes: Vec<String> = answer["scope"].as_str().unwrap_or("").split_whitespace().map(str::to_string).collect();
322 if purpose == Purpose::Mail && !scopes.iter().any(|s| s == MAIL_SCOPE) {
324 give_back(&refresh_token, client_id, purpose, email);
325 return Err(SyncError::Message("google-mail-unticked".into()));
326 }
327 let signed_in = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);
328 let grant = Grant { client_id: client_id.trim().into(), client_secret: client_secret.trim().into(), refresh_token, scopes, signed_in };
329 crate::secret::save_named(&purpose.keyring_name(email), &serde_json::to_string(&grant).map_err(|e| SyncError::Server(e.to_string()))?)?;
330 if let Some(access) = answer["access_token"].as_str() {
331 let lasts = answer["expires_in"].as_u64().unwrap_or(3600);
332 remember(&purpose.keyring_name(email), access, lasts);
333 }
334 Ok(grant)
335}
336
337fn give_back(token: &str, client_id: &str, purpose: Purpose, email: &str) {
341 let shared = grant_for(purpose.other(), email).is_some_and(|g| g.client_id == client_id.trim());
342 if !shared {
343 let _ = post(REVOKE, &[("token", token)]);
344 }
345}
346
347static ACCESS: Mutex<Option<HashMap<String, (String, Instant)>>> = Mutex::new(None);
350
351fn remember(key: &str, token: &str, lasts: u64) {
352 if let Ok(mut access) = ACCESS.lock() {
353 access.get_or_insert_with(HashMap::new).insert(key.to_string(), (token.to_string(), Instant::now() + Duration::from_secs(lasts.saturating_sub(60))));
354 }
355}
356
357fn remembered(key: &str) -> Option<String> {
359 ACCESS.lock().ok().and_then(|a| a.as_ref().and_then(|m| m.get(key).cloned())).filter(|(_, until)| *until > Instant::now()).map(|(token, _)| token)
360}
361
362pub fn access_token(email: &str, fresh: bool) -> Result<String, SyncError> {
364 access_token_for(Purpose::Pim, email, fresh)
365}
366
367pub fn access_token_for(purpose: Purpose, email: &str, fresh: bool) -> Result<String, SyncError> {
371 #[cfg(feature = "insecure-test-tls")]
373 if let Some(token) = std::env::var_os("SIOUL_TEST_GOOGLE_TOKEN") {
374 return Ok(token.to_string_lossy().to_string());
375 }
376 let key = purpose.keyring_name(email);
377 if !fresh && let Some(token) = remembered(&key) {
378 return Ok(token);
379 }
380 let grant = grant_for(purpose, email).ok_or_else(|| SyncError::Login(SIGN_IN_AGAIN.into()))?;
382 let (token, lasts) = refreshed(TOKEN, &grant)?;
383 remember(&key, &token, lasts);
384 Ok(token)
385}
386
387fn refreshed(endpoint: &str, grant: &Grant) -> Result<(String, u64), SyncError> {
390 let answer = post(endpoint, &[("client_id", &grant.client_id), ("client_secret", &grant.client_secret), ("refresh_token", &grant.refresh_token), ("grant_type", "refresh_token")]).map_err(|e| ended(e, grant))?;
391 let token = answer["access_token"].as_str().ok_or_else(|| SyncError::Server("Google gave no access token".into()))?.to_string();
392 Ok((token, answer["expires_in"].as_u64().unwrap_or(3600)))
393}
394
395fn ended(error: SyncError, grant: &Grant) -> SyncError {
398 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);
399 let days = (now - grant.signed_in) as f64 / 86_400.0;
400 let own = built_in().is_none_or(|(id, _)| id != grant.client_id);
401 match error {
402 SyncError::Login(d) if d == SIGN_IN_AGAIN && own && grant.signed_in > 0 && (6.5..9.0).contains(&days) => SyncError::Login(IN_TESTING.into()),
403 other => other,
404 }
405}
406
407pub fn revoke(email: &str) -> Result<(), SyncError> {
409 revoke_for(Purpose::Pim, email)
410}
411
412pub fn revoke_for(purpose: Purpose, email: &str) -> Result<(), SyncError> {
416 if let Some(grant) = grant_for(purpose, email) {
417 give_back(&grant.refresh_token, &grant.client_id, purpose, email);
418 }
419 if let Ok(mut access) = ACCESS.lock()
420 && let Some(map) = access.as_mut()
421 {
422 map.remove(&purpose.keyring_name(email));
423 }
424 crate::secret::forget_named(&purpose.keyring_name(email))
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn a_sign_in_page_with_pkce() {
433 let sign_in = begin("123.apps.googleusercontent.com", "you@example.org").unwrap();
434 assert!(sign_in.url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?client_id=123.apps.googleusercontent.com&redirect_uri=http%3A%2F%2F127.0.0.1%3A"));
435 assert!(sign_in.url.contains("&code_challenge_method=S256&") && sign_in.url.contains("&login_hint=you%40example.org"));
436 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(sign_in.verifier.as_bytes()));
437 assert!(sign_in.url.contains(&format!("code_challenge={challenge}&")));
438 assert!(sign_in.verifier.len() >= 43, "RFC 7636: 43 characters at least");
439 }
440
441 #[test]
442 fn google_comes_back_here() {
443 let sign_in = begin("id", "you@example.org").unwrap();
444 let port = sign_in.listener.local_addr().unwrap().port();
445 let state = sign_in.state.clone();
446 let browser = std::thread::spawn(move || {
447 for path in ["/favicon.ico".to_string(), format!("/?state={state}&code=4%2F0Abc&scope=email")] {
449 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
450 stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n").as_bytes()).unwrap();
451 let mut answer = String::new();
452 let _ = std::io::Read::read_to_string(&mut stream, &mut answer);
453 }
454 });
455 assert_eq!(wait_for_code(&sign_in, Duration::from_secs(10), &AtomicBool::new(false)).unwrap(), "4/0Abc");
456 let other = begin("id", "you@example.org").unwrap();
458 assert!(matches!(wait_for_code(&other, Duration::from_secs(10), &AtomicBool::new(true)), Err(SyncError::Message(m)) if m == "google-cancelled"));
459 browser.join().unwrap();
460 let payload = URL_SAFE_NO_PAD.encode(br#"{"email":"You@Example.org"}"#);
462 assert_eq!(email_of(&format!("x.{payload}.y")).as_deref(), Some("you@example.org"));
463 }
464
465 #[test]
466 fn mail_is_asked_apart() {
467 let mail = begin_for(Purpose::Mail, "123.apps.googleusercontent.com", "you@gmail.com").unwrap();
468 assert!(mail.url.contains("&scope=openid%20email%20https%3A%2F%2Fmail.google.com%2F&"), "{}", mail.url);
469 let calendars = begin("123.apps.googleusercontent.com", "you@gmail.com").unwrap();
470 assert!(!calendars.url.contains("mail.google.com"));
471 assert_eq!(Purpose::Mail.keyring_name(" You@Gmail.com "), "google-mail:you@gmail.com");
472 assert_eq!(Purpose::Pim.keyring_name("You@Gmail.com"), "google:you@gmail.com", "as before mail came");
473 }
474
475 #[test]
478 fn an_answer_waiting_is_read_even_late() {
479 let sign_in = begin("id", "you@example.org").unwrap();
480 let port = sign_in.listener.local_addr().unwrap().port();
481 let mut browser = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
482 browser.write_all(format!("GET /?state={}&code=late HTTP/1.1\r\nHost: localhost\r\n\r\n", sign_in.state).as_bytes()).unwrap();
483 assert_eq!(wait_for_code(&sign_in, Duration::ZERO, &AtomicBool::new(false)).unwrap(), "late");
484 let other = begin("id", "you@example.org").unwrap();
486 assert!(matches!(wait_for_code(&other, Duration::ZERO, &AtomicBool::new(false)), Err(SyncError::Message(m)) if m == "google-timeout"));
487 }
488
489 #[test]
493 fn a_refresh_token_brings_an_access_token() {
494 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
495 let port = listener.local_addr().unwrap().port();
496 let server = std::thread::spawn(move || {
497 let mut asked = Vec::new();
498 let answers = [
499 ("200 OK", r#"{"access_token":"ya29.new","expires_in":3599,"scope":"https://mail.google.com/","token_type":"Bearer"}"#),
500 ("400 Bad Request", r#"{"error":"invalid_grant","error_description":"Token has been expired or revoked."}"#),
501 ];
502 for (status, answer) in answers {
503 let (mut stream, _) = listener.accept().unwrap();
504 let mut reader = BufReader::new(stream.try_clone().unwrap());
505 let (mut length, mut line) = (0, String::new());
506 loop {
507 line.clear();
508 reader.read_line(&mut line).unwrap();
509 if line == "\r\n" {
510 break;
511 }
512 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
513 length = value.trim().parse().unwrap();
514 }
515 }
516 let mut body = vec![0; length];
517 std::io::Read::read_exact(&mut reader, &mut body).unwrap();
518 asked.push(String::from_utf8(body).unwrap());
519 stream.write_all(format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}", answer.len()).as_bytes()).unwrap();
520 }
521 asked
522 });
523 let endpoint = format!("http://127.0.0.1:{port}/token");
524 let grant = Grant { client_id: "id".into(), client_secret: "secret".into(), refresh_token: "1//refresh".into(), scopes: vec![MAIL_SCOPE.into()], signed_in: 0 };
525 assert_eq!(refreshed(&endpoint, &grant).unwrap(), ("ya29.new".to_string(), 3599));
526 assert_eq!(refreshed(&endpoint, &grant).err(), Some(SyncError::Login(SIGN_IN_AGAIN.into())));
527 let asked = server.join().unwrap();
528 assert!(asked[0].contains("grant_type=refresh_token") && asked[0].contains("refresh_token=1%2F%2Frefresh") && asked[0].contains("client_id=id"), "{}", asked[0]);
529 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
530 let week_old = Grant { signed_in: now - 7 * 86_400, ..grant };
531 assert_eq!(ended(SyncError::Login(SIGN_IN_AGAIN.into()), &week_old), SyncError::Login(IN_TESTING.into()));
532 }
533
534 #[test]
535 fn access_tokens_are_kept_by_purpose_until_they_end() {
536 remember(&Purpose::Mail.keyring_name("a@example.org"), "mail-token", 3600);
537 remember(&Purpose::Pim.keyring_name("a@example.org"), "calendars-token", 3600);
538 assert_eq!(remembered("google-mail:a@example.org").as_deref(), Some("mail-token"));
539 assert_eq!(remembered("google:a@example.org").as_deref(), Some("calendars-token"));
540 remember("google-mail:b@example.org", "short", 30);
542 assert_eq!(remembered("google-mail:b@example.org"), None);
543 }
544}