use crate::cli::ui::Loader;
use crate::config::{ApiConfig, SshConfig};
use crate::utils::open_with_default_handler;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::time::{sleep, Duration};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginRequestPayload {
device_name: Option<String>,
hostname: Option<String>,
platform: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginRequestResponse {
browser_url: String,
expires_at: String,
flow_id: String,
poll_interval_seconds: u64,
poll_token: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginPollRequest {
flow_id: String,
poll_token: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliLoginPollResponse {
status: String,
access_token: Option<String>,
expires_at: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliAuthSessionResponse {
user: CliAuthSessionUser,
token: CliAuthSessionToken,
}
#[derive(Debug, Deserialize)]
struct CliAuthSessionUser {
name: String,
email: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CliAuthSessionToken {
label: Option<String>,
prefix: String,
expires_at: Option<String>,
}
fn current_hostname() -> Option<String> {
for key in ["HOSTNAME", "COMPUTERNAME"] {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn build_device_name(hostname: Option<&str>) -> Option<String> {
let username = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
match (username, hostname) {
(Some(user), Some(host)) => Some(format!("{}@{}", user, host)),
(Some(user), None) => Some(user),
(None, Some(host)) => Some(host.to_string()),
(None, None) => None,
}
}
fn current_platform() -> String {
std::env::consts::OS.to_string()
}
async fn request_cli_login_flow(
client: &Client,
api: &ApiConfig,
) -> Result<CliLoginRequestResponse, String> {
let hostname = current_hostname();
let payload = CliLoginRequestPayload {
device_name: build_device_name(hostname.as_deref()),
hostname,
platform: Some(current_platform()),
};
client
.post(api.cli_auth_request_endpoint())
.json(&payload)
.send()
.await
.map_err(|e| format!("Failed to start CLI login flow: {}", e))?
.error_for_status()
.map_err(|e| format!("CLI login flow request failed: {}", e))?
.json::<CliLoginRequestResponse>()
.await
.map_err(|e| format!("Failed to parse CLI login flow response: {}", e))
}
async fn poll_cli_login_flow(
client: &Client,
api: &ApiConfig,
flow: &CliLoginRequestResponse,
) -> Result<CliLoginPollResponse, String> {
client
.post(api.cli_auth_poll_endpoint())
.json(&CliLoginPollRequest {
flow_id: flow.flow_id.clone(),
poll_token: flow.poll_token.clone(),
})
.send()
.await
.map_err(|e| format!("Failed to poll CLI login flow: {}", e))?
.error_for_status()
.map_err(|e| format!("CLI login flow polling failed: {}", e))?
.json::<CliLoginPollResponse>()
.await
.map_err(|e| format!("Failed to parse CLI login poll response: {}", e))
}
async fn fetch_cli_session(
client: &Client,
api: &ApiConfig,
token: &str,
) -> Result<CliAuthSessionResponse, String> {
client
.get(api.cli_auth_session_endpoint())
.bearer_auth(token)
.send()
.await
.map_err(|e| format!("Failed to verify CLI login token: {}", e))?
.error_for_status()
.map_err(|e| format!("CLI login token verification failed: {}", e))?
.json::<CliAuthSessionResponse>()
.await
.map_err(|e| format!("Failed to parse CLI session response: {}", e))
}
fn save_cli_token(token: String) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
config.xbp_api_token = Some(token);
config.save()
}
pub async fn run_login() -> Result<(), String> {
let api = ApiConfig::load();
let client = Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let request_loader = Loader::start("Creating browser login request");
let flow = request_cli_login_flow(&client, &api).await?;
request_loader.success_with("Browser login request created");
println!("Open this URL to authorize the CLI:");
println!("{}", flow.browser_url);
println!();
println!("Flow expires at: {}", flow.expires_at);
if let Err(error) = open_with_default_handler(&flow.browser_url) {
println!("Could not open the browser automatically: {}", error);
}
let poll_loader = Loader::start("Waiting for browser approval");
loop {
let response = poll_cli_login_flow(&client, &api, &flow).await?;
match response.status.as_str() {
"pending" => {
let expiry = response
.expires_at
.as_deref()
.unwrap_or(flow.expires_at.as_str());
poll_loader.update(&format!("Waiting for approval (expires {})", expiry));
sleep(Duration::from_secs(flow.poll_interval_seconds.max(1))).await;
}
"expired" => {
poll_loader.fail("expired");
return Err(format!(
"The login request expired. Open {} and run `xbp login` again.",
api.cli_auth_browser_url(&flow.flow_id)
));
}
"completed" => {
poll_loader.fail("already used");
return Err(
"This login request was already completed. Run `xbp login` again.".to_string(),
);
}
"approved" => {
let access_token = response.access_token.ok_or_else(|| {
"CLI login flow reported approval without an access token.".to_string()
})?;
let verified = fetch_cli_session(&client, &api, &access_token).await?;
save_cli_token(access_token)?;
poll_loader.success_with("CLI login approved");
println!(
"Signed in as {} <{}>",
verified.user.name, verified.user.email
);
if let Some(label) = verified.token.label.as_deref() {
println!("CLI token label: {}", label);
}
println!("CLI token prefix: {}", verified.token.prefix);
if let Some(expires_at) = verified.token.expires_at.as_deref() {
println!("CLI token expires at: {}", expires_at);
}
return Ok(());
}
other => {
poll_loader.fail(other);
return Err(format!("Unexpected CLI login status: {}", other));
}
}
}
}