use crate::error::GorError;
use crate::host::Host;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Write;
use std::time::{Duration, Instant};
const CLIENT_ID: &str = "Iv23liqA1L1VQn1AOc1s";
#[derive(Debug, Deserialize)]
pub struct DeviceCodeResponse {
pub user_code: String,
pub device_code: String,
pub verification_uri: String,
pub interval: u64,
#[serde(default)]
pub expires_in: u64,
}
#[derive(Debug, Deserialize)]
pub struct AccessTokenResponse {
pub access_token: String,
#[allow(dead_code)]
pub token_type: String,
#[allow(dead_code)]
#[serde(default)]
pub scope: String,
}
#[derive(Debug, Deserialize)]
struct OAuthError {
error: String,
#[allow(dead_code)]
error_description: Option<String>,
}
pub fn request_device_code(
host: &Host,
scopes: Option<&str>,
) -> Result<DeviceCodeResponse, GorError> {
let client = reqwest::blocking::Client::new();
let url = host.device_code_url();
let mut params = HashMap::new();
params.insert("client_id", CLIENT_ID);
let default_scopes = "repo,read:org,workflow,gist";
let scope_str = scopes.unwrap_or(default_scopes);
params.insert("scope", scope_str);
tracing::info!("Requesting device code from {url}");
let response = client
.post(&url)
.header("Accept", "application/json")
.json(¶ms)
.send()
.map_err(GorError::Http)?;
let status = response.status();
if !status.is_success() {
let body = response.text().unwrap_or_default();
return Err(GorError::Auth(format!(
"device code request failed ({status}): {body}"
)));
}
response
.json()
.map_err(|e| GorError::Auth(format!("failed to parse device code response: {e}")))
}
pub fn poll_for_token(
host: &Host,
device_code: &str,
interval: u64,
expires_in: u64,
) -> Result<String, GorError> {
let client = reqwest::blocking::Client::new();
let url = host.access_token_url();
let deadline = Instant::now() + Duration::from_secs(expires_in);
let poll_interval = Duration::from_secs(interval);
let mut params = HashMap::new();
params.insert("client_id", CLIENT_ID);
params.insert("device_code", device_code);
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
loop {
if Instant::now() > deadline {
return Err(GorError::DeviceTimeout(
"timed out waiting for authorization".to_string(),
));
}
let response = client
.post(&url)
.header("Accept", "application/json")
.form(¶ms)
.send()
.map_err(GorError::Http)?;
let status = response.status();
if status.is_success() {
let token_resp: AccessTokenResponse = response.json().map_err(|e| {
GorError::Auth(format!("failed to parse access token response: {e}"))
})?;
return Ok(token_resp.access_token);
}
let error_body: OAuthError = response.json().unwrap_or_else(|_| OAuthError {
error: "unknown".to_string(),
error_description: None,
});
match error_body.error.as_str() {
"authorization_pending" => {
tracing::debug!("Authorization pending, waiting {poll_interval:?}");
std::thread::sleep(poll_interval);
}
"slow_down" => {
tracing::debug!("Slowing down polling");
std::thread::sleep(poll_interval + Duration::from_secs(5));
}
"expired_token" => {
return Err(GorError::DeviceTimeout(
"device code expired before authorization".to_string(),
));
}
"access_denied" => {
return Err(GorError::DeviceDeclined);
}
other => {
return Err(GorError::Auth(format!(
"OAuth error during polling: {other}"
)));
}
}
}
}
#[allow(clippy::print_stderr)]
pub fn display_instructions(user_code: &str, verification_uri: &str) {
let msg = format!("Open {verification_uri} and enter the following code:\n\n {user_code}\n");
let stderr = std::io::stderr();
let mut handle = stderr.lock();
let _ = writeln!(handle, "{msg}");
let _ = handle.flush();
}