OXYGENHUB

Coverage / src/cli/auth.rs 0.0%

0 of 292 lines run, 0 of 64 functions · run #300 on main · run missed

1
//! `oxy auth`: sign in to a hub once, for every repository on it.
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
/// The scopes oxy asks for: read and push repositories, and name the
14
/// account it signed in as.
15
const SCOPES: [&str; 3] = ["repo:read", "repo:write", "user:read"];
16
17
/// The hub named on the command line (a hub, a host, or any URL on it), else
18
/// that of this checkout's `origin` (or its only remote).
190×
fn resolve_hub(arg: Option<&str>) -> Result<String> {
200×
    if let Some(a) = arg {
210×
        let url = if a.contains("://") {
220×
            a.to_string()
23
        } else {
240×
            format!("https://{a}")
25
        };
260×
        return hub_of(&url).with_context(|| format!("{a} is not a hub URL"));
270×
    }
280×
    if let Ok(repo) = Repo::open(Path::new(".")) {
290×
        let remotes = &repo.config.remotes;
300×
        let url = remotes.get("origin").or_else(|| {
310×
            (remotes.len() == 1)
320×
                .then(|| remotes.values().next())
330×
                .flatten()
340×
        });
350×
        if let Some(hub) = url.and_then(|u| hub_of(u)) {
360×
            return Ok(hub);
370×
        }
380×
    }
390×
    bail!("name the hub, as in: oxy auth login https://oxygenhub.io")
400×
}
41
42
/// The account a token signs in as, from the hub's `/api/v1/user`.
430×
fn whoami(hub: &str, options: &RemoteOptions) -> Result<std::result::Result<String, String>> {
440×
    let (code, body) = http_json("GET", &format!("{hub}/api/v1/user"), None, options)
450×
        .map_err(|e| anyhow::anyhow!("{e}"))?;
460×
    if code != 200 {
470×
        return Ok(Err(format!("HTTP {code}")));
480×
    }
490×
    let v: Value = serde_json::from_str(&body).context("the hub's answer about the account")?;
500×
    Ok(v["username"]
510×
        .as_str()
520×
        .map(String::from)
530×
        .ok_or_else(|| "no account in the answer".to_string()))
540×
}
55
560×
fn device_client() -> String {
570×
    let host = std::fs::read_to_string("/etc/hostname")
580×
        .ok()
590×
        .or_else(|| std::env::var("COMPUTERNAME").ok())
600×
        .map(|h| h.trim().to_string())
610×
        .filter(|h| !h.is_empty());
620×
    match host {
630×
        Some(h) => format!("oxy on {h}"),
640×
        None => "oxy".to_string(),
65
    }
660×
}
67
68
/// The device sign-in (RFC 8628): show a code, wait for its approval on
69
/// the hub's web site, and return the token it grants.
700×
fn device_sign_in(hub: &str, options: &RemoteOptions) -> Result<String> {
710×
    let request = serde_json::json!({ "client": device_client(), "scopes": SCOPES }).to_string();
720×
    let (code, body) = http_json(
730×
        "POST",
740×
        &format!("{hub}/api/v1/device/code"),
750×
        Some(&request),
760×
        options,
77
    )
780×
    .map_err(|e| anyhow::anyhow!("{e}"))?;
790×
    let v: Value = serde_json::from_str(&body)
800×
        .with_context(|| format!("{hub} did not answer a sign-in request (HTTP {code})"))?;
810×
    if code != 200 {
820×
        bail!(
83
            "{hub} cannot sign in a device: {}",
840×
            v["error_description"]
850×
                .as_str()
860×
                .or(v["detail"].as_str())
870×
                .unwrap_or(&body)
88
        );
890×
    }
900×
    let device = v["device_code"]
910×
        .as_str()
920×
        .context("no device code")?
930×
        .to_string();
940×
    let user_code = v["user_code"].as_str().unwrap_or("?");
950×
    let url = v["verification_uri_complete"]
960×
        .as_str()
970×
        .or(v["verification_uri"].as_str())
980×
        .unwrap_or(hub);
990×
    let mut interval = v["interval"].as_u64().unwrap_or(5).max(1);
1000×
    let deadline = Instant::now() + Duration::from_secs(v["expires_in"].as_u64().unwrap_or(900));
1010×
    eprintln!(
102
        "Open {} and approve the code {}",
1030×
        ui::path(url),
1040×
        ui::subject(user_code)
105
    );
1060×
    eprintln!(
107
        "  {} {}",
1080×
        ui::note(),
1090×
        ui::dim("approve only a code you started yourself, just now")
110
    );
1110×
    let sp = ui::spinner("Waiting for approval (Ctrl-C to give up)…");
1120×
    let poll = serde_json::json!({ "device_code": device }).to_string();
113
    loop {
1140×
        std::thread::sleep(Duration::from_secs(interval));
1150×
        if Instant::now() > deadline {
1160×
            sp.clear();
1170×
            bail!("the code expired before it was approved; run oxy auth login again");
1180×
        }
1190×
        let (code, body) = http_json(
1200×
            "POST",
1210×
            &format!("{hub}/api/v1/device/token"),
1220×
            Some(&poll),
1230×
            options,
124
        )
1250×
        .map_err(|e| anyhow::anyhow!("{e}"))?;
1260×
        let v: Value = serde_json::from_str(&body).unwrap_or_default();
1270×
        if code == 200 {
1280×
            sp.clear();
1290×
            return v["access_token"]
1300×
                .as_str()
1310×
                .map(String::from)
1320×
                .context("no token in the hub's answer");
1330×
        }
1340×
        match v["error"].as_str() {
1350×
            Some("authorization_pending") => {}
1360×
            Some("slow_down") => interval += 5,
1370×
            Some("access_denied") => {
1380×
                sp.clear();
1390×
                bail!("the sign-in was denied on the hub");
140
            }
1410×
            Some("expired_token") => {
1420×
                sp.clear();
1430×
                bail!("the code expired before it was approved; run oxy auth login again");
144
            }
145
            _ => {
1460×
                sp.clear();
1470×
                bail!("signing in: HTTP {code}: {}", body.trim());
148
            }
149
        }
150
    }
1510×
}
152
1530×
fn where_stored(stored: &Stored) -> String {
1540×
    match stored {
1550×
        Stored::File(p) => format!("{} (yours only)", p.display()),
1560×
        Stored::Helper(h) => format!("credential helper {h}"),
157
    }
1580×
}
159
1600×
pub fn login(hub: Option<&str>, with_token: bool, ca: Option<PathBuf>) -> Result<i32> {
1610×
    let hub = resolve_hub(hub)?;
1620×
    if !token_allowed(&hub) {
1630×
        bail!("{hub} is plain http: a token would cross the network unencrypted; sign in to its https address");
1640×
    }
1650×
    let store = Credentials::user();
1660×
    let ca = ca
1670×
        .map(|c| std::fs::canonicalize(&c).unwrap_or(c))
1680×
        .or_else(|| store.get(&hub).ok().flatten().and_then(|e| e.ca))
1690×
        .or_else(|| std::env::var("OXY_CA").ok().map(PathBuf::from));
1700×
    let anonymous = RemoteOptions {
1710×
        token: None,
1720×
        ca: ca.clone(),
1730×
        ..RemoteOptions::default()
1740×
    };
1750×
    let token = if with_token {
1760×
        let mut line = String::new();
1770×
        std::io::stdin()
1780×
            .lock()
1790×
            .read_line(&mut line)
1800×
            .context("reading the token from standard input")?;
1810×
        let t = line.trim().to_string();
1820×
        if t.is_empty() {
1830×
            bail!("no token on standard input");
1840×
        }
1850×
        t
186
    } else {
1870×
        device_sign_in(&hub, &anonymous)?
188
    };
1890×
    let options = RemoteOptions {
1900×
        token: Some(token.clone()),
1910×
        ..anonymous
1920×
    };
1930×
    let account = match whoami(&hub, &options)? {
1940×
        Ok(a) => Some(a),
1950×
        Err(why) if with_token => bail!("{hub} did not accept the token ({why})"),
196
        // A token without user:read still serves repositories.
1970×
        Err(_) => None,
198
    };
1990×
    let stored = store
2000×
        .save(&hub, &token, account.as_deref(), ca.as_deref())
2010×
        .map_err(|e| anyhow::anyhow!("{e}"))?;
2020×
    let who = account
2030×
        .as_deref()
2040×
        .map(|a| format!(" as {}", ui::author(a)))
2050×
        .unwrap_or_default();
2060×
    println!("{} to {}{who}", ui::done("Signed in"), ui::path(&hub));
2070×
    println!(
208
        "  {} {}",
2090×
        ui::note(),
2100×
        ui::dim(format!("token in {}", where_stored(&stored)))
211
    );
2120×
    Ok(0)
2130×
}
214
215
/// The start of a token, enough to tell tokens apart and no more.
2160×
fn masked(token: &str) -> String {
2170×
    let shown: String = token.chars().take(10).collect();
2180×
    format!("{shown}…")
2190×
}
220
2210×
pub fn status() -> Result<i32> {
2220×
    let store = Credentials::user();
2230×
    let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
2240×
    if store.too_open() {
2250×
        println!(
2260×
            "{} {} {}",
2270×
            ui::caution(),
2280×
            ui::path(store.path().display()),
2290×
            ui::warn("can be read by other users, so oxy ignores it: run chmod 600 on it")
2300×
        );
2310×
    }
2320×
    if std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty()) {
2330×
        println!(
2340×
            "{} {}",
2350×
            ui::note(),
2360×
            ui::dim("OXY_TOKEN is set: it is used ahead of the tokens below, for every hub")
2370×
        );
2380×
    }
2390×
    if entries.is_empty() {
2400×
        println!(
241
            "{}",
2420×
            ui::dim("Not signed in to any hub: run oxy auth login HUB")
243
        );
2440×
        return Ok(0);
2450×
    }
2460×
    let helper = store.helper();
2470×
    let mut bad = false;
2480×
    for e in entries {
2490×
        let full = store.get(&e.hub).map_err(|e| anyhow::anyhow!("{e}"))?;
2500×
        let token = full.as_ref().and_then(|f| f.token.clone());
2510×
        println!("{}", ui::section(&e.hub));
2520×
        let Some(token) = token else {
2530×
            println!(
254
                "  {}",
2550×
                ui::warn("no token stored: run oxy auth login again")
256
            );
2570×
            bad = true;
2580×
            continue;
259
        };
2600×
        let options = RemoteOptions {
2610×
            token: Some(token.clone()),
2620×
            ca: e.ca.clone(),
2630×
            ..RemoteOptions::default()
2640×
        };
2650×
        let check = match whoami(&e.hub, &options) {
2660×
            Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")),
2670×
            Ok(Err(why)) if why == "HTTP 401" => {
2680×
                bad = true;
2690×
                ui::err("rejected by the hub: expired or revoked; run oxy auth login again")
270
            }
2710×
            Ok(Err(why)) => ui::warn(format!("not checked ({why})")),
2720×
            Err(e) => ui::warn(format!("not checked ({e})")),
273
        };
2740×
        println!(
275
            "{}",
2760×
            ui::kv("token", format!("{} {}", masked(&token), check))
277
        );
2780×
        if let Some(a) = &e.account {
2790×
            println!("{}", ui::kv("account", ui::author(a)));
2800×
        }
2810×
        let place = match &helper {
2820×
            Some(h) => format!("credential helper {h}"),
2830×
            None => store.path().display().to_string(),
284
        };
2850×
        println!("{}", ui::kv("stored", ui::dim(place)));
2860×
        if let Some(ca) = &e.ca {
2870×
            println!("{}", ui::kv("ca", ui::path(ca.display())));
2880×
        }
289
    }
2900×
    Ok(if bad { 1 } else { 0 })
2910×
}
292
2930×
pub fn logout(hub: Option<&str>) -> Result<i32> {
2940×
    let store = Credentials::user();
2950×
    let hub = match hub {
2960×
        Some(_) => resolve_hub(hub)?,
297
        None => {
2980×
            let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
2990×
            match (entries.len(), resolve_hub(None)) {
3000×
                (_, Ok(h)) => h,
3010×
                (1, Err(_)) => entries[0].hub.clone(),
3020×
                _ => bail!("name the hub to sign out of (oxy auth status lists them)"),
303
            }
304
        }
305
    };
3060×
    let Some(entry) = store.remove(&hub).map_err(|e| anyhow::anyhow!("{e}"))? else {
3070×
        println!("{}", ui::dim(format!("Not signed in to {hub}")));
3080×
        return Ok(0);
309
    };
310
    // Revoke the token on the hub too, so a copy left anywhere is useless.
3110×
    let revoked = match &entry.token {
3120×
        Some(t) => {
3130×
            let options = RemoteOptions {
3140×
                token: Some(t.clone()),
3150×
                ca: entry.ca.clone(),
3160×
                ..RemoteOptions::default()
3170×
            };
3180×
            matches!(
3190×
                http_json(
3200×
                    "DELETE",
3210×
                    &format!("{hub}/api/v1/user/token"),
3220×
                    None,
3230×
                    &options
3240×
                ),
325
                Ok((200 | 204, _))
326
            )
327
        }
3280×
        None => false,
329
    };
3300×
    println!("{} of {}", ui::done("Signed out"), ui::path(&hub));
3310×
    if revoked {
3320×
        println!(
3330×
            "  {} {}",
3340×
            ui::note(),
3350×
            ui::dim("the token was revoked on the hub")
3360×
        );
3370×
    } else {
3380×
        println!(
3390×
            "  {} {}",
3400×
            ui::caution(),
3410×
            ui::warn("the hub did not revoke the token: revoke it under Settings → Tokens")
3420×
        );
3430×
    }
3440×
    Ok(0)
3450×
}