1use 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
25pub const SCOPE_APPDATA: &str = "https://www.googleapis.com/auth/drive.appdata";
27pub 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#[derive(Clone)]
49pub struct Client {
50 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 pub fn new(id: &str, secret: &str) -> Self {
67 Self {
68 id: id.to_owned(),
69 secret: secret.to_owned(),
70 }
71 }
72
73 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 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#[derive(Clone)]
100pub struct Grant {
101 pub refresh_token: String,
103 pub scopes: Vec<String>,
105 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 pub fn has(&self, scope: &str) -> bool {
121 self.scopes.iter().any(|s| s == scope)
122 }
123
124 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 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
145pub 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#[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
220pub 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 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#[derive(Clone, Debug)]
318pub struct DeviceCode {
319 pub verification_url: String,
321 pub user_code: String,
323 pub expires_in: Duration,
325}
326
327pub 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
400pub 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 Err(auth_error(format!(
434 "the sign-in is no longer valid ({reason})"
435 )))
436}
437
438pub 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 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#[derive(Clone, Debug, PartialEq, Eq)]
457pub enum Location {
458 Keyring,
460 File(PathBuf),
462}
463
464#[derive(Clone, Debug)]
468#[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 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 pub fn file_only(file: impl Into<PathBuf>) -> Self {
491 Self {
492 keyring: false,
493 ..Self::new("", "", file)
494 }
495 }
496
497 pub fn file(&self) -> &Path {
499 &self.file
500 }
501
502 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 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 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
542pub 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
571pub 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 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 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}