Skip to main content

grr_cli/core/auth/
device.rs

1//! OAuth 2.0 Device Authorization Grant (RFC 8628).
2//!
3//! Headless fallback for the default PKCE loopback flow: the CLI shows a
4//! URL plus a user code, the user approves in any browser (on any device),
5//! and the CLI polls the token endpoint until approval lands.
6//!
7//! A configured client secret is sent when present; secretless PKCE
8//! providers work with it absent.
9
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12use anyhow::anyhow;
13
14use crate::core::error::{GrrError, Result, json_error_detail};
15use tracing::warn;
16
17use super::TokenStorage;
18
19const DEVICE_CODE_URL: &str = "https://oauth2.googleapis.com/device/code";
20const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
21
22/// The scopes Google's device flow will actually grant.
23///
24/// The flow keeps its own allowlist: anything outside it comes back as
25/// `invalid_scope: Invalid device flow scope: <scope>`, which fails the whole
26/// request. It lives per flow, not in the Discovery index, so it cannot be
27/// derived from the embedded data.
28///
29/// Verified against the live device endpoint on 2026-10-13 with a "TVs and
30/// Limited Input devices" client — one scope at a time, because Google names
31/// only the first offender. Google's own page (below) is *not* the endpoint's
32/// truth: it lists `drive.appdata` and `drive.metadata.readonly`, which the
33/// endpoint rejects, and omits the legacy GData scopes, which it accepts.
34///
35/// Source: <https://developers.google.com/identity/protocols/oauth2/limited-input-device>
36const DEVICE_FLOW_SCOPES: &[&str] = &[
37    "https://www.google.com/calendar/feeds",
38    "https://www.google.com/m8/feeds",
39    "https://www.googleapis.com/auth/calendar",
40    "https://www.googleapis.com/auth/calendar.readonly",
41    "https://www.googleapis.com/auth/drive.file",
42    "https://www.googleapis.com/auth/userinfo.email",
43    "https://www.googleapis.com/auth/userinfo.profile",
44    "openid",
45];
46
47/// The consent set the device flow may send: `requested` — the flag-derived set
48/// `grr auth login` would have used, `--service`/`--all-scopes` included —
49/// filtered to what a TV/limited-input client can be granted. An
50/// *intersection*, never a union: one scope outside Google's allowlist fails
51/// the whole request with `invalid_scope`, granting nothing.
52///
53/// A device login is therefore a *narrower* credential than a browser login —
54/// Gmail, Chat, Docs, Sheets, Slides, Tasks, Forms, Apps Script, Analytics and
55/// the admin surfaces are all outside Google's allowlist. That is Google's
56/// rule, not a choice here; asking anyway would grant nothing at all. What the
57/// allowlist drops is logged, so the user learns the grant is narrower than the
58/// flags asked for before they approve it, rather than after a 403.
59pub(crate) fn device_flow_scopes(requested: &[String]) -> Vec<String> {
60    let mut granted: Vec<String> = requested
61        .iter()
62        .filter(|scope| DEVICE_FLOW_SCOPES.contains(&scope.as_str()))
63        .cloned()
64        .collect();
65    // Sorted so the consent string is byte-stable whatever order the flags
66    // produced.
67    granted.sort_unstable();
68    granted.dedup();
69
70    if granted.len() < requested.len() {
71        warn!(
72            "device flow: Google grants a TV/limited-input client {} of the {} scopes requested \
73             (drive.file, calendar and legacy Calendar/Contacts, userinfo, openid); the {} it \
74             drops — Gmail, Chat, Docs, Sheets, Slides, Tasks, Forms, Apps Script, Analytics and \
75             the admin APIs — need the browser flow (`grr auth login`).",
76            granted.len(),
77            requested.len(),
78            requested.len() - granted.len()
79        );
80    }
81    granted
82}
83
84/// Pending device authorization: show `verification_url` + `user_code` to
85/// the user, then poll with [`super::GoogleAuth::poll_device_code`] until done.
86pub struct DeviceAuthChallenge {
87    /// URL the user must open (e.g. <https://www.google.com/device>).
88    pub verification_url: String,
89    /// Short code the user types at `verification_url`.
90    pub user_code: String,
91    pub(crate) device_code: String,
92    pub(crate) interval: Duration,
93    pub(crate) deadline: Instant,
94}
95
96impl DeviceAuthChallenge {
97    /// How long to wait before the next poll (grows on `slow_down`).
98    pub fn retry_after(&self) -> Duration {
99        self.interval
100    }
101
102    /// Whether the user code has expired and the flow must restart.
103    pub fn is_expired(&self) -> bool {
104        Instant::now() >= self.deadline
105    }
106}
107
108impl super::GoogleAuth {
109    /// The client the device flow must authenticate as, plus its secret.
110    ///
111    /// Google's device endpoint accepts one client type only — "TVs and
112    /// Limited Input devices" — and answers the browser flow's Desktop
113    /// client with `invalid_client: Invalid client type`, which tells a
114    /// user nothing. Refusing locally, with the console steps, is the fix.
115    fn device_client(&self) -> Result<(String, Option<String>)> {
116        let id = self
117            .config
118            .device_client_id
119            .as_deref()
120            .map(str::trim)
121            .filter(|id| !id.is_empty())
122            .ok_or_else(|| {
123                GrrError::Auth(
124                    anyhow!(
125                        "the device flow needs an OAuth client of type \"TVs and Limited Input \
126                         devices\": Google rejects the Desktop client with `Invalid client type`.\n\
127                         Create one in the Google Cloud console (APIs & Services → Credentials → \
128                         Create credentials → OAuth client ID → TVs and Limited Input devices), \
129                         then supply it as GRR_OAUTH__DEVICE_CLIENT_ID / \
130                         GRR_OAUTH__DEVICE_CLIENT_SECRET, or as device-client-id / \
131                         device-client-secret in the [oauth] block of ~/.grr/config.toml. \
132                         Building from source can also bake it in: GRR_DEVICE_CLIENT_ID / \
133                         GRR_DEVICE_CLIENT_SECRET (or a repo-root .env).\n\
134                         The browser flow (`grr auth login`) needs none of this."
135                    )
136                    .into(),
137                )
138            })?;
139
140        let secret = self
141            .config
142            .device_client_secret
143            .as_deref()
144            .map(str::trim)
145            .filter(|secret| !secret.is_empty())
146            .map(str::to_owned);
147        Ok((id.to_owned(), secret))
148    }
149
150    /// Start a device flow asking for exactly `scopes` — the caller's
151    /// flag-derived set, already intersected with Google's allowlist by
152    /// [`device_flow_scopes`]. Returns the challenge to display to the user.
153    pub async fn request_device_code(&self, scopes: &[String]) -> Result<DeviceAuthChallenge> {
154        let (client_id, client_secret) = self.device_client()?;
155        if scopes.is_empty() {
156            return Err(GrrError::Auth(
157                anyhow!(
158                    "the requested scopes are all outside Google's device-flow allowlist, so the \
159                     device endpoint would refuse the whole request; the browser flow \
160                     (`grr auth login`) can ask for them"
161                )
162                .into(),
163            ));
164        }
165
166        let form = vec![("client_id", client_id), ("scope", scopes.join(" "))];
167
168        let (status, body) = self
169            .token_request(DEVICE_CODE_URL, client_secret.as_deref(), form)
170            .await?;
171
172        if !status.is_success() {
173            // `invalid_scope` here means Google's per-flow allowlist moved:
174            // the scope is one this flow cannot show, so the whole request is
175            // refused. Say that, rather than repeating a bare scope URL, and
176            // point at the list that has to be updated.
177            let detail = json_error_detail(&body);
178            let hint = if body["error"].as_str() == Some("invalid_scope") {
179                "\nGoogle's device flow keeps its own scope allowlist (DEVICE_FLOW_SCOPES in \
180                 src/core/auth/device.rs, verified 2026-10-13). It has moved: update that list, \
181                 or use the browser flow (`grr auth login`), which has no such ceiling."
182            } else {
183                ""
184            };
185            return Err(GrrError::Auth(
186                anyhow!("device endpoint returned {status}: {detail}{hint}").into(),
187            ));
188        }
189
190        let device_code = body["device_code"]
191            .as_str()
192            .ok_or_else(|| GrrError::Auth(anyhow!("device endpoint omitted device_code").into()))?;
193        let user_code = body["user_code"]
194            .as_str()
195            .ok_or_else(|| GrrError::Auth(anyhow!("device endpoint omitted user_code").into()))?;
196        let verification_url = body
197            .get("verification_url")
198            .or_else(|| body.get("verification_uri"))
199            .and_then(serde_json::Value::as_str)
200            .ok_or_else(|| {
201                GrrError::Auth(anyhow!("device endpoint omitted verification_url").into())
202            })?;
203
204        let expires_in = body["expires_in"].as_u64().unwrap_or(1800);
205        let interval = body["interval"]
206            .as_u64()
207            .map(Duration::from_secs)
208            .filter(|d| !d.is_zero())
209            .unwrap_or_else(|| Duration::from_secs(5));
210
211        Ok(DeviceAuthChallenge {
212            verification_url: verification_url.to_string(),
213            user_code: user_code.to_string(),
214            device_code: device_code.to_string(),
215            interval,
216            deadline: Instant::now() + Duration::from_secs(expires_in),
217        })
218    }
219
220    /// Poll once. Returns `Ok(None)` while the user has not approved yet
221    /// (caller sleeps `retry_after()` and retries), `Ok(Some)` with the
222    /// stored token on approval, or `Err` on denial/expiry/failure.
223    ///
224    /// `scopes` is the same set [`Self::request_device_code`] was called with:
225    /// it is what the credential records when Google's response omits `scope`,
226    /// so the fallback is what was *requested*, never the index superset.
227    pub async fn poll_device_code(
228        &self,
229        challenge: &mut DeviceAuthChallenge,
230        scopes: &[String],
231    ) -> Result<Option<TokenStorage>> {
232        // Device polling reports status via 400 + JSON error codes, so the
233        // body — not the HTTP status — drives the decision below.
234        let (client_id, client_secret) = self.device_client()?;
235        let (_, body) = self
236            .token_request(
237                &self.token_endpoint,
238                client_secret.as_deref(),
239                vec![
240                    ("client_id", client_id),
241                    ("device_code", challenge.device_code.clone()),
242                    ("grant_type", DEVICE_GRANT.to_string()),
243                ],
244            )
245            .await?;
246
247        if let Some(code) = body["error"].as_str() {
248            match code {
249                "authorization_pending" => return Ok(None),
250                "slow_down" => {
251                    challenge.interval += Duration::from_secs(5);
252                    return Ok(None);
253                }
254                _ => {
255                    return Err(GrrError::Auth(
256                        anyhow!("device poll failed: {}", json_error_detail(&body)).into(),
257                    ));
258                }
259            }
260        }
261
262        let storage = token_storage_from_response(&body, scopes)?;
263        *self.token_storage.write().await = Some(storage.clone());
264        self.save_token(&storage).await?;
265        Ok(Some(storage))
266    }
267
268    /// Fresh interactive login: drops any stored (possibly dead) token, runs
269    /// the PKCE browser flow with exactly `scopes`, saves and returns the new
270    /// token. The caller decides the set — see [`super::login_scopes`],
271    /// [`super::scopes_for_service`] and [`super::scopes`].
272    pub async fn login(&self, scopes: &[String]) -> Result<TokenStorage> {
273        // Best effort: a dead on-disk token must never block fresh consent.
274        let _ = self.revoke().await;
275        let storage = self.run_oauth_flow(scopes).await?;
276        *self.token_storage.write().await = Some(storage.clone());
277        self.save_token(&storage).await?;
278        Ok(storage)
279    }
280}
281
282/// Build [`TokenStorage`] from a successful token-endpoint JSON body.
283/// Shared by the PKCE exchange and the device poll.
284///
285/// `scopes` is what the caller wants stored when the response is silent:
286/// the *requested* list on a fresh login, or the stored credential's own
287/// scope on a refresh (which sends no scope list at all). Google's response
288/// carries what was actually *granted* in `scope`, and the two genuinely
289/// differ — the consent screen lets a user approve a subset, and a project
290/// whose consent screen does not declare a scope (Google Auth Platform →
291/// Data access) is granted nothing for it, silently. Storing the request as
292/// if it were the grant makes a later 403 look like a stale token instead of
293/// a scope the account never approved, so the response wins whenever it says
294/// anything; this list is only the fallback for providers that omit it.
295pub(crate) fn token_storage_from_response(
296    token_data: &serde_json::Value,
297    scopes: &[String],
298) -> Result<TokenStorage> {
299    let access_token = token_data["access_token"]
300        .as_str()
301        .ok_or_else(|| GrrError::Auth(anyhow!("No access token in response").into()))?
302        .to_string();
303
304    let expires_in = token_data["expires_in"].as_u64().unwrap_or(3600);
305    let refresh_token = token_data["refresh_token"].as_str().map(str::to_string);
306
307    let expires_at = SystemTime::now()
308        .duration_since(UNIX_EPOCH)
309        .map(|d| d.as_secs())
310        .unwrap_or(0)
311        + expires_in;
312
313    let granted = token_data["scope"]
314        .as_str()
315        .map(str::trim)
316        .filter(|s| !s.is_empty())
317        .map(str::to_owned)
318        .unwrap_or_else(|| scopes.join(" "));
319
320    Ok(TokenStorage {
321        access_token,
322        refresh_token,
323        expires_at,
324        token_type: "Bearer".to_string(),
325        scope: granted,
326    })
327}
328
329#[cfg(test)]
330mod tests {
331    use super::super::{GoogleAuth, TokenStorage};
332    use crate::core::config::OAuthConfig;
333
334    async fn auth(device: Option<(&str, &str)>) -> crate::core::error::Result<GoogleAuth> {
335        let config = OAuthConfig {
336            client_id: "desktop-id.apps.googleusercontent.com".into(),
337            client_secret: Some("desktop-secret".into()),
338            device_client_id: device.map(|(id, _)| id.to_owned()),
339            device_client_secret: device.map(|(_, secret)| secret.to_owned()),
340        };
341        let storage = TokenStorage {
342            access_token: "test-token".into(),
343            refresh_token: None,
344            expires_at: u64::MAX,
345            token_type: "Bearer".into(),
346            scope: String::new(),
347        };
348        GoogleAuth::with_token(config, storage).await
349    }
350
351    /// The token response says what was granted; the request only says what
352    /// was asked for. Google drops scopes a project has not declared (Data
353    /// access), and the consent screen lets a user approve a subset — so the
354    /// stored grant is what makes a later 403 diagnosable instead of looking
355    /// like a stale token.
356    #[test]
357    fn the_stored_scope_is_the_grant_not_the_request() -> anyhow::Result<()> {
358        let requested = vec!["https://www.googleapis.com/auth/gmail.readonly".to_owned()];
359
360        let granted = super::token_storage_from_response(
361            &serde_json::json!({
362                "access_token": "t",
363                "expires_in": 3600,
364                "scope": "openid https://www.googleapis.com/auth/userinfo.email",
365            }),
366            &requested,
367        )?;
368        assert_eq!(
369            granted.scope,
370            "openid https://www.googleapis.com/auth/userinfo.email"
371        );
372
373        // A response that says nothing about scope falls back to the request.
374        let silent = super::token_storage_from_response(
375            &serde_json::json!({ "access_token": "t", "expires_in": 3600 }),
376            &requested,
377        )?;
378        assert_eq!(silent.scope, requested.join(" "));
379
380        // So does a blank one (a provider placeholder must not erase the set).
381        let blank = super::token_storage_from_response(
382            &serde_json::json!({ "access_token": "t", "scope": "   " }),
383            &requested,
384        )?;
385        assert_eq!(blank.scope, requested.join(" "));
386
387        Ok(())
388    }
389
390    /// Google rejects every scope outside its device-flow allowlist with
391    /// `invalid_scope` — one bad scope fails the whole request — so the device
392    /// consent set is the *intersection* of the flag-derived request with that
393    /// allowlist: never a union, never the index superset, and the flags do
394    /// govern (`--all-scopes` is the only way the allowlist's drive.file and
395    /// calendar.readonly come along, because the default set does not ask for
396    /// them).
397    #[test]
398    fn the_device_flow_requests_the_flags_intersected_with_the_allowlist() {
399        let consent = crate::core::auth::scopes();
400        let login = crate::core::auth::login_scopes();
401
402        // `grr auth login --device` with no flags: the default set, filtered.
403        let default = super::device_flow_scopes(&login);
404        for granted in [
405            "https://www.googleapis.com/auth/calendar",
406            "https://www.googleapis.com/auth/userinfo.email",
407            "openid",
408        ] {
409            assert!(
410                default.contains(&granted.to_owned()),
411                "{granted} was dropped"
412            );
413        }
414        // Asked for by the default set, outside the allowlist: one of these
415        // reaching Google fails the whole request.
416        for rejected in [
417            "https://mail.google.com/",
418            "https://www.googleapis.com/auth/drive",
419            "https://www.googleapis.com/auth/drive.appdata",
420            "https://www.googleapis.com/auth/drive.metadata.readonly",
421        ] {
422            assert!(
423                !default.contains(&rejected.to_owned()),
424                "{rejected} must never reach the device endpoint"
425            );
426        }
427        assert!(
428            !default.iter().any(|s| s.contains("/auth/chat.")),
429            "Chat is not a device-flow scope: {default:?}"
430        );
431        // The default set asks for `drive`, not drive.file: the device flow
432        // stops being useful for the allowlist-only scopes until a flag asks.
433        for allowlist_only in [
434            "https://www.googleapis.com/auth/drive.file",
435            "https://www.googleapis.com/auth/calendar.readonly",
436        ] {
437            assert!(!default.contains(&allowlist_only.to_owned()));
438            assert!(
439                login.iter().all(|scope| scope != allowlist_only),
440                "{allowlist_only} unexpectedly entered the default login set"
441            );
442        }
443
444        // `--all-scopes`: the whole index, still intersected.
445        let all = super::device_flow_scopes(consent);
446        for useful in [
447            "https://www.googleapis.com/auth/drive.file",
448            "https://www.googleapis.com/auth/calendar.readonly",
449        ] {
450            assert!(all.contains(&useful.to_owned()), "{useful} was dropped");
451        }
452        assert_eq!(
453            all,
454            consent
455                .iter()
456                .filter(|scope| super::DEVICE_FLOW_SCOPES.contains(&scope.as_str()))
457                .cloned()
458                .collect::<Vec<_>>(),
459            "--all-scopes must be exactly the index intersected with the allowlist"
460        );
461
462        // Never wider than the request, never outside the allowlist, and
463        // byte-stable however the request was ordered.
464        for scope in all.iter().chain(default.iter()) {
465            assert!(
466                consent.iter().any(|known| known == scope),
467                "{scope} is not in the consent set at all"
468            );
469            assert!(
470                super::DEVICE_FLOW_SCOPES.contains(&scope.as_str()),
471                "{scope} is not in Google's device-flow allowlist"
472            );
473        }
474        let mut shuffled = login.clone();
475        shuffled.reverse();
476        assert_eq!(default, super::device_flow_scopes(&shuffled));
477    }
478
479    /// Google answers the Desktop client's device request with
480    /// `invalid_client: Invalid client type` — a message that names neither
481    /// the cause nor the fix. The flow refuses locally instead, naming the
482    /// client type and every knob that supplies it.
483    #[tokio::test]
484    async fn a_missing_device_client_is_refused_locally_with_instructions() -> anyhow::Result<()> {
485        let Err(err) = auth(None).await?.device_client() else {
486            return Err(anyhow::anyhow!(
487                "a build with no device client must refuse locally, not call Google"
488            ));
489        };
490        let err = err.to_string();
491        assert!(
492            err.contains("TVs and Limited Input devices"),
493            "does not name the client type: {err}"
494        );
495        assert!(
496            err.contains("GRR_OAUTH__DEVICE_CLIENT_ID"),
497            "the runtime env knob is not named: {err}"
498        );
499        assert!(
500            err.contains("device-client-id"),
501            "no config-file knob named: {err}"
502        );
503        Ok(())
504    }
505
506    /// The device flow authenticates as the device client, so the Desktop id
507    /// never reaches Google's device endpoint (which would reject it).
508    #[tokio::test]
509    async fn the_configured_device_client_is_the_one_used() -> anyhow::Result<()> {
510        let (id, secret) = auth(Some((
511            "device-id.apps.googleusercontent.com",
512            "device-secret",
513        )))
514        .await?
515        .device_client()?;
516        assert_eq!(id, "device-id.apps.googleusercontent.com");
517        assert_eq!(secret.as_deref(), Some("device-secret"));
518        assert_ne!(
519            id, "desktop-id.apps.googleusercontent.com",
520            "the browser client must not be sent to the device endpoint"
521        );
522        Ok(())
523    }
524}