From a7246389ae8115bab036e4edf5f5240d06901251 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 4 Oct 2026 21:48:47 -0700 Subject: [PATCH] Move Snowglobe and Files authentication into the Rust dashboard Preserve account and credential IDs, import existing password hashes and passkeys, and add revocable invitations with optional passkey onboarding. Use native host-bound sessions for Snowglobe and Copyparty while retaining password-protected file shares and Keycloak for other services. Back up and restore native account and connection stores with verified SQLite copies. Include the verified MCP catalog and consent pages plus prompted cross-platform agent installers. Redirect the unsupported nested userscript URL to discord-pluralkit-predict. Assisted-by: gpt-6.1-sol --- dashboard/.gitignore | 1 + dashboard/Cargo.lock | 451 ++++++++++- dashboard/Cargo.toml | 4 + dashboard/agent/install.ps1 | 41 + dashboard/agent/install.sh | 53 ++ dashboard/agent/setup.mjs | 192 +++++ dashboard/agent/source.json | 4 + dashboard/package.json | 1 + dashboard/pnpm-lock.yaml | 4 +- dashboard/src/auth.rs | 1114 ++++++++++++++++++++++++++ dashboard/src/cache.rs | 73 -- dashboard/src/core.rs | 9 +- dashboard/src/main.rs | 115 ++- dashboard/src/mcp.rs | 190 +++-- dashboard/src/relay.rs | 27 + dashboard/src/shale.rs | 52 +- dashboard/src/telemetry.rs | 24 +- dashboard/src/users.rs | 647 +++++++-------- dashboard/web/api.contract.ts | 33 +- dashboard/web/auth.ts | 24 + dashboard/web/components/Sidebar.tsx | 3 +- dashboard/web/main.tsx | 8 + dashboard/web/pages/Account.tsx | 134 ++-- dashboard/web/pages/MCP.css | 79 +- dashboard/web/pages/MCP.tsx | 189 +++-- dashboard/web/pages/MCPAccess.tsx | 34 + dashboard/web/pages/MCPConsent.tsx | 84 ++ dashboard/web/pages/SignIn.css | 10 + dashboard/web/pages/SignIn.tsx | 58 ++ dashboard/web/pages/Users.tsx | 100 +-- dashboard/web/types/mcp.ts | 14 +- dashboard/web/types/users.ts | 3 - nixos/configuration.nix | 3 + nixos/dashboard.nix | 6 +- readme.md | 110 ++- service/copyparty/copyparty.conf | 7 +- tools/build-agent.py | 29 + tools/dashboard-agent-test.py | 175 ++++ tools/dashboard-auth-test.py | 196 +++++ tools/dashboard-backup-test.py | 71 ++ tools/dashboard-mcp-test.py | 2 +- tools/dashboard-relay-test.py | 2 +- tools/dashboard-shale-link-test.py | 56 +- tools/data.py | 48 +- tools/deploy.py | 3 + tools/import-dashboard-auth.py | 59 ++ tools/router.py | 57 +- 47 files changed, 3783 insertions(+), 816 deletions(-) create mode 100644 dashboard/agent/install.ps1 create mode 100644 dashboard/agent/install.sh create mode 100644 dashboard/agent/setup.mjs create mode 100644 dashboard/agent/source.json create mode 100644 dashboard/src/auth.rs create mode 100644 dashboard/web/auth.ts create mode 100644 dashboard/web/pages/MCPAccess.tsx create mode 100644 dashboard/web/pages/MCPConsent.tsx create mode 100644 dashboard/web/pages/SignIn.css create mode 100644 dashboard/web/pages/SignIn.tsx create mode 100644 tools/build-agent.py create mode 100644 tools/dashboard-agent-test.py create mode 100644 tools/dashboard-auth-test.py create mode 100644 tools/dashboard-backup-test.py create mode 100644 tools/import-dashboard-auth.py diff --git a/dashboard/.gitignore b/dashboard/.gitignore index c9b60c56f8344e5229a9ef30ed024babbcbbc819..2925148a23320aa72f16daa5d6e8ee24b2a5a3ad 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -3,3 +3,4 @@ dist/ .cache/ data/ target/ +agent/relay.mjs diff --git a/dashboard/Cargo.lock b/dashboard/Cargo.lock index 5625afbec5c4d972b7fdcffa373943742a7398bc..9fc05f11cb81d84eeb56cfe17e2d7a151ed563db 100644 --- a/dashboard/Cargo.lock +++ b/dashboard/Cargo.lock @@ -20,6 +20,57 @@ dependencies = [ "libc", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -99,6 +150,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -111,12 +168,38 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey 0.1.1", + "serde", +] + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -225,6 +308,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -264,6 +353,26 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "2.1.1" @@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -393,6 +503,21 @@ 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" @@ -552,6 +677,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -576,10 +712,17 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home-dashboard" version = "0.1.0" dependencies = [ + "argon2", "axum", "base64 0.22.1", "bytes", @@ -592,6 +735,8 @@ dependencies = [ "rmcp", "rusqlite", "scraper", + "serde", + "serde_cbor_2", "serde_json", "sha1", "sha2", @@ -602,6 +747,7 @@ dependencies = [ "url", "uuid", "walkdir", + "webauthn-rs", ] [[package]] @@ -889,6 +1035,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -972,6 +1124,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.3" @@ -1012,6 +1170,41 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[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.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1021,12 +1214,58 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +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 2.0.119", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1050,6 +1289,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.3" @@ -1136,6 +1392,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" @@ -1174,7 +1436,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -1196,7 +1458,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -1268,6 +1530,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -1419,14 +1687,14 @@ dependencies = [ "http-body", "http-body-util", "indexmap", - "pastey", + "pastey 0.2.3", "pin-project-lite", "rand 0.10.3", "schemars", "serde", "serde_json", "sse-stream", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -1464,6 +1732,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustls" version = "0.23.45" @@ -1601,6 +1878,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1835,6 +2122,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -1855,13 +2153,33 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1875,6 +2193,36 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2073,7 +2421,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -2110,6 +2458,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2263,6 +2613,74 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.5", + "rand_chacha", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2434,6 +2852,23 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2454,7 +2889,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "synstructure", + "synstructure 0.14.0", ] [[package]] @@ -2495,7 +2930,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "synstructure", + "synstructure 0.14.0", ] [[package]] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 430eb082959dd61a6413c1a308de3475e1d363da..32b52b5dee99a6a334ea2b1df728ed4c41bec176 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +argon2 = "0.5" axum = { version = "0.8.9", features = ["multipart", "ws"] } base64 = "0.22" bytes = "1" @@ -15,6 +16,8 @@ regex = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_cbor_2 = "0.13" scraper = { version = "0.27", default-features = false } serde_json = "1" sha1 = "0.10" @@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] } url = "2" uuid = { version = "1", features = ["v4"] } walkdir = "2" +webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } [profile.release] lto = "thin" diff --git a/dashboard/agent/install.ps1 b/dashboard/agent/install.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..41f1caaa7c69037074ba196b0e3241e81621d59e --- /dev/null +++ b/dashboard/agent/install.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Stop' +$Server = __SERVER__ + +function Install-Agent { + $Identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $Principal = New-Object Security.Principal.WindowsPrincipal($Identity) + if ($Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this installer from your usual PowerShell window, without administrator privileges.' + } + $Base = Join-Path $env:LOCALAPPDATA 'AgentRelay' + $Stage = Join-Path $Base ('.install.' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force $Base | Out-Null + & icacls.exe $Base /inheritance:r /grant:r ('*' + $Identity.User.Value + ':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Unable to protect the agent folder. Check its permissions and retry.' } + try { + New-Item -ItemType Directory -Force $Stage | Out-Null + $Node = Join-Path $Base 'node.exe' + if (!(Test-Path $Node)) { + Write-Host 'Downloading the agent runtime…' + $Arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { 'arm64' } else { 'x64' } + $Checksums = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 'https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt').Content + $Match = [regex]::Match($Checksums, "(?m)^([a-f0-9]{64})\s+(node-(v24\.[0-9]+\.[0-9]+)-win-$Arch\.zip)\s*$") + if (!$Match.Success) { throw 'No runtime download is available for this machine.' } + $Archive = Join-Path $Stage $Match.Groups[2].Value + Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 ("https://nodejs.org/dist/" + $Match.Groups[3].Value + '/' + $Match.Groups[2].Value) -OutFile $Archive + if ((Get-FileHash $Archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Match.Groups[1].Value) { + throw 'The runtime checksum did not match. Run the installer again.' + } + Expand-Archive $Archive $Stage + Copy-Item (Join-Path $Stage ($Match.Groups[2].Value.Replace('.zip', '') + '\node.exe')) $Node + } + Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/relay.mjs" -OutFile (Join-Path $Stage 'relay.mjs') + Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/setup.mjs" -OutFile (Join-Path $Stage 'setup.mjs') + & $Node (Join-Path $Stage 'setup.mjs') install $Server $Base + if ($LASTEXITCODE -ne 0) { throw 'Installation stopped. Correct the problem above, then run the installer again.' } + } finally { + Remove-Item -Recurse -Force $Stage + } +} + +Install-Agent diff --git a/dashboard/agent/install.sh b/dashboard/agent/install.sh new file mode 100644 index 0000000000000000000000000000000000000000..3e9b3d13cf804b24fca2ce45ce9150e1a61ed2e3 --- /dev/null +++ b/dashboard/agent/install.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu +umask 077 +server=__SERVER__ + +install_agent() { + [ "$(id -u)" != 0 ] || { echo 'Run this installer as your login user, without sudo.' >&2; return 1; } + case $(uname -s) in + Linux) os=linux; base=${XDG_DATA_HOME:-"$HOME/.local/share"}/agent-relay + command -v systemctl >/dev/null || { echo 'This installer needs a systemd user session.' >&2; return 1; } + systemctl --user show-environment >/dev/null || { echo 'Sign in to a systemd user session, then run the installer again.' >&2; return 1; } ;; + Darwin) os=darwin; base="$HOME/Library/Application Support/AgentRelay" ;; + *) echo "Use $server/agent/install.ps1 from Windows PowerShell." >&2; return 1 ;; + esac + case $(uname -m) in + x86_64|amd64) arch=x64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo 'This installer supports x64 and arm64 machines.' >&2; return 1 ;; + esac + command -v curl >/dev/null || { echo 'Install curl, then run this installer again.' >&2; return 1; } + mkdir -p "$base" + chmod 700 "$base" + stage=$(mktemp -d "$base/.install.XXXXXX") + trap 'rm -rf "$stage"' EXIT HUP INT TERM + if [ ! -x "$base/node" ] && [ -f /etc/NIXOS ]; then + echo 'Installing the agent runtime…' + nix --extra-experimental-features 'nix-command flakes' build nixpkgs#nodejs_24 --out-link "$base/node-runtime" + ln -sf "$base/node-runtime/bin/node" "$base/node" + elif [ ! -x "$base/node" ]; then + echo 'Downloading the agent runtime…' + curl -fsSL https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt -o "$stage/checksums" + archive=$(awk -v suffix="-$os-$arch.tar.gz" '$2 ~ /^node-v24\./ && substr($2, length($2)-length(suffix)+1) == suffix {print $2}' "$stage/checksums") + [ -n "$archive" ] || { echo 'No runtime download is available for this machine.' >&2; return 1; } + version=${archive#node-}; version=${version%%-$os-*} + curl -fsSL "https://nodejs.org/dist/$version/$archive" -o "$stage/$archive" + expected=$(awk -v file="$archive" '$2 == file {print $1}' "$stage/checksums") + if command -v sha256sum >/dev/null; then + actual=$(sha256sum "$stage/$archive"); actual=${actual%% *} + else + actual=$(shasum -a 256 "$stage/$archive"); actual=${actual%% *} + fi + [ "$actual" = "$expected" ] || { echo 'The runtime checksum did not match. Run the installer again.' >&2; return 1; } + tar -xzf "$stage/$archive" -C "$stage" + cp "$stage/${archive%.tar.gz}/bin/node" "$base/node" + chmod 700 "$base/node" + fi + curl -fsSL "$server/agent/relay.mjs" -o "$stage/relay.mjs" + curl -fsSL "$server/agent/setup.mjs" -o "$stage/setup.mjs" + "$base/node" "$stage/setup.mjs" install "$server" "$base" execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'inherit' }); +const psQuote = (text) => "'" + text.replaceAll("'", "''") + "'"; +const shellQuote = (text) => "'" + text.replaceAll("'", "'\\''") + "'"; + +async function stop() { + if (windows) ps(`$ErrorActionPreference = 'Stop' +if (Get-ScheduledTask -TaskName ${psQuote(task)} -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName ${psQuote(task)} } +Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { + $_.ExecutablePath -eq ${psQuote(join(base, 'node.exe'))} -and $_.CommandLine.Contains(${psQuote(join(base, 'relay.mjs'))}) +} | ForEach-Object { + & taskkill.exe /PID $_.ProcessId /T /F | Out-Null + if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue)) { throw 'Unable to stop the previous agent. Close it and run the installer again.' } +}`); + else if (mac) { + if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status !== 0) return; + execFileSync('launchctl', ['bootout', `${domain}/net.paperclover.agent-relay`]); + for (let attempt = 0; attempt < 40; attempt++) { + try { execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }); } + catch { return; } + await sleep(250); + } + throw new Error('The previous agent is still stopping. Wait and run the installer again.'); + } + else if (spawnSync('systemctl', ['--user', 'show', '-P', 'LoadState', 'agent-relay.service'], { encoding: 'utf8' }).stdout.trim() === 'loaded') { + execFileSync('systemctl', ['--user', 'stop', 'agent-relay.service']); + } +} + +async function main() { + if (action === 'stop') { await stop(); return; } + if (action === 'start') { + if (windows) ps(`$ErrorActionPreference = 'Stop'; Start-ScheduledTask -TaskName ${psQuote(task)}`); + else if (mac) { + if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status === 0) { + execFileSync('launchctl', ['kickstart', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' }); + } else execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' }); + } + else execFileSync('systemctl', ['--user', 'start', 'agent-relay.service'], { stdio: 'inherit' }); + return; + } + if (action === 'status') { + if (windows) ps(`$ErrorActionPreference = 'Stop'; Get-ScheduledTask -TaskName ${psQuote(task)} | Select-Object TaskName,State`); + else if (mac) execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' }); + else execFileSync('systemctl', ['--user', 'status', '--no-pager', 'agent-relay.service'], { stdio: 'inherit' }); + return; + } + if (action === 'uninstall') { + await stop(); + if (windows) ps(`$ErrorActionPreference = 'Stop'; Unregister-ScheduledTask -TaskName ${psQuote(task)} -Confirm:$false`); + else { + if (!mac) execFileSync('systemctl', ['--user', 'disable', 'agent-relay.service'], { stdio: 'inherit' }); + await rm(unit, { force: true }); + if (!mac) execFileSync('systemctl', ['--user', 'daemon-reload']); + } + console.log(`Startup removed. Pairing and files remain in ${base} and ${data}.`); + return; + } + if (action !== 'install' || !server || !installDir) throw new Error('Use install, status, start, stop, or uninstall.'); + const origin = new URL(server); + if (origin.origin !== server || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) { + throw new Error('Use an HTTPS dashboard origin, or localhost for a preview.'); + } + const staged = dirname(process.argv[1]); + let previous; + try { previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (previous && previous.server !== server) throw new Error(`This machine is paired to ${previous.server}. Unlink it there before changing dashboards.`); + const input = createInterface({ input: process.stdin, output: process.stdout }); + const closed = new AbortController(); + input.once('close', () => closed.abort()); + const ask = (text) => input.question(text, { signal: closed.signal }); + let name, desktopWrite; + const roots = []; + try { + console.log(`Agent Relay · ${server}\nCodex and Claude Code must already be installed and signed in.`); + console.log('Linked clients can read saved Codex and Claude Code chats on this machine.'); + name = previous ? 'this machine' : (await ask(`Machine name [${hostname()}]: `)).trim() || hostname(); + if (previous) console.log('The existing machine pairing will be kept.'); + if (name.length > 100) throw new Error('Enter a machine name up to 100 characters.'); + console.log('Allowed folders apply to new chats. Existing chats keep their own permissions.'); + if (previous?.roots?.length) console.log(`Current folders: ${previous.roots.join(', ')}`); + console.log('Enter one project folder at a time. Leave blank to finish.'); + if (previous) console.log('Leave the first answer blank to keep the current folders. Enter - to clear them.'); + while (true) { + const answer = (await ask('Project folder: ')).trim(); + if (!answer) { if (!roots.length && previous) roots.push(...previous.roots); break; } + if (answer === '-' && !roots.length) break; + const path = await realpath(resolve(answer === '~' ? homedir() : answer.startsWith('~/') ? join(homedir(), answer.slice(2)) : answer)); + if (!(await stat(path)).isDirectory()) throw new Error('Choose an existing project folder.'); + if (!roots.includes(path)) roots.push(path); + } + if (!windows) { + console.log('Experimental Codex desktop control lets linked clients send messages and interrupt chats. App updates may break it.'); + const answer = (await ask(`Enable desktop control? [${previous?.desktopWrite ? 'Y/n' : 'y/N'}]: `)).trim().toLowerCase(); + if (answer && !['y', 'yes', 'n', 'no'].includes(answer)) throw new Error('Answer yes or no.'); + desktopWrite = answer ? ['y', 'yes'].includes(answer) : previous?.desktopWrite ?? false; + } else desktopWrite = false; + } catch (error) { + if (closed.signal.aborted) throw new Error('Keep the terminal open to answer the install prompts, then run the installer again.'); + throw error; + } finally { input.close(); } + await mkdir(data, { recursive: true, mode: 0o700 }); + if (previous) { + const response = await fetch(`${server}/pairing`, { headers: { Authorization: `Bearer ${previous.token}` }, signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error(`The saved pairing is unavailable (${response.status}). Check the dashboard before reinstalling.`); + } else { + await new Promise((accept, reject) => { + const child = spawn(process.execPath, [join(staged, 'relay.mjs'), 'pair', '--server', server, '--name', name, '--data-dir', data, + ...roots.flatMap((root) => ['--allow-root', root]), ...(desktopWrite ? ['--codex-desktop-write'] : [])], { stdio: 'inherit' }); + child.on('error', reject); + child.on('exit', (code) => code === 0 ? accept() : reject(new Error('Pairing stopped. Run the installer again for a new code.'))); + }); + previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8')); + } + const config = { ...previous, roots, desktopWrite }; + const binaries = {}; + for (const cli of ['codex', 'claude']) { + try { + const found = windows ? execFileSync('where.exe', [cli], { encoding: 'utf8' }).trim().split(/\r?\n/)[0] : + execFileSync('/bin/sh', ['-c', 'command -v "$1"', 'sh', cli], { encoding: 'utf8' }).trim(); + if (found) binaries[cli] = found; + } catch { console.log(`${cli} was not found. Install it and rerun this installer to enable its chats.`); } + } + await stop(); + await writeFile(join(data, 'agent.json.pending'), JSON.stringify(config) + '\n', { mode: 0o600 }); + await rename(join(data, 'agent.json.pending'), join(data, 'agent.json')); + for (const file of ['relay.mjs', 'setup.mjs']) await copyFile(join(staged, file), join(base, file)); + const args = [join(base, 'relay.mjs'), 'run', '--data-dir', data, + ...Object.entries(binaries).flatMap(([cli, path]) => [`--${cli}-bin`, path])]; + const environment = Object.fromEntries(['PATH', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : [])); + environment.PATH = [base, environment.PATH].filter(Boolean).join(delimiter); + if (windows) { + const runner = join(base, 'run.ps1'); + await writeFile(runner, "$ErrorActionPreference = 'Stop'\n" + Object.entries(environment).map(([key, value]) => `$env:${key} = ${psQuote(value)}`).join('\n') + + `\n& ${psQuote(process.execPath)} ${args.map(psQuote).join(' ')} *>> ${psQuote(join(base, 'agent.log'))}\nexit $LASTEXITCODE\n`); + ps(`$ErrorActionPreference = 'Stop' +$user = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${psQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${runner}"`)} +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user +$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +Register-ScheduledTask -TaskName ${psQuote(task)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null +Start-ScheduledTask -TaskName ${psQuote(task)} +if ((Get-ScheduledTask -TaskName ${psQuote(task)}).State -eq 'Disabled') { throw 'Enable the Agent Relay task and run the installer again.' }`); + await writeFile(join(base, 'agent-relay.cmd'), `@echo off\r\n"${process.execPath}" "${join(base, 'setup.mjs')}" %*\r\n`); + } else { + await mkdir(dirname(unit), { recursive: true }); + if (mac) { + const xml = (text) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + await writeFile(unit, `\n\n +Labelnet.paperclover.agent-relay +ProgramArguments${[process.execPath, ...args].map((arg) => `${xml(arg)}`).join('')} +EnvironmentVariables${Object.entries(environment).map(([key, value]) => `${key}${xml(value)}`).join('')} +RunAtLoadKeepAliveThrottleInterval30 +StandardOutPath${xml(join(base, 'agent.log'))} +StandardErrorPath${xml(join(base, 'agent.log'))} +\n`); + execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' }); + execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }); + } else { + const quote = (text) => '"' + text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('%', '%%') + '"'; + await writeFile(unit, `[Unit]\nDescription=Agent Relay\n\n[Service]\nExecStart=${[process.execPath, ...args].map((arg) => quote(arg).replaceAll('$', '$$')).join(' ')}\n` + + Object.entries(environment).map(([key, value]) => `Environment=${quote(`${key}=${value}`)}`).join('\n') + + '\nRestart=on-failure\nRestartSec=30\nUMask=0077\n\n[Install]\nWantedBy=default.target\n'); + execFileSync('systemctl', ['--user', 'daemon-reload']); + execFileSync('systemctl', ['--user', 'enable', '--now', 'agent-relay.service'], { stdio: 'inherit' }); + execFileSync('systemctl', ['--user', 'is-active', '--quiet', 'agent-relay.service']); + } + await writeFile(join(base, 'agent-relay'), `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(join(base, 'setup.mjs'))} "$@"\n`, { mode: 0o700 }); + } + console.log(`Installed. Agent Relay starts at login.\nOpen ${server}/mcp/settings/agents and check that ${name} is online.`); + const manage = join(base, windows ? 'agent-relay.cmd' : 'agent-relay'); + console.log(`Check startup: ${windows ? '& ' + psQuote(manage) : shellQuote(manage)} status\nUse start, stop, or uninstall in place of status.`); +} + +main().catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/dashboard/agent/source.json b/dashboard/agent/source.json new file mode 100644 index 0000000000000000000000000000000000000000..29bfe76f9b9f83bc636e5743338a74fa964c8f16 --- /dev/null +++ b/dashboard/agent/source.json @@ -0,0 +1,4 @@ +{ + "source": "../../../agent-relay", + "entry": "src/agent.ts" +} diff --git a/dashboard/package.json b/dashboard/package.json index f68270bc15b2d0f0979450e128c60186b7d8540c..76b4f286bdf333124daa27c832a0862066f2e225 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -15,6 +15,7 @@ "@solidjs/router": "^1.0.0", "@types/node": "^26.6.2", "concurrently": "^10.0.5", + "esbuild": "0.28.2", "lucide-solid": "^1.48.0", "solid-js": "^1.9.15", "typescript": "^7.0.2", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 9f20c2d77993dc2e60ced953a345506606446a3e..4fb3cd5cb9f11e321709f526dbfdef9e12f23daf 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: concurrently: specifier: ^10.0.5 version: 10.0.5 + esbuild: + specifier: 0.28.2 + version: 0.28.2 lucide-solid: specifier: ^1.48.0 version: 1.48.0(solid-js@1.9.15) @@ -1379,7 +1382,6 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 - optional: true escalade@3.2.0: {} diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs new file mode 100644 index 0000000000000000000000000000000000000000..8aa7c2fe59ecf9200e7654aaf412080c83affcd1 --- /dev/null +++ b/dashboard/src/auth.rs @@ -0,0 +1,1114 @@ +use crate::*; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}, +}; +use rusqlite::{Connection, OptionalExtension, params as sql}; +use std::os::unix::fs::PermissionsExt; +use webauthn_rs::prelude::*; + +const COOKIE: &str = "__Host-snow-session"; +const FLOW_COOKIE: &str = "__Host-snow-flow"; +const SESSION_TTL: i64 = 30 * 86400; +const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm"]; + +pub struct Store { + pub db: Mutex, + pub origin: url::Url, + file: url::Url, + webauthn: Webauthn, + passwords: Semaphore, +} + +pub fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get("cookie")? + .to_str() + .ok()? + .split(';') + .find_map(|part| { + let (key, value) = part.trim().split_once('=')?; + (key == name).then(|| value.to_owned()) + }) +} +fn set_cookie(name: &str, value: &str, ttl: i64) -> String { + format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}") +} +fn row(db: &Connection, statement: &str, key: &str) -> Result { + let value: Option = db.query_row(statement, [key], |r| r.get(0)).optional()?; + Ok(value + .map(|s| serde_json::from_str(&s)) + .transpose()? + .unwrap_or(Value::Null)) +} +fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result { + let value: Option = db + .query_row( + "SELECT data FROM pending WHERE hash=? AND kind=? AND expires>?", + sql![mcp::hash(token), kind, now() as i64], + |r| r.get(0), + ) + .optional()?; + if consume && value.is_some() { + db.execute("DELETE FROM pending WHERE hash=?", [mcp::hash(token)])?; + } + Ok(value + .map(|s| serde_json::from_str(&s)) + .transpose()? + .unwrap_or(Value::Null)) +} +fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result { + db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?; + let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?; + if count >= 4096 { + return Err(Error::new( + 429, + "Too many sign-in requests. Try again in a few minutes.", + )); + } + let token = mcp::secret(); + db.execute( + "INSERT INTO pending VALUES (?,?,?,?)", + sql![ + mcp::hash(&token), + kind, + value.to_string(), + now() as i64 + ttl + ], + )?; + Ok(token) +} +pub fn user(db: &Connection, id: &str) -> Result { + let mut profile = row(db, "SELECT profile FROM users WHERE id=?", id)?; + if profile.is_null() { + return Err(Error::new( + 404, + "This account no longer exists. Sign in again.", + )); + } + profile["id"] = json!(id); + let mut statement = db.prepare("SELECT roles.id, roles.name FROM roles JOIN memberships ON roles.id=memberships.role_id WHERE user_id=? ORDER BY roles.name")?; + profile["groups"] = json!( + statement + .query_map([id], |r| Ok( + json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?}) + ))? + .collect::, _>>()? + ); + Ok(profile) +} +pub fn credentials(db: &Connection, id: &str) -> Result { + let mut statement = db.prepare( + "SELECT id, kind, label, created FROM credentials WHERE user_id=? ORDER BY created", + )?; + Ok(json!(statement.query_map([id], |r| Ok(json!({"id":r.get::<_,String>(0)?,"type":r.get::<_,String>(1)?,"userLabel":r.get::<_,Option>(2)?,"createdDate":r.get::<_,i64>(3)?})))?.collect::,_>>()?)) +} +pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> { + for key in [ + "id", + "groups", + "sessions", + "credentials", + "picture", + "console", + ] { + profile.as_object_mut().unwrap().remove(key); + } + db.execute( + "UPDATE users SET profile=? WHERE id=?", + sql![profile.to_string(), id], + ) + .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?; + Ok(()) +} +pub fn password_hash(password: &str) -> Result { + let salt = SaltString::encode_b64(&rand::random::<[u8; 16]>()) + .map_err(|_| Error::new(500, "Couldn't prepare password storage."))?; + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|_| Error::new(500, "Couldn't store the password."))? + .to_string()) +} +pub fn set_password(db: &Connection, id: &str, hash: &str) -> Result<()> { + db.execute( + "DELETE FROM credentials WHERE user_id=? AND kind='password'", + [id], + )?; + db.execute( + "INSERT INTO credentials VALUES (?,?,?,?,?,?)", + sql![ + uuid::Uuid::new_v4().to_string(), + id, + "password", + Option::::None, + (now() * 1000.0) as i64, + json!({"phc":hash}).to_string() + ], + )?; + Ok(()) +} + +impl Store { + pub fn new(data: &std::path::Path, origin: &str, file: &str, rp: &str) -> Result { + let origin = url::Url::parse(origin)?; + let file = url::Url::parse(file)?; + if origin.scheme() != "https" + || file.scheme() != "https" + || origin.path() != "/" + || file.path() != "/" + || origin.origin() == file.origin() + { + return Err(Error::new( + 500, + "Set separate HTTPS origins for Snowglobe and Files.", + )); + } + let rp_origin = url::Url::parse(&format!("https://{rp}"))?; + let webauthn = WebauthnBuilder::new(rp, &rp_origin)? + .append_allowed_origin(&origin) + .rp_name("snow globe") + .build()?; + std::fs::create_dir_all(data)?; + let path = data.canonicalize()?.join("accounts.sqlite"); + let db = Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE + | rusqlite::OpenFlags::SQLITE_OPEN_CREATE + | rusqlite::OpenFlags::SQLITE_OPEN_NOFOLLOW, + )?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; + CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, profile TEXT NOT NULL, username TEXT GENERATED ALWAYS AS (json_extract(profile,'$.username')) STORED UNIQUE); + CREATE UNIQUE INDEX IF NOT EXISTS verified_email ON users(lower(json_extract(profile,'$.email'))) WHERE json_extract(profile,'$.emailVerified')=1 AND json_extract(profile,'$.email') IS NOT NULL; + CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE); + CREATE TABLE IF NOT EXISTS memberships (user_id TEXT REFERENCES users(id) ON DELETE CASCADE, role_id TEXT REFERENCES roles(id), PRIMARY KEY(user_id,role_id)); + CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,kind TEXT NOT NULL,label TEXT,created INTEGER NOT NULL,data TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,client TEXT NOT NULL CHECK(client IN ('dashboard','file')),expires INTEGER NOT NULL,ip TEXT NOT NULL,created INTEGER NOT NULL,last_used INTEGER NOT NULL,auth_time INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; + Ok(Self { + db: Mutex::new(db), + origin, + file, + webauthn, + passwords: Semaphore::new(2), + }) + } + pub fn ready(&self) -> bool { + self.db + .lock() + .unwrap() + .query_row("SELECT EXISTS(SELECT 1 FROM users)", [], |r| r.get(0)) + .unwrap_or(false) + } + pub fn import(&self, export: Value) -> Result { + if self + .webauthn + .get_allowed_origins() + .first() + .and_then(|u| u.host_str()) + != export["rpId"].as_str() + { + return Err(Error::new( + 400, + "The export's passkey domain does not match this server.", + )); + } + let mut db = self.db.lock().unwrap(); + let digest = mcp::hash(&export.to_string()); + if db.query_row( + "SELECT EXISTS(SELECT 1 FROM migration WHERE digest=?)", + [&digest], + |r| r.get::<_, bool>(0), + )? { + return Ok( + json!({"accounts":array(&export["users"]).len(),"credentials":array(&export["users"]).iter().map(|u|array(&u["credentials"]).len()).sum::()}), + ); + } + if db.query_row("SELECT count(*) FROM users", [], |r| r.get::<_, i64>(0))? != 0 { + return Err(Error::new( + 409, + "Accounts already exist. Import into an empty store.", + )); + } + let transaction = db.transaction()?; + for role in array(&export["roles"]) { + if GROUPS.contains(&string(&role["name"])) { + transaction.execute( + "INSERT INTO roles VALUES (?,?)", + sql![string(&role["id"]), string(&role["name"])], + )?; + } + } + let mut count = 0; + for profile in array(&export["users"]) { + let id = string(&profile["id"]); + uuid::Uuid::parse_str(id)?; + string(&profile["username"]) + .parse::() + .map_err(|_| Error::new(400, "The source has an invalid username."))?; + let mut value = profile.clone(); + value["requiredActions"] = json!( + array(&profile["requiredActions"]) + .iter() + .filter(|v| **v == "UPDATE_PASSWORD" || **v == "UPDATE_PROFILE") + .collect::>() + ); + for key in ["id", "roles", "credentials"] { + value.as_object_mut().unwrap().remove(key); + } + transaction.execute( + "INSERT INTO users(id,profile) VALUES (?,?)", + sql![id, value.to_string()], + )?; + for role in array(&profile["roles"]) { + transaction.execute( + "INSERT INTO memberships SELECT ?,id FROM roles WHERE id=?", + sql![id, string(role)], + )?; + } + for credential in array(&profile["credentials"]) { + let kind = string(&credential["type"]); + let source = &credential["credentialData"]; + let data = match kind { + "password" => { + if source["algorithm"] != "argon2" + || source["additionalParameters"]["type"][0] != "id" + { + return Err(Error::new( + 500, + "The source uses an unsupported password format.", + )); + } + let parameters = &source["additionalParameters"]; + let salt = STANDARD_NO_PAD + .encode(STANDARD.decode(string(&credential["secretData"]["salt"]))?); + let hash = STANDARD_NO_PAD + .encode(STANDARD.decode(string(&credential["secretData"]["value"]))?); + let phc = format!( + "$argon2id$v=19$m={},t={},p={}${}${}", + string(¶meters["memory"][0]), + source["hashIterations"], + string(¶meters["parallelism"][0]), + salt, + hash + ); + PasswordHash::new(&phc).map_err(|_| { + Error::new(500, "The source password hash couldn't be imported.") + })?; + json!({"phc":phc}) + } + "webauthn-passwordless" => { + let key: serde_cbor_2::Value = serde_cbor_2::from_slice( + &URL_SAFE_NO_PAD.decode(string(&source["credentialPublicKey"]))?, + )?; + let public_key = COSEKey::try_from(&key)?; + let cred = Credential { + cred_id: STANDARD.decode(string(&source["credentialId"]))?.into(), + cred: public_key, + counter: source["counter"].as_u64().unwrap_or(0).try_into()?, + transports: serde_json::from_value(source["transports"].clone()) + .unwrap_or(None), + user_verified: true, + backup_eligible: false, + backup_state: false, + registration_policy: serde_json::from_value(json!("required"))?, + extensions: Default::default(), + attestation: Default::default(), + attestation_format: AttestationFormat::None, + }; + // Keycloak omits backup flags; learn them only from the first verified assertion. + json!({"passkey":Passkey::from(cred),"handle":URL_SAFE_NO_PAD.encode(id.as_bytes()),"backupUnknown":true}) + } + _ => { + return Err(Error::new( + 500, + "The source has a credential type this import doesn't support.", + )); + } + }; + transaction.execute( + "INSERT INTO credentials VALUES (?,?,?,?,?,?)", + sql![ + string(&credential["id"]), + id, + kind, + credential["userLabel"].as_str(), + credential["createdDate"].as_i64().unwrap_or(0), + data.to_string() + ], + )?; + count += 1; + } + } + transaction.execute("INSERT INTO migration VALUES (?)", [digest])?; + transaction.commit()?; + Ok(json!({"accounts":array(&export["users"]).len(),"credentials":count})) + } + pub fn session(&self, headers: &HeaderMap, client: &str) -> Result { + let Some(token) = cookie(headers, COOKIE) else { + return Ok(Value::Null); + }; + let db = self.db.lock().unwrap(); + let id: Option = db + .query_row( + "SELECT user_id FROM sessions WHERE hash=? AND client=? AND expires>?", + sql![mcp::hash(&token), client, now() as i64], + |r| r.get(0), + ) + .optional()?; + let Some(id) = id else { + return Ok(Value::Null); + }; + let user = user(&db, &id)?; + if user["enabled"] != true { + return Ok(Value::Null); + } + db.execute( + "UPDATE sessions SET last_used=? WHERE hash=? AND last_used, + ) -> Result { + let token = mcp::secret(); + let db = self.db.lock().unwrap(); + if user(&db, id)?["enabled"] != true { + return Err(Error::new(403, "This account is disabled.")); + } + if let Some(expected) = password { + if row( + &db, + "SELECT data FROM credentials WHERE user_id=? AND kind='password'", + id, + )? != *expected + { + return Err(Error::new(401, "Your password changed. Sign in again.")); + } + } + db.execute("DELETE FROM sessions WHERE expires<=?", [now() as i64])?; + let ip = headers + .get("X-Studio-Client-IP") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + db.execute( + "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)", + sql![ + mcp::hash(&token), + id, + client, + now() as i64 + SESSION_TTL, + ip, + (now() * 1000.0) as i64, + (now() * 1000.0) as i64, + now() as i64 + ], + )?; + Ok(set_cookie(COOKIE, &token, SESSION_TTL)) + } + fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> { + let ip = headers + .get("X-Studio-Client-IP") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + let db = self.db.lock().unwrap(); + db.execute("DELETE FROM attempts WHERE expires<=?", [now() as i64])?; + let address = mcp::hash(ip); + db.execute( + "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1", + sql![address, now() as i64 + 300], + )?; + let total: i64 = + db.query_row("SELECT count FROM attempts WHERE key=?", [address], |r| { + r.get(0) + })?; + if total > 100 { + return Err(Error::new( + 429, + "Too many attempts. Try again in five minutes.", + )); + } + let key = mcp::hash(&format!("{ip}:{name}")); + db.execute( + "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1", + sql![key, now() as i64 + 300], + )?; + let count: i64 = db.query_row("SELECT count FROM attempts WHERE key=?", [key], |r| { + r.get(0) + })?; + if count > 20 { + return Err(Error::new( + 429, + "Too many attempts. Try again in five minutes.", + )); + } + Ok(()) + } + fn csrf(&self, headers: &HeaderMap, body: &Value) -> Result<()> { + if headers.get("origin").and_then(|v| v.to_str().ok()) + != Some(self.origin.origin().ascii_serialization().as_str()) + { + return Err(Error::new(403, "Open sign-in on Snowglobe and try again.")); + } + let cookie = cookie(headers, FLOW_COOKIE).unwrap_or_default(); + if cookie.is_empty() + || !bool::from(cookie.as_bytes().ct_eq(string(&body["csrf"]).as_bytes())) + || pending(&self.db.lock().unwrap(), &cookie, "csrf", false)?.is_null() + { + return Err(Error::new( + 403, + "Sign-in expired. Reload the page and try again.", + )); + } + Ok(()) + } + fn next(&self, id: &str, flow: &str, path: &str) -> Result { + if flow.is_empty() { + if !path.starts_with('/') + || path.starts_with("//") + || path.contains('\\') + || path.chars().any(char::is_control) + { + return Ok("/".into()); + } + return Ok(path.to_owned()); + } + let db = self.db.lock().unwrap(); + if !array(&user(&db, id)?["requiredActions"]).is_empty() { + return Ok("/account".into()); + } + let value = pending(&db, flow, "file", false)?; + if value.is_null() { + return Err(Error::new( + 400, + "File sign-in expired. Open Files and try again.", + )); + } + let code = issue(&db, "handoff", json!({"user":id,"flow":flow}), 60)?; + Ok(format!( + "{}auth/file/callback?code={}", + self.file, + encoded(&code) + )) + } + pub fn sessions(db: &Connection, id: &str) -> Result { + let mut statement = db.prepare("SELECT hash,ip,created,last_used,client FROM sessions WHERE user_id=? AND expires>? ORDER BY last_used DESC")?; + Ok(json!(statement.query_map(sql![id,now() as i64],|r|Ok(json!({"id":r.get::<_,String>(0)?,"ipAddress":r.get::<_,String>(1)?,"start":r.get::<_,i64>(2)?,"lastAccess":r.get::<_,i64>(3)?,"clients":{"snow":r.get::<_,String>(4)?}})))?.collect::,_>>()?)) + } + pub async fn hash_password(&self, password: &str) -> Result { + let _slot = self + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let password = password.to_owned(); + tokio::task::spawn_blocking(move || password_hash(&password)).await? + } + pub fn recent(&self, headers: &HeaderMap) -> Result<()> { + let token = cookie(headers, COOKIE).unwrap_or_default(); + let valid: bool = self.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM sessions WHERE hash=? AND client='dashboard' AND expires>? AND auth_time>?)",sql![mcp::hash(&token),now() as i64,now() as i64-900],|r|r.get(0))?; + if !valid { + return Err(Error::new( + 403, + "Sign out and sign in again before changing sign-in methods.", + )); + } + Ok(()) + } + pub fn setup_link(&self, id: &str) -> Result { + let db = self.db.lock().unwrap(); + let profile = user(&db, id)?; + if profile["enabled"] != true { + return Err(Error::new( + 400, + "Enable this account before creating a setup link.", + )); + } + db.execute( + "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?", + [id], + )?; + let token = issue(&db, "setup", json!({"user":id}), 86400)?; + Ok(format!("{}sign-in?setup={}", self.origin, token)) + } +} + +pub async fn route(State(app): State>, request: Request) -> Result { + let auth = &app.auth; + let path = request.uri().path().to_owned(); + let method = request.method().clone(); + let query: HashMap = + url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes()) + .into_owned() + .collect(); + let headers = request.headers().clone(); + if path == "/auth/file/check" && method == Method::GET { + let user = auth.session(&headers, "file")?; + if user.is_null() || !array(&user["requiredActions"]).is_empty() { + return Ok(StatusCode::UNAUTHORIZED.into_response()); + } + let groups = array(&user["groups"]) + .iter() + .map(|g| string(&g["name"])) + .collect::>() + .join(","); + return Ok(( + StatusCode::NO_CONTENT, + [ + ( + "X-Auth-Request-Preferred-Username", + string(&user["username"]).to_owned(), + ), + ("X-Auth-Request-Groups", groups), + ], + ) + .into_response()); + } + if path == "/auth/file/sign-in" && method == Method::GET { + let target = query + .get("rd") + .map(String::as_str) + .unwrap_or(auth.file.as_str()); + let destination = auth.file.join(target)?; + if destination.origin() != auth.file.origin() + || !destination.username().is_empty() + || destination.password().is_some() + { + return Err(Error::new(400, "Open Files to sign in.")); + } + let flow = issue( + &auth.db.lock().unwrap(), + "file", + json!({"next":destination}), + 300, + )?; + return Ok(( + StatusCode::FOUND, + [ + ( + "location", + format!("{}auth/continue?flow={flow}", auth.origin), + ), + ("set-cookie", set_cookie(FLOW_COOKIE, &flow, 300)), + ], + ) + .into_response()); + } + if path == "/auth/continue" && method == Method::GET { + let flow = query.get("flow").cloned().unwrap_or_default(); + let user = auth.session(&headers, "dashboard")?; + let next = if user.is_null() { + format!("/sign-in?flow={}", encoded(&flow)) + } else { + auth.next(string(&user["id"]), &flow, "/")? + }; + return Ok((StatusCode::FOUND, [("location", next)]).into_response()); + } + if path == "/auth/file/callback" && method == Method::GET { + let token = query.get("code").cloned().unwrap_or_default(); + let (id, next) = { + let mut db = auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let code = pending(&transaction, &token, "handoff", false)?; + let flow = cookie(&headers, FLOW_COOKIE).unwrap_or_default(); + if code.is_null() + || flow.is_empty() + || !bool::from(flow.as_bytes().ct_eq(string(&code["flow"]).as_bytes())) + { + return Err(Error::new( + 403, + "File sign-in expired. Open Files and try again.", + )); + } + let target = pending(&transaction, &flow, "file", true)?; + if target.is_null() { + return Err(Error::new( + 403, + "File sign-in expired. Open Files and try again.", + )); + } + pending(&transaction, &token, "handoff", true)?; + let user = user(&transaction, string(&code["user"]))?; + if user["enabled"] != true { + return Err(Error::new(403, "This account is disabled. Contact Clover.")); + } + let result = ( + string(&code["user"]).to_owned(), + string(&target["next"]).to_owned(), + ); + transaction.commit()?; + result + }; + let session = auth.create_session(&id, "file", &headers, None)?; + return Ok(( + StatusCode::FOUND, + [("location", next), ("set-cookie", session)], + ) + .into_response()); + } + if path == "/auth/status" && method == Method::GET { + let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?; + let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?}); + if let Some(setup) = query.get("setup") { + let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?; + if entry.is_null() { + return Err(Error::new( + 410, + "This link expired. Ask Clover for a new one.", + )); + } + value["setup"] = + user(&auth.db.lock().unwrap(), string(&entry["user"]))?["username"].clone(); + } + return Ok(( + [ + ("set-cookie", set_cookie(FLOW_COOKIE, &csrf, 900)), + ("cache-control", "no-store".into()), + ], + axum::Json(value), + ) + .into_response()); + } + if path == "/auth/sign-out" || path == "/auth/file/sign-out" { + if method == Method::GET && path == "/auth/file/sign-out" { + return Ok(axum::response::Html("Sign out of Files
").into_response()); + } + if method != Method::POST { + return Err(Error::new(405, "Use the sign-out button.")); + } + let expected = if path.contains("/file/") { + &auth.file + } else { + &auth.origin + }; + if headers.get("origin").and_then(|v| v.to_str().ok()) + != Some(expected.origin().ascii_serialization().as_str()) + { + return Err(Error::new(403, "Open your account to sign out.")); + } + if let Some(token) = cookie(&headers, COOKIE) { + auth.db + .lock() + .unwrap() + .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?; + } + if path.contains("/file/") { + return Ok(( + StatusCode::SEE_OTHER, + [ + ("set-cookie", set_cookie(COOKIE, "", 0)), + ("location", "/".into()), + ], + ) + .into_response()); + } + return Ok(( + [("set-cookie", set_cookie(COOKIE, "", 0))], + axum::Json(json!({"next":"/sign-in"})), + ) + .into_response()); + } + if method != Method::POST { + return Err(Error::new(404, "No sign-in action here.")); + } + let body: Value = + serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 128 * 1024).await?) + .map_err(|_| Error::new(400, "Reload the form and try again."))?; + auth.csrf(&headers, &body)?; + if path == "/auth/password" || path == "/auth/passkey/start" { + let name = string(&body["username"]).trim().to_lowercase(); + if name.len() > 254 || name.is_empty() { + return Err(Error::new(400, "Enter your username.")); + } + auth.limit(&headers, &name)?; + let id: Option = auth.db.lock().unwrap().query_row("SELECT id FROM users WHERE username=? OR (lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1) ORDER BY username=? DESC LIMIT 1",sql![name,name,name],|r|r.get(0)).optional()?; + let user = id + .as_ref() + .map(|id| user(&auth.db.lock().unwrap(), id)) + .transpose()? + .unwrap_or(Value::Null); + if path == "/auth/password" { + let password = string(&body["password"]).to_owned(); + if password.len() > 1024 { + return Err(Error::new(400, "That password is too long.")); + } + let data = row( + &auth.db.lock().unwrap(), + "SELECT data FROM credentials WHERE user_id=? AND kind='password'", + id.as_deref().unwrap_or(""), + )?; + let phc = string(&data["phc"]).to_owned(); + let _slot = auth + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let verified = tokio::task::spawn_blocking(move || { + if phc.is_empty() { + let _ = password_hash(&password); + return false; + } + PasswordHash::new(&phc).is_ok_and(|hash| { + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok() + }) + }) + .await?; + if !verified || user["enabled"] != true { + return Err(Error::new( + 401, + "That username or password doesn't match. Try again.", + )); + } + let id = id.unwrap(); + let session = auth.create_session(&id, "dashboard", &headers, Some(&data))?; + let next = if !array(&user["requiredActions"]).is_empty() { + "/account".into() + } else { + auth.next(&id, string(&body["flow"]), string(&body["next"]))? + }; + return Ok( + ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(), + ); + } + if user["enabled"] != true { + return Err(Error::new( + 401, + "No passkey is available for that username. Try your password.", + )); + } + let id = id.unwrap(); + let keys = passkeys(&auth.db.lock().unwrap(), &id)?; + if keys.is_empty() { + return Err(Error::new( + 401, + "No passkey is available for that username. Try your password.", + )); + } + let (options, state) = auth.webauthn.start_passkey_authentication(&keys)?; + let token = issue( + &auth.db.lock().unwrap(), + "authentication", + json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"]}), + 300, + )?; + return Ok(axum::Json(json!({"options":options,"token":token})).into_response()); + } + if path == "/auth/passkey/finish" { + let value = pending( + &auth.db.lock().unwrap(), + string(&body["token"]), + "authentication", + true, + )?; + if value.is_null() || value["csrf"] != body["csrf"] { + return Err(Error::new(403, "Passkey sign-in expired. Try again.")); + } + let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone()) + .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?; + let mut state = value["state"].clone(); + let mut allowed: Vec = + serde_json::from_value(state["ast"]["credentials"].clone())?; + { + let db = auth.db.lock().unwrap(); + let mut statement = db.prepare( + "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'", + )?; + for stored in + statement.query_map([string(&value["user"])], |r| r.get::<_, String>(0))? + { + let stored: Value = serde_json::from_str(&stored?)?; + let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?; + if stored["backupUnknown"] == true + && passkey.cred_id().as_slice() == credential.get_credential_id() + { + let flags = credential + .response + .authenticator_data + .as_slice() + .get(32) + .copied() + .ok_or_else(|| Error::new(400, "The passkey response was incomplete."))?; + for key in &mut allowed { + if key.cred_id == *passkey.cred_id() { + key.backup_eligible = flags & 8 != 0; + key.backup_state = flags & 16 != 0; + } + } + } + } + } + state["ast"]["credentials"] = json!(allowed); + let state: PasskeyAuthentication = serde_json::from_value(state)?; + let result = auth + .webauthn + .finish_passkey_authentication(&credential, &state) + .map_err(|error| { + eprintln!("passkey authentication: {error:?}"); + Error::new( + 401, + "That passkey couldn't sign in. Try again or use your password.", + ) + })?; + let id = string(&value["user"]); + { + let db = auth.db.lock().unwrap(); + let user = user(&db, id)?; + if user["enabled"] != true { + return Err(Error::new(403, "This account is disabled. Contact Clover.")); + } + let mut statement = db.prepare( + "SELECT id,data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'", + )?; + let rows = statement + .query_map([id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + let mut matched = false; + for (key, data) in rows { + let mut data: Value = serde_json::from_str(&data)?; + let mut passkey: Passkey = serde_json::from_value(data["passkey"].clone())?; + if passkey.cred_id() == result.cred_id() { + if let Some(handle) = body["credential"]["response"]["userHandle"].as_str() { + if !handle.is_empty() && handle != string(&data["handle"]) { + return Err(Error::new( + 401, + "That passkey belongs to a different account.", + )); + } + } + matched = true; + let current: Credential = passkey.clone().into(); + if (current.counter != 0 || result.counter() != 0) + && result.counter() <= current.counter + { + return Err(Error::new( + 401, + "This passkey returned an old counter. Try another sign-in method.", + )); + } + if data["backupUnknown"] == true { + let mut key: Credential = passkey.into(); + key.backup_eligible = result.backup_eligible(); + key.backup_state = result.backup_state(); + passkey = key.into(); + data.as_object_mut().unwrap().remove("backupUnknown"); + } + passkey.update_credential(&result); + data["passkey"] = json!(passkey); + db.execute( + "UPDATE credentials SET data=? WHERE id=?", + sql![data.to_string(), key], + )?; + break; + } + } + if !matched { + return Err(Error::new( + 401, + "This passkey was removed. Try another sign-in method.", + )); + } + } + let session = auth.create_session(id, "dashboard", &headers, None)?; + let next = + if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() { + "/account".into() + } else { + auth.next(id, string(&value["flow"]), string(&value["next"]))? + }; + return Ok(([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response()); + } + if path == "/auth/setup" { + let email = string(&body["email"]).trim(); + if !email.contains('@') || email.len() > 254 { + return Err(Error::new(400, "Enter your email address.")); + } + let password = string(&body["password"]).to_owned(); + if password.chars().count() < 8 || password.len() > 1024 { + return Err(Error::new( + 400, + "Use a password with at least 8 characters.", + )); + } + let _slot = auth + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let hash = tokio::task::spawn_blocking(move || password_hash(&password)).await??; + let id = { + let mut db = auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let entry = pending(&transaction, string(&body["setup"]), "setup", true)?; + if entry.is_null() { + return Err(Error::new( + 410, + "This link expired. Ask Clover for a new one.", + )); + } + let id = string(&entry["user"]).to_owned(); + let mut profile = user(&transaction, &id)?; + profile["email"] = json!(email); + profile["emailVerified"] = json!(false); + if profile["enabled"] != true { + return Err(Error::new( + 403, + "This account is disabled. Ask Clover for a new link.", + )); + } + profile["requiredActions"] = json!([]); + set_password(&transaction, &id, &hash)?; + save_user(&transaction, &id, profile)?; + transaction.execute("DELETE FROM sessions WHERE user_id=?", [&id])?; + transaction.commit()?; + id + }; + users::revoke_connections(&app, &id)?; + return Ok(( + [( + "set-cookie", + auth.create_session(&id, "dashboard", &headers, None)?, + )], + axum::Json(json!({"next":"/account?welcome=1"})), + ) + .into_response()); + } + let user = auth.session(&headers, "dashboard")?; + if user.is_null() { + return Err(Error::new(401, "Sign in to manage your account.")); + } + let id = string(&user["id"]); + if path == "/auth/passkey/register" { + auth.recent(&headers)?; + let db = auth.db.lock().unwrap(); + let keys = passkeys(&db, id)?; + let ids = keys.iter().map(|key| key.cred_id().clone()).collect(); + let uuid = uuid::Uuid::parse_str(id)?; + let (options, state) = auth.webauthn.start_passkey_registration( + uuid, + string(&user["username"]), + string(&user["username"]), + Some(ids), + )?; + let token = issue( + &db, + "registration", + json!({"user":id,"csrf":body["csrf"],"state":state}), + 300, + )?; + return Ok(axum::Json(json!({"options":options,"token":token})).into_response()); + } + if path == "/auth/passkey/save" { + auth.recent(&headers)?; + let db = auth.db.lock().unwrap(); + let value = pending(&db, string(&body["token"]), "registration", true)?; + if value.is_null() || value["user"] != user["id"] || value["csrf"] != body["csrf"] { + return Err(Error::new(403, "Passkey setup expired. Try again.")); + } + let credential: RegisterPublicKeyCredential = + serde_json::from_value(body["credential"].clone()).map_err(|_| { + Error::new(400, "The browser couldn't create your passkey. Try again.") + })?; + let state: PasskeyRegistration = serde_json::from_value(value["state"].clone())?; + let passkey = auth + .webauthn + .finish_passkey_registration(&credential, &state) + .map_err(|_| Error::new(400, "That passkey couldn't be added. Try again."))?; + let label = string(&body["label"]).trim(); + if label.len() > 100 { + return Err(Error::new(400, "Use a shorter passkey name.")); + } + db.execute("INSERT INTO credentials VALUES (?,?,?,?,?,?)",sql![uuid::Uuid::new_v4().to_string(),id,"webauthn-passwordless",if label.is_empty(){"passkey"}else{label},(now()*1000.0) as i64,json!({"passkey":passkey,"handle":URL_SAFE_NO_PAD.encode(uuid::Uuid::parse_str(id)?.as_bytes())}).to_string()])?; + return Ok(StatusCode::NO_CONTENT.into_response()); + } + if path == "/auth/password/change" { + auth.recent(&headers)?; + let data = row( + &auth.db.lock().unwrap(), + "SELECT data FROM credentials WHERE user_id=? AND kind='password'", + id, + )?; + let phc = string(&data["phc"]).to_owned(); + let current = string(&body["current"]).to_owned(); + let password = string(&body["password"]).to_owned(); + if password.chars().count() < 8 || password.len() > 1024 || current.len() > 1024 { + return Err(Error::new( + 400, + "Use a password with at least 8 characters.", + )); + } + auth.limit(&headers, id)?; + let _slot = auth + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let hash = tokio::task::spawn_blocking(move || { + if !phc.is_empty() + && !PasswordHash::new(&phc).is_ok_and(|hash| { + Argon2::default() + .verify_password(current.as_bytes(), &hash) + .is_ok() + }) + { + return Err(Error::new( + 401, + "Your current password doesn't match. Try again.", + )); + } + password_hash(&password) + }) + .await??; + { + let mut db = auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let mut profile = self::user(&transaction, id)?; + if profile["enabled"] != true { + return Err(Error::new(403, "This account is disabled.")); + } + set_password(&transaction, id, &hash)?; + profile["requiredActions"] = json!( + array(&user["requiredActions"]) + .iter() + .filter(|v| **v != "UPDATE_PASSWORD") + .collect::>() + ); + save_user(&transaction, id, profile)?; + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; + transaction.commit()?; + } + users::revoke_connections(&app, id)?; + return Ok(( + [( + "set-cookie", + auth.create_session(id, "dashboard", &headers, None)?, + )], + StatusCode::NO_CONTENT, + ) + .into_response()); + } + Err(Error::new(404, "No account action here.")) +} + +fn passkeys(db: &Connection, id: &str) -> Result> { + let mut statement = db + .prepare("SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'")?; + statement + .query_map([id], |r| r.get::<_, String>(0))? + .map(|data| { + let value: Value = serde_json::from_str(&data?)?; + Ok(serde_json::from_value(value["passkey"].clone())?) + }) + .collect() +} diff --git a/dashboard/src/cache.rs b/dashboard/src/cache.rs index 7fe428f7ae307c3d57b5b4e193b95e63e4d4570c..e00d135bff9b49b098b477556ea000ab9791e135 100644 --- a/dashboard/src/cache.rs +++ b/dashboard/src/cache.rs @@ -51,34 +51,6 @@ impl Cache { Ok(entry) } - pub async fn coalesce(&self, key: String, load: F) -> Result> - where - F: FnOnce() -> Fut + Send + 'static, - Fut: Future> + Send + 'static, - { - let started = Instant::now(); - let entry = self.entry(key)?; - let guard = entry.loading.clone().lock_owned().await; - { - let state = entry.state.lock().unwrap(); - if let Some((at, value)) = &state.value - && *at >= started - { - return Ok(value.clone()); - } - if let Some((at, error)) = &state.failure - && *at >= started - { - return Err(error.clone()); - } - } - tokio::spawn(async move { - let _guard = guard; - entry.store(load().await) - }) - .await? - } - pub fn invalidate(&self, key: &str) { self.0.lock().unwrap().remove(key); } @@ -180,51 +152,6 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; #[tokio::test] - async fn coalesced_identity_reads_do_not_reuse_completed_or_failed_results() { - let cache = Arc::new(Cache::default()); - let calls = Arc::new(AtomicUsize::new(0)); - let mut readers = Vec::new(); - for _ in 0..100 { - let (cache, calls) = (cache.clone(), calls.clone()); - readers.push(tokio::spawn(async move { - cache - .coalesce("identity:owner".into(), move || async move { - calls.fetch_add(1, Ordering::SeqCst); - tokio::time::sleep(Duration::from_millis(20)).await; - Ok(serde_json::json!({"enabled":true})) - }) - .await - .unwrap() - })); - } - for reader in readers { - assert_eq!(reader.await.unwrap().value["enabled"], true); - } - assert_eq!(calls.load(Ordering::SeqCst), 1); - let disabled = cache - .coalesce("identity:owner".into(), || async { - Ok(serde_json::json!({"enabled":false})) - }) - .await - .unwrap(); - assert_eq!(disabled.value["enabled"], false); - assert!( - cache - .coalesce("identity:owner".into(), || async { - Err(Error::new(502, "unavailable")) - }) - .await - .is_err() - ); - let recovered = cache - .coalesce("identity:owner".into(), || async { - Ok(serde_json::json!({"enabled":true})) - }) - .await - .unwrap(); - assert_eq!(recovered.value["enabled"], true); - } - #[tokio::test] async fn disconnecting_reader_does_not_cancel_shared_load() { let cache = Arc::new(Cache::default()); let started = Arc::new(tokio::sync::Notify::new()); diff --git a/dashboard/src/core.rs b/dashboard/src/core.rs index fd6afcd1017c2f7d5576f117561e1549da6f999f..8fb702b04594580152036d5f585bdf824cbf703f 100644 --- a/dashboard/src/core.rs +++ b/dashboard/src/core.rs @@ -839,7 +839,9 @@ mod tests { #[tokio::test] async fn launcher_excludes_directories_and_grouped_definitions_before_import() { let root = std::env::temp_dir().join(format!("studio-launcher-{}", uuid::Uuid::new_v4())); - tokio::fs::create_dir_all(root.join("config")).await.unwrap(); + tokio::fs::create_dir_all(root.join("config")) + .await + .unwrap(); for directory in ["retired", "personal"] { tokio::fs::create_dir_all(root.join("service").join(directory)) .await @@ -864,7 +866,10 @@ mod tests { assert!(module.contains("/personal/service.pkl")); assert!(module.contains("/personal/fixture.pkl")); assert_eq!( - module.lines().filter(|line| line.starts_with("import ")).count(), + module + .lines() + .filter(|line| line.starts_with("import ")) + .count(), 2, ); tokio::fs::remove_dir_all(root).await.unwrap(); diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index 2c77577101c368bd758dda58189b5d575b893cb0..89d4533cb2d7404e857866376e8de5433e2b9b2c 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -1,4 +1,5 @@ mod apps; +mod auth; mod cache; mod core; mod deploys; @@ -82,6 +83,7 @@ impl Document { } struct App { + auth: auth::Store, mcp: mcp::Store, relay: relay::Broker, shale: shale::Backend, @@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box> { "STUDIO_PUBLIC_ORIGIN", &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")), ); + let auth = auth::Store::new( + &PathBuf::from(env("STUDIO_DATA_DIR", "data")), + &origin, + &env( + "STUDIO_FILE_ORIGIN", + &format!("https://file.{}", env("STUDIO_DOMAIN", "studio.test")), + ), + &env( + "STUDIO_AUTH_RP_ID", + &format!("auth.{}", env("STUDIO_DOMAIN", "studio.test")), + ), + ) + .map_err(|error| std::io::Error::other(error.message))?; + if let Some(path) = std::env::args().skip(1).next() { + if path != "--import-accounts" { + return Err(std::io::Error::other("Unknown dashboard argument.").into()); + } + let path = std::env::args() + .nth(2) + .ok_or_else(|| std::io::Error::other("Provide an account export path."))?; + let result = auth + .import(serde_json::from_slice(&std::fs::read(path)?)?) + .map_err(|error| std::io::Error::other(error.message))?; + println!("{result}"); + return Ok(()); + } + let import = PathBuf::from(env("STUDIO_DATA_DIR", "data")).join("accounts-import.json"); + if import.exists() { + auth.import(serde_json::from_slice(&std::fs::read(&import)?)?) + .map_err(|error| std::io::Error::other(error.message))?; + std::fs::remove_file(&import)?; + } + if env("STUDIO_AUTH_REQUIRED", "0") == "1" && !auth.ready() { + return Err(std::io::Error::other( + "Import accounts before starting native authentication.", + ) + .into()); + } let app = Arc::new(App { + auth, mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin) .map_err(|error| std::io::Error::other(error.message))?, relay: relay::Broker::default(), @@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box> { let dist = env("STUDIO_WEB_DIR", "dist"); let router = Router::new() .route("/api/{*path}", any(api)) + .route("/auth/{*path}", any(auth::route)) .route("/oauth/{*path}", any(mcp::oauth)) .route("/.well-known/{*path}", any(mcp::oauth)) .nest_service("/assets", ServeDir::new(format!("{dist}/assets"))) .with_state(app.clone()) .merge(observability::router(app.clone())) .merge(shale::router(app.clone())) - .merge(relay::router(app)) + .merge(relay::router(app.clone())) .fallback_service( ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))), ) .layer(axum::middleware::from_fn( move |mut request: Request, next: axum::middleware::Next| { let proof = proof.clone(); + let app = app.clone(); async move { if mcp::public(request.uri().path()) { request.headers_mut().remove("Studio-Proxy-Token"); @@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box> { } request.headers_mut().remove("Studio-Proxy-Token"); } - let asset = request.uri().path().starts_with("/assets/"); + let path = request.uri().path().to_owned(); + let asset = path.starts_with("/assets/"); + if app.auth.ready() + && !mcp::public(&path) + && !path.starts_with("/auth/") + && !asset + && path != "/sign-in" + { + request.headers_mut().remove("User-Name"); + request.headers_mut().remove("User-Groups"); + let account = match app.auth.session(request.headers(), "dashboard") { + Ok(account) => account, + Err(error) => return error.into_response(), + }; + if account.is_null() { + return if path.starts_with("/api/") { + Error::new(401, "Sign in to Snowglobe.").into_response() + } else { + ( + StatusCode::FOUND, + [( + "location", + format!( + "/sign-in?next={}", + encoded(&request.uri().to_string()) + ), + )], + ) + .into_response() + }; + } + if !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ) && request + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()) + != Some(app.auth.origin.origin().ascii_serialization().as_str()) + { + return Error::new(403, "Open Snowglobe and try again.") + .into_response(); + } + if !array(&account["requiredActions"]).is_empty() + && !path.starts_with("/api/account") + && path.starts_with("/api/") + && path != "/api/me" + { + return Error::new( + 403, + "Change your temporary password in your account first.", + ) + .into_response(); + } + let groups = array(&account["groups"]) + .iter() + .map(|v| string(&v["name"])) + .collect::>() + .join(","); + request + .headers_mut() + .insert("User-Name", string(&account["username"]).parse().unwrap()); + request + .headers_mut() + .insert("User-Groups", groups.parse().unwrap()); + } let document = !asset && !request.uri().path().starts_with("/api/"); if document { request.headers_mut().remove("if-modified-since"); request.headers_mut().remove("if-none-match"); } let mut response = next.run(request).await; + response.headers_mut().insert("referrer-policy", "no-referrer".parse().unwrap()); + response.headers_mut().insert("x-content-type-options", "nosniff".parse().unwrap()); + response.headers_mut().insert("x-frame-options", "DENY".parse().unwrap()); if asset && response.status().is_success() { response.headers_mut().insert( "cache-control", diff --git a/dashboard/src/mcp.rs b/dashboard/src/mcp.rs index 703cb7624271ff0e719f92ee8b57ef9ec9a3ac7a..d1345e7318e05b8e8b958165eb1cf566b2178671 100644 --- a/dashboard/src/mcp.rs +++ b/dashboard/src/mcp.rs @@ -445,7 +445,7 @@ impl Store { tx.commit()?; return Ok(( StatusCode::FOUND, - [("location", format!("/mcp?request={}", encoded(&pending)))], + [("location", format!("/connect/{}", encoded(&pending)))], ) .into_response()); } @@ -490,6 +490,16 @@ impl Store { #[derive(Clone)] pub(crate) struct Grant(pub Value); +pub(crate) fn active_owner(app: &App, grant: &Value) -> Result { + let profile = match auth::user(&app.auth.db.lock().unwrap(), string(&grant["user"])) { + Ok(profile) => profile, + Err(error) if error.status == 404 => return Ok(false), + Err(error) => return Err(error), + }; + Ok(profile["enabled"] == true + && (grant["resource"] != app.mcp.resource("observability") + || array(&profile["groups"]).iter().any(|role| role["name"] == "infra-admin"))) +} pub fn router( app: Arc, catalog: &str, @@ -529,7 +539,9 @@ pub fn router( return Error::new(403, "This origin cannot use the connector.") .into_response(); } - match app.mcp.authenticate(request.headers(), &resource) { + match app.mcp.authenticate(request.headers(), &resource).and_then(|grant| { + if active_owner(&app, &grant)? { Ok(grant) } else { Err(Error::new(401, "invalid_token")) } + }) { Ok(grant) => { request.extensions_mut().insert(Grant(grant)); if let Some(value) = request.headers_mut().get_mut("authorization") { @@ -554,10 +566,16 @@ pub fn router( pub fn public(path: &str) -> bool { path.starts_with("/oauth/") - || path.starts_with("/mcp/") + || CATALOGS.iter().any(|(id, _, _)| { + path == format!("/mcp/{id}") || path.starts_with(&format!("/mcp/{id}/")) + }) || path.starts_with("/.well-known/oauth-") || path == "/pairing" || path == "/agent/connect" + || matches!( + path, + "/agent/install.sh" | "/agent/install.ps1" | "/agent/setup.mjs" | "/agent/relay.mjs" + ) || path.starts_with("/api/v1/") } pub async fn oauth(State(app): State>, request: Request) -> Response { @@ -590,19 +608,7 @@ pub async fn oauth(State(app): State>, request: Request) -> Response { let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null}; if path == "/oauth/token" && method == Method::POST { let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?; - let id = string(&grant["user"]); - let (profile, roles) = match tokio::try_join!( - host::call(json!({"operation":"iam.request","path":format!("/users/{id}"),"method":"GET","body":null})), - host::call(json!({"operation":"iam.request","path":format!("/users/{id}/role-mappings/realm"),"method":"GET","body":null})) - ) { - Ok(identity) => identity, - Err(error) if error.status == 404 => { - revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; - return Err(fail("invalid_grant")); - } - Err(error) => return Err(error), - }; - if profile["body"]["enabled"] != true || grant["resource"] == app.mcp.resource("observability") && !array(&roles["body"]).iter().any(|role| role["name"] == "infra-admin") { + if !active_owner(&app, &grant)? { revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; return Err(fail("invalid_grant")); } @@ -633,6 +639,25 @@ pub async fn oauth(State(app): State>, request: Request) -> Response { response } +fn chosen_resources(body: &Value, resources: &[Value], shale: bool) -> Result { + if shale && body["resources"] == "all" { + return Ok(json!("all")); + } + let chosen = body["resources"] + .as_array() + .filter(|items| !items.is_empty() && items.len() <= resources.len()) + .ok_or_else(|| Error::new(400, "Choose each available resource once."))?; + if chosen.iter().enumerate().any(|(index, item)| { + !resources.iter().any(|resource| item == &resource["id"]) || chosen[..index].contains(item) + }) { + return Err(Error::new( + 403, + "Choose resources available to your account.", + )); + } + Ok(json!(chosen)) +} + pub async fn manage( app: Arc, method: &Method, @@ -672,32 +697,66 @@ pub async fn manage( .keep_alive(axum::response::sse::KeepAlive::default()) .into_response()); } - let consent_resource = if let ["consent", id] = parts { - get( + let consent_resource = if let ["connections", id] = parts + && method != Method::DELETE + { + let grant = get(&app.mcp.db.lock().unwrap(), &format!("grant:{id}"))?; + if grant["user"] != owner_id { + return Err(Error::new(404, "No connection with that ID.")); + } + string(&grant["resource"]).to_owned() + } else if let ["consent", id] = parts { + let pending = get( &app.mcp.db.lock().unwrap(), &format!("pending:{}", hash(id)), - )?["resource"] - .as_str() - .unwrap_or_default() - .to_owned() + )?; + if pending.is_null() { + return Err(Error::new( + 404, + "This connection request expired. Start it again.", + )); + } + if !pending["owner"].is_null() && pending["owner"] != owner_id { + return Err(Error::new( + 403, + "This connection request belongs to another account.", + )); + } + string(&pending["resource"]).to_owned() } else { String::new() }; let agent_consent = consent_resource == app.mcp.resource("agents"); let shale_consent = consent_resource == app.mcp.resource("shale"); + let catalog = CATALOGS + .iter() + .find(|(id, _, _)| consent_resource == app.mcp.resource(id)) + .map(|(id, _, _)| *id); let mut linked = true; - let resources: Vec = if agent_consent { + let mut resource_error = None; + let resources: Vec = if body["deny"] == true { + Vec::new() + } else if agent_consent { relay::machines(&app.mcp.db.lock().unwrap(), owner_id)? .into_iter() .map(|m| json!({"id":m["id"],"name":m["name"]})) .collect() - } else if shale_consent && body["deny"] != true { - match shale::repositories(&app, owner_id).await { + } else if shale_consent { + let available = if body["resources"] == "all" { + shale::verified_session(&app, owner_id).await.map(|_| Vec::new()) + } else { + shale::repositories(&app, owner_id).await + }; + match available { Ok(repositories) => repositories, Err(error) if error.status == 401 => { linked = false; Vec::new() } + Err(error) if method == Method::GET => { + resource_error = Some(error.message); + Vec::new() + } Err(error) => return Err(error), } } else if consent_resource == app.mcp.resource("observability") @@ -726,13 +785,14 @@ pub async fn manage( let machines = relay::machines(&tx, owner_id)?; let connections = grants.iter().map(|grant| { let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()}; - let resources: Vec<_> = array(&grant["resources"]).iter().map(|id| if grant["resource"] == app.mcp.resource("agents") {machines.iter().find(|m| m["id"] == *id).map(|m| m["name"].clone()).unwrap_or_else(|| json!("Unlinked machine"))} else {id.clone()}).collect(); - Ok(json!({"id":grant["id"],"name":name,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]})) + let resources = if grant["resource"] == app.mcp.resource("shale") && grant["resources"] == "all" {json!("all")} else {json!(array(&grant["resources"]).iter().map(|id| if grant["resource"] == app.mcp.resource("agents") {machines.iter().find(|m| m["id"] == *id).map(|m| m["name"].clone()).unwrap_or_else(|| json!("Unlinked machine"))} else {id.clone()}).collect::>())}; + let catalog = CATALOGS.iter().find(|(id, _, _)| grant["resource"] == app.mcp.resource(id)).map(|(id, _, _)| *id); + Ok(json!({"id":grant["id"],"name":name,"catalog":catalog,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]})) }).collect::>>()?; let shale = get(&tx, &format!("shale-session:{owner_id}"))?; let catalogs: Vec<_> = CATALOGS .iter() - .map(|(id, name, _)| json!({"name":name,"endpoint":app.mcp.resource(id)})) + .map(|(id, name, _)| json!({"id":id,"name":name,"endpoint":app.mcp.resource(id)})) .collect(); json!({"catalogs":catalogs,"connections":connections,"machines":app.relay.view(machines,None),"shale":if shale["origin"] != app.shale.origin.as_str() {Value::Null} else {json!({"linkedAt":shale["linkedAt"]})}}) } @@ -770,29 +830,19 @@ pub async fn manage( "UPDATE records SET value=? WHERE key=?", rusqlite::params![pending.to_string(), key], )?; - json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"scopes":pending["scopes"],"resources":resources,"linked":linked}) + json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"catalog":catalog,"account":owner["username"],"redirectHost":redirect(string(&pending["redirect"]))?.host_str(),"scopes":pending["scopes"],"resources":resources,"linked":linked,"resourceError":resource_error}) } else { if shale_consent - && get(&tx, &format!("shale-session:{owner_id}"))?["origin"] - != app.shale.origin.as_str() + && (!linked + || get(&tx, &format!("shale-session:{owner_id}"))?["origin"] + != app.shale.origin.as_str()) { return Err(Error::new( 401, "Link your Shale account before allowing repository access.", )); } - let chosen = body["resources"] - .as_array() - .filter(|a| !a.is_empty() && a.len() <= resources.len()) - .ok_or_else(|| Error::new(400, "Choose each available resource once."))?; - if chosen.iter().enumerate().any(|(index, r)| { - !resources.iter().any(|id| r == &id["id"]) || chosen[..index].contains(r) - }) { - return Err(Error::new( - 403, - "Choose resources available to your account.", - )); - } + let chosen = chosen_resources(&body, &resources, shale_consent)?; if list(&tx, "grant:")? .iter() .filter(|g| g["user"] == owner_id) @@ -826,13 +876,37 @@ pub async fn manage( } } ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?, - ["connections", id] if method == Method::DELETE => { - let grant = get(&tx, &format!("grant:{id}"))?; + ["connections", id] + if method == Method::GET || method == Method::POST || method == Method::DELETE => + { + let key = format!("grant:{id}"); + let mut grant = get(&tx, &key)?; if grant["user"] != owner_id { return Err(Error::new(404, "No connection with that ID.")); } - revoke(&tx, id)?; - Value::Null + if method == Method::DELETE { + revoke(&tx, id)?; + Value::Null + } else if method == Method::GET { + json!({"resources": resources, "selected": grant["resources"], "linked": linked,"resourceError":resource_error}) + } else { + if shale_consent && !linked { + return Err(Error::new( + 401, + "Link your Shale account before allowing repository access.", + )); + } + let chosen = chosen_resources(&body, &resources, shale_consent)?; + grant["resources"] = json!(chosen); + if agent_consent { + grant["targets"] = json!(chosen); + } + tx.execute( + "UPDATE records SET value=? WHERE key=?", + rusqlite::params![grant.to_string(), key], + )?; + Value::Null + } } _ => return Err(Error::new(404, "No endpoint here.")), }; @@ -849,6 +923,28 @@ pub async fn manage( #[cfg(test)] mod tests { + #[test] + fn resource_updates_reject_empty_duplicates_and_foreign_choices() { + let resources = vec![json!({"id":"alpha"}), json!({"id":"beta"})]; + assert_eq!( + chosen_resources(&json!({"resources":["beta"]}), &resources, false).unwrap(), + json!(["beta"]) + ); + for selected in [ + json!([]), + json!(["alpha", "alpha"]), + json!(["foreign"]), + json!([null]), + ] { + assert!(chosen_resources(&json!({"resources":selected}), &resources, false).is_err()); + } + assert_eq!( + chosen_resources(&json!({"resources":"all"}), &[], true).unwrap(), + json!("all") + ); + assert!(chosen_resources(&json!({"resources":"all"}), &resources, false).is_err()); + } + use super::*; struct Fixture { store: Arc, diff --git a/dashboard/src/relay.rs b/dashboard/src/relay.rs index 8c02fbef820cce11a20ad4a7b1c5ce57597a57ea..e7e7af00789b6b65bb151dfa11ea84bae0b896d7 100644 --- a/dashboard/src/relay.rs +++ b/dashboard/src/relay.rs @@ -618,6 +618,9 @@ async fn rest(State(app): State>, request: Request) -> Response { let grant = app .mcp .authenticate(request.headers(), &app.mcp.resource("agents"))?; + if !mcp::active_owner(&app, &grant)? { + return Err(Error::new(401, "This connection's account is no longer authorized.")); + } let id = string(&grant["id"]); let method = request.method().clone(); let path = request @@ -778,13 +781,37 @@ impl ServerHandler for Agents { .into()) } } +async fn installer(State(app): State>, request: Request) -> Response { + let origin = app.mcp.origin.origin().ascii_serialization(); + let source = if request.uri().path().ends_with(".ps1") { + include_str!("../agent/install.ps1") + .replace("__SERVER__", &format!("'{}'", origin.replace('\'', "''"))) + } else { + include_str!("../agent/install.sh").replace( + "__SERVER__", + &format!("'{}'", origin.replace('\'', "'\\''")), + ) + }; + ([("content-type", "text/plain; charset=utf-8")], source).into_response() +} pub fn router(app: Arc) -> Router { let state = app.clone(); let expected_host = app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); + let agent = env("STUDIO_AGENT_DIR", "agent"); Router::new() .route("/pairing", any(pairing)) .route("/agent/connect", any(connect)) + .route("/agent/install.sh", axum::routing::get(installer)) + .route("/agent/install.ps1", axum::routing::get(installer)) + .route_service( + "/agent/setup.mjs", + ServeFile::new(format!("{agent}/setup.mjs")), + ) + .route_service( + "/agent/relay.mjs", + ServeFile::new(format!("{agent}/relay.mjs")), + ) .route("/api/v1/{*path}", any(rest)) .with_state(app.clone()) .merge(mcp::router(app, "agents", move || { diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs index b6d97956eed2e4bbbb4e4c5de556b15ef254bec8..5e114f75b0f01fbe9ee5c47f5bc01a79e235d907 100644 --- a/dashboard/src/shale.rs +++ b/dashboard/src/shale.rs @@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String { fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result { if repository.is_empty() || repository.len() > 255 - || matches!(repository, "." | ".." | "-") + || repository + .split('/') + .any(|part| matches!(part, "" | "." | ".." | "-")) || repository .chars() - .any(|c| c.is_control() || c.is_whitespace() || "/\\%?#".contains(c)) + .any(|c| c.is_control() || c.is_whitespace() || "\\%?#".contains(c)) { return Err(Error::new( 400, @@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu .path_segments_mut() .unwrap() .clear() - .push(repository) + .extend(repository.split('/')) .extend(suffix.iter().copied()); Ok(target) } @@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result { .map(str::to_owned) .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab.")) } -pub async fn repositories(app: &App, owner: &str) -> Result> { +pub(crate) async fn verified_session(app: &App, owner: &str) -> Result { let session = session(app, owner)?; username( &app.shale .page(&app.shale.origin.join("/-/settings")?, &session) .await?, )?; + Ok(session) +} +pub async fn repositories(app: &App, owner: &str) -> Result> { + let session = verified_session(app, owner).await?; let body = app.shale.page(&app.shale.origin, &session).await?; let document = document(&body, "page-index", None)?; let mut repositories = Vec::new(); @@ -359,12 +365,12 @@ impl ServerHandler for Shale { current(app, grant, &credential)?; if name == "list_repositories" { let mut repositories = repositories(app, string(&grant["user"])).await?; - repositories.retain(|r| array(&grant["resources"]).contains(&r["id"])); + repositories.retain(|r| grant["resources"] == "all" || array(&grant["resources"]).contains(&r["id"])); current(app, grant, &credential)?; return Ok(json!({"repositories":repositories})); } let repository = arguments.get("repository").and_then(Value::as_str) - .filter(|r| array(&grant["resources"]).iter().any(|id| id == *r)) + .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r)) .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?; let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { @@ -556,6 +562,15 @@ pub async fn manage( let owner_id = string(&owner["id"]); let key = format!("shale-session:{owner_id}"); match *method { + Method::GET => match repositories(&app, owner_id).await { + Ok(resources) => { + Ok(axum::Json(json!({"linked":true,"resources":resources})).into_response()) + } + Err(error) if error.status == 401 => { + Ok(axum::Json(json!({"linked":false,"resources":[]})).into_response()) + } + Err(error) => Err(error), + }, Method::POST => { let pending = if let Some(id) = body["request"].as_str() { let db = app.mcp.db.lock().unwrap(); @@ -740,10 +755,10 @@ pub async fn oauth(app: Arc, request: Request) -> Response { let _ = app.shale.get("/-/logout", Some(&session)).await; return Err(error); } - let mut target = app.mcp.origin.join("mcp")?; - if let Some(request) = link["request"].as_str() { - target.query_pairs_mut().append_pair("request", request); - } + let target = app.mcp.origin.join(&match link["request"].as_str() { + Some(request) => format!("connect/{request}"), + None => "mcp/settings/shale".to_owned(), + })?; Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response()) }.await; let mut response = match result { @@ -779,7 +794,11 @@ mod tests { "..", "-", "../other", - "one/two", + "one//two", + "one/../two", + "one/./two", + "one/-/two", + "one/", "one\\two", "%2e%2e", "one?x", @@ -795,6 +814,17 @@ mod tests { let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap(); assert_eq!(path.origin(), origin.origin()); assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1"); + let path = repository_path( + &origin, + "userscripts/discord-pluralkit-predict", + &["issues", "1"], + ) + .unwrap(); + assert_eq!(path.origin(), origin.origin()); + assert_eq!( + path.path(), + "/userscripts/discord-pluralkit-predict/issues/1" + ); } #[test] fn issue_pages_must_match_repository_identity_and_issue_number() { diff --git a/dashboard/src/telemetry.rs b/dashboard/src/telemetry.rs index 36977e4eb79125bb58d7b3efdd62a0ceafdf4b1c..2bf2d94de43d0a72bafc78fa2dec6995e12f9939 100644 --- a/dashboard/src/telemetry.rs +++ b/dashboard/src/telemetry.rs @@ -823,12 +823,20 @@ pub fn start(app: Arc) { .unwrap() .iter() .flat_map(|(id, job)| { - array(&job["job"]["TaskGroups"]).iter() + array(&job["job"]["TaskGroups"]) + .iter() .flat_map(|group| array(&group["Services"])) .flat_map(move |service| { array(&service["Tags"]).iter().filter_map(move |tag| { - string(tag).strip_prefix("studio-metrics-path=") - .map(|path| (id.clone(), string(&service["Name"]).to_owned(), path.to_owned())) + string(tag).strip_prefix("studio-metrics-path=").map( + |path| { + ( + id.clone(), + string(&service["Name"]).to_owned(), + path.to_owned(), + ) + }, + ) }) }) }) @@ -842,7 +850,10 @@ pub fn start(app: Arc) { let response = app .request( Method::GET, - &format!("{}{path}", endpoint(app.clone(), &service).await?), + &format!( + "{}{path}", + endpoint(app.clone(), &service).await? + ), )? .timeout(Duration::from_secs(5)) .send() @@ -852,7 +863,10 @@ pub fn start(app: Arc) { Method::POST, &format!( "{base}/api/v1/import/prometheus?{}", - params(&[("extra_label", format!("service={id}")), ("extra_label", format!("instance={service}"))]) + params(&[ + ("extra_label", format!("service={id}")), + ("extra_label", format!("instance={service}")) + ]) ), )? .body(response.text().await?) diff --git a/dashboard/src/users.rs b/dashboard/src/users.rs index 742e326ae641043555b2ab4928fa7d140201683b..1f356017ccbb204b1b6a1e63b65f33ed15c7872c 100644 --- a/dashboard/src/users.rs +++ b/dashboard/src/users.rs @@ -1,85 +1,7 @@ use crate::*; use axum::extract::{FromRequest, Multipart}; -use futures::{StreamExt, stream}; +use rusqlite::{OptionalExtension, params as sql}; -async fn call(path: &str, method: Method, body: Option) -> Result { - host::call(json!({"operation":"iam.request", "path":path, - "method":method.as_str(), "body":body})) - .await -} -async fn get(path: &str) -> Result { - Ok(call(path, Method::GET, None).await?["body"].take()) -} -async fn list() -> Result { - let list = get("/users?max=1000").await?; - let found = stream::iter(array(&list).iter().cloned()) - .map(|mut user| async move { - user["groups"] = get(&format!( - "/users/{}/role-mappings/realm", - encoded(string(&user["id"])) - )) - .await?; - for key in ["email", "firstName", "lastName"] { - if user.get(key).is_none() { - user[key] = Value::Null; - } - } - Ok::<_, Error>(user) - }) - .buffered(4) - .collect::>() - .await - .into_iter() - .collect::>>()?; - Ok(json!(found)) -} -async fn directory(app: Arc) -> Result> { - app.cache - .get( - "users".into(), - Duration::from_secs(300), - move || async move { - let (list, groups) = tokio::try_join!(list(), get("/roles"))?; - let users = stream::iter(array(&list).iter().cloned()) - .map(|mut user| async move { - user["sessions"] = - get(&format!("/users/{}/sessions", encoded(string(&user["id"])))) - .await?; - Ok::<_, Error>(user) - }) - .buffered(4) - .collect::>() - .await - .into_iter() - .collect::>>()?; - Ok(json!({"users":users,"groups":groups})) - }, - ) - .await -} -async fn found(id: &str) -> Result { - array(&list().await?) - .iter() - .find(|u| u["id"] == id) - .cloned() - .ok_or_else(|| Error::new(404, "No user with that id")) -} -async fn spare(me: &Value, id: &str, remove_role: Option<&str>) -> Result<()> { - let user = found(id).await?; - if user["username"] == me["name"] { - let keeps_admin = remove_role.is_some() - && array(&user["groups"]) - .iter() - .any(|g| g["name"] == "infra-admin" && g["name"] != remove_role.unwrap()); - if !keeps_admin { - return Err(Error::new( - 400, - "That would lock you out of this page. Sign in as another admin to change it.", - )); - } - } - Ok(()) -} fn uuid(id: &str) -> Result<()> { if regex::Regex::new( r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", @@ -113,7 +35,7 @@ fn profile(body: &Value, full: bool) -> Result { let value = match key { "username" => { let v = string(value).trim().to_lowercase(); - if !username_pattern.is_match(&v) { + if v.len() > 254 || !username_pattern.is_match(&v) { return Err(Error::new( 400, "Usernames use lowercase letters, digits, dots, dashes, and @", @@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result { .as_str() .ok_or_else(|| Error::new(400, "Enter a name or email address."))? .trim(); + if v.len() > 254 { + return Err(Error::new(400, "Use a shorter name or email address.")); + } if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) { return Err(Error::new(400, "Enter a full email address")); } @@ -138,7 +63,7 @@ fn profile(body: &Value, full: bool) -> Result { value.clone() } _ => { - if !value.is_array() || array(value).iter().any(|v| !v.is_string()) { + if !value.is_array() || array(value).iter().any(|v| v != "UPDATE_PASSWORD") { return Err(Error::new(400, "Invalid required actions.")); } value.clone() @@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result { fn password(value: &Value) -> Result<&str> { value .as_str() - .filter(|s| s.chars().count() >= 8) + .filter(|s| s.chars().count() >= 8 && s.len() <= 1024) .ok_or_else(|| Error::new(400, "Use at least 8 characters")) } +pub(crate) fn revoke_connections(app: &App, id: &str) -> Result<()> { + let mut db = app.mcp.db.lock().unwrap(); + let transaction = db.transaction()?; + for grant in mcp::list(&transaction, "grant:")? { + if grant["user"] == id { + mcp::revoke(&transaction, string(&grant["id"]))?; + } + } + transaction.execute( + "DELETE FROM records WHERE json_extract(value,'$.owner')=?", + [id], + )?; + transaction.commit()?; + Ok(()) +} + +pub async fn self_user(app: &App, me: &Value) -> Result { + let db = app.auth.db.lock().unwrap(); + let id: Option = db + .query_row( + "SELECT id FROM users WHERE username=?", + [string(&me["name"])], + |r| r.get(0), + ) + .optional()?; + auth::user( + &db, + &id.ok_or_else(|| Error::new(401, "Sign in again to open your account."))?, + ) +} + pub async fn route( app: Arc, method: &Method, @@ -163,142 +119,207 @@ pub async fn route( body: Value, ) -> Result { if parts.is_empty() && method == Method::GET { - return Ok(directory(app).await?.response()); + let db = app.auth.db.lock().unwrap(); + let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?; + let ids = statement + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + let users = ids + .iter() + .map(|id| { + let mut user = auth::user(&db, id)?; + user["sessions"] = auth::Store::sessions(&db, id)?; + Ok(user) + }) + .collect::>>()?; + let mut statement = db.prepare("SELECT id,name FROM roles ORDER BY name")?; + let groups = statement + .query_map([], |r| { + Ok(json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?})) + })? + .collect::, _>>()?; + return Ok(Document::new(json!({"users":users,"groups":groups})).response()); } - if let Some(id) = parts.first() { - uuid(id)?; - } - let value = match parts { - [] if method == Method::POST => { - let mut profile = profile(&body["profile"], true)?; - let setup = string(&body["setup"]["kind"]); - if setup == "email" && profile["email"].is_null() { - return Err(Error::new(400, "Add an email address to send a setup link")); - } - if setup != "email" && setup != "password" { - return Err(Error::new(400, "Choose how this user signs in.")); - } - if setup == "password" { - password(&body["setup"]["password"])?; - } - if !body["groups"].is_array() { - return Err(Error::new(400, "Choose groups.")); - } - for group in array(&body["groups"]) { - uuid(string(group))?; - } - let actions = if setup == "email" { - json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"]) - } else { - json!([]) - }; - profile["enabled"] = json!(true); - profile["emailVerified"] = json!(false); - profile["requiredActions"] = actions.clone(); - let response = call("/users", Method::POST, Some(profile)).await?; - let id = response["id"] - .as_str() - .ok_or_else(|| { - Error::new( - 502, - "Keycloak created the user but did not return its ID. Reload the page.", - ) - })? - .to_owned(); - for group in array(&body["groups"]) { - change_role(&id, string(group), Method::POST).await?; - } - if setup == "email" { - call( - &format!("/users/{id}/execute-actions-email"), - Method::PUT, - Some(actions), + if parts.is_empty() && method == Method::POST { + let mut profile = profile(&body["profile"], true)?; + let setup = string(&body["setup"]["kind"]); + if setup != "invite" && setup != "password" { + return Err(Error::new(400, "Choose an invitation or a password.")); + } + let hash = if setup == "password" { + Some( + app.auth + .hash_password(password(&body["setup"]["password"])?) + .await?, + ) + } else { + None + }; + if !body["groups"].is_array() { + return Err(Error::new(400, "Choose groups.")); + } + let id = uuid::Uuid::new_v4().to_string(); + profile["enabled"] = json!(true); + profile["emailVerified"] = json!(false); + profile["requiredActions"] = json!([if hash.is_some() { + "UPDATE_PASSWORD" + } else { + "SETUP" + }]); + profile["createdTimestamp"] = json!((now() * 1000.0) as i64); + profile["attributes"] = json!({}); + { + let mut db = app.auth.db.lock().unwrap(); + let transaction = db.transaction()?; + transaction + .execute( + "INSERT INTO users(id,profile) VALUES (?,?)", + sql![id, profile.to_string()], ) - .await?; + .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?; + for group in array(&body["groups"]) { + let group = string(group); + uuid(group)?; + if transaction.execute( + "INSERT OR IGNORE INTO memberships SELECT ?,id FROM roles WHERE id=?", + sql![id, group], + )? == 0 + { + return Err(Error::new(400, "Choose an available group.")); + } + } + if let Some(hash) = &hash { + auth::set_password(&transaction, &id, hash)?; + } + transaction.commit()?; + } + return Ok((StatusCode::CREATED,axum::Json(json!({"id":id,"url":if setup=="invite" {Some(app.auth.setup_link(&id)?)}else{None}}))).into_response()); + } + let id = parts + .first() + .ok_or_else(|| Error::new(404, "No user here."))?; + uuid(id)?; + if *parts == [*id, "setup-link"] && method == Method::POST { + return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response()); + } + let hash = if *parts == [*id, "password"] && method == Method::PUT { + Some(app.auth.hash_password(password(&body["password"])?).await?) + } else { + None + }; + let mut db = app.auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let mut user = auth::user(&transaction, id)?; + let own = user["username"] == me["name"]; + let value = match parts { + [_] if method == Method::PATCH => { + let patch = profile(&body, false)?; + if own && patch["enabled"] == false { + return Err(Error::new( + 400, + "Sign in as another admin to disable your account.", + )); + } + if patch.get("username").is_some() && patch["username"] != user["username"] { + return Err(Error::new( + 400, + "Usernames are fixed to preserve service identities.", + )); + } + user.as_object_mut() + .unwrap() + .extend(patch.as_object().unwrap().clone()); + auth::save_user(&transaction, id, user)?; + Value::Null + } + [_] if method == Method::DELETE => { + if own { + return Err(Error::new( + 400, + "Sign in as another admin to delete your account.", + )); + } + transaction.execute( + "DELETE FROM pending WHERE json_extract(data,'$.user')=?", + [id], + )?; + transaction.execute("DELETE FROM users WHERE id=?", [id])?; + Value::Null + } + [_, "groups", group] if method == Method::PUT || method == Method::DELETE => { + let name: Option = transaction + .query_row("SELECT name FROM roles WHERE id=?", [group], |r| r.get(0)) + .optional()?; + let name = name + .ok_or_else(|| Error::new(404, "This group no longer exists. Reload the page."))?; + if own && method == Method::DELETE && name == "infra-admin" { + return Err(Error::new( + 400, + "Sign in as another admin to remove your admin access.", + )); + } + if method == Method::PUT { + transaction.execute( + "INSERT OR IGNORE INTO memberships VALUES (?,?)", + sql![id, group], + )?; } else { - call(&format!("/users/{id}/reset-password"),Method::PUT,Some(json!({"type":"password","value":body["setup"]["password"],"temporary":true}))).await?; + transaction.execute( + "DELETE FROM memberships WHERE user_id=? AND role_id=?", + sql![id, group], + )?; } - app.cache.invalidate("users"); - return Ok((StatusCode::CREATED, axum::Json(json!({"id":id}))).into_response()); - } - [id] if method == Method::PATCH => { - let profile = profile(&body, false)?; - if profile["enabled"] == false { - spare(me, id, None).await?; - } - call(&format!("/users/{id}"), Method::PUT, Some(profile)).await?; - Value::Null - } - [id] if method == Method::DELETE => { - spare(me, id, None).await?; - call(&format!("/users/{id}"), Method::DELETE, None).await?; - Value::Null - } - [id, "groups", group] if method == Method::PUT || method == Method::DELETE => { - uuid(group)?; - if method == Method::DELETE { - let groups = get("/roles").await?; - let name = array(&groups) - .iter() - .find(|g| g["id"] == *group) - .map(|g| string(&g["name"])); - spare(me, id, name).await?; - } - change_role( - id, - group, - if method == Method::PUT { - Method::POST - } else { - Method::DELETE - }, - ) - .await?; Value::Null } - [id, "credentials"] if method == Method::GET => { - get(&format!("/users/{id}/credentials")).await? - } - [id, "logout"] if method == Method::POST => { - call(&format!("/users/{id}/logout"), Method::POST, None).await?; + [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?, + [_, "logout"] if method == Method::POST => { + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; Value::Null } - [id, "actions-email"] if method == Method::POST => { - let user = found(id).await?; - if user["email"].is_null() { - return Err(Error::new(400, "Add an email address first")); - } - if array(&user["requiredActions"]).is_empty() { - return Err(Error::new(400, "Pick at least one required action first")); - } - call( - &format!("/users/{id}/execute-actions-email"), - Method::PUT, - Some(user["requiredActions"].clone()), - ) - .await?; + [_, "setup-link"] if method == Method::DELETE => { + transaction.execute( + "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?", + [id], + )?; Value::Null } - [id, "password"] if method == Method::PUT => { - let password = password(&body["password"])?; + [_, "password"] if method == Method::PUT => { if !body["temporary"].is_boolean() { return Err(Error::new( 400, "Choose whether this password is temporary.", )); } - call( - &format!("/users/{id}/reset-password"), - Method::PUT, - Some(json!({"type":"password","value":password,"temporary":body["temporary"]})), - ) - .await?; + auth::set_password(&transaction, id, hash.as_deref().unwrap())?; + user["requiredActions"] = if body["temporary"] == true { + json!(["UPDATE_PASSWORD"]) + } else { + json!([]) + }; + auth::save_user(&transaction, id, user)?; + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; Value::Null } - _ => return Err(Error::new(404, "Not Found")), + _ => return Err(Error::new(404, "No account action here.")), }; if method != Method::GET { - app.cache.invalidate("users"); + transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration','handoff') AND json_extract(data,'$.user')=?",[id])?; + if body["enabled"] == false { + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; + transaction.execute( + "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?", + [id], + )?; + } + } + transaction.commit()?; + drop(db); + if body["enabled"] == false + || hash.is_some() + || (method == Method::DELETE && !matches!(parts, [_, "setup-link"])) + || matches!(parts, [_, "logout"]) + { + revoke_connections(&app, id)?; } Ok(if value.is_null() { StatusCode::NO_CONTENT.into_response() @@ -306,54 +327,6 @@ pub async fn route( Document::new(value).response() }) } -async fn change_role(id: &str, group: &str, method: Method) -> Result<()> { - let roles = get("/roles").await?; - let role = array(&roles) - .iter() - .find(|g| g["id"] == group) - .ok_or_else(|| Error::new(404, "That role is no longer available. Reload the page."))?; - call( - &format!("/users/{id}/role-mappings/realm"), - method, - Some(json!([role])), - ) - .await?; - Ok(()) -} -pub async fn self_user(app: &App, me: &Value) -> Result { - let name = string(&me["name"]).to_owned(); - let value = app - .cache - .coalesce(format!("identity:{name}"), move || async move { - let found = get(&format!("/users?username={}&exact=true", encoded(&name))).await?; - let mut user = array(&found) - .iter() - .find(|u| u["username"] == name) - .cloned() - .ok_or_else(|| { - Error::new( - 404, - format!( - "Keycloak has no user named {}. Sign out, then sign in again.", - name - ), - ) - })?; - user["groups"] = get(&format!( - "/users/{}/role-mappings/realm", - encoded(string(&user["id"])) - )) - .await?; - for key in ["email", "firstName", "lastName"] { - if user.get(key).is_none() { - user[key] = Value::Null; - } - } - Ok(user) - }) - .await?; - Ok(value.value.clone()) -} fn image_type(bytes: &[u8]) -> Option<&'static str> { if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { Some("image/webp") @@ -396,130 +369,62 @@ pub async fn account( me: &Value, ) -> Result { let method = request.method().clone(); - let headers = request.headers(); - let origin = format!( - "{}://{}", - headers - .get("X-Forwarded-Proto") - .and_then(|h| h.to_str().ok()) - .unwrap_or("http"), - headers - .get("X-Forwarded-Host") - .or(headers.get("Host")) - .and_then(|h| h.to_str().ok()) - .unwrap_or("localhost") - ); - let realm = || { - std::env::var("STUDIO_KEYCLOAK_URL") - .map(|s| format!("{s}/realms/master")) - .map_err(|_| { - Error::new( - 501, - "Keycloak isn't connected to this home server. Connect it, then retry.", - ) - }) - }; - if parts == ["sign-out"] && method == Method::GET { - let logout = format!( - "{}/protocol/openid-connect/logout?{}", - realm()?, - params(&[ - ("client_id", "forward-auth".into()), - ("post_logout_redirect_uri", format!("{origin}/")) - ]) - ); - return Ok(( - StatusCode::FOUND, - [( - "location", - format!("/snow.oauth2/sign_out?{}", params(&[("rd", logout)])), - )], - ) - .into_response()); - } - if let ["actions", action] = parts { - if method != Method::GET - || (![ - "webauthn-register-passwordless", - "UPDATE_PASSWORD", - "UPDATE_EMAIL", - ] - .contains(action) - && !regex::Regex::new(r"^delete_credential:[\w-]+$") - .unwrap() - .is_match(action)) - { - return Err(Error::new( - 400, - "Keycloak can't start that action from here", - )); - } - return Ok(( - StatusCode::FOUND, - [( - "location", - format!( - "{}/protocol/openid-connect/auth?{}", - realm()?, - params(&[ - ("client_id", "forward-auth".into()), - ("redirect_uri", format!("{origin}/account")), - ("response_type", "code".into()), - ("scope", "openid".into()), - ("kc_action", action.to_string()) - ]) - ), - )], - ) - .into_response()); - } let mut user = self_user(&app, me).await?; let id = string(&user["id"]).to_owned(); let value = match parts { [] if method == Method::GET => { - let attributes = user - .as_object_mut() - .unwrap() - .remove("attributes") - .unwrap_or(Value::Null); - user["picture"] = attributes["picture"][0].clone(); - user["credentials"] = get(&format!("/users/{id}/credentials")).await?; - user["console"] = json!(format!("{}/account", realm()?)); + user["picture"] = user["attributes"]["picture"][0].clone(); + user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?; + user.as_object_mut().unwrap().remove("attributes"); user } [] if method == Method::PATCH => { - let body: Value = serde_json::from_slice( - &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?, - ) - .map_err(|_| Error::new(400, "Invalid profile."))?; - let mut value = serde_json::Map::new(); - for key in ["firstName", "lastName"] { - if let Some(v) = body.get(key) { - let v = v - .as_str() - .ok_or_else(|| Error::new(400, "Enter a name."))? - .trim(); - value.insert( - key.into(), - if v.is_empty() { Value::Null } else { json!(v) }, - ); + let body: Value = + serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?; + for key in ["firstName", "lastName", "email"] { + if body.get(key).is_some() { + let mut field = serde_json::Map::new(); + field.insert(key.to_owned(), body[key].clone()); + let patch = profile(&Value::Object(field), false)?; + user[key] = patch[key].clone(); + if key == "email" { + user["emailVerified"] = json!(false); + } } } - call( - &format!("/users/{id}"), - Method::PUT, - Some(Value::Object(value)), - ) - .await?; + user["requiredActions"] = json!( + array(&user["requiredActions"]) + .iter() + .filter(|v| **v != "UPDATE_PROFILE") + .collect::>() + ); + auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; Value::Null } - ["verify-email"] if method == Method::POST => { - call( - &format!("/users/{id}/execute-actions-email"), - Method::PUT, - Some(json!(["VERIFY_EMAIL"])), - ) - .await?; + ["credentials", credential] if method == Method::DELETE => { + app.auth.recent(request.headers())?; + let mut db = app.auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let count: i64 = transaction.query_row( + "SELECT count(*) FROM credentials WHERE user_id=?", + [&id], + |r| r.get(0), + )?; + if count <= 1 { + return Err(Error::new( + 400, + "Add another sign-in method before removing this one.", + )); + } + if transaction.execute( + "DELETE FROM credentials WHERE user_id=? AND id=?", + sql![id, credential], + )? == 0 + { + return Err(Error::new(404, "This sign-in method was already removed.")); + } + transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration') AND json_extract(data,'$.user')=?",[&id])?; + transaction.commit()?; Value::Null } ["picture"] if method == Method::PUT => { @@ -554,32 +459,22 @@ pub async fn account( tokio::fs::create_dir_all(app.data.join("pictures")).await?; tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?; let picture = format!( - "{origin}/api/account/pictures/{id}?v={}", + "{}/api/account/pictures/{id}?v={}", + app.auth.origin.origin().ascii_serialization(), (now() * 1000.0) as u64 ); - call( - &format!("/users/{id}"), - Method::PUT, - Some(json!({"attributes":{"picture":[picture]}})), - ) - .await?; + user["attributes"]["picture"] = json!([picture]); + auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; json!({"picture":picture}) } ["picture"] if method == Method::DELETE => { - call( - &format!("/users/{id}"), - Method::PUT, - Some(json!({"attributes":{"picture":null}})), - ) - .await?; + user["attributes"]["picture"] = Value::Null; + auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await; Value::Null } - _ => return Err(Error::new(404, "Not Found")), + _ => return Err(Error::new(404, "No account action here.")), }; - if method != Method::GET { - app.cache.invalidate("users"); - } Ok(if value.is_null() { StatusCode::NO_CONTENT.into_response() } else { diff --git a/dashboard/web/api.contract.ts b/dashboard/web/api.contract.ts index 8f0dd97eac896786849c5741237f933321d6ffaf..9442e1ef8e9fc198137eb4e8f4e2f4521b8dacad 100644 --- a/dashboard/web/api.contract.ts +++ b/dashboard/web/api.contract.ts @@ -1,4 +1,4 @@ -import type { Connections, Consent } from "./types/mcp.ts"; +import type { Access, Connections, Consent, Resources } from "./types/mcp.ts"; import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts"; import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts"; import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts"; @@ -55,10 +55,10 @@ type ExplorerRoutes = { [P in keyof Explorer as `${Prefix export type Api = Hono<{}, { "/mcp": { $get: Endpoint; }; - "/mcp/shale": { $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint; }; + "/mcp/shale": { $get: Endpoint<{ linked: boolean; resources: Resources }>; $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint; }; "/mcp/consent/:id": { $get: Endpoint; - $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: string[] } | { deny: true } }>; + $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: Access } | { deny: true } }>; }; "/mcp/relay/pair": { $post: Endpoint; }; "/mcp/relay/machines/:id": { @@ -66,7 +66,11 @@ export type Api = Hono<{}, { $delete: Endpoint; }; "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; }; - "/mcp/connections/:id": { $delete: Endpoint; }; + "/mcp/connections/:id": { + $get: Endpoint<{ resources: Resources; selected: Access; linked: boolean; resourceError: string | null }, { param: { id: string } }>; + $post: Endpoint; + $delete: Endpoint; + }; "/me": { $get: Endpoint; @@ -210,7 +214,7 @@ export type Api = Hono<{}, { }; "/users": { $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>; - $post: Endpoint<{ id: string; }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "email"; } | { kind: "password"; password: string; }; }; }, 201>; + $post: Endpoint<{ id: string; url: string | null }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "invite"; } | { kind: "password"; password: string; }; }; }, 201>; }; "/users/:id": { $patch: Endpoint; @@ -226,8 +230,9 @@ export type Api = Hono<{}, { "/users/:id/logout": { $post: Endpoint; }; - "/users/:id/actions-email": { - $post: Endpoint; + "/users/:id/setup-link": { + $post: Endpoint<{url:string}, { param: { id: string; }; }>; + $delete: Endpoint; }; "/users/:id/password": { $put: Endpoint; @@ -283,11 +288,8 @@ export type Api = Hono<{}, { $post: Endpoint; }; "/account": { - $get: Endpoint; - $patch: Endpoint; - }; - "/account/verify-email": { - $post: Endpoint; + $get: Endpoint; + $patch: Endpoint; }; "/account/picture": { $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; @@ -296,10 +298,7 @@ export type Api = Hono<{}, { "/account/pictures/:id": { $get: Endpoint; }; - "/account/actions/:action": { - $get: Endpoint; - }; - "/account/sign-out": { - $get: Endpoint; + "/account/credentials/:id": { + $delete: Endpoint; }; } & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>; diff --git a/dashboard/web/auth.ts b/dashboard/web/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..3997cdefbe9bb7aa2301750785d88f9a7ad41da6 --- /dev/null +++ b/dashboard/web/auth.ts @@ -0,0 +1,24 @@ +export async function authRequest(path: string, body?: object): Promise { + const response = await fetch(`/auth/${path}`, body ? { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + } : undefined); + if (!response.ok) throw new DetailedError(response.statusText, { statusCode: response.status, detail: { data: await response.text() } }); + return response.status === 204 ? undefined as T : response.json(); +} + +export async function addPasskey(label: string) { + const { csrf } = await authRequest<{ csrf: string }>("status"); + const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialCreationOptionsJSON }; token: string }>("passkey/register", { csrf }); + const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.publicKey) }); + if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey setup was canceled. Try again when you're ready."); + await authRequest("passkey/save", { csrf, token, credential: credential.toJSON(), label }); +} + +export async function signOut() { + await authRequest("sign-out", {}); + window.location.assign("/sign-in"); +} + +export const authReason = (failure: unknown) => failure instanceof DetailedError ? reason(failure) : failure instanceof Error ? failure.message : "Couldn't finish sign-in. Try again."; +import { DetailedError } from "hono/client"; +import { reason } from "./api.ts"; diff --git a/dashboard/web/components/Sidebar.tsx b/dashboard/web/components/Sidebar.tsx index 070f4901e5afdb5ca01c9c437ce2fd646a2dd289..2651f9c881a4bdab0da55ba70567d07e8ad1bfb4 100644 --- a/dashboard/web/components/Sidebar.tsx +++ b/dashboard/web/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts"; import { queries } from "../api.ts"; import snowflake from "../snowflake.svg"; import { bytes, cores, plural } from "../format.ts"; +import { signOut } from "../auth.ts"; import { account, Avatar, displayName } from "../pages/Account.tsx"; import { status } from "../pages/Overview.tsx"; import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; @@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) {