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};
15
16use super::TokenStorage;
17
18const DEVICE_CODE_URL: &str = "https://oauth2.googleapis.com/device/code";
19const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
20
21/// Pending device authorization: show `verification_url` + `user_code` to
22/// the user, then poll with [`super::GoogleAuth::poll_device_code`] until done.
23pub struct DeviceAuthChallenge {
24    /// URL the user must open (e.g. <https://www.google.com/device>).
25    pub verification_url: String,
26    /// Short code the user types at `verification_url`.
27    pub user_code: String,
28    pub(crate) device_code: String,
29    pub(crate) interval: Duration,
30    pub(crate) deadline: Instant,
31}
32
33impl DeviceAuthChallenge {
34    /// How long to wait before the next poll (grows on `slow_down`).
35    pub fn retry_after(&self) -> Duration {
36        self.interval
37    }
38
39    /// Whether the user code has expired and the flow must restart.
40    pub fn is_expired(&self) -> bool {
41        Instant::now() >= self.deadline
42    }
43}
44
45impl super::GoogleAuth {
46    /// Start a device flow: returns the challenge to display to the user.
47    pub async fn request_device_code(&self) -> Result<DeviceAuthChallenge> {
48        let mut form = vec![
49            ("client_id", self.config.client_id.clone()),
50            ("scope", super::scopes_joined()),
51        ];
52        if let Some(secret) = self.config.client_secret.clone().filter(|s| !s.is_empty()) {
53            form.push(("client_secret", secret));
54        }
55
56        let response = self
57            .http_client
58            .post(DEVICE_CODE_URL)
59            .form(&form)
60            .send()
61            .await
62            .map_err(GrrError::Http)?;
63
64        let status = response.status();
65        let body_text = response.text().await.map_err(GrrError::Http)?;
66        let body: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| {
67            GrrError::Auth(
68                anyhow!("device endpoint returned {status}: unparseable body ({e})").into(),
69            )
70        })?;
71
72        if !status.is_success() {
73            return Err(GrrError::Auth(
74                anyhow!(
75                    "device endpoint returned {status}: {}",
76                    json_error_detail(&body)
77                )
78                .into(),
79            ));
80        }
81
82        let device_code = body["device_code"]
83            .as_str()
84            .ok_or_else(|| GrrError::Auth(anyhow!("device endpoint omitted device_code").into()))?;
85        let user_code = body["user_code"]
86            .as_str()
87            .ok_or_else(|| GrrError::Auth(anyhow!("device endpoint omitted user_code").into()))?;
88        let verification_url = body
89            .get("verification_url")
90            .or_else(|| body.get("verification_uri"))
91            .and_then(serde_json::Value::as_str)
92            .ok_or_else(|| {
93                GrrError::Auth(anyhow!("device endpoint omitted verification_url").into())
94            })?;
95
96        let expires_in = body["expires_in"].as_u64().unwrap_or(1800);
97        let interval = body["interval"]
98            .as_u64()
99            .map(Duration::from_secs)
100            .filter(|d| !d.is_zero())
101            .unwrap_or_else(|| Duration::from_secs(5));
102
103        Ok(DeviceAuthChallenge {
104            verification_url: verification_url.to_string(),
105            user_code: user_code.to_string(),
106            device_code: device_code.to_string(),
107            interval,
108            deadline: Instant::now() + Duration::from_secs(expires_in),
109        })
110    }
111
112    /// Poll once. Returns `Ok(None)` while the user has not approved yet
113    /// (caller sleeps `retry_after()` and retries), `Ok(Some)` with the
114    /// stored token on approval, or `Err` on denial/expiry/failure.
115    pub async fn poll_device_code(
116        &self,
117        challenge: &mut DeviceAuthChallenge,
118    ) -> Result<Option<TokenStorage>> {
119        // Device polling reports status via 400 + JSON error codes, so the
120        // body — not the HTTP status — drives the decision below.
121        let (_, body) = self
122            .token_request(vec![
123                ("client_id", self.config.client_id.clone()),
124                ("device_code", challenge.device_code.clone()),
125                ("grant_type", DEVICE_GRANT.to_string()),
126            ])
127            .await?;
128
129        if let Some(code) = body["error"].as_str() {
130            match code {
131                "authorization_pending" => return Ok(None),
132                "slow_down" => {
133                    challenge.interval += Duration::from_secs(5);
134                    return Ok(None);
135                }
136                _ => {
137                    return Err(GrrError::Auth(
138                        anyhow!("device poll failed: {}", json_error_detail(&body)).into(),
139                    ));
140                }
141            }
142        }
143
144        let storage = token_storage_from_response(&body, super::scopes())?;
145        *self.token_storage.write().await = Some(storage.clone());
146        self.save_token(&storage).await?;
147        Ok(Some(storage))
148    }
149
150    /// Fresh interactive login: drops any stored (possibly dead) token,
151    /// runs the PKCE browser flow, saves and returns the new token.
152    pub async fn login(&self) -> Result<TokenStorage> {
153        // Best effort: a dead on-disk token must never block fresh consent.
154        let _ = self.revoke().await;
155        let storage = self.run_oauth_flow().await?;
156        *self.token_storage.write().await = Some(storage.clone());
157        self.save_token(&storage).await?;
158        Ok(storage)
159    }
160}
161
162/// Build [`TokenStorage`] from a successful token-endpoint JSON body.
163/// Shared by the PKCE exchange and the device poll.
164pub(crate) fn token_storage_from_response(
165    token_data: &serde_json::Value,
166    scopes: &[String],
167) -> Result<TokenStorage> {
168    let access_token = token_data["access_token"]
169        .as_str()
170        .ok_or_else(|| GrrError::Auth(anyhow!("No access token in response").into()))?
171        .to_string();
172
173    let expires_in = token_data["expires_in"].as_u64().unwrap_or(3600);
174    let refresh_token = token_data["refresh_token"].as_str().map(str::to_string);
175
176    let expires_at = SystemTime::now()
177        .duration_since(UNIX_EPOCH)
178        .map(|d| d.as_secs())
179        .unwrap_or(0)
180        + expires_in;
181
182    Ok(TokenStorage {
183        access_token,
184        refresh_token,
185        expires_at,
186        token_type: "Bearer".to_string(),
187        scope: scopes.join(" "),
188    })
189}