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 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// The stand-ins of the tests below read and write their connections.
26#[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
33/// Percent-encoding, as other modules of this crate ask it of this one.
34pub(crate) use crate::loopback::encode;
35
36/// What Sioul asks, all at once (Google adds none later to a desktop app):
37/// who you are, your calendars, your contacts, your tasks.
38pub const SCOPES: &str = "openid email https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav https://www.googleapis.com/auth/tasks";
39/// Gmail over IMAP and SMTP: Google's one scope for them, a restricted one (docs/google.md, "Mail").
40pub const MAIL_SCOPE: &str = "https://mail.google.com/";
41/// What the mail's sign-in asks: who you are, and your mail.
42pub const MAIL_SCOPES: &str = "openid email https://mail.google.com/";
43
44/// What a sign-in is for. Each has its own grant, so that the mail's
45/// restricted scope stays apart from the calendars', and either can be
46/// removed alone.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Purpose {
49    /// Calendars, contacts and tasks.
50    Pim,
51    /// Mail, over IMAP and SMTP.
52    Mail,
53}
54
55impl Purpose {
56    /// The scopes asked on Google's page.
57    pub fn scopes(self) -> &'static str {
58        match self {
59            Purpose::Pim => SCOPES,
60            Purpose::Mail => MAIL_SCOPES,
61        }
62    }
63
64    /// The address's other part.
65    fn other(self) -> Purpose {
66        match self {
67            Purpose::Pim => Purpose::Mail,
68            Purpose::Mail => Purpose::Pim,
69        }
70    }
71
72    /// Its entry in the keyring: `google:<address>` (as before mail came), `google-mail:<address>`.
73    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";
84/// What a refused refresh token says (`SyncError::Login`): the account must be signed in again.
85pub const SIGN_IN_AGAIN: &str = "google-again";
86/// The same, about a week after signing in with your own key: Google ended it
87/// because your project is still in testing, and says it will again.
88pub const IN_TESTING: &str = "google-testing";
89
90/// Sioul's own OAuth client, when the build was given one: a desktop client
91/// whose secret is no secret (Google says so of installed applications), the
92/// same for everyone. None: each person uses a key of their own.
93pub 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}
99/// Where Google's calendars and address books are found.
100pub 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/// Your OAuth client, and what it was given; kept in the keyring as JSON.
106#[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    /// When Google gave it, Unix seconds: a sign-in ended after a week tells a project left in testing.
113    #[serde(default)]
114    pub signed_in: i64,
115}
116
117/// Printed without the refresh token or the secret, should it ever be.
118impl 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
124/// The grant kept for an address's calendars, contacts and tasks.
125pub fn grant(email: &str) -> Option<Grant> {
126    grant_for(Purpose::Pim, email)
127}
128
129/// The grant kept for an address, for a purpose.
130pub 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
134/// A key of your own kept on this device for an address, to sign in with
135/// again or for its other part: the mail's, else the calendars' unless it is
136/// Sioul's own (which cannot read mail).
137pub 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
150/// A sign-in under way: the page to open, and the door Google comes back to.
151pub struct SignIn {
152    listener: TcpListener,
153    verifier: String,
154    state: String,
155    redirect: String,
156    purpose: Purpose,
157    /// The page to open in your browser.
158    pub url: String,
159}
160
161fn query(pairs: &[(&str, &str)]) -> String {
162    loopback::query(pairs)
163}
164
165/// Opens the door on this computer and makes the page to open: PKCE's
166/// challenge (SHA-256 of a random verifier) and a random state.
167pub fn begin(client_id: &str, email: &str) -> Result<SignIn, SyncError> {
168    begin_for(Purpose::Pim, client_id, email)
169}
170
171/// `begin`, for a purpose: its scopes asked, its grant kept apart.
172pub fn begin_for(purpose: Purpose, client_id: &str, email: &str) -> Result<SignIn, SyncError> {
173    // 127.0.0.1, else [::1]: Google takes either (RFC 8252 §7.3).
174    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
194/// The answer Google's page sends back to this computer: its code, checked
195/// against the state. `stop` ends the wait (Cancel). The door is shared with
196/// Microsoft's sign-in (`loopback.rs`); Google's words and outcomes are these.
197fn 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        // On a phone, with the way back to Sioul, since the browser stays in front.
201        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
222/// The e-mail the ID token says, from its payload (it came straight from Google over TLS).
223fn 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
229/// Waits for Google's answer (five minutes at most, or until `stop`), trades
230/// its code for tokens, checks it is the address you gave (and, for mail,
231/// that its access was left ticked on Google's page), and keeps the grant in
232/// the keyring.
233pub 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    // The browser may be signed in to another Google account: its access goes back.
241    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    // Google lets each access be unticked on its page: mail without its scope is nothing.
252    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
266/// A token given back to Google, unless the address's other part keeps a
267/// grant of the same key: Google ends a key's access to an account as a
268/// whole, and would end that one too.
269fn 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
276/// Access tokens, by grant (its keyring name: purpose and address), until
277/// about when they end; in memory only.
278static 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
286/// The access token kept in memory under a grant's name, while it lasts.
287fn 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
291/// An access token for an address's calendars, contacts and tasks (`access_token_for`).
292pub fn access_token(email: &str, fresh: bool) -> Result<String, SyncError> {
293    access_token_for(Purpose::Pim, email, fresh)
294}
295
296/// An access token for an address, for a purpose: the one in memory while it
297/// lasts, else a new one from the refresh token. `fresh` asks a new one
298/// (after a refusal).
299pub fn access_token_for(purpose: Purpose, email: &str, fresh: bool) -> Result<String, SyncError> {
300    // A local stand-in for Google, in tests only (never in Sioul's builds).
301    #[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    // No grant kept: Google's page again.
310    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
316/// A new access token traded for a grant's refresh token at `endpoint`
317/// (Google's, or a stand-in in tests), and how long it lasts, in seconds.
318fn 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
324/// A refused refresh token about seven days after signing in with a key of
325/// your own: Google's limit for projects left in testing, said as such.
326fn 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
336/// Gives the calendars' access back to Google and forgets it here (`revoke_for`).
337pub fn revoke(email: &str) -> Result<(), SyncError> {
338    revoke_for(Purpose::Pim, email)
339}
340
341/// Gives an access back to Google and forgets it here (its account is
342/// removed). Google is not asked when the address's other part keeps a grant
343/// of the same key: it would end that one too.
344pub 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            // A favicon first, then the answer.
377            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        // Cancel ends the wait.
386        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        // The ID token's e-mail, from its payload.
390        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    /// The re-check of 15 October 2026, L1-bis: Google's answer as a browser
405    /// sends it, with its headers (800 to 3,000 bytes), ends in its page and
406    /// an orderly close, never a reset.
407    #[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    /// The browser answered while Sioul was frozen (a phone): its request
421    /// waited in the system's queue, and is read even once the time is up.
422    #[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        // Nothing waiting, and the time up: said so.
430        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    /// Google's token endpoint, stood in for on this computer: a refresh
435    /// token traded for an access token; a refused one asks to sign in again,
436    /// said as the end of a project in testing about a week after signing in.
437    #[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        // Ending within the minute: asked again rather than used.
486        remember("google-mail:b@example.org", "short", 30);
487        assert_eq!(remembered("google-mail:b@example.org"), None);
488    }
489}