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