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,
264    /// runs the PKCE browser flow, saves and returns the new token.
265    pub async fn login(&self) -> Result<TokenStorage> {
266        // Best effort: a dead on-disk token must never block fresh consent.
267        let _ = self.revoke().await;
268        let storage = self.run_oauth_flow().await?;
269        *self.token_storage.write().await = Some(storage.clone());
270        self.save_token(&storage).await?;
271        Ok(storage)
272    }
273}
274
275/// Build [`TokenStorage`] from a successful token-endpoint JSON body.
276/// Shared by the PKCE exchange and the device poll.
277pub(crate) fn token_storage_from_response(
278    token_data: &serde_json::Value,
279    scopes: &[String],
280) -> Result<TokenStorage> {
281    let access_token = token_data["access_token"]
282        .as_str()
283        .ok_or_else(|| GrrError::Auth(anyhow!("No access token in response").into()))?
284        .to_string();
285
286    let expires_in = token_data["expires_in"].as_u64().unwrap_or(3600);
287    let refresh_token = token_data["refresh_token"].as_str().map(str::to_string);
288
289    let expires_at = SystemTime::now()
290        .duration_since(UNIX_EPOCH)
291        .map(|d| d.as_secs())
292        .unwrap_or(0)
293        + expires_in;
294
295    Ok(TokenStorage {
296        access_token,
297        refresh_token,
298        expires_at,
299        token_type: "Bearer".to_string(),
300        scope: scopes.join(" "),
301    })
302}
303
304#[cfg(test)]
305mod tests {
306    use super::super::{GoogleAuth, TokenStorage};
307    use crate::core::config::OAuthConfig;
308
309    async fn auth(device: Option<(&str, &str)>) -> crate::core::error::Result<GoogleAuth> {
310        let config = OAuthConfig {
311            client_id: "desktop-id.apps.googleusercontent.com".into(),
312            client_secret: Some("desktop-secret".into()),
313            device_client_id: device.map(|(id, _)| id.to_owned()),
314            device_client_secret: device.map(|(_, secret)| secret.to_owned()),
315        };
316        let storage = TokenStorage {
317            access_token: "test-token".into(),
318            refresh_token: None,
319            expires_at: u64::MAX,
320            token_type: "Bearer".into(),
321            scope: String::new(),
322        };
323        GoogleAuth::with_token(config, storage).await
324    }
325
326    /// Google rejects every scope outside its device-flow allowlist with
327    /// `invalid_scope` — one bad scope fails the whole request — so the device
328    /// consent string is the intersection, and it is deliberately narrower
329    /// than the browser one (Gmail and Chat are not in it).
330    #[test]
331    fn the_device_flow_requests_only_scopes_google_grants() {
332        let device = super::device_flow_scopes_joined();
333        let requested: Vec<&str> = device.split(' ').collect();
334
335        // The flow stays usable: the two scopes that matter are in it.
336        for useful in [
337            "https://www.googleapis.com/auth/drive.file",
338            "https://www.googleapis.com/auth/calendar.readonly",
339        ] {
340            assert!(requested.contains(&useful), "{useful} was dropped");
341        }
342        // Scopes Google's device endpoint rejects — one bad scope fails the
343        // whole request. mail.google.com and chat.* are outside the flow's
344        // allowlist entirely; the other two are on Google's *page* for the
345        // flow but rejected by the endpoint itself (verified 2026-10-13).
346        for rejected in [
347            "https://mail.google.com/",
348            "https://www.googleapis.com/auth/drive.appdata",
349            "https://www.googleapis.com/auth/drive.metadata.readonly",
350        ] {
351            assert!(
352                !requested.contains(&rejected),
353                "{rejected} must never reach the device endpoint"
354            );
355        }
356        assert!(
357            !requested.iter().any(|s| s.contains("/auth/chat.")),
358            "Chat is not a device-flow scope: {device}"
359        );
360        // Never wider than the browser consent set.
361        for scope in &requested {
362            assert!(
363                crate::core::auth::scopes().iter().any(|s| s == scope),
364                "{scope} is not in the consent set at all"
365            );
366        }
367    }
368
369    /// Google answers the Desktop client's device request with
370    /// `invalid_client: Invalid client type` — a message that names neither
371    /// the cause nor the fix. The flow refuses locally instead, naming the
372    /// client type and every knob that supplies it.
373    #[tokio::test]
374    async fn a_missing_device_client_is_refused_locally_with_instructions() -> anyhow::Result<()> {
375        let Err(err) = auth(None).await?.device_client() else {
376            return Err(anyhow::anyhow!(
377                "a build with no device client must refuse locally, not call Google"
378            ));
379        };
380        let err = err.to_string();
381        assert!(
382            err.contains("TVs and Limited Input devices"),
383            "does not name the client type: {err}"
384        );
385        assert!(
386            err.contains("GRR_OAUTH__DEVICE_CLIENT_ID"),
387            "the runtime env knob is not named: {err}"
388        );
389        assert!(
390            err.contains("device-client-id"),
391            "no config-file knob named: {err}"
392        );
393        Ok(())
394    }
395
396    /// The device flow authenticates as the device client, so the Desktop id
397    /// never reaches Google's device endpoint (which would reject it).
398    #[tokio::test]
399    async fn the_configured_device_client_is_the_one_used() -> anyhow::Result<()> {
400        let (id, secret) = auth(Some((
401            "device-id.apps.googleusercontent.com",
402            "device-secret",
403        )))
404        .await?
405        .device_client()?;
406        assert_eq!(id, "device-id.apps.googleusercontent.com");
407        assert_eq!(secret.as_deref(), Some("device-secret"));
408        assert_ne!(
409            id, "desktop-id.apps.googleusercontent.com",
410            "the browser client must not be sent to the device endpoint"
411        );
412        Ok(())
413    }
414}