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 |