From f4db1862340a62c140f81d5c2575cce21ba56fe4 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Mon, 1 Jun 2026 16:27:36 -0700 Subject: [PATCH] =?UTF-8?q?hephd:=20OIDC=20client=20auth=20=E2=80=94=20dev?= =?UTF-8?q?ice-code=20flow=20+=20token=20attach=20(auth=2010b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the auth loop: clients obtain a bearer token and present it to the hub (tech-spec §13). - oauth module: DeviceFlow (RFC 8628 — discover, start, poll handling authorization_pending/slow_down, refresh) + StoredToken + TokenStore (OS keyring via `keyring`, in-memory for tests) + current_bearer (loads and refreshes-on-expiry). - heph auth login/logout: runs the device flow, prints the verification URL + user code, caches the token in the keyring. - sync_once gains a bearer arg; the daemon (Daemon::spawn_sync_loop + sync.now) obtains it via current_bearer; RemoteStore attaches it to /rpc. --oidc-issuer/--oidc-client-id configure the spoke/client. - Fix a latent panic: reqwest::blocking spins its own runtime and panics inside the daemon's spawn_blocking pool. All blocking auth/proxy HTTP (OidcVerifier JWKS, DeviceFlow, RemoteStore) now uses runtime-free `ureq`; async reqwest remains only for sync_once. (Caught by the new e2e test.) - Tests (offline): device flow + refresh + token store vs a mock OAuth server; a full spoke->authenticated-hub loop (valid token accepted, missing token rejected) signed by a runtime-generated RSA key. 112 tests green; clippy -D warnings + fmt + prek clean. Slice 10 (auth) complete; next is heph.nvim. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1131 +++++++++++++++++++++- Cargo.toml | 8 +- README.md | 7 +- crates/heph/src/main.rs | 70 +- crates/hephd/Cargo.toml | 2 + crates/hephd/src/auth.rs | 38 +- crates/hephd/src/lib.rs | 15 + crates/hephd/src/main.rs | 59 +- crates/hephd/src/oauth.rs | 327 +++++++ crates/hephd/src/remote.rs | 65 +- crates/hephd/src/server.rs | 71 +- crates/hephd/src/sync.rs | 23 +- crates/hephd/tests/auth_hub.rs | 77 +- crates/hephd/tests/oauth.rs | 153 +++ crates/hephd/tests/sync_http.rs | 24 +- docs/changelog.d/v1-prototype.feature.md | 3 +- docs/reference/tech-spec.md | 7 +- 17 files changed, 1996 insertions(+), 84 deletions(-) create mode 100644 crates/hephd/src/oauth.rs create mode 100644 crates/hephd/tests/oauth.rs diff --git a/Cargo.lock b/Cargo.lock index 6a74c3e..c1f43a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -97,6 +114,48 @@ dependencies = [ "rustversion", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -108,6 +167,59 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -231,6 +343,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -243,6 +377,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.63" @@ -259,6 +402,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -294,6 +443,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -355,6 +514,55 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -370,6 +578,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -433,12 +650,42 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -482,6 +729,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -541,6 +797,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -615,12 +904,43 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -647,7 +967,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -662,6 +981,30 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -681,10 +1024,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", + "futures-macro", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -720,11 +1062,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "group" version = "0.13.0" @@ -745,13 +1100,28 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -801,6 +1171,7 @@ dependencies = [ "fs4", "heph-core", "jsonwebtoken", + "keyring", "rand 0.8.6", "reqwest", "rsa", @@ -811,8 +1182,21 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "ureq", ] +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -1026,6 +1410,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1047,6 +1437,28 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1101,6 +1513,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "openssl", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1110,12 +1537,28 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -1151,6 +1594,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1187,12 +1636,31 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1204,6 +1672,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1213,6 +1694,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1239,6 +1734,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1265,6 +1769,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1287,6 +1802,63 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -1408,6 +1980,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -1435,6 +2018,20 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1459,6 +2056,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1468,6 +2075,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1528,6 +2144,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -1642,9 +2264,7 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", - "futures-channel", "futures-core", - "futures-util", "http", "http-body", "http-body-util", @@ -1678,6 +2298,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rrule" version = "0.13.0" @@ -1761,6 +2395,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1805,6 +2474,61 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1865,6 +2589,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1877,6 +2612,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1903,6 +2649,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -1913,6 +2669,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1984,6 +2746,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2034,7 +2802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -2156,6 +2924,36 @@ dependencies = [ "syn", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -2276,6 +3074,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "ulid" version = "1.2.1" @@ -2304,6 +3113,50 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2316,6 +3169,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2376,7 +3235,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -2434,6 +3302,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.99" @@ -2454,6 +3356,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2604,18 +3515,125 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -2657,6 +3675,62 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.50" @@ -2756,3 +3830,40 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index bd921e4..54f59a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,10 +35,16 @@ clap = { version = "4", features = ["derive"] } fs4 = "0.12" axum = "0.8" jsonwebtoken = { version = "10", features = ["rust_crypto"] } +keyring = { version = "3", features = [ + "apple-native", + "sync-secret-service", + "crypto-rust", + "vendored", +] } +ureq = { version = "3", features = ["json"] } reqwest = { version = "0.13", default-features = false, features = [ "json", "query", - "blocking", ] } [profile.release] diff --git a/README.md b/README.md index 217a590..f87a9b0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision ## Status -**Phase 1 (v1 prototype) — in progress** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and the hub authenticates op exchange with OIDC bearer tokens.** The offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies; the hub verifies tokens (JWKS/RS256) and enforces single-tenant ownership. Remaining: the client-side device-code login + token cache, and the Neovim plugin. Built test-first (108 tests at last update). The canonical tracker is **tech-spec §14**. +**Phase 1 (v1 prototype) — nearly feature-complete** on branch `feature/v1-prototype`. **All three runtime modes work, replicas sync through a hub over HTTP, and op exchange is authenticated end-to-end with OIDC** (Authentik): the hub verifies bearer tokens (JWKS/RS256) and enforces single-tenant ownership, and `heph auth login` runs the device-code flow, caching tokens in the OS keyring. The offline-first everyday config (`local` + `hub_url`) converges with a `yrs` text-CRDT merging bodies. Remaining: the Neovim plugin (the primary surface). Built test-first (112 tests at last update). The canonical tracker is **tech-spec §14**. | Area | State | |---|---| @@ -23,7 +23,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | `server` (hub) mode + spoke push/pull sync over HTTP (axum) | ✅ done | | `client` mode + `RemoteStore` (online-only, no replica) | ✅ done | | OIDC hub auth — bearer-token verification + owner gate | ✅ done | -| OIDC client — device-code login, keyring token cache | ⏳ next | +| OIDC client — device-code login, keyring token cache | ✅ done | +| `heph.nvim` (primary surface) | ⏳ next | | `heph.nvim` (primary surface) | ⏳ | ## Architecture @@ -35,7 +36,7 @@ A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). - **`heph.nvim/`** *(planned)* — the Neovim plugin, the primary editing/agenda surface. -**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate; the client-side device-code login is in progress. Local-only instances need no auth. +**Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate — and clients obtain tokens via the OAuth 2.0 device-code flow (`heph auth login`), cached in the OS keyring. Local-only instances need no auth. ## Build & run diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index ddccc11..7ab71eb 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -9,7 +9,7 @@ use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use heph_core::{Node, RankedTask, Task}; -use hephd::{default_socket_path, Client}; +use hephd::{default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore}; #[derive(Parser, Debug)] #[command(name = "heph", version, about)] @@ -81,10 +81,77 @@ enum Command { /// Destination directory (created if needed). dir: PathBuf, }, + /// Authenticate this device with a sync hub (OAuth 2.0 device-code flow). + Auth { + #[command(subcommand)] + action: AuthAction, + }, +} + +#[derive(Subcommand, Debug)] +enum AuthAction { + /// Log in via the device-code flow; caches the bearer token for hub sync. + Login { + /// Hub/server URL this token is for (keys the credential store entry). + #[arg(long)] + hub_url: String, + /// OIDC issuer, e.g. https://authentik.ops.eblu.me/application/o/heph/. + #[arg(long)] + issuer: String, + /// OIDC client id this device authenticates as. + #[arg(long)] + client_id: String, + /// Scopes to request (`offline_access` yields a refresh token). + #[arg(long, default_value = "openid offline_access")] + scope: String, + }, + /// Forget the cached token for a hub. + Logout { + /// Hub/server URL whose cached token to remove. + #[arg(long)] + hub_url: String, + }, +} + +/// Run the device-code flow (or clear a token) — no daemon needed. +fn run_auth(action: AuthAction) -> Result<()> { + match action { + AuthAction::Login { + hub_url, + issuer, + client_id, + scope, + } => { + let flow = DeviceFlow::discover(&issuer, &client_id)?; + let auth = flow.start(&scope)?; + let uri = auth + .verification_uri_complete + .as_deref() + .unwrap_or(&auth.verification_uri); + println!( + "To authorize hephaestus, visit:\n {uri}\nand enter code: {}\n\nWaiting…", + auth.user_code + ); + let token = flow.poll(&auth, std::thread::sleep)?; + KeyringTokenStore::new(hub_url.as_str()).save(&token)?; + println!("Logged in. Token cached for {hub_url}."); + } + AuthAction::Logout { hub_url } => { + KeyringTokenStore::new(hub_url.as_str()).clear()?; + println!("Logged out of {hub_url}."); + } + } + Ok(()) } fn main() -> Result<()> { let cli = Cli::parse(); + + // `auth` runs locally (device-code flow + keyring); it needs no daemon. + if let Command::Auth { action } = cli.command { + return run_auth(action); + } + let socket = cli.socket.unwrap_or_else(default_socket_path); let mut client = Client::connect(&socket)?; @@ -157,6 +224,7 @@ fn main() -> Result<()> { let count = result.get("count").and_then(Value::as_u64).unwrap_or(0); println!("Exported {count} nodes to {}", dir.display()); } + Command::Auth { .. } => unreachable!("auth is handled before connecting"), } Ok(()) } diff --git a/crates/hephd/Cargo.toml b/crates/hephd/Cargo.toml index 8a425fb..807feb4 100644 --- a/crates/hephd/Cargo.toml +++ b/crates/hephd/Cargo.toml @@ -29,7 +29,9 @@ clap.workspace = true fs4.workspace = true axum.workspace = true jsonwebtoken.workspace = true +keyring.workspace = true reqwest.workspace = true +ureq.workspace = true [dev-dependencies] tempfile = "3" diff --git a/crates/hephd/src/auth.rs b/crates/hephd/src/auth.rs index b7eee96..e3081a1 100644 --- a/crates/hephd/src/auth.rs +++ b/crates/hephd/src/auth.rs @@ -62,7 +62,7 @@ struct Discovery { pub struct OidcVerifier { issuer: String, audience: String, - http: reqwest::blocking::Client, + http: ureq::Agent, jwks: RwLock>, } @@ -74,37 +74,43 @@ impl OidcVerifier { OidcVerifier { issuer: issuer.into(), audience: audience.into(), - http: reqwest::blocking::Client::new(), + http: crate::blocking_agent(), jwks: RwLock::new(None), } } + /// GET `url` and decode a JSON body, erroring on a non-success status. + fn get_json(&self, url: &str) -> Result { + let mut resp = self + .http + .get(url) + .call() + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !resp.status().is_success() { + return Err(AuthError::Provider(format!( + "{url} returned {}", + resp.status() + ))); + } + resp.body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string())) + } + /// Resolve the JWKS URI from the provider's discovery document. fn jwks_uri(&self) -> Result { let url = format!( "{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/') ); - let disc: Discovery = self - .http - .get(url) - .send() - .and_then(reqwest::blocking::Response::error_for_status) - .and_then(reqwest::blocking::Response::json) - .map_err(|e| AuthError::Provider(e.to_string()))?; + let disc: Discovery = self.get_json(&url)?; Ok(disc.jwks_uri) } /// Fetch (and cache) the provider's JWKS. fn refresh_jwks(&self) -> Result<(), AuthError> { let uri = self.jwks_uri()?; - let set: JwkSet = self - .http - .get(uri) - .send() - .and_then(reqwest::blocking::Response::error_for_status) - .and_then(reqwest::blocking::Response::json) - .map_err(|e| AuthError::Provider(e.to_string()))?; + let set: JwkSet = self.get_json(&uri)?; *self.jwks.write().expect("jwks lock poisoned") = Some(set); Ok(()) } diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index 14b0b80..60f7de3 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod client; pub mod clock; pub mod lock; +pub mod oauth; pub mod remote; pub mod rpc; pub mod server; @@ -22,10 +23,24 @@ pub use auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; pub use client::Client; pub use clock::SystemClock; pub use lock::LockGuard; +pub use oauth::{current_bearer, DeviceFlow, KeyringTokenStore, StoredToken, TokenStore}; pub use remote::RemoteStore; pub use server::Daemon; pub use sync::{sync_once, SyncReport}; +/// A blocking HTTP agent for the auth paths (JWKS fetch, device flow, the +/// `client`-mode `/rpc` proxy). It spins **no** async runtime, so unlike +/// `reqwest::blocking` it is safe inside `spawn_blocking` and plain sync code; +/// 4xx/5xx are *not* turned into errors so callers can read error bodies +/// (e.g. the device flow's `authorization_pending`). +pub(crate) fn blocking_agent() -> ureq::Agent { + ureq::Agent::new_with_config( + ureq::Agent::config_builder() + .http_status_as_error(false) + .build(), + ) +} + /// Default unix socket path: `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to /// the system temp dir when `XDG_RUNTIME_DIR` is unset (tech-spec §3). pub fn default_socket_path() -> PathBuf { diff --git a/crates/hephd/src/main.rs b/crates/hephd/src/main.rs index dcc0b72..e8d9cd1 100644 --- a/crates/hephd/src/main.rs +++ b/crates/hephd/src/main.rs @@ -17,7 +17,8 @@ use tokio::net::{TcpListener, UnixListener}; use heph_core::LocalStore; use hephd::{ - default_db_path, default_socket_path, sync, Daemon, LockGuard, RemoteStore, SystemClock, + default_db_path, default_socket_path, sync, Daemon, KeyringTokenStore, LockGuard, RemoteStore, + SystemClock, TokenStore, }; /// How often a spoke background-syncs with its hub. @@ -71,6 +72,28 @@ struct Cli { /// OIDC audience (client id) hub tokens must carry (server mode). #[arg(long)] oidc_audience: Option, + + /// OIDC client id this device authenticates as, for spoke/client sync. With + /// --oidc-issuer, the device attaches a cached bearer token to hub requests. + #[arg(long)] + oidc_client_id: Option, +} + +/// Build the spoke/client token source: a keyring store keyed by `account` (the +/// hub/server url) plus the issuer + client id. `None` unless both are set. +fn spoke_auth( + account: &str, + issuer: Option<&String>, + client_id: Option<&String>, +) -> Option<(Arc, String, String)> { + match (issuer, client_id) { + (Some(issuer), Some(client_id)) => Some(( + Arc::new(KeyringTokenStore::new(account)) as Arc, + issuer.clone(), + client_id.clone(), + )), + _ => None, + } } #[tokio::main] @@ -98,7 +121,17 @@ async fn main() -> Result<()> { .clone() .context("client mode requires --server-url")?; tracing::info!(%server_url, "client mode: proxying to server (no local replica)"); - (None, Daemon::new(RemoteStore::new(&server_url))) + let store = match spoke_auth( + &server_url, + cli.oidc_issuer.as_ref(), + cli.oidc_client_id.as_ref(), + ) { + Some((tokens, issuer, client_id)) => { + RemoteStore::with_auth(&server_url, tokens, issuer, client_id) + } + None => RemoteStore::new(&server_url), + }; + (None, Daemon::new(store)) } Mode::Local | Mode::Server => { let db = cli.db.clone().unwrap_or_else(default_db_path); @@ -109,7 +142,12 @@ async fn main() -> Result<()> { // Take the exclusive lock before opening the store (tech-spec §3.1). let lock = LockGuard::acquire(&db)?; let store = LocalStore::open(&db, Box::new(SystemClock))?; - let daemon = Daemon::new(store).with_hub(cli.hub_url.clone()); + let spoke = cli.hub_url.as_deref().and_then(|hub| { + spoke_auth(hub, cli.oidc_issuer.as_ref(), cli.oidc_client_id.as_ref()) + }); + let daemon = Daemon::new(store) + .with_hub(cli.hub_url.clone()) + .with_spoke_auth(spoke); // server mode: expose the hub HTTP endpoint over the same store. if cli.mode == Mode::Server { @@ -146,20 +184,7 @@ async fn main() -> Result<()> { } // spoke: background-sync the op-log with the configured hub. - if let Some(hub) = cli.hub_url.clone() { - let store = daemon.store(); - tokio::spawn(async move { - let http = reqwest::Client::new(); - let mut tick = tokio::time::interval(SYNC_INTERVAL); - loop { - tick.tick().await; - match hephd::sync_once(store.clone(), &hub, &http).await { - Ok(report) => tracing::debug!(?report, "background sync"), - Err(e) => tracing::warn!("background sync failed: {e}"), - } - } - }); - } + daemon.spawn_sync_loop(SYNC_INTERVAL); (Some(lock), daemon) } diff --git a/crates/hephd/src/oauth.rs b/crates/hephd/src/oauth.rs new file mode 100644 index 0000000..9cba7c8 --- /dev/null +++ b/crates/hephd/src/oauth.rs @@ -0,0 +1,327 @@ +//! Client-side OIDC: the OAuth 2.0 device-code flow (RFC 8628), token storage, +//! and refresh (tech-spec §13). +//! +//! A spoke (`local` + `hub_url`) or a `client` uses this to obtain the bearer +//! token it presents to the hub. The flow is **blocking** — it is interactive +//! (`heph auth login` waits for the user to authorize in a browser) and the +//! daemon only refreshes from its blocking pool. Tokens persist in a +//! [`TokenStore`] (the OS keyring in production, in-memory in tests). + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::auth::AuthError; + +/// The standard device-code grant type. +const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; +/// Treat a token as expired this many seconds early, to avoid races. +const EXPIRY_SKEW: u64 = 30; + +/// Persisted OIDC tokens for one provider. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredToken { + /// The bearer token presented to the hub. + pub access_token: String, + /// Used to obtain a fresh access token without re-authenticating. + pub refresh_token: Option, + /// Unix seconds at which `access_token` expires. + pub expires_at: u64, +} + +impl StoredToken { + /// Whether the access token is expired (or within the safety skew). + pub fn is_expired(&self, now: u64) -> bool { + now + EXPIRY_SKEW >= self.expires_at + } +} + +/// Current unix time in seconds. +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Where tokens persist between runs. +pub trait TokenStore: Send + Sync { + /// Load the stored token, if any. + fn load(&self) -> Option; + /// Persist (replacing) the token. + fn save(&self, token: &StoredToken) -> Result<(), AuthError>; + /// Remove any stored token. + fn clear(&self) -> Result<(), AuthError>; +} + +/// An in-memory [`TokenStore`] for tests. +#[derive(Default)] +pub struct MemoryTokenStore(std::sync::Mutex>); + +impl TokenStore for MemoryTokenStore { + fn load(&self) -> Option { + self.0.lock().expect("token lock poisoned").clone() + } + fn save(&self, token: &StoredToken) -> Result<(), AuthError> { + *self.0.lock().expect("token lock poisoned") = Some(token.clone()); + Ok(()) + } + fn clear(&self) -> Result<(), AuthError> { + *self.0.lock().expect("token lock poisoned") = None; + Ok(()) + } +} + +/// A [`TokenStore`] backed by the OS keyring (Keychain / Secret Service). The +/// token JSON is stored as the secret for `(service, account)`. +pub struct KeyringTokenStore { + service: String, + account: String, +} + +impl KeyringTokenStore { + /// Store tokens under this service, keyed by `account` (the hub url). + pub fn new(account: impl Into) -> KeyringTokenStore { + KeyringTokenStore { + service: "hephaestus".into(), + account: account.into(), + } + } + + fn entry(&self) -> Result { + keyring::Entry::new(&self.service, &self.account) + .map_err(|e| AuthError::Provider(e.to_string())) + } +} + +impl TokenStore for KeyringTokenStore { + fn load(&self) -> Option { + let secret = self.entry().ok()?.get_password().ok()?; + serde_json::from_str(&secret).ok() + } + fn save(&self, token: &StoredToken) -> Result<(), AuthError> { + let json = serde_json::to_string(token).map_err(|e| AuthError::Provider(e.to_string()))?; + self.entry()? + .set_password(&json) + .map_err(|e| AuthError::Provider(e.to_string())) + } + fn clear(&self) -> Result<(), AuthError> { + match self.entry()?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(AuthError::Provider(e.to_string())), + } + } +} + +/// The device-authorization response (RFC 8628 §3.2). +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceAuth { + /// The code the daemon polls the token endpoint with. + pub device_code: String, + /// The short code the user types at the verification page. + pub user_code: String, + /// Where the user goes to authorize. + pub verification_uri: String, + /// Verification URI with the code pre-filled (optional). + #[serde(default)] + pub verification_uri_complete: Option, + /// Seconds between polls. + #[serde(default = "default_interval")] + pub interval: u64, + /// Seconds until `device_code` expires. + pub expires_in: u64, +} + +fn default_interval() -> u64 { + 5 +} + +/// Discovery fields the device flow needs. +#[derive(Debug, Deserialize)] +struct DiscoveryDoc { + device_authorization_endpoint: String, + token_endpoint: String, +} + +/// A token-endpoint success response. +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + expires_in: Option, +} + +impl TokenResponse { + fn into_stored(self) -> StoredToken { + StoredToken { + access_token: self.access_token, + refresh_token: self.refresh_token, + expires_at: now_secs() + self.expires_in.unwrap_or(3600), + } + } +} + +/// A token-endpoint error response (RFC 6749 §5.2 / RFC 8628 §3.5). +#[derive(Debug, Deserialize)] +struct TokenErrorBody { + error: String, +} + +/// Drives the OAuth 2.0 device-code flow against one provider. +pub struct DeviceFlow { + client_id: String, + http: ureq::Agent, + device_authorization_endpoint: String, + token_endpoint: String, +} + +impl DeviceFlow { + /// Discover the device + token endpoints from `issuer` and build a flow. + pub fn discover(issuer: &str, client_id: &str) -> Result { + let http = crate::blocking_agent(); + let url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + let mut resp = http + .get(&url) + .call() + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !resp.status().is_success() { + return Err(AuthError::Provider(format!( + "discovery returned {}", + resp.status() + ))); + } + let doc: DiscoveryDoc = resp + .body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string()))?; + Ok(DeviceFlow { + client_id: client_id.to_string(), + http, + device_authorization_endpoint: doc.device_authorization_endpoint, + token_endpoint: doc.token_endpoint, + }) + } + + /// Request a device + user code (RFC 8628 §3.1). + pub fn start(&self, scope: &str) -> Result { + let mut resp = self + .http + .post(&self.device_authorization_endpoint) + .send_form([("client_id", self.client_id.as_str()), ("scope", scope)]) + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !resp.status().is_success() { + return Err(AuthError::Provider(format!( + "device authorization returned {}", + resp.status() + ))); + } + resp.body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string())) + } + + /// Poll the token endpoint until the user authorizes, the code expires, or + /// access is denied. `sleep` is injected so tests need not wait in real + /// time (production passes [`std::thread::sleep`]). + pub fn poll( + &self, + auth: &DeviceAuth, + sleep: impl Fn(Duration), + ) -> Result { + let deadline = now_secs() + auth.expires_in; + let mut interval = auth.interval.max(1); + loop { + if now_secs() >= deadline { + return Err(AuthError::Invalid("device code expired".into())); + } + let mut response = self + .http + .post(&self.token_endpoint) + .send_form([ + ("grant_type", DEVICE_GRANT), + ("device_code", auth.device_code.as_str()), + ("client_id", self.client_id.as_str()), + ]) + .map_err(|e| AuthError::Provider(e.to_string()))?; + + if response.status().is_success() { + let token: TokenResponse = response + .body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string()))?; + return Ok(token.into_stored()); + } + + // A non-success is either "keep waiting" or a terminal failure. + let body: TokenErrorBody = response + .body_mut() + .read_json() + .map_err(|e| AuthError::Provider(e.to_string()))?; + match body.error.as_str() { + "authorization_pending" => {} + "slow_down" => interval += 5, + other => return Err(AuthError::Invalid(format!("device flow failed: {other}"))), + } + sleep(Duration::from_secs(interval)); + } + } + + /// Exchange a refresh token for a fresh access token (RFC 6749 §6). + pub fn refresh(&self, refresh_token: &str) -> Result { + let mut response = self + .http + .post(&self.token_endpoint) + .send_form([ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", self.client_id.as_str()), + ]) + .map_err(|e| AuthError::Provider(e.to_string()))?; + if !response.status().is_success() { + return Err(AuthError::Provider(format!( + "token refresh returned {}", + response.status() + ))); + } + let mut token: StoredToken = response + .body_mut() + .read_json::() + .map_err(|e| AuthError::Provider(e.to_string()))? + .into_stored(); + // Providers may omit the refresh token on refresh — keep the old one. + if token.refresh_token.is_none() { + token.refresh_token = Some(refresh_token.to_string()); + } + Ok(token) + } +} + +/// Return a usable access token from `store`, refreshing via `issuer`/`client_id` +/// if the stored one is expired. Returns `None` if nothing is stored; errors if +/// a refresh was needed but failed. Saves a refreshed token back to `store`. +pub fn current_bearer( + store: &dyn TokenStore, + issuer: &str, + client_id: &str, +) -> Result, AuthError> { + let Some(token) = store.load() else { + return Ok(None); + }; + if !token.is_expired(now_secs()) { + return Ok(Some(token.access_token)); + } + let Some(refresh) = token.refresh_token.clone() else { + return Err(AuthError::Invalid( + "token expired and no refresh token".into(), + )); + }; + let refreshed = DeviceFlow::discover(issuer, client_id)?.refresh(&refresh)?; + store.save(&refreshed)?; + Ok(Some(refreshed.access_token)) +} diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 33ec49a..b4734dc 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -11,6 +11,8 @@ //! background-syncs, it reads and writes the hub live. They are stubbed //! accordingly; the daemon never invokes them in this mode. +use std::sync::Arc; + use serde::de::DeserializeOwned; use serde_json::{json, Value}; @@ -19,33 +21,74 @@ use heph_core::{ SyncCursors, Task, TaskState, }; +use crate::oauth::{self, TokenStore}; use crate::rpc::{Response, NOT_FOUND}; +/// How a client obtains the bearer token it presents to the server. +struct AuthCtx { + tokens: Arc, + issuer: String, + client_id: String, +} + /// A no-replica store that proxies to a `server` over HTTP. pub struct RemoteStore { base: String, - http: reqwest::blocking::Client, + http: ureq::Agent, + auth: Option, } impl RemoteStore { - /// Point a client at `server_url` (e.g. `http://hub.example:8787`). + /// Point a client at `server_url` (e.g. `http://hub.example:8787`), + /// unauthenticated. pub fn new(server_url: &str) -> RemoteStore { RemoteStore { base: server_url.trim_end_matches('/').to_string(), - http: reqwest::blocking::Client::new(), + http: crate::blocking_agent(), + auth: None, + } + } + + /// Point a client at `server_url`, attaching a cached OIDC bearer token + /// (refreshed as needed) from `tokens` to every call. + pub fn with_auth( + server_url: &str, + tokens: Arc, + issuer: String, + client_id: String, + ) -> RemoteStore { + RemoteStore { + auth: Some(AuthCtx { + tokens, + issuer, + client_id, + }), + ..RemoteStore::new(server_url) } } /// Issue one `/rpc` call, returning the raw `result` value. fn call(&self, method: &str, params: Value) -> Result { - let response: Response = self - .http - .post(format!("{}/rpc", self.base)) - .json(&json!({ "method": method, "params": params })) - .send() - .and_then(reqwest::blocking::Response::error_for_status) - .map_err(|e| Error::Remote(e.to_string()))? - .json() + let mut request = self.http.post(format!("{}/rpc", self.base)); + if let Some(auth) = &self.auth { + let bearer = oauth::current_bearer(auth.tokens.as_ref(), &auth.issuer, &auth.client_id) + .map_err(|e| Error::Remote(e.to_string()))?; + if let Some(bearer) = bearer { + request = request.header("Authorization", format!("Bearer {bearer}")); + } + } + let mut http_response = request + .send_json(json!({ "method": method, "params": params })) + .map_err(|e| Error::Remote(e.to_string()))?; + if !http_response.status().is_success() { + return Err(Error::Remote(format!( + "server returned {}", + http_response.status() + ))); + } + let response: Response = http_response + .body_mut() + .read_json() .map_err(|e| Error::Remote(e.to_string()))?; if let Some(err) = response.error { // Preserve "not found" so callers keep the typed contract. diff --git a/crates/hephd/src/server.rs b/crates/hephd/src/server.rs index 055b9a0..389b7ea 100644 --- a/crates/hephd/src/server.rs +++ b/crates/hephd/src/server.rs @@ -10,6 +10,7 @@ //! ops with the configured hub (tech-spec §6.1, §12). use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::Result; use serde_json::{json, Value}; @@ -18,9 +19,18 @@ use tokio::net::{UnixListener, UnixStream}; use heph_core::Store; +use crate::oauth::{self, TokenStore}; use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR}; use crate::sync::{self, SharedStore}; +/// How a spoke obtains the bearer token it presents to its hub (tech-spec §13). +#[derive(Clone)] +struct SpokeAuth { + store: Arc, + issuer: String, + client_id: String, +} + /// The shared, cheaply-cloneable context each connection serves from. #[derive(Clone)] struct Ctx { @@ -28,6 +38,28 @@ struct Ctx { /// The hub this device syncs with, if it is a spoke (`local` + `hub_url`). hub_url: Option, http: reqwest::Client, + /// Token source for authenticated sync (None ⇒ unauthenticated hub). + auth: Option, +} + +impl Ctx { + /// The current bearer token for hub sync (refreshing if expired), or `None` + /// if this spoke has no auth configured / no usable token. + async fn bearer(&self) -> Option { + let auth = self.auth.clone()?; + let result = tokio::task::spawn_blocking(move || { + oauth::current_bearer(auth.store.as_ref(), &auth.issuer, &auth.client_id) + }) + .await; + match result { + Ok(Ok(token)) => token, + Ok(Err(e)) => { + tracing::warn!("could not obtain bearer token: {e}"); + None + } + Err(_) => None, + } + } } /// A running daemon over a shared store (any [`Store`] backend). @@ -43,6 +75,7 @@ impl Daemon { store: Arc::new(Mutex::new(store)), hub_url: None, http: reqwest::Client::new(), + auth: None, }, } } @@ -53,12 +86,47 @@ impl Daemon { self } + /// Configure how this spoke obtains its bearer token for authenticated sync. + /// `None` (or an unset hub) leaves sync unauthenticated. + pub fn with_spoke_auth( + mut self, + auth: Option<(Arc, String, String)>, + ) -> Daemon { + self.ctx.auth = auth.map(|(store, issuer, client_id)| SpokeAuth { + store, + issuer, + client_id, + }); + self + } + /// The shared store handle, for code that needs to reach the same store the /// daemon serves (the hub HTTP router and background sync, tech-spec §6.1). pub fn store(&self) -> SharedStore { self.ctx.store.clone() } + /// If this is a spoke (`hub_url` set), spawn a background task that syncs the + /// op-log with the hub every `interval` (attaching a bearer token when auth + /// is configured). No-op otherwise. + pub fn spawn_sync_loop(&self, interval: Duration) { + let Some(hub) = self.ctx.hub_url.clone() else { + return; + }; + let ctx = self.ctx.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(interval); + loop { + tick.tick().await; + let bearer = ctx.bearer().await; + match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await { + Ok(report) => tracing::debug!(?report, "background sync"), + Err(e) => tracing::warn!("background sync failed: {e}"), + } + } + }); + } + /// Serve connections on `listener` until the task is cancelled. Each /// connection is handled concurrently; all share the one store. pub async fn serve(&self, listener: UnixListener) -> Result<()> { @@ -145,7 +213,8 @@ async fn sync_now(ctx: &Ctx) -> Result { message: "no hub_url configured; this instance is standalone".into(), }); }; - match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http).await { + let bearer = ctx.bearer().await; + match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await { Ok(report) => Ok(json!(report)), Err(e) => Err(RpcError { code: INTERNAL_ERROR, diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index 7a8ce59..4080907 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -12,9 +12,10 @@ //! //! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]): //! each side transfers only the tail it hasn't sent/seen. Merge is idempotent, -//! so a re-pushed op the hub already has is a harmless no-op. Auth is deferred to -//! tech-spec §13 (slice 10) — the endpoint is currently unauthenticated and -//! scoped to the hub's single owner. +//! so a re-pushed op the hub already has is a harmless no-op. When the hub is +//! configured with a verifier ([`crate::auth`]), every route requires a valid +//! OIDC bearer token whose `sub` owns the hub (tech-spec §13); spokes attach +//! that token via the `bearer` argument to [`sync_once`]. use std::sync::{Arc, Mutex}; @@ -234,6 +235,7 @@ pub async fn sync_once( store: SharedStore, hub_url: &str, http: &reqwest::Client, + bearer: Option<&str>, ) -> Result { let base = hub_url.trim_end_matches('/'); let mut report = SyncReport::default(); @@ -248,6 +250,9 @@ pub async fn sync_once( if let Some(after) = &cursors.last_pulled_hlc { req = req.query(&[("after", after)]); } + if let Some(token) = bearer { + req = req.bearer_auth(token); + } let pulled: OpsBody = req.send().await?.error_for_status()?.json().await?; report.pulled = pulled.ops.len(); if !pulled.ops.is_empty() { @@ -268,11 +273,13 @@ pub async fn sync_once( if !to_push.is_empty() { // `ops_since` returns HLC order, so the last is the new cursor. let max_pushed = to_push.last().map(|o| o.hlc.clone()); - http.post(format!("{base}/sync/push")) - .json(&OpsBody { ops: to_push }) - .send() - .await? - .error_for_status()?; + let mut req = http + .post(format!("{base}/sync/push")) + .json(&OpsBody { ops: to_push }); + if let Some(token) = bearer { + req = req.bearer_auth(token); + } + req.send().await?.error_for_status()?; if let Some(cursor) = max_pushed { let hub = hub_url.to_string(); with_store(&store, move |s| s.record_sync(&hub, Some(&cursor), None)).await?; diff --git a/crates/hephd/tests/auth_hub.rs b/crates/hephd/tests/auth_hub.rs index c594cf0..dd8d013 100644 --- a/crates/hephd/tests/auth_hub.rs +++ b/crates/hephd/tests/auth_hub.rs @@ -27,7 +27,7 @@ use rsa::{RsaPrivateKey, RsaPublicKey}; use serde::Serialize; use serde_json::{json, Value}; -use heph_core::{FixedClock, LocalStore}; +use heph_core::{FixedClock, LocalStore, NewNode, Store}; use hephd::auth::{AuthError, Claims, OidcVerifier, TokenVerifier}; use hephd::sync::{self, SharedStore}; @@ -102,14 +102,15 @@ fn start_hub(verifier: Option>) -> String { /// `POST /rpc health` with an optional bearer token; return the HTTP status. fn rpc_health_status(base: &str, token: Option<&str>) -> u16 { - let http = reqwest::blocking::Client::new(); - let mut req = http - .post(format!("{base}/rpc")) - .json(&json!({ "method": "health", "params": {} })); + let mut req = ureq::post(format!("{base}/rpc")); if let Some(t) = token { req = req.header("Authorization", format!("Bearer {t}")); } - req.send().unwrap().status().as_u16() + match req.send_json(json!({ "method": "health", "params": {} })) { + Ok(resp) => resp.status().as_u16(), + Err(ureq::Error::StatusCode(code)) => code, + Err(e) => panic!("request failed: {e}"), + } } fn stub(pairs: &[(&str, &str)]) -> Option> { @@ -292,3 +293,67 @@ fn oidc_verifier_rejects_forgeries() { let token = encode(&header, &nosub, &rsa_key()).unwrap(); assert!(verifier.verify(&token).is_err(), "missing sub"); } + +// --- layer 3: the loop closes — spoke ⇄ authed hub over real HTTP ----------- + +const OWNER: &str = "canonical-user"; + +/// Adopt the canonical owner over a temp store and share it. +fn shared_replica() -> SharedStore { + let dir = Box::leak(Box::new(tempfile::tempdir().unwrap())); + let mut store = + LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap(); + store.adopt_owner(OWNER).unwrap(); + Arc::new(Mutex::new(store)) +} + +#[tokio::test] +async fn spoke_syncs_to_an_authed_hub_only_with_a_valid_token() { + let issuer = start_mock_idp(); + + // A hub that requires tokens issued by the mock IdP. + let hub_store = shared_replica(); + let verifier = Arc::new(OidcVerifier::new(issuer.clone(), AUDIENCE)); + let hub_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let hub_url = format!("http://{}", hub_listener.local_addr().unwrap()); + { + let app = sync::router(hub_store.clone(), Some(verifier)); + tokio::spawn(async move { axum::serve(hub_listener, app).await.unwrap() }); + } + + // A spoke with a local node to push. + let spoke = shared_replica(); + let node_id = spoke + .lock() + .unwrap() + .create_node(NewNode::doc("Roof", "shingles")) + .unwrap() + .id; + + let http = reqwest::Client::new(); + + // Without a token the hub refuses the exchange. + assert!( + sync::sync_once(spoke.clone(), &hub_url, &http, None) + .await + .is_err(), + "unauthenticated sync must fail" + ); + + // With a valid token signed by the IdP, the push is accepted and the node + // reaches the hub. + let token = sign(&good_claims(&issuer), KID); + let report = sync::sync_once(spoke.clone(), &hub_url, &http, Some(&token)) + .await + .expect("authenticated sync succeeds"); + assert!(report.pushed > 0, "spoke pushed nothing"); + assert!( + hub_store + .lock() + .unwrap() + .get_node(&node_id) + .unwrap() + .is_some(), + "node did not reach the hub" + ); +} diff --git a/crates/hephd/tests/oauth.rs b/crates/hephd/tests/oauth.rs new file mode 100644 index 0000000..f61c872 --- /dev/null +++ b/crates/hephd/tests/oauth.rs @@ -0,0 +1,153 @@ +//! Device-code flow + token store (tech-spec §13, slice 10b), offline. +//! +//! A mock OAuth provider serves discovery, the device-authorization endpoint, +//! and the token endpoint (which reports `authorization_pending` once before +//! issuing tokens). We drive `DeviceFlow` against it with an injected no-op +//! sleep, so the polling loop is exercised deterministically and instantly. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; +use std::thread; +use std::time::Duration; + +use axum::extract::{Form, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; + +use hephd::oauth::{DeviceFlow, MemoryTokenStore, StoredToken, TokenStore}; + +#[derive(Clone)] +struct IdpState { + base: String, + /// How many times the token endpoint has been polled for the device code. + polls: Arc, +} + +/// Start a mock OIDC provider; return its base URL. +fn start_idp() -> String { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tx.send(base.clone()).unwrap(); + let state = IdpState { + base, + polls: Arc::new(AtomicUsize::new(0)), + }; + let app = Router::new() + .route("/.well-known/openid-configuration", get(discovery)) + .route("/device", post(device_authorization)) + .route("/token", post(token)) + .with_state(state); + axum::serve(listener, app).await.unwrap(); + }); + }); + rx.recv_timeout(Duration::from_secs(5)).unwrap() +} + +async fn discovery(State(s): State) -> Json { + Json(json!({ + "issuer": s.base, + "device_authorization_endpoint": format!("{}/device", s.base), + "token_endpoint": format!("{}/token", s.base), + })) +} + +async fn device_authorization(State(s): State) -> Json { + Json(json!({ + "device_code": "dev-code-xyz", + "user_code": "WDJB-MJHT", + "verification_uri": format!("{}/activate", s.base), + "interval": 1, + "expires_in": 300, + })) +} + +async fn token(State(s): State, Form(form): Form>) -> Response { + match form.get("grant_type").map(String::as_str) { + Some("urn:ietf:params:oauth:grant-type:device_code") => { + // Report pending on the first poll, then issue tokens. + if s.polls.fetch_add(1, Ordering::SeqCst) == 0 { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "authorization_pending" })), + ) + .into_response(); + } + Json(json!({ + "access_token": "access-1", + "refresh_token": "refresh-1", + "expires_in": 3600, + })) + .into_response() + } + Some("refresh_token") => Json(json!({ + "access_token": "access-2", + "expires_in": 3600, + })) + .into_response(), + _ => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "unsupported_grant_type" })), + ) + .into_response(), + } +} + +#[test] +fn device_flow_polls_pending_then_issues_a_token() { + let issuer = start_idp(); + let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap(); + + let auth = flow.start("openid").unwrap(); + assert_eq!(auth.user_code, "WDJB-MJHT"); + assert!(auth.verification_uri.contains("/activate")); + + // No real waiting — the injected sleep is a no-op. + let token = flow.poll(&auth, |_| {}).unwrap(); + assert_eq!(token.access_token, "access-1"); + assert_eq!(token.refresh_token.as_deref(), Some("refresh-1")); + assert!(token.expires_at > 0); +} + +#[test] +fn refresh_keeps_the_old_refresh_token_when_omitted() { + let issuer = start_idp(); + let flow = DeviceFlow::discover(&issuer, "heph-cli").unwrap(); + let refreshed = flow.refresh("refresh-1").unwrap(); + assert_eq!(refreshed.access_token, "access-2"); + // The provider omitted a new refresh token, so the old one is retained. + assert_eq!(refreshed.refresh_token.as_deref(), Some("refresh-1")); +} + +#[test] +fn memory_token_store_round_trips_and_reports_expiry() { + let store = MemoryTokenStore::default(); + assert!(store.load().is_none()); + + let token = StoredToken { + access_token: "a".into(), + refresh_token: Some("r".into()), + expires_at: 10_000, + }; + store.save(&token).unwrap(); + assert_eq!(store.load(), Some(token.clone())); + + assert!(!token.is_expired(5_000), "still valid well before expiry"); + assert!( + token.is_expired(10_000), + "expired at the boundary (with skew)" + ); + + store.clear().unwrap(); + assert!(store.load().is_none()); +} diff --git a/crates/hephd/tests/sync_http.rs b/crates/hephd/tests/sync_http.rs index c9c8e21..de8b7bf 100644 --- a/crates/hephd/tests/sync_http.rs +++ b/crates/hephd/tests/sync_http.rs @@ -70,9 +70,13 @@ async fn a_node_propagates_a_to_hub_to_b() { }; // A pushes to the hub; B pulls from it. - let up = sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); + let up = sync::sync_once(a.clone(), &hub_url, &http, None) + .await + .unwrap(); assert!(up.pushed > 0, "A pushed nothing"); - let down = sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + let down = sync::sync_once(b.clone(), &hub_url, &http, None) + .await + .unwrap(); assert!(down.applied > 0, "B applied nothing"); let on_b = b.lock().unwrap().get_node(&id).unwrap().expect("reached B"); @@ -98,8 +102,12 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() { .unwrap() .node_id }; - sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); - sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + sync::sync_once(a.clone(), &hub_url, &http, None) + .await + .unwrap(); + sync::sync_once(b.clone(), &hub_url, &http, None) + .await + .unwrap(); // Divergent offline edits on conflict-tracked fields; B's is later (higher // HLC) so its whole scalar snapshot wins. @@ -116,8 +124,12 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() { // A few exchanges in each direction settle it. for _ in 0..2 { - sync::sync_once(a.clone(), &hub_url, &http).await.unwrap(); - sync::sync_once(b.clone(), &hub_url, &http).await.unwrap(); + sync::sync_once(a.clone(), &hub_url, &http, None) + .await + .unwrap(); + sync::sync_once(b.clone(), &hub_url, &http, None) + .await + .unwrap(); } let ta = a.lock().unwrap().get_task(&task_id).unwrap().unwrap(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index e5bec27..c874259 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -11,5 +11,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Body text CRDT (§5, §12, slice 8d): node bodies now merge through the `yrs` text CRDT (`body_crdt`) instead of last-writer-wins — whole-buffer writes are diffed into the doc and the yrs delta rides the op, so concurrent edits to different regions both survive and never enqueue a conflict. - Network sync over HTTP (§6.1, §12, slice 9a): `hephd --mode server` exposes a sync hub (`POST /sync/push`, `GET /sync/pull?after=`, axum) over the same store; `hephd --mode local --hub-url ` becomes a spoke that background-syncs its op-log with that hub (and on demand via the `sync.now`/`sync.status` RPC). Exchange is incremental by HLC cursor (`sync_state`) and idempotent. The merge engine is `heph-core`'s, unchanged. Unauthenticated/single-owner for now (auth lands with OIDC). `conflicts.list`/`conflicts.resolve` are now reachable over the daemon socket. - Client mode (§3.1, slice 9b): `hephd --mode client --server-url ` runs with no local replica, proxying every store call to a server's `POST /rpc` endpoint (the full daemon API over HTTP). The daemon is now backend-agnostic (`local`/`server` front a `LocalStore`, `client` a `RemoteStore`), so surfaces see the same unix-socket API in every mode. -- Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer --oidc-audience ` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP). Client-side login (device-code flow) lands next. +- Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer --oidc-audience ` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP). +- Client authentication (§13, slice 10b): `heph auth login --hub-url --issuer --client-id ` runs the OAuth 2.0 device-code flow and caches the token in the OS keyring; spokes and `client` mode attach it to hub requests, refreshing on expiry (`--oidc-issuer`/`--oidc-client-id`). Offline-tested against a mock OAuth server and a full spoke-to-authenticated-hub loop. (Auth/proxy HTTP uses the runtime-free `ureq`, since `reqwest::blocking` is unsafe inside the async daemon.) - CI runs the Rust suite (fmt/clippy/test) via the project build hook. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 5998b5b..9deae82 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -327,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **108 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **112 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet). **Done** @@ -342,12 +342,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **Network sync (§6.1, §12, slice 9a):** **transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=` over the same store; a spoke (`local` + `hub_url`) runs `sync::sync_once` (pull→merge, then push) and background-syncs on a 30s interval. Incremental by HLC cursor (`sync_state`/`SyncCursors`); idempotent re-push is a no-op. Two spokes converge through a real-HTTP hub (incl. scalar conflict) in `tests/sync_http.rs`. **Unauthenticated for now, single-owner** (auth + per-user scoping is slice 10). - ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`. - ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected). -- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal. +- ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop. +- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). **Not yet done (resume order)** -1. ⏳ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`heph auth login`), token cache in the OS keyring + auto-refresh, the spoke attaching its bearer to `sync_once`/RPC, and local→authed **adoption** (owner-embedded deterministic-id rewrite). Multi-tenant hub (owner-per-token storage) remains a future extension beyond the current single-tenant gate. +1. ⏳ **Adoption refinement + multi-tenant (§13):** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. 2. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). ## Related