org_gdocs/auth.rs
1//! OAuth 2.0 authentication via the installed-app (loopback) flow.
2//!
3//! Token acquisition, on-disk caching, and silent refresh are delegated to
4//! `yup-oauth2` — the maintained crate the Google API bindings themselves use —
5//! so this crate never hand-rolls the OAuth dance (architecture decision A2).
6//!
7//! Scopes are least-privilege (D4): edit Google Docs, and access only the Drive
8//! files this app creates or opens (sufficient to read and resolve comments on
9//! app-created documents).
10
11use std::path::Path;
12
13use hyper_rustls::HttpsConnector;
14use hyper_util::client::legacy::Client;
15use hyper_util::client::legacy::connect::HttpConnector;
16use hyper_util::rt::TokioExecutor;
17use yup_oauth2::authenticator::Authenticator;
18use yup_oauth2::{
19 CustomHyperClientBuilder, InstalledFlowAuthenticator, InstalledFlowReturnMethod,
20 read_application_secret,
21};
22
23use crate::error::{Error, Result};
24
25/// OAuth scopes requested by `org-gdocs` (least privilege, D4).
26pub const SCOPES: [&str; 2] = [
27 "https://www.googleapis.com/auth/documents",
28 "https://www.googleapis.com/auth/drive.file",
29];
30
31/// HTTPS connector shared by the authenticator and the API hubs.
32pub type Connector = HttpsConnector<HttpConnector>;
33
34/// A built OAuth authenticator over the shared HTTPS [`Connector`].
35pub type GoogleAuthenticator = Authenticator<Connector>;
36
37/// Build an HTTPS connector using rustls with the platform's native roots.
38///
39/// # Errors
40///
41/// Returns [`Error::Auth`] when the native root certificate store cannot be
42/// loaded.
43pub fn https_connector() -> Result<Connector> {
44 // rustls 0.23 needs a process-default `CryptoProvider`; with both `ring` and
45 // `aws-lc-rs` reachable in the tree it cannot pick one automatically. Install
46 // `ring` (our selected backend) once — a later call returns the already-set
47 // provider, which we ignore.
48 let _ = rustls::crypto::ring::default_provider().install_default();
49
50 let connector = hyper_rustls::HttpsConnectorBuilder::new()
51 .with_native_roots()
52 .map_err(|err| Error::Auth(format!("load native TLS roots: {err}")))?
53 .https_or_http()
54 .enable_http1()
55 .enable_http2()
56 .build();
57 Ok(connector)
58}
59
60/// Build an authenticator for the given credential and token-cache paths.
61///
62/// The interactive loopback flow runs lazily on first token request (see
63/// [`obtain_token`]); constructing the authenticator alone does not prompt.
64///
65/// # Errors
66///
67/// Returns [`Error::Auth`] when the client secret cannot be read or the
68/// authenticator cannot be built.
69pub async fn authenticator(
70 credentials_path: &Path,
71 token_path: &Path,
72) -> Result<GoogleAuthenticator> {
73 let secret = read_application_secret(credentials_path)
74 .await
75 .map_err(|err| {
76 Error::Auth(format!(
77 "read credentials {}: {err}",
78 credentials_path.display()
79 ))
80 })?;
81
82 // `persist_tokens_to_disk` writes the cache but does not create its parent
83 // directory; ensure the data dir exists first (the Go reference did the same).
84 ensure_parent_dir(token_path)?;
85
86 let client = Client::builder(TokioExecutor::new()).build(https_connector()?);
87
88 InstalledFlowAuthenticator::with_client(
89 secret,
90 InstalledFlowReturnMethod::HTTPRedirect,
91 CustomHyperClientBuilder::from(client),
92 )
93 .persist_tokens_to_disk(token_path)
94 .build()
95 .await
96 .map_err(|err| Error::Auth(format!("build authenticator: {err}")))
97}
98
99/// Ensure the parent directory of `path` exists, creating it if necessary.
100///
101/// # Errors
102///
103/// Returns [`Error::Io`] when the directory cannot be created.
104fn ensure_parent_dir(path: &Path) -> Result<()> {
105 let Some(parent) = path.parent() else {
106 return Ok(());
107 };
108 std::fs::create_dir_all(parent).map_err(|source| Error::Io {
109 path: parent.to_path_buf(),
110 source,
111 })
112}
113
114/// Force token acquisition for [`SCOPES`], running the interactive loopback flow
115/// when no valid cached token exists and persisting the result.
116///
117/// # Errors
118///
119/// Returns [`Error::Auth`] when the flow fails or no token can be obtained.
120pub async fn obtain_token(auth: &GoogleAuthenticator) -> Result<()> {
121 auth.token(&SCOPES)
122 .await
123 .map(|_token| ())
124 .map_err(|err| Error::Auth(format!("acquire token: {err}")))
125}