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, TcpStream};
use tokio::task::JoinSet;
use crate::client::{Client, check, http_client, no_server_error};
use crate::config::{Settings, 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 turn_away(mut sock: TcpStream, status: &str) {
let resp = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
}
type Query = Vec<(String, String)>;
const MAX_OPEN_CONNECTIONS: usize = 32;
const HEAD_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_HEAD: usize = 16 * 1024;
async fn read_head(sock: &mut TcpStream) -> std::io::Result<Vec<u8>> {
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() < MAX_HEAD {
let n = sock.read(&mut chunk).await?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
Ok(buf)
}
async fn serve_connection(
mut sock: TcpStream,
state: String,
head_timeout: Duration,
) -> Option<Query> {
let buf = match tokio::time::timeout(head_timeout, read_head(&mut sock)).await {
Ok(Ok(buf)) => buf,
Ok(Err(_)) | Err(_) => return None,
};
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 {
turn_away(sock, "404 Not Found").await;
return None;
};
let pairs: Query = url::form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect();
let sent_state = pairs.iter().find(|(k, _)| k == "state").map(|(_, v)| v);
if sent_state.is_none_or(|s| *s != state) {
turn_away(sock, "400 Bad Request").await;
return None;
}
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;
Some(pairs)
}
async fn wait_for_callback(listener: TcpListener, state: &str) -> anyhow::Result<Query> {
serve_callbacks(listener, state, MAX_OPEN_CONNECTIONS, HEAD_TIMEOUT).await
}
async fn serve_callbacks(
listener: TcpListener,
state: &str,
max_open: usize,
head_timeout: Duration,
) -> anyhow::Result<Query> {
let mut open = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept(), if open.len() < max_open => {
let (sock, _) = accepted?;
open.spawn(serve_connection(sock, state.to_string(), head_timeout));
}
Some(done) = open.join_next() => {
if let Some(pairs) = done? {
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, &state))
.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 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 previous = replaced_key(ctx.store.get(&base), &token.api_key);
let stored = ctx.store.set(&base, &token.api_key, &token.name)?;
if let Some(old) = previous
&& let Err(e) = revoke_stored_key(&ctx.settings, old).await
{
output::warn(format!(
"could not revoke the previous key on the server: {e:#}"
));
}
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);
};
if let Err(e) = revoke_stored_key(&ctx.settings, key).await {
output::warn(format!("could not revoke the key on the server: {e:#}"));
}
ctx.store.remove(&base)?;
eprintln!("Signed out of {base}.");
Ok(exit::OK)
}
fn replaced_key(stored: Option<String>, new: &str) -> Option<String> {
stored.filter(|old| old != new)
}
async fn revoke_stored_key(settings: &Settings, key: String) -> anyhow::Result<()> {
let mut settings = settings.clone();
settings.api_key = Some(key);
settings.key_source = crate::config::KeySource::Stored;
let client = Client::new(&settings)?;
match client.whoami().await {
Ok(w) => match w.key {
Some(k) => client.revoke_key(k.id).await,
None => Ok(()),
},
Err(e) if exit::code_of(&e) == exit::AUTH => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::{replaced_key, serve_callbacks};
async fn listen(
max_open: usize,
head_timeout: Duration,
) -> (u16, tokio::task::JoinHandle<anyhow::Result<super::Query>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let served = tokio::spawn(serve_callbacks(listener, "right", max_open, head_timeout));
(port, served)
}
async fn connect(port: u16) -> TcpStream {
TcpStream::connect(("127.0.0.1", port)).await.unwrap()
}
async fn send_callback(sock: &mut TcpStream) {
sock.write_all(b"GET /callback?code=C&state=right HTTP/1.1\r\nHost: x\r\n\r\n")
.await
.unwrap();
}
async fn read_all(sock: &mut TcpStream, wait: Duration) -> Option<String> {
let mut out = Vec::new();
let read = tokio::time::timeout(wait, sock.read_to_end(&mut out)).await;
read.ok()
.map(|_| String::from_utf8_lossy(&out).into_owned())
}
#[tokio::test]
async fn a_connection_that_sends_no_head_is_dropped_after_the_timeout() {
let (port, served) = listen(8, Duration::from_millis(300)).await;
let mut silent = connect(port).await;
assert_eq!(
read_all(&mut silent, Duration::from_secs(5))
.await
.as_deref(),
Some(""),
"the silent connection is closed by the CLI"
);
let mut real = connect(port).await;
send_callback(&mut real).await;
let answer = read_all(&mut real, Duration::from_secs(5)).await.unwrap();
assert!(answer.starts_with("HTTP/1.1 200"), "{answer}");
let pairs = served.await.unwrap().unwrap();
assert!(pairs.contains(&("code".to_string(), "C".to_string())));
}
#[tokio::test]
async fn at_most_max_open_connections_are_served_at_once() {
let (port, served) = listen(2, Duration::from_millis(1500)).await;
let _stalled = [connect(port).await, connect(port).await];
let mut real = connect(port).await;
send_callback(&mut real).await;
assert_eq!(
read_all(&mut real, Duration::from_millis(200)).await,
None,
"a third connection is not served while two are open"
);
let answer = read_all(&mut real, Duration::from_secs(10)).await.unwrap();
assert!(answer.starts_with("HTTP/1.1 200"), "{answer}");
served.await.unwrap().unwrap();
}
#[test]
fn a_login_replaces_a_different_stored_key_only() {
assert_eq!(replaced_key(None, "sm_new"), None);
assert_eq!(replaced_key(Some("sm_new".into()), "sm_new"), None);
assert_eq!(
replaced_key(Some("sm_old".into()), "sm_new").as_deref(),
Some("sm_old")
);
}
}