OXYGENHUB

Coverage / src/cli/auth.rs 0.0%

0 of 322 lines run, 0 of 68 functions · run #427 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
/// `oxy auth grant`: keep an agent's grant token (oxygenhub#241) for
216
/// keyless signing, read from standard input.
2170×
pub fn grant(hub: Option<&str>) -> Result<i32> {
218
    use std::io::IsTerminal;
2190×
    let hub = resolve_hub(hub)?;
2200×
    if !token_allowed(&hub) {
2210×
        bail!("{hub} is plain http: a token would cross the network unencrypted; use its https address");
2220×
    }
2230×
    if std::io::stdin().is_terminal() {
2240×
        eprint!("Agent grant token for {hub}: ");
2250×
    }
2260×
    let mut line = String::new();
2270×
    std::io::stdin()
2280×
        .lock()
2290×
        .read_line(&mut line)
2300×
        .context("reading the token from standard input")?;
2310×
    let token = line.trim();
2320×
    if !token.starts_with("oxyagent_") {
2330×
        bail!("that is not an agent grant token (they start oxyagent_; `oh agent grant NAME` makes one)");
2340×
    }
2350×
    let stored = Credentials::user()
2360×
        .save_grant(&hub, token)
2370×
        .map_err(|e| anyhow::anyhow!("{e}"))?;
2380×
    println!(
239
        "{} for {}",
2400×
        ui::done("Kept the agent grant"),
2410×
        ui::path(&hub)
242
    );
2430×
    println!(
244
        "  {} {}",
2450×
        ui::note(),
2460×
        ui::dim(format!(
247
            "token in {}; sign with it: oxy config author.signer keyless",
2480×
            where_stored(&stored)
249
        ))
250
    );
2510×
    Ok(0)
2520×
}
253
254
/// The start of a token, enough to tell tokens apart and no more.
2550×
fn masked(token: &str) -> String {
2560×
    let shown: String = token.chars().take(10).collect();
2570×
    format!("{shown}…")
2580×
}
259
2600×
pub fn status() -> Result<i32> {
2610×
    let store = Credentials::user();
2620×
    let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
2630×
    if store.too_open() {
2640×
        println!(
2650×
            "{} {} {}",
2660×
            ui::caution(),
2670×
            ui::path(store.path().display()),
2680×
            ui::warn("can be read by other users, so oxy ignores it: run chmod 600 on it")
2690×
        );
2700×
    }
2710×
    if std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty()) {
2720×
        println!(
2730×
            "{} {}",
2740×
            ui::note(),
2750×
            ui::dim("OXY_TOKEN is set: it is used ahead of the tokens below, for every hub")
2760×
        );
2770×
    }
2780×
    if entries.is_empty() {
2790×
        println!(
280
            "{}",
2810×
            ui::dim("Not signed in to any hub: run oxy auth login HUB")
282
        );
2830×
        return Ok(0);
2840×
    }
2850×
    let helper = store.helper();
2860×
    let mut bad = false;
2870×
    for e in entries {
2880×
        let full = store.get(&e.hub).map_err(|e| anyhow::anyhow!("{e}"))?;
2890×
        let token = full.as_ref().and_then(|f| f.token.clone());
2900×
        println!("{}", ui::section(&e.hub));
2910×
        let Some(token) = token else {
2920×
            println!(
293
                "  {}",
2940×
                ui::warn("no token stored: run oxy auth login again")
295
            );
2960×
            bad = true;
2970×
            continue;
298
        };
2990×
        let options = RemoteOptions {
3000×
            token: Some(token.clone()),
3010×
            ca: e.ca.clone(),
3020×
            ..RemoteOptions::default()
3030×
        };
3040×
        let check = match whoami(&e.hub, &options) {
3050×
            Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")),
3060×
            Ok(Err(why)) if why == "HTTP 401" => {
3070×
                bad = true;
3080×
                ui::err("rejected by the hub: expired or revoked; run oxy auth login again")
309
            }
3100×
            Ok(Err(why)) => ui::warn(format!("not checked ({why})")),
3110×
            Err(e) => ui::warn(format!("not checked ({e})")),
312
        };
3130×
        println!(
314
            "{}",
3150×
            ui::kv("token", format!("{} {}", masked(&token), check))
316
        );
3170×
        if let Some(a) = &e.account {
3180×
            println!("{}", ui::kv("account", ui::author(a)));
3190×
        }
3200×
        let place = match &helper {
3210×
            Some(h) => format!("credential helper {h}"),
3220×
            None => store.path().display().to_string(),
323
        };
3240×
        println!("{}", ui::kv("stored", ui::dim(place)));
3250×
        if let Some(ca) = &e.ca {
3260×
            println!("{}", ui::kv("ca", ui::path(ca.display())));
3270×
        }
328
    }
3290×
    Ok(if bad { 1 } else { 0 })
3300×
}
331
3320×
pub fn logout(hub: Option<&str>) -> Result<i32> {
3330×
    let store = Credentials::user();
3340×
    let hub = match hub {
3350×
        Some(_) => resolve_hub(hub)?,
336
        None => {
3370×
            let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
3380×
            match (entries.len(), resolve_hub(None)) {
3390×
                (_, Ok(h)) => h,
3400×
                (1, Err(_)) => entries[0].hub.clone(),
3410×
                _ => bail!("name the hub to sign out of (oxy auth status lists them)"),
342
            }
343
        }
344
    };
3450×
    let Some(entry) = store.remove(&hub).map_err(|e| anyhow::anyhow!("{e}"))? else {
3460×
        println!("{}", ui::dim(format!("Not signed in to {hub}")));
3470×
        return Ok(0);
348
    };
349
    // Revoke the token on the hub too, so a copy left anywhere is useless.
3500×
    let revoked = match &entry.token {
3510×
        Some(t) => {
3520×
            let options = RemoteOptions {
3530×
                token: Some(t.clone()),
3540×
                ca: entry.ca.clone(),
3550×
                ..RemoteOptions::default()
3560×
            };
3570×
            matches!(
3580×
                http_json(
3590×
                    "DELETE",
3600×
                    &format!("{hub}/api/v1/user/token"),
3610×
                    None,
3620×
                    &options
3630×
                ),
364
                Ok((200 | 204, _))
365
            )
366
        }
3670×
        None => false,
368
    };
3690×
    println!("{} of {}", ui::done("Signed out"), ui::path(&hub));
3700×
    if revoked {
3710×
        println!(
3720×
            "  {} {}",
3730×
            ui::note(),
3740×
            ui::dim("the token was revoked on the hub")
3750×
        );
3760×
    } else {
3770×
        println!(
3780×
            "  {} {}",
3790×
            ui::caution(),
3800×
            ui::warn("the hub did not revoke the token: revoke it under Settings → Tokens")
3810×
        );
3820×
    }
3830×
    Ok(0)
3840×
}