use std::path::Path;
use std::time::Duration;
use serde::Deserialize;
use crate::cli::cloud_credentials::CloudCredentials;
type Result<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
const CLIENT_ID: &str = "bitrouter-cli";
pub const DEFAULT_CLOUD_URL: &str = "https://bitrouter.ai";
const SLOW_DOWN_BACKOFF_SECS: u64 = 5;
pub fn resolve_cloud_url(explicit: Option<&str>) -> String {
let raw = explicit
.map(str::to_owned)
.or_else(|| std::env::var("BITROUTER_CLOUD_URL").ok())
.unwrap_or_else(|| DEFAULT_CLOUD_URL.to_owned());
raw.trim_end_matches('/').to_owned()
}
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
#[serde(default = "default_expires_in")]
expires_in: u64,
#[serde(default = "default_interval")]
interval: u64,
}
fn default_interval() -> u64 {
5
}
fn default_expires_in() -> u64 {
900
}
#[derive(Debug, Deserialize)]
struct ApiKeyResponse {
api_key: String,
key_id: String,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
pub fn run_login(home: &Path, base_url: &str) -> Result {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let device = request_device_code(&client, base_url)?;
eprintln!();
eprintln!(" Sign in to BitRouter Cloud");
eprintln!(" ──────────────────────────");
let open_url = device
.verification_uri_complete
.as_deref()
.unwrap_or(device.verification_uri.as_str());
eprintln!(" Open: {open_url}");
eprintln!(" Code: {}", device.user_code);
eprintln!();
eprintln!(
" Waiting for approval (times out in {}s)...",
device.expires_in
);
let minted = poll_for_api_key(&client, base_url, &device)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let creds = CloudCredentials {
api_key: minted.api_key,
key_id: minted.key_id,
base_url: base_url.to_owned(),
minted_at: now,
};
creds.save(home)?;
eprintln!(" ✓ Logged in");
eprintln!(
" Credentials saved to {}",
CloudCredentials::path(home).display()
);
eprintln!();
Ok(())
}
pub fn run_logout(home: &Path) -> Result {
let path = CloudCredentials::path(home);
let existed = path.exists();
CloudCredentials::delete(home)?;
if existed {
println!("Logged out. Credentials at {} removed.", path.display());
} else {
println!("Not logged in (no credentials file).");
}
Ok(())
}
pub fn run_whoami(home: &Path) -> Result {
match CloudCredentials::load(home) {
Some(creds) => {
println!("Logged in to {}", creds.base_url);
println!(" key id: {}", creds.key_id);
println!(" minted at: {}", creds.minted_at);
}
None => {
println!("Not logged in. Run `bitrouter login` to authenticate.");
}
}
Ok(())
}
fn request_device_code(
client: &reqwest::blocking::Client,
base_url: &str,
) -> Result<DeviceCodeResponse> {
let url = format!("{base_url}/api/auth/device/code");
let resp = client
.post(&url)
.json(&serde_json::json!({ "client_id": CLIENT_ID }))
.send()?;
let status = resp.status();
let body = resp.text()?;
if !status.is_success() {
return Err(format!("device code request failed ({status}): {body}").into());
}
let parsed: DeviceCodeResponse = serde_json::from_str(&body)
.map_err(|e| format!("failed to parse device code response: {e}\nbody: {body}"))?;
Ok(parsed)
}
fn poll_for_api_key(
client: &reqwest::blocking::Client,
base_url: &str,
device: &DeviceCodeResponse,
) -> Result<ApiKeyResponse> {
let url = format!("{base_url}/api/device/token");
let mut interval = Duration::from_secs(device.interval.max(1));
let deadline =
std::time::Instant::now() + Duration::from_secs(device.expires_in.max(device.interval));
loop {
if std::time::Instant::now() >= deadline {
return Err("device code expired before approval — please try again".into());
}
std::thread::sleep(interval);
let resp = client
.post(&url)
.json(&serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device.device_code,
"client_id": CLIENT_ID,
}))
.send()?;
let status = resp.status();
let body = resp.text()?;
if status.is_success() {
let minted: ApiKeyResponse = serde_json::from_str(&body)
.map_err(|e| format!("failed to parse token response: {e}\nbody: {body}"))?;
return Ok(minted);
}
let err: ErrorResponse = serde_json::from_str(&body)
.map_err(|e| format!("unexpected token error ({status}): {e}\nbody: {body}"))?;
match err.error.as_deref() {
Some("authorization_pending") => continue,
Some("slow_down") => {
interval += Duration::from_secs(SLOW_DOWN_BACKOFF_SECS);
continue;
}
Some("expired_token") => {
return Err("device code expired — please try again".into());
}
Some("access_denied") => {
return Err("approval denied in the browser".into());
}
Some(other) => {
let detail = err
.error_description
.as_deref()
.unwrap_or("(no description)");
return Err(format!("login failed: {other} — {detail}").into());
}
None => {
return Err(format!("unexpected token response ({status}): {body}").into());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_cloud_url_precedence() {
let saved = std::env::var("BITROUTER_CLOUD_URL").ok();
unsafe { std::env::remove_var("BITROUTER_CLOUD_URL") };
assert_eq!(resolve_cloud_url(None), DEFAULT_CLOUD_URL);
assert_eq!(
resolve_cloud_url(Some("https://example.com///")),
"https://example.com"
);
unsafe { std::env::set_var("BITROUTER_CLOUD_URL", "http://localhost:3000/") };
assert_eq!(resolve_cloud_url(None), "http://localhost:3000");
assert_eq!(
resolve_cloud_url(Some("http://from-flag")),
"http://from-flag"
);
unsafe {
match saved {
Some(v) => std::env::set_var("BITROUTER_CLOUD_URL", v),
None => std::env::remove_var("BITROUTER_CLOUD_URL"),
}
}
}
}