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