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/// Every scope the embedded index names, minus the ones Google will not show
38/// a user (see [`APP_AUTH_SCOPES`]), plus a curated floor for scopes no
39/// method declares. This is the *superset* a credential may hold — what
40/// `grr auth login --all-scopes` asks for, and what `grr api describe` counts
41/// against — but deliberately not what a login requests by default: see
42/// [`login_scopes`] for why the default is small. A token keeps whatever it
43/// was granted, so a build that widens this list needs one `grr auth login`
44/// to take effect.
45pub fn scopes() -> &'static [String] {
46    static SCOPES: LazyLock<Vec<String>> = LazyLock::new(|| {
47        let mut set: Vec<String> = CURATED_SCOPES.iter().map(|s| (*s).to_owned()).collect();
48        set.extend(crate::discovery::all_scopes());
49        set.retain(|scope| !APP_AUTH_SCOPES.contains(&scope.as_str()));
50        set.sort_unstable();
51        set.dedup();
52        set
53    });
54    &SCOPES
55}
56
57/// What `grr auth login` asks for when no flag says otherwise: the services
58/// every grr user touches, small enough that Google's consent screen
59/// completes with them ticked.
60///
61/// The size is the point. Google's granular-permission screen puts one
62/// checkbox per requested scope, and on an unverified project its submit
63/// stops completing somewhere between the 27 boxes that go through in a
64/// second and the 74 that never do — no error, no redirect, just a spinner
65/// until the callback times out. The whole index is ~112 scopes, so
66/// requesting all of it means a first-time user cannot finish a login at all.
67/// Google's own guidance for that screen says the same thing in policy terms:
68/// avoid bundling scopes at sign-in, and grow the grant with incremental
69/// authorization.
70///
71/// Everything else stays reachable: `grr auth login --service X` asks for one
72/// service's scopes, `--all-scopes` for the whole index, and because every
73/// login carries `include_granted_scopes` those requests *merge* with what the
74/// account already holds instead of replacing it.
75const LOGIN_SCOPES: &[&str] = &[
76    "https://mail.google.com/",
77    "https://www.googleapis.com/auth/gmail.readonly",
78    "https://www.googleapis.com/auth/gmail.modify",
79    "https://www.googleapis.com/auth/gmail.send",
80    "https://www.googleapis.com/auth/drive",
81    "https://www.googleapis.com/auth/calendar",
82    "https://www.googleapis.com/auth/calendar.events",
83    "https://www.googleapis.com/auth/tasks",
84    "https://www.googleapis.com/auth/documents",
85    "https://www.googleapis.com/auth/spreadsheets",
86    "https://www.googleapis.com/auth/presentations",
87    "https://www.googleapis.com/auth/userinfo.email",
88    "https://www.googleapis.com/auth/userinfo.profile",
89    "openid",
90];
91
92/// [`LOGIN_SCOPES`], filtered to scopes the embedded index actually declares —
93/// requesting one no build can declare is a consent hang of its own.
94pub fn login_scopes() -> Vec<String> {
95    let available = scopes();
96    LOGIN_SCOPES
97        .iter()
98        .filter(|scope| available.iter().any(|known| known.as_str() == **scope))
99        .map(|scope| (*scope).to_owned())
100        .collect()
101}
102
103/// Every scope one service's methods declare, plus the sign-in trio, for
104/// `grr auth login --service`. The index is the same source the command tree
105/// and `grr api describe` read, so the set matches exactly what that service
106/// can be asked to do — and stays far below the size at which Google's consent
107/// screen stops submitting. `None` when the index has no such service.
108pub fn scopes_for_service(service: &str) -> Option<Vec<String>> {
109    let mut set: Vec<String> = Vec::new();
110    let entry = crate::discovery::services().get(service)?;
111    for method in &entry.methods {
112        for scope in &method.scopes {
113            if !APP_AUTH_SCOPES.contains(&scope.as_str()) {
114                set.push(scope.clone());
115            }
116        }
117    }
118    for basic in SIGN_IN_SCOPES {
119        set.push((*basic).to_owned());
120    }
121    set.sort_unstable();
122    set.dedup();
123    Some(set)
124}
125
126/// The three scopes every credential needs: without them `grr auth status`
127/// cannot read the profile that names the account.
128pub(crate) const SIGN_IN_SCOPES: &[&str] = &[
129    "https://www.googleapis.com/auth/userinfo.email",
130    "https://www.googleapis.com/auth/userinfo.profile",
131    "openid",
132];
133
134/// Scopes every release has carried since long before the set became
135/// index-derived. The index names all of them today; keeping them as a floor
136/// means a discovery regression cannot silently drop a scope that an
137/// existing credential and an existing user rely on.
138const CURATED_SCOPES: &[&str] = &[
139    // Sign-in. No method in the index declares these, but every credential
140    // needs them to name the account: without `openid`/`userinfo.*` the token
141    // cannot answer `grr auth status`, and a login that omits them looks
142    // healthy while every profile call 403s.
143    "openid",
144    "https://www.googleapis.com/auth/userinfo.email",
145    "https://www.googleapis.com/auth/userinfo.profile",
146    // Gmail
147    "https://www.googleapis.com/auth/gmail.readonly",
148    "https://www.googleapis.com/auth/gmail.compose",
149    "https://www.googleapis.com/auth/gmail.modify",
150    "https://www.googleapis.com/auth/gmail.labels",
151    "https://mail.google.com/",
152    // Calendar
153    "https://www.googleapis.com/auth/calendar",
154    // Drive
155    "https://www.googleapis.com/auth/drive",
156    // People (contacts)
157    "https://www.googleapis.com/auth/contacts",
158    "https://www.googleapis.com/auth/contacts.other.readonly",
159    // Chat
160    "https://www.googleapis.com/auth/chat.messages",
161    "https://www.googleapis.com/auth/chat.spaces",
162    "https://www.googleapis.com/auth/chat.delete",
163    "https://www.googleapis.com/auth/chat.memberships",
164    "https://www.googleapis.com/auth/chat.messages.reactions",
165    // Forms
166    "https://www.googleapis.com/auth/forms.body",
167    "https://www.googleapis.com/auth/forms.responses.readonly",
168    // Search Console
169    "https://www.googleapis.com/auth/webmasters.readonly",
170];
171
172/// Scopes the index names that are granted to a *service account*, never to a
173/// user, and that Google therefore refuses to show on a user consent screen.
174/// They are Chat's app-authentication surface: `chat.bot` "only supports app
175/// authentication", the `chat.app.*` family is used by a Chat app's service
176/// account (with administrator approval), and `chat.import` exists solely for
177/// domain-wide delegation to a service account.
178///
179/// One of them in the request fails the *entire* authorization with
180/// `Error 400: invalid_scope — Some requested scopes cannot be shown:
181/// [...]`, granting nothing, so they must stay out of [`scopes`]. Nothing is
182/// lost by leaving them out: a user credential cannot call the methods behind
183/// them, because those methods authenticate as the app, not as the user.
184///
185/// Keep in sync with Google's Chat authentication docs
186/// (<https://developers.google.com/workspace/chat/authenticate-authorize> and
187/// <https://developers.google.com/workspace/chat/authorize-import>): a refresh
188/// that adds another `chat.app.*` scope needs it listed here too, or login
189/// stops working for everyone.
190pub(crate) const APP_AUTH_SCOPES: &[&str] = &[
191    "https://www.googleapis.com/auth/chat.bot",
192    "https://www.googleapis.com/auth/chat.import",
193    "https://www.googleapis.com/auth/chat.app.delete",
194    "https://www.googleapis.com/auth/chat.app.memberships",
195    "https://www.googleapis.com/auth/chat.app.memberships.readonly",
196    "https://www.googleapis.com/auth/chat.app.messages.readonly",
197    "https://www.googleapis.com/auth/chat.app.spaces",
198    "https://www.googleapis.com/auth/chat.app.spaces.create",
199    "https://www.googleapis.com/auth/chat.app.spaces.readonly",
200    "https://www.googleapis.com/auth/chat.app.all.memberships.readonly",
201    "https://www.googleapis.com/auth/chat.app.all.messages.readonly",
202    "https://www.googleapis.com/auth/chat.app.all.spaces.readonly",
203];
204
205/// OAuth2 client with PKCE support
206#[derive(Clone)]
207pub struct GoogleAuth {
208    config: OAuthConfig,
209    http_client: HttpClient,
210    token_storage: Arc<RwLock<Option<TokenStorage>>>,
211    store: TokenStore,
212    token_endpoint: String,
213}
214
215impl GoogleAuth {
216    /// Create new GoogleAuth from config
217    pub async fn new(config: OAuthConfig) -> Result<Self> {
218        Self::with_store(config, TokenStore::auto().await?).await
219    }
220
221    /// Create an auth handle bound to one named account's own token
222    /// (multi-account support). Same OAuth client and scopes; every store
223    /// read and write targets that account's keyring entry / fallback file
224    /// exclusively. The name is validated and normalized here (rejects
225    /// spaces, colons, and path separators; lowercased).
226    pub async fn new_for(config: OAuthConfig, account: impl Into<String>) -> Result<Self> {
227        let account = store::validate_account_name(account.into().as_str())?;
228        let store = TokenStore::auto_named(&account).await?;
229        Self::with_store(config, store).await
230    }
231
232    async fn with_store(config: OAuthConfig, store: TokenStore) -> Result<Self> {
233        let http_client = HttpClient::builder()
234            .timeout(Duration::from_secs(30))
235            .build()
236            .map_err(|e| GrrError::Config(format!("Failed to create HTTP client: {}", e)))?;
237        // The store is NOT read here. Reading the OS keyring raises an
238        // approval prompt on unsigned binaries (macOS re-prompts per build),
239        // and the constructor runs for every command — including `--dry-run`,
240        // `schema`, and `api list`, which never need a credential. The first
241        // read happens in `get_access_token`, the first moment a credential
242        // is actually required.
243        Ok(Self {
244            config,
245            http_client,
246            token_storage: Arc::new(RwLock::new(None)),
247            store,
248            token_endpoint: GOOGLE_TOKEN_URL.to_string(),
249        })
250    }
251
252    /// A handle bound to `account`'s own token store, reusing this handle's
253    /// config (and thus the same OAuth client). The token cache is NOT
254    /// shared: the new handle loads the named account's stored token.
255    ///
256    /// This is what `commands/auth.rs` uses to rebind a default handle when
257    /// a per-command `--account` is present.
258    pub async fn with_account_store(&self, account: impl Into<String>) -> Result<Self> {
259        Self::new_for(self.config.clone(), account).await
260    }
261
262    /// A fresh handle on the DEFAULT account's store — today's exact entry
263    /// naming, no account suffix.
264    ///
265    /// `grr auth login`/`status` rebuild their handle through this so a
266    /// named `--account` and the default take the same code path; without
267    /// an account it is behavior-identical to the original construction.
268    pub async fn with_default_account_store(&self) -> Result<Self> {
269        Self::with_store(self.config.clone(), TokenStore::default_named().await?).await
270    }
271
272    /// Token storage backend in use (for logs and `auth login` output).
273    pub fn token_backend(&self) -> &'static str {
274        self.store.backend()
275    }
276
277    /// The shared HTTP client, which speaks HTTP/3 (QUIC) with an HTTP/2
278    /// fallback.
279    ///
280    /// Exposed for `grr api`, which issues requests to arbitrary
281    /// Discovery-derived URLs. Routing those through the same client is the
282    /// point: the dynamic surface gets the same transport as the curated
283    /// commands rather than quietly dropping to HTTP/1.1.
284    pub fn http(&self) -> &HttpClient {
285        &self.http_client
286    }
287
288    /// Construct an auth handle pre-loaded with an in-memory token.
289    ///
290    /// Test hook (`#[doc(hidden)]`): skips OAuth and persistent storage
291    /// entirely until a test explicitly installs a token path.
292    #[doc(hidden)]
293    pub async fn with_token(config: OAuthConfig, storage: TokenStorage) -> Result<Self> {
294        let this = Self::with_store(config, TokenStore::Memory).await?;
295        *this.token_storage.write().await = Some(storage);
296        Ok(this)
297    }
298
299    /// Override the OAuth2 token endpoint URL (test injection hook).
300    #[doc(hidden)]
301    pub fn with_token_endpoint(mut self, url: impl Into<String>) -> Self {
302        self.token_endpoint = url.into();
303        self
304    }
305
306    /// Redirect all token persistence to an explicit file (test injection
307    /// hook). Production code never touches the real credential store
308    /// because tests always pass a tempdir path here.
309    #[doc(hidden)]
310    pub fn with_token_path(mut self, path: PathBuf) -> Self {
311        self.store = TokenStore::file(path);
312        self
313    }
314
315    /// Get valid access token, refreshing if necessary
316    pub async fn get_access_token(&self) -> Result<String> {
317        let mut storage_guard = self.token_storage.write().await;
318
319        // First credential use in this process (the constructor never reads
320        // the store — see `with_store`). A logged-in user must never fall
321        // into the implicit OAuth flow just because the token was not loaded
322        // yet, so the store read happens before the None-means-fresh-install
323        // branch below.
324        if storage_guard.is_none() {
325            *storage_guard = self.store.load().await?;
326        }
327
328        if let Some(storage) = storage_guard.as_ref() {
329            if !storage.is_expired() {
330                debug!(
331                    "Using cached access token ({}s remaining)",
332                    storage.remaining_secs()
333                );
334                return Ok(storage.access_token.clone());
335            }
336
337            // A stored credential that cannot produce a fresh token must never
338            // trigger the implicit browser flow: `run_oauth_flow` binds port
339            // 3434 mid-API-call and wedges callers for the callback timeout.
340            let Some(refresh_token) = storage.refresh_token.clone() else {
341                return Err(GrrError::Auth(
342                    anyhow!(
343                        "stored token is expired and has no refresh token; rerun `grr auth login`"
344                    )
345                    .into(),
346                ));
347            };
348
349            info!("Refreshing expired access token");
350            match self.refresh_token(&refresh_token).await {
351                Ok(new_storage) => {
352                    *storage_guard = Some(new_storage.clone());
353                    self.save_token(&new_storage).await?;
354                    return Ok(new_storage.access_token);
355                }
356                Err(e) => {
357                    warn!("Token refresh failed: {}", e);
358                    // Keep the expired storage in place: clearing it would
359                    // route subsequent callers into the implicit OAuth flow.
360                    return Err(GrrError::Auth(
361                        anyhow!("{e}; rerun `grr auth login`").into(),
362                    ));
363                }
364            }
365        }
366
367        // Fresh install / implicit install path: no stored token at all. The
368        // default set keeps the consent page small; widening is an explicit
369        // `grr auth login --service …` / `--all-scopes` after this.
370        info!("No valid token, starting OAuth flow");
371        let storage = self.run_oauth_flow(&login_scopes()).await?;
372        *storage_guard = Some(storage.clone());
373        self.save_token(&storage).await?;
374        Ok(storage.access_token)
375    }
376
377    /// POST one form to the OAuth token endpoint and parse the JSON body a
378    /// single time. `client_secret` is appended when the caller names one —
379    /// a parameter rather than a field read, because the device flow
380    /// authenticates as a *different* client (type "TVs and Limited Input
381    /// devices") than the browser flow, and one request carrying both
382    /// secrets would be rejected.
383    ///
384    /// Returns `(status, body)` rather than making a success decision here,
385    /// so each caller keeps its own logic and messages: the PKCE exchange
386    /// and the refresh grant report Google's error detail on non-2xx, while
387    /// the device poll reads `error` codes out of a 400 body. Three callers
388    /// previously duplicated this request/parse plumbing byte for byte.
389    async fn token_request(
390        &self,
391        client_secret: Option<&str>,
392        mut form: Vec<(&str, String)>,
393    ) -> Result<(reqwest::StatusCode, serde_json::Value)> {
394        if let Some(secret) = client_secret.filter(|s| !s.is_empty()) {
395            form.push(("client_secret", secret.to_string()));
396        }
397
398        let response = self
399            .http_client
400            .post(self.token_endpoint.as_str())
401            .form(&form)
402            .send()
403            .await
404            .map_err(GrrError::Http)?;
405
406        let status = response.status();
407        let body_text = response.text().await.map_err(GrrError::Http)?;
408        let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
409            GrrError::Auth(
410                anyhow!("token endpoint returned {status}: unparseable body ({e})").into(),
411            )
412        })?;
413        Ok((status, body))
414    }
415
416    /// Refresh access token using refresh token.
417    /// The secret is sent only when configured (Google mandates it even
418    /// for Desktop clients; PKCE-only providers omit it entirely).
419    async fn refresh_token(&self, refresh_token: &str) -> Result<TokenStorage> {
420        let (status, token_data) = self
421            .token_request(
422                self.config.client_secret.as_deref(),
423                vec![
424                    ("client_id", self.config.client_id.clone()),
425                    ("refresh_token", refresh_token.to_string()),
426                    ("grant_type", "refresh_token".to_string()),
427                ],
428            )
429            .await?;
430
431        if !status.is_success() {
432            // Google reports rejection reasons (e.g. invalid_grant) in the
433            // response body; surface them instead of a generic parse failure.
434            return Err(GrrError::Auth(
435                anyhow!(
436                    "token endpoint returned {}: {}",
437                    status,
438                    crate::core::error::json_error_detail(&token_data)
439                )
440                .into(),
441            ));
442        }
443
444        let mut storage = self::device::token_storage_from_response(&token_data, scopes())?;
445
446        // Google only sometimes rotates the refresh token; when the
447        // response omits one, keep the stored value instead of clobbering
448        // it with None (which would brick all future refreshes).
449        if storage.refresh_token.is_none() {
450            storage.refresh_token = Some(refresh_token.to_string());
451        }
452
453        Ok(storage)
454    }
455
456    /// Revoke current token
457    pub async fn revoke(&self) -> Result<()> {
458        let mut storage_guard = self.token_storage.write().await;
459        *storage_guard = None;
460        self.store.delete().await?;
461        info!("Token revoked and removed");
462        Ok(())
463    }
464}
465
466pub struct AuthConfigBuilder {
467    config: OAuthConfig,
468}
469
470impl AuthConfigBuilder {
471    pub fn new() -> Self {
472        Self {
473            config: OAuthConfig::default(),
474        }
475    }
476
477    /// Take the whole [`OAuthConfig`] instead of copying fields one at a
478    /// time: the config carries the device-flow client too, and a
479    /// field-by-field copy silently dropped it — `grr auth login --device`
480    /// then refused a device client the user had configured.
481    pub fn oauth(mut self, config: OAuthConfig) -> Self {
482        self.config = config;
483        // Empty strings behave as absent: the secret is omitted everywhere.
484        self.config.client_secret = self.config.client_secret.take().filter(|s| !s.is_empty());
485        self.config.device_client_secret = self
486            .config
487            .device_client_secret
488            .take()
489            .filter(|s| !s.is_empty());
490        self
491    }
492
493    pub async fn build(self) -> Result<GoogleAuth> {
494        GoogleAuth::new(self.config).await
495    }
496}
497
498impl Default for AuthConfigBuilder {
499    fn default() -> Self {
500        Self::new()
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    /// The regression this guards: an app-authentication scope in the consent
509    /// request makes Google reject the whole authorization — no scope is
510    /// granted at all, so login cannot complete. And the filter must be
511    /// surgical: Chat's *user* scopes stay in.
512    #[test]
513    fn the_consent_set_drops_app_authentication_scopes_only() {
514        let consent = scopes();
515        for scope in APP_AUTH_SCOPES {
516            assert!(
517                !consent.iter().any(|consented| consented == scope),
518                "{scope} is app-authentication-only; Google refuses to show it and \
519                 fails the entire authorization request"
520            );
521        }
522        for scope in [
523            "https://www.googleapis.com/auth/chat.messages",
524            "https://www.googleapis.com/auth/chat.admin.spaces",
525            "https://www.googleapis.com/auth/chat.memberships.app",
526        ] {
527            assert!(
528                consent.iter().any(|consented| consented == scope),
529                "{scope} is a user-consent scope and must stay in the request"
530            );
531        }
532    }
533}
534
535/// The default login set is what keeps Google's consent page submittable, so
536/// it gets its own checks: small, inside [`scopes`], and free of everything a
537/// personal account can never be granted.
538#[cfg(test)]
539mod login_scope_tests {
540    use super::*;
541
542    #[test]
543    fn the_default_login_set_is_small_and_inside_the_full_set() {
544        let login = login_scopes();
545        let full = scopes();
546
547        // Past roughly 27 ticked boxes an unverified project's consent screen
548        // stops submitting with no error at all, so the default has to stay
549        // well below that — the number is the insurance policy.
550        assert!(
551            login.len() <= 20,
552            "the default login set is {} scopes: Google's consent screen stops \
553             submitting past ~27 ticked boxes, so this is a regression",
554            login.len()
555        );
556        assert!(
557            login.len() >= 10,
558            "the default set lost its core services: {login:?}"
559        );
560        for scope in &login {
561            assert!(
562                full.iter().any(|known| known == scope),
563                "{scope} is not in scopes() — requesting an undeclared scope hangs too"
564            );
565        }
566        for wanted in [
567            "https://mail.google.com/",
568            "https://www.googleapis.com/auth/drive",
569            "https://www.googleapis.com/auth/calendar",
570            "https://www.googleapis.com/auth/tasks",
571            "https://www.googleapis.com/auth/documents",
572            "https://www.googleapis.com/auth/spreadsheets",
573            "https://www.googleapis.com/auth/presentations",
574            "openid",
575        ] {
576            assert!(
577                login.iter().any(|scope| scope == wanted),
578                "{wanted} is mainstream and must be in the default set"
579            );
580        }
581        for unwanted in [
582            "https://www.googleapis.com/auth/admin.directory.user",
583            "https://www.googleapis.com/auth/chat.admin.spaces",
584            "https://www.googleapis.com/auth/groups",
585            "https://www.google.com/calendar/feeds",
586        ] {
587            assert!(
588                !login.iter().any(|scope| scope == unwanted),
589                "{unwanted} cannot be granted to a personal account; asking for it \
590                 only makes the consent page bigger"
591            );
592        }
593    }
594
595    #[test]
596    fn service_scopes_come_from_the_index_and_stay_narrow() {
597        let docs = scopes_for_service("docs").unwrap_or_default();
598        assert!(!docs.is_empty(), "docs is one of the 14 services");
599        assert!(
600            docs.iter()
601                .any(|scope| scope == "https://www.googleapis.com/auth/documents")
602        );
603        assert!(
604            docs.iter().any(|scope| scope == "openid"),
605            "the sign-in scopes travel with every request so the token can name the account"
606        );
607        assert!(
608            docs.len() < scopes().len(),
609            "one service's scopes must stay far below the whole index"
610        );
611        for scope in &docs {
612            assert!(
613                scopes().iter().any(|known| known == scope),
614                "{scope} is not in scopes()"
615            );
616        }
617        assert!(scopes_for_service("not-a-service").is_none());
618    }
619}