use std::io::Write;
use std::time::{Duration, Instant};
use anyhow::Context;
use reqwest::StatusCode;
use semora_proto::{CliDeviceRequest, CliDeviceResponse, CliTokenRequest, CliTokenResponse};
use crate::client::{Client, check, http_client, no_server_error};
use crate::commands::docs::web_link;
use crate::config::{Settings, origin};
use crate::credentials::Stored;
use crate::{Ctx, exit, output};
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())
}
fn has_desktop() -> bool {
let set = |var: &str| std::env::var_os(var).is_some_and(|v| !v.is_empty());
if set("SSH_CONNECTION") || set("SSH_TTY") {
return false;
}
cfg!(any(target_os = "macos", windows)) || set("DISPLAY") || set("WAYLAND_DISPLAY")
}
const MAX_FAILED_POLLS: u32 = 5;
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 http = http_client()?;
let resp = http
.post(format!("{base}/api/v1/cli/device"))
.json(&CliDeviceRequest { name })
.send()
.await
.with_context(|| format!("could not reach {base}"))?;
if resp.status() == StatusCode::NOT_FOUND {
return Err(exit::not_found(format!(
"{base} has no sign-in for this CLI; it may be older than this CLI. Update the \
server, or set SEMORA_API_KEY to a key from your account page."
)));
}
let grant: CliDeviceResponse = check(resp, "sign-in").await?.json().await?;
let url = web_link(&grant.verification_uri_complete)?;
let wait = Duration::from_secs(grant.expires_in.clamp(1, 3600));
let code: String = grant
.user_code
.chars()
.filter(|c| !c.is_control())
.collect();
let output::Styles { brand, done, .. } = output::err();
anstream::eprintln!("Sign-in code: {brand}{code}{brand:#}");
anstream::eprintln!("Open this link in any browser, on this machine or another:\n");
anstream::println!("{LINK}{url}{LINK:#}", LINK = output::LINK);
std::io::stdout().flush()?;
anstream::eprintln!(
"\n{DIM}Waiting for you to choose Allow there (up to {} minutes)...{DIM:#}",
minutes(wait),
DIM = output::DIM
);
if !no_browser && ctx.settings.open_browser && has_desktop() {
let _ = open::that_detached(&url);
}
let deadline = Instant::now() + wait;
let mut interval = Duration::from_secs(grant.interval.clamp(1, 30));
let mut failed = 0u32;
let (api_key, key_name, user) = loop {
tokio::time::sleep(interval).await;
if Instant::now() >= deadline {
return Err(expired(wait));
}
let sent = http
.post(format!("{base}/api/v1/cli/token"))
.json(&CliTokenRequest {
device_code: grant.device_code.clone(),
})
.send()
.await;
let resp = match sent {
Ok(r) if r.status() == StatusCode::TOO_MANY_REQUESTS => {
interval = (interval * 2).min(Duration::from_secs(30));
continue;
}
Ok(r) if !r.status().is_server_error() => r,
other => {
failed += 1;
if failed >= MAX_FAILED_POLLS {
return Err(match other {
Ok(r) => check(r, "sign-in").await.err().unwrap_or_else(|| {
anyhow::anyhow!("sign-in failed: the server kept failing")
}),
Err(e) => anyhow::Error::new(e).context(format!("could not reach {base}")),
});
}
continue;
}
};
failed = 0;
match check(resp, "sign-in").await?.json().await? {
CliTokenResponse::Pending => {}
CliTokenResponse::Denied => {
return Err(exit::auth("sign-in was denied in the browser"));
}
CliTokenResponse::Expired => return Err(expired(wait)),
CliTokenResponse::Approved {
api_key,
name,
user,
} => break (api_key, name, user),
}
};
let previous = replaced_key(ctx.store.get(&base), &api_key);
let stored = ctx.store.set(&base, &api_key, &key_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(),
};
anstream::eprintln!(
"{done}Signed in{done:#} as {}. Key \"{}\" saved in {place}.",
output::clean_name(&user.email),
output::clean_name(&key_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)
}
fn expired(wait: Duration) -> anyhow::Error {
exit::timeout(format!(
"nobody allowed the sign-in within {} minutes; run `semora login` again",
minutes(wait)
))
}
fn minutes(wait: Duration) -> u64 {
wait.as_secs().div_ceil(60)
}
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)?;
let done = output::err().done;
anstream::eprintln!("{done}Signed out{done:#} 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 super::replaced_key;
#[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")
);
}
}