1use 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
22const 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
47pub(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
63pub struct DeviceAuthChallenge {
66 pub verification_url: String,
68 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 pub fn retry_after(&self) -> Duration {
78 self.interval
79 }
80
81 pub fn is_expired(&self) -> bool {
83 Instant::now() >= self.deadline
84 }
85}
86
87impl super::GoogleAuth {
88 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 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 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 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 pub async fn poll_device_code(
225 &self,
226 challenge: &mut DeviceAuthChallenge,
227 ) -> Result<Option<TokenStorage>> {
228 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 pub async fn login(&self, scopes: &[String]) -> Result<TokenStorage> {
268 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
277pub(crate) fn token_storage_from_response(
289 token_data: &serde_json::Value,
290 scopes: &[String],
291) -> Result<TokenStorage> {
292 let access_token = token_data["access_token"]
293 .as_str()
294 .ok_or_else(|| GrrError::Auth(anyhow!("No access token in response").into()))?
295 .to_string();
296
297 let expires_in = token_data["expires_in"].as_u64().unwrap_or(3600);
298 let refresh_token = token_data["refresh_token"].as_str().map(str::to_string);
299
300 let expires_at = SystemTime::now()
301 .duration_since(UNIX_EPOCH)
302 .map(|d| d.as_secs())
303 .unwrap_or(0)
304 + expires_in;
305
306 let granted = token_data["scope"]
307 .as_str()
308 .map(str::trim)
309 .filter(|s| !s.is_empty())
310 .map(str::to_owned)
311 .unwrap_or_else(|| scopes.join(" "));
312
313 Ok(TokenStorage {
314 access_token,
315 refresh_token,
316 expires_at,
317 token_type: "Bearer".to_string(),
318 scope: granted,
319 })
320}
321
322#[cfg(test)]
323mod tests {
324 use super::super::{GoogleAuth, TokenStorage};
325 use crate::core::config::OAuthConfig;
326
327 async fn auth(device: Option<(&str, &str)>) -> crate::core::error::Result<GoogleAuth> {
328 let config = OAuthConfig {
329 client_id: "desktop-id.apps.googleusercontent.com".into(),
330 client_secret: Some("desktop-secret".into()),
331 device_client_id: device.map(|(id, _)| id.to_owned()),
332 device_client_secret: device.map(|(_, secret)| secret.to_owned()),
333 };
334 let storage = TokenStorage {
335 access_token: "test-token".into(),
336 refresh_token: None,
337 expires_at: u64::MAX,
338 token_type: "Bearer".into(),
339 scope: String::new(),
340 };
341 GoogleAuth::with_token(config, storage).await
342 }
343
344 #[test]
350 fn the_stored_scope_is_the_grant_not_the_request() -> anyhow::Result<()> {
351 let requested = vec!["https://www.googleapis.com/auth/gmail.readonly".to_owned()];
352
353 let granted = super::token_storage_from_response(
354 &serde_json::json!({
355 "access_token": "t",
356 "expires_in": 3600,
357 "scope": "openid https://www.googleapis.com/auth/userinfo.email",
358 }),
359 &requested,
360 )?;
361 assert_eq!(
362 granted.scope,
363 "openid https://www.googleapis.com/auth/userinfo.email"
364 );
365
366 let silent = super::token_storage_from_response(
368 &serde_json::json!({ "access_token": "t", "expires_in": 3600 }),
369 &requested,
370 )?;
371 assert_eq!(silent.scope, requested.join(" "));
372
373 let blank = super::token_storage_from_response(
375 &serde_json::json!({ "access_token": "t", "scope": " " }),
376 &requested,
377 )?;
378 assert_eq!(blank.scope, requested.join(" "));
379
380 Ok(())
381 }
382
383 #[test]
388 fn the_device_flow_requests_only_scopes_google_grants() {
389 let device = super::device_flow_scopes_joined();
390 let requested: Vec<&str> = device.split(' ').collect();
391
392 for useful in [
394 "https://www.googleapis.com/auth/drive.file",
395 "https://www.googleapis.com/auth/calendar.readonly",
396 ] {
397 assert!(requested.contains(&useful), "{useful} was dropped");
398 }
399 for rejected in [
404 "https://mail.google.com/",
405 "https://www.googleapis.com/auth/drive.appdata",
406 "https://www.googleapis.com/auth/drive.metadata.readonly",
407 ] {
408 assert!(
409 !requested.contains(&rejected),
410 "{rejected} must never reach the device endpoint"
411 );
412 }
413 assert!(
414 !requested.iter().any(|s| s.contains("/auth/chat.")),
415 "Chat is not a device-flow scope: {device}"
416 );
417 for scope in &requested {
419 assert!(
420 crate::core::auth::scopes().iter().any(|s| s == scope),
421 "{scope} is not in the consent set at all"
422 );
423 }
424 }
425
426 #[tokio::test]
431 async fn a_missing_device_client_is_refused_locally_with_instructions() -> anyhow::Result<()> {
432 let Err(err) = auth(None).await?.device_client() else {
433 return Err(anyhow::anyhow!(
434 "a build with no device client must refuse locally, not call Google"
435 ));
436 };
437 let err = err.to_string();
438 assert!(
439 err.contains("TVs and Limited Input devices"),
440 "does not name the client type: {err}"
441 );
442 assert!(
443 err.contains("GRR_OAUTH__DEVICE_CLIENT_ID"),
444 "the runtime env knob is not named: {err}"
445 );
446 assert!(
447 err.contains("device-client-id"),
448 "no config-file knob named: {err}"
449 );
450 Ok(())
451 }
452
453 #[tokio::test]
456 async fn the_configured_device_client_is_the_one_used() -> anyhow::Result<()> {
457 let (id, secret) = auth(Some((
458 "device-id.apps.googleusercontent.com",
459 "device-secret",
460 )))
461 .await?
462 .device_client()?;
463 assert_eq!(id, "device-id.apps.googleusercontent.com");
464 assert_eq!(secret.as_deref(), Some("device-secret"));
465 assert_ne!(
466 id, "desktop-id.apps.googleusercontent.com",
467 "the browser client must not be sent to the device endpoint"
468 );
469 Ok(())
470 }
471}