//! Signing in through the browser: the host's sign-in page approves a //! request this command started, and the command picks up the token. //! Upstream's hosts use Upstream's CLI sign-in (coline.app), so Onset gets the //! same kind of session the upstream CLI does. Hosts that don't offer one //! take a token (`on login --token `). use std::io::IsTerminal; use std::time::{Duration, Instant}; use serde::Deserialize; use serde_json::json; use crate::out::Fail; const POLL: Duration = Duration::from_secs(2); const GIVE_UP: Duration = Duration::from_secs(5 * 60); #[derive(Deserialize)] struct Envelope { data: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Created { challenge_id: String, verify_url: String, } #[derive(Deserialize)] struct Status { status: String, #[serde(default)] token: Option, } fn open_in_browser(url: &str) { crate::sys::open_url(url); } /// Gets a token by sending the person to `login`, the host's sign-in endpoint. pub fn browser(host: &str, login: Option<&str>) -> Result { let Some(login) = login else { return Err(Fail::new("bad_usage", format!("{host} doesn't offer browser sign-in")) .state("nothing changed") .next(format!("on login {host} --token "))); }; let agent = crate::remote::http(); let offline = |e: ureq::Error| Fail::new("offline", format!("can't reach {login}: {e}")).state("nothing changed"); let mut response = agent.post(login).send_json(json!({})).map_err(offline)?; if response.status().as_u16() >= 300 { return Err(Fail::new("failed", format!("{host}'s sign-in answered {}", response.status())).state("nothing changed")); } let created: Envelope = response.body_mut().read_json().map_err(|e| Fail::new("failed", format!("{host}'s sign-in sent something unexpected: {e}")))?; let created = created.data.ok_or_else(|| Fail::new("failed", format!("{host}'s sign-in sent something unexpected")))?; eprintln!("Open this in a browser to sign in to {host}:\n {}\n\nWaiting for you to approve it (Ctrl+C to stop)...", created.verify_url); if std::io::stderr().is_terminal() && std::env::var_os("CI").is_none() { open_in_browser(&created.verify_url); } let started = Instant::now(); while started.elapsed() < GIVE_UP { std::thread::sleep(POLL); let Ok(mut response) = agent.get(format!("{login}/{}", created.challenge_id)).call() else { continue }; if response.status().as_u16() != 200 { continue; } let Ok(status) = response.body_mut().read_json::>() else { continue }; let Some(status) = status.data else { continue }; match status.status.as_str() { "approved" => { if let Some(token) = status.token.filter(|t| !t.is_empty()) { return Ok(token); } } "denied" => return Err(Fail::new("not_logged_in", "the sign-in was denied in the browser").state("nothing changed")), "expired" => return Err(Fail::new("not_logged_in", "the sign-in request expired").state("nothing changed").next(format!("on login {host}"))), _ => {} } } Err(Fail::new("not_logged_in", "gave up waiting for the sign-in after 5 minutes").state("nothing changed").next(format!("on login {host}"))) }