Skip to main content

sioul_sync/
google.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Google's calendars and contacts, over its CalDAV and CardDAV, signed in
5//! with OAuth 2.0 as native apps should be (RFC 8252): your browser opens on
6//! Google's page, Google sends the answer back to this computer (a loopback
7//! address, a port chosen at random), PKCE (RFC 7636) proves it is the same
8//! program asking. The OAuth client is Sioul's own when the build was given
9//! one (`SIOUL_GOOGLE_CLIENT_ID` and `SIOUL_GOOGLE_CLIENT_SECRET` at build
10//! time: a key registered once for Sioul, as Thunderbird has its own), else
11//! yours, made once in your Google Cloud project (docs/google.md). What
12//! stays: the client and the refresh token, in the system keyring; the access
13//! token, in memory.
14//!
15//! Mail (IMAP and SMTP over SASL XOAUTH2, `sasl.rs`) is signed in apart, with
16//! a grant of its own (`Purpose::Mail`): Gmail's scope is restricted, given
17//! only to a key of your own until Sioul's has passed Google's review.
18
19use 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
30/// What Sioul asks, all at once (Google adds none later to a desktop app):
31/// who you are, your calendars, your contacts, your tasks.
32pub const SCOPES: &str = "openid email https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav https://www.googleapis.com/auth/tasks";
33/// Gmail over IMAP and SMTP: Google's one scope for them, a restricted one (docs/google.md, "Mail").
34pub const MAIL_SCOPE: &str = "https://mail.google.com/";
35/// What the mail's sign-in asks: who you are, and your mail.
36pub const MAIL_SCOPES: &str = "openid email https://mail.google.com/";
37
38/// What a sign-in is for. Each has its own grant, so that the mail's
39/// restricted scope stays apart from the calendars', and either can be
40/// removed alone.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Purpose {
43    /// Calendars, contacts and tasks.
44    Pim,
45    /// Mail, over IMAP and SMTP.
46    Mail,
47}
48
49impl Purpose {
50    /// The scopes asked on Google's page.
51    pub fn scopes(self) -> &'static str {
52        match self {
53            Purpose::Pim => SCOPES,
54            Purpose::Mail => MAIL_SCOPES,
55        }
56    }
57
58    /// The address's other part.
59    fn other(self) -> Purpose {
60        match self {
61            Purpose::Pim => Purpose::Mail,
62            Purpose::Mail => Purpose::Pim,
63        }
64    }
65
66    /// Its entry in the keyring: `google:<address>` (as before mail came), `google-mail:<address>`.
67    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";
78/// What a refused refresh token says (`SyncError::Login`): the account must be signed in again.
79pub const SIGN_IN_AGAIN: &str = "google-again";
80/// The same, about a week after signing in with your own key: Google ended it
81/// because your project is still in testing, and says it will again.
82pub const IN_TESTING: &str = "google-testing";
83
84/// Sioul's own OAuth client, when the build was given one: a desktop client
85/// whose secret is no secret (Google says so of installed applications), the
86/// same for everyone. None: each person uses a key of their own.
87pub 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}
93/// Where Google's calendars and address books are found.
94pub 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/// Your OAuth client, and what it was given; kept in the keyring as JSON.
100#[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    /// When Google gave it, Unix seconds: a sign-in ended after a week tells a project left in testing.
107    #[serde(default)]
108    pub signed_in: i64,
109}
110
111/// Printed without the refresh token or the secret, should it ever be.
112impl 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
118/// The grant kept for an address's calendars, contacts and tasks.
119pub fn grant(email: &str) -> Option<Grant> {
120    grant_for(Purpose::Pim, email)
121}
122
123/// The grant kept for an address, for a purpose.
124pub 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
128/// A key of your own kept on this device for an address, to sign in with
129/// again or for its other part: the mail's, else the calendars' unless it is
130/// Sioul's own (which cannot read mail).
131pub 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
149/// A sign-in under way: the page to open, and the door Google comes back to.
150pub struct SignIn {
151    listener: TcpListener,
152    verifier: String,
153    state: String,
154    redirect: String,
155    purpose: Purpose,
156    /// The page to open in your browser.
157    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
200/// Opens the door on this computer and makes the page to open: PKCE's
201/// challenge (SHA-256 of a random verifier) and a random state.
202pub fn begin(client_id: &str, email: &str) -> Result<SignIn, SyncError> {
203    begin_for(Purpose::Pim, client_id, email)
204}
205
206/// `begin`, for a purpose: its scopes asked, its grant kept apart.
207pub 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
230/// The answer Google's page sends back to this computer: its code, checked
231/// against the state. `stop` ends the wait (Cancel).
232fn 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        // An answer waiting is read before the time is called: a phone
240        // freezes Sioul while the browser is in front (its clock then jumps),
241        // and the browser's request waits meanwhile in the system's queue.
242        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        // "GET /?state=…&code=…&scope=… HTTP/1.1"; anything else (a favicon) is not it.
260        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        // The page your browser shows, in your language; on a phone, with the
272        // way back to Sioul, since the browser stays in front.
273        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('&', "&amp;").replace('<', "&lt;");
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
293/// The e-mail the ID token says, from its payload (it came straight from Google over TLS).
294fn 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
300/// Waits for Google's answer (five minutes at most, or until `stop`), trades
301/// its code for tokens, checks it is the address you gave (and, for mail,
302/// that its access was left ticked on Google's page), and keeps the grant in
303/// the keyring.
304pub 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    // The browser may be signed in to another Google account: its access goes back.
312    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    // Google lets each access be unticked on its page: mail without its scope is nothing.
323    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
337/// A token given back to Google, unless the address's other part keeps a
338/// grant of the same key: Google ends a key's access to an account as a
339/// whole, and would end that one too.
340fn 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
347/// Access tokens, by grant (its keyring name: purpose and address), until
348/// about when they end; in memory only.
349static 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
357/// The access token kept in memory under a grant's name, while it lasts.
358fn 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
362/// An access token for an address's calendars, contacts and tasks (`access_token_for`).
363pub fn access_token(email: &str, fresh: bool) -> Result<String, SyncError> {
364    access_token_for(Purpose::Pim, email, fresh)
365}
366
367/// An access token for an address, for a purpose: the one in memory while it
368/// lasts, else a new one from the refresh token. `fresh` asks a new one
369/// (after a refusal).
370pub fn access_token_for(purpose: Purpose, email: &str, fresh: bool) -> Result<String, SyncError> {
371    // A local stand-in for Google, in tests only (never in Sioul's builds).
372    #[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    // No grant kept: Google's page again.
381    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
387/// A new access token traded for a grant's refresh token at `endpoint`
388/// (Google's, or a stand-in in tests), and how long it lasts, in seconds.
389fn 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
395/// A refused refresh token about seven days after signing in with a key of
396/// your own: Google's limit for projects left in testing, said as such.
397fn 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
407/// Gives the calendars' access back to Google and forgets it here (`revoke_for`).
408pub fn revoke(email: &str) -> Result<(), SyncError> {
409    revoke_for(Purpose::Pim, email)
410}
411
412/// Gives an access back to Google and forgets it here (its account is
413/// removed). Google is not asked when the address's other part keeps a grant
414/// of the same key: it would end that one too.
415pub 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            // A favicon first, then the answer.
448            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        // Cancel ends the wait.
457        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        // The ID token's e-mail, from its payload.
461        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    /// The browser answered while Sioul was frozen (a phone): its request
476    /// waited in the system's queue, and is read even once the time is up.
477    #[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        // Nothing waiting, and the time up: said so.
485        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    /// Google's token endpoint, stood in for on this computer: a refresh
490    /// token traded for an access token; a refused one asks to sign in again,
491    /// said as the end of a project in testing about a week after signing in.
492    #[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        // Ending within the minute: asked again rather than used.
541        remember("google-mail:b@example.org", "short", 30);
542        assert_eq!(remembered("google-mail:b@example.org"), None);
543    }
544}