oh signs in through oxy's credential store; pin oxygen with oxy auth and remote bookmarks (refs andymccall/oxygen#47, #48) 2bbe88a85860
oh auth login saves its token in oxy's credential store, and oh looks tokens up there, so one sign-in serves oh and oxy alike; ~/.config/oh/config.toml keeps only the default hub. The pinned oxygen no longer copies a token into a subrepo (it borrows the parent's when it fetches), names remote bookmarks, and clones the repository's default bookmark, which the hub already reports.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 13:49 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
2bbe88a8586052df5c4a8b95725cbec3b70fed875e889918d7d5351f26a5369d- Depends on
- 19bb31ff1f5c 380996db84e5 42fa95540468 5c6e92fd8d71 60f11b71a740 a4f01d51c2a7 b5668108487f c4c87f481d05
- Depended on by
- d305b3b0afdc
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 15 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **One sign-in for oh and oxy (oxygen#47).** `oh auth login` keeps its token in oxy's credential store (`~/.config/oxy/credentials.toml`, or the keyring through `auth.helper`), and `oh` finds tokens there, so `oxy auth login` signs `oh` in too and the reverse. `~/.config/oh/config.toml` now holds only the default hub. Pinned oxygen brings remote bookmarks by name, and clones check out the repository's default bookmark. |
|
| 8 | + | ||
| 7 | 9 | - **Device tokens expire, and the approval page says where a request came from (#70).** A token from device sign-in lasts 90 days past its last use; Settings → Tokens shows its expiry, renewed on use. The `/device` page shows when the code was asked for and from which address (the client's, through the proxy when `behind_proxy` is set), and warns to approve only a code you started yourself. `DELETE /api/v1/user/token` revokes the token it is called with, which `oxy auth logout` uses. |
|
| 8 | 10 | ||
| 9 | 11 | - **Device sign-in (#67).** An app gets a token without a pasted secret. It asks `/api/v1/device/code` for a code and shows it; you approve the code at `/device`, signed in with your second factor, choosing what the app may do; its next poll gets the token. `oh auth login` signs in this way; `--token` still takes a token. |
|
| … | 11 unchanged lines |
modified crates/hub/tests/accounts.rs +11 −0
| … | 187 unchanged lines |
||
| 188 | 188 | std::env::remove_var("OXY_TOKEN"); |
|
| 189 | 189 | assert!(ok); |
|
| 190 | 190 | ||
| 191 | + | // oxygen#47: signed in once (oxy auth login), a private repository |
|
| 192 | + | // clones with no OXY_TOKEN, and the clone keeps no token of its own. |
|
| 193 | + | let store = oxy_core::credentials::Credentials::user(); |
|
| 194 | + | let hub = oxy_core::credentials::hub_of(&h.base).unwrap(); |
|
| 195 | + | store.save(&hub, &rw, Some("andy"), None).unwrap(); |
|
| 196 | + | let signed_in = Repo::clone(&format!("{}/andy/secret", h.base), &work.path().join("signedin")); |
|
| 197 | + | store.remove(&hub).unwrap(); |
|
| 198 | + | let signed_in = signed_in.unwrap(); |
|
| 199 | + | assert!(signed_in.config.remote_tokens.is_empty()); |
|
| 200 | + | assert_eq!(signed_in.bookmark_heads().unwrap(), vec![c1]); |
|
| 201 | + | ||
| 191 | 202 | // Linking a key: upload the identity, sign the challenge, see attribution. |
|
| 192 | 203 | let (key, identity) = { |
|
| 193 | 204 | let k = client.config.author_key.unwrap(); |
|
| … | 276 unchanged lines |
modified crates/hub/tests/flow.rs +4 −3
| … | 447 unchanged lines |
||
| 448 | 448 | let app = client(&format!("{}/andy/app", h.base), work.path()); |
|
| 449 | 449 | app.submodule_add(&format!("{}/andy/lib", h.base), "deps/lib", Some("main")).unwrap(); |
|
| 450 | 450 | // Vault is private: a checkout with no token for this hub cannot add |
|
| 451 | - | // it, but the app's remote on the same hub lends its token (oxygen#32), |
|
| 452 | - | // which the subrepo then records for its own fetches. |
|
| 451 | + | // it, but the app's remote on the same hub lends its token (oxygen#32). |
|
| 452 | + | // The subrepo keeps no copy: its own fetches find the parent's (oxygen#47). |
|
| 453 | 453 | sqlx::query("UPDATE repositories SET visibility = 'private' WHERE name = 'vault'").execute(&h.app.db).await.unwrap(); |
|
| 454 | 454 | let bare_work = tempfile::tempdir().unwrap(); |
|
| 455 | 455 | let mut bare = Repo::init(bare_work.path()).unwrap(); |
|
| … | 2 unchanged lines |
||
| 458 | 458 | assert!(bare.submodule_add(&format!("{}/andy/vault", h.base), "deps/vault", Some("main")).is_err(), "not without a token"); |
|
| 459 | 459 | app.submodule_add(&format!("{}/andy/vault", h.base), "deps/vault", Some("main")).unwrap(); |
|
| 460 | 460 | let nested = Repo::open(&work.path().join("deps/vault")).unwrap(); |
|
| 461 | - | assert_eq!(nested.config.remote_tokens.get("origin").map(String::as_str), Some("site")); |
|
| 461 | + | assert!(nested.config.remote_tokens.is_empty(), "no token is copied into a subrepo"); |
|
| 462 | + | nested.fetch("origin").unwrap(); |
|
| 462 | 463 | // The pusher below is the site token, which has no account: it cannot read vault. |
|
| 463 | 464 | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 464 | 465 | write(work.path(), "Makefile", "all:\n"); |
|
| … | 866 unchanged lines |
modified crates/oh/src/main.rs +8 −3
| … | 10 unchanged lines |
||
| 11 | 11 | use client::Client; |
|
| 12 | 12 | use hub_api::{FlowLogLine, FlowRun, Issue, IssueStateChange, NewComment, NewIssue, NewProposal, NewRelease, Proposal, Release}; |
|
| 13 | 13 | use std::time::Duration; |
|
| 14 | - | use target::{Choice, Config, Host, Target}; |
|
| 14 | + | use target::{Choice, Config, Target}; |
|
| 15 | 15 | ||
| 16 | 16 | #[derive(Parser)] |
|
| 17 | 17 | #[command(name = "oh", version = env!("OXY_VERSION_LONG"), about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")] |
|
| … | 302 unchanged lines |
||
| 320 | 320 | } |
|
| 321 | 321 | let t = Target { hub: hub.clone(), token: Some(token.clone()), ca: ca.clone(), owner: String::new(), name: String::new() }; |
|
| 322 | 322 | let me: serde_json::Value = Client::new(&t)?.get("/user").context("checking the token")?; |
|
| 323 | - | cfg.hosts.insert(hub.clone(), Host { token, ca }); |
|
| 323 | + | let account = me["username"].as_str().unwrap_or("?"); |
|
| 324 | + | // One sign-in for oh and oxy alike: the token goes to oxy's store. |
|
| 325 | + | let key = oxy_core::credentials::hub_of(&hub).with_context(|| format!("{hub} is not a hub URL"))?; |
|
| 326 | + | let ca = ca.map(|c| std::fs::canonicalize(&c).unwrap_or(c)); |
|
| 327 | + | oxy_core::credentials::Credentials::user().save(&key, &token, Some(account), ca.as_deref()).map_err(|e| anyhow::anyhow!("{e}"))?; |
|
| 324 | 328 | cfg.default_hub = Some(hub.clone()); |
|
| 325 | 329 | cfg.save()?; |
|
| 326 | - | println!("{} signed in to {hub} as {}", ui::ok("✔"), ui::strong(me["username"].as_str().unwrap_or("?"))); |
|
| 330 | + | println!("{} signed in to {hub} as {}", ui::ok("✔"), ui::strong(account)); |
|
| 331 | + | println!("{}", ui::dim(" oxy uses this sign-in too; oxy auth status lists it, oxy auth logout ends it")); |
|
| 327 | 332 | Ok(0) |
|
| 328 | 333 | } |
|
| 329 | 334 | ||
| … | 451 unchanged lines |
modified crates/oh/src/target.rs +11 −7
| … | 7 unchanged lines |
||
| 8 | 8 | ||
| 9 | 9 | use anyhow::{anyhow, bail, Context, Result}; |
|
| 10 | 10 | use serde::{Deserialize, Serialize}; |
|
| 11 | - | use std::collections::BTreeMap; |
|
| 12 | 11 | use std::path::{Path, PathBuf}; |
|
| 13 | 12 | ||
| 14 | - | /// `~/.config/oh/config.toml`: tokens and CAs by hub, and the default hub. |
|
| 13 | + | /// `~/.config/oh/config.toml`: the default hub. Tokens are oxy's: one |
|
| 14 | + | /// sign-in (`oh auth login` or `oxy auth login`) serves both, kept in |
|
| 15 | + | /// oxy's credential store (oxygen#47). |
|
| 15 | 16 | #[derive(Debug, Default, Serialize, Deserialize)] |
|
| 16 | 17 | pub struct Config { |
|
| 17 | 18 | #[serde(default)] |
|
| 18 | 19 | pub default_hub: Option<String>, |
|
| 19 | - | #[serde(default)] |
|
| 20 | - | pub hosts: BTreeMap<String, Host>, |
|
| 21 | 20 | } |
|
| 22 | 21 | ||
| 23 | - | #[derive(Clone, Debug, Default, Serialize, Deserialize)] |
|
| 22 | + | /// What oxy's credential store holds for a hub. |
|
| 23 | + | #[derive(Clone, Debug, Default)] |
|
| 24 | 24 | pub struct Host { |
|
| 25 | 25 | pub token: String, |
|
| 26 | - | #[serde(default)] |
|
| 27 | 26 | pub ca: Option<PathBuf>, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | fn stored(hub: &str) -> Host { |
|
| 30 | + | let (token, ca) = oxy_core::credentials::Credentials::user().lookup(hub); |
|
| 31 | + | Host { token: token.unwrap_or_default(), ca } |
|
| 28 | 32 | } |
|
| 29 | 33 | ||
| 30 | 34 | pub fn config_path() -> Result<PathBuf> { |
|
| … | 82 unchanged lines |
||
| 113 | 117 | pub fn resolve(choice: &Choice, dir: &Path, cfg: &Config) -> Result<Target> { |
|
| 114 | 118 | let env_token = std::env::var("OH_TOKEN").ok().or_else(|| std::env::var("OXY_TOKEN").ok()).filter(|t| !t.is_empty()); |
|
| 115 | 119 | let env_ca = std::env::var_os("OXY_CA").map(PathBuf::from); |
|
| 116 | - | let saved = |hub: &str| cfg.hosts.get(hub).cloned(); |
|
| 120 | + | let saved = |hub: &str| Some(stored(hub)); |
|
| 117 | 121 | if let Some(r) = choice.repo { |
|
| 118 | 122 | let (owner, name) = r.split_once('/').ok_or_else(|| anyhow!("--repo takes OWNER/NAME"))?; |
|
| 119 | 123 | let hub = choice |
|
| … | 68 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.