Skip to main content

grr_cli/core/auth/
mod.rs

1//! OAuth2 authentication: PKCE loopback flow (RFC 8252) with an RFC 8628
2//! device-flow fallback, tokens in the OS keyring.
3//!
4//! Multi-account: one OAuth client, one token per Google account. An auth
5//! handle is bound to one account's store entry (the default account keeps
6//! the legacy naming byte-for-byte); named accounts log in independently.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use anyhow::anyhow;
13use reqwest::Client as HttpClient;
14use tokio::sync::RwLock;
15use tracing::{debug, info, warn};
16
17use crate::core::config::OAuthConfig;
18use crate::core::error::{GrrError, Result};
19
20mod device;
21mod oauth;
22mod server;
23mod store;
24mod token;
25
26pub use device::DeviceAuthChallenge;
27pub use token::TokenStorage;
28
29use store::TokenStore;
30
31const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
32
33/// Loopback redirect for the native-app flow (RFC 8252 ยง7.3).
34pub(crate) const REDIRECT_URI: &str = "http://localhost:3434/oauth/callback";
35
36/// Union of every service's scopes (Gmail, Calendar, Drive, People, Chat,
37/// Forms). Least-privilege selection is deliberately not implemented:
38/// one credential, everything works, nothing to configure.
39pub const SCOPES: &[&str] = &[
40    // Gmail
41    "https://www.googleapis.com/auth/gmail.readonly",
42    "https://www.googleapis.com/auth/gmail.compose",
43    "https://www.googleapis.com/auth/gmail.modify",
44    "https://www.googleapis.com/auth/gmail.labels",
45    "https://mail.google.com/",
46    // Calendar
47    "https://www.googleapis.com/auth/calendar",
48    // Drive
49    "https://www.googleapis.com/auth/drive",
50    // People (contacts)
51    "https://www.googleapis.com/auth/contacts",
52    "https://www.googleapis.com/auth/contacts.other.readonly",
53    // Chat
54    "https://www.googleapis.com/auth/chat.messages",
55    "https://www.googleapis.com/auth/chat.spaces",
56    "https://www.googleapis.com/auth/chat.delete",
57    "https://www.googleapis.com/auth/chat.memberships",
58    "https://www.googleapis.com/auth/chat.messages.reactions",
59    // Forms
60    "https://www.googleapis.com/auth/forms.body",
61    "https://www.googleapis.com/auth/forms.responses.readonly",
62];
63
64pub(crate) fn scopes_joined() -> String {
65    SCOPES.join(" ")
66}
67
68/// OAuth2 client with PKCE support
69#[derive(Clone)]
70pub struct GoogleAuth {
71    config: OAuthConfig,
72    http_client: HttpClient,
73    token_storage: Arc<RwLock<Option<TokenStorage>>>,
74    store: TokenStore,
75    token_endpoint: String,
76}
77
78impl GoogleAuth {
79    /// Create new GoogleAuth from config
80    pub async fn new(config: OAuthConfig) -> Result<Self> {
81        Self::with_store(config, TokenStore::auto().await?).await
82    }
83
84    /// Create an auth handle bound to one named account's own token
85    /// (multi-account support). Same OAuth client and scopes; every store
86    /// read and write targets that account's keyring entry / fallback file
87    /// exclusively. The name is validated and normalized here (rejects
88    /// spaces, colons, and path separators; lowercased).
89    pub async fn new_for(config: OAuthConfig, account: impl Into<String>) -> Result<Self> {
90        let account = store::validate_account_name(account.into().as_str())?;
91        let store = TokenStore::auto_named(&account).await?;
92        Self::with_store(config, store).await
93    }
94
95    async fn with_store(config: OAuthConfig, store: TokenStore) -> Result<Self> {
96        let http_client = HttpClient::builder()
97            .timeout(Duration::from_secs(30))
98            .build()
99            .map_err(|e| GrrError::Config(format!("Failed to create HTTP client: {}", e)))?;
100        // The store is NOT read here. Reading the OS keyring raises an
101        // approval prompt on unsigned binaries (macOS re-prompts per build),
102        // and the constructor runs for every command โ€” including `--dry-run`,
103        // `schema`, and `api list`, which never need a credential. The first
104        // read happens in `get_access_token`, the first moment a credential
105        // is actually required.
106        Ok(Self {
107            config,
108            http_client,
109            token_storage: Arc::new(RwLock::new(None)),
110            store,
111            token_endpoint: GOOGLE_TOKEN_URL.to_string(),
112        })
113    }
114
115    /// A handle bound to `account`'s own token store, reusing this handle's
116    /// config (and thus the same OAuth client). The token cache is NOT
117    /// shared: the new handle loads the named account's stored token.
118    ///
119    /// This is what `commands/auth.rs` uses to rebind a default handle when
120    /// a per-command `--account` is present.
121    pub async fn with_account_store(&self, account: impl Into<String>) -> Result<Self> {
122        Self::new_for(self.config.clone(), account).await
123    }
124
125    /// A fresh handle on the DEFAULT account's store โ€” today's exact entry
126    /// naming, no account suffix.
127    ///
128    /// `grr auth login`/`status` rebuild their handle through this so a
129    /// named `--account` and the default take the same code path; without
130    /// an account it is behavior-identical to the original construction.
131    pub async fn with_default_account_store(&self) -> Result<Self> {
132        Self::with_store(self.config.clone(), TokenStore::default_named().await?).await
133    }
134
135    /// Token storage backend in use (for logs and `auth login` output).
136    pub fn token_backend(&self) -> &'static str {
137        self.store.backend()
138    }
139
140    /// The shared HTTP client, which speaks HTTP/3 (QUIC) with an HTTP/2
141    /// fallback.
142    ///
143    /// Exposed for `grr api`, which issues requests to arbitrary
144    /// Discovery-derived URLs. Routing those through the same client is the
145    /// point: the dynamic surface gets the same transport as the curated
146    /// commands rather than quietly dropping to HTTP/1.1.
147    pub fn http(&self) -> &HttpClient {
148        &self.http_client
149    }
150
151    /// Construct an auth handle pre-loaded with an in-memory token.
152    ///
153    /// Test hook (`#[doc(hidden)]`): skips OAuth and persistent storage
154    /// entirely until a test explicitly installs a token path.
155    #[doc(hidden)]
156    pub async fn with_token(config: OAuthConfig, storage: TokenStorage) -> Result<Self> {
157        let this = Self::with_store(config, TokenStore::Memory).await?;
158        *this.token_storage.write().await = Some(storage);
159        Ok(this)
160    }
161
162    /// Override the OAuth2 token endpoint URL (test injection hook).
163    #[doc(hidden)]
164    pub fn with_token_endpoint(mut self, url: impl Into<String>) -> Self {
165        self.token_endpoint = url.into();
166        self
167    }
168
169    /// Redirect all token persistence to an explicit file (test injection
170    /// hook). Production code never touches the real credential store
171    /// because tests always pass a tempdir path here.
172    #[doc(hidden)]
173    pub fn with_token_path(mut self, path: PathBuf) -> Self {
174        self.store = TokenStore::file(path);
175        self
176    }
177
178    /// Get valid access token, refreshing if necessary
179    pub async fn get_access_token(&self) -> Result<String> {
180        let mut storage_guard = self.token_storage.write().await;
181
182        // First credential use in this process (the constructor never reads
183        // the store โ€” see `with_store`). A logged-in user must never fall
184        // into the implicit OAuth flow just because the token was not loaded
185        // yet, so the store read happens before the None-means-fresh-install
186        // branch below.
187        if storage_guard.is_none() {
188            *storage_guard = self.store.load().await?;
189        }
190
191        if let Some(storage) = storage_guard.as_ref() {
192            if !storage.is_expired() {
193                debug!(
194                    "Using cached access token ({}s remaining)",
195                    storage.remaining_secs()
196                );
197                return Ok(storage.access_token.clone());
198            }
199
200            // A stored credential that cannot produce a fresh token must never
201            // trigger the implicit browser flow: `run_oauth_flow` binds port
202            // 3434 mid-API-call and wedges callers for the callback timeout.
203            let Some(refresh_token) = storage.refresh_token.clone() else {
204                return Err(GrrError::Auth(
205                    anyhow!(
206                        "stored token is expired and has no refresh token; rerun `grr auth login`"
207                    )
208                    .into(),
209                ));
210            };
211
212            info!("Refreshing expired access token");
213            match self.refresh_token(&refresh_token).await {
214                Ok(new_storage) => {
215                    *storage_guard = Some(new_storage.clone());
216                    self.save_token(&new_storage).await?;
217                    return Ok(new_storage.access_token);
218                }
219                Err(e) => {
220                    warn!("Token refresh failed: {}", e);
221                    // Keep the expired storage in place: clearing it would
222                    // route subsequent callers into the implicit OAuth flow.
223                    return Err(GrrError::Auth(
224                        anyhow!("{e}; rerun `grr auth login`").into(),
225                    ));
226                }
227            }
228        }
229
230        // Fresh install / explicit login path: no stored token at all.
231        info!("No valid token, starting OAuth flow");
232        let storage = self.run_oauth_flow().await?;
233        *storage_guard = Some(storage.clone());
234        self.save_token(&storage).await?;
235        Ok(storage.access_token)
236    }
237
238    /// POST one form to the OAuth token endpoint and parse the JSON body a
239    /// single time. The client secret is appended when configured.
240    ///
241    /// Returns `(status, body)` rather than making a success decision here,
242    /// so each caller keeps its own logic and messages: the PKCE exchange
243    /// and the refresh grant report Google's error detail on non-2xx, while
244    /// the device poll reads `error` codes out of a 400 body. Three callers
245    /// previously duplicated this request/parse plumbing byte for byte.
246    async fn token_request(
247        &self,
248        mut form: Vec<(&str, String)>,
249    ) -> Result<(reqwest::StatusCode, serde_json::Value)> {
250        if let Some(secret) = self
251            .config
252            .client_secret
253            .as_deref()
254            .filter(|s| !s.is_empty())
255        {
256            form.push(("client_secret", secret.to_string()));
257        }
258
259        let response = self
260            .http_client
261            .post(self.token_endpoint.as_str())
262            .form(&form)
263            .send()
264            .await
265            .map_err(GrrError::Http)?;
266
267        let status = response.status();
268        let body_text = response.text().await.map_err(GrrError::Http)?;
269        let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
270            GrrError::Auth(
271                anyhow!("token endpoint returned {status}: unparseable body ({e})").into(),
272            )
273        })?;
274        Ok((status, body))
275    }
276
277    /// Refresh access token using refresh token.
278    /// The secret is sent only when configured (Google mandates it even
279    /// for Desktop clients; PKCE-only providers omit it entirely).
280    async fn refresh_token(&self, refresh_token: &str) -> Result<TokenStorage> {
281        let (status, token_data) = self
282            .token_request(vec![
283                ("client_id", self.config.client_id.clone()),
284                ("refresh_token", refresh_token.to_string()),
285                ("grant_type", "refresh_token".to_string()),
286            ])
287            .await?;
288
289        if !status.is_success() {
290            // Google reports rejection reasons (e.g. invalid_grant) in the
291            // response body; surface them instead of a generic parse failure.
292            return Err(GrrError::Auth(
293                anyhow!(
294                    "token endpoint returned {}: {}",
295                    status,
296                    crate::core::error::json_error_detail(&token_data)
297                )
298                .into(),
299            ));
300        }
301
302        let mut storage = self::device::token_storage_from_response(&token_data, SCOPES)?;
303
304        // Google only sometimes rotates the refresh token; when the
305        // response omits one, keep the stored value instead of clobbering
306        // it with None (which would brick all future refreshes).
307        if storage.refresh_token.is_none() {
308            storage.refresh_token = Some(refresh_token.to_string());
309        }
310
311        Ok(storage)
312    }
313
314    /// Revoke current token
315    pub async fn revoke(&self) -> Result<()> {
316        let mut storage_guard = self.token_storage.write().await;
317        *storage_guard = None;
318        self.store.delete().await?;
319        info!("Token revoked and removed");
320        Ok(())
321    }
322}
323
324pub struct AuthConfigBuilder {
325    config: OAuthConfig,
326}
327
328impl AuthConfigBuilder {
329    pub fn new() -> Self {
330        Self {
331            config: OAuthConfig::default(),
332        }
333    }
334
335    pub fn client_id(mut self, id: impl Into<String>) -> Self {
336        self.config.client_id = id.into();
337        self
338    }
339
340    pub fn client_secret(mut self, secret: Option<String>) -> Self {
341        // Empty strings behave as absent: the secret is omitted everywhere.
342        self.config.client_secret = secret.filter(|s| !s.is_empty());
343        self
344    }
345
346    pub async fn build(self) -> Result<GoogleAuth> {
347        GoogleAuth::new(self.config).await
348    }
349}
350
351impl Default for AuthConfigBuilder {
352    fn default() -> Self {
353        Self::new()
354    }
355}