use std::io::{BufRead, Write};
use std::time::Duration;
use anyhow::{Context, bail};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rand::RngCore;
use semora_proto::{CliTokenRequest, CliTokenResponse};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use crate::client::{Client, check, http_client, no_server_error};
use crate::config::origin;
use crate::credentials::Stored;
use crate::{Ctx, exit, output};
const LOGIN_TIMEOUT: Duration = Duration::from_secs(600);
fn random_b64(n: usize) -> String {
let mut buf = vec![0u8; n];
rand::rngs::OsRng.fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
pub(crate) fn hostname() -> String {
for var in ["COMPUTERNAME", "HOSTNAME"] {
if let Ok(h) = std::env::var(var)
&& !h.trim().is_empty()
{
return h.trim().to_lowercase();
}
}
std::process::Command::new("hostname")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_lowercase())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "this-machine".into())
}
const DONE_PAGE: &str = "<!doctype html><meta charset=utf-8><title>Semora</title>\
<body style=\"font:16px system-ui;margin:4rem auto;max-width:32rem\"><h1>Signed in</h1>\
<p>The Semora CLI has its key. You can close this tab.</p>";
const DENIED_PAGE: &str = "<!doctype html><meta charset=utf-8><title>Semora</title>\
<body style=\"font:16px system-ui;margin:4rem auto;max-width:32rem\"><h1>Not signed in</h1>\
<p>The request was denied. You can close this tab.</p>";
async fn wait_for_callback(listener: TcpListener) -> anyhow::Result<Vec<(String, String)>> {
loop {
let (mut sock, _) = listener.accept().await?;
let mut buf = Vec::with_capacity(2048);
let mut chunk = [0u8; 1024];
while !buf.windows(4).any(|w| w == b"\r\n\r\n") && buf.len() < 16 * 1024 {
let n = sock.read(&mut chunk).await?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
let head = String::from_utf8_lossy(&buf);
let target = head
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("")
.to_string();
let Some(query) = target.strip_prefix("/callback?") else {
let _ = sock
.write_all(
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
continue;
};
let pairs: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect();
let body = if pairs.iter().any(|(k, _)| k == "error") {
DENIED_PAGE
} else {
DONE_PAGE
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
return Ok(pairs);
}
}
pub async fn login(ctx: &Ctx, no_browser: bool, name: Option<String>) -> anyhow::Result<i32> {
let server = ctx.settings.server.clone().ok_or_else(no_server_error)?;
let base = origin(&server);
let name = name.unwrap_or_else(|| format!("cli@{}", hostname()));
let verifier = random_b64(32);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let state = random_b64(16);
let listener = if no_browser {
None
} else {
Some(
TcpListener::bind("127.0.0.1:0")
.await
.context("opening a local port for the sign-in callback")?,
)
};
let port = match &listener {
Some(l) => l.local_addr()?.port(),
None => 0,
};
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("port", &port.to_string())
.append_pair("state", &state)
.append_pair("code_challenge", &challenge)
.append_pair("name", &name)
.finish();
let url = format!("{base}/cli/authorize?{query}");
eprintln!("Sign in to {base} in your browser and allow the CLI:");
println!("{url}");
std::io::stdout().flush()?;
let code = match listener {
Some(listener) => {
if ctx.settings.open_browser && open::that(&url).is_err() {
output::warn("could not open a browser; open the URL above yourself");
}
let pairs = tokio::time::timeout(LOGIN_TIMEOUT, wait_for_callback(listener))
.await
.map_err(|_| exit::timeout("no answer from the browser within 10 minutes"))??;
let get = |k: &str| {
pairs
.iter()
.find(|(key, _)| key == k)
.map(|(_, v)| v.clone())
};
if get("state").as_deref() != Some(state.as_str()) {
bail!("the sign-in callback's state did not match; start `semora login` again");
}
if let Some(err) = get("error") {
return Err(exit::auth(format!("sign-in was not allowed ({err})")));
}
get("code").context("the sign-in callback had no code")?
}
None => {
eprint!("After you allow it, paste the code shown in the browser: ");
std::io::stderr().flush()?;
let line = tokio::task::spawn_blocking(|| {
let mut s = String::new();
std::io::stdin().lock().read_line(&mut s).map(|_| s)
})
.await??;
let code = line.trim().to_string();
if code.is_empty() {
bail!("no code entered");
}
code
}
};
let resp = http_client()?
.post(format!("{base}/api/v1/cli/token"))
.json(&CliTokenRequest {
code,
code_verifier: verifier,
})
.send()
.await
.with_context(|| format!("could not reach {base}"))?;
let token: CliTokenResponse = check(resp, "sign-in").await?.json().await?;
let stored = ctx.store.set(&base, &token.api_key, &token.name)?;
let place = match stored {
Stored::Keyring => "the system keyring".to_string(),
Stored::File => ctx.store.path().display().to_string(),
};
eprintln!(
"Signed in to {base} as {}. Key \"{}\" saved in {place}.",
token.user.email, token.name
);
if ctx.settings.key_source == crate::config::KeySource::Env {
output::warn("SEMORA_API_KEY is set, so it wins over this login until you unset it");
}
Ok(exit::OK)
}
pub async fn logout(ctx: &Ctx) -> anyhow::Result<i32> {
let server = ctx.settings.server.clone().ok_or_else(no_server_error)?;
let base = origin(&server);
let Some(key) = ctx.store.get(&base) else {
eprintln!("No stored login for {base}.");
if ctx.settings.key_source == crate::config::KeySource::Env {
eprintln!("SEMORA_API_KEY comes from the environment or a .env file; remove it there.");
}
return Ok(exit::OK);
};
let mut settings = ctx.settings.clone();
settings.api_key = Some(key);
let client = Client::new(&settings)?;
match client.whoami().await {
Ok(w) => {
if let Some(k) = w.key
&& let Err(e) = client.revoke_key(k.id).await
{
output::warn(format!("could not revoke the key on the server: {e:#}"));
}
}
Err(e) if exit::code_of(&e) == exit::AUTH => {} Err(e) => output::warn(format!(
"could not reach the server to revoke the key: {e:#}"
)),
}
ctx.store.remove(&base)?;
eprintln!("Signed out of {base}.");
Ok(exit::OK)
}