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