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