generated from eblume/project-template
hephd: OIDC hub authentication — verification side (auth 10a)
Some checks failed
Build / validate (pull_request) Failing after 3s
Some checks failed
Build / validate (pull_request) Failing after 3s
Authenticate op exchange at the network boundary (tech-spec §13). The hub now requires a valid OIDC bearer token on /sync/* and /rpc; local mode is unchanged (no auth). - heph-core: Store::authorize_owner_sub — single-tenant gate that claims the owner's oidc_sub on first sight, then authorizes only that sub (403 for any other identity). LocalStore impl over users.oidc_sub; RemoteStore stub. - hephd auth module: TokenVerifier trait (mockable seam) + OidcVerifier (jsonwebtoken, rust_crypto). Strict validation: RS256 pinned, exact iss + aud, exp/nbf, required sub; JWKS discovered + cached, refetched on unknown kid (rotation). Claims/AuthError. - Hub router takes Option<verifier>; an axum middleware on every route extracts the Bearer token, verifies it off the async worker, and runs the owner gate — 401 missing/invalid, 403 wrong identity, 503 IdP-unreachable. Open (no auth) when unconfigured, for local dev. - main: --oidc-issuer/--oidc-audience enable the hub verifier (server mode). - Security tests, all offline: stub-verifier middleware (missing/bad/valid + owner gate) and an adversarial battery driving OidcVerifier against an in-process mock IdP — rejects expired, wrong iss/aud, unknown kid, tampered signature, alg confusion (HS256/none), and missing sub. The RSA key + JWKS are generated at runtime (rsa/rand/base64 dev-deps) so no key is committed. - tech-spec: add an end-of-v1 dependency-refresh pass to the roadmap. 108 tests green; clippy -D warnings + fmt + prek clean. Next: client-side device-code login + keyring (10b). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d54e913c2
commit
497c62a988
17 changed files with 1235 additions and 29 deletions
608
Cargo.lock
generated
608
Cargo.lock
generated
|
|
@ -183,12 +183,24 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
|
|
@ -210,6 +222,15 @@ version = "2.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
|
|
@ -328,18 +349,82 @@ dependencies = [
|
|||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
|
|
@ -354,6 +439,38 @@ dependencies = [
|
|||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
|
|
@ -365,6 +482,65 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"hkdf",
|
||||
"pem-rfc7468",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
|
@ -414,9 +590,25 @@ version = "2.4.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -497,6 +689,28 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
|
|
@ -511,6 +725,17 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
|
|
@ -571,10 +796,14 @@ version = "0.0.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"clap",
|
||||
"fs4",
|
||||
"heph-core",
|
||||
"jsonwebtoken",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rsa",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
|
@ -584,6 +813,24 @@ dependencies = [
|
|||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.1"
|
||||
|
|
@ -830,11 +1077,38 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "10.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hmac",
|
||||
"js-sys",
|
||||
"p256",
|
||||
"p384",
|
||||
"pem",
|
||||
"rand 0.8.6",
|
||||
"rsa",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"signature",
|
||||
"simple_asn1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
|
|
@ -842,6 +1116,12 @@ version = "0.2.186"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
|
|
@ -933,6 +1213,58 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -940,6 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -954,6 +1287,30 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p384"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"primeorder",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
|
|
@ -982,6 +1339,25 @@ dependencies = [
|
|||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -1032,6 +1408,27 @@ version = "0.2.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs1"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
|
||||
dependencies = [
|
||||
"der",
|
||||
"pkcs8",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
|
|
@ -1047,6 +1444,12 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
|
|
@ -1056,6 +1459,15 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
|
|
@ -1076,7 +1488,7 @@ dependencies = [
|
|||
"bitflags",
|
||||
"num-traits",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
|
|
@ -1122,6 +1534,8 @@ version = "0.8.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
|
|
@ -1131,10 +1545,20 @@ version = "0.9.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
|
|
@ -1150,6 +1574,9 @@ name = "rand_core"
|
|||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
|
|
@ -1157,7 +1584,7 @@ version = "0.9.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1241,6 +1668,16 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rrule"
|
||||
version = "0.13.0"
|
||||
|
|
@ -1255,6 +1692,26 @@ dependencies = [
|
|||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
|
|
@ -1269,6 +1726,15 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
|
|
@ -1325,6 +1791,26 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
|
@ -1391,6 +1877,17 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
|
|
@ -1406,6 +1903,28 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
|
|
@ -1443,6 +1962,22 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
|
@ -1455,6 +1990,12 @@ version = "0.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
|
@ -1493,7 +2034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
|
|
@ -1548,6 +2089,37 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
|
|
@ -1698,6 +2270,12 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "ulid"
|
||||
version = "1.2.1"
|
||||
|
|
@ -2120,6 +2698,26 @@ dependencies = [
|
|||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||
clap = { version = "4", features = ["derive"] }
|
||||
fs4 = "0.12"
|
||||
axum = "0.8"
|
||||
jsonwebtoken = { version = "10", features = ["rust_crypto"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"json",
|
||||
"query",
|
||||
|
|
|
|||
|
|
@ -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 are implemented and replicas sync through a hub over HTTP** — the offline-first everyday config (`local` + `hub_url`) converges end-to-end with a `yrs` text-CRDT merging bodies, and `client` mode proxies to a server with no local replica. Remaining: auth and the Neovim plugin. Built test-first (102 tests at last update). The canonical tracker is **tech-spec §14**.
|
||||
**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**.
|
||||
|
||||
| Area | State |
|
||||
|---|---|
|
||||
|
|
@ -22,7 +22,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision
|
|||
| yrs text-CRDT for body merge | ✅ done |
|
||||
| `server` (hub) mode + spoke push/pull sync over HTTP (axum) | ✅ done |
|
||||
| `client` mode + `RemoteStore` (online-only, no replica) | ✅ done |
|
||||
| OIDC/Authentik auth + per-user isolation | ⏳ next |
|
||||
| OIDC hub auth — bearer-token verification + owner gate | ✅ done |
|
||||
| OIDC client — device-code login, keyring token cache | ⏳ next |
|
||||
| `heph.nvim` (primary surface) | ⏳ |
|
||||
|
||||
## Architecture
|
||||
|
|
@ -34,7 +35,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** *(planned)*: OIDC against Authentik, with per-user isolation.
|
||||
**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.
|
||||
|
||||
## Build & run
|
||||
|
||||
|
|
|
|||
|
|
@ -330,6 +330,29 @@ impl Store for LocalStore {
|
|||
syncstate::record(&self.conn, peer, pushed, pulled, now)
|
||||
}
|
||||
|
||||
fn authorize_owner_sub(&mut self, sub: &str) -> Result<bool> {
|
||||
// The owner's bound identity (NULL until first authenticated sync).
|
||||
let current: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT oidc_sub FROM users WHERE id = ?1",
|
||||
[&self.owner_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
match current {
|
||||
None => {
|
||||
self.conn.execute(
|
||||
"UPDATE users SET oidc_sub = ?1 WHERE id = ?2",
|
||||
(sub, &self.owner_id),
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
Some(existing) => Ok(existing == sub),
|
||||
}
|
||||
}
|
||||
|
||||
fn conflicts_list(&self) -> Result<Vec<Conflict>> {
|
||||
apply::list_conflicts(&self.conn, &self.owner_id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,13 @@ pub trait Store {
|
|||
fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>)
|
||||
-> Result<()>;
|
||||
|
||||
/// Single-tenant authentication gate (tech-spec §13). Map an OIDC `sub` to
|
||||
/// this store's owner: on first sight, **claim** the owner by binding its
|
||||
/// `oidc_sub`; thereafter authorize only that same `sub`. Returns `true` if
|
||||
/// the `sub` owns this store, `false` if a different identity presented a
|
||||
/// token. A hub calls this before serving any op exchange.
|
||||
fn authorize_owner_sub(&mut self, sub: &str) -> Result<bool>;
|
||||
|
||||
/// Open merge conflicts surfaced for the user (`heph conflicts`).
|
||||
fn conflicts_list(&self) -> Result<Vec<Conflict>>;
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,21 @@ fn sync_cursors_default_empty_then_advance_per_direction() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_sub_gate_claims_first_then_requires_match() {
|
||||
// Single-tenant gate (§13): the first sub claims the owner; only that sub
|
||||
// is authorized thereafter.
|
||||
let (mut a, _ca) = replica(1000);
|
||||
assert!(a.authorize_owner_sub("sub-alice").unwrap(), "first claims");
|
||||
assert!(a.authorize_owner_sub("sub-alice").unwrap(), "same sub ok");
|
||||
assert!(
|
||||
!a.authorize_owner_sub("sub-mallory").unwrap(),
|
||||
"a different identity must be rejected"
|
||||
);
|
||||
// Still bound to the original after a rejection.
|
||||
assert!(a.authorize_owner_sub("sub-alice").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_round_trip_propagates_a_node() {
|
||||
let (mut a, _ca) = replica(1000);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ tracing-subscriber.workspace = true
|
|||
clap.workspace = true
|
||||
fs4.workspace = true
|
||||
axum.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
reqwest.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
# Auth tests generate a throwaway RSA key + JWKS at runtime (no key in the repo).
|
||||
rsa = "0.9"
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
|
|
|
|||
153
crates/hephd/src/auth.rs
Normal file
153
crates/hephd/src/auth.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! Hub-side OIDC bearer-token verification (tech-spec §13).
|
||||
//!
|
||||
//! The hub authenticates op exchange at the **network boundary**: a request to
|
||||
//! `/sync/*` or `/rpc` must carry a valid OIDC access token. Verification is a
|
||||
//! [`TokenVerifier`] trait so the hub never hard-depends on a live IdP — tests
|
||||
//! inject a stub, and the real [`OidcVerifier`] is exercised against an
|
||||
//! in-process mock IdP (see `tests/auth_hub.rs`).
|
||||
//!
|
||||
//! Security posture (the easy-to-get-wrong parts, each covered by a test):
|
||||
//! - **algorithm pinning** — only `RS256`; `jsonwebtoken::decode` rejects any
|
||||
//! token whose header `alg` is not in the validation set, so `none`/`HS256`
|
||||
//! confusion cannot select a different key type.
|
||||
//! - **issuer + audience** — both required and matched exactly.
|
||||
//! - **expiry** — `exp` validated (and `nbf` if present).
|
||||
//! - **subject** — `sub` required.
|
||||
//! - **key rotation** — an unknown `kid` triggers a single JWKS refetch.
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
use jsonwebtoken::jwk::{Jwk, JwkSet};
|
||||
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The verified identity we rely on — only the subject is consumed; the rest of
|
||||
/// the token is validated by [`jsonwebtoken`] but discarded.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// The stable OIDC subject (Authentik `sub_mode: hashed_user_id`).
|
||||
pub sub: String,
|
||||
}
|
||||
|
||||
/// Why a token was not accepted.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
/// No bearer token was presented.
|
||||
#[error("missing bearer token")]
|
||||
Missing,
|
||||
/// The token was present but failed validation.
|
||||
#[error("invalid token: {0}")]
|
||||
Invalid(String),
|
||||
/// The identity provider could not be reached to fetch keys.
|
||||
#[error("identity provider unreachable: {0}")]
|
||||
Provider(String),
|
||||
}
|
||||
|
||||
/// Verifies a bearer token and returns its [`Claims`]. A trait so the hub can be
|
||||
/// tested with a stub and never requires a live IdP at test time.
|
||||
pub trait TokenVerifier: Send + Sync {
|
||||
/// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its
|
||||
/// claims, or an [`AuthError`].
|
||||
fn verify(&self, bearer: &str) -> Result<Claims, AuthError>;
|
||||
}
|
||||
|
||||
/// What an OIDC provider's discovery document tells us (we only need the JWKS).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Discovery {
|
||||
jwks_uri: String,
|
||||
}
|
||||
|
||||
/// Verifies tokens against a real OIDC provider: discovers the JWKS, caches it,
|
||||
/// and validates RS256 signatures + `iss`/`aud`/`exp`/`sub`.
|
||||
pub struct OidcVerifier {
|
||||
issuer: String,
|
||||
audience: String,
|
||||
http: reqwest::blocking::Client,
|
||||
jwks: RwLock<Option<JwkSet>>,
|
||||
}
|
||||
|
||||
impl OidcVerifier {
|
||||
/// Verify tokens whose `iss` equals `issuer` and `aud` contains `audience`.
|
||||
/// `issuer` must match the token's `iss` claim exactly (for Authentik, the
|
||||
/// per-application issuer `https://.../application/o/<slug>/`).
|
||||
pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> OidcVerifier {
|
||||
OidcVerifier {
|
||||
issuer: issuer.into(),
|
||||
audience: audience.into(),
|
||||
http: reqwest::blocking::Client::new(),
|
||||
jwks: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the JWKS URI from the provider's discovery document.
|
||||
fn jwks_uri(&self) -> Result<String, AuthError> {
|
||||
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()))?;
|
||||
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()))?;
|
||||
*self.jwks.write().expect("jwks lock poisoned") = Some(set);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The cached signing key for `kid`, if present.
|
||||
fn cached_key(&self, kid: &str) -> Option<Jwk> {
|
||||
self.jwks
|
||||
.read()
|
||||
.expect("jwks lock poisoned")
|
||||
.as_ref()
|
||||
.and_then(|set| set.find(kid).cloned())
|
||||
}
|
||||
|
||||
/// Find `kid`, refetching the JWKS once if it is not already cached (key
|
||||
/// rotation).
|
||||
fn key_for(&self, kid: &str) -> Result<Jwk, AuthError> {
|
||||
if let Some(key) = self.cached_key(kid) {
|
||||
return Ok(key);
|
||||
}
|
||||
self.refresh_jwks()?;
|
||||
self.cached_key(kid)
|
||||
.ok_or_else(|| AuthError::Invalid(format!("unknown signing key '{kid}'")))
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenVerifier for OidcVerifier {
|
||||
fn verify(&self, bearer: &str) -> Result<Claims, AuthError> {
|
||||
let header = decode_header(bearer).map_err(|e| AuthError::Invalid(e.to_string()))?;
|
||||
let kid = header
|
||||
.kid
|
||||
.ok_or_else(|| AuthError::Invalid("token header has no kid".into()))?;
|
||||
let jwk = self.key_for(&kid)?;
|
||||
let key = DecodingKey::from_jwk(&jwk).map_err(|e| AuthError::Invalid(e.to_string()))?;
|
||||
|
||||
// Strict validation: RS256 only (decode rejects other `alg`s), exact
|
||||
// issuer + audience, and require the claims we depend on.
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.set_issuer(&[&self.issuer]);
|
||||
validation.set_audience(&[&self.audience]);
|
||||
validation.set_required_spec_claims(&["exp", "sub", "aud", "iss"]);
|
||||
|
||||
let data = decode::<Claims>(bearer, &key, &validation)
|
||||
.map_err(|e| AuthError::Invalid(e.to_string()))?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
//! query/mutation logic all lives in `heph-core`; this crate is transport,
|
||||
//! locking, and (later) sync/auth.
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod clock;
|
||||
pub mod lock;
|
||||
|
|
@ -17,6 +18,7 @@ pub mod sync;
|
|||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use auth::{AuthError, Claims, OidcVerifier, TokenVerifier};
|
||||
pub use client::Client;
|
||||
pub use clock::SystemClock;
|
||||
pub use lock::LockGuard;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
//! `hephd` binary — starts the daemon in `local` or `server` mode.
|
||||
//! `hephd` binary — starts the daemon in `local`, `server`, or `client` mode.
|
||||
//!
|
||||
//! Both modes own the local SQLite file (exclusive lock) and serve surfaces
|
||||
//! over a unix socket. **server** additionally exposes the hub HTTP endpoint for
|
||||
//! spokes to sync against; a **local** instance given `--hub-url` becomes a
|
||||
//! syncing spoke that background-exchanges its op-log with that hub (tech-spec
|
||||
//! §3.1, §6.1, §12). `client` mode (no local replica) is a later slice.
|
||||
//! `local`/`server` own the local SQLite file (exclusive lock); `client` keeps
|
||||
//! no replica and proxies to a `--server-url`. All three serve surfaces over a
|
||||
//! unix socket. **server** additionally exposes the hub HTTP endpoint for spokes
|
||||
//! to sync against (requiring OIDC bearer tokens when `--oidc-issuer`/`-audience`
|
||||
//! are set); a **local** instance given `--hub-url` becomes a syncing spoke that
|
||||
//! background-exchanges its op-log with that hub (tech-spec §3.1, §6.1, §12, §13).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -60,6 +62,15 @@ struct Cli {
|
|||
/// Server to proxy to (client mode only; required there).
|
||||
#[arg(long)]
|
||||
server_url: Option<String>,
|
||||
|
||||
/// OIDC issuer to verify hub bearer tokens against (server mode). When set
|
||||
/// with --oidc-audience, the hub endpoints require a valid token.
|
||||
#[arg(long)]
|
||||
oidc_issuer: Option<String>,
|
||||
|
||||
/// OIDC audience (client id) hub tokens must carry (server mode).
|
||||
#[arg(long)]
|
||||
oidc_audience: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -106,7 +117,23 @@ async fn main() -> Result<()> {
|
|||
.http_addr
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_HTTP_ADDR.to_string());
|
||||
let app = sync::router(daemon.store());
|
||||
let verifier: Option<Arc<dyn hephd::TokenVerifier>> =
|
||||
match (cli.oidc_issuer.clone(), cli.oidc_audience.clone()) {
|
||||
(Some(issuer), Some(audience)) => {
|
||||
tracing::info!(%issuer, "hub requires OIDC bearer tokens");
|
||||
Some(Arc::new(hephd::OidcVerifier::new(issuer, audience)))
|
||||
}
|
||||
(None, None) => {
|
||||
tracing::warn!(
|
||||
"hub running UNAUTHENTICATED (no --oidc-issuer/--oidc-audience)"
|
||||
);
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("--oidc-issuer and --oidc-audience must be set together")
|
||||
}
|
||||
};
|
||||
let app = sync::router(daemon.store(), verifier);
|
||||
let http_listener = TcpListener::bind(&addr)
|
||||
.await
|
||||
.with_context(|| format!("binding hub HTTP endpoint {addr}"))?;
|
||||
|
|
|
|||
|
|
@ -206,6 +206,13 @@ impl Store for RemoteStore {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn authorize_owner_sub(&mut self, _sub: &str) -> Result<bool> {
|
||||
// Hub-side gate; a no-replica client never hosts an endpoint to guard.
|
||||
Err(Error::Remote(
|
||||
"authorize_owner_sub is a hub-side operation".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn conflicts_list(&self) -> Result<Vec<Conflict>> {
|
||||
self.call_as("conflicts.list", json!({}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -28,12 +30,21 @@ use serde_json::Value;
|
|||
|
||||
use heph_core::{Op, Store};
|
||||
|
||||
use crate::auth::{AuthError, TokenVerifier};
|
||||
use crate::rpc::{self, Response, RpcError, INTERNAL_ERROR};
|
||||
|
||||
/// The shared store a hub serves from — any [`Store`], so a `server` fronts a
|
||||
/// `LocalStore` and (later modes) could front another backend.
|
||||
pub type SharedStore = Arc<Mutex<dyn Store + Send>>;
|
||||
|
||||
/// What the hub HTTP routes share: the store and (when authentication is
|
||||
/// configured) the bearer-token verifier.
|
||||
#[derive(Clone)]
|
||||
struct HubState {
|
||||
store: SharedStore,
|
||||
verifier: Option<Arc<dyn TokenVerifier>>,
|
||||
}
|
||||
|
||||
/// A batch of ops in flight (push body / pull response).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OpsBody {
|
||||
|
|
@ -86,13 +97,70 @@ fn apply_batch(
|
|||
Ok((applied, max_hlc))
|
||||
}
|
||||
|
||||
/// The hub's HTTP router (server mode). Mount it on a TCP listener.
|
||||
pub fn router(store: SharedStore) -> Router {
|
||||
/// The hub's HTTP router (server mode). Mount it on a TCP listener. When
|
||||
/// `verifier` is `Some`, every route requires a valid OIDC bearer token whose
|
||||
/// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev).
|
||||
pub fn router(store: SharedStore, verifier: Option<Arc<dyn TokenVerifier>>) -> Router {
|
||||
let state = HubState { store, verifier };
|
||||
Router::new()
|
||||
.route("/sync/pull", get(pull))
|
||||
.route("/sync/push", post(push))
|
||||
.route("/rpc", post(rpc_call))
|
||||
.with_state(store)
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Reject any request lacking a valid bearer token whose `sub` owns this hub.
|
||||
/// A no-op when the hub has no verifier configured (open dev mode).
|
||||
async fn require_auth(
|
||||
State(state): State<HubState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<AxumResponse, StatusCode> {
|
||||
let Some(verifier) = state.verifier.clone() else {
|
||||
return Ok(next.run(request).await); // open: no auth configured
|
||||
};
|
||||
|
||||
let Some(token) = bearer_token(&request) else {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
|
||||
// Verification (and the store gate) hit the network / DB — run off the async
|
||||
// worker on the blocking pool.
|
||||
let claims = tokio::task::spawn_blocking(move || verifier.verify(&token))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(|e| match e {
|
||||
AuthError::Provider(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::UNAUTHORIZED,
|
||||
})?;
|
||||
|
||||
// Single-tenant gate: the token's identity must own this hub.
|
||||
let store = state.store.clone();
|
||||
let owns = tokio::task::spawn_blocking(move || {
|
||||
store
|
||||
.lock()
|
||||
.expect("store mutex poisoned")
|
||||
.authorize_owner_sub(&claims.sub)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
if !owns {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Extract the `Authorization: Bearer <token>` value, if present.
|
||||
fn bearer_token(request: &Request) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// One `POST /rpc` call: a method name + params, mirroring the unix-socket RPC.
|
||||
|
|
@ -106,8 +174,8 @@ struct RpcCall {
|
|||
/// `POST /rpc` — run one [`rpc::dispatch`] call on the hub's store and return a
|
||||
/// JSON-RPC-shaped [`Response`] (result xor error). Always HTTP 200; method
|
||||
/// failures travel in the body so the client can reconstruct the error.
|
||||
async fn rpc_call(State(store): State<SharedStore>, Json(call): Json<RpcCall>) -> Json<Response> {
|
||||
let store = store.clone();
|
||||
async fn rpc_call(State(state): State<HubState>, Json(call): Json<RpcCall>) -> Json<Response> {
|
||||
let store = state.store.clone();
|
||||
let dispatched = tokio::task::spawn_blocking(move || {
|
||||
let mut guard = store.lock().expect("store mutex poisoned");
|
||||
rpc::dispatch(&mut *guard, &call.method, call.params)
|
||||
|
|
@ -136,10 +204,10 @@ struct PullQuery {
|
|||
|
||||
/// `GET /sync/pull?after=<hlc>` — ops past the caller's cursor, HLC order.
|
||||
async fn pull(
|
||||
State(store): State<SharedStore>,
|
||||
State(state): State<HubState>,
|
||||
Query(q): Query<PullQuery>,
|
||||
) -> Result<Json<OpsBody>, StatusCode> {
|
||||
let ops = with_store(&store, move |s| s.ops_since(q.after.as_deref()))
|
||||
let ops = with_store(&state.store, move |s| s.ops_since(q.after.as_deref()))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(OpsBody { ops }))
|
||||
|
|
@ -147,10 +215,10 @@ async fn pull(
|
|||
|
||||
/// `POST /sync/push` — merge the caller's ops; reply with how many newly applied.
|
||||
async fn push(
|
||||
State(store): State<SharedStore>,
|
||||
State(state): State<HubState>,
|
||||
Json(body): Json<OpsBody>,
|
||||
) -> Result<Json<SyncReport>, StatusCode> {
|
||||
let (applied, _max) = with_store(&store, move |s| apply_batch(s, body.ops))
|
||||
let (applied, _max) = with_store(&state.store, move |s| apply_batch(s, body.ops))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(SyncReport {
|
||||
|
|
|
|||
294
crates/hephd/tests/auth_hub.rs
Normal file
294
crates/hephd/tests/auth_hub.rs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
//! Hub authentication (tech-spec §13, slice 10a). Two layers, both offline:
|
||||
//!
|
||||
//! 1. **Middleware + owner gate** via a stub verifier — proves the hub rejects
|
||||
//! missing/invalid tokens, admits valid ones, and enforces single-tenant
|
||||
//! ownership — with zero crypto.
|
||||
//! 2. **`OidcVerifier` against an in-process mock IdP** (a real RSA key + JWKS) —
|
||||
//! an adversarial battery proving the *crypto* path accepts a good token and
|
||||
//! rejects every common forgery (expired, wrong iss/aud, unknown kid,
|
||||
//! tampered signature, `alg` confusion, missing `sub`).
|
||||
//!
|
||||
//! No external IdP is touched; Authentik is only needed for a manual smoke test.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{mpsc, Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
use rsa::pkcs8::{EncodePrivateKey, LineEnding};
|
||||
use rsa::traits::PublicKeyParts;
|
||||
use rsa::{RsaPrivateKey, RsaPublicKey};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use heph_core::{FixedClock, LocalStore};
|
||||
use hephd::auth::{AuthError, Claims, OidcVerifier, TokenVerifier};
|
||||
use hephd::sync::{self, SharedStore};
|
||||
|
||||
const NOW: i64 = 1_704_067_200_000;
|
||||
const AUDIENCE: &str = "heph-hub";
|
||||
const KID: &str = "test-key-1";
|
||||
|
||||
/// A throwaway RSA keypair generated once per test run — no key material lives
|
||||
/// in the repo. `pem` signs tokens; `n`/`e` (base64url) are served in the JWKS.
|
||||
struct TestKey {
|
||||
pem: String,
|
||||
n: String,
|
||||
e: String,
|
||||
}
|
||||
|
||||
fn test_key() -> &'static TestKey {
|
||||
static KEY: OnceLock<TestKey> = OnceLock::new();
|
||||
KEY.get_or_init(|| {
|
||||
let mut rng = rand::thread_rng();
|
||||
let private = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA key");
|
||||
let public = RsaPublicKey::from(&private);
|
||||
TestKey {
|
||||
pem: private
|
||||
.to_pkcs8_pem(LineEnding::LF)
|
||||
.expect("encode PEM")
|
||||
.to_string(),
|
||||
n: URL_SAFE_NO_PAD.encode(public.n().to_bytes_be()),
|
||||
e: URL_SAFE_NO_PAD.encode(public.e().to_bytes_be()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- layer 1: middleware + owner gate (stub verifier) ---------------------
|
||||
|
||||
/// A verifier that maps known opaque tokens to subjects — no crypto.
|
||||
struct StubVerifier(HashMap<String, String>);
|
||||
impl TokenVerifier for StubVerifier {
|
||||
fn verify(&self, bearer: &str) -> Result<Claims, AuthError> {
|
||||
self.0
|
||||
.get(bearer)
|
||||
.map(|sub| Claims { sub: sub.clone() })
|
||||
.ok_or_else(|| AuthError::Invalid("unknown stub token".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a hub with the given verifier over a fresh temp store; return base URL.
|
||||
fn start_hub(verifier: Option<Arc<dyn TokenVerifier>>) -> 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 dir = tempfile::tempdir().unwrap();
|
||||
let store =
|
||||
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
|
||||
let shared: SharedStore = Arc::new(Mutex::new(store));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
tx.send(listener.local_addr().unwrap()).unwrap();
|
||||
let _keep = dir;
|
||||
axum::serve(listener, sync::router(shared, verifier))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
});
|
||||
format!(
|
||||
"http://{}",
|
||||
rx.recv_timeout(Duration::from_secs(5)).unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
/// `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": {} }));
|
||||
if let Some(t) = token {
|
||||
req = req.header("Authorization", format!("Bearer {t}"));
|
||||
}
|
||||
req.send().unwrap().status().as_u16()
|
||||
}
|
||||
|
||||
fn stub(pairs: &[(&str, &str)]) -> Option<Arc<dyn TokenVerifier>> {
|
||||
let map = pairs
|
||||
.iter()
|
||||
.map(|(t, s)| (t.to_string(), s.to_string()))
|
||||
.collect();
|
||||
Some(Arc::new(StubVerifier(map)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_hub_needs_no_token() {
|
||||
let base = start_hub(None);
|
||||
assert_eq!(rpc_health_status(&base, None), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authed_hub_rejects_missing_and_bad_tokens_admits_valid() {
|
||||
let base = start_hub(stub(&[("tok-alice", "alice")]));
|
||||
assert_eq!(rpc_health_status(&base, None), 401, "missing token");
|
||||
assert_eq!(rpc_health_status(&base, Some("garbage")), 401, "bad token");
|
||||
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_gate_rejects_a_second_identity() {
|
||||
let base = start_hub(stub(&[("tok-alice", "alice"), ("tok-mallory", "mallory")]));
|
||||
// Alice authenticates first and claims the hub.
|
||||
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200);
|
||||
// A different valid identity is forbidden (single-tenant isolation).
|
||||
assert_eq!(rpc_health_status(&base, Some("tok-mallory")), 403);
|
||||
// Alice still works.
|
||||
assert_eq!(rpc_health_status(&base, Some("tok-alice")), 200);
|
||||
}
|
||||
|
||||
// --- layer 2: OidcVerifier against a mock IdP (real RS256) -----------------
|
||||
|
||||
/// Start a mock OIDC provider (discovery + JWKS) on an ephemeral port; the
|
||||
/// returned URL is both the issuer and the discovery base.
|
||||
fn start_mock_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 app = Router::new()
|
||||
.route("/.well-known/openid-configuration", get(discovery))
|
||||
.route("/jwks", get(jwks))
|
||||
.with_state(base);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(5)).unwrap()
|
||||
}
|
||||
|
||||
async fn discovery(State(base): State<String>) -> Json<Value> {
|
||||
Json(json!({ "issuer": base, "jwks_uri": format!("{base}/jwks") }))
|
||||
}
|
||||
|
||||
async fn jwks() -> Json<Value> {
|
||||
let key = test_key();
|
||||
Json(json!({
|
||||
"keys": [{
|
||||
"kty": "RSA", "use": "sig", "alg": "RS256",
|
||||
"kid": KID, "n": key.n, "e": key.e,
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TokenClaims {
|
||||
sub: String,
|
||||
iss: String,
|
||||
aud: String,
|
||||
exp: u64,
|
||||
iat: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NoSubClaims {
|
||||
iss: String,
|
||||
aud: String,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn rsa_key() -> EncodingKey {
|
||||
EncodingKey::from_rsa_pem(test_key().pem.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
/// Sign an RS256 token with the given `kid` and standard claims.
|
||||
fn sign(claims: &TokenClaims, kid: &str) -> String {
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some(kid.to_string());
|
||||
encode(&header, claims, &rsa_key()).unwrap()
|
||||
}
|
||||
|
||||
fn good_claims(issuer: &str) -> TokenClaims {
|
||||
TokenClaims {
|
||||
sub: "hashed-eblume".into(),
|
||||
iss: issuer.into(),
|
||||
aud: AUDIENCE.into(),
|
||||
exp: unix_now() + 3600,
|
||||
iat: unix_now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_verifier_accepts_a_valid_token() {
|
||||
let issuer = start_mock_idp();
|
||||
let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE);
|
||||
let token = sign(&good_claims(&issuer), KID);
|
||||
let claims = verifier.verify(&token).expect("valid token accepted");
|
||||
assert_eq!(claims.sub, "hashed-eblume");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_verifier_rejects_forgeries() {
|
||||
let issuer = start_mock_idp();
|
||||
let verifier = OidcVerifier::new(issuer.clone(), AUDIENCE);
|
||||
|
||||
// expired (well past jsonwebtoken's default 60s leeway)
|
||||
let mut c = good_claims(&issuer);
|
||||
c.exp = unix_now() - 3600;
|
||||
assert!(verifier.verify(&sign(&c, KID)).is_err(), "expired");
|
||||
|
||||
// wrong issuer
|
||||
let mut c = good_claims(&issuer);
|
||||
c.iss = "https://evil.example".into();
|
||||
assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong iss");
|
||||
|
||||
// wrong audience
|
||||
let mut c = good_claims(&issuer);
|
||||
c.aud = "someone-else".into();
|
||||
assert!(verifier.verify(&sign(&c, KID)).is_err(), "wrong aud");
|
||||
|
||||
// unknown signing key (kid not in JWKS, even after refetch)
|
||||
assert!(
|
||||
verifier
|
||||
.verify(&sign(&good_claims(&issuer), "other-kid"))
|
||||
.is_err(),
|
||||
"unknown kid"
|
||||
);
|
||||
|
||||
// tampered signature
|
||||
let mut token = sign(&good_claims(&issuer), KID);
|
||||
let last = token.pop().unwrap();
|
||||
token.push(if last == 'A' { 'B' } else { 'A' });
|
||||
assert!(verifier.verify(&token).is_err(), "tampered signature");
|
||||
|
||||
// algorithm confusion: HS256-signed token must be rejected (RS256 pinned)
|
||||
let mut hs = Header::new(Algorithm::HS256);
|
||||
hs.kid = Some(KID.to_string());
|
||||
let hs_token = encode(&hs, &good_claims(&issuer), &EncodingKey::from_secret(b"x")).unwrap();
|
||||
assert!(verifier.verify(&hs_token).is_err(), "HS256 confusion");
|
||||
|
||||
// alg: none — a header claiming no signature
|
||||
let none_token = "eyJhbGciOiJub25lIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJzdWIiOiJ4In0.";
|
||||
assert!(verifier.verify(none_token).is_err(), "alg none");
|
||||
|
||||
// missing sub
|
||||
let nosub = NoSubClaims {
|
||||
iss: issuer.clone(),
|
||||
aud: AUDIENCE.into(),
|
||||
exp: unix_now() + 3600,
|
||||
};
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some(KID.to_string());
|
||||
let token = encode(&header, &nosub, &rsa_key()).unwrap();
|
||||
assert!(verifier.verify(&token).is_err(), "missing sub");
|
||||
}
|
||||
|
|
@ -32,7 +32,9 @@ fn start_server() -> String {
|
|||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
tx.send(listener.local_addr().unwrap()).unwrap();
|
||||
let _keep = dir; // keep the temp DB alive while we serve
|
||||
axum::serve(listener, sync::router(shared)).await.unwrap();
|
||||
axum::serve(listener, sync::router(shared, None))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
});
|
||||
let addr = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ async fn start_hub() -> String {
|
|||
Box::leak(Box::new(dir)); // keep the hub DB file alive
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let app = sync::router(hub);
|
||||
let app = sync::router(hub, None);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ 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=<hlc>`, axum) over the same store; `hephd --mode local --hub-url <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 <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 <url> --oidc-audience <client-id>` (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.
|
||||
- CI runs the Rust suite (fmt/clippy/test) via the project build hook.
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ All layers are required; CI runs them on every push/PR (extend `.forgejo/scripts
|
|||
- **Web UI** (the hub serves sync only in v1; reserve `axum` for it later).
|
||||
- **Actual k3s deployment** to blumeops (Dagger→Zot image, ArgoCD app + Kustomize manifests, external-secrets) — fast-follow once the architecture is proven; the hub binary is built to be deployable.
|
||||
- **Calendar** integration (read-mostly CalDAV; **never explode recurrence into stored events**), iOS/Watch capture, inferred/semantic context, P2P-over-tailnet sync fallback.
|
||||
- **Dependency-refresh pass:** before declaring v1 done, sweep every external dependency (Cargo crates, the Neovim plugin's deps, CI/tooling) up to its latest stable release, re-running the full suite + `clippy`/`fmt` to catch breakage. Slices add deps at the version current when written; this reconciles them once at the end rather than churning mid-build.
|
||||
|
||||
See [[design]] §5–§7 for the constraints later phases impose on present choices (keep tasks vs. calendar events separate; expand RRULEs lazily).
|
||||
|
||||
|
|
@ -326,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 — **102 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 — **108 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet).
|
||||
|
||||
**Done**
|
||||
|
||||
|
|
@ -340,12 +341,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi
|
|||
- ✅ **Body text CRDT (§5, §12, slice 8d):** node bodies merge through the **`yrs`** text CRDT (`body_crdt` BLOB) instead of LWW. A device authors under a stable `client_id` derived from its `origin`; whole-buffer writes are diffed (common prefix/suffix, char-boundary safe) into the doc; the yrs delta rides the `node.create`/`node.set` op (`body_crdt` field) and `apply` merges it — concurrent disjoint edits both survive and never enqueue a conflict.
|
||||
- ✅ **Network sync (§6.1, §12, slice 9a):** **transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=<hlc>` 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.
|
||||
- ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup).
|
||||
|
||||
**Not yet done (resume order)**
|
||||
|
||||
1. ⏳ **OIDC/Authentik auth (§13):** device-code flow, bearer token on the hub `POST /sync/*` + `/rpc` endpoints, full per-user isolation, adoption-with-deterministic-ids.
|
||||
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.
|
||||
2. ⏳ **`heph.nvim` (§8):** obsidian.nvim parity + task views; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner).
|
||||
|
||||
## Related
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue