Skip to main content

gor/auth/
device.rs

1//! OAuth device flow implementation.
2//!
3//! Implements the GitHub OAuth device flow as described in
4//! <https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow>.
5//!
6//! The flow:
7//! 1. Request a device code from `POST /login/device/code`
8//! 2. Show the user a one-time code and URL
9//! 3. Poll `POST /login/oauth/access_token` until the user completes authorization
10//! 4. Return the access token
11
12use crate::error::GorError;
13use crate::host::Host;
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::io::Write;
17use std::time::{Duration, Instant};
18
19/// The OAuth client ID for the `gor` CLI app.
20///
21/// This is a public client ID registered for the device flow.
22/// It follows GitHub's guidance for CLI tools.
23const CLIENT_ID: &str = "Iv23liqA1L1VQn1AOc1s";
24
25/// Response from `POST /login/device/code`.
26#[derive(Debug, Deserialize)]
27pub struct DeviceCodeResponse {
28    /// The device verification code (shown to the user).
29    pub user_code: String,
30    /// The device code (used for polling).
31    pub device_code: String,
32    /// The URL the user should visit to enter the code.
33    pub verification_uri: String,
34    /// The polling interval in seconds.
35    pub interval: u64,
36    /// The lifetime of the device code in seconds.
37    #[serde(default)]
38    pub expires_in: u64,
39}
40
41/// Response from `POST /login/oauth/access_token` (success).
42#[derive(Debug, Deserialize)]
43pub struct AccessTokenResponse {
44    /// The OAuth access token.
45    pub access_token: String,
46    /// The token type (should be `bearer`).
47    #[allow(dead_code)]
48    pub token_type: String,
49    /// The scopes granted.
50    #[allow(dead_code)]
51    #[serde(default)]
52    pub scope: String,
53}
54
55/// Error response from `POST /login/oauth/access_token`.
56#[derive(Debug, Deserialize)]
57struct OAuthError {
58    error: String,
59    #[allow(dead_code)]
60    error_description: Option<String>,
61}
62
63/// Request a device code from GitHub.
64///
65/// # Errors
66///
67/// Returns an error if the HTTP request fails or GitHub returns an error.
68pub fn request_device_code(
69    host: &Host,
70    scopes: Option<&str>,
71) -> Result<DeviceCodeResponse, GorError> {
72    let client = reqwest::blocking::Client::new();
73    let url = host.device_code_url();
74
75    let mut params = HashMap::new();
76    params.insert("client_id", CLIENT_ID);
77    let default_scopes = "repo,read:org,workflow,gist";
78    let scope_str = scopes.unwrap_or(default_scopes);
79    params.insert("scope", scope_str);
80
81    tracing::info!("Requesting device code from {url}");
82    let response = client
83        .post(&url)
84        .header("Accept", "application/json")
85        .json(&params)
86        .send()
87        .map_err(GorError::Http)?;
88
89    let status = response.status();
90    if !status.is_success() {
91        let body = response.text().unwrap_or_default();
92        return Err(GorError::Auth(format!(
93            "device code request failed ({status}): {body}"
94        )));
95    }
96
97    response
98        .json()
99        .map_err(|e| GorError::Auth(format!("failed to parse device code response: {e}")))
100}
101
102/// Poll for the access token until the user completes authorization or the flow times out.
103///
104/// Displays a spinner and status messages to stderr while polling.
105///
106/// # Errors
107///
108/// Returns [`GorError::DeviceTimeout`] if the user doesn't authorize within the timeout.
109/// Returns [`GorError::DeviceDeclined`] if the user declines authorization.
110/// Returns [`GorError::Auth`] for other errors.
111pub fn poll_for_token(
112    host: &Host,
113    device_code: &str,
114    interval: u64,
115    expires_in: u64,
116) -> Result<String, GorError> {
117    let client = reqwest::blocking::Client::new();
118    let url = host.access_token_url();
119    let deadline = Instant::now() + Duration::from_secs(expires_in);
120    let poll_interval = Duration::from_secs(interval);
121
122    let mut params = HashMap::new();
123    params.insert("client_id", CLIENT_ID);
124    params.insert("device_code", device_code);
125    params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
126
127    loop {
128        if Instant::now() > deadline {
129            return Err(GorError::DeviceTimeout(
130                "timed out waiting for authorization".to_string(),
131            ));
132        }
133
134        let response = client
135            .post(&url)
136            .header("Accept", "application/json")
137            .form(&params)
138            .send()
139            .map_err(GorError::Http)?;
140
141        let status = response.status();
142
143        if status.is_success() {
144            let token_resp: AccessTokenResponse = response.json().map_err(|e| {
145                GorError::Auth(format!("failed to parse access token response: {e}"))
146            })?;
147            return Ok(token_resp.access_token);
148        }
149
150        // Check for specific OAuth errors
151        let error_body: OAuthError = response.json().unwrap_or_else(|_| OAuthError {
152            error: "unknown".to_string(),
153            error_description: None,
154        });
155
156        match error_body.error.as_str() {
157            "authorization_pending" => {
158                // User hasn't completed auth yet — keep polling.
159                tracing::debug!("Authorization pending, waiting {poll_interval:?}");
160                std::thread::sleep(poll_interval);
161            }
162            "slow_down" => {
163                // GitHub asks us to slow down — increase interval.
164                tracing::debug!("Slowing down polling");
165                std::thread::sleep(poll_interval + Duration::from_secs(5));
166            }
167            "expired_token" => {
168                return Err(GorError::DeviceTimeout(
169                    "device code expired before authorization".to_string(),
170                ));
171            }
172            "access_denied" => {
173                return Err(GorError::DeviceDeclined);
174            }
175            other => {
176                return Err(GorError::Auth(format!(
177                    "OAuth error during polling: {other}"
178                )));
179            }
180        }
181    }
182}
183
184/// Display the device activation instructions to the user.
185///
186/// Prints the one-time code and activation URL to stderr.
187#[allow(clippy::print_stderr)]
188pub fn display_instructions(user_code: &str, verification_uri: &str) {
189    let msg = format!("Open {verification_uri} and enter the following code:\n\n  {user_code}\n");
190    // Write directly to stderr for immediate display.
191    let stderr = std::io::stderr();
192    let mut handle = stderr.lock();
193    let _ = writeln!(handle, "{msg}");
194    let _ = handle.flush();
195}