| ff1f62fe Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
use super::ui; |
| 4 |
use anyhow::{bail, Context, Result}; |
| 5 |
use oxy_core::credentials::{hub_of, token_allowed, Credentials, Stored}; |
| 6 |
use oxy_core::protocol::{http_json, RemoteOptions}; |
| 7 |
use oxy_core::repo::Repo; |
| 8 |
use serde_json::Value; |
| 9 |
use std::io::BufRead; |
| 10 |
use std::path::{Path, PathBuf}; |
| 11 |
use std::time::{Duration, Instant}; |
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
const SCOPES: [&str; 3] = ["repo:read", "repo:write", "user:read"]; |
| 16 |
|
| 17 |
|
| 18 |
|
| 19 |
fn resolve_hub(arg: Option<&str>) -> Result<String> { |
| 20 |
if let Some(a) = arg { |
| 21 |
let url = if a.contains("://") { |
| 22 |
a.to_string() |
| 23 |
} else { |
| 24 |
format!("https://{a}") |
| 25 |
}; |
| 26 |
return hub_of(&url).with_context(|| format!("{a} is not a hub URL")); |
| 27 |
} |
| 28 |
if let Ok(repo) = Repo::open(Path::new(".")) { |
| 29 |
let remotes = &repo.config.remotes; |
| 30 |
let url = remotes.get("origin").or_else(|| { |
| 31 |
(remotes.len() == 1) |
| 32 |
.then(|| remotes.values().next()) |
| 33 |
.flatten() |
| 34 |
}); |
| 35 |
if let Some(hub) = url.and_then(|u| hub_of(u)) { |
| 36 |
return Ok(hub); |
| 37 |
} |
| 38 |
} |
| 39 |
bail!("name the hub, as in: oxy auth login https://oxygenhub.io") |
| 40 |
} |
| 41 |
|
| 42 |
|
| 43 |
fn whoami(hub: &str, options: &RemoteOptions) -> Result<std::result::Result<String, String>> { |
| 44 |
let (code, body) = http_json("GET", &format!("{hub}/api/v1/user"), None, options) |
| 45 |
.map_err(|e| anyhow::anyhow!("{e}"))?; |
| 46 |
if code != 200 { |
| 47 |
return Ok(Err(format!("HTTP {code}"))); |
| 48 |
} |
| 49 |
let v: Value = serde_json::from_str(&body).context("the hub's answer about the account")?; |
| 50 |
Ok(v["username"] |
| 51 |
.as_str() |
| 52 |
.map(String::from) |
| 53 |
.ok_or_else(|| "no account in the answer".to_string())) |
| 54 |
} |
| 55 |
|
| 56 |
fn device_client() -> String { |
| 57 |
let host = std::fs::read_to_string("/etc/hostname") |
| 58 |
.ok() |
| 59 |
.or_else(|| std::env::var("COMPUTERNAME").ok()) |
| 60 |
.map(|h| h.trim().to_string()) |
| 61 |
.filter(|h| !h.is_empty()); |
| 62 |
match host { |
| 63 |
Some(h) => format!("oxy on {h}"), |
| 64 |
None => "oxy".to_string(), |
| 65 |
} |
| 66 |
} |
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
fn device_sign_in(hub: &str, options: &RemoteOptions) -> Result<String> { |
| 71 |
let request = serde_json::json!({ "client": device_client(), "scopes": SCOPES }).to_string(); |
| 72 |
let (code, body) = http_json( |
| 73 |
"POST", |
| 74 |
&format!("{hub}/api/v1/device/code"), |
| 75 |
Some(&request), |
| 76 |
options, |
| 77 |
) |
| 78 |
.map_err(|e| anyhow::anyhow!("{e}"))?; |
| 79 |
let v: Value = serde_json::from_str(&body) |
| 80 |
.with_context(|| format!("{hub} did not answer a sign-in request (HTTP {code})"))?; |
| 81 |
if code != 200 { |
| 82 |
bail!( |
| 83 |
"{hub} cannot sign in a device: {}", |
| 84 |
v["error_description"] |
| 85 |
.as_str() |
| 86 |
.or(v["detail"].as_str()) |
| 87 |
.unwrap_or(&body) |
| 88 |
); |
| 89 |
} |
| 90 |
let device = v["device_code"] |
| 91 |
.as_str() |
| 92 |
.context("no device code")? |
| 93 |
.to_string(); |
| 94 |
let user_code = v["user_code"].as_str().unwrap_or("?"); |
| 95 |
let url = v["verification_uri_complete"] |
| 96 |
.as_str() |
| 97 |
.or(v["verification_uri"].as_str()) |
| 98 |
.unwrap_or(hub); |
| 99 |
let mut interval = v["interval"].as_u64().unwrap_or(5).max(1); |
| 100 |
let deadline = Instant::now() + Duration::from_secs(v["expires_in"].as_u64().unwrap_or(900)); |
| 101 |
eprintln!( |
| 102 |
"Open {} and approve the code {}", |
| 103 |
ui::path(url), |
| 104 |
ui::subject(user_code) |
| 105 |
); |
| 106 |
eprintln!( |
| 107 |
" {} {}", |
| 108 |
ui::note(), |
| 109 |
ui::dim("approve only a code you started yourself, just now") |
| 110 |
); |
| 111 |
let sp = ui::spinner("Waiting for approval (Ctrl-C to give up)…"); |
| 112 |
let poll = serde_json::json!({ "device_code": device }).to_string(); |
| 113 |
loop { |
| 114 |
std::thread::sleep(Duration::from_secs(interval)); |
| 115 |
if Instant::now() > deadline { |
| 116 |
sp.clear(); |
| 117 |
bail!("the code expired before it was approved; run oxy auth login again"); |
| 118 |
} |
| 119 |
let (code, body) = http_json( |
| 120 |
"POST", |
| 121 |
&format!("{hub}/api/v1/device/token"), |
| 122 |
Some(&poll), |
| 123 |
options, |
| 124 |
) |
| 125 |
.map_err(|e| anyhow::anyhow!("{e}"))?; |
| 126 |
let v: Value = serde_json::from_str(&body).unwrap_or_default(); |
| 127 |
if code == 200 { |
| 128 |
sp.clear(); |
| 129 |
return v["access_token"] |
| 130 |
.as_str() |
| 131 |
.map(String::from) |
| 132 |
.context("no token in the hub's answer"); |
| 133 |
} |
| 134 |
match v["error"].as_str() { |
| 135 |
Some("authorization_pending") => {} |
| 136 |
Some("slow_down") => interval += 5, |
| 137 |
Some("access_denied") => { |
| 138 |
sp.clear(); |
| 139 |
bail!("the sign-in was denied on the hub"); |
| 140 |
} |
| 141 |
Some("expired_token") => { |
| 142 |
sp.clear(); |
| 143 |
bail!("the code expired before it was approved; run oxy auth login again"); |
| 144 |
} |
| 145 |
_ => { |
| 146 |
sp.clear(); |
| 147 |
bail!("signing in: HTTP {code}: {}", body.trim()); |
| 148 |
} |
| 149 |
} |
| 150 |
} |
| 151 |
} |
| 152 |
|
| 153 |
fn where_stored(stored: &Stored) -> String { |
| 154 |
match stored { |
| 155 |
Stored::File(p) => format!("{} (yours only)", p.display()), |
| 156 |
Stored::Helper(h) => format!("credential helper {h}"), |
| 157 |
} |
| 158 |
} |
| 159 |
|
| 160 |
pub fn login(hub: Option<&str>, with_token: bool, ca: Option<PathBuf>) -> Result<i32> { |
| 161 |
let hub = resolve_hub(hub)?; |
| 162 |
if !token_allowed(&hub) { |
| 163 |
bail!("{hub} is plain http: a token would cross the network unencrypted; sign in to its https address"); |
| 164 |
} |
| 165 |
let store = Credentials::user(); |
| 166 |
let ca = ca |
| 167 |
.map(|c| std::fs::canonicalize(&c).unwrap_or(c)) |
| 168 |
.or_else(|| store.get(&hub).ok().flatten().and_then(|e| e.ca)) |
| 169 |
.or_else(|| std::env::var("OXY_CA").ok().map(PathBuf::from)); |
| 170 |
let anonymous = RemoteOptions { |
| 171 |
token: None, |
| 172 |
ca: ca.clone(), |
| 68a92484 Andy McCall ↶ |
173 |
..RemoteOptions::default() |
| ff1f62fe Andy McCall ↶ |
174 |
}; |
| 175 |
let token = if with_token { |
| 176 |
let mut line = String::new(); |
| 177 |
std::io::stdin() |
| 178 |
.lock() |
| 179 |
.read_line(&mut line) |
| 180 |
.context("reading the token from standard input")?; |
| 181 |
let t = line.trim().to_string(); |
| 182 |
if t.is_empty() { |
| 183 |
bail!("no token on standard input"); |
| 184 |
} |
| 185 |
t |
| 186 |
} else { |
| 187 |
device_sign_in(&hub, &anonymous)? |
| 188 |
}; |
| 189 |
let options = RemoteOptions { |
| 190 |
token: Some(token.clone()), |
| 191 |
..anonymous |
| 192 |
}; |
| 193 |
let account = match whoami(&hub, &options)? { |
| 194 |
Ok(a) => Some(a), |
| 195 |
Err(why) if with_token => bail!("{hub} did not accept the token ({why})"), |
| 196 |
|
| 197 |
Err(_) => None, |
| 198 |
}; |
| 199 |
let stored = store |
| 200 |
.save(&hub, &token, account.as_deref(), ca.as_deref()) |
| 201 |
.map_err(|e| anyhow::anyhow!("{e}"))?; |
| 202 |
let who = account |
| 203 |
.as_deref() |
| 204 |
.map(|a| format!(" as {}", ui::author(a))) |
| 205 |
.unwrap_or_default(); |
| 206 |
println!("{} to {}{who}", ui::done("Signed in"), ui::path(&hub)); |
| 207 |
println!( |
| 208 |
" {} {}", |
| 209 |
ui::note(), |
| 210 |
ui::dim(format!("token in {}", where_stored(&stored))) |
| 211 |
); |
| 212 |
Ok(0) |
| 213 |
} |
| 214 |
|
| 215 |
|
| 216 |
fn masked(token: &str) -> String { |
| 217 |
let shown: String = token.chars().take(10).collect(); |
| 218 |
format!("{shown}…") |
| 219 |
} |
| 220 |
|
| 221 |
pub fn status() -> Result<i32> { |
| 222 |
let store = Credentials::user(); |
| 223 |
let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?; |
| 224 |
if store.too_open() { |
| 225 |
println!( |
| 226 |
"{} {} {}", |
| 227 |
ui::caution(), |
| 228 |
ui::path(store.path().display()), |
| 229 |
ui::warn("can be read by other users, so oxy ignores it: run chmod 600 on it") |
| 230 |
); |
| 231 |
} |
| 232 |
if std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty()) { |
| 233 |
println!( |
| 234 |
"{} {}", |
| 235 |
ui::note(), |
| 236 |
ui::dim("OXY_TOKEN is set: it is used ahead of the tokens below, for every hub") |
| 237 |
); |
| 238 |
} |
| 239 |
if entries.is_empty() { |
| 240 |
println!( |
| 241 |
"{}", |
| 242 |
ui::dim("Not signed in to any hub: run oxy auth login HUB") |
| 243 |
); |
| 244 |
return Ok(0); |
| 245 |
} |
| 246 |
let helper = store.helper(); |
| 247 |
let mut bad = false; |
| 248 |
for e in entries { |
| 249 |
let full = store.get(&e.hub).map_err(|e| anyhow::anyhow!("{e}"))?; |
| 250 |
let token = full.as_ref().and_then(|f| f.token.clone()); |
| 251 |
println!("{}", ui::section(&e.hub)); |
| 252 |
let Some(token) = token else { |
| 253 |
println!( |
| 254 |
" {}", |
| 255 |
ui::warn("no token stored: run oxy auth login again") |
| 256 |
); |
| 257 |
bad = true; |
| 258 |
continue; |
| 259 |
}; |
| 260 |
let options = RemoteOptions { |
| 261 |
token: Some(token.clone()), |
| 262 |
ca: e.ca.clone(), |
| 68a92484 Andy McCall ↶ |
263 |
..RemoteOptions::default() |
| ff1f62fe Andy McCall ↶ |
264 |
}; |
| 265 |
let check = match whoami(&e.hub, &options) { |
| 266 |
Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")), |
| 267 |
Ok(Err(why)) if why == "HTTP 401" => { |
| 268 |
bad = true; |
| 269 |
ui::err("rejected by the hub: expired or revoked; run oxy auth login again") |
| 270 |
} |
| 271 |
Ok(Err(why)) => ui::warn(format!("not checked ({why})")), |
| 272 |
Err(e) => ui::warn(format!("not checked ({e})")), |
| 273 |
}; |
| 274 |
println!( |
| 275 |
"{}", |
| 276 |
ui::kv("token", format!("{} {}", masked(&token), check)) |
| 277 |
); |
| 278 |
if let Some(a) = &e.account { |
| 279 |
println!("{}", ui::kv("account", ui::author(a))); |
| 280 |
} |
| 281 |
let place = match &helper { |
| 282 |
Some(h) => format!("credential helper {h}"), |
| 283 |
None => store.path().display().to_string(), |
| 284 |
}; |
| 285 |
println!("{}", ui::kv("stored", ui::dim(place))); |
| 286 |
if let Some(ca) = &e.ca { |
| 287 |
println!("{}", ui::kv("ca", ui::path(ca.display()))); |
| 288 |
} |
| 289 |
} |
| 290 |
Ok(if bad { 1 } else { 0 }) |
| 291 |
} |
| 292 |
|
| 293 |
pub fn logout(hub: Option<&str>) -> Result<i32> { |
| 294 |
let store = Credentials::user(); |
| 295 |
let hub = match hub { |
| 296 |
Some(_) => resolve_hub(hub)?, |
| 297 |
None => { |
| 298 |
let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?; |
| 299 |
match (entries.len(), resolve_hub(None)) { |
| 300 |
(_, Ok(h)) => h, |
| 301 |
(1, Err(_)) => entries[0].hub.clone(), |
| 302 |
_ => bail!("name the hub to sign out of (oxy auth status lists them)"), |
| 303 |
} |
| 304 |
} |
| 305 |
}; |
| 306 |
let Some(entry) = store.remove(&hub).map_err(|e| anyhow::anyhow!("{e}"))? else { |
| 307 |
println!("{}", ui::dim(format!("Not signed in to {hub}"))); |
| 308 |
return Ok(0); |
| 309 |
}; |
| 310 |
|
| 311 |
let revoked = match &entry.token { |
| 312 |
Some(t) => { |
| 313 |
let options = RemoteOptions { |
| 314 |
token: Some(t.clone()), |
| 315 |
ca: entry.ca.clone(), |
| 68a92484 Andy McCall ↶ |
316 |
..RemoteOptions::default() |
| ff1f62fe Andy McCall ↶ |
317 |
}; |
| 318 |
matches!( |
| 319 |
http_json( |
| 320 |
"DELETE", |
| 321 |
&format!("{hub}/api/v1/user/token"), |
| 322 |
None, |
| 323 |
&options |
| 324 |
), |
| 325 |
Ok((200 | 204, _)) |
| 326 |
) |
| 327 |
} |
| 328 |
None => false, |
| 329 |
}; |
| 330 |
println!("{} of {}", ui::done("Signed out"), ui::path(&hub)); |
| 331 |
if revoked { |
| 332 |
println!( |
| 333 |
" {} {}", |
| 334 |
ui::note(), |
| 335 |
ui::dim("the token was revoked on the hub") |
| 336 |
); |
| 337 |
} else { |
| 338 |
println!( |
| 339 |
" {} {}", |
| 340 |
ui::caution(), |
| 341 |
ui::warn("the hub did not revoke the token: revoke it under Settings → Tokens") |
| 342 |
); |
| 343 |
} |
| 344 |
Ok(0) |
| 345 |
} |