1use crate::SyncError;
20use crate::loopback::{self, Verdict};
21use base64::Engine;
22use base64::engine::general_purpose::URL_SAFE_NO_PAD;
23use sha2::{Digest, Sha256};
24use std::collections::HashMap;
25#[cfg(test)]
27use std::io::{BufRead, BufReader, Write};
28use std::net::TcpListener;
29use std::sync::Mutex;
30use std::sync::atomic::AtomicBool;
31use std::time::{Duration, Instant};
32
33pub(crate) use crate::loopback::encode;
35
36pub const SCOPES: &str = "openid email https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav https://www.googleapis.com/auth/tasks";
39pub const MAIL_SCOPE: &str = "https://mail.google.com/";
41pub const MAIL_SCOPES: &str = "openid email https://mail.google.com/";
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Purpose {
49 Pim,
51 Mail,
53}
54
55impl Purpose {
56 pub fn scopes(self) -> &'static str {
58 match self {
59 Purpose::Pim => SCOPES,
60 Purpose::Mail => MAIL_SCOPES,
61 }
62 }
63
64 fn other(self) -> Purpose {
66 match self {
67 Purpose::Pim => Purpose::Mail,
68 Purpose::Mail => Purpose::Pim,
69 }
70 }
71
72 fn keyring_name(self, email: &str) -> String {
74 let email = email.trim().to_lowercase();
75 match self {
76 Purpose::Pim => format!("google:{email}"),
77 Purpose::Mail => format!("google-mail:{email}"),
78 }
79 }
80}
81const AUTHORIZE: &str = "https://accounts.google.com/o/oauth2/v2/auth";
82const TOKEN: &str = "https://oauth2.googleapis.com/token";
83const REVOKE: &str = "https://oauth2.googleapis.com/revoke";
84pub const SIGN_IN_AGAIN: &str = "google-again";
86pub const IN_TESTING: &str = "google-testing";
89
90pub fn built_in() -> Option<(&'static str, &'static str)> {
94 match (option_env!("SIOUL_GOOGLE_CLIENT_ID"), option_env!("SIOUL_GOOGLE_CLIENT_SECRET")) {
95 (Some(id), Some(secret)) if !id.trim().is_empty() => Some((id, secret)),
96 _ => None,
97 }
98}
99pub fn caldav_start(email: &str) -> String {
101 format!("https://apidata.googleusercontent.com/caldav/v2/{}/user", email.trim())
102}
103pub const CARDDAV_START: &str = "https://www.googleapis.com/.well-known/carddav";
104
105#[derive(Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107pub struct Grant {
108 pub client_id: String,
109 pub client_secret: String,
110 pub refresh_token: String,
111 pub scopes: Vec<String>,
112 #[serde(default)]
114 pub signed_in: i64,
115}
116
117impl std::fmt::Debug for Grant {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("Grant").field("client_id", &self.client_id).field("scopes", &self.scopes).field("signed_in", &self.signed_in).finish_non_exhaustive()
121 }
122}
123
124pub fn grant(email: &str) -> Option<Grant> {
126 grant_for(Purpose::Pim, email)
127}
128
129pub fn grant_for(purpose: Purpose, email: &str) -> Option<Grant> {
131 crate::secret::named(&purpose.keyring_name(email)).and_then(|t| serde_json::from_str(&t).ok())
132}
133
134pub fn own_key(email: &str) -> Option<(String, String)> {
138 let built_in = built_in().map(|(id, _)| id);
139 [Purpose::Mail, Purpose::Pim]
140 .into_iter()
141 .filter_map(|purpose| grant_for(purpose, email))
142 .find(|g| !g.client_id.is_empty() && Some(g.client_id.as_str()) != built_in)
143 .map(|g| (g.client_id, g.client_secret))
144}
145
146fn random(bytes: usize) -> String {
147 loopback::random(bytes)
148}
149
150pub struct SignIn {
152 listener: TcpListener,
153 verifier: String,
154 state: String,
155 redirect: String,
156 purpose: Purpose,
157 pub url: String,
159}
160
161fn query(pairs: &[(&str, &str)]) -> String {
162 loopback::query(pairs)
163}
164
165pub fn begin(client_id: &str, email: &str) -> Result<SignIn, SyncError> {
168 begin_for(Purpose::Pim, client_id, email)
169}
170
171pub fn begin_for(purpose: Purpose, client_id: &str, email: &str) -> Result<SignIn, SyncError> {
173 let (listener, redirect) = loopback::open(true)?;
175 let verifier = random(48);
176 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
177 let state = random(16);
178 let url = format!(
179 "{AUTHORIZE}?{}",
180 query(&[
181 ("client_id", client_id.trim()),
182 ("redirect_uri", &redirect),
183 ("response_type", "code"),
184 ("scope", purpose.scopes()),
185 ("code_challenge", &challenge),
186 ("code_challenge_method", "S256"),
187 ("state", &state),
188 ("login_hint", email.trim()),
189 ])
190 );
191 Ok(SignIn { listener, verifier, state, redirect, purpose, url })
192}
193
194fn wait_for_code(sign_in: &SignIn, deadline: Duration, stop: &AtomicBool) -> Result<String, SyncError> {
198 let ends = loopback::Ends { cancelled: SyncError::Message("google-cancelled".into()), timed_out: SyncError::Message("google-timeout".into()) };
199 loopback::wait(&sign_in.listener, &sign_in.state, deadline, stop, ends, |answer| {
200 let granted = if cfg!(target_os = "android") { "google-page-granted-phone" } else { "google-page-granted" };
202 match (answer.state_ok, answer.get("code"), answer.get("error")) {
203 (true, Some(code), _) => Verdict::Done(granted, Ok(code.to_string())),
204 (_, _, Some(_)) => Verdict::Done("google-page-denied", Err(SyncError::Message("google-denied".into()))),
205 _ => Verdict::Done("google-page-foreign", Err(SyncError::Message("google-state".into()))),
206 }
207 })
208}
209
210fn post(url: &str, form: &[(&str, &str)]) -> Result<serde_json::Value, SyncError> {
211 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into();
212 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()))?;
213 let status = response.status().as_u16();
214 let body: serde_json::Value = serde_json::from_str(&response.body_mut().read_to_string().unwrap_or_default()).unwrap_or_default();
215 if status == 200 {
216 return Ok(body);
217 }
218 let error = body["error"].as_str().unwrap_or("");
219 Err(if error == "invalid_grant" { SyncError::Login(SIGN_IN_AGAIN.into()) } else { SyncError::Server(format!("Google: {status} {error}")) })
220}
221
222fn email_of(id_token: &str) -> Option<String> {
224 let payload = id_token.split('.').nth(1)?;
225 let json: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?).ok()?;
226 json["email"].as_str().map(str::to_lowercase)
227}
228
229pub fn finish(sign_in: SignIn, client_id: &str, client_secret: &str, email: &str, stop: &AtomicBool) -> Result<Grant, SyncError> {
234 let purpose = sign_in.purpose;
235 let code = wait_for_code(&sign_in, Duration::from_secs(300), stop)?;
236 let answer = post(
237 TOKEN,
238 &[("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)],
239 )?;
240 if let Some(signed_in) = answer["id_token"].as_str().and_then(email_of)
242 && signed_in != email.trim().to_lowercase()
243 {
244 if let Some(token) = answer["refresh_token"].as_str().or(answer["access_token"].as_str()) {
245 let _ = post(REVOKE, &[("token", token)]);
246 }
247 return Err(SyncError::Message(format!("google-other:{signed_in}")));
248 }
249 let refresh_token = answer["refresh_token"].as_str().ok_or_else(|| SyncError::Server("Google gave no refresh token".into()))?.to_string();
250 let scopes: Vec<String> = answer["scope"].as_str().unwrap_or("").split_whitespace().map(str::to_string).collect();
251 if purpose == Purpose::Mail && !scopes.iter().any(|s| s == MAIL_SCOPE) {
253 give_back(&refresh_token, client_id, purpose, email);
254 return Err(SyncError::Message("google-mail-unticked".into()));
255 }
256 let signed_in = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);
257 let grant = Grant { client_id: client_id.trim().into(), client_secret: client_secret.trim().into(), refresh_token, scopes, signed_in };
258 crate::secret::save_named(&purpose.keyring_name(email), &serde_json::to_string(&grant).map_err(|e| SyncError::Server(e.to_string()))?)?;
259 if let Some(access) = answer["access_token"].as_str() {
260 let lasts = answer["expires_in"].as_u64().unwrap_or(3600);
261 remember(&purpose.keyring_name(email), access, lasts);
262 }
263 Ok(grant)
264}
265
266fn give_back(token: &str, client_id: &str, purpose: Purpose, email: &str) {
270 let shared = grant_for(purpose.other(), email).is_some_and(|g| g.client_id == client_id.trim());
271 if !shared {
272 let _ = post(REVOKE, &[("token", token)]);
273 }
274}
275
276static ACCESS: Mutex<Option<HashMap<String, (String, Instant)>>> = Mutex::new(None);
279
280fn remember(key: &str, token: &str, lasts: u64) {
281 if let Ok(mut access) = ACCESS.lock() {
282 access.get_or_insert_with(HashMap::new).insert(key.to_string(), (token.to_string(), Instant::now() + Duration::from_secs(lasts.saturating_sub(60))));
283 }
284}
285
286fn remembered(key: &str) -> Option<String> {
288 ACCESS.lock().ok().and_then(|a| a.as_ref().and_then(|m| m.get(key).cloned())).filter(|(_, until)| *until > Instant::now()).map(|(token, _)| token)
289}
290
291pub fn access_token(email: &str, fresh: bool) -> Result<String, SyncError> {
293 access_token_for(Purpose::Pim, email, fresh)
294}
295
296pub fn access_token_for(purpose: Purpose, email: &str, fresh: bool) -> Result<String, SyncError> {
300 #[cfg(feature = "insecure-test-tls")]
302 if let Some(token) = std::env::var_os("SIOUL_TEST_GOOGLE_TOKEN") {
303 return Ok(token.to_string_lossy().to_string());
304 }
305 let key = purpose.keyring_name(email);
306 if !fresh && let Some(token) = remembered(&key) {
307 return Ok(token);
308 }
309 let grant = grant_for(purpose, email).ok_or_else(|| SyncError::Login(SIGN_IN_AGAIN.into()))?;
311 let (token, lasts) = refreshed(TOKEN, &grant)?;
312 remember(&key, &token, lasts);
313 Ok(token)
314}
315
316fn refreshed(endpoint: &str, grant: &Grant) -> Result<(String, u64), SyncError> {
319 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))?;
320 let token = answer["access_token"].as_str().ok_or_else(|| SyncError::Server("Google gave no access token".into()))?.to_string();
321 Ok((token, answer["expires_in"].as_u64().unwrap_or(3600)))
322}
323
324fn ended(error: SyncError, grant: &Grant) -> SyncError {
327 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64);
328 let days = (now - grant.signed_in) as f64 / 86_400.0;
329 let own = built_in().is_none_or(|(id, _)| id != grant.client_id);
330 match error {
331 SyncError::Login(d) if d == SIGN_IN_AGAIN && own && grant.signed_in > 0 && (6.5..9.0).contains(&days) => SyncError::Login(IN_TESTING.into()),
332 other => other,
333 }
334}
335
336pub fn revoke(email: &str) -> Result<(), SyncError> {
338 revoke_for(Purpose::Pim, email)
339}
340
341pub fn revoke_for(purpose: Purpose, email: &str) -> Result<(), SyncError> {
345 if let Some(grant) = grant_for(purpose, email) {
346 give_back(&grant.refresh_token, &grant.client_id, purpose, email);
347 }
348 if let Ok(mut access) = ACCESS.lock()
349 && let Some(map) = access.as_mut()
350 {
351 map.remove(&purpose.keyring_name(email));
352 }
353 crate::secret::forget_named(&purpose.keyring_name(email))
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn a_sign_in_page_with_pkce() {
362 let sign_in = begin("123.apps.googleusercontent.com", "you@example.org").unwrap();
363 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"));
364 assert!(sign_in.url.contains("&code_challenge_method=S256&") && sign_in.url.contains("&login_hint=you%40example.org"));
365 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(sign_in.verifier.as_bytes()));
366 assert!(sign_in.url.contains(&format!("code_challenge={challenge}&")));
367 assert!(sign_in.verifier.len() >= 43, "RFC 7636: 43 characters at least");
368 }
369
370 #[test]
371 fn google_comes_back_here() {
372 let sign_in = begin("id", "you@example.org").unwrap();
373 let port = sign_in.listener.local_addr().unwrap().port();
374 let state = sign_in.state.clone();
375 let browser = std::thread::spawn(move || {
376 for path in ["/favicon.ico".to_string(), format!("/?state={state}&code=4%2F0Abc&scope=email")] {
378 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
379 stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n").as_bytes()).unwrap();
380 let mut answer = String::new();
381 let _ = std::io::Read::read_to_string(&mut stream, &mut answer);
382 }
383 });
384 assert_eq!(wait_for_code(&sign_in, Duration::from_secs(10), &AtomicBool::new(false)).unwrap(), "4/0Abc");
385 let other = begin("id", "you@example.org").unwrap();
387 assert!(matches!(wait_for_code(&other, Duration::from_secs(10), &AtomicBool::new(true)), Err(SyncError::Message(m)) if m == "google-cancelled"));
388 browser.join().unwrap();
389 let payload = URL_SAFE_NO_PAD.encode(br#"{"email":"You@Example.org"}"#);
391 assert_eq!(email_of(&format!("x.{payload}.y")).as_deref(), Some("you@example.org"));
392 }
393
394 #[test]
395 fn mail_is_asked_apart() {
396 let mail = begin_for(Purpose::Mail, "123.apps.googleusercontent.com", "you@gmail.com").unwrap();
397 assert!(mail.url.contains("&scope=openid%20email%20https%3A%2F%2Fmail.google.com%2F&"), "{}", mail.url);
398 let calendars = begin("123.apps.googleusercontent.com", "you@gmail.com").unwrap();
399 assert!(!calendars.url.contains("mail.google.com"));
400 assert_eq!(Purpose::Mail.keyring_name(" You@Gmail.com "), "google-mail:you@gmail.com");
401 assert_eq!(Purpose::Pim.keyring_name("You@Gmail.com"), "google:you@gmail.com", "as before mail came");
402 }
403
404 #[test]
408 fn googles_answer_ends_cleanly_in_the_browser() {
409 for size in [800, 1060, 1500, 2200, 3000] {
410 let sign_in = begin("id", "you@example.org").unwrap();
411 let port = sign_in.listener.local_addr().unwrap().port();
412 let target = format!("/?state={}&iss=https%3A%2F%2Faccounts.google.com&code=4%2F0AVMBsJ-the-code&scope=email%20openid%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar&authuser=0&prompt=consent", sign_in.state);
413 let browser = std::thread::spawn(move || crate::loopback::browser_answer(port, &target, size));
414 assert_eq!(wait_for_code(&sign_in, Duration::from_secs(10), &AtomicBool::new(false)).unwrap(), "4/0AVMBsJ-the-code");
415 let page = browser.join().unwrap();
416 assert!(page.as_ref().is_ok_and(|p| p.starts_with("HTTP/1.1 200")), "{size} bytes: {page:?}");
417 }
418 }
419
420 #[test]
423 fn an_answer_waiting_is_read_even_late() {
424 let sign_in = begin("id", "you@example.org").unwrap();
425 let port = sign_in.listener.local_addr().unwrap().port();
426 let mut browser = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
427 browser.write_all(format!("GET /?state={}&code=late HTTP/1.1\r\nHost: localhost\r\n\r\n", sign_in.state).as_bytes()).unwrap();
428 assert_eq!(wait_for_code(&sign_in, Duration::ZERO, &AtomicBool::new(false)).unwrap(), "late");
429 let other = begin("id", "you@example.org").unwrap();
431 assert!(matches!(wait_for_code(&other, Duration::ZERO, &AtomicBool::new(false)), Err(SyncError::Message(m)) if m == "google-timeout"));
432 }
433
434 #[test]
438 fn a_refresh_token_brings_an_access_token() {
439 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
440 let port = listener.local_addr().unwrap().port();
441 let server = std::thread::spawn(move || {
442 let mut asked = Vec::new();
443 let answers = [
444 ("200 OK", r#"{"access_token":"ya29.new","expires_in":3599,"scope":"https://mail.google.com/","token_type":"Bearer"}"#),
445 ("400 Bad Request", r#"{"error":"invalid_grant","error_description":"Token has been expired or revoked."}"#),
446 ];
447 for (status, answer) in answers {
448 let (mut stream, _) = listener.accept().unwrap();
449 let mut reader = BufReader::new(stream.try_clone().unwrap());
450 let (mut length, mut line) = (0, String::new());
451 loop {
452 line.clear();
453 reader.read_line(&mut line).unwrap();
454 if line == "\r\n" {
455 break;
456 }
457 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
458 length = value.trim().parse().unwrap();
459 }
460 }
461 let mut body = vec![0; length];
462 std::io::Read::read_exact(&mut reader, &mut body).unwrap();
463 asked.push(String::from_utf8(body).unwrap());
464 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();
465 }
466 asked
467 });
468 let endpoint = format!("http://127.0.0.1:{port}/token");
469 let grant = Grant { client_id: "id".into(), client_secret: "secret".into(), refresh_token: "1//refresh".into(), scopes: vec![MAIL_SCOPE.into()], signed_in: 0 };
470 assert_eq!(refreshed(&endpoint, &grant).unwrap(), ("ya29.new".to_string(), 3599));
471 assert_eq!(refreshed(&endpoint, &grant).err(), Some(SyncError::Login(SIGN_IN_AGAIN.into())));
472 let asked = server.join().unwrap();
473 assert!(asked[0].contains("grant_type=refresh_token") && asked[0].contains("refresh_token=1%2F%2Frefresh") && asked[0].contains("client_id=id"), "{}", asked[0]);
474 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
475 let week_old = Grant { signed_in: now - 7 * 86_400, ..grant };
476 assert_eq!(ended(SyncError::Login(SIGN_IN_AGAIN.into()), &week_old), SyncError::Login(IN_TESTING.into()));
477 }
478
479 #[test]
480 fn access_tokens_are_kept_by_purpose_until_they_end() {
481 remember(&Purpose::Mail.keyring_name("a@example.org"), "mail-token", 3600);
482 remember(&Purpose::Pim.keyring_name("a@example.org"), "calendars-token", 3600);
483 assert_eq!(remembered("google-mail:a@example.org").as_deref(), Some("mail-token"));
484 assert_eq!(remembered("google:a@example.org").as_deref(), Some("calendars-token"));
485 remember("google-mail:b@example.org", "short", 30);
487 assert_eq!(remembered("google-mail:b@example.org"), None);
488 }
489}