//! `oxy auth`: sign in to a hub once, for every repository on it. use super::ui; use anyhow::{bail, Context, Result}; use oxy_core::credentials::{hub_of, token_allowed, Credentials, Stored}; use oxy_core::protocol::{http_json, RemoteOptions}; use oxy_core::repo::Repo; use serde_json::Value; use std::io::BufRead; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; /// The scopes oxy asks for: read and push repositories, and name the /// account it signed in as. const SCOPES: [&str; 3] = ["repo:read", "repo:write", "user:read"]; /// The hub named on the command line (a hub, a host, or any URL on it), else /// that of this checkout's `origin` (or its only remote). fn resolve_hub(arg: Option<&str>) -> Result { if let Some(a) = arg { let url = if a.contains("://") { a.to_string() } else { format!("https://{a}") }; return hub_of(&url).with_context(|| format!("{a} is not a hub URL")); } if let Ok(repo) = Repo::open(Path::new(".")) { let remotes = &repo.config.remotes; let url = remotes.get("origin").or_else(|| { (remotes.len() == 1) .then(|| remotes.values().next()) .flatten() }); if let Some(hub) = url.and_then(|u| hub_of(u)) { return Ok(hub); } } bail!("name the hub, as in: oxy auth login https://oxygenhub.io") } /// The account a token signs in as, from the hub's `/api/v1/user`. fn whoami(hub: &str, options: &RemoteOptions) -> Result> { let (code, body) = http_json("GET", &format!("{hub}/api/v1/user"), None, options) .map_err(|e| anyhow::anyhow!("{e}"))?; if code != 200 { return Ok(Err(format!("HTTP {code}"))); } let v: Value = serde_json::from_str(&body).context("the hub's answer about the account")?; Ok(v["username"] .as_str() .map(String::from) .ok_or_else(|| "no account in the answer".to_string())) } fn device_client() -> String { let host = std::fs::read_to_string("/etc/hostname") .ok() .or_else(|| std::env::var("COMPUTERNAME").ok()) .map(|h| h.trim().to_string()) .filter(|h| !h.is_empty()); match host { Some(h) => format!("oxy on {h}"), None => "oxy".to_string(), } } /// The device sign-in (RFC 8628): show a code, wait for its approval on /// the hub's web site, and return the token it grants. fn device_sign_in(hub: &str, options: &RemoteOptions) -> Result { let request = serde_json::json!({ "client": device_client(), "scopes": SCOPES }).to_string(); let (code, body) = http_json( "POST", &format!("{hub}/api/v1/device/code"), Some(&request), options, ) .map_err(|e| anyhow::anyhow!("{e}"))?; let v: Value = serde_json::from_str(&body) .with_context(|| format!("{hub} did not answer a sign-in request (HTTP {code})"))?; if code != 200 { bail!( "{hub} cannot sign in a device: {}", v["error_description"] .as_str() .or(v["detail"].as_str()) .unwrap_or(&body) ); } let device = v["device_code"] .as_str() .context("no device code")? .to_string(); let user_code = v["user_code"].as_str().unwrap_or("?"); let url = v["verification_uri_complete"] .as_str() .or(v["verification_uri"].as_str()) .unwrap_or(hub); let mut interval = v["interval"].as_u64().unwrap_or(5).max(1); let deadline = Instant::now() + Duration::from_secs(v["expires_in"].as_u64().unwrap_or(900)); eprintln!( "Open {} and approve the code {}", ui::path(url), ui::subject(user_code) ); eprintln!( " {} {}", ui::note(), ui::dim("approve only a code you started yourself, just now") ); let sp = ui::spinner("Waiting for approval (Ctrl-C to give up)…"); let poll = serde_json::json!({ "device_code": device }).to_string(); loop { std::thread::sleep(Duration::from_secs(interval)); if Instant::now() > deadline { sp.clear(); bail!("the code expired before it was approved; run oxy auth login again"); } let (code, body) = http_json( "POST", &format!("{hub}/api/v1/device/token"), Some(&poll), options, ) .map_err(|e| anyhow::anyhow!("{e}"))?; let v: Value = serde_json::from_str(&body).unwrap_or_default(); if code == 200 { sp.clear(); return v["access_token"] .as_str() .map(String::from) .context("no token in the hub's answer"); } match v["error"].as_str() { Some("authorization_pending") => {} Some("slow_down") => interval += 5, Some("access_denied") => { sp.clear(); bail!("the sign-in was denied on the hub"); } Some("expired_token") => { sp.clear(); bail!("the code expired before it was approved; run oxy auth login again"); } _ => { sp.clear(); bail!("signing in: HTTP {code}: {}", body.trim()); } } } } fn where_stored(stored: &Stored) -> String { match stored { Stored::File(p) => format!("{} (yours only)", p.display()), Stored::Helper(h) => format!("credential helper {h}"), } } pub fn login(hub: Option<&str>, with_token: bool, ca: Option) -> Result { let hub = resolve_hub(hub)?; if !token_allowed(&hub) { bail!("{hub} is plain http: a token would cross the network unencrypted; sign in to its https address"); } let store = Credentials::user(); let ca = ca .map(|c| std::fs::canonicalize(&c).unwrap_or(c)) .or_else(|| store.get(&hub).ok().flatten().and_then(|e| e.ca)) .or_else(|| std::env::var("OXY_CA").ok().map(PathBuf::from)); let anonymous = RemoteOptions { token: None, ca: ca.clone(), repo_id: None, }; let token = if with_token { let mut line = String::new(); std::io::stdin() .lock() .read_line(&mut line) .context("reading the token from standard input")?; let t = line.trim().to_string(); if t.is_empty() { bail!("no token on standard input"); } t } else { device_sign_in(&hub, &anonymous)? }; let options = RemoteOptions { token: Some(token.clone()), ..anonymous }; let account = match whoami(&hub, &options)? { Ok(a) => Some(a), Err(why) if with_token => bail!("{hub} did not accept the token ({why})"), // A token without user:read still serves repositories. Err(_) => None, }; let stored = store .save(&hub, &token, account.as_deref(), ca.as_deref()) .map_err(|e| anyhow::anyhow!("{e}"))?; let who = account .as_deref() .map(|a| format!(" as {}", ui::author(a))) .unwrap_or_default(); println!("{} to {}{who}", ui::done("Signed in"), ui::path(&hub)); println!( " {} {}", ui::note(), ui::dim(format!("token in {}", where_stored(&stored))) ); Ok(0) } /// The start of a token, enough to tell tokens apart and no more. fn masked(token: &str) -> String { let shown: String = token.chars().take(10).collect(); format!("{shown}…") } pub fn status() -> Result { let store = Credentials::user(); let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?; if store.too_open() { println!( "{} {} {}", ui::caution(), ui::path(store.path().display()), ui::warn("can be read by other users, so oxy ignores it: run chmod 600 on it") ); } if std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty()) { println!( "{} {}", ui::note(), ui::dim("OXY_TOKEN is set: it is used ahead of the tokens below, for every hub") ); } if entries.is_empty() { println!( "{}", ui::dim("Not signed in to any hub: run oxy auth login HUB") ); return Ok(0); } let helper = store.helper(); let mut bad = false; for e in entries { let full = store.get(&e.hub).map_err(|e| anyhow::anyhow!("{e}"))?; let token = full.as_ref().and_then(|f| f.token.clone()); println!("{}", ui::section(&e.hub)); let Some(token) = token else { println!( " {}", ui::warn("no token stored: run oxy auth login again") ); bad = true; continue; }; let options = RemoteOptions { token: Some(token.clone()), ca: e.ca.clone(), repo_id: None, }; let check = match whoami(&e.hub, &options) { Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")), Ok(Err(why)) if why == "HTTP 401" => { bad = true; ui::err("rejected by the hub: expired or revoked; run oxy auth login again") } Ok(Err(why)) => ui::warn(format!("not checked ({why})")), Err(e) => ui::warn(format!("not checked ({e})")), }; println!( "{}", ui::kv("token", format!("{} {}", masked(&token), check)) ); if let Some(a) = &e.account { println!("{}", ui::kv("account", ui::author(a))); } let place = match &helper { Some(h) => format!("credential helper {h}"), None => store.path().display().to_string(), }; println!("{}", ui::kv("stored", ui::dim(place))); if let Some(ca) = &e.ca { println!("{}", ui::kv("ca", ui::path(ca.display()))); } } Ok(if bad { 1 } else { 0 }) } pub fn logout(hub: Option<&str>) -> Result { let store = Credentials::user(); let hub = match hub { Some(_) => resolve_hub(hub)?, None => { let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?; match (entries.len(), resolve_hub(None)) { (_, Ok(h)) => h, (1, Err(_)) => entries[0].hub.clone(), _ => bail!("name the hub to sign out of (oxy auth status lists them)"), } } }; let Some(entry) = store.remove(&hub).map_err(|e| anyhow::anyhow!("{e}"))? else { println!("{}", ui::dim(format!("Not signed in to {hub}"))); return Ok(0); }; // Revoke the token on the hub too, so a copy left anywhere is useless. let revoked = match &entry.token { Some(t) => { let options = RemoteOptions { token: Some(t.clone()), ca: entry.ca.clone(), repo_id: None, }; matches!( http_json( "DELETE", &format!("{hub}/api/v1/user/token"), None, &options ), Ok((200 | 204, _)) ) } None => false, }; println!("{} of {}", ui::done("Signed out"), ui::path(&hub)); if revoked { println!( " {} {}", ui::note(), ui::dim("the token was revoked on the hub") ); } else { println!( " {} {}", ui::caution(), ui::warn("the hub did not revoke the token: revoke it under Settings → Tokens") ); } Ok(0) }