OXYGENHUB

Sign in once per hub: oxy auth login, status and logout (closes #47) ff1f62fee654

oxy auth login HUB signs in through the hub's device sign-in (or reads a
token with --with-token) and keeps one token per hub in
~/.config/oxy/credentials.toml, written owner-only, or in the system
keyring through a Git credential helper (auth.helper). Clone, fetch, push
and subrepos find it by the URL's scheme, host and port, after
remote.NAME.token and OXY_TOKEN, and before an enclosing checkout's remote
on the same hub. Nothing secret is written into a checkout: a subrepo no
longer gets a copy of its parent's token.

A token goes over https, or plain http to a loopback address only, and a
credentials file others can read is ignored. oxy auth status checks each
token with its hub; oxy auth logout revokes it there too. Identity secret
keys are created owner-only rather than tightened after the write.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 13:29 UTC
Signature
✔ signed by Andy McCall
Hash
ff1f62fee65473fe0769680b297ec28fa60db0ca8d231f68e5c3b99406a59175
Depends on
28fc23d7706a 2b5ee371060e 3e5772ccf9da 4dcb8c0916b7 4dee277c327a 5b9c6ad45f6e 61e3bce7ea5b 648724d8e0de 7421309f5372 750251b21a96 75167672ebcd 7b9809e67c3d 83e0677b07fe 8fd78eb52ad2 94084087a876 982864068a59 a07f60a37fb2 b3c5a59a875a ba160f60219e ce14f0d68b15 d02e545dc265 d42369abf180 d82e44097323 ee06a6c557b7 f9c98deef326 fce44ae9ad29 ff0af1ee299d
Depended on by
0588de69a84d 569802ebae0e 642da2636de2 2452b4407ab7 ee130dd9eef2 d36ba2f79f95 e59512f2f075 c96354484bd9 c895bc52e4e0 e5f8920bc8d0 b582948f1912 68a924846379 5ad2b56544c5 ff1a64f81c1c 9f9e72b93e4e 14edbaaf5291
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
50 atoms, 2 tree operations, 14 files

Browse the tree at this change

added crates/oxy-core/src/credentials.rs +576 −0
1 +
//! Per-user credentials for hubs: one sign-in serves every repository on a
2 +
//! hub, and nothing secret is written into a checkout.
3 +
//!
4 +
//! The store is `credentials.toml` in the user's oxy directory (see
5 +
//! [`crate::repo::default_home`]), one section per hub:
6 +
//!
7 +
//! ```text
8 +
//! [https://oxygenhub.io]
9 +
//! account = "andymccall"
10 +
//! token = "oxyhub_…"
11 +
//! ```
12 +
//!
13 +
//! A hub is a scheme, host and port: a token is only ever sent to the hub
14 +
//! it was stored for. The file is written readable by its owner only, and
15 +
//! is ignored with a warning when anyone else can read it. With
16 +
//! `auth.helper` set in the user's `config.toml`, tokens go to that helper
17 +
//! instead (Git's credential-helper protocol, so `libsecret`,
18 +
//! `osxkeychain` and `manager` keep them in the system keyring) and the
19 +
//! file keeps only what is not secret.
20 +
//!
21 +
//! A token is sent over `https`, or over plain `http` to this machine only
22 +
//! ([`token_allowed`]); hub transports drop it on any redirect.
23 +
24 +
use crate::protocol::RemoteOptions;
25 +
use std::collections::{BTreeMap, HashMap};
26 +
use std::io::Write;
27 +
use std::path::{Path, PathBuf};
28 +
use std::process::{Command, Stdio};
29 +
use std::sync::{Mutex, OnceLock};
30 +
31 +
/// The user name oxy's tokens are stored under in a credential helper, so
32 +
/// they stay apart from Git credentials for the same host.
33 +
const HELPER_USER: &str = "oxy-token";
34 +
35 +
/// The hub of a URL: `scheme://host[:port]`, lower case, for `http` and
36 +
/// `https` URLs only.
37 +
pub fn hub_of(url: &str) -> Option<String> {
38 +
    let (scheme, rest) = url.split_once("://")?;
39 +
    let scheme = scheme.to_ascii_lowercase();
40 +
    if scheme != "http" && scheme != "https" {
41 +
        return None;
42 +
    }
43 +
    let host = rest.split(['/', '?', '#']).next()?;
44 +
    // Never key a hub on credentials embedded in the URL.
45 +
    let host = host.rsplit('@').next()?;
46 +
    if host.is_empty() {
47 +
        return None;
48 +
    }
49 +
    Some(format!("{scheme}://{}", host.to_ascii_lowercase()))
50 +
}
51 +
52 +
/// Whether a token may be sent to `url`: always over `https`, and over
53 +
/// plain `http` only to this machine (a hub under test, or a runner's own).
54 +
pub fn token_allowed(url: &str) -> bool {
55 +
    let Some(hub) = hub_of(url) else {
56 +
        return true;
57 +
    };
58 +
    match hub.split_once("://") {
59 +
        Some(("https", _)) => true,
60 +
        Some((_, host)) => {
61 +
            let name = match host.strip_prefix('[') {
62 +
                Some(v6) => v6.split(']').next().unwrap_or(""),
63 +
                None => host.rsplit_once(':').map_or(host, |(h, _)| h),
64 +
            };
65 +
            name == "localhost"
66 +
                || name == "::1"
67 +
                || name
68 +
                    .parse::<std::net::Ipv4Addr>()
69 +
                    .is_ok_and(|a| a.is_loopback())
70 +
        }
71 +
        None => false,
72 +
    }
73 +
}
74 +
75 +
/// What the store holds for one hub.
76 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
77 +
pub struct HubEntry {
78 +
    /// `scheme://host[:port]`.
79 +
    pub hub: String,
80 +
    /// The account the token signs in as, recorded at sign-in.
81 +
    pub account: Option<String>,
82 +
    /// The token, when it is in the file (not with a helper).
83 +
    pub token: Option<String>,
84 +
    /// Extra roots to trust for this hub (a self-signed hub).
85 +
    pub ca: Option<PathBuf>,
86 +
}
87 +
88 +
/// Where a saved token went.
89 +
#[derive(Clone, Debug, PartialEq, Eq)]
90 +
pub enum Stored {
91 +
    /// The credentials file, owner-only.
92 +
    File(PathBuf),
93 +
    /// A credential helper, by its `auth.helper` value.
94 +
    Helper(String),
95 +
}
96 +
97 +
/// The credential store of one oxy home directory.
98 +
#[derive(Clone, Debug)]
99 +
pub struct Credentials {
100 +
    home: PathBuf,
101 +
}
102 +
103 +
impl Credentials {
104 +
    /// The store in `home` (an oxy home directory).
105 +
    pub fn at(home: impl Into<PathBuf>) -> Credentials {
106 +
        Credentials { home: home.into() }
107 +
    }
108 +
109 +
    /// The store in the user's oxy home directory.
110 +
    pub fn user() -> Credentials {
111 +
        Credentials::at(crate::repo::default_home())
112 +
    }
113 +
114 +
    /// `credentials.toml` in the home directory.
115 +
    pub fn path(&self) -> PathBuf {
116 +
        self.home.join("credentials.toml")
117 +
    }
118 +
119 +
    /// The configured credential helper (`auth.helper` in the user file).
120 +
    pub fn helper(&self) -> Option<String> {
121 +
        crate::repo::read_config_file(&self.home.join("config.toml"))
122 +
            .ok()?
123 +
            .remove("auth.helper")
124 +
            .filter(|h| !h.trim().is_empty())
125 +
    }
126 +
127 +
    /// Every hub in the file, tokens as stored there (none with a helper).
128 +
    pub fn entries(&self) -> Result<Vec<HubEntry>, String> {
129 +
        Ok(self.read()?.into_values().collect())
130 +
    }
131 +
132 +
    /// Whether the file can be read by anyone but its owner, in which case
133 +
    /// lookups ignore it.
134 +
    pub fn too_open(&self) -> bool {
135 +
        #[cfg(unix)]
136 +
        {
137 +
            use std::os::unix::fs::PermissionsExt;
138 +
            if let Ok(m) = std::fs::metadata(self.path()) {
139 +
                return m.permissions().mode() & 0o077 != 0;
140 +
            }
141 +
        }
142 +
        false
143 +
    }
144 +
145 +
    /// The entry for `hub`, its token fetched from the helper if one is set.
146 +
    pub fn get(&self, hub: &str) -> Result<Option<HubEntry>, String> {
147 +
        let entry = self.read()?.remove(hub);
148 +
        let helper = self.helper();
149 +
        let mut entry = match (entry, &helper) {
150 +
            (Some(e), _) => e,
151 +
            // A helper may hold a token stored before the file was cleared.
152 +
            (None, Some(_)) => HubEntry {
153 +
                hub: hub.to_string(),
154 +
                ..HubEntry::default()
155 +
            },
156 +
            (None, None) => return Ok(None),
157 +
        };
158 +
        if entry.token.is_none() {
159 +
            if let Some(h) = &helper {
160 +
                entry.token = run_helper(h, "get", hub, None)?;
161 +
            }
162 +
        }
163 +
        if entry.token.is_none() && entry.account.is_none() && entry.ca.is_none() {
164 +
            return Ok(None);
165 +
        }
166 +
        Ok(Some(entry))
167 +
    }
168 +
169 +
    /// Save a token (and what is known about it) for `hub`.
170 +
    pub fn save(
171 +
        &self,
172 +
        hub: &str,
173 +
        token: &str,
174 +
        account: Option<&str>,
175 +
        ca: Option<&Path>,
176 +
    ) -> Result<Stored, String> {
177 +
        let mut all = self.read()?;
178 +
        let helper = self.helper();
179 +
        let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry {
180 +
            hub: hub.to_string(),
181 +
            ..HubEntry::default()
182 +
        });
183 +
        entry.account = account.map(String::from).or(entry.account.take());
184 +
        if let Some(ca) = ca {
185 +
            entry.ca = Some(ca.to_path_buf());
186 +
        }
187 +
        let stored = match &helper {
188 +
            Some(h) => {
189 +
                run_helper(h, "store", hub, Some(token))?;
190 +
                entry.token = None;
191 +
                Stored::Helper(h.clone())
192 +
            }
193 +
            None => {
194 +
                entry.token = Some(token.to_string());
195 +
                Stored::File(self.path())
196 +
            }
197 +
        };
198 +
        self.write(&all)?;
199 +
        forget_cached();
200 +
        Ok(stored)
201 +
    }
202 +
203 +
    /// Remove `hub` from the file and the helper; returns what it held,
204 +
    /// with the token, so the caller can revoke it.
205 +
    pub fn remove(&self, hub: &str) -> Result<Option<HubEntry>, String> {
206 +
        let found = self.get(hub)?;
207 +
        let mut all = self.read()?;
208 +
        if all.remove(hub).is_some() {
209 +
            self.write(&all)?;
210 +
        }
211 +
        if let Some(h) = self.helper() {
212 +
            run_helper(&h, "erase", hub, None)?;
213 +
        }
214 +
        forget_cached();
215 +
        Ok(found)
216 +
    }
217 +
218 +
    fn read(&self) -> Result<BTreeMap<String, HubEntry>, String> {
219 +
        let path = self.path();
220 +
        let text = match std::fs::read_to_string(&path) {
221 +
            Ok(t) => t,
222 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
223 +
            Err(e) => return Err(format!("reading {}: {e}", path.display())),
224 +
        };
225 +
        let mut out = BTreeMap::new();
226 +
        let mut current: Option<HubEntry> = None;
227 +
        for (n, line) in text.lines().enumerate() {
228 +
            let line = line.trim();
229 +
            if line.is_empty() || line.starts_with('#') {
230 +
                continue;
231 +
            }
232 +
            if let Some(hub) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
233 +
                if let Some(e) = current.take() {
234 +
                    out.insert(e.hub.clone(), e);
235 +
                }
236 +
                current = Some(HubEntry {
237 +
                    hub: hub.trim().to_string(),
238 +
                    ..HubEntry::default()
239 +
                });
240 +
                continue;
241 +
            }
242 +
            let bad = || {
243 +
                format!(
244 +
                    "{} line {}: expected key = \"value\"",
245 +
                    path.display(),
246 +
                    n + 1
247 +
                )
248 +
            };
249 +
            let (k, v) = line.split_once('=').ok_or_else(bad)?;
250 +
            let v = unquote(v.trim()).ok_or_else(bad)?;
251 +
            let Some(e) = current.as_mut() else {
252 +
                return Err(format!(
253 +
                    "{} line {}: a key before any [hub] section",
254 +
                    path.display(),
255 +
                    n + 1
256 +
                ));
257 +
            };
258 +
            match k.trim() {
259 +
                "account" => e.account = Some(v),
260 +
                "token" => e.token = Some(v),
261 +
                "ca" => e.ca = Some(PathBuf::from(v)),
262 +
                // Unknown keys are kept out of lookups, not fatal.
263 +
                _ => {}
264 +
            }
265 +
        }
266 +
        if let Some(e) = current {
267 +
            out.insert(e.hub.clone(), e);
268 +
        }
269 +
        Ok(out)
270 +
    }
271 +
272 +
    fn write(&self, all: &BTreeMap<String, HubEntry>) -> Result<(), String> {
273 +
        let mut text =
274 +
            String::from("# oxy credentials: a token for each hub. Keep this file private.\n");
275 +
        for e in all.values() {
276 +
            text.push_str(&format!("\n[{}]\n", e.hub));
277 +
            if let Some(a) = &e.account {
278 +
                text.push_str(&format!("account = {}\n", quote(a)));
279 +
            }
280 +
            if let Some(t) = &e.token {
281 +
                text.push_str(&format!("token = {}\n", quote(t)));
282 +
            }
283 +
            if let Some(c) = &e.ca {
284 +
                text.push_str(&format!("ca = {}\n", quote(&c.to_string_lossy())));
285 +
            }
286 +
        }
287 +
        let path = self.path();
288 +
        std::fs::create_dir_all(&self.home)
289 +
            .map_err(|e| format!("creating {}: {e}", self.home.display()))?;
290 +
        crate::durable::write_atomic_private(&path, text.as_bytes())
291 +
            .map_err(|e| format!("writing {}: {e}", path.display()))
292 +
    }
293 +
294 +
    /// The stored token and CA for `url`'s hub, if any: memoized, so a
295 +
    /// helper runs once per hub in a process. A file anyone else can read
296 +
    /// is ignored, with a warning.
297 +
    pub fn lookup(&self, url: &str) -> (Option<String>, Option<PathBuf>) {
298 +
        let Some(hub) = hub_of(url) else {
299 +
            return (None, None);
300 +
        };
301 +
        let key = (self.home.clone(), hub.clone());
302 +
        if let Some(hit) = cache().lock().unwrap().get(&key) {
303 +
            return hit.clone();
304 +
        }
305 +
        let found = if self.too_open() {
306 +
            warn_once(&format!(
307 +
                "warning: {} can be read by other users, so oxy ignores it; run chmod 600 on it",
308 +
                self.path().display()
309 +
            ));
310 +
            (None, None)
311 +
        } else {
312 +
            match self.get(&hub) {
313 +
                Ok(Some(e)) => (e.token, e.ca),
314 +
                Ok(None) => (None, None),
315 +
                Err(e) => {
316 +
                    warn_once(&format!("warning: credentials for {hub}: {e}"));
317 +
                    (None, None)
318 +
                }
319 +
            }
320 +
        };
321 +
        cache().lock().unwrap().insert(key, found.clone());
322 +
        found
323 +
    }
324 +
325 +
    /// Options for reaching `url` without a configured remote: `$OXY_TOKEN`
326 +
    /// and `$OXY_CA`, else what this store holds for its hub.
327 +
    pub fn options_for(&self, url: &str) -> RemoteOptions {
328 +
        let env_token = std::env::var("OXY_TOKEN").ok().filter(|t| !t.is_empty());
329 +
        let env_ca = std::env::var("OXY_CA").ok().map(PathBuf::from);
330 +
        let (token, ca) = match (&env_token, &env_ca) {
331 +
            (Some(_), Some(_)) => (None, None),
332 +
            _ => self.lookup(url),
333 +
        };
334 +
        RemoteOptions {
335 +
            token: env_token.or(token),
336 +
            ca: env_ca.or(ca),
337 +
            repo_id: None,
338 +
        }
339 +
    }
340 +
}
341 +
342 +
type Cache = Mutex<HashMap<(PathBuf, String), (Option<String>, Option<PathBuf>)>>;
343 +
344 +
fn cache() -> &'static Cache {
345 +
    static CACHE: OnceLock<Cache> = OnceLock::new();
346 +
    CACHE.get_or_init(Default::default)
347 +
}
348 +
349 +
fn forget_cached() {
350 +
    cache().lock().unwrap().clear();
351 +
}
352 +
353 +
fn warn_once(message: &str) {
354 +
    static SEEN: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
355 +
    if SEEN
356 +
        .get_or_init(Default::default)
357 +
        .lock()
358 +
        .unwrap()
359 +
        .insert(message.to_string())
360 +
    {
361 +
        eprintln!("{message}");
362 +
    }
363 +
}
364 +
365 +
fn quote(v: &str) -> String {
366 +
    let mut out = String::from('"');
367 +
    for c in v.chars() {
368 +
        match c {
369 +
            '"' => out.push_str("\\\""),
370 +
            '\\' => out.push_str("\\\\"),
371 +
            '\n' => out.push_str("\\n"),
372 +
            c => out.push(c),
373 +
        }
374 +
    }
375 +
    out.push('"');
376 +
    out
377 +
}
378 +
379 +
fn unquote(v: &str) -> Option<String> {
380 +
    let inner = v.strip_prefix('"')?.strip_suffix('"')?;
381 +
    let mut out = String::new();
382 +
    let mut chars = inner.chars();
383 +
    while let Some(c) = chars.next() {
384 +
        if c == '\\' {
385 +
            match chars.next()? {
386 +
                'n' => out.push('\n'),
387 +
                c => out.push(c),
388 +
            }
389 +
        } else {
390 +
            out.push(c);
391 +
        }
392 +
    }
393 +
    Some(out)
394 +
}
395 +
396 +
/// Run a credential helper (Git's protocol): `get`, `store` or `erase` for
397 +
/// `hub`. `get` returns the password the helper answered with.
398 +
///
399 +
/// `auth.helper` is a helper name (run as `git credential-NAME`), an
400 +
/// absolute path, or a shell command after `!`, as in Git.
401 +
fn run_helper(
402 +
    helper: &str,
403 +
    action: &str,
404 +
    hub: &str,
405 +
    token: Option<&str>,
406 +
) -> Result<Option<String>, String> {
407 +
    let (protocol, host) = hub
408 +
        .split_once("://")
409 +
        .ok_or_else(|| format!("{hub} is not a hub URL"))?;
410 +
    let mut cmd = if let Some(shell) = helper.strip_prefix('!') {
411 +
        let mut c = Command::new("sh");
412 +
        c.arg("-c").arg(format!("{shell} {action}"));
413 +
        c
414 +
    } else if Path::new(helper).is_absolute() {
415 +
        let mut c = Command::new(helper);
416 +
        c.arg(action);
417 +
        c
418 +
    } else {
419 +
        let mut c = Command::new("git");
420 +
        c.arg(format!("credential-{helper}")).arg(action);
421 +
        c
422 +
    };
423 +
    let mut input = format!("protocol={protocol}\nhost={host}\nusername={HELPER_USER}\n");
424 +
    if let Some(t) = token {
425 +
        input.push_str(&format!("password={t}\n"));
426 +
    }
427 +
    input.push('\n');
428 +
    let mut child = cmd
429 +
        .stdin(Stdio::piped())
430 +
        .stdout(Stdio::piped())
431 +
        .stderr(Stdio::inherit())
432 +
        .spawn()
433 +
        .map_err(|e| format!("running credential helper {helper:?}: {e}"))?;
434 +
    child
435 +
        .stdin
436 +
        .take()
437 +
        .expect("piped")
438 +
        .write_all(input.as_bytes())
439 +
        .map_err(|e| format!("credential helper {helper:?}: {e}"))?;
440 +
    let out = child
441 +
        .wait_with_output()
442 +
        .map_err(|e| format!("credential helper {helper:?}: {e}"))?;
443 +
    if !out.status.success() {
444 +
        return Err(format!("credential helper {helper:?} {action} failed"));
445 +
    }
446 +
    if action != "get" {
447 +
        return Ok(None);
448 +
    }
449 +
    Ok(String::from_utf8_lossy(&out.stdout)
450 +
        .lines()
451 +
        .find_map(|l| l.strip_prefix("password="))
452 +
        .map(String::from)
453 +
        .filter(|p| !p.is_empty()))
454 +
}
455 +
456 +
#[cfg(test)]
457 +
mod tests {
458 +
    use super::*;
459 +
460 +
    #[test]
461 +
    fn hubs_are_scheme_host_and_port() {
462 +
        assert_eq!(
463 +
            hub_of("https://OxygenHub.io/doomsdayone/metalgear").as_deref(),
464 +
            Some("https://oxygenhub.io")
465 +
        );
466 +
        assert_eq!(
467 +
            hub_of("http://127.0.0.1:7300/a/b").as_deref(),
468 +
            Some("http://127.0.0.1:7300")
469 +
        );
470 +
        assert_eq!(
471 +
            hub_of("https://user:[email protected]/x").as_deref(),
472 +
            Some("https://hub.example")
473 +
        );
474 +
        assert_eq!(hub_of("/srv/repo"), None);
475 +
        assert_eq!(hub_of("file:///srv/repo"), None);
476 +
    }
477 +
478 +
    #[test]
479 +
    fn tokens_go_over_https_or_to_this_machine() {
480 +
        assert!(token_allowed("https://oxygenhub.io/a/b"));
481 +
        assert!(token_allowed("http://127.0.0.1:7300/a/b"));
482 +
        assert!(token_allowed("http://localhost/a"));
483 +
        assert!(token_allowed("http://[::1]:8080/a"));
484 +
        assert!(!token_allowed("http://oxygenhub.io/a/b"));
485 +
        assert!(!token_allowed("http://10.0.0.1/a"));
486 +
        assert!(!token_allowed("http://127.0.0.1.evil.example/a"));
487 +
    }
488 +
489 +
    #[test]
490 +
    fn save_get_and_remove_round_trip_privately() {
491 +
        let dir = tempfile::tempdir().unwrap();
492 +
        let store = Credentials::at(dir.path());
493 +
        assert_eq!(store.get("https://hub.example").unwrap(), None);
494 +
        let stored = store
495 +
            .save("https://hub.example", "tok\"en", Some("andy"), None)
496 +
            .unwrap();
497 +
        assert_eq!(stored, Stored::File(store.path()));
498 +
        store
499 +
            .save(
500 +
                "http://127.0.0.1:7300",
501 +
                "local",
502 +
                None,
503 +
                Some(Path::new("/ca.pem")),
504 +
            )
505 +
            .unwrap();
506 +
        let e = store.get("https://hub.example").unwrap().unwrap();
507 +
        assert_eq!(e.token.as_deref(), Some("tok\"en"));
508 +
        assert_eq!(e.account.as_deref(), Some("andy"));
509 +
        assert!(!store.too_open());
510 +
        assert_eq!(
511 +
            store.lookup("https://hub.example/o/r"),
512 +
            (Some("tok\"en".into()), None)
513 +
        );
514 +
        // Another host, scheme or port gets nothing.
515 +
        assert_eq!(store.lookup("https://other.example/o/r"), (None, None));
516 +
        assert_eq!(store.lookup("http://hub.example/o/r"), (None, None));
517 +
        assert_eq!(store.lookup("https://hub.example:8443/o/r"), (None, None));
518 +
        let gone = store.remove("https://hub.example").unwrap().unwrap();
519 +
        assert_eq!(gone.token.as_deref(), Some("tok\"en"));
520 +
        assert_eq!(store.lookup("https://hub.example/o/r"), (None, None));
521 +
        assert_eq!(store.entries().unwrap().len(), 1);
522 +
    }
523 +
524 +
    #[cfg(unix)]
525 +
    #[test]
526 +
    fn a_file_others_can_read_is_ignored() {
527 +
        use std::os::unix::fs::PermissionsExt;
528 +
        let dir = tempfile::tempdir().unwrap();
529 +
        let store = Credentials::at(dir.path());
530 +
        store.save("https://open.example", "t", None, None).unwrap();
531 +
        let mode = std::fs::metadata(store.path())
532 +
            .unwrap()
533 +
            .permissions()
534 +
            .mode();
535 +
        assert_eq!(mode & 0o777, 0o600);
536 +
        std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
537 +
        assert!(store.too_open());
538 +
        assert_eq!(store.lookup("https://open.example/o/r"), (None, None));
539 +
    }
540 +
541 +
    #[cfg(unix)]
542 +
    #[test]
543 +
    fn a_helper_keeps_the_token_out_of_the_file() {
544 +
        let dir = tempfile::tempdir().unwrap();
545 +
        let vault = dir.path().join("vault");
546 +
        // A helper that keeps one password in a file, as a keyring would.
547 +
        let helper = format!(
548 +
            "!f() {{ case $1 in get) [ -f {v} ] && echo password=$(cat {v});; store) sed -n 's/^password=//p' > {v};; erase) rm -f {v};; esac; }}; f",
549 +
            v = vault.display()
550 +
        );
551 +
        std::fs::write(
552 +
            dir.path().join("config.toml"),
553 +
            format!("auth.helper = \"{helper}\"\n"),
554 +
        )
555 +
        .unwrap();
556 +
        let store = Credentials::at(dir.path());
557 +
        assert!(store.helper().is_some());
558 +
        let stored = store
559 +
            .save("https://hub.example", "secret", Some("andy"), None)
560 +
            .unwrap();
561 +
        assert!(matches!(stored, Stored::Helper(_)));
562 +
        let text = std::fs::read_to_string(store.path()).unwrap();
563 +
        assert!(!text.contains("secret"), "{text}");
564 +
        assert_eq!(
565 +
            store
566 +
                .get("https://hub.example")
567 +
                .unwrap()
568 +
                .unwrap()
569 +
                .token
570 +
                .as_deref(),
571 +
            Some("secret")
572 +
        );
573 +
        store.remove("https://hub.example").unwrap();
574 +
        assert!(!vault.exists());
575 +
    }
576 +
}
added src/cli/auth.rs +345 −0
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,
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 +
215 +
/// The start of a token, enough to tell tokens apart and no more.
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(),
263 +
            repo_id: None,
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 +
    // Revoke the token on the hub too, so a copy left anywhere is useless.
311 +
    let revoked = match &entry.token {
312 +
        Some(t) => {
313 +
            let options = RemoteOptions {
314 +
                token: Some(t.clone()),
315 +
                ca: entry.ca.clone(),
316 +
                repo_id: None,
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 +
}
modified CHANGELOG.md +7 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Sign in once per hub: `oxy auth login`, `status` and `logout` (#47).** `oxy auth login https://oxygenhub.io` signs in through the browser, and every repository on that hub then clones, fetches and pushes with the token, subrepos included. Nothing secret goes into a checkout any more.
6 +
  - The token is kept in `~/.config/oxy/credentials.toml`, readable by you only, or in the system keyring through a Git credential helper (`oxy config --user auth.helper libsecret`).
7 +
  - `--with-token` reads a token from standard input, for CI and scripts. `oxy auth logout` also revokes the token on the hub.
8 +
  - A token goes over `https` only (plain `http` only to this machine), never to another host after a redirect. A credentials file that others can read is ignored.
9 +
  - Lookup order: `remote.<name>.token`, `OXY_TOKEN`, the store, then an enclosing checkout's remote on the same hub. Clones no longer copy a token into a subrepo's configuration.
10 +
  - Identity secret keys are now created owner-only, rather than tightened just after being written.
11 +
5 12
Measured on the Linux kernel's tree (95 943 files); `docs/benchmarks.md` has the tables.
6 13
7 14
- **Faster than Git on the first commit, a clean status, fsck, gc and the pack's size.**
…
28 unchanged lines
modified README.md +12 −4
…
41 unchanged lines
42 42
Output uses colour, glyphs, and relative times on a UTF-8 terminal, and
43 43
falls back to plain ASCII when piped, with `--plain`, or with `NO_COLOR` set.
44 44
45 -
Collaborating:
45 +
Collaborating through a hub such as OxygenHub: sign in once, in the
46 +
browser, and every repository on that hub clones, fetches and pushes with it.
46 47
47 48
```
49 +
oxy auth login https://oxygenhub.io
50 +
oxy clone https://oxygenhub.io/owner/project
51 +
oxy push
52 +
oxy pull
53 +
```
54 +
55 +
Or with your own server:
56 +
57 +
```
48 58
oxy serve --root /srv/oxy --bind 0.0.0.0:8890 --tls-cert cert.pem --tls-key key.pem --token SECRET
59 +
echo SECRET | oxy auth login https://host:8890 --with-token
49 60
oxy clone https://host:8890/project
50 -
oxy config remote.origin.token SECRET
51 -
oxy push
52 -
oxy pull
53 61
```
54 62
55 63
Trust policy, so a server accepts only changes from keys you have granted:
…
37 unchanged lines
modified crates/oxy-core/src/durable.rs +20 −1
…
17 unchanged lines
18 18
19 19
/// Replace `path` with `data`, atomically and durably.
20 20
pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> {
21 +
    write_atomic_mode(path, data, false)
22 +
}
23 +
24 +
/// [`write_atomic`] for a secret: on Unix the file is created readable by
25 +
/// its owner only, so the secret is never on disk with wider permissions.
26 +
pub fn write_atomic_private(path: &Path, data: &[u8]) -> io::Result<()> {
27 +
    write_atomic_mode(path, data, true)
28 +
}
29 +
30 +
fn write_atomic_mode(path: &Path, data: &[u8], private: bool) -> io::Result<()> {
21 31
    let dir = path.parent().unwrap_or(Path::new("."));
22 32
    let name = path
23 33
        .file_name()
…
4 unchanged lines
28 38
    let tmp = dir.join(format!(".{name}.tmp-{}", hex::encode(nonce)));
29 39
    step("create temporary file")?;
30 40
    let written = (|| {
31 -
        let mut f = fs::File::create(&tmp)?;
41 +
        let mut open = fs::OpenOptions::new();
42 +
        open.write(true).create_new(true);
43 +
        #[cfg(unix)]
44 +
        if private {
45 +
            use std::os::unix::fs::OpenOptionsExt;
46 +
            open.mode(0o600);
47 +
        }
48 +
        #[cfg(not(unix))]
49 +
        let _ = private;
50 +
        let mut f = open.open(&tmp)?;
32 51
        f.write_all(data)?;
33 52
        f.sync_all()?;
34 53
        step("rename into place")?;
…
129 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
5 unchanged lines
6 6
pub mod attributes;
7 7
pub mod cbor;
8 8
pub mod chunk;
9 +
pub mod credentials;
9 10
pub mod diff;
10 11
pub mod durable;
11 12
pub mod fasthash;
…
20 unchanged lines
modified crates/oxy-core/src/protocol.rs +47 −2
…
272 unchanged lines
273 273
274 274
impl HttpRemote {
275 275
    pub fn new(base: &str, options: &RemoteOptions) -> Result<HttpRemote> {
276 +
        refuse_plain_token(base, options)?;
276 277
        let agent = ureq::AgentBuilder::new()
277 278
            .tls_config(tls_client_config(options.ca.as_deref())?)
278 279
            .build();
…
42 unchanged lines
321 322
    }
322 323
}
323 324
325 +
/// Refuse to send a token where anyone on the way could read it: plain
326 +
/// `http` to another machine (see [`crate::credentials::token_allowed`]).
327 +
fn refuse_plain_token(url: &str, options: &RemoteOptions) -> Result<()> {
328 +
    if options.token.is_some() && !crate::credentials::token_allowed(url) {
329 +
        return Err(RepoError::Msg(format!(
330 +
            "{url}: refusing to send a token over plain http; use https"
331 +
        )));
332 +
    }
333 +
    Ok(())
334 +
}
335 +
324 336
/// What to do about a failed request, recognised from its message.
325 337
pub fn hint_for(message: &str) -> Option<&'static str> {
326 338
    let m = message.to_ascii_lowercase();
327 339
    let hint = if m.contains("http 401") {
328 -
        "the server did not accept the token: set one with `oxy config remote.<remote>.token TOKEN` (a personal access token; pushing needs repo:write)"
340 +
        "the hub needs you signed in, or did not accept the token: run `oxy auth login HUB` (pushing needs repo:write)"
329 341
    } else if m.contains("http 403") {
330 342
        "the token or account lacks permission: pushing needs a token with repo:write and write access to the repository"
331 343
    } else if m.contains("http 404") {
332 -
        "nothing at this URL: check the owner and repository name, or create the repository on the hub first"
344 +
        "nothing at this URL that you can see: check the owner and repository name; a private repository needs you signed in (`oxy auth login HUB`)"
333 345
    } else if m.contains("unknownissuer")
334 346
        || m.contains("unknown issuer")
335 347
        || m.contains("certificate")
…
20 unchanged lines
356 368
    match hint_for(&message) {
357 369
        Some(h) => format!("{message}\n  hint: {h}"),
358 370
        None => message,
371 +
    }
372 +
}
373 +
374 +
/// A JSON request to a hub API (`GET`, `POST` or `DELETE`), with the same
375 +
/// TLS trust and token rules as remotes. Returns the status and body, an
376 +
/// error status included; only transport failures are errors.
377 +
pub fn http_json(
378 +
    method: &str,
379 +
    url: &str,
380 +
    body: Option<&str>,
381 +
    options: &RemoteOptions,
382 +
) -> Result<(u16, String)> {
383 +
    refuse_plain_token(url, options)?;
384 +
    let agent = ureq::AgentBuilder::new()
385 +
        .tls_config(tls_client_config(options.ca.as_deref())?)
386 +
        .timeout(std::time::Duration::from_secs(60))
387 +
        .build();
388 +
    let mut req = agent.request(method, url).set("Accept", "application/json");
389 +
    if let Some(t) = &options.token {
390 +
        req = req.set("Authorization", &format!("Bearer {t}"));
391 +
    }
392 +
    let resp = match body {
393 +
        Some(b) => req.set("Content-Type", "application/json").send_string(b),
394 +
        None => req.call(),
395 +
    };
396 +
    match resp {
397 +
        Ok(r) => {
398 +
            let code = r.status();
399 +
            Ok((code, r.into_string().unwrap_or_default()))
400 +
        }
401 +
        Err(ureq::Error::Status(code, r)) => Ok((code, r.into_string().unwrap_or_default())),
402 +
        Err(e) => Err(RepoError::Msg(with_hint(e.to_string()))),
359 403
    }
360 404
}
361 405
362 406
/// An authenticated GET of any URL on a host, with the same TLS trust and
363 407
/// token as remotes use (for hub APIs: proposals). Returns the body.
364 408
pub fn http_get(url: &str, options: &RemoteOptions) -> Result<Vec<u8>> {
409 +
    refuse_plain_token(url, options)?;
365 410
    let agent = ureq::AgentBuilder::new()
366 411
        .tls_config(tls_client_config(options.ca.as_deref())?)
367 412
        .build();
…
760 unchanged lines
modified crates/oxy-core/src/repo/config.rs +2 −0
…
107 unchanged lines
108 108
                }
109 109
            }
110 110
            "workspace.bookmark" => self.bookmark = value.into(),
111 +
            // Read by the credential store from the user file.
112 +
            "auth.helper" => {}
111 113
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
112 114
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
113 115
            "filter.max-blob" => {
…
130 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +1 −6
…
64 unchanged lines
65 65
        let key = self.store.put_identity(id)?;
66 66
        fs::create_dir_all(&self.keys_dir)?;
67 67
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
68 -
        write_atomic(&path, &sk.encode(&key))?;
68 +
        crate::durable::write_atomic_private(&path, &sk.encode(&key))?;
69 69
        write_atomic(
70 70
            &self.keys_dir.join(format!("{}.identity", key.to_hex())),
71 71
            &id.encode(),
72 72
        )?;
73 -
        #[cfg(unix)]
74 -
        {
75 -
            use std::os::unix::fs::PermissionsExt;
76 -
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
77 -
        }
78 73
        Ok(key)
79 74
    }
80 75
…
470 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +81 −48
…
873 unchanged lines
874 874
        })
875 875
    }
876 876
877 -
    /// Credentials and trust for a remote: `remote.<name>.token` (else
878 -
    /// `$OXY_TOKEN`) and `remote.<name>.ca` (else `$OXY_CA`).
879 877
    /// Refuse a remote that is another repository. An **unbound** remote
880 878
    /// (id all zero: a hosted repository nobody has pushed to) is accepted
881 879
    /// for pushes, which bind it to this repository's id (spec §16.1).
…
20 unchanged lines
902 900
        ))
903 901
    }
904 902
903 +
    /// Credentials and trust for a remote: `remote.<name>.token` and
904 +
    /// `remote.<name>.ca`, else the defaults for its URL
905 +
    /// ([`Repo::fill_credentials`]).
905 906
    pub(crate) fn remote_options(&self, name: &str) -> RemoteOptions {
906 -
        RemoteOptions {
907 +
        let url = self.config.remotes.get(name).cloned().unwrap_or_default();
908 +
        let mut options = RemoteOptions {
907 909
            repo_id: self.id().ok(),
908 -
            token: self
909 -
                .config
910 -
                .remote_tokens
911 -
                .get(name)
912 -
                .cloned()
913 -
                .or_else(|| std::env::var("OXY_TOKEN").ok()),
914 -
            ca: self
915 -
                .config
916 -
                .remote_cas
917 -
                .get(name)
918 -
                .map(PathBuf::from)
919 -
                .or_else(|| std::env::var("OXY_CA").ok().map(PathBuf::from)),
910 +
            token: self.config.remote_tokens.get(name).cloned(),
911 +
            ca: self.config.remote_cas.get(name).map(PathBuf::from),
912 +
        };
913 +
        self.fill_credentials(&url, &mut options);
914 +
        options
915 +
    }
916 +
917 +
    /// The user's credential store (`credentials.toml` beside the user
918 +
    /// configuration file).
919 +
    pub fn credentials(&self) -> crate::credentials::Credentials {
920 +
        crate::credentials::Credentials::at(
921 +
            self.user_config
922 +
                .parent()
923 +
                .map(Path::to_path_buf)
924 +
                .unwrap_or_else(default_home),
925 +
        )
926 +
    }
927 +
928 +
    /// Fill in what `options` lacks for `url`: `$OXY_TOKEN` and `$OXY_CA`,
929 +
    /// then the user's credential store for its hub, then a remote on the
930 +
    /// same hub in an enclosing checkout (a subrepo borrows its parent's).
931 +
    fn fill_credentials(&self, url: &str, options: &mut RemoteOptions) {
932 +
        if options.token.is_some() && options.ca.is_some() {
933 +
            return;
934 +
        }
935 +
        let stored = self.credentials().options_for(url);
936 +
        options.token = options.token.take().or(stored.token);
937 +
        options.ca = options.ca.take().or(stored.ca);
938 +
        if options.token.is_some() && options.ca.is_some() {
939 +
            return;
920 940
        }
941 +
        let Some(hub) = crate::credentials::hub_of(url) else {
942 +
            return;
943 +
        };
944 +
        let mut at = self.root.parent();
945 +
        while let Some(dir) = at {
946 +
            let Ok(cfg) = read_config_file(&dir.join(OXY_DIR).join("config")) else {
947 +
                break;
948 +
            };
949 +
            for (key, remote_url) in &cfg {
950 +
                let Some(name) = key
951 +
                    .strip_prefix("remote.")
952 +
                    .and_then(|k| k.strip_suffix(".url"))
953 +
                else {
954 +
                    continue;
955 +
                };
956 +
                if crate::credentials::hub_of(remote_url).as_deref() != Some(hub.as_str()) {
957 +
                    continue;
958 +
                }
959 +
                if options.token.is_none() {
960 +
                    options.token = cfg.get(&format!("remote.{name}.token")).cloned();
961 +
                }
962 +
                if options.ca.is_none() {
963 +
                    options.ca = cfg.get(&format!("remote.{name}.ca")).map(PathBuf::from);
964 +
                }
965 +
            }
966 +
            if options.token.is_some() && options.ca.is_some() {
967 +
                return;
968 +
            }
969 +
            at = dir.parent();
970 +
        }
921 971
    }
922 972
923 973
    /// Push seal attestations on their own (hub spec §7.4): a bundle with
…
27 unchanged lines
951 1001
    }
952 1002
953 1003
    /// Open a remote by name or, failing that, by URL with the default
954 -
    /// credentials (`OXY_TOKEN`, `OXY_CA`).
1004 +
    /// credentials (see [`Repo::credentials_for`]).
955 1005
    pub fn open_remote_at(&self, url: &str) -> Result<(String, Box<dyn crate::protocol::Remote>)> {
956 1006
        if let Some(name) = self.remote_for_url(url) {
957 1007
            return Ok((name.clone(), self.open_named_remote(&name)?));
958 1008
        }
959 1009
        let options = RemoteOptions {
960 -
            token: std::env::var("OXY_TOKEN").ok(),
961 -
            ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
962 1010
            repo_id: self.id().ok(),
1011 +
            ..self.credentials_for(url)
963 1012
        };
964 1013
        Ok((url.to_string(), open_remote(url, &options)?))
965 1014
    }
…
4 unchanged lines
970 1019
        match self.remote_for_url(url) {
971 1020
            Some(name) => self.remote_options(&name),
972 1021
            None => RemoteOptions {
973 -
                token: std::env::var("OXY_TOKEN").ok(),
974 -
                ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
975 1022
                repo_id: self.id().ok(),
1023 +
                ..self.credentials_for(url)
976 1024
            },
977 1025
        }
978 1026
    }
…
701 unchanged lines
1680 1728
        sparse: &[String],
1681 1729
        depth: Option<u32>,
1682 1730
    ) -> Result<Repo> {
1683 -
        let options = RemoteOptions {
1684 -
            token: std::env::var("OXY_TOKEN").ok(),
1685 -
            ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
1686 -
            repo_id: None,
1687 -
        };
1688 -
        Self::clone_using(url, dest, max_blob, sparse, depth, &options, false)
1731 +
        let options = crate::credentials::Credentials::user().options_for(url);
1732 +
        Self::clone_using(url, dest, max_blob, sparse, depth, &options)
1689 1733
    }
1690 1734
1691 -
    /// Clone a subrepo with credentials the parent has for the same hub
1692 -
    /// (see [`Repo::credentials_for`]), recording them on its `origin` so it
1693 -
    /// fetches and pushes with them after.
1735 +
    /// Clone a subrepo with the credentials the parent would use for its
1736 +
    /// hub (see [`Repo::credentials_for`]). Nothing is recorded in the
1737 +
    /// subrepo: it finds the same ones again through its parent.
1694 1738
    pub(crate) fn clone_as_subrepo(&self, url: &str, dest: &Path) -> Result<Repo> {
1695 -
        // Only credentials from a remote are recorded; the environment's
1696 -
        // stay in the environment.
1697 -
        let borrowed = self.remote_on_same_hub(url).is_some();
1698 1739
        let options = self.credentials_for(url);
1699 -
        Self::clone_using(url, dest, None, &[], None, &options, borrowed)
1740 +
        Self::clone_using(url, dest, None, &[], None, &options)
1700 1741
    }
1701 1742
1702 1743
    /// The name of a remote of this repository on the same hub (scheme,
…
18 unchanged lines
1721 1762
1722 1763
    /// The token and CA to reach `url` with: those of a remote of this
1723 1764
    /// repository on the same hub (scheme, host and port), else the
1724 -
    /// environment's (`OXY_TOKEN`, `OXY_CA`), as a clone would use them.
1765 +
    /// environment's (`OXY_TOKEN`, `OXY_CA`), the user's credential store
1766 +
    /// and enclosing checkouts', as a clone would use them.
1725 1767
    pub(crate) fn credentials_for(&self, url: &str) -> RemoteOptions {
1726 1768
        match self.remote_on_same_hub(url) {
1727 1769
            Some(name) => RemoteOptions {
1728 1770
                repo_id: None,
1729 1771
                ..self.remote_options(&name)
1730 1772
            },
1731 -
            None => RemoteOptions {
1732 -
                token: std::env::var("OXY_TOKEN").ok(),
1733 -
                ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
1734 -
                repo_id: None,
1735 -
            },
1773 +
            None => {
1774 +
                let mut options = RemoteOptions::default();
1775 +
                self.fill_credentials(url, &mut options);
1776 +
                options
1777 +
            }
1736 1778
        }
1737 1779
    }
1738 1780
…
4 unchanged lines
1743 1785
        sparse: &[String],
1744 1786
        depth: Option<u32>,
1745 1787
        options: &RemoteOptions,
1746 -
        record: bool,
1747 1788
    ) -> Result<Repo> {
1748 1789
        let remote = open_remote(url, options)?;
1749 1790
        let id = remote.repo_id()?;
…
4 unchanged lines
1754 1795
        }
1755 1796
        let mut repo = Repo::init_with_id(dest, id)?;
1756 1797
        repo.config.set("remote.origin.url", url)?;
1757 -
        if record {
1758 -
            if let Some(t) = &options.token {
1759 -
                repo.config.set("remote.origin.token", t)?;
1760 -
            }
1761 -
            if let Some(ca) = &options.ca {
1762 -
                repo.config.set("remote.origin.ca", &ca.to_string_lossy())?;
1763 -
            }
1764 -
        }
1765 1798
        if let Some(m) = max_blob {
1766 1799
            repo.config.set("filter.max-blob", &m.to_string())?;
1767 1800
        } else if !sparse.is_empty() {
…
40 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +49 −0
…
6230 unchanged lines
6231 6231
    }
6232 6232
}
6233 6233
6234 +
/// oxygen#47: a remote with no token of its own uses the user's credential
6235 +
/// store for its hub, and a nested checkout finds its parent's remote
6236 +
/// token there instead of keeping a copy.
6234 6237
#[test]
6238 +
fn remotes_use_the_credential_store_and_the_enclosing_checkout() {
6239 +
    hermetic();
6240 +
    let home = tempfile::tempdir().unwrap();
6241 +
    let dir = tempfile::tempdir().unwrap();
6242 +
    let mut parent = Repo::init(dir.path()).unwrap().with_home(home.path());
6243 +
    parent
6244 +
        .config
6245 +
        .set("remote.origin.url", "https://hub.example/o/parent")
6246 +
        .unwrap();
6247 +
    parent.save_config().unwrap();
6248 +
    assert_eq!(parent.remote_options("origin").token, None);
6249 +
    crate::credentials::Credentials::at(home.path())
6250 +
        .save("https://hub.example", "stored", Some("andy"), None)
6251 +
        .unwrap();
6252 +
    assert_eq!(
6253 +
        parent.remote_options("origin").token.as_deref(),
6254 +
        Some("stored")
6255 +
    );
6256 +
6257 +
    let empty_home = tempfile::tempdir().unwrap();
6258 +
    let nested = dir.path().join("deps").join("child");
6259 +
    let mut child = Repo::init(&nested).unwrap().with_home(empty_home.path());
6260 +
    child
6261 +
        .config
6262 +
        .set("remote.origin.url", "https://hub.example/o/child")
6263 +
        .unwrap();
6264 +
    child.save_config().unwrap();
6265 +
    assert_eq!(child.remote_options("origin").token, None);
6266 +
    parent
6267 +
        .config
6268 +
        .set("remote.origin.token", "checkout")
6269 +
        .unwrap();
6270 +
    parent.save_config().unwrap();
6271 +
    assert_eq!(
6272 +
        child.remote_options("origin").token.as_deref(),
6273 +
        Some("checkout")
6274 +
    );
6275 +
    // Another hub's checkout token is not borrowed.
6276 +
    child
6277 +
        .config
6278 +
        .set("remote.origin.url", "https://other.example/o/child")
6279 +
        .unwrap();
6280 +
    assert_eq!(child.remote_options("origin").token, None);
6281 +
}
6282 +
6283 +
#[test]
6235 6284
fn the_metadata_cache_survives_a_torn_tail() {
6236 6285
    hermetic();
6237 6286
    let dir = tempfile::tempdir().unwrap();
…
19 unchanged lines
modified docs/guide.md +28 −3
…
250 unchanged lines
251 251
On your machine:
252 252
253 253
```
254 +
echo SECRET | oxy auth login https://host:8890 --with-token --ca my-ca.pem
254 255
oxy clone https://host:8890/project
255 256
cd project
256 -
oxy config remote.origin.token SECRET   # or export OXY_TOKEN
257 -
oxy config remote.origin.ca my-ca.pem   # self-signed server: extra roots to trust (or OXY_CA)
258 257
oxy push
259 258
oxy pull
260 259
```
261 260
261 +
`--ca` is only for a server with a self-signed certificate. On a hub such as
262 +
OxygenHub, `oxy auth login https://oxygenhub.io` signs in through the
263 +
browser instead: it shows a code to approve on the hub, and the hub issues a
264 +
token that lasts 90 days past its last use.
265 +
266 +
### Signing in
267 +
268 +
`oxy auth login HUB` keeps one token per hub (scheme, host and port) in
269 +
`~/.config/oxy/credentials.toml`, readable by you only, and every repository
270 +
on that hub uses it: clone, fetch, push and subrepos alike. Nothing secret is
271 +
written into a checkout. `oxy auth status` lists the hubs you are signed in
272 +
to and checks each token with its hub; `oxy auth logout HUB` forgets the token
273 +
and revokes it on the hub.
274 +
275 +
To keep tokens in the system keyring instead, name a Git credential helper:
276 +
`oxy config --user auth.helper libsecret` (Linux), `osxkeychain` (macOS) or
277 +
`manager` (Windows). The file then keeps only the account and CA.
278 +
279 +
A token is found in this order: the remote's own `remote.<name>.token`, then
280 +
`$OXY_TOKEN` (for CI), then the store, then a remote on the same hub in an
281 +
enclosing checkout (so a subrepo uses its parent's). oxy sends a token over
282 +
`https` only, or over plain `http` to this machine; it is never sent to
283 +
another host after a redirect, and a credentials file that others can read
284 +
is ignored.
285 +
262 286
- `oxy fetch` updates `origin/main` without touching your bookmark.
263 287
- `oxy pull` fetches, then makes your bookmark the union of both sides.
264 288
  There is no merge commit; unrelated changes simply coexist, and a real
…
179 unchanged lines
444 468
| `filter.max-blob`, `filter.paths` | lazy blobs and sparse subtrees (section 11) |
445 469
| `hooks.semantic-deps` | run the semantic-deps hook at commit |
446 470
| `workspace.bookmark` | this workspace's bookmark (`main`) |
447 -
| `remote.<name>.url`, `.token`, `.ca` | remotes; token and CA fall back to `OXY_TOKEN` and `OXY_CA` |
471 +
| `remote.<name>.url`, `.token`, `.ca` | remotes; token and CA fall back to `OXY_TOKEN` and `OXY_CA`, then `oxy auth login`'s store |
472 +
| `auth.helper` (user file) | a Git credential helper to keep `oxy auth` tokens in, such as `libsecret` |
448 473
449 474
## 16. Shell prompt
450 475
…
50 unchanged lines
modified docs/spec.md +3 −2
…
808 unchanged lines
809 809
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. |
810 810
| `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. |
811 811
| `.oxy/config` | Repository-local settings, TOML. |
812 -
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
812 +
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
813 +
| `~/.config/oxy/credentials.toml` | Tokens from `oxy auth login`, one `[scheme://host[:port]]` section per hub with `token`, `account` and `ca`. Written owner-only; ignored when others can read it. With `auth.helper` set, the token is kept by that helper (Git's credential-helper protocol, user name `oxy-token`) and the section holds the rest. |
813 814
814 -
Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs.
815 +
Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. A remote's token and CA without `remote.<name>.token` or `.ca` come from `OXY_TOKEN` and `OXY_CA`, then from `credentials.toml` for the remote's hub, then from a remote on the same hub in an enclosing repository's `.oxy/config`. A client MUST NOT send a token over plain `http` except to a loopback address, and MUST NOT forward it across a redirect. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs.
815 816
816 817
Filters (`filter=<name>`) are clean/smudge programs configured in user or repo config, as in Git. Implementations MUST refuse to run filters defined in `.oxy/config` of a freshly cloned repository until the user approves them.
817 818
…
227 unchanged lines
modified src/cli/mod.rs +39 −1
1 1
//! Command-line definitions and dispatch.
2 2
3 +
mod auth;
3 4
mod format;
4 5
mod identity;
5 6
mod json;
…
318 unchanged lines
324 325
        /// The bookmark whose heads to add
325 326
        bookmark: String,
326 327
    },
328 +
    /// Sign in to a hub once, for every repository on it
329 +
    Auth {
330 +
        #[command(subcommand)]
331 +
        cmd: AuthCmd,
332 +
    },
327 333
    /// Subrepositories: nested repositories pinned to a state
328 334
    Submodule {
329 335
        #[command(subcommand)]
…
105 unchanged lines
435 441
        #[command(subcommand)]
436 442
        cmd: PolicyCmd,
437 443
    },
438 -
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, workspace.bookmark, remote.<name>.url|token|ca)
444 +
    /// Set a configuration key (author.*, core.sign, core.op-retention, core.gc-grace, core.checkpoint-interval, filter.max-blob, filter.paths, workspace.bookmark, remote.<name>.url|token|ca, auth.helper with --user)
439 445
    Config {
440 446
        /// The key, such as author.name or remote.origin.token
441 447
        key: String,
…
117 unchanged lines
559 565
        /// versions of the files they touch
560 566
        #[arg(long, conflicts_with = "discard")]
561 567
        carry: bool,
568 +
    },
569 +
}
570 +
571 +
#[derive(Subcommand)]
572 +
enum AuthCmd {
573 +
    /// Sign in to HUB in the browser (device sign-in) and keep the token for
574 +
    /// every repository there
575 +
    Login {
576 +
        /// The hub, as https://oxygenhub.io or oxygenhub.io (default: this checkout's origin)
577 +
        hub: Option<String>,
578 +
        /// Read a token from standard input instead (for CI and scripts)
579 +
        #[arg(long)]
580 +
        with_token: bool,
581 +
        /// PEM file of extra roots to trust (a hub with a self-signed certificate)
582 +
        #[arg(long)]
583 +
        ca: Option<PathBuf>,
562 584
    },
585 +
    /// List the hubs you are signed in to, checking each token with its hub
586 +
    Status,
587 +
    /// Forget HUB's token and revoke it on the hub
588 +
    Logout {
589 +
        /// The hub (default: this checkout's origin, or the only one)
590 +
        hub: Option<String>,
591 +
    },
563 592
}
564 593
565 594
#[derive(Subcommand)]
…
484 unchanged lines
1050 1079
            } => workspace::bookmark_switch(&name, discard, carry),
1051 1080
        },
1052 1081
        Cmd::Merge { bookmark } => workspace::merge(&bookmark),
1082 +
        Cmd::Auth { cmd } => match cmd {
1083 +
            AuthCmd::Login {
1084 +
                hub,
1085 +
                with_token,
1086 +
                ca,
1087 +
            } => auth::login(hub.as_deref(), with_token, ca),
1088 +
            AuthCmd::Status => auth::status(),
1089 +
            AuthCmd::Logout { hub } => auth::logout(hub.as_deref()),
1090 +
        },
1053 1091
        Cmd::Submodule { cmd } => match cmd {
1054 1092
            SubmoduleCmd::Add {
1055 1093
                url,
…
165 unchanged lines