1use crate::error::GorError;
13use crate::host::Host;
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::io::Write;
17use std::time::{Duration, Instant};
18
19const CLIENT_ID: &str = "Iv23liqA1L1VQn1AOc1s";
24
25#[derive(Debug, Deserialize)]
27pub struct DeviceCodeResponse {
28 pub user_code: String,
30 pub device_code: String,
32 pub verification_uri: String,
34 pub interval: u64,
36 #[serde(default)]
38 pub expires_in: u64,
39}
40
41#[derive(Debug, Deserialize)]
43pub struct AccessTokenResponse {
44 pub access_token: String,
46 #[allow(dead_code)]
48 pub token_type: String,
49 #[allow(dead_code)]
51 #[serde(default)]
52 pub scope: String,
53}
54
55#[derive(Debug, Deserialize)]
57struct OAuthError {
58 error: String,
59 #[allow(dead_code)]
60 error_description: Option<String>,
61}
62
63pub 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(¶ms)
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
102pub 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(¶ms)
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 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 tracing::debug!("Authorization pending, waiting {poll_interval:?}");
160 std::thread::sleep(poll_interval);
161 }
162 "slow_down" => {
163 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#[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 let stderr = std::io::stderr();
192 let mut handle = stderr.lock();
193 let _ = writeln!(handle, "{msg}");
194 let _ = handle.flush();
195}