use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::anyhow;
use crate::core::error::{GrrError, Result, json_error_detail};
use tracing::warn;
use super::TokenStorage;
const DEVICE_CODE_URL: &str = "https://oauth2.googleapis.com/device/code";
const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
const DEVICE_FLOW_SCOPES: &[&str] = &[
"https://www.google.com/calendar/feeds",
"https://www.google.com/m8/feeds",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"openid",
];
pub(crate) fn device_flow_scopes(requested: &[String]) -> Vec<String> {
let mut granted: Vec<String> = requested
.iter()
.filter(|scope| DEVICE_FLOW_SCOPES.contains(&scope.as_str()))
.cloned()
.collect();
granted.sort_unstable();
granted.dedup();
if granted.len() < requested.len() {
warn!(
"device flow: Google grants a TV/limited-input client {} of the {} scopes requested \
(drive.file, calendar and legacy Calendar/Contacts, userinfo, openid); the {} it \
drops — Gmail, Chat, Docs, Sheets, Slides, Tasks, Forms, Apps Script, Analytics and \
the admin APIs — need the browser flow (`grr auth login`).",
granted.len(),
requested.len(),
requested.len() - granted.len()
);
}
granted
}
pub struct DeviceAuthChallenge {
pub verification_url: String,
pub user_code: String,
pub(crate) device_code: String,
pub(crate) interval: Duration,
pub(crate) deadline: Instant,
}
impl DeviceAuthChallenge {
pub fn retry_after(&self) -> Duration {
self.interval
}
pub fn is_expired(&self) -> bool {
Instant::now() >= self.deadline
}
}
impl super::GoogleAuth {
fn device_client(&self) -> Result<(String, Option<String>)> {
let id = self
.config
.device_client_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
.ok_or_else(|| {
GrrError::Auth(
anyhow!(
"the device flow needs an OAuth client of type \"TVs and Limited Input \
devices\": Google rejects the Desktop client with `Invalid client type`.\n\
Create one in the Google Cloud console (APIs & Services → Credentials → \
Create credentials → OAuth client ID → TVs and Limited Input devices), \
then supply it as GRR_OAUTH__DEVICE_CLIENT_ID / \
GRR_OAUTH__DEVICE_CLIENT_SECRET, or as device-client-id / \
device-client-secret in the [oauth] block of ~/.grr/config.toml. \
Building from source can also bake it in: GRR_DEVICE_CLIENT_ID / \
GRR_DEVICE_CLIENT_SECRET (or a repo-root .env).\n\
The browser flow (`grr auth login`) needs none of this."
)
.into(),
)
})?;
let secret = self
.config
.device_client_secret
.as_deref()
.map(str::trim)
.filter(|secret| !secret.is_empty())
.map(str::to_owned);
Ok((id.to_owned(), secret))
}
pub async fn request_device_code(&self, scopes: &[String]) -> Result<DeviceAuthChallenge> {
let (client_id, client_secret) = self.device_client()?;
if scopes.is_empty() {
return Err(GrrError::Auth(
anyhow!(
"the requested scopes are all outside Google's device-flow allowlist, so the \
device endpoint would refuse the whole request; the browser flow \
(`grr auth login`) can ask for them"
)
.into(),
));
}
let form = vec![("client_id", client_id), ("scope", scopes.join(" "))];
let (status, body) = self
.token_request(DEVICE_CODE_URL, client_secret.as_deref(), form)
.await?;
if !status.is_success() {
let detail = json_error_detail(&body);
let hint = if body["error"].as_str() == Some("invalid_scope") {
"\nGoogle's device flow keeps its own scope allowlist (DEVICE_FLOW_SCOPES in \
src/core/auth/device.rs, verified 2026-10-13). It has moved: update that list, \
or use the browser flow (`grr auth login`), which has no such ceiling."
} else {
""
};
return Err(GrrError::Auth(
anyhow!("device endpoint returned {status}: {detail}{hint}").into(),
));
}
let device_code = body["device_code"]
.as_str()
.ok_or_else(|| GrrError::Auth(anyhow!("device endpoint omitted device_code").into()))?;
let user_code = body["user_code"]
.as_str()
.ok_or_else(|| GrrError::Auth(anyhow!("device endpoint omitted user_code").into()))?;
let verification_url = body
.get("verification_url")
.or_else(|| body.get("verification_uri"))
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
GrrError::Auth(anyhow!("device endpoint omitted verification_url").into())
})?;
let expires_in = body["expires_in"].as_u64().unwrap_or(1800);
let interval = body["interval"]
.as_u64()
.map(Duration::from_secs)
.filter(|d| !d.is_zero())
.unwrap_or_else(|| Duration::from_secs(5));
Ok(DeviceAuthChallenge {
verification_url: verification_url.to_string(),
user_code: user_code.to_string(),
device_code: device_code.to_string(),
interval,
deadline: Instant::now() + Duration::from_secs(expires_in),
})
}
pub async fn poll_device_code(
&self,
challenge: &mut DeviceAuthChallenge,
scopes: &[String],
) -> Result<Option<TokenStorage>> {
let (client_id, client_secret) = self.device_client()?;
let (_, body) = self
.token_request(
&self.token_endpoint,
client_secret.as_deref(),
vec![
("client_id", client_id),
("device_code", challenge.device_code.clone()),
("grant_type", DEVICE_GRANT.to_string()),
],
)
.await?;
if let Some(code) = body["error"].as_str() {
match code {
"authorization_pending" => return Ok(None),
"slow_down" => {
challenge.interval += Duration::from_secs(5);
return Ok(None);
}
_ => {
return Err(GrrError::Auth(
anyhow!("device poll failed: {}", json_error_detail(&body)).into(),
));
}
}
}
let storage = token_storage_from_response(&body, scopes)?;
*self.token_storage.write().await = Some(storage.clone());
self.save_token(&storage).await?;
Ok(Some(storage))
}
pub async fn login(&self, scopes: &[String]) -> Result<TokenStorage> {
let _ = self.revoke().await;
let storage = self.run_oauth_flow(scopes).await?;
*self.token_storage.write().await = Some(storage.clone());
self.save_token(&storage).await?;
Ok(storage)
}
}
pub(crate) fn token_storage_from_response(
token_data: &serde_json::Value,
scopes: &[String],
) -> Result<TokenStorage> {
let access_token = token_data["access_token"]
.as_str()
.ok_or_else(|| GrrError::Auth(anyhow!("No access token in response").into()))?
.to_string();
let expires_in = token_data["expires_in"].as_u64().unwrap_or(3600);
let refresh_token = token_data["refresh_token"].as_str().map(str::to_string);
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
+ expires_in;
let granted = token_data["scope"]
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| scopes.join(" "));
Ok(TokenStorage {
access_token,
refresh_token,
expires_at,
token_type: "Bearer".to_string(),
scope: granted,
})
}
#[cfg(test)]
mod tests {
use super::super::{GoogleAuth, TokenStorage};
use crate::core::config::OAuthConfig;
async fn auth(device: Option<(&str, &str)>) -> crate::core::error::Result<GoogleAuth> {
let config = OAuthConfig {
client_id: "desktop-id.apps.googleusercontent.com".into(),
client_secret: Some("desktop-secret".into()),
device_client_id: device.map(|(id, _)| id.to_owned()),
device_client_secret: device.map(|(_, secret)| secret.to_owned()),
};
let storage = TokenStorage {
access_token: "test-token".into(),
refresh_token: None,
expires_at: u64::MAX,
token_type: "Bearer".into(),
scope: String::new(),
};
GoogleAuth::with_token(config, storage).await
}
#[test]
fn the_stored_scope_is_the_grant_not_the_request() -> anyhow::Result<()> {
let requested = vec!["https://www.googleapis.com/auth/gmail.readonly".to_owned()];
let granted = super::token_storage_from_response(
&serde_json::json!({
"access_token": "t",
"expires_in": 3600,
"scope": "openid https://www.googleapis.com/auth/userinfo.email",
}),
&requested,
)?;
assert_eq!(
granted.scope,
"openid https://www.googleapis.com/auth/userinfo.email"
);
let silent = super::token_storage_from_response(
&serde_json::json!({ "access_token": "t", "expires_in": 3600 }),
&requested,
)?;
assert_eq!(silent.scope, requested.join(" "));
let blank = super::token_storage_from_response(
&serde_json::json!({ "access_token": "t", "scope": " " }),
&requested,
)?;
assert_eq!(blank.scope, requested.join(" "));
Ok(())
}
#[test]
fn the_device_flow_requests_the_flags_intersected_with_the_allowlist() {
let consent = crate::core::auth::scopes();
let login = crate::core::auth::login_scopes();
let default = super::device_flow_scopes(&login);
for granted in [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/userinfo.email",
"openid",
] {
assert!(
default.contains(&granted.to_owned()),
"{granted} was dropped"
);
}
for rejected in [
"https://mail.google.com/",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/drive.appdata",
"https://www.googleapis.com/auth/drive.metadata.readonly",
] {
assert!(
!default.contains(&rejected.to_owned()),
"{rejected} must never reach the device endpoint"
);
}
assert!(
!default.iter().any(|s| s.contains("/auth/chat.")),
"Chat is not a device-flow scope: {default:?}"
);
for allowlist_only in [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/calendar.readonly",
] {
assert!(!default.contains(&allowlist_only.to_owned()));
assert!(
login.iter().all(|scope| scope != allowlist_only),
"{allowlist_only} unexpectedly entered the default login set"
);
}
let all = super::device_flow_scopes(consent);
for useful in [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/calendar.readonly",
] {
assert!(all.contains(&useful.to_owned()), "{useful} was dropped");
}
assert_eq!(
all,
consent
.iter()
.filter(|scope| super::DEVICE_FLOW_SCOPES.contains(&scope.as_str()))
.cloned()
.collect::<Vec<_>>(),
"--all-scopes must be exactly the index intersected with the allowlist"
);
for scope in all.iter().chain(default.iter()) {
assert!(
consent.iter().any(|known| known == scope),
"{scope} is not in the consent set at all"
);
assert!(
super::DEVICE_FLOW_SCOPES.contains(&scope.as_str()),
"{scope} is not in Google's device-flow allowlist"
);
}
let mut shuffled = login.clone();
shuffled.reverse();
assert_eq!(default, super::device_flow_scopes(&shuffled));
}
#[tokio::test]
async fn a_missing_device_client_is_refused_locally_with_instructions() -> anyhow::Result<()> {
let Err(err) = auth(None).await?.device_client() else {
return Err(anyhow::anyhow!(
"a build with no device client must refuse locally, not call Google"
));
};
let err = err.to_string();
assert!(
err.contains("TVs and Limited Input devices"),
"does not name the client type: {err}"
);
assert!(
err.contains("GRR_OAUTH__DEVICE_CLIENT_ID"),
"the runtime env knob is not named: {err}"
);
assert!(
err.contains("device-client-id"),
"no config-file knob named: {err}"
);
Ok(())
}
#[tokio::test]
async fn the_configured_device_client_is_the_one_used() -> anyhow::Result<()> {
let (id, secret) = auth(Some((
"device-id.apps.googleusercontent.com",
"device-secret",
)))
.await?
.device_client()?;
assert_eq!(id, "device-id.apps.googleusercontent.com");
assert_eq!(secret.as_deref(), Some("device-secret"));
assert_ne!(
id, "desktop-id.apps.googleusercontent.com",
"the browser client must not be sent to the device endpoint"
);
Ok(())
}
}