use crate::auth::secure_string::SecureString;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
fn github_client_id() -> String {
std::env::var("SECUREGIT_GITHUB_CLIENT_ID")
.unwrap_or_else(|_| "securegit-placeholder".to_string())
}
fn gitlab_client_id() -> String {
std::env::var("SECUREGIT_GITLAB_CLIENT_ID")
.unwrap_or_else(|_| "securegit-placeholder".to_string())
}
#[derive(Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: u64,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: Option<String>,
error: Option<String>,
#[serde(default)]
scope: Option<String>,
}
pub struct OAuthResult {
pub token: SecureString,
pub scope: String,
pub user: String,
}
pub async fn github_device_flow(
on_code: impl Fn(&str, &str),
on_waiting: impl Fn(),
) -> Result<OAuthResult> {
let client = reqwest::Client::new();
let client_id = github_client_id();
let resp = client
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.json(&serde_json::json!({
"client_id": client_id,
"scope": "repo"
}))
.send()
.await
.context("Failed to request device code")?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
bail!("GitHub device code request failed: {}", text);
}
let device: DeviceCodeResponse = resp.json().await.context("Failed to parse device code")?;
on_code(&device.user_code, &device.verification_uri);
let _ = open::that(&device.verification_uri);
let mut interval = device.interval;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(device.expires_in);
loop {
if std::time::Instant::now() > deadline {
bail!("Device code expired. Please try again.");
}
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
on_waiting();
let resp = client
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.json(&serde_json::json!({
"client_id": client_id,
"device_code": device.device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"
}))
.send()
.await
.context("Failed to poll for token")?;
let token_resp: TokenResponse = resp.json().await?;
if let Some(token) = token_resp.access_token {
let secure_token = SecureString::from_string(token);
let scope = token_resp.scope.unwrap_or_default();
let user = get_github_user(&secure_token)
.await
.unwrap_or_else(|_| "unknown".to_string());
return Ok(OAuthResult {
token: secure_token,
scope,
user,
});
}
match token_resp.error.as_deref() {
Some("authorization_pending") => continue,
Some("slow_down") => {
interval += 5;
continue;
}
Some("expired_token") => bail!("Device code expired. Please try again."),
Some("access_denied") => bail!("Authorization was denied by the user."),
Some(err) => bail!("OAuth error: {}", err),
None => continue,
}
}
}
pub async fn gitlab_device_flow(
on_code: impl Fn(&str, &str),
on_waiting: impl Fn(),
) -> Result<OAuthResult> {
let client = reqwest::Client::new();
let client_id = gitlab_client_id();
let resp = client
.post("https://gitlab.com/oauth/authorize_device")
.header("Accept", "application/json")
.form(&[("client_id", client_id.as_str()), ("scope", "api")])
.send()
.await
.context("Failed to request device code from GitLab")?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
bail!("GitLab device code request failed: {}", text);
}
let device: DeviceCodeResponse = resp.json().await.context("Failed to parse device code")?;
on_code(&device.user_code, &device.verification_uri);
let _ = open::that(&device.verification_uri);
let mut interval = device.interval;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(device.expires_in);
loop {
if std::time::Instant::now() > deadline {
bail!("Device code expired. Please try again.");
}
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
on_waiting();
let resp = client
.post("https://gitlab.com/oauth/token")
.header("Accept", "application/json")
.form(&[
("client_id", client_id.as_str()),
("device_code", device.device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.context("Failed to poll for token")?;
let token_resp: TokenResponse = resp.json().await?;
if let Some(token) = token_resp.access_token {
let secure_token = SecureString::from_string(token);
let scope = token_resp.scope.unwrap_or_default();
let user = get_gitlab_user(&secure_token)
.await
.unwrap_or_else(|_| "unknown".to_string());
return Ok(OAuthResult {
token: secure_token,
scope,
user,
});
}
match token_resp.error.as_deref() {
Some("authorization_pending") => continue,
Some("slow_down") => {
interval += 5;
continue;
}
Some("expired_token") => bail!("Device code expired. Please try again."),
Some("access_denied") => bail!("Authorization was denied by the user."),
Some(err) => bail!("OAuth error: {}", err),
None => continue,
}
}
}
async fn get_github_user(token: &SecureString) -> Result<String> {
let client = reqwest::Client::new();
let resp = client
.get("https://api.github.com/user")
.header("Authorization", format!("Bearer {}", token.as_str()))
.header("User-Agent", "securegit")
.send()
.await?;
let data: serde_json::Value = resp.json().await?;
Ok(data["login"].as_str().unwrap_or("unknown").to_string())
}
async fn get_gitlab_user(token: &SecureString) -> Result<String> {
let client = reqwest::Client::new();
let resp = client
.get("https://gitlab.com/api/v4/user")
.header("PRIVATE-TOKEN", token.as_str())
.send()
.await?;
let data: serde_json::Value = resp.json().await?;
Ok(data["username"].as_str().unwrap_or("unknown").to_string())
}