OXYGENHUB
# 8b3c9b2ba502 oxygen / src / cli / auth.rs
11.7 KiB · Rust · last changed Strata: a repository kept on a store, as a remote 1 month ago source blame history raw
ff1f62fe Andy McCall ↶ 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).
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
/// The account a token signs in as, from the hub's `/api/v1/user`.
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
/// 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.
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(),
173
        repo_id: None,
9f9e72b9 Andy McCall ↶ 174
        cache_dir: None,
ff1f62fe Andy McCall ↶ 175
    };
176
    let token = if with_token {
177
        let mut line = String::new();
178
        std::io::stdin()
179
            .lock()
180
            .read_line(&mut line)
181
            .context("reading the token from standard input")?;
182
        let t = line.trim().to_string();
183
        if t.is_empty() {
184
            bail!("no token on standard input");
185
        }
186
        t
187
    } else {
188
        device_sign_in(&hub, &anonymous)?
189
    };
190
    let options = RemoteOptions {
191
        token: Some(token.clone()),
192
        ..anonymous
193
    };
194
    let account = match whoami(&hub, &options)? {
195
        Ok(a) => Some(a),
196
        Err(why) if with_token => bail!("{hub} did not accept the token ({why})"),
197
        // A token without user:read still serves repositories.
198
        Err(_) => None,
199
    };
200
    let stored = store
201
        .save(&hub, &token, account.as_deref(), ca.as_deref())
202
        .map_err(|e| anyhow::anyhow!("{e}"))?;
203
    let who = account
204
        .as_deref()
205
        .map(|a| format!(" as {}", ui::author(a)))
206
        .unwrap_or_default();
207
    println!("{} to {}{who}", ui::done("Signed in"), ui::path(&hub));
208
    println!(
209
        "  {} {}",
210
        ui::note(),
211
        ui::dim(format!("token in {}", where_stored(&stored)))
212
    );
213
    Ok(0)
214
}
215
216
/// The start of a token, enough to tell tokens apart and no more.
217
fn masked(token: &str) -> String {
218
    let shown: String = token.chars().take(10).collect();
219
    format!("{shown}…")
220
}
221
222
pub fn status() -> Result<i32> {
223
    let store = Credentials::user();
224
    let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
225
    if store.too_open() {
226
        println!(
227
            "{} {} {}",
228
            ui::caution(),
229
            ui::path(store.path().display()),
230
            ui::warn("can be read by other users, so oxy ignores it: run chmod 600 on it")
231
        );
232
    }
233
    if std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty()) {
234
        println!(
235
            "{} {}",
236
            ui::note(),
237
            ui::dim("OXY_TOKEN is set: it is used ahead of the tokens below, for every hub")
238
        );
239
    }
240
    if entries.is_empty() {
241
        println!(
242
            "{}",
243
            ui::dim("Not signed in to any hub: run oxy auth login HUB")
244
        );
245
        return Ok(0);
246
    }
247
    let helper = store.helper();
248
    let mut bad = false;
249
    for e in entries {
250
        let full = store.get(&e.hub).map_err(|e| anyhow::anyhow!("{e}"))?;
251
        let token = full.as_ref().and_then(|f| f.token.clone());
252
        println!("{}", ui::section(&e.hub));
253
        let Some(token) = token else {
254
            println!(
255
                "  {}",
256
                ui::warn("no token stored: run oxy auth login again")
257
            );
258
            bad = true;
259
            continue;
260
        };
261
        let options = RemoteOptions {
262
            token: Some(token.clone()),
263
            ca: e.ca.clone(),
264
            repo_id: None,
9f9e72b9 Andy McCall ↶ 265
            cache_dir: None,
ff1f62fe Andy McCall ↶ 266
        };
267
        let check = match whoami(&e.hub, &options) {
268
            Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")),
269
            Ok(Err(why)) if why == "HTTP 401" => {
270
                bad = true;
271
                ui::err("rejected by the hub: expired or revoked; run oxy auth login again")
272
            }
273
            Ok(Err(why)) => ui::warn(format!("not checked ({why})")),
274
            Err(e) => ui::warn(format!("not checked ({e})")),
275
        };
276
        println!(
277
            "{}",
278
            ui::kv("token", format!("{} {}", masked(&token), check))
279
        );
280
        if let Some(a) = &e.account {
281
            println!("{}", ui::kv("account", ui::author(a)));
282
        }
283
        let place = match &helper {
284
            Some(h) => format!("credential helper {h}"),
285
            None => store.path().display().to_string(),
286
        };
287
        println!("{}", ui::kv("stored", ui::dim(place)));
288
        if let Some(ca) = &e.ca {
289
            println!("{}", ui::kv("ca", ui::path(ca.display())));
290
        }
291
    }
292
    Ok(if bad { 1 } else { 0 })
293
}
294
295
pub fn logout(hub: Option<&str>) -> Result<i32> {
296
    let store = Credentials::user();
297
    let hub = match hub {
298
        Some(_) => resolve_hub(hub)?,
299
        None => {
300
            let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
301
            match (entries.len(), resolve_hub(None)) {
302
                (_, Ok(h)) => h,
303
                (1, Err(_)) => entries[0].hub.clone(),
304
                _ => bail!("name the hub to sign out of (oxy auth status lists them)"),
305
            }
306
        }
307
    };
308
    let Some(entry) = store.remove(&hub).map_err(|e| anyhow::anyhow!("{e}"))? else {
309
        println!("{}", ui::dim(format!("Not signed in to {hub}")));
310
        return Ok(0);
311
    };
312
    // Revoke the token on the hub too, so a copy left anywhere is useless.
313
    let revoked = match &entry.token {
314
        Some(t) => {
315
            let options = RemoteOptions {
316
                token: Some(t.clone()),
317
                ca: entry.ca.clone(),
318
                repo_id: None,
9f9e72b9 Andy McCall ↶ 319
                cache_dir: None,
ff1f62fe Andy McCall ↶ 320
            };
321
            matches!(
322
                http_json(
323
                    "DELETE",
324
                    &format!("{hub}/api/v1/user/token"),
325
                    None,
326
                    &options
327
                ),
328
                Ok((200 | 204, _))
329
            )
330
        }
331
        None => false,
332
    };
333
    println!("{} of {}", ui::done("Signed out"), ui::path(&hub));
334
    if revoked {
335
        println!(
336
            "  {} {}",
337
            ui::note(),
338
            ui::dim("the token was revoked on the hub")
339
        );
340
    } else {
341
        println!(
342
            "  {} {}",
343
            ui::caution(),
344
            ui::warn("the hub did not revoke the token: revoke it under Settings → Tokens")
345
        );
346
    }
347
    Ok(0)
348
}