use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Result, bail};
use harmont_cloud::{HarmontClient, HarmontError};
use crate::settings;
pub(crate) async fn run(env: &BTreeMap<String, String>, paste: bool) -> Result<()> {
let (client, api) = settings::anon_client()?;
let app = app_url(&api, env);
let token = if paste {
login_paste(env, &client, &app).await?
} else {
login_loopback(&client, &app).await?
};
hm_config::creds::set_cloud_token(&api, &token);
let authed = HarmontClient::with_base_url(token, &api);
match authed.raw().get_current_user().await {
Ok(resp) => {
let me = resp.into_inner();
tracing::info!(
"logged in as {} ({})",
me.name.clone().unwrap_or_else(|| me.email.clone()),
me.email,
);
}
Err(e) => {
tracing::warn!("logged in, but could not read user profile: {e}");
}
}
Ok(())
}
async fn login_loopback(client: &HarmontClient, app: &str) -> Result<String> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let nonce = random_nonce();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let auth_url = format!("{app}/cli-login?port={port}&nonce={nonce}");
tracing::info!("opening browser to {auth_url}");
if webbrowser::open(&auth_url).is_err() {
tracing::warn!("couldn't auto-open the browser. Open this URL manually:\n {auth_url}");
}
let accept = async {
if let Ok((stream, _addr)) = listener.accept().await {
let (reader, mut writer) = stream.into_split();
let mut buf_reader = BufReader::new(reader);
let mut request_line = String::new();
let _ = buf_reader.read_line(&mut request_line).await;
let body = "<html><body>Login received. You can close this tab.</body></html>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
writer.write_all(response.as_bytes()).await.ok();
writer.shutdown().await.ok();
}
};
let _ = tokio::time::timeout(Duration::from_secs(180), accept).await;
poll_claim(client, &nonce).await
}
async fn poll_claim(client: &HarmontClient, nonce: &str) -> Result<String> {
let deadline = std::time::Instant::now() + Duration::from_secs(60);
loop {
match client.claim_token(nonce).await {
Ok(token) => return Ok(token),
Err(HarmontError::Api {
status: 400, code, ..
}) if code == "cli_code_invalid" => {
if std::time::Instant::now() >= deadline {
bail!(
"timed out waiting for the browser to authorize this login (60s).\n \
fix: re-run `hm cloud login`, or use `hm cloud login --paste`"
);
}
tokio::time::sleep(Duration::from_millis(750)).await;
}
Err(e) => return Err(e.into()),
}
}
}
async fn login_paste(
env: &BTreeMap<String, String>,
client: &HarmontClient,
app: &str,
) -> Result<String> {
let auth_url = format!("{app}/cli-login?paste=true");
tracing::info!("Open this URL in your browser, then paste the code:\n {auth_url}");
let _ = webbrowser::open(&auth_url);
let code = if let Some(c) = env.get("HM_LOGIN_CODE") {
c.clone()
} else {
dialoguer::Input::<String>::new()
.with_prompt("code")
.interact()
.map_err(|e| anyhow::anyhow!("failed to read code: {e}"))?
};
let code = code.trim().to_string();
if code.is_empty() {
bail!("no code pasted");
}
Ok(client.redeem_code(&code).await?)
}
fn app_url(api: &str, env: &BTreeMap<String, String>) -> String {
hm_config::app_url(api, env.get("HM_APP_URL").map(String::as_str))
}
fn random_nonce() -> String {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let id = uuid::Uuid::new_v4();
URL_SAFE_NO_PAD.encode(id.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn app_url_maps_prod_api_to_app() {
assert_eq!(
app_url("https://api.harmont.dev", &env(&[])),
"https://app.harmont.dev"
);
}
#[test]
fn app_url_env_override_wins() {
assert_eq!(
app_url(
"https://api.harmont.dev",
&env(&[("HM_APP_URL", "http://localhost:5173/")])
),
"http://localhost:5173"
);
}
#[test]
fn app_url_falls_back_to_api_for_unmapped_host() {
assert_eq!(
app_url("http://localhost:4000", &env(&[])),
"http://localhost:4000"
);
}
#[test]
fn nonces_are_distinct() {
assert_ne!(random_nonce(), random_nonce());
}
}