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