use super::credentials;
use anyhow::{Result, anyhow};
use reqwest::Client;
use serde::Deserialize;
use std::time::{Duration, Instant};
const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev";
const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000";
const CLI_CLIENT_ID: &str = "syncable-cli";
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TokenResponse {
Success {
access_token: String,
#[allow(dead_code)]
token_type: String,
expires_in: Option<u64>,
refresh_token: Option<String>,
},
Error {
error: String,
#[allow(dead_code)]
error_description: Option<String>,
},
}
fn get_api_url() -> &'static str {
if std::env::var("SYNCABLE_ENV").as_deref() == Ok("development") {
SYNCABLE_API_URL_DEV
} else {
SYNCABLE_API_URL_PROD
}
}
pub async fn login(no_browser: bool) -> Result<()> {
println!("🔐 Authenticating with Syncable...\n");
let client = Client::new();
let api_url = get_api_url();
let response = client
.post(format!("{}/api/auth/device/code", api_url))
.json(&serde_json::json!({
"client_id": CLI_CLIENT_ID,
"scope": "openid profile email"
}))
.send()
.await
.map_err(|e| anyhow!("Failed to connect to Syncable API: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Failed to request device authorization: {} - {}",
status,
body
));
}
let device_code: DeviceCodeResponse = response
.json()
.await
.map_err(|e| anyhow!("Invalid response from server: {}", e))?;
println!("📱 Device Authorization");
println!(" ─────────────────────────────────────");
println!(" Visit: {}", device_code.verification_uri);
println!(" Code: \x1b[1;36m{}\x1b[0m", device_code.user_code);
println!(" ─────────────────────────────────────\n");
if !no_browser {
let url = device_code
.verification_uri_complete
.as_ref()
.unwrap_or(&device_code.verification_uri);
if let Err(e) = open::that(url) {
println!("⚠️ Could not open browser automatically: {}", e);
println!(" Please open the URL above manually.");
} else {
println!("🌐 Browser opened. Waiting for authorization...");
}
} else {
println!(" Please open the URL above and enter the code.");
}
println!();
poll_for_token(&client, api_url, &device_code).await
}
async fn poll_for_token(
client: &Client,
api_url: &str,
device_code: &DeviceCodeResponse,
) -> Result<()> {
let mut interval = device_code.interval;
let deadline = Instant::now() + Duration::from_secs(device_code.expires_in);
loop {
if Instant::now() > deadline {
return Err(anyhow!(
"Device code expired. Please run 'sync-ctl auth login' again."
));
}
tokio::time::sleep(Duration::from_secs(interval)).await;
let response = client
.post(format!("{}/api/auth/device/token", api_url))
.json(&serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code.device_code,
"client_id": CLI_CLIENT_ID,
}))
.send()
.await;
let response = match response {
Ok(r) => r,
Err(e) => {
println!("⚠️ Network error, retrying: {}", e);
continue;
}
};
let body = response.text().await.unwrap_or_default();
let token_response: TokenResponse = match serde_json::from_str(&body) {
Ok(r) => r,
Err(_) => {
continue;
}
};
match token_response {
TokenResponse::Success {
access_token,
expires_in,
refresh_token,
..
} => {
credentials::save_credentials(
&access_token,
refresh_token.as_deref(),
None, expires_in,
)?;
println!("\n\x1b[1;32m✅ Authentication successful!\x1b[0m");
println!(" Credentials saved to ~/.syncable.toml");
return Ok(());
}
TokenResponse::Error { error, .. } => {
match error.as_str() {
"authorization_pending" => {
continue;
}
"slow_down" => {
interval += 5;
continue;
}
"access_denied" => {
return Err(anyhow!("Authorization was denied by the user."));
}
"expired_token" => {
return Err(anyhow!(
"Device code expired. Please run 'sync-ctl auth login' again."
));
}
_ => {
return Err(anyhow!("Authorization failed: {}", error));
}
}
}
}
}
}