Skip to main content

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}