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(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 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
84pub struct DeviceAuthChallenge {
87 pub verification_url: String,
89 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 pub fn retry_after(&self) -> Duration {
99 self.interval
100 }
101
102 pub fn is_expired(&self) -> bool {
104 Instant::now() >= self.deadline
105 }
106}
107
108impl super::GoogleAuth {
109 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 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 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 pub async fn poll_device_code(
228 &self,
229 challenge: &mut DeviceAuthChallenge,
230 scopes: &[String],
231 ) -> Result<Option<TokenStorage>> {
232 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 pub async fn login(&self, scopes: &[String]) -> Result<TokenStorage> {
273 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
282pub(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 #[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 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 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 #[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 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 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 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 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 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 #[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 #[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}