Skip to main content

bothy_oauth/
lib.rs

1//! Google OAuth for Bothy apps that run their own sign-in: the CLI, the Python binding, embedded
2//! agents.
3//!
4//! The core crate never does OAuth; it asks an [`AccessTokenProvider`] for tokens. This crate
5//! provides one ([`RefreshingTokens`]) plus the flows to get the long-lived grant behind it:
6//! a browser sign-in with a loopback redirect and PKCE ([`loopback_login`]), and the device
7//! flow for machines without a browser ([`device_login`]). [`TokenStore`] keeps the grant in
8//! the OS keyring or a file readable only by the user.
9//!
10//! Tokens and client secrets never appear in `Debug` output or error messages.
11
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14use std::time::{Duration, Instant};
15
16use async_trait::async_trait;
17use base64::Engine as _;
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19pub use bothy::auth::AccessTokenProvider;
20use bothy::{Error, ErrorKind, Result};
21use serde_json::{Value as J, json};
22use sha2::{Digest, Sha256};
23use tokio::io::{AsyncReadExt, AsyncWriteExt};
24
25/// The hidden per-app folder (`appDataFolder`). Not available through the device flow.
26pub const SCOPE_APPDATA: &str = "https://www.googleapis.com/auth/drive.appdata";
27/// Files the app created, such as the visible `Bothy/<name>` folders.
28pub const SCOPE_FILE: &str = "https://www.googleapis.com/auth/drive.file";
29
30const AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
31const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
32const DEVICE_URL: &str = "https://oauth2.googleapis.com/device/code";
33const REVOKE_URL: &str = "https://oauth2.googleapis.com/revoke";
34
35fn invalid(message: impl Into<String>) -> Error {
36    Error::new(ErrorKind::InvalidInput, message.into())
37}
38
39fn auth_error(message: impl Into<String>) -> Error {
40    Error::new(ErrorKind::AuthRequired, message.into())
41}
42
43fn offline(message: impl Into<String>) -> Error {
44    Error::new(ErrorKind::Offline, message.into())
45}
46
47/// An OAuth client of the app's Google Cloud project.
48#[derive(Clone)]
49pub struct Client {
50    /// The client id (`…apps.googleusercontent.com`).
51    pub id: String,
52    secret: String,
53}
54
55impl std::fmt::Debug for Client {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("Client")
58            .field("id", &self.id)
59            .finish_non_exhaustive()
60    }
61}
62
63impl Client {
64    /// A client from its id and secret (installed-app secrets are not confidential, but
65    /// Google requires them).
66    pub fn new(id: &str, secret: &str) -> Self {
67        Self {
68            id: id.to_owned(),
69            secret: secret.to_owned(),
70        }
71    }
72
73    /// Parses the JSON file downloaded from the Cloud console (`installed` or `web`).
74    pub fn from_json(bytes: &[u8]) -> Result<Self> {
75        let j: J =
76            serde_json::from_slice(bytes).map_err(|_| invalid("the client file is not JSON"))?;
77        let c = j.get("installed").or_else(|| j.get("web")).unwrap_or(&j);
78        match c["client_id"].as_str().filter(|s| !s.is_empty()) {
79            Some(id) => Ok(Self::new(
80                id,
81                c["client_secret"].as_str().unwrap_or_default(),
82            )),
83            None => Err(invalid(
84                "the client file has no client_id; download it from Google Cloud console → APIs & Services → Credentials",
85            )),
86        }
87    }
88
89    /// Reads a client JSON file.
90    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
91        let path = path.as_ref();
92        let bytes = std::fs::read(path)
93            .map_err(|e| invalid(format!("cannot read {}", path.display())).with_source(e))?;
94        Self::from_json(&bytes)
95    }
96}
97
98/// A long-lived grant: the refresh token and the scopes it covers.
99#[derive(Clone)]
100pub struct Grant {
101    /// The refresh token. Keep it secret.
102    pub refresh_token: String,
103    /// Scopes the user granted.
104    pub scopes: Vec<String>,
105    /// Which client issued it: `desktop` (loopback) or `device` (device flow).
106    pub client: String,
107}
108
109impl std::fmt::Debug for Grant {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("Grant")
112            .field("scopes", &self.scopes)
113            .field("client", &self.client)
114            .finish_non_exhaustive()
115    }
116}
117
118impl Grant {
119    /// Whether the grant covers `scope`.
120    pub fn has(&self, scope: &str) -> bool {
121        self.scopes.iter().any(|s| s == scope)
122    }
123
124    /// The stored form.
125    pub fn to_json(&self) -> String {
126        json!({"refresh_token": self.refresh_token, "scopes": self.scopes, "client": self.client})
127            .to_string()
128    }
129
130    /// Parses the stored form.
131    pub fn from_json(s: &str) -> Option<Self> {
132        let j: J = serde_json::from_str(s).ok()?;
133        Some(Self {
134            refresh_token: j["refresh_token"].as_str()?.to_owned(),
135            scopes: j["scopes"]
136                .as_array()?
137                .iter()
138                .filter_map(|v| v.as_str().map(str::to_owned))
139                .collect(),
140            client: j["client"].as_str()?.to_owned(),
141        })
142    }
143}
144
145/// The HTTP client for Google's OAuth endpoints (rustls with ring).
146pub fn http() -> Result<reqwest::Client> {
147    let _ = rustls::crypto::ring::default_provider().install_default();
148    let builder = reqwest::Client::builder()
149        .connect_timeout(Duration::from_secs(15))
150        .timeout(Duration::from_secs(60));
151    #[cfg(target_os = "android")]
152    let builder = builder.tls_backend_preconfigured(android_tls());
153    builder
154        .build()
155        .map_err(|e| Error::new(ErrorKind::Internal, "cannot build HTTP client").with_source(e))
156}
157
158/// Android has no platform verifier without JNI setup; verify against Mozilla's roots.
159#[cfg(target_os = "android")]
160fn android_tls() -> rustls::ClientConfig {
161    let roots = rustls::RootCertStore {
162        roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
163    };
164    let mut config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
165        rustls::crypto::ring::default_provider(),
166    ))
167    .with_safe_default_protocol_versions()
168    .expect("ring supports the default TLS versions")
169    .with_root_certificates(roots)
170    .with_no_client_auth();
171    config.alpn_protocols = vec![b"http/1.1".to_vec()];
172    config
173}
174
175fn random_b64(n: usize) -> String {
176    let mut b = vec![0u8; n];
177    getrandom::fill(&mut b).expect("system random generator");
178    URL_SAFE_NO_PAD.encode(b)
179}
180
181async fn post_form(http: &reqwest::Client, url: &str, form: &[(&str, &str)]) -> Result<(u16, J)> {
182    let resp = http
183        .post(url)
184        .form(form)
185        .send()
186        .await
187        .map_err(|e| offline("cannot reach Google").with_source(e))?;
188    let status = resp.status().as_u16();
189    let body = resp
190        .bytes()
191        .await
192        .map_err(|e| offline("Google closed the connection").with_source(e))?;
193    Ok((status, serde_json::from_slice(&body).unwrap_or(J::Null)))
194}
195
196fn grant_from(j: &J, client: &str) -> Result<Grant> {
197    let refresh = j["refresh_token"].as_str().ok_or_else(|| {
198        auth_error("Google returned no refresh token; sign out and sign in again")
199    })?;
200    Ok(Grant {
201        refresh_token: refresh.to_owned(),
202        scopes: j["scope"]
203            .as_str()
204            .unwrap_or_default()
205            .split(' ')
206            .map(str::to_owned)
207            .collect(),
208        client: client.to_owned(),
209    })
210}
211
212fn oauth_error(j: &J) -> String {
213    let e = j["error"].as_str().unwrap_or("unknown_error");
214    match j["error_description"].as_str() {
215        Some(d) => format!("{e}: {d}"),
216        None => e.to_owned(),
217    }
218}
219
220/// Signs in through the browser: a loopback redirect to 127.0.0.1 with PKCE.
221///
222/// `show_url` gets the sign-in URL; it should open it in a browser or print it.
223pub async fn loopback_login(
224    client: &Client,
225    scopes: &[&str],
226    show_url: impl FnOnce(&str),
227) -> Result<Grant> {
228    let io = |what: &'static str| move |e| Error::new(ErrorKind::Internal, what).with_source(e);
229    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
230        .await
231        .map_err(io("cannot listen on 127.0.0.1"))?;
232    let port = listener.local_addr().map_err(io("cannot listen"))?.port();
233    let redirect = format!("http://127.0.0.1:{port}");
234    let verifier = random_b64(32);
235    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
236    let state = random_b64(16);
237    let mut url = reqwest::Url::parse(AUTH_URL).expect("valid URL");
238    url.query_pairs_mut()
239        .append_pair("client_id", &client.id)
240        .append_pair("redirect_uri", &redirect)
241        .append_pair("response_type", "code")
242        .append_pair("scope", &scopes.join(" "))
243        .append_pair("code_challenge", &challenge)
244        .append_pair("code_challenge_method", "S256")
245        .append_pair("state", &state)
246        .append_pair("access_type", "offline")
247        .append_pair("prompt", "consent");
248    show_url(url.as_str());
249
250    let code = tokio::time::timeout(Duration::from_secs(300), async {
251        loop {
252            let (mut sock, _) = listener.accept().await.map_err(io("sign-in redirect"))?;
253            let mut buf = vec![0u8; 8192];
254            let n = sock.read(&mut buf).await.unwrap_or(0);
255            let req = String::from_utf8_lossy(&buf[..n]);
256            let Some(target) = req.lines().next().and_then(|l| l.split(' ').nth(1)) else {
257                continue;
258            };
259            let params: std::collections::HashMap<String, String> =
260                reqwest::Url::parse(&format!("http://localhost{target}"))
261                    .map(|u| u.query_pairs().into_owned().collect())
262                    .unwrap_or_default();
263            if params.is_empty() {
264                // Browsers also ask for /favicon.ico.
265                let _ = sock
266                    .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
267                    .await;
268                continue;
269            }
270            let ok = params.get("state") == Some(&state) && params.contains_key("code");
271            let page = if ok {
272                "Bothy is signed in. You can close this tab."
273            } else {
274                "Sign-in failed. Return to the app."
275            };
276            let resp = format!(
277                "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}",
278                page.len()
279            );
280            let _ = sock.write_all(resp.as_bytes()).await;
281            if let Some(err) = params.get("error") {
282                return Err(auth_error(format!("sign-in was not completed: {err}")));
283            }
284            if !ok {
285                return Err(auth_error("sign-in redirect had a wrong state; try again"));
286            }
287            return Ok(params["code"].clone());
288        }
289    })
290    .await
291    .map_err(|_| auth_error("sign-in timed out after 5 minutes"))??;
292
293    let http = http()?;
294    let (status, j) = post_form(
295        &http,
296        TOKEN_URL,
297        &[
298            ("code", &code),
299            ("client_id", &client.id),
300            ("client_secret", &client.secret),
301            ("redirect_uri", &redirect),
302            ("grant_type", "authorization_code"),
303            ("code_verifier", &verifier),
304        ],
305    )
306    .await?;
307    if status != 200 {
308        return Err(auth_error(format!(
309            "token exchange failed: {}",
310            oauth_error(&j)
311        )));
312    }
313    grant_from(&j, "desktop")
314}
315
316/// What the user must do to approve a device sign-in.
317#[derive(Clone, Debug)]
318pub struct DeviceCode {
319    /// The page to open on a phone or computer.
320    pub verification_url: String,
321    /// The code to enter there.
322    pub user_code: String,
323    /// How long the code stays valid.
324    pub expires_in: Duration,
325}
326
327/// Signs in on a machine without a browser: the user enters a code on another device.
328/// Google allows only `drive.file` here (it rejects `drive.appdata` in this flow).
329///
330/// `show` gets the code to display; the call then waits until the user approves.
331pub async fn device_login(client: &Client, show: impl FnOnce(&DeviceCode)) -> Result<Grant> {
332    let http = http()?;
333    let (status, j) = post_form(
334        &http,
335        DEVICE_URL,
336        &[("client_id", &client.id), ("scope", SCOPE_FILE)],
337    )
338    .await?;
339    if status != 200 {
340        let mut msg = format!("device sign-in failed: {}", oauth_error(&j));
341        if j["error"] == "invalid_client" {
342            msg.push_str(
343                "\nhint: the device client must be of type \"TVs and Limited Input devices\"",
344            );
345        }
346        return Err(auth_error(msg));
347    }
348    let device_code = j["device_code"]
349        .as_str()
350        .ok_or_else(|| auth_error("no device code"))?
351        .to_owned();
352    let mut interval = j["interval"].as_u64().unwrap_or(5).max(1);
353    let expires = j["expires_in"].as_u64().unwrap_or(1800);
354    show(&DeviceCode {
355        verification_url: j["verification_url"]
356            .as_str()
357            .or(j["verification_uri"].as_str())
358            .unwrap_or("https://www.google.com/device")
359            .to_owned(),
360        user_code: j["user_code"].as_str().unwrap_or_default().to_owned(),
361        expires_in: Duration::from_secs(expires),
362    });
363
364    let expired = || auth_error("the code expired; start the device sign-in again");
365    let deadline = tokio::time::Instant::now() + Duration::from_secs(expires);
366    loop {
367        tokio::time::sleep(Duration::from_secs(interval)).await;
368        if tokio::time::Instant::now() >= deadline {
369            return Err(expired());
370        }
371        let (status, j) = post_form(
372            &http,
373            TOKEN_URL,
374            &[
375                ("client_id", &client.id),
376                ("client_secret", &client.secret),
377                ("device_code", &device_code),
378                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
379            ],
380        )
381        .await?;
382        if status == 200 {
383            return grant_from(&j, "device");
384        }
385        match j["error"].as_str() {
386            Some("authorization_pending") => {}
387            Some("slow_down") => interval += 5,
388            Some("access_denied") => return Err(auth_error("sign-in was denied")),
389            Some("expired_token") => return Err(expired()),
390            _ => {
391                return Err(auth_error(format!(
392                    "device sign-in failed: {}",
393                    oauth_error(&j)
394                )));
395            }
396        }
397    }
398}
399
400/// Exchanges a refresh token for an access token: `(token, lifetime)`.
401pub async fn refresh(
402    http: &reqwest::Client,
403    client: &Client,
404    refresh_token: &str,
405) -> Result<(String, Duration)> {
406    let resp = http
407        .post(TOKEN_URL)
408        .form(&[
409            ("client_id", client.id.as_str()),
410            ("client_secret", client.secret.as_str()),
411            ("refresh_token", refresh_token),
412            ("grant_type", "refresh_token"),
413        ])
414        .send()
415        .await
416        .map_err(|e| offline("cannot reach Google to refresh the token").with_source(e))?;
417    let status = resp.status();
418    let j: J = match resp.bytes().await {
419        Ok(b) => serde_json::from_slice(&b).unwrap_or(J::Null),
420        Err(_) => J::Null,
421    };
422    if status.is_success()
423        && let Some(t) = j["access_token"].as_str()
424    {
425        let ttl = j["expires_in"].as_u64().unwrap_or(3600);
426        return Ok((t.to_owned(), Duration::from_secs(ttl)));
427    }
428    let reason = oauth_error(&j);
429    if status.is_server_error() {
430        return Err(offline(format!("token refresh failed: {reason}")));
431    }
432    // invalid_grant: revoked, expired (7 days in "Testing" mode) or rotated.
433    Err(auth_error(format!(
434        "the sign-in is no longer valid ({reason})"
435    )))
436}
437
438/// Revokes a refresh token at Google. Google revokes per Cloud project: every device of the
439/// user signed in with this project is signed out, so apps should usually only forget the
440/// token locally ([`TokenStore::clear`]).
441pub async fn revoke(refresh_token: &str) -> Result<()> {
442    let http = http()?;
443    let (status, j) = post_form(&http, REVOKE_URL, &[("token", refresh_token)]).await?;
444    // 400 invalid_token means it was already revoked or expired.
445    if status == 200 || j["error"] == "invalid_token" {
446        Ok(())
447    } else {
448        Err(Error::new(
449            ErrorKind::Internal,
450            format!("revocation failed: {}", oauth_error(&j)),
451        ))
452    }
453}
454
455/// Where [`TokenStore::save`] put the grant.
456#[derive(Clone, Debug, PartialEq, Eq)]
457pub enum Location {
458    /// The OS keyring.
459    Keyring,
460    /// A file readable only by the user.
461    File(PathBuf),
462}
463
464/// Keeps a grant in the OS keyring, or in a `0600` file where there is none (headless Linux
465/// without a Secret Service). `BOTHY_TOKEN_STORE=file`, or [`TokenStore::file_only`], uses only
466/// the file.
467#[derive(Clone, Debug)]
468// Without the keyring feature only `file` is used.
469#[cfg_attr(not(feature = "keyring"), allow(dead_code))]
470pub struct TokenStore {
471    service: String,
472    account: String,
473    file: PathBuf,
474    keyring: bool,
475}
476
477impl TokenStore {
478    /// A store under the keyring entry `service`/`account`, with `file` as the fallback.
479    pub fn new(service: &str, account: &str, file: impl Into<PathBuf>) -> Self {
480        Self {
481            service: service.to_owned(),
482            account: account.to_owned(),
483            file: file.into(),
484            keyring: cfg!(feature = "keyring")
485                && !std::env::var("BOTHY_TOKEN_STORE").is_ok_and(|v| v == "file"),
486        }
487    }
488
489    /// A store that only uses `file`.
490    pub fn file_only(file: impl Into<PathBuf>) -> Self {
491        Self {
492            keyring: false,
493            ..Self::new("", "", file)
494        }
495    }
496
497    /// The fallback file.
498    pub fn file(&self) -> &Path {
499        &self.file
500    }
501
502    /// Stores the grant and removes any copy left in the other place.
503    pub fn save(&self, grant: &Grant) -> Result<Location> {
504        #[cfg(feature = "keyring")]
505        if self.keyring
506            && let Ok(entry) = keyring::Entry::new(&self.service, &self.account)
507            && entry.set_password(&grant.to_json()).is_ok()
508        {
509            let _ = std::fs::remove_file(&self.file);
510            return Ok(Location::Keyring);
511        }
512        write_private(&self.file, grant.to_json().as_bytes())?;
513        Ok(Location::File(self.file.clone()))
514    }
515
516    /// The stored grant, if any.
517    pub fn load(&self) -> Option<Grant> {
518        #[cfg(feature = "keyring")]
519        if self.keyring
520            && let Ok(entry) = keyring::Entry::new(&self.service, &self.account)
521            && let Ok(s) = entry.get_password()
522        {
523            return Grant::from_json(&s);
524        }
525        std::fs::read_to_string(&self.file)
526            .ok()
527            .and_then(|s| Grant::from_json(&s))
528    }
529
530    /// Forgets the grant on this machine (it stays valid at Google; see [`revoke`]).
531    pub fn clear(&self) {
532        #[cfg(feature = "keyring")]
533        if self.keyring
534            && let Ok(entry) = keyring::Entry::new(&self.service, &self.account)
535        {
536            let _ = entry.delete_credential();
537        }
538        let _ = std::fs::remove_file(&self.file);
539    }
540}
541
542/// Writes `bytes` to `path` atomically, readable only by the user (mode `0600` on Unix).
543pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
544    let io = |e| {
545        Error::new(
546            ErrorKind::Internal,
547            format!("cannot write {}", path.display()),
548        )
549        .with_source(e)
550    };
551    if let Some(dir) = path.parent() {
552        std::fs::create_dir_all(dir).map_err(io)?;
553    }
554    let tmp = path.with_extension("tmp");
555    {
556        use std::io::Write;
557        let mut opts = std::fs::OpenOptions::new();
558        opts.write(true).create(true).truncate(true);
559        #[cfg(unix)]
560        {
561            use std::os::unix::fs::OpenOptionsExt;
562            opts.mode(0o600);
563        }
564        let mut f = opts.open(&tmp).map_err(io)?;
565        f.write_all(bytes).map_err(io)?;
566        f.sync_all().map_err(io)?;
567    }
568    std::fs::rename(&tmp, path).map_err(io)
569}
570
571/// Fresh access tokens from a grant, cached until a minute before they expire.
572pub struct RefreshingTokens {
573    client: Client,
574    refresh_token: String,
575    http: reqwest::Client,
576    cached: Mutex<Option<(String, Instant)>>,
577}
578
579impl std::fmt::Debug for RefreshingTokens {
580    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581        f.debug_struct("RefreshingTokens")
582            .field("client", &self.client)
583            .finish_non_exhaustive()
584    }
585}
586
587impl RefreshingTokens {
588    /// A provider for `grant`, which `client` issued.
589    pub fn new(client: Client, grant: &Grant) -> Result<Self> {
590        Ok(Self {
591            client,
592            refresh_token: grant.refresh_token.clone(),
593            http: http()?,
594            cached: Mutex::new(None),
595        })
596    }
597}
598
599#[async_trait]
600impl AccessTokenProvider for RefreshingTokens {
601    async fn access_token(&self) -> Result<String> {
602        if let Some((t, until)) = self
603            .cached
604            .lock()
605            .unwrap_or_else(|e| e.into_inner())
606            .clone()
607            && Instant::now() < until
608        {
609            return Ok(t);
610        }
611        let (token, ttl) = refresh(&self.http, &self.client, &self.refresh_token).await?;
612        // Refresh a minute early.
613        let until = Instant::now() + ttl.saturating_sub(Duration::from_secs(60));
614        *self.cached.lock().unwrap_or_else(|e| e.into_inner()) = Some((token.clone(), until));
615        Ok(token)
616    }
617
618    async fn invalidate(&self, rejected: &str) {
619        let mut c = self.cached.lock().unwrap_or_else(|e| e.into_inner());
620        if c.as_ref().is_some_and(|(t, _)| t == rejected) {
621            *c = None;
622        }
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn client_files_from_the_console() {
632        let c = Client::from_json(
633            br#"{"installed":{"client_id":"abc.apps.googleusercontent.com","client_secret":"s"}}"#,
634        )
635        .unwrap();
636        assert_eq!(c.id, "abc.apps.googleusercontent.com");
637        assert_eq!(
638            Client::from_json(b"{}").unwrap_err().kind(),
639            ErrorKind::InvalidInput
640        );
641        assert!(Client::from_json(b"nope").is_err());
642    }
643
644    #[test]
645    fn debug_output_never_contains_secrets() {
646        let c = Client::new("id", "TOP-SECRET");
647        let g = Grant {
648            refresh_token: "REFRESH".into(),
649            scopes: vec![SCOPE_FILE.into()],
650            client: "device".into(),
651        };
652        let t = RefreshingTokens::new(c.clone(), &g).unwrap();
653        let all = format!("{c:?}{g:?}{t:?}");
654        assert!(
655            !all.contains("TOP-SECRET") && !all.contains("REFRESH"),
656            "{all}"
657        );
658    }
659
660    #[test]
661    fn grant_json_round_trip_and_file_store() {
662        let g = Grant {
663            refresh_token: "r".into(),
664            scopes: vec!["a".into(), "b".into()],
665            client: "desktop".into(),
666        };
667        let back = Grant::from_json(&g.to_json()).unwrap();
668        assert_eq!(
669            (back.refresh_token, back.scopes, back.client),
670            (g.refresh_token.clone(), g.scopes.clone(), g.client.clone())
671        );
672
673        let dir = tempfile::tempdir().unwrap();
674        let store = TokenStore::file_only(dir.path().join("t/token.json"));
675        assert!(store.load().is_none());
676        assert_eq!(
677            store.save(&g).unwrap(),
678            Location::File(store.file().to_owned())
679        );
680        #[cfg(unix)]
681        {
682            use std::os::unix::fs::PermissionsExt;
683            let mode = std::fs::metadata(store.file())
684                .unwrap()
685                .permissions()
686                .mode();
687            assert_eq!(mode & 0o777, 0o600);
688        }
689        assert!(store.load().unwrap().has("b"));
690        store.clear();
691        assert!(store.load().is_none());
692    }
693}