OXYGENHUB
# c96354484bd9 oxygen / src / cli / auth.rs
12.8 KiB · Rust · last changed Agents sign keyless: author.signer = keyless (keyless step 5c) 18 days 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(),
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
        // A token without user:read still serves repositories.
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
c895bc52 Andy McCall ↶ 215
/// `oxy auth grant`: keep an agent's grant token (oxygenhub#241) for
216
/// keyless signing, read from standard input.
217
pub fn grant(hub: Option<&str>) -> Result<i32> {
218
    use std::io::IsTerminal;
219
    let hub = resolve_hub(hub)?;
220
    if !token_allowed(&hub) {
221
        bail!("{hub} is plain http: a token would cross the network unencrypted; use its https address");
222
    }
223
    if std::io::stdin().is_terminal() {
224
        eprint!("Agent grant token for {hub}: ");
225
    }
226
    let mut line = String::new();
227
    std::io::stdin()
228
        .lock()
229
        .read_line(&mut line)
230
        .context("reading the token from standard input")?;
231
    let token = line.trim();
232
    if !token.starts_with("oxyagent_") {
233
        bail!("that is not an agent grant token (they start oxyagent_; `oh agent grant NAME` makes one)");
234
    }
235
    let stored = Credentials::user()
236
        .save_grant(&hub, token)
237
        .map_err(|e| anyhow::anyhow!("{e}"))?;
238
    println!(
239
        "{} for {}",
240
        ui::done("Kept the agent grant"),
241
        ui::path(&hub)
242
    );
243
    println!(
244
        "  {} {}",
245
        ui::note(),
246
        ui::dim(format!(
247
            "token in {}; sign with it: oxy config author.signer keyless",
248
            where_stored(&stored)
249
        ))
250
    );
251
    Ok(0)
252
}
253
ff1f62fe Andy McCall ↶ 254
/// The start of a token, enough to tell tokens apart and no more.
255
fn masked(token: &str) -> String {
256
    let shown: String = token.chars().take(10).collect();
257
    format!("{shown}…")
258
}
259
260
pub fn status() -> Result<i32> {
261
    let store = Credentials::user();
262
    let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
263
    if store.too_open() {
264
        println!(
265
            "{} {} {}",
266
            ui::caution(),
267
            ui::path(store.path().display()),
268
            ui::warn("can be read by other users, so oxy ignores it: run chmod 600 on it")
269
        );
270
    }
271
    if std::env::var("OXY_TOKEN").is_ok_and(|t| !t.is_empty()) {
272
        println!(
273
            "{} {}",
274
            ui::note(),
275
            ui::dim("OXY_TOKEN is set: it is used ahead of the tokens below, for every hub")
276
        );
277
    }
278
    if entries.is_empty() {
279
        println!(
280
            "{}",
281
            ui::dim("Not signed in to any hub: run oxy auth login HUB")
282
        );
283
        return Ok(0);
284
    }
285
    let helper = store.helper();
286
    let mut bad = false;
287
    for e in entries {
288
        let full = store.get(&e.hub).map_err(|e| anyhow::anyhow!("{e}"))?;
289
        let token = full.as_ref().and_then(|f| f.token.clone());
290
        println!("{}", ui::section(&e.hub));
291
        let Some(token) = token else {
292
            println!(
293
                "  {}",
294
                ui::warn("no token stored: run oxy auth login again")
295
            );
296
            bad = true;
297
            continue;
298
        };
299
        let options = RemoteOptions {
300
            token: Some(token.clone()),
301
            ca: e.ca.clone(),
68a92484 Andy McCall ↶ 302
            ..RemoteOptions::default()
ff1f62fe Andy McCall ↶ 303
        };
304
        let check = match whoami(&e.hub, &options) {
305
            Ok(Ok(a)) => ui::ok(format!("valid, signed in as {a}")),
306
            Ok(Err(why)) if why == "HTTP 401" => {
307
                bad = true;
308
                ui::err("rejected by the hub: expired or revoked; run oxy auth login again")
309
            }
310
            Ok(Err(why)) => ui::warn(format!("not checked ({why})")),
311
            Err(e) => ui::warn(format!("not checked ({e})")),
312
        };
313
        println!(
314
            "{}",
315
            ui::kv("token", format!("{} {}", masked(&token), check))
316
        );
317
        if let Some(a) = &e.account {
318
            println!("{}", ui::kv("account", ui::author(a)));
319
        }
320
        let place = match &helper {
321
            Some(h) => format!("credential helper {h}"),
322
            None => store.path().display().to_string(),
323
        };
324
        println!("{}", ui::kv("stored", ui::dim(place)));
325
        if let Some(ca) = &e.ca {
326
            println!("{}", ui::kv("ca", ui::path(ca.display())));
327
        }
328
    }
329
    Ok(if bad { 1 } else { 0 })
330
}
331
332
pub fn logout(hub: Option<&str>) -> Result<i32> {
333
    let store = Credentials::user();
334
    let hub = match hub {
335
        Some(_) => resolve_hub(hub)?,
336
        None => {
337
            let entries = store.entries().map_err(|e| anyhow::anyhow!("{e}"))?;
338
            match (entries.len(), resolve_hub(None)) {
339
                (_, Ok(h)) => h,
340
                (1, Err(_)) => entries[0].hub.clone(),
341
                _ => bail!("name the hub to sign out of (oxy auth status lists them)"),
342
            }
343
        }
344
    };
345
    let Some(entry) = store.remove(&hub).map_err(|e| anyhow::anyhow!("{e}"))? else {
346
        println!("{}", ui::dim(format!("Not signed in to {hub}")));
347
        return Ok(0);
348
    };
349
    // Revoke the token on the hub too, so a copy left anywhere is useless.
350
    let revoked = match &entry.token {
351
        Some(t) => {
352
            let options = RemoteOptions {
353
                token: Some(t.clone()),
354
                ca: entry.ca.clone(),
68a92484 Andy McCall ↶ 355
                ..RemoteOptions::default()
ff1f62fe Andy McCall ↶ 356
            };
357
            matches!(
358
                http_json(
359
                    "DELETE",
360
                    &format!("{hub}/api/v1/user/token"),
361
                    None,
362
                    &options
363
                ),
364
                Ok((200 | 204, _))
365
            )
366
        }
367
        None => false,
368
    };
369
    println!("{} of {}", ui::done("Signed out"), ui::path(&hub));
370
    if revoked {
371
        println!(
372
            "  {} {}",
373
            ui::note(),
374
            ui::dim("the token was revoked on the hub")
375
        );
376
    } else {
377
        println!(
378
            "  {} {}",
379
            ui::caution(),
380
            ui::warn("the hub did not revoke the token: revoke it under Settings → Tokens")
381
        );
382
    }
383
    Ok(0)
384
}