use serde::Deserialize;
use serde_json::json;
use crate::error::{Result, RototoError};
use super::GITHUB_USER_AGENT;
pub async fn exchange_github_code(
client_id: &str,
client_secret: &str,
code: &str,
) -> Result<String> {
let started = std::time::Instant::now();
tracing::debug!(
operation = "github.oauth.exchange",
method = "POST",
url = "https://github.com/login/oauth/access_token",
"console outbound GitHub OAuth exchange started"
);
#[derive(Deserialize)]
struct Exchange {
access_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
let response = reqwest::Client::new()
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.header("User-Agent", GITHUB_USER_AGENT)
.json(&json!({
"client_id": client_id,
"client_secret": client_secret,
"code": code,
}))
.send()
.await
.map_err(|err| {
tracing::warn!(
operation = "github.oauth.exchange",
method = "POST",
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub OAuth exchange failed before response"
);
RototoError::new(format!("GitHub OAuth exchange failed: {err}"))
})?;
let ok = response.status().is_success();
let status = response.status();
let body: Exchange = response.json().await.map_err(|err| {
tracing::warn!(
operation = "github.oauth.exchange",
method = "POST",
status = status.as_u16(),
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub OAuth exchange response decode failed"
);
RototoError::new(format!("GitHub OAuth exchange failed: {err}"))
})?;
match body.access_token {
Some(token) if ok => {
tracing::info!(
operation = "github.oauth.exchange",
method = "POST",
status = status.as_u16(),
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub OAuth exchange completed"
);
Ok(token)
}
_ => {
tracing::warn!(
operation = "github.oauth.exchange",
method = "POST",
status = status.as_u16(),
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub OAuth exchange returned error"
);
Err(RototoError::new(
body.error_description
.or(body.error)
.unwrap_or_else(|| "GitHub OAuth failed".to_owned()),
))
}
}
}
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub interval_seconds: u64,
pub expires_in_seconds: u64,
}
pub async fn start_device_flow(client_id: &str) -> Result<DeviceCode> {
let started = std::time::Instant::now();
tracing::debug!(
operation = "github.device.start",
method = "POST",
url = "https://github.com/login/device/code",
"console outbound GitHub device flow start call started"
);
#[derive(Deserialize)]
struct DeviceResponse {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
interval: u64,
expires_in: u64,
}
let response = reqwest::Client::new()
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.header("User-Agent", GITHUB_USER_AGENT)
.json(&json!({ "client_id": client_id, "scope": "read:user repo" }))
.send()
.await
.map_err(|err| {
tracing::warn!(
operation = "github.device.start",
method = "POST",
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow start call failed before response"
);
RototoError::new(format!("GitHub device flow start failed: {err}"))
})?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
tracing::warn!(
operation = "github.device.start",
method = "POST",
status = status.as_u16(),
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow start call returned error status"
);
return Err(RototoError::new(format!(
"GitHub device flow start failed: {status}: {text}"
)));
}
let status = response.status();
let body: DeviceResponse = response.json().await.map_err(|err| {
tracing::warn!(
operation = "github.device.start",
method = "POST",
status = status.as_u16(),
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow start response decode failed"
);
RototoError::new(format!("GitHub device flow start failed: {err}"))
})?;
tracing::info!(
operation = "github.device.start",
method = "POST",
status = status.as_u16(),
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow start call completed"
);
Ok(DeviceCode {
device_code: body.device_code,
user_code: body.user_code,
verification_uri: body.verification_uri,
interval_seconds: body.interval.max(5),
expires_in_seconds: body.expires_in,
})
}
pub enum DevicePoll {
Pending,
SlowDown,
Token(String),
Failed(String),
}
pub async fn poll_device_flow(client_id: &str, device_code: &str) -> Result<DevicePoll> {
let started = std::time::Instant::now();
tracing::debug!(
operation = "github.device.poll",
method = "POST",
url = "https://github.com/login/oauth/access_token",
"console outbound GitHub device flow poll call started"
);
#[derive(Deserialize)]
struct PollResponse {
access_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
let response = reqwest::Client::new()
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.header("User-Agent", GITHUB_USER_AGENT)
.json(&json!({
"client_id": client_id,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}))
.send()
.await
.map_err(|err| {
tracing::warn!(
operation = "github.device.poll",
method = "POST",
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow poll call failed before response"
);
RototoError::new(format!("GitHub device flow poll failed: {err}"))
})?;
let status = response.status();
let body: PollResponse = response.json().await.map_err(|err| {
tracing::warn!(
operation = "github.device.poll",
method = "POST",
status = status.as_u16(),
error = %err,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow poll response decode failed"
);
RototoError::new(format!("GitHub device flow poll failed: {err}"))
})?;
if let Some(token) = body.access_token {
tracing::info!(
operation = "github.device.poll",
method = "POST",
status = status.as_u16(),
outcome = "authorized",
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow poll completed"
);
return Ok(DevicePoll::Token(token));
}
let outcome = match body.error.as_deref() {
Some("authorization_pending") => "pending",
Some("slow_down") => "slow_down",
Some(_) => "failed",
None => "failed",
};
if outcome == "failed" {
tracing::warn!(
operation = "github.device.poll",
method = "POST",
status = status.as_u16(),
outcome,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow poll returned terminal failure"
);
} else {
tracing::info!(
operation = "github.device.poll",
method = "POST",
status = status.as_u16(),
outcome,
latency_ms = started.elapsed().as_millis(),
"console outbound GitHub device flow poll completed"
);
}
Ok(match body.error.as_deref() {
Some("authorization_pending") => DevicePoll::Pending,
Some("slow_down") => DevicePoll::SlowDown,
Some(error) => DevicePoll::Failed(
body.error_description
.unwrap_or_else(|| format!("GitHub device flow failed: {error}")),
),
None => DevicePoll::Failed("GitHub device flow failed".to_owned()),
})
}