From b7b2488f4e5c48434d2b275ce260ea21e8c65e42 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 18 Mar 2026 21:01:16 +0000 Subject: [PATCH] feat: add builtin oauth, removing AIP dependency --- .env.example | 4 ++-- Cargo.lock | 509 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- Cargo.toml | 12 +++++++++++- docker-compose.yml | 4 ++-- docs/README.md | 2 +- docs/getting-started/authentication.md | 50 ++++++++++++++++++-------------------------------- docs/getting-started/configuration.md | 6 ++++-- docs/getting-started/dashboard.md | 2 +- docs/getting-started/deployment/docker.md | 5 ----- docs/getting-started/deployment/other.md | 6 +++--- docs/getting-started/deployment/railway.md | 12 +++++------- docs/getting-started/quickstart.md | 2 +- docs/reference/admin-api.md | 15 ++++++++------- docs/reference/architecture.md | 52 +++++++++++++++++++++++++++++++++++----------------- docs/reference/glossary.md | 2 -- docs/reference/production-deployment.md | 19 ++++++++++--------- docs/reference/troubleshooting.md | 21 +++++++++------------ docs/reference/xrpc-api.md | 4 ++-- docs/tutorials/statusphere.md | 2 +- migrations/postgres/20260319000000_create_oauth_tables.sql | 12 ++++++++++++ migrations/sqlite/20260319000000_create_oauth_tables.sql | 12 ++++++++++++ src/aip.rs | 259 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- src/auth/jwks.rs | 58 ---------------------------------------------------------- src/auth/middleware.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------ src/auth/mod.rs | 6 ++++++ src/auth/oauth_store.rs | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/auth/routes.rs | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/auth/service_auth.rs | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 33 +++++++++++++++++---------------- src/dns.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 30 +++++++++++++++++++++++++++++- src/lua/atproto_api.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++--- src/lua/db_api.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++--- src/lua/execute.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- src/lua/http_api.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++--- src/lua/record.rs | 5 +++-- src/main.rs | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- src/repo/dpop.rs | 222 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ src/repo/mod.rs | 3 +-- src/repo/pds.rs | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------- src/repo/session.rs | 53 ++++++++++++++--------------------------------------- src/repo/upload_blob.rs | 4 ++-- src/server.rs | 11 ++++++++--- src/xrpc/procedure.rs | 8 ++++---- tests/common/app.rs | 48 +++++++++++++++++++++++++++++++++++++++++++----- tests/common/auth.rs | 36 ++++++++++++++++++++++-------------- tests/common/fixtures.rs | 5 ----- tests/e2e_admin.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------- tests/e2e_labelers.rs | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------- tests/e2e_network_lexicons.rs | 34 +++++++++++++++++++--------------- tests/e2e_xrpc.rs | 58 +++++++++++++++++++++++++++++++++++----------------------- tests/lua_atproto_api.rs | 38 ++++++++++++++++++++++++++++++++++++-- tests/lua_db_api.rs | 38 ++++++++++++++++++++++++++++++++++++-- web/next.config.ts | 4 ++-- web/src/app/dashboard/backfill/page.tsx | 18 +++++++----------- web/src/app/dashboard/lexicons/page.tsx | 12 +++++------- web/src/app/dashboard/page.tsx | 6 ++---- web/src/app/dashboard/records/page.tsx | 24 +++++++++++------------- web/src/app/dashboard/settings/api-keys/page.tsx | 14 +++++--------- web/src/app/dashboard/settings/env-variables/page.tsx | 15 +++++---------- web/src/app/dashboard/settings/labelers/page.tsx | 16 ++++++---------- web/src/app/dashboard/settings/rate-limits/page.tsx | 18 +++++++----------- web/src/app/dashboard/settings/users/page.tsx | 18 ++++++++---------- web/src/hooks/use-current-user.ts | 6 +++--- web/src/hooks/use-lua-completions.ts | 8 +++----- web/src/lib/api.ts | 153 +++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------ web/src/lib/auth-context.tsx | 319 +++++++++++++++++++++++++++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- web/src/lib/config-context.tsx | 8 +++----- web/src/lib/dpop.ts | 113 ----------------------------------------------------------------------------------------------------------------- 69 file(s) changed, 2244 insertion(s)(+), 1730 deletion(s)(-) diff --git a/.env.example b/.env.example --- a/.env.example +++ b/.env.example @@ -14,7 +14,8 @@ TAP_COLLECTION_FILTERS= TAP_SIGNAL_COLLECTIONS= # HappyView -AIP_URL=https://aip.gamesgamesgamesgames.games +PUBLIC_URL=http://localhost:3000 +SESSION_SECRET=change-me-in-production TAP_URL=http://tap:2480 RELAY_URL=https://relay1.us-east.bsky.network PORT=3000 @@ -22,4 +23,3 @@ # Web dashboard WEB_HOSTNAME=0.0.0.0 API_URL=http://happyview:3000 -AIP_PROXY_URL=https://aip.gamesgamesgamesgames.games diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -3,6 +3,12 @@ # It is not intended for manual editing. version = 4 [[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -52,6 +58,29 @@ "serde_json", ] [[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] name = "async-trait" version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -78,6 +107,101 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] +name = "atrium-api" +version = "0.25.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f182d9437cd447ed87eca75540151653e332d6753a2a4749d72c0f15aa1f179" +dependencies = [ + "atrium-common", + "atrium-xrpc", + "chrono", + "http", + "ipld-core", + "langtag", + "regex", + "serde", + "serde_bytes", + "serde_json", + "thiserror 1.0.69", + "tokio", + "trait-variant", +] + +[[package]] +name = "atrium-common" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff94b4ce3e9ba11d8bda83674e75ccaca281d5251ec3816d03e6bb23583ff4f" +dependencies = [ + "dashmap", + "lru", + "moka", + "thiserror 1.0.69", + "tokio", + "trait-variant", + "web-time", +] + +[[package]] +name = "atrium-identity" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e7cfd14c15bda5087b340a4a8825a7315bbf06a4f879a02186f10481e8a22a6" +dependencies = [ + "atrium-api", + "atrium-common", + "atrium-xrpc", + "serde", + "serde_html_form", + "serde_json", + "thiserror 1.0.69", + "trait-variant", +] + +[[package]] +name = "atrium-oauth" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0189ceacad86c3f19e79a548e75897d95d33286857d9f692d83cf9b094cf83d" +dependencies = [ + "atrium-api", + "atrium-common", + "atrium-identity", + "atrium-xrpc", + "base64", + "chrono", + "dashmap", + "ecdsa", + "elliptic-curve", + "jose-jwa", + "jose-jwk", + "p256", + "rand 0.8.5", + "reqwest", + "serde", + "serde_html_form", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "trait-variant", +] + +[[package]] +name = "atrium-xrpc" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944b35cc08732d40ddbb3356be9e38d11aed4b4c40c33f5b0f235e0650eff296" +dependencies = [ + "http", + "serde", + "serde_html_form", + "serde_json", + "thiserror 1.0.69", + "trait-variant", +] + +[[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -158,10 +282,52 @@ "tracing", ] [[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] [[package]] name = "base64" @@ -281,6 +447,20 @@ "half", ] [[package]] +name = "cid" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" +dependencies = [ + "core2", + "multibase", + "multihash", + "serde", + "serde_bytes", + "unsigned-varint", +] + +[[package]] name = "cmake" version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -290,6 +470,23 @@ "cc", ] [[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] name = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -305,6 +502,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "base64", + "hkdf", + "hmac", + "percent-encoding", + "rand 0.8.5", + "sha2", + "subtle", + "time", + "version_check", +] + +[[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -321,6 +541,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -343,6 +572,15 @@ name = "crc-catalog" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] [[package]] name = "critical-section" @@ -432,6 +670,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] +name = "data-encoding-macro" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] name = "deadpool" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -624,6 +882,16 @@ "pin-project-lite", ] [[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -646,6 +914,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] name = "flume" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -893,7 +1171,13 @@ name = "happyview" version = "0.1.0" dependencies = [ "arc-swap", + "atrium-api", + "atrium-common", + "atrium-identity", + "atrium-oauth", + "atrium-xrpc", "axum", + "axum-extra", "base64", "bytes", "chrono", @@ -905,8 +1189,11 @@ "hex", "hickory-resolver", "http-body-util", "ipnet", + "jose-jwk", "jsonwebtoken", + "k256", "mlua", + "multibase", "p256", "rand 0.9.2", "regex", @@ -998,7 +1285,7 @@ "ipnet", "once_cell", "rand 0.9.2", "ring", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tokio", "tracing", @@ -1021,7 +1308,7 @@ "parking_lot", "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -1341,6 +1628,17 @@ "winreg", ] [[package]] +name = "ipld-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda" +dependencies = [ + "cid", + "serde", + "serde_bytes", +] + +[[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1373,6 +1671,40 @@ "libc", ] [[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "serde", + "zeroize", +] + +[[package]] name = "js-sys" version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1398,6 +1730,29 @@ "simple_asn1", ] [[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "langtag" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed60c85f254d6ae8450cec15eedd921efbc4d1bdf6fcf6202b9a58b403f6f805" +dependencies = [ + "serde", +] + +[[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1474,6 +1829,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] name = "lua-src" version = "550.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1490,6 +1854,17 @@ checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" dependencies = [ "cc", "which", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1540,6 +1915,16 @@ "unicase", ] [[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] name = "mio" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1590,15 +1975,41 @@ version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" dependencies = [ + "async-lock", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", "equivalent", + "event-listener", + "futures-util", "parking_lot", "portable-atomic", "smallvec", "tagptr", "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +dependencies = [ + "core2", + "serde", + "unsigned-varint", ] [[package]] @@ -2298,6 +2709,16 @@ "serde", ] [[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2315,6 +2736,19 @@ dependencies = [ "proc-macro2", "quote", "syn", +] + +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", ] [[package]] @@ -2437,6 +2871,12 @@ "rand_core 0.6.4", ] [[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] name = "simple_asn1" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2444,7 +2884,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2544,7 +2984,7 @@ "serde", "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2628,7 +3068,7 @@ "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "whoami", ] @@ -2666,7 +3106,7 @@ "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "whoami", ] @@ -2691,7 +3131,7 @@ "percent-encoding", "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", ] @@ -2792,11 +3232,31 @@ ] [[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.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[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", ] [[package]] @@ -2985,6 +3445,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -3095,6 +3556,17 @@ "tracing-serde", ] [[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3115,7 +3587,7 @@ "rand 0.9.2", "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -3169,6 +3641,12 @@ name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" [[package]] name = "untrusted" @@ -3372,6 +3850,16 @@ name = "web-sys" version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -3916,6 +4404,9 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "serde", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -2,20 +2,30 @@ [package] name = "happyview" version = "0.1.0" edition = "2024" +default-run = "happyview" [dependencies] arc-swap = "1" +atrium-oauth = { version = "0.1", features = ["default-client"] } +atrium-identity = "0.1" +atrium-common = "0.1" +atrium-api = { version = "0.25", features = ["agent"] } +atrium-xrpc = "0.12" axum = "0.8" +axum-extra = { version = "0.10", features = ["cookie", "cookie-signed", "cookie-key-expansion", "query"] } base64 = "0.22" dashmap = "6" dotenvy = "0.15" hex = "0.4" futures-util = "0.3" +jose-jwk = { version = "0.1", default-features = false, features = ["p256"] } jsonwebtoken = "9" bytes = "1" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" -p256 = { version = "0.13", features = ["pkcs8"] } +k256 = { version = "0.13", features = ["ecdsa"] } +multibase = "0.9" +p256 = { version = "0.13", features = ["pkcs8", "ecdsa"] } uuid = { version = "1", features = ["v4"] } rand = "0.9" reqwest = { version = "0.12", features = ["json"] } diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,7 +49,8 @@ - cargo-git:/usr/local/cargo/git - cargo-target:/app/target environment: DATABASE_URL: ${DATABASE_URL}/happyview - AIP_URL: ${AIP_URL} + PUBLIC_URL: ${PUBLIC_URL} + SESSION_SECRET: ${SESSION_SECRET} TAP_URL: ${TAP_URL} TAP_ADMIN_PASSWORD: ${TAP_ADMIN_PASSWORD} RELAY_URL: ${RELAY_URL} @@ -73,7 +74,6 @@ - web-node-modules:/app/node_modules environment: HOSTNAME: ${WEB_HOSTNAME} API_URL: ${API_URL} - AIP_PROXY_URL: ${AIP_PROXY_URL} volumes: # Uncomment if using Postgres: diff --git a/docs/README.md b/docs/README.md --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ ## Features - 📜 **Lexicon-Driven**: Upload your lexicon schemas and HappyView generates fully functional XRPC query and procedure endpoints automatically, no code required - 🔄 **Real-Time Sync**: Records stream in from the AT Protocol network in real-time via [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap), with cryptographic verification and backfill via the admin API -- 🔐 **OAuth Built In**: [AIP](https://github.com/graze-social/aip) handles authentication, and writes are proxied back to the user's PDS, so there's no session management needed +- 🔐 **OAuth Built In**: AT Protocol OAuth is handled natively via `atrium-oauth`, and writes are proxied back to the user's PDS with automatic DPoP and token refresh - 🌙 **Lua Scripting**: Add custom query and procedure logic with Lua scripts that have full access to the record database - 🗄️ **Automatic Indexing**: HappyView indexes relevant records into PostgreSQL as they arrive, ready to query - 🪝 **Index Hooks**: Attach Lua scripts to record collections that fire on every create, update, or delete — sync to search engines, trigger webhooks, or build materialized views in real time diff --git a/docs/getting-started/authentication.md b/docs/getting-started/authentication.md --- a/docs/getting-started/authentication.md +++ b/docs/getting-started/authentication.md @@ -1,6 +1,6 @@ # Authentication -HappyView uses [AT Protocol OAuth](https://atproto.com/specs/oauth) for authentication, handled by an external [AIP](https://github.com/graze-social/aip) instance. HappyView does not store credentials or issue tokens: all OAuth is delegated to AIP. +HappyView uses [AT Protocol OAuth](https://atproto.com/specs/oauth) for authentication, handled natively via the `atrium-oauth` library. HappyView manages the full OAuth flow internally — no external auth service is required. ## Which endpoints require auth? @@ -11,52 +11,38 @@ | Procedures (`POST /xrpc/{method}`) | Yes | | Admin API (`/admin/*`) | Yes (must be a user with appropriate [permissions](../guides/permissions.md)) | | Health check (`GET /health`) | No | -Authenticated requests must include an `Authorization` header with a token issued by AIP: +Authentication uses signed session cookies set during the OAuth login flow. For programmatic access, API keys (prefixed `hv_`) are also supported via the `Authorization: Bearer` header. -``` -Authorization: Bearer -``` +## Logging in via the dashboard -## Getting a token from the dashboard +1. Open the dashboard and click **Log in** +2. Enter your AT Protocol handle (e.g. `user.bsky.social`) +3. You'll be redirected to your identity provider's authorization page +4. After approving, you're redirected back to HappyView with a session cookie set -The easiest way to get a token for CLI or curl usage is through the [web dashboard](dashboard.md): +The session cookie is HttpOnly and signed. It persists across browser sessions until you log out or the OAuth session expires. -1. Open the dashboard and log in with your AT Protocol identity -2. Open your browser's developer tools (F12 or Cmd+Shift+I) -3. Go to **Application** (Chrome) or **Storage** (Firefox) > **Session Storage** -4. Find the entry for your dashboard's URL -5. Copy the value of the `session` key: this contains your access token +## Programmatic access -You can then use it in curl: +For scripts or CI/CD pipelines, use [API keys](../guides/api-keys.md) instead of OAuth: ```sh -export TOKEN="your-token-here" +export TOKEN="hv_your-api-key-here" curl http://localhost:3000/admin/lexicons \ -H "Authorization: Bearer $TOKEN" ``` -Tokens expire based on AIP's configuration. When a token expires, log in again through the dashboard to get a new one. +API keys are created via the dashboard or `POST /admin/api-keys`. See the [API Keys guide](../guides/api-keys.md) for details. -## Programmatic access +## How authentication works -For scripts or applications that need to authenticate programmatically, you'll need to implement the AT Protocol OAuth flow against your AIP instance. This involves: +HappyView supports three authentication methods: -1. Registering an OAuth client with AIP -2. Redirecting the user to AIP's authorization endpoint -3. Exchanging the authorization code for an access token -4. Using that token with HappyView +1. **Session cookie** (web UI) — Set during the OAuth callback flow. The signed cookie contains the user's DID, which HappyView reads on each request. +2. **API key** (programmatic) — Bearer tokens starting with `hv_`. HappyView looks up the key hash in the database to resolve the caller's DID and permissions. +3. **Service auth JWT** (AT Protocol inter-service) — Standard AT Protocol service authentication via signed JWTs. HappyView validates the signature by resolving the issuer's DID document. -See the [AIP documentation](https://github.com/graze-social/aip) for endpoint details and the [ATProto OAuth spec](https://atproto.com/specs/oauth) for the full protocol. - -## How token validation works - -When HappyView receives an authenticated request, it forwards the token to AIP's `/oauth/userinfo` endpoint. AIP responds with the user's DID, which HappyView uses to: - -- Identify who is making the request -- Proxy writes to the correct PDS -- Check admin permissions (for admin endpoints) - -Token validation happens on every request; there is no local token caching. +For write operations (procedures), HappyView uses the stored OAuth session to proxy writes to the user's PDS. The `atrium-oauth` library handles DPoP proof generation and token refresh automatically. ## Admin access diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -8,7 +8,8 @@ | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) | | `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set | -| `AIP_URL` | yes | --- | [AIP](https://github.com/graze-social/aip) instance URL for OAuth token validation | +| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`) | +| `SESSION_SECRET` | no | dev default | Secret key for signing session cookies. **Must be set in production** | | `HOST` | no | `0.0.0.0` | Bind host | | `PORT` | no | `3000` | Bind port | | `TAP_URL` | no | `http://localhost:2480` | [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) instance URL for real-time record streaming and backfill | @@ -23,7 +24,8 @@ ```sh # SQLite (default — zero setup required) DATABASE_URL=sqlite://data/happyview.db?mode=rwc -AIP_URL=http://localhost:8080 +PUBLIC_URL=http://localhost:3000 +SESSION_SECRET=change-me-in-production # Or use Postgres instead: # DATABASE_URL=postgres://happyview:happyview@localhost/happyview diff --git a/docs/getting-started/dashboard.md b/docs/getting-started/dashboard.md --- a/docs/getting-started/dashboard.md +++ b/docs/getting-started/dashboard.md @@ -4,7 +4,7 @@ HappyView ships with a web dashboard that provides a visual interface for everything the [admin API](../reference/admin-api.md) offers: managing lexicons, viewing indexed records, and monitoring backfill jobs. It runs as a separate Next.js application alongside the Rust backend. ## Logging in for the first time -The dashboard uses AT Protocol OAuth via AIP. If no users exist in the database yet, the first authenticated request to any admin endpoint automatically bootstraps that user as the super user with all permissions. +The dashboard uses AT Protocol OAuth. Click **Log in** and enter your handle to authenticate. If no users exist in the database yet, the first authenticated request to any admin endpoint automatically bootstraps that user as the super user with all permissions. ## Settings diff --git a/docs/getting-started/deployment/docker.md b/docs/getting-started/deployment/docker.md --- a/docs/getting-started/deployment/docker.md +++ b/docs/getting-started/deployment/docker.md @@ -5,11 +5,6 @@ ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) and Docker Compose -- An [AIP](https://github.com/graze-social/aip) instance for OAuth. The Docker Compose config points at the public AIP instance at `aip.gamesgamesgamesgames.games` by default. - -:::warning -This public AIP instance is provided for development convenience only. Production deployments should run their own AIP instance or risk being blocked. See the [AIP documentation](https://github.com/graze-social/aip) for setup. -::: ## 1. Clone and configure diff --git a/docs/getting-started/deployment/other.md b/docs/getting-started/deployment/other.md --- a/docs/getting-started/deployment/other.md +++ b/docs/getting-started/deployment/other.md @@ -1,11 +1,10 @@ # Local Development from Source -This guide runs HappyView directly with `cargo run`, with you managing AIP and Tap separately. If you'd rather use Docker Compose to run everything together, see [Local Development with Docker](docker.md). +This guide runs HappyView directly with `cargo run`, with you managing Tap separately. If you'd rather use Docker Compose to run everything together, see [Local Development with Docker](docker.md). ## Prerequisites - Rust (stable) -- A running [AIP](https://github.com/graze-social/aip) instance (handles OAuth). See the [AIP documentation](https://github.com/graze-social/aip) for setup. - A running [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) instance (delivers real-time records and handles backfill). See the [Tap documentation](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) for setup. - (Optional) PostgreSQL 17+ if you prefer Postgres over the default SQLite @@ -22,7 +21,8 @@ ```sh # SQLite (default — no setup needed, file created automatically) DATABASE_URL=sqlite://data/happyview.db?mode=rwc -AIP_URL=http://localhost:8080 +PUBLIC_URL=http://localhost:3000 +SESSION_SECRET=change-me-in-production TAP_URL=http://localhost:2480 TAP_ADMIN_PASSWORD=your-secret-here ``` diff --git a/docs/getting-started/deployment/railway.md b/docs/getting-started/deployment/railway.md --- a/docs/getting-started/deployment/railway.md +++ b/docs/getting-started/deployment/railway.md @@ -1,6 +1,6 @@ # Deploy on Railway -The fastest way to get HappyView running is with Railway. This template deploys HappyView, [AIP](https://github.com/graze-social/aip) (OAuth provider), [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) (real-time data and backfill), and Postgres with a single click: +The fastest way to get HappyView running is with Railway. This template deploys HappyView, [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) (real-time data and backfill), and Postgres with a single click: [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/happyview?referralCode=0QOgj_) @@ -8,13 +8,11 @@ ## Required configuration After deploying the template, you'll need to configure a few things before the stack works properly: -1. **Set your admin DID.** In the AIP service variables, set `ADMIN_DIDS` to your AT Protocol DID (e.g. `did:plc:abc123...`). You can find your DID by looking up your handle on [Internect](https://internect.info/). - -2. **Generate AIP signing keys.** The `OAUTH_SIGNING_KEYS` and `ATPROTO_OAUTH_SIGNING_KEYS` variables require multibase-encoded P-256 private keys. See the [AIP Signing Keys documentation](https://github.com/graze-social/aip/blob/main/CONFIGURATION.md#signing-keys) for generation instructions. +1. **Set your session secret.** In the HappyView service variables, set `SESSION_SECRET` to a strong random value. This is used to sign session cookies. -3. **Assign public domains.** In the Railway dashboard, add a public domain to both the HappyView and AIP services. The services need publicly accessible URLs to handle OAuth callbacks and XRPC requests. +2. **Assign a public domain.** In the Railway dashboard, add a public domain to the HappyView service. The service needs a publicly accessible URL for OAuth callbacks. Set `PUBLIC_URL` to this domain (e.g. `https://happyview-production.up.railway.app`). :::note - Your instances can use custom domains or Railway's generated URLs with no additional configuration. The domains are injected automatically to the containers. + Your instance can use a custom domain or Railway's generated URL with no additional configuration. ::: -4. Access your HappyView dashboard at the instance's public URL. +3. Access your HappyView dashboard at the instance's public URL. The first user to log in is automatically bootstrapped as the super user. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -8,7 +8,7 @@ Pick whichever option fits your situation: | Option | Best for | | ------------------------------------------ | ------------------------------------------------------------------------------------ | -| [**Railway**](deployment/railway.md) | Fastest path — one-click deploy of the full stack (HappyView + AIP + Tap + Postgres) | +| [**Railway**](deployment/railway.md) | Fastest path — one-click deploy of the full stack (HappyView + Tap + Postgres) | | [**Docker Compose**](deployment/docker.md) | Local development with the full stack in containers | | [**From source**](deployment/other.md) | Running HappyView with `cargo run` and managing dependencies yourself | diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -1,15 +1,16 @@ # Admin API -The admin API lets you manage lexicons, monitor records, run backfill jobs, and control user access. All endpoints live under `/admin` and require an [AIP](https://github.com/graze-social/aip)-issued Bearer token from a DID that exists in the `users` table, with the appropriate [permissions](../guides/permissions.md) for the endpoint being called. You can also manage all of this through the [web dashboard](../getting-started/dashboard.md). +The admin API lets you manage lexicons, monitor records, run backfill jobs, and control user access. All endpoints live under `/admin` and require authentication from a DID that exists in the `users` table, with the appropriate [permissions](../guides/permissions.md) for the endpoint being called. You can also manage all of this through the [web dashboard](../getting-started/dashboard.md). ## Auth -The admin API supports two authentication methods: +The admin API supports three authentication methods: -1. **OAuth (AIP)** — the Bearer token is validated against AIP's `/oauth/userinfo` endpoint to retrieve the caller's DID. -2. **API keys** — read/write tokens starting with `hv_`. See the [API Keys guide](../guides/api-keys.md) for details. +1. **Session cookie** (web UI) — Set during the OAuth login flow. The signed cookie contains the user's DID. +2. **API keys** — read/write tokens starting with `hv_`, passed as `Authorization: Bearer hv_...`. See the [API Keys guide](../guides/api-keys.md) for details. +3. **Service auth JWT** — AT Protocol inter-service authentication via signed JWTs. -In both cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request. +In all cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request. **Auto-bootstrap**: If the `users` table is empty, the first authenticated request automatically creates the caller as the **super user** with all permissions granted. @@ -26,12 +27,12 @@ | Status | Meaning | | ------------------ | -------------------------------------------------------------------------------------------------------------- | | `400 Bad Request` | Invalid input (missing required fields, malformed lexicon JSON) | -| `401 Unauthorized` | Missing or invalid Bearer token. See [AIP documentation](https://github.com/graze-social/aip) for token issues | +| `401 Unauthorized` | Missing or invalid session cookie, API key, or service auth JWT | | `403 Forbidden` | Authenticated DID is not in the users table, or user lacks the required permission | | `404 Not Found` | Lexicon, user, or backfill job not found | ```sh -# All examples assume $TOKEN is an AIP-issued access token or API key +# All examples assume $TOKEN is an API key (hv_...) AUTH="Authorization: Bearer $TOKEN" ``` diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -33,20 +33,22 @@ ## Module overview ``` src/ - main.rs Startup: config, DB, migrations, spawn Tap worker, start server - lib.rs AppState struct, module declarations + main.rs Startup: config, DB, migrations, build OAuth client, spawn Tap worker, start server + lib.rs AppState struct (incl. OAuth client + cookie key), module declarations config.rs Environment variable loading + dns.rs DNS TXT resolver for atrium handle resolution error.rs AppError enum (Auth, BadRequest, Forbidden, Internal, NotFound, PdsError) - server.rs Axum router: fixed routes + admin nest + XRPC catch-all + static files + server.rs Axum router: fixed routes + admin nest + auth routes + XRPC catch-all + static files lexicon.rs ParsedLexicon, LexiconRegistry (Arc>) profile.rs DID document resolution, PDS discovery, profile fetching tap.rs Tap WebSocket listener, collection filter sync, backfill delegation - aip.rs AIP reverse proxy resolve.rs NSID authority resolution (DNS TXT → DID → PDS) auth/ - mod.rs Re-exports - middleware.rs Claims extractor (validates Bearer token via AIP /oauth/userinfo) - jwks.rs JWKS key fetching + mod.rs Re-exports, COOKIE_NAME constant + middleware.rs Claims extractor (cookie auth, API key, or service auth JWT) + routes.rs OAuth endpoints (/auth/login, /auth/callback, /auth/logout, /auth/me) + oauth_store.rs Database-backed session and state stores for atrium-oauth + service_auth.rs XRPC service-to-service JWT validation (ES256/ES256K) admin/ mod.rs Admin route definitions auth.rs UserAuth extractor (Claims + DID lookup + permission check + auto-bootstrap) @@ -71,9 +73,8 @@ sandbox.rs Restricted Lua environment (removed modules, instruction limit) tid.rs TID generation for Lua scripts repo/ mod.rs Re-exports - dpop.rs DPoP JWT proof generation (ES256/P-256) - pds.rs PDS proxy helpers (JSON POST, blob POST, response forwarding) - session.rs ATP session fetching from AIP + pds.rs PDS proxy helpers (JSON POST, blob POST, response forwarding via OAuth session) + session.rs OAuth session restoration from atrium store upload_blob.rs Blob upload handler xrpc/ mod.rs Re-exports @@ -97,14 +98,14 @@ ### Writes (procedures) ``` -Client POST /xrpc/{method} + Bearer token - -> Claims extractor validates token via AIP /oauth/userinfo +Client POST /xrpc/{method} + session cookie or Bearer token + -> Claims extractor (cookie, API key, or service auth JWT) -> xrpc::xrpc_post() -> LexiconRegistry lookup (must be Procedure type) -> If Lua script attached: execute script (has access to Record API) -> Else: default create/update (auto-detect based on uri field) - -> Fetch ATP session from AIP /api/atprotocol/session - -> Generate DPoP proof (ES256) + -> Restore OAuth session from atrium store (by DID) + -> atrium handles DPoP proof generation and token refresh -> Proxy to user's PDS (createRecord or putRecord) -> Upsert record locally -> Forward PDS response @@ -113,9 +114,9 @@ ### Admin endpoints ``` -Client request + Bearer token +Client request + session cookie or Bearer token -> AdminAuth extractor: - 1. Claims validation via AIP + 1. Claims validation (cookie, API key, or service auth JWT) 2. DID lookup in users table (auto-bootstrap super user if empty) 3. Permission check (403 if missing required permission) -> Admin handler @@ -207,6 +208,23 @@ | `created_at` | timestamptz | | | `last_used_at`| timestamptz| | | `revoked_at` | timestamptz | Set when revoked (soft delete) | +### `oauth_sessions` + +| Column | Type | Description | +| -------------- | ----------- | -------------------------------------------- | +| `did` | text (PK) | User's AT Protocol DID | +| `session_data` | text | Serialized OAuth session (managed by atrium) | +| `created_at` | timestamptz | | +| `updated_at` | timestamptz | | + +### `oauth_state` + +| Column | Type | Description | +| ------------ | ----------- | -------------------------------------------- | +| `state_key` | text (PK) | OAuth state parameter | +| `state_data` | text | Serialized state (managed by atrium) | +| `created_at` | timestamptz | | + ### `event_logs` | Column | Type | Description | @@ -259,4 +277,4 @@ TEST_DATABASE_URL=postgres://happyview:happyview@localhost:5433/happyview_test cargo test docker compose -f docker-compose.test.yml down ``` -End-to-end tests use `wiremock` to mock external services (AIP, PLC directory, PDSes) and a real database for full integration coverage. By default tests use SQLite; set `TEST_DATABASE_URL` to a Postgres connection string to test against Postgres. +End-to-end tests use `wiremock` to mock external services (PLC directory, PDSes) and a real database for full integration coverage. By default tests use SQLite; set `TEST_DATABASE_URL` to a Postgres connection string to test against Postgres. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -32,8 +32,6 @@ **XRPC** — The HTTP-based RPC protocol used by the AT Protocol. Query methods map to GET requests, procedure methods map to POST requests. See [XRPC API](xrpc-api.md). ## HappyView-specific terms -**AIP** — [Authentication and Identity Provider](https://github.com/graze-social/aip). An external service that handles AT Protocol OAuth for HappyView. Issues Bearer tokens used for authentication. - **Backfill** — The process of bulk-indexing existing records from the network. HappyView discovers repos via the relay and delegates record fetching to Tap. Runs when a new record-type lexicon is uploaded or triggered manually. See [Backfill](../guides/backfill.md). **Network lexicon** — A lexicon fetched directly from the AT Protocol network via DNS authority resolution, rather than uploaded manually. See [Lexicons - Network lexicons](../guides/lexicons.md#network-lexicons). diff --git a/docs/reference/production-deployment.md b/docs/reference/production-deployment.md --- a/docs/reference/production-deployment.md +++ b/docs/reference/production-deployment.md @@ -1,6 +1,6 @@ # Deployment -HappyView requires a database and an [AIP](https://github.com/graze-social/aip) instance for OAuth. SQLite is the default; Postgres is also supported, but requires additional setup. The [Quickstart](../getting-started/deployment/railway.md) covers the fastest path with Railway. This page covers other deployment options. +HappyView requires a database. SQLite is the default; Postgres is also supported, but requires additional setup. The [Quickstart](../getting-started/deployment/railway.md) covers the fastest path with Railway. This page covers other deployment options. ## Docker @@ -28,7 +28,8 @@ ports: - "3000:3000" environment: DATABASE_URL: "sqlite://data/happyview.db?mode=rwc" - AIP_URL: "https://aip.example.com" + PUBLIC_URL: "https://happyview.example.com" + SESSION_SECRET: "${SESSION_SECRET}" volumes: - happyview-data:/app/data @@ -55,7 +56,8 @@ ports: - "3000:3000" environment: DATABASE_URL: "postgres://happyview:${POSTGRES_PASSWORD}@postgres/happyview" - AIP_URL: "https://aip.example.com" + PUBLIC_URL: "https://happyview.example.com" + SESSION_SECRET: "${SESSION_SECRET}" depends_on: postgres: condition: service_healthy @@ -69,11 +71,10 @@ The general process for any hosting platform: 1. Choose a database: SQLite (default, zero setup) or Postgres 17+ (provision separately) -2. Deploy an [AIP](https://github.com/graze-social/aip) instance (handles OAuth for your AppView) -3. Set `DATABASE_URL` and `AIP_URL` environment variables (see [Configuration](../getting-started/configuration.md) for all options) -4. Deploy the Docker image or build from source -5. HappyView listens on `PORT` (default `3000`) -6. Health check: `GET /health` returns `ok` +2. Set `DATABASE_URL`, `PUBLIC_URL`, and `SESSION_SECRET` environment variables (see [Configuration](../getting-started/configuration.md) for all options) +3. Deploy the Docker image or build from source +4. HappyView listens on `PORT` (default `3000`) +5. Health check: `GET /health` returns `ok` See the [database setup guide](../guides/database-setup.md) for details on both backends. @@ -85,7 +86,7 @@ HappyView supports SQLite (default) and Postgres. The backend is auto-detected from the `DATABASE_URL` scheme (`sqlite://` or `postgres://`). Migrations run automatically on startup. No manual migration step is needed. See the [database setup guide](../guides/database-setup.md) for details. ## TLS -HappyView does not terminate TLS. Put it behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.) for HTTPS. +HappyView does not terminate TLS. Put it behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.) for HTTPS. Make sure `PUBLIC_URL` matches the public-facing URL (including `https://`). ## Logging diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -28,11 +28,9 @@ **Symptom**: `POST /xrpc/your.method.name` returns `{"error": "..."}` with status 401. **Causes**: -- The `Authorization: Bearer ` header is missing or malformed. -- The token has expired or is invalid. Tokens are validated against AIP's `/oauth/userinfo` endpoint. -- AIP is unreachable. Check that `AIP_URL` is set correctly and the AIP service is running. - -For AIP-specific issues, see the [AIP documentation](https://github.com/graze-social/aip). +- No session cookie or `Authorization: Bearer` header is present. +- The session cookie has expired or was signed with a different `SESSION_SECRET`. +- The API key has been revoked or is invalid. ## Admin endpoints return 403 Forbidden @@ -41,7 +39,7 @@ **Causes**: - Your DID is not in the users table. Ask an existing user with `users:create` permission to add you via `POST /admin/users`. -- If this is a fresh deployment with no users, the first authenticated request to any admin endpoint automatically bootstraps you as the super user. Make sure you're sending a valid Bearer token. +- If this is a fresh deployment with no users, the first authenticated request to any admin endpoint automatically bootstraps you as the super user. Make sure you're logged in via the dashboard or using a valid API key. - You may be in the users table but lack the required permission for the endpoint you're calling. Check your permissions with `GET /admin/users` or ask a user with `users:update` permission to grant the permission you need. ## Permission denied errors @@ -90,13 +88,12 @@ - The Tap connection hasn't synced the new collection filter after a lexicon change. This should happen automatically. Check server logs for connection errors. ## OAuth or login issues -OAuth is handled entirely by [AIP](https://github.com/graze-social/aip). If users can't log in or tokens aren't working: +HappyView handles AT Protocol OAuth internally via the `atrium-oauth` library. If users can't log in: -1. Verify AIP is running and reachable at the configured `AIP_URL`. -2. Check that AIP has valid signing keys configured (`OAUTH_SIGNING_KEYS`). -3. Check that both HappyView and AIP have public URLs assigned (required for OAuth callbacks). - -See the [AIP documentation](https://github.com/graze-social/aip) for setup and debugging. +1. Verify `PUBLIC_URL` is set correctly and the URL is publicly accessible (required for OAuth callbacks). +2. Check that the user's PDS authorization server is reachable. +3. Verify `SESSION_SECRET` hasn't changed since sessions were created (changing it invalidates all existing session cookies). +4. Check server logs for OAuth-specific error messages. ## Database connection errors diff --git a/docs/reference/xrpc-api.md b/docs/reference/xrpc-api.md --- a/docs/reference/xrpc-api.md +++ b/docs/reference/xrpc-api.md @@ -7,7 +7,7 @@ ## Auth - **Queries** (`GET /xrpc/{method}`): unauthenticated -- **Procedures** (`POST /xrpc/{method}`): require an AIP-issued `Authorization: Bearer ` header +- **Procedures** (`POST /xrpc/{method}`): require authentication (session cookie, API key, or service auth JWT) - **getProfile**: requires auth - **uploadBlob**: requires auth @@ -184,7 +184,7 @@ | Status | Meaning | Common causes | |--------|---------|---------------| | `400 Bad Request` | Invalid input | Missing required fields, malformed JSON, invalid AT URI | -| `401 Unauthorized` | Authentication failed | Missing or invalid Bearer token. See [AIP documentation](https://github.com/graze-social/aip) for token issues | +| `401 Unauthorized` | Authentication failed | Missing or invalid session cookie, API key, or service auth JWT | | `404 Not Found` | Method or record not found | XRPC method has no matching lexicon, or the requested record doesn't exist | | `500 Internal Server Error` | Server-side failure | Lua script error, database error, or upstream PDS failure | diff --git a/docs/tutorials/statusphere.md b/docs/tutorials/statusphere.md --- a/docs/tutorials/statusphere.md +++ b/docs/tutorials/statusphere.md @@ -23,7 +23,7 @@ ## Step 1: Upload the record lexicon First, upload the `xyz.statusphere.status` lexicon to HappyView. This tells HappyView to start indexing Statusphere records from across the network as they're created, updated, or deleted. -The examples below use `$TOKEN` as a placeholder for an AIP-issued access token. See [Authentication](../getting-started/authentication.md) for how to get one. +The examples below use `$TOKEN` as a placeholder for an API key. See [Authentication](../getting-started/authentication.md) and the [API Keys guide](../guides/api-keys.md) for how to get one. ```sh curl -X POST http://localhost:3000/admin/lexicons \ diff --git a/migrations/postgres/20260319000000_create_oauth_tables.sql b/migrations/postgres/20260319000000_create_oauth_tables.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260319000000_create_oauth_tables.sql @@ -0,0 +1,12 @@ +CREATE TABLE oauth_sessions ( + did TEXT PRIMARY KEY, + session_data TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE oauth_state ( + state_key TEXT PRIMARY KEY, + state_data TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/migrations/sqlite/20260319000000_create_oauth_tables.sql b/migrations/sqlite/20260319000000_create_oauth_tables.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260319000000_create_oauth_tables.sql @@ -0,0 +1,12 @@ +CREATE TABLE oauth_sessions ( + did TEXT PRIMARY KEY, + session_data TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE oauth_state ( + state_key TEXT PRIMARY KEY, + state_data TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/aip.rs b/src/aip.rs deleted file mode 100644 --- a/src/aip.rs +++ /dev/null @@ -1,259 +0,0 @@ -use axum::body::Body; -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, Method, StatusCode, Uri}; -use axum::response::{IntoResponse, Response}; - -use crate::AppState; - -/// Reverse-proxy requests from `/aip/*` to the configured AIP server. -pub async fn aip_proxy( - State(state): State, - method: Method, - Path(path): Path, - uri: Uri, - headers: HeaderMap, - body: Body, -) -> impl IntoResponse { - let query = uri.query().map(|q| format!("?{q}")).unwrap_or_default(); - let upstream_url = format!("{}/{path}{query}", state.config.aip_url); - - let mut req = state.http.request(method.clone(), &upstream_url); - - // Copy relevant request headers - for name in ["content-type", "authorization", "dpop", "accept"] { - if let Some(val) = headers.get(name) { - req = req.header(name, val); - } - } - - // Attach body for non-GET requests - if method != Method::GET { - let bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await { - Ok(b) => b, - Err(_) => { - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::from("request body too large")) - .unwrap(); - } - }; - req = req.body(bytes); - } - - let upstream_resp = match req.send().await { - Ok(r) => r, - Err(e) => { - tracing::error!("AIP proxy error: {e:#}"); - return Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("upstream request failed")) - .unwrap(); - } - }; - - let status = upstream_resp.status(); - let mut resp_headers = HeaderMap::new(); - - // Copy relevant response headers - for name in [ - "content-type", - "dpop-nonce", - "www-authenticate", - "cache-control", - ] { - if let Some(val) = upstream_resp.headers().get(name) { - resp_headers.insert( - name.parse::().unwrap(), - val.clone(), - ); - } - } - - let bytes = match upstream_resp.bytes().await { - Ok(b) => b, - Err(e) => { - tracing::error!("AIP proxy read error: {e}"); - return Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("failed to read upstream response")) - .unwrap(); - } - }; - - let mut response = Response::new(Body::from(bytes)); - *response.status_mut() = status; - *response.headers_mut() = resp_headers; - response -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::Router; - use axum::body::to_bytes; - use axum::extract::Request; - use axum::routing::{get, post}; - use tokio::sync::watch; - use tower::ServiceExt; - - fn test_state(aip_url: &str) -> AppState { - sqlx::any::install_default_drivers(); - let config = crate::config::Config { - host: "127.0.0.1".into(), - port: 3000, - database_url: String::new(), - database_backend: crate::db::DatabaseBackend::Sqlite, - aip_url: aip_url.into(), - aip_public_url: String::new(), - tap_url: String::new(), - tap_admin_password: None, - relay_url: String::new(), - plc_url: String::new(), - static_dir: String::new(), - event_log_retention_days: 30, - }; - let (tx, _) = watch::channel(vec![]); - let (labeler_tx, _) = watch::channel(()); - AppState { - config, - http: reqwest::Client::new(), - db: sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(), - db_backend: crate::db::DatabaseBackend::Sqlite, - lexicons: crate::lexicon::LexiconRegistry::new(), - collections_tx: tx, - labeler_subscriptions_tx: labeler_tx, - rate_limiter: crate::rate_limit::RateLimiter::new( - false, - crate::rate_limit::RateLimitConfig { - capacity: 100, - refill_rate: 2.0, - default_query_cost: 1, - default_procedure_cost: 1, - default_proxy_cost: 1, - }, - vec![], - ), - } - } - - #[tokio::test] - async fn proxy_forwards_get_request() { - let mock = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/oauth/authorize")) - .respond_with( - wiremock::ResponseTemplate::new(200) - .set_body_string("ok") - .insert_header("content-type", "text/plain") - .insert_header("dpop-nonce", "test-nonce"), - ) - .mount(&mock) - .await; - - let state = test_state(&mock.uri()); - let app = Router::new() - .route("/aip/{*path}", get(aip_proxy)) - .with_state(state); - - let req = Request::builder() - .method("GET") - .uri("/aip/oauth/authorize") - .body(Body::empty()) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!(resp.headers().get("dpop-nonce").unwrap(), "test-nonce"); - let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); - assert_eq!(&body[..], b"ok"); - } - - #[tokio::test] - async fn proxy_forwards_post_with_body() { - let mock = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/oauth/token")) - .respond_with( - wiremock::ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"access_token": "tok"})) - .insert_header("content-type", "application/json"), - ) - .mount(&mock) - .await; - - let state = test_state(&mock.uri()); - let app = Router::new() - .route("/aip/{*path}", post(aip_proxy)) - .with_state(state); - - let req = Request::builder() - .method("POST") - .uri("/aip/oauth/token") - .header("content-type", "application/x-www-form-urlencoded") - .body(Body::from("grant_type=authorization_code")) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - assert_eq!( - resp.headers().get("content-type").unwrap(), - "application/json" - ); - } - - #[tokio::test] - async fn proxy_forwards_query_string() { - let mock = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/oauth/authorize")) - .and(wiremock::matchers::query_param("client_id", "abc")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("found")) - .mount(&mock) - .await; - - let state = test_state(&mock.uri()); - let app = Router::new() - .route("/aip/{*path}", get(aip_proxy)) - .with_state(state); - - let req = Request::builder() - .method("GET") - .uri("/aip/oauth/authorize?client_id=abc") - .body(Body::empty()) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); - assert_eq!(&body[..], b"found"); - } - - #[tokio::test] - async fn proxy_preserves_error_status() { - let mock = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/oauth/token")) - .respond_with( - wiremock::ResponseTemplate::new(400) - .set_body_string("bad request") - .insert_header("www-authenticate", "DPoP error=\"use_dpop_nonce\""), - ) - .mount(&mock) - .await; - - let state = test_state(&mock.uri()); - let app = Router::new() - .route("/aip/{*path}", post(aip_proxy)) - .with_state(state); - - let req = Request::builder() - .method("POST") - .uri("/aip/oauth/token") - .body(Body::from("grant_type=authorization_code")) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - assert!(resp.headers().get("www-authenticate").is_some()); - } -} diff --git a/src/auth/jwks.rs b/src/auth/jwks.rs deleted file mode 100644 --- a/src/auth/jwks.rs +++ /dev/null @@ -1,58 +0,0 @@ -use jsonwebtoken::jwk::JwkSet; -use std::sync::Arc; -use tokio::sync::RwLock; -use tracing::{info, warn}; - -/// Periodically fetches and caches AIP's JWKS for token verification. -#[derive(Clone)] -pub struct JwksProvider { - jwks: Arc>>, - jwks_url: String, - http: reqwest::Client, -} - -impl JwksProvider { - pub fn new(jwks_url: String) -> Self { - Self { - jwks: Arc::new(RwLock::new(None)), - jwks_url, - http: reqwest::Client::new(), - } - } - - /// Fetch the JWKS from AIP once, returning an error if it fails. - pub async fn refresh(&self) -> Result<(), String> { - let resp = self - .http - .get(&self.jwks_url) - .send() - .await - .map_err(|e| format!("failed to fetch JWKS: {e}"))?; - - let jwks: JwkSet = resp - .json() - .await - .map_err(|e| format!("failed to parse JWKS: {e}"))?; - - info!(keys = jwks.keys.len(), "refreshed JWKS from AIP"); - *self.jwks.write().await = Some(jwks); - Ok(()) - } - - /// Get a snapshot of the current keyset. - pub async fn keyset(&self) -> Option { - self.jwks.read().await.clone() - } - - /// Start a background task that refreshes JWKS every `interval`. - pub fn spawn_refresh_loop(self, interval: std::time::Duration) { - tokio::spawn(async move { - loop { - if let Err(e) = self.refresh().await { - warn!("JWKS refresh failed: {e}"); - } - tokio::time::sleep(interval).await; - } - }); - } -} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -1,16 +1,20 @@ use axum::extract::FromRequestParts; use axum::http::request::Parts; -use serde::Deserialize; +use axum_extra::extract::cookie::{Key, SignedCookieJar}; use crate::AppState; +use crate::auth::COOKIE_NAME; use crate::error::AppError; -/// Authenticated user identity extracted from an AIP-issued access token. +/// Authenticated user identity. +/// +/// Tries two auth paths in order: +/// 1. Signed cookie (web UI sessions via OAuth) +/// 2. Bearer token starting with `hv_` (API key — handled downstream by UserAuth) +/// 3. Bearer service auth JWT (AT Protocol inter-service calls) #[derive(Debug, Clone)] pub struct Claims { did: String, - token: String, - dpop_proof: Option, } impl Claims { @@ -19,34 +23,13 @@ pub fn did(&self) -> &str { &self.did } - /// The raw access token for forwarding to AIP's XRPC proxy. - pub fn token(&self) -> &str { - &self.token - } - - /// The DPoP proof from the client request, if present. - pub fn dpop_proof(&self) -> Option<&str> { - self.dpop_proof.as_deref() - } - /// Test-only constructor. #[cfg(test)] - pub fn new_for_test(did: String, token: String) -> Self { - Self { - did, - token, - dpop_proof: None, - } + pub fn new_for_test(did: String) -> Self { + Self { did } } } -#[derive(Deserialize)] -struct UserinfoResponse { - sub: String, -} - -/// Axum extractor that validates the Bearer token by forwarding it to AIP's -/// `/oauth/userinfo` endpoint. AIP returns the DID in the `sub` field. impl FromRequestParts for Claims { type Rejection = AppError; @@ -54,90 +37,62 @@ async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { + // Path 1: Cookie auth (web UI) + let jar: SignedCookieJar = SignedCookieJar::from_request_parts(parts, state) + .await + .map_err(|_| AppError::Auth("failed to read cookies".into()))?; + + if let Some(cookie) = jar.get(COOKIE_NAME) { + let did = cookie.value().to_string(); + return Ok(Claims { did }); + } + + // Path 2: Authorization header let header = parts .headers .get("authorization") .and_then(|v| v.to_str().ok()) - .ok_or_else(|| AppError::Auth("missing Authorization header".into()))?; - - let (scheme, token) = if let Some(t) = header.strip_prefix("DPoP ") { - ("DPoP", t) - } else if let Some(t) = header.strip_prefix("Bearer ") { - ("Bearer", t) - } else { - return Err(AppError::Auth("invalid Authorization scheme".into())); - }; + .ok_or_else(|| { + AppError::Auth("missing Authorization header or session cookie".into()) + })?; - let dpop_proof = parts - .headers - .get("dpop") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); + if let Some(token) = header.strip_prefix("Bearer ") { + // API key tokens start with hv_ — let them through with a placeholder DID. + // The admin middleware (UserAuth) will resolve the actual DID from the API key. + if token.starts_with("hv_") { + // API key auth is handled by UserAuth extractor which looks up the key. + // We need to extract the DID from the api_keys table. + let did = resolve_api_key_did(state, token).await?; + return Ok(Claims { did }); + } - let userinfo_url = format!( - "{}/oauth/userinfo", - state.config.aip_url.trim_end_matches('/') - ); - - tracing::debug!( - url = %userinfo_url, - scheme = %scheme, - has_dpop_proof = dpop_proof.is_some(), - "forwarding token to AIP userinfo" - ); - - let mut req = state - .http - .get(&userinfo_url) - .header("authorization", format!("{scheme} {token}")); - - if let Some(ref proof) = dpop_proof { - req = req.header("dpop", proof); + // Otherwise, try service auth JWT + let service_auth = super::service_auth::ServiceAuth::from_bearer(token, state).await?; + return Ok(Claims { + did: service_auth.did, + }); } - let resp = req.send().await.map_err(|e| { - tracing::error!(url = %userinfo_url, error = %e, "AIP userinfo request failed to send"); - AppError::Auth(format!("userinfo request failed: {e}")) - })?; + Err(AppError::Auth("invalid Authorization scheme".into())) + } +} - if !resp.status().is_success() { - let status = resp.status(); - let nonce = resp - .headers() - .get("dpop-nonce") - .and_then(|v| v.to_str().ok()) - .map(String::from); - let body = resp.text().await.unwrap_or_default(); +/// Look up the DID associated with an API key. +async fn resolve_api_key_did(state: &AppState, token: &str) -> Result { + use crate::db::adapt_sql; + use sha2::{Digest, Sha256}; - tracing::warn!( - url = %userinfo_url, - status = %status, - body = %body, - dpop_nonce = ?nonce, - has_dpop_proof = dpop_proof.is_some(), - "AIP userinfo request failed" - ); + let hash = hex::encode(Sha256::digest(token.as_bytes())); + let sql = adapt_sql( + "SELECT u.did FROM api_keys k JOIN users u ON k.user_id = u.id WHERE k.key_hash = ? AND k.revoked_at IS NULL", + state.db_backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(&hash) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("API key lookup failed: {e}")))?; - // Relay the nonce so the client can retry with it. - if let Some(ref nonce_str) = nonce { - return Err(AppError::AuthDpopNonce(nonce_str.clone())); - } - - return Err(AppError::Auth(format!( - "userinfo returned {}: {}", - status, body - ))); - } - - let info: UserinfoResponse = resp - .json() - .await - .map_err(|e| AppError::Auth(format!("invalid userinfo response: {e}")))?; - - Ok(Claims { - did: info.sub, - token: token.to_string(), - dpop_proof, - }) - } + row.map(|(did,)| did) + .ok_or_else(|| AppError::Auth("invalid API key".into())) } diff --git a/src/auth/mod.rs b/src/auth/mod.rs --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,3 +1,9 @@ pub mod middleware; +pub mod oauth_store; +pub mod routes; +pub mod service_auth; pub use middleware::Claims; +pub use service_auth::ServiceAuth; + +pub const COOKIE_NAME: &str = "happyview_session"; diff --git a/src/auth/oauth_store.rs b/src/auth/oauth_store.rs new file mode 100644 --- /dev/null +++ b/src/auth/oauth_store.rs @@ -0,0 +1,178 @@ +use atrium_api::types::string::Did; +use atrium_common::store::Store; +use atrium_oauth::store::session::{Session, SessionStore}; +use atrium_oauth::store::state::{InternalStateData, StateStore}; +use sqlx::AnyPool; +use std::fmt; + +use crate::db::{DatabaseBackend, adapt_sql}; + +#[derive(Debug)] +pub enum StoreError { + Sqlx(sqlx::Error), + Json(serde_json::Error), +} + +impl fmt::Display for StoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StoreError::Sqlx(e) => write!(f, "database error: {e}"), + StoreError::Json(e) => write!(f, "json error: {e}"), + } + } +} + +impl std::error::Error for StoreError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + StoreError::Sqlx(e) => Some(e), + StoreError::Json(e) => Some(e), + } + } +} + +impl From for StoreError { + fn from(e: sqlx::Error) -> Self { + StoreError::Sqlx(e) + } +} + +impl From for StoreError { + fn from(e: serde_json::Error) -> Self { + StoreError::Json(e) + } +} + +// --- DbSessionStore --- + +#[derive(Clone)] +pub struct DbSessionStore { + pool: AnyPool, + backend: DatabaseBackend, +} + +impl DbSessionStore { + pub fn new(pool: AnyPool, backend: DatabaseBackend) -> Self { + Self { pool, backend } + } +} + +impl Store for DbSessionStore { + type Error = StoreError; + + async fn get(&self, key: &Did) -> Result, Self::Error> { + let row: Option<(String,)> = sqlx::query_as(&adapt_sql( + "SELECT session_data FROM oauth_sessions WHERE did = ?", + self.backend, + )) + .bind(key.as_ref()) + .fetch_optional(&self.pool) + .await?; + + match row { + Some((data,)) => Ok(Some(serde_json::from_str(&data)?)), + None => Ok(None), + } + } + + async fn set(&self, key: Did, value: Session) -> Result<(), Self::Error> { + let json = serde_json::to_string(&value)?; + sqlx::query(&adapt_sql( + "INSERT INTO oauth_sessions (did, session_data, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT (did) DO UPDATE SET session_data = EXCLUDED.session_data, updated_at = datetime('now')", + self.backend, + )) + .bind(key.as_ref()) + .bind(&json) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn del(&self, key: &Did) -> Result<(), Self::Error> { + sqlx::query(&adapt_sql( + "DELETE FROM oauth_sessions WHERE did = ?", + self.backend, + )) + .bind(key.as_ref()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn clear(&self) -> Result<(), Self::Error> { + sqlx::query("DELETE FROM oauth_sessions") + .execute(&self.pool) + .await?; + Ok(()) + } +} + +impl SessionStore for DbSessionStore {} + +// --- DbStateStore --- + +#[derive(Clone)] +pub struct DbStateStore { + pool: AnyPool, + backend: DatabaseBackend, +} + +impl DbStateStore { + pub fn new(pool: AnyPool, backend: DatabaseBackend) -> Self { + Self { pool, backend } + } +} + +impl Store for DbStateStore { + type Error = StoreError; + + async fn get(&self, key: &String) -> Result, Self::Error> { + let row: Option<(String,)> = sqlx::query_as(&adapt_sql( + "SELECT state_data FROM oauth_state WHERE state_key = ?", + self.backend, + )) + .bind(key) + .fetch_optional(&self.pool) + .await?; + + match row { + Some((data,)) => Ok(Some(serde_json::from_str(&data)?)), + None => Ok(None), + } + } + + async fn set(&self, key: String, value: InternalStateData) -> Result<(), Self::Error> { + let json = serde_json::to_string(&value)?; + sqlx::query(&adapt_sql( + "INSERT INTO oauth_state (state_key, state_data) VALUES (?, ?) + ON CONFLICT (state_key) DO UPDATE SET state_data = EXCLUDED.state_data", + self.backend, + )) + .bind(&key) + .bind(&json) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn del(&self, key: &String) -> Result<(), Self::Error> { + sqlx::query(&adapt_sql( + "DELETE FROM oauth_state WHERE state_key = ?", + self.backend, + )) + .bind(key) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn clear(&self) -> Result<(), Self::Error> { + sqlx::query("DELETE FROM oauth_state") + .execute(&self.pool) + .await?; + Ok(()) + } +} + +impl StateStore for DbStateStore {} diff --git a/src/auth/routes.rs b/src/auth/routes.rs new file mode 100644 --- /dev/null +++ b/src/auth/routes.rs @@ -0,0 +1,127 @@ +use axum::{ + Json, Router, + extract::{Query, State}, + response::Redirect, + routing::{get, post}, +}; +use axum_extra::extract::cookie::{Cookie, Key, SignedCookieJar}; +use serde::Deserialize; + +use crate::AppState; +use crate::auth::COOKIE_NAME; +use crate::db::adapt_sql; +use crate::error::AppError; + +#[derive(Deserialize)] +pub struct LoginQuery { + handle: String, +} + +#[derive(Deserialize)] +pub struct CallbackQuery { + code: String, + state: Option, + iss: Option, +} + +pub fn routes() -> Router { + Router::new() + .route("/login", get(login)) + .route("/callback", get(callback)) + .route("/logout", post(logout)) + .route("/me", get(me)) +} + +async fn login( + State(state): State, + Query(query): Query, +) -> Result, AppError> { + let url = state + .oauth + .authorize(&query.handle, Default::default()) + .await + .map_err(|e| AppError::Internal(format!("OAuth authorize failed: {e}")))?; + + Ok(Json(serde_json::json!({ "url": url }))) +} + +async fn callback( + State(state): State, + jar: SignedCookieJar, + Query(query): Query, +) -> Result<(SignedCookieJar, Redirect), AppError> { + let params = atrium_oauth::CallbackParams { + code: query.code, + state: query.state, + iss: query.iss, + }; + + let (session, _app_state) = state + .oauth + .callback(params) + .await + .map_err(|e| AppError::Internal(format!("OAuth callback failed: {e}")))?; + + use atrium_api::agent::SessionManager; + let did = session + .did() + .await + .ok_or_else(|| AppError::Internal("no DID in OAuth session".into()))?; + + let mut cookie = Cookie::new(COOKIE_NAME, did.to_string()); + cookie.set_path("/"); + cookie.set_http_only(true); + cookie.set_same_site(axum_extra::extract::cookie::SameSite::Lax); + if state.config.public_url.starts_with("https") { + cookie.set_secure(true); + } + + let jar = jar.add(cookie); + Ok((jar, Redirect::to("/"))) +} + +async fn logout( + State(state): State, + jar: SignedCookieJar, +) -> Result, AppError> { + if let Some(cookie) = jar.get(COOKIE_NAME) { + let did_str = cookie.value().to_string(); + if let Ok(did) = atrium_api::types::string::Did::new(did_str) { + let _ = state.oauth.revoke(&did).await; + } + } + + let mut removal = Cookie::from(COOKIE_NAME); + removal.set_path("/"); + let jar = jar.remove(removal); + Ok(jar) +} + +#[derive(serde::Serialize)] +struct MeResponse { + did: String, + is_admin: bool, +} + +async fn me( + State(state): State, + jar: SignedCookieJar, +) -> Result, AppError> { + let cookie = jar + .get(COOKIE_NAME) + .ok_or(AppError::Auth("not authenticated".into()))?; + let did = cookie.value().to_string(); + + let backend = state.db_backend; + let user: Option<(i32,)> = + sqlx::query_as(&adapt_sql("SELECT 1 FROM users WHERE did = ?", backend)) + .bind(&did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; + + Ok(Json(MeResponse { + did, + is_admin: user.is_some(), + })) +} diff --git a/src/auth/service_auth.rs b/src/auth/service_auth.rs new file mode 100644 --- /dev/null +++ b/src/auth/service_auth.rs @@ -0,0 +1,283 @@ +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256Key, signature::Verifier}; +use serde::Deserialize; + +use crate::AppState; +use crate::error::AppError; + +/// Authenticated ATProto user identity extracted from a service auth JWT. +/// +/// Used for XRPC endpoints that receive proxied requests from PDSes. +/// The JWT is signed by the caller's signing key and validated by +/// resolving their DID document. +#[derive(Debug, Clone)] +pub struct ServiceAuth { + /// The authenticated user's DID (from `iss`). + pub did: String, +} + +// JWT types +#[derive(Deserialize)] +struct JwtHeader { + alg: String, + #[serde(default)] + typ: Option, +} + +#[derive(Deserialize)] +struct JwtPayload { + iss: String, + aud: String, + exp: u64, + #[serde(default)] + lxm: Option, +} + +// DID document types +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DidDocument { + #[serde(default)] + verification_method: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct VerificationMethod { + id: String, + #[serde(rename = "type")] + method_type: String, + #[serde(default)] + public_key_multibase: Option, +} + +impl ServiceAuth { + /// Validate a Bearer token as a service auth JWT. + pub async fn from_bearer(token: &str, state: &AppState) -> Result { + let payload = verify_service_jwt(token, state, false).await?; + Ok(ServiceAuth { did: payload.iss }) + } +} + +// Axum extractor +impl FromRequestParts for ServiceAuth { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(AppError::Auth("missing Authorization header".into()))?; + + let token = header + .strip_prefix("Bearer ") + .ok_or(AppError::Auth("invalid Authorization scheme".into()))?; + + Self::from_bearer(token, state).await + } +} + +// JWT verification (boxed future to allow recursion for retry) +fn verify_service_jwt<'a>( + token: &'a str, + state: &'a AppState, + is_retry: bool, +) -> std::pin::Pin> + Send + 'a>> +{ + Box::pin(async move { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid JWT structure".into())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| AppError::Auth("invalid JWT header".into()))?; + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid JWT payload".into()))?; + let sig_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| AppError::Auth("invalid JWT signature".into()))?; + + let header: JwtHeader = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid JWT header".into()))?; + let payload: JwtPayload = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid JWT payload".into()))?; + + // Reject forbidden typ values. + if let Some(ref typ) = header.typ { + let t = typ.to_lowercase(); + if t == "at+jwt" || t == "refresh+jwt" || t == "dpop+jwt" { + return Err(AppError::Auth("forbidden JWT typ".into())); + } + } + + // Only support ES256 and ES256K. + if header.alg != "ES256" && header.alg != "ES256K" { + tracing::warn!(alg = %header.alg, "unsupported JWT algorithm"); + return Err(AppError::Auth("unsupported JWT algorithm".into())); + } + + // Check expiration. + let now = chrono::Utc::now().timestamp() as u64; + if now > payload.exp { + tracing::warn!(exp = payload.exp, now = now, "service auth JWT expired"); + return Err(AppError::Auth("JWT expired".into())); + } + + // Check audience if SERVICE_DID is configured (optional for HappyView). + // For now, accept any audience. + let _ = &payload.aud; + + // Check lxm if present (optional validation). + if let Some(ref _lxm) = payload.lxm { + // Allow any lxm for now — HappyView serves many different XRPC methods. + } + + // Resolve the issuer's DID document to get their signing key. + let signing_key = resolve_signing_key(&payload.iss, state).await?; + + // Verify signature: message is "header.payload" as UTF-8 bytes. + let msg = format!("{}.{}", parts[0], parts[1]); + + let valid = match header.alg.as_str() { + "ES256" => verify_es256(msg.as_bytes(), &sig_bytes, &signing_key), + "ES256K" => verify_es256k(msg.as_bytes(), &sig_bytes, &signing_key), + _ => false, + }; + + if !valid && !is_retry { + tracing::debug!(iss = %payload.iss, "signature failed, retrying with fresh DID doc"); + return verify_service_jwt(token, state, true).await; + } + if !valid { + tracing::warn!(iss = %payload.iss, "service auth JWT signature verification failed"); + return Err(AppError::Auth("JWT signature verification failed".into())); + } + + Ok(payload) + }) +} + +// DID resolution +async fn resolve_signing_key(did: &str, state: &AppState) -> Result, AppError> { + let url = if did.starts_with("did:plc:") { + format!("{}/{did}", state.config.plc_url.trim_end_matches('/')) + } else if did.starts_with("did:web:") { + let domain = did.strip_prefix("did:web:").unwrap(); + let domain = domain.replace(':', "/"); + format!("https://{domain}/.well-known/did.json") + } else { + return Err(AppError::BadRequest(format!( + "unsupported DID method: {did}" + ))); + }; + + let resp = state + .http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("DID resolution failed for {did}: {e}")))?; + + if !resp.status().is_success() { + return Err(AppError::Internal(format!( + "DID resolution returned {} for {did}", + resp.status() + ))); + } + + let doc: DidDocument = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid DID document for {did}: {e}")))?; + + let vm = doc + .verification_method + .iter() + .find(|vm| vm.id == format!("{did}#atproto") || vm.id == "#atproto") + .ok_or_else(|| { + AppError::Internal(format!( + "no #atproto verification method in DID doc for {did}" + )) + })?; + + let multibase = vm.public_key_multibase.as_deref().ok_or_else(|| { + AppError::Internal(format!("no publicKeyMultibase on #atproto key for {did}")) + })?; + + decode_multibase_key(multibase, &vm.method_type) +} + +/// Decode a multibase-encoded public key from a DID document. +fn decode_multibase_key(multibase_str: &str, method_type: &str) -> Result, AppError> { + let (_, decoded) = multibase::decode(multibase_str) + .map_err(|e| AppError::Internal(format!("multibase decode failed: {e}")))?; + + match method_type { + "Multikey" => { + if decoded.len() < 2 { + return Err(AppError::Internal("multikey too short".into())); + } + Ok(decoded[2..].to_vec()) + } + "EcdsaSecp256r1VerificationKey2019" | "EcdsaSecp256k1VerificationKey2019" => Ok(decoded), + other => Err(AppError::Internal(format!( + "unsupported verification method type: {other}" + ))), + } +} + +// Signature verification +fn verify_es256(msg: &[u8], sig_bytes: &[u8], key_bytes: &[u8]) -> bool { + let Ok(verifying_key) = P256Key::from_sec1_bytes(key_bytes) else { + tracing::warn!("failed to parse P-256 public key"); + return false; + }; + + if let Ok(sig) = P256Signature::from_bytes(sig_bytes.into()) + && verifying_key.verify(msg, &sig).is_ok() + { + return true; + } + + if let Ok(sig) = P256Signature::from_bytes(sig_bytes.into()) + && let Some(normalized) = sig.normalize_s() + && verifying_key.verify(msg, &normalized).is_ok() + { + return true; + } + + false +} + +fn verify_es256k(msg: &[u8], sig_bytes: &[u8], key_bytes: &[u8]) -> bool { + use k256::ecdsa::{Signature as K256Signature, VerifyingKey as K256Key, signature::Verifier}; + + let Ok(verifying_key) = K256Key::from_sec1_bytes(key_bytes) else { + tracing::warn!("failed to parse secp256k1 public key"); + return false; + }; + + if let Ok(sig) = K256Signature::from_bytes(sig_bytes.into()) + && verifying_key.verify(msg, &sig).is_ok() + { + return true; + } + + if let Ok(sig) = K256Signature::from_bytes(sig_bytes.into()) + && let Some(normalized) = sig.normalize_s() + && verifying_key.verify(msg, &normalized).is_ok() + { + return true; + } + + false +} diff --git a/src/config.rs b/src/config.rs --- a/src/config.rs +++ b/src/config.rs @@ -9,8 +9,8 @@ pub host: String, pub port: u16, pub database_url: String, pub database_backend: DatabaseBackend, - pub aip_url: String, - pub aip_public_url: String, + pub public_url: String, + pub session_secret: String, pub tap_url: String, pub tap_admin_password: Option, pub relay_url: String, @@ -35,9 +35,9 @@ .and_then(|p| p.parse().ok()) .unwrap_or(3000), database_url, database_backend, - aip_url: env::var("AIP_URL").expect("AIP_URL must be set"), - aip_public_url: env::var("AIP_PUBLIC_URL") - .unwrap_or_else(|_| env::var("AIP_URL").expect("AIP_URL must be set")), + public_url: env::var("PUBLIC_URL").expect("PUBLIC_URL must be set"), + session_secret: env::var("SESSION_SECRET") + .unwrap_or_else(|_| "change-me-in-production-not-secure".into()), tap_url: env::var("TAP_URL").unwrap_or_else(|_| "http://localhost:2480".into()), tap_admin_password: env::var("TAP_ADMIN_PASSWORD").ok(), relay_url: env::var("RELAY_URL").unwrap_or_else(|_| "https://bsky.network".into()), @@ -68,7 +68,8 @@ "HOST", "PORT", "DATABASE_URL", "DATABASE_BACKEND", - "AIP_URL", + "PUBLIC_URL", + "SESSION_SECRET", "TAP_URL", "TAP_ADMIN_PASSWORD", "RELAY_URL", @@ -84,7 +85,7 @@ unsafe fn set_required_env() { unsafe { env::set_var("DATABASE_URL", "postgres://localhost/test"); - env::set_var("AIP_URL", "http://localhost:4000"); + env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } } @@ -95,8 +96,8 @@ host: "127.0.0.1".into(), port: 8080, database_url: String::new(), database_backend: DatabaseBackend::Postgres, - aip_url: String::new(), - aip_public_url: String::new(), + public_url: String::new(), + session_secret: String::new(), tap_url: String::new(), tap_admin_password: None, relay_url: String::new(), @@ -119,7 +120,7 @@ set_required_env(); } let config = Config::from_env(); assert_eq!(config.database_url, "postgres://localhost/test"); - assert_eq!(config.aip_url, "http://localhost:4000"); + assert_eq!(config.public_url, "http://127.0.0.1:3000"); } #[test] @@ -162,15 +163,15 @@ #[should_panic(expected = "DATABASE_URL must be set")] fn from_env_panics_without_database_url() { unsafe { clear_env(); - env::set_var("AIP_URL", "http://localhost:4000"); + env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } Config::from_env(); } #[test] #[serial] - #[should_panic(expected = "AIP_URL must be set")] - fn from_env_panics_without_aip_url() { + #[should_panic(expected = "PUBLIC_URL must be set")] + fn from_env_panics_without_public_url() { unsafe { clear_env(); env::set_var("DATABASE_URL", "postgres://localhost/test"); @@ -219,7 +220,7 @@ fn database_backend_detected_from_url() { unsafe { clear_env(); env::set_var("DATABASE_URL", "postgres://localhost/test"); - env::set_var("AIP_URL", "http://localhost:4000"); + env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Postgres); @@ -231,7 +232,7 @@ fn database_backend_sqlite_detected_from_url() { unsafe { clear_env(); env::set_var("DATABASE_URL", "sqlite://data/happyview.db?mode=rwc"); - env::set_var("AIP_URL", "http://localhost:4000"); + env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Sqlite); @@ -244,7 +245,7 @@ unsafe { clear_env(); env::set_var("DATABASE_URL", "postgres://localhost/test"); env::set_var("DATABASE_BACKEND", "sqlite"); - env::set_var("AIP_URL", "http://localhost:4000"); + env::set_var("PUBLIC_URL", "http://127.0.0.1:3000"); } let config = Config::from_env(); assert_eq!(config.database_backend, DatabaseBackend::Sqlite); diff --git a/src/dns.rs b/src/dns.rs new file mode 100644 --- /dev/null +++ b/src/dns.rs @@ -0,0 +1,48 @@ +use atrium_identity::handle::DnsTxtResolver; +use hickory_resolver::TokioResolver; + +/// Native DNS TXT resolver using system DNS configuration. +/// Implements atrium's `DnsTxtResolver` trait for use with `AtprotoHandleResolver`, +/// and provides a `lookup_txt` method for direct TXT record queries. +#[derive(Clone)] +pub struct NativeDnsResolver { + resolver: TokioResolver, +} + +impl Default for NativeDnsResolver { + fn default() -> Self { + Self::new() + } +} + +impl NativeDnsResolver { + pub fn new() -> Self { + Self { + resolver: TokioResolver::builder_tokio() + .expect("Failed to read system DNS config") + .build(), + } + } + + /// Look up TXT records for a given name, returning all record values. + pub async fn lookup_txt(&self, name: &str) -> Result, String> { + let response = self + .resolver + .txt_lookup(name) + .await + .map_err(|e| format!("DNS TXT lookup failed for {name}: {e}"))?; + + Ok(response.iter().map(|txt| txt.to_string()).collect()) + } +} + +impl DnsTxtResolver for NativeDnsResolver { + async fn resolve( + &self, + query: &str, + ) -> core::result::Result, Box> { + self.lookup_txt(query).await.map_err(|e| { + Box::new(std::io::Error::other(e)) as Box + }) + } +} diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ pub mod admin; -pub mod aip; pub mod auth; pub mod config; pub mod db; +pub mod dns; pub mod error; pub mod event_log; pub mod labeler; @@ -17,13 +17,33 @@ pub mod server; pub mod tap; pub mod xrpc; +use auth::oauth_store::{DbSessionStore, DbStateStore}; use config::Config; use db::DatabaseBackend; +use dns::NativeDnsResolver; use lexicon::LexiconRegistry; use rate_limit::RateLimiter; use std::sync::Arc; use tokio::sync::watch; +use atrium_identity::did::CommonDidResolver; +use atrium_identity::handle::AtprotoHandleResolver; +use atrium_oauth::DefaultHttpClient; + +pub type HappyViewOAuthClient = atrium_oauth::OAuthClient< + DbStateStore, + DbSessionStore, + CommonDidResolver, + AtprotoHandleResolver, +>; + +pub type HappyViewOAuthSession = atrium_oauth::OAuthSession< + DefaultHttpClient, + CommonDidResolver, + AtprotoHandleResolver, + DbSessionStore, +>; + #[derive(Clone)] pub struct AppState { pub config: Config, @@ -34,4 +54,12 @@ pub lexicons: LexiconRegistry, pub collections_tx: watch::Sender>, pub labeler_subscriptions_tx: watch::Sender<()>, pub rate_limiter: Arc, + pub oauth: Arc, + pub cookie_key: axum_extra::extract::cookie::Key, +} + +impl axum::extract::FromRef for axum_extra::extract::cookie::Key { + fn from_ref(state: &AppState) -> Self { + state.cookie_key.clone() + } } diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -210,8 +210,8 @@ host: "127.0.0.1".into(), port: 3000, database_url: String::new(), database_backend: crate::db::DatabaseBackend::Sqlite, - aip_url: String::new(), - aip_public_url: String::new(), + public_url: String::new(), + session_secret: "test-secret".into(), tap_url: String::new(), tap_admin_password: None, relay_url: String::new(), @@ -222,10 +222,48 @@ }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); sqlx::any::install_default_drivers(); + let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + session_store: crate::auth::oauth_store::DbSessionStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); AppState { config, http: reqwest::Client::new(), - db: sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(), + db: test_db, db_backend: DatabaseBackend::Sqlite, lexicons: LexiconRegistry::new(), collections_tx: tx, @@ -240,6 +278,10 @@ default_procedure_cost: 1, default_proxy_cost: 1, }, vec![], + ), + oauth: std::sync::Arc::new(oauth), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", ), } } diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -618,8 +618,8 @@ host: "127.0.0.1".into(), port: 3000, database_url: String::new(), database_backend: crate::db::DatabaseBackend::Sqlite, - aip_url: String::new(), - aip_public_url: String::new(), + public_url: String::new(), + session_secret: "test-secret".into(), tap_url: String::new(), tap_admin_password: None, relay_url: String::new(), @@ -630,10 +630,48 @@ }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); sqlx::any::install_default_drivers(); + let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + session_store: crate::auth::oauth_store::DbSessionStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); AppState { config, http: reqwest::Client::new(), - db: sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(), + db: test_db, db_backend: DatabaseBackend::Sqlite, lexicons: LexiconRegistry::new(), collections_tx: tx, @@ -648,6 +686,10 @@ default_procedure_cost: 1, default_proxy_cost: 1, }, vec![], + ), + oauth: std::sync::Arc::new(oauth), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", ), } } diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -57,7 +57,7 @@ // Capture script source and input for error logging before anything is consumed. let script_source = script.to_string(); let input_json = input.clone(); - let session = match repo::get_atp_session(state, claims.token()).await { + let session = match repo::get_oauth_session(state, claims.did()).await { Ok(s) => s, Err(e) => { let error_message = format!("{e}"); @@ -952,8 +952,8 @@ host: "127.0.0.1".into(), port: 3000, database_url: String::new(), database_backend: crate::db::DatabaseBackend::Sqlite, - aip_url: String::new(), - aip_public_url: String::new(), + public_url: String::new(), + session_secret: "test-secret".into(), tap_url: String::new(), tap_admin_password: None, relay_url: String::new(), @@ -964,10 +964,48 @@ }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); sqlx::any::install_default_drivers(); + let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + session_store: crate::auth::oauth_store::DbSessionStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); AppState { config, http: reqwest::Client::new(), - db: sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(), + db: test_db, db_backend: DatabaseBackend::Sqlite, lexicons: LexiconRegistry::new(), collections_tx: tx, @@ -982,6 +1020,10 @@ default_procedure_cost: 1, default_proxy_cost: 1, }, vec![], + ), + oauth: std::sync::Arc::new(oauth), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", ), } } diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -92,8 +92,8 @@ host: "127.0.0.1".into(), port: 3000, database_url: String::new(), database_backend: crate::db::DatabaseBackend::Sqlite, - aip_url: String::new(), - aip_public_url: String::new(), + public_url: String::new(), + session_secret: "test-secret".into(), tap_url: String::new(), tap_admin_password: None, relay_url: String::new(), @@ -104,10 +104,48 @@ }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); sqlx::any::install_default_drivers(); + let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + session_store: crate::auth::oauth_store::DbSessionStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); AppState { config, http: reqwest::Client::new(), - db: sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(), + db: test_db, db_backend: crate::db::DatabaseBackend::Sqlite, lexicons: LexiconRegistry::new(), collections_tx: tx, @@ -122,6 +160,10 @@ default_procedure_cost: 1, default_proxy_cost: 1, }, vec![], + ), + oauth: std::sync::Arc::new(oauth), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", ), } } diff --git a/src/lua/record.rs b/src/lua/record.rs --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -4,10 +4,11 @@ use serde_json::{Value, json}; use std::sync::Arc; use crate::AppState; +use crate::HappyViewOAuthSession; use crate::auth::Claims; use crate::db::{adapt_sql, now_rfc3339}; use crate::record_refs::sync_refs; -use crate::repo::{self, AtpSession}; +use crate::repo; use super::tid::generate_tid; @@ -27,7 +28,7 @@ pub fn register_record_api( lua: &Lua, state: Arc, claims: Arc, - session: Arc, + session: Arc, ) -> LuaResult<()> { // -- methods table (shared by all Record instances) -- let methods = lua.create_table()?; diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,9 @@ +use std::sync::Arc; + +use happyview::auth::oauth_store::{DbSessionStore, DbStateStore}; use happyview::config::Config; use happyview::db; +use happyview::dns::NativeDnsResolver; use happyview::lexicon::{LexiconRegistry, ParsedLexicon, ProcedureAction}; use happyview::rate_limit::RateLimiter; use happyview::resolve::{fetch_lexicon_from_pds, resolve_nsid_authority}; @@ -7,6 +11,13 @@ use happyview::{AppState, labeler, server, tap}; use tokio::sync::watch; use tracing::{info, warn}; +use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig}; +use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; +use atrium_oauth::{ + AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, + GrantType, KnownScope, OAuthClientConfig, OAuthResolverConfig, Scope, +}; + #[tokio::main] async fn main() { dotenvy::dotenv().ok(); @@ -22,7 +33,7 @@ let config = Config::from_env(); let db_backend = config.database_backend; // Connect to database and run migrations. - let db = db::connect(&config.database_url, db_backend).await; + let db_pool = db::connect(&config.database_url, db_backend).await; info!( backend = ?db_backend, @@ -31,7 +42,7 @@ ); // Backfill record_refs in the background (first run after upgrade) { - let db_bg = db.clone(); + let db_bg = db_pool.clone(); let backend = db_backend; tokio::spawn(async move { let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM record_refs") @@ -95,7 +106,7 @@ } let lexicons = LexiconRegistry::new(); lexicons - .load_from_db(&db) + .load_from_db(&db_pool) .await .expect("failed to load lexicons"); @@ -104,7 +115,7 @@ let http = reqwest::Client::new(); let network_rows: Vec<(String, Option, Option)> = sqlx::query_as( "SELECT id, authority_did, target_collection FROM lexicons WHERE source = 'network'", ) - .fetch_all(&db) + .fetch_all(&db_pool) .await .unwrap_or_default(); @@ -135,7 +146,7 @@ .bind(&lexicon_json_str) .bind(&now) .bind(&now) .bind(nsid) - .execute(&db) + .execute(&db_pool) .await { warn!(nsid, "failed to update network lexicon in DB: {e}"); @@ -163,10 +174,95 @@ ); } // Initialize rate limiter from DB. - let rl_state = RateLimiter::load_from_db(&db).await; + let rl_state = RateLimiter::load_from_db(&db_pool).await; let rate_limiter = RateLimiter::new(rl_state.enabled, rl_state.global, rl_state.allowlist); tokio::spawn(rate_limiter.clone().spawn_cleanup()); + // Build atrium-oauth client + let dns = NativeDnsResolver::new(); + let callback_url = format!("{}/auth/callback", config.public_url.trim_end_matches('/')); + let atrium_http = Arc::new(DefaultHttpClient::default()); + + let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { + plc_directory_url: config.plc_url.clone(), + http_client: Arc::clone(&atrium_http), + }); + + let handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { + dns_txt_resolver: dns, + http_client: Arc::clone(&atrium_http), + }); + + let is_loopback = + config.public_url.contains("127.0.0.1") || config.public_url.contains("[::1]"); + + let resolver_config = OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }; + + let oauth_client = if is_loopback { + info!("Using loopback OAuth client metadata (local development)"); + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec![callback_url]), + scopes: Some(vec![ + Scope::Known(KnownScope::Atproto), + Scope::Known(KnownScope::TransitionGeneric), + ]), + }, + keys: None, + state_store: DbStateStore::new(db_pool.clone(), db_backend), + session_store: DbSessionStore::new(db_pool.clone(), db_backend), + resolver: resolver_config, + }) + .expect("Failed to create OAuth client") + } else { + atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoClientMetadata { + client_id: format!( + "{}/oauth/client-metadata.json", + config.public_url.trim_end_matches('/') + ), + client_uri: Some(config.public_url.clone()), + redirect_uris: vec![callback_url], + token_endpoint_auth_method: AuthMethod::None, + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], + scopes: vec![ + Scope::Known(KnownScope::Atproto), + Scope::Known(KnownScope::TransitionGeneric), + ], + jwks_uri: None, + token_endpoint_auth_signing_alg: None, + }, + keys: None, + state_store: DbStateStore::new(db_pool.clone(), db_backend), + session_store: DbSessionStore::new(db_pool.clone(), db_backend), + resolver: resolver_config, + }) + .expect("Failed to create OAuth client") + }; + + if config.session_secret == "change-me-in-production-not-secure" { + if db_backend == happyview::db::DatabaseBackend::Postgres { + tracing::error!( + "INSECURE SESSION SECRET — You are using the default session secret with a \ + Postgres backend, which likely indicates a production deployment. \ + Set SESSION_SECRET to a random string of at least 64 characters." + ); + } else { + warn!( + "Using the default session secret. Set SESSION_SECRET to a random \ + string in production." + ); + } + } + + let cookie_key = + axum_extra::extract::cookie::Key::derive_from(config.session_secret.as_bytes()); + let initial_collections = lexicons.get_record_collections().await; let initial_collections_for_sync = initial_collections.clone(); let (collections_tx, collections_rx) = watch::channel(initial_collections); @@ -175,12 +271,14 @@ let state = AppState { config: config.clone(), http, - db, + db: db_pool, db_backend, lexicons, collections_tx, labeler_subscriptions_tx, rate_limiter, + oauth: Arc::new(oauth_client), + cookie_key, }; // Sync initial collections to Tap on startup. diff --git a/src/repo/dpop.rs b/src/repo/dpop.rs deleted file mode 100644 --- a/src/repo/dpop.rs +++ /dev/null @@ -1,222 +0,0 @@ -use base64::Engine; -use p256::pkcs8::EncodePrivateKey; -use sha2::{Digest, Sha256}; - -use crate::error::AppError; - -use super::session::DpopJwk; - -#[derive(serde::Serialize)] -struct DpopClaims { - jti: String, - htm: String, - htu: String, - iat: i64, - exp: i64, - ath: String, - #[serde(skip_serializing_if = "Option::is_none")] - nonce: Option, -} - -/// Generate a DPoP proof JWT for a PDS request. -pub(crate) fn generate_dpop_proof( - method: &str, - url: &str, - dpop_jwk: &DpopJwk, - access_token: &str, - nonce: Option<&str>, -) -> Result { - // Decode the private key from base64url - let d_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&dpop_jwk.d) - .map_err(|e| AppError::Internal(format!("invalid DPoP key d: {e}")))?; - - let secret_key = p256::SecretKey::from_slice(&d_bytes) - .map_err(|e| AppError::Internal(format!("invalid P-256 key: {e}")))?; - - let pkcs8_der = secret_key - .to_pkcs8_der() - .map_err(|e| AppError::Internal(format!("PKCS#8 conversion failed: {e}")))?; - - let encoding_key = jsonwebtoken::EncodingKey::from_ec_der(pkcs8_der.as_bytes()); - - // Public JWK for the header (no private component) - let public_jwk = jsonwebtoken::jwk::Jwk { - common: jsonwebtoken::jwk::CommonParameters { - public_key_use: None, - key_operations: None, - key_algorithm: None, - key_id: None, - x509_url: None, - x509_chain: None, - x509_sha1_fingerprint: None, - x509_sha256_fingerprint: None, - }, - algorithm: jsonwebtoken::jwk::AlgorithmParameters::EllipticCurve( - jsonwebtoken::jwk::EllipticCurveKeyParameters { - key_type: jsonwebtoken::jwk::EllipticCurveKeyType::EC, - curve: jsonwebtoken::jwk::EllipticCurve::P256, - x: dpop_jwk.x.clone(), - y: dpop_jwk.y.clone(), - }, - ), - }; - - let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); - header.typ = Some("dpop+jwt".to_string()); - header.jwk = Some(public_jwk); - - // Access token hash (ath) - let ath_hash = Sha256::digest(access_token.as_bytes()); - let ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(ath_hash); - - let now = chrono::Utc::now().timestamp(); - let claims = DpopClaims { - jti: uuid::Uuid::new_v4().to_string(), - htm: method.to_uppercase(), - htu: url.to_string(), - iat: now, - exp: now + 300, - ath, - nonce: nonce.map(|n| n.to_string()), - }; - - jsonwebtoken::encode(&header, &claims, &encoding_key) - .map_err(|e| AppError::Internal(format!("DPoP proof signing failed: {e}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_dpop_jwk() -> DpopJwk { - use p256::elliptic_curve::rand_core::OsRng; - use p256::elliptic_curve::sec1::ToEncodedPoint; - // Generate a valid P-256 key for testing - let secret = p256::SecretKey::random(&mut OsRng); - let public = secret.public_key(); - let point = public.to_encoded_point(false); - - DpopJwk { - x: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(point.x().unwrap()), - y: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(point.y().unwrap()), - d: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret.to_bytes()), - } - } - - #[test] - fn dpop_proof_produces_valid_jwt_structure() { - let jwk = test_dpop_jwk(); - let token = generate_dpop_proof( - "POST", - "https://pds.example.com/xrpc/test", - &jwk, - "access-tok", - None, - ) - .unwrap(); - - let parts: Vec<&str> = token.split('.').collect(); - assert_eq!(parts.len(), 3, "JWT should have 3 parts"); - } - - #[test] - fn dpop_proof_header_has_correct_fields() { - let jwk = test_dpop_jwk(); - let token = generate_dpop_proof( - "POST", - "https://pds.example.com/xrpc/test", - &jwk, - "access-tok", - None, - ) - .unwrap(); - - let header_b64 = token.split('.').next().unwrap(); - let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(header_b64) - .unwrap(); - let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); - - assert_eq!(header["typ"], "dpop+jwt"); - assert_eq!(header["alg"], "ES256"); - assert!(header.get("jwk").is_some()); - } - - #[test] - fn dpop_proof_claims_have_correct_fields() { - let jwk = test_dpop_jwk(); - let token = generate_dpop_proof( - "GET", - "https://pds.example.com/xrpc/test", - &jwk, - "my-access-token", - None, - ) - .unwrap(); - - let payload_b64 = token.split('.').nth(1).unwrap(); - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .unwrap(); - let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); - - assert_eq!(claims["htm"], "GET"); - assert_eq!(claims["htu"], "https://pds.example.com/xrpc/test"); - assert!(claims.get("jti").is_some()); - assert!(claims.get("iat").is_some()); - assert!(claims.get("exp").is_some()); - assert!(claims.get("ath").is_some()); - assert!(claims.get("nonce").is_none()); - } - - #[test] - fn dpop_proof_includes_nonce_when_provided() { - let jwk = test_dpop_jwk(); - let token = generate_dpop_proof( - "POST", - "https://pds.example.com/xrpc/test", - &jwk, - "tok", - Some("abc123"), - ) - .unwrap(); - - let payload_b64 = token.split('.').nth(1).unwrap(); - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .unwrap(); - let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); - - assert_eq!(claims["nonce"], "abc123"); - } - - #[test] - fn dpop_proof_ath_is_sha256_of_access_token() { - let jwk = test_dpop_jwk(); - let access_token = "test-access-token"; - let token = - generate_dpop_proof("POST", "https://example.com", &jwk, access_token, None).unwrap(); - - let payload_b64 = token.split('.').nth(1).unwrap(); - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .unwrap(); - let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); - - let expected_hash = Sha256::digest(access_token.as_bytes()); - let expected_ath = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(expected_hash); - assert_eq!(claims["ath"], expected_ath); - } - - #[test] - fn dpop_proof_invalid_key_returns_error() { - let jwk = DpopJwk { - x: "invalid".into(), - y: "invalid".into(), - d: "invalid".into(), - }; - let result = generate_dpop_proof("POST", "https://example.com", &jwk, "tok", None); - assert!(result.is_err()); - } -} diff --git a/src/repo/mod.rs b/src/repo/mod.rs --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -1,8 +1,7 @@ -mod dpop; mod pds; pub(crate) mod session; mod upload_blob; pub(crate) use pds::{forward_pds_response, pds_post_json_raw}; -pub(crate) use session::{AtpSession, get_atp_session}; +pub(crate) use session::get_oauth_session; pub use upload_blob::upload_blob; diff --git a/src/repo/pds.rs b/src/repo/pds.rs --- a/src/repo/pds.rs +++ b/src/repo/pds.rs @@ -1,13 +1,12 @@ +use atrium_xrpc::{HttpClient, XrpcClient}; use axum::body::Bytes; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde_json::Value; use crate::AppState; +use crate::HappyViewOAuthSession; use crate::error::AppError; - -use super::dpop::generate_dpop_proof; -use super::session::AtpSession; /// Forward a PDS response back to the client, preserving status and body. pub(crate) async fn forward_pds_response(resp: reqwest::Response) -> Result { @@ -33,120 +32,78 @@ Err(AppError::PdsError(axum_status, body)) } } -/// POST JSON to a PDS XRPC endpoint with DPoP auth and nonce retry. -/// Returns the raw reqwest::Response so callers can inspect the body. +/// POST JSON to a PDS XRPC endpoint using the OAuth session. +/// The OAuthSession handles DPoP proof generation and nonce retry internally. pub(crate) async fn pds_post_json_raw( - state: &AppState, - session: &AtpSession, + _state: &AppState, + session: &HappyViewOAuthSession, xrpc_method: &str, body: &Value, ) -> Result { - let url = format!( - "{}/xrpc/{xrpc_method}", - session.pds_endpoint.trim_end_matches('/') - ); + let pds_endpoint = session.base_uri(); + let url = format!("{}/xrpc/{xrpc_method}", pds_endpoint.trim_end_matches('/')); - let dpop = generate_dpop_proof("POST", &url, &session.dpop_jwk, &session.access_token, None)?; + let body_bytes = serde_json::to_vec(body) + .map_err(|e| AppError::Internal(format!("failed to serialize body: {e}")))?; - let resp = state - .http - .post(&url) - .header("authorization", format!("DPoP {}", session.access_token)) - .header("dpop", &dpop) - .json(body) - .send() + let request = atrium_xrpc::http::Request::builder() + .method("POST") + .uri(&url) + .header("content-type", "application/json") + .body(body_bytes) + .map_err(|e| AppError::Internal(format!("failed to build request: {e}")))?; + + let response = session + .send_http(request) .await .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; - // Retry with nonce if PDS requires it - if resp.status() == reqwest::StatusCode::UNAUTHORIZED - && let Some(nonce) = resp - .headers() - .get("dpop-nonce") - .and_then(|v| v.to_str().ok()) - { - let nonce = nonce.to_string(); - tracing::debug!("retrying with DPoP nonce"); - - let dpop = generate_dpop_proof( - "POST", - &url, - &session.dpop_jwk, - &session.access_token, - Some(&nonce), - )?; - - let resp = state - .http - .post(&url) - .header("authorization", format!("DPoP {}", session.access_token)) - .header("dpop", &dpop) - .json(body) - .send() - .await - .map_err(|e| AppError::Internal(format!("PDS request retry failed: {e}")))?; + // Convert atrium http::Response to reqwest::Response + let (parts, body) = response.into_parts(); + let http_resp = atrium_xrpc::http::Response::from_parts(parts, body); + let reqwest_resp = reqwest::Response::from(http_resp); - return Ok(resp); - } - - Ok(resp) + Ok(reqwest_resp) } -/// POST a binary blob to the PDS with DPoP auth and nonce retry. +/// POST a binary blob to the PDS with OAuth session auth. pub(super) async fn pds_post_blob( - state: &AppState, - session: &AtpSession, + _state: &AppState, + session: &HappyViewOAuthSession, content_type: &str, blob: Bytes, ) -> Result { + let pds_endpoint = session.base_uri(); let url = format!( "{}/xrpc/com.atproto.repo.uploadBlob", - session.pds_endpoint.trim_end_matches('/') + pds_endpoint.trim_end_matches('/') ); - let dpop = generate_dpop_proof("POST", &url, &session.dpop_jwk, &session.access_token, None)?; - - let resp = state - .http - .post(&url) - .header("authorization", format!("DPoP {}", session.access_token)) - .header("dpop", &dpop) + let request = atrium_xrpc::http::Request::builder() + .method("POST") + .uri(&url) .header("content-type", content_type) - .body(blob.clone()) - .send() + .body(blob.to_vec()) + .map_err(|e| AppError::Internal(format!("failed to build request: {e}")))?; + + let response = session + .send_http(request) .await .map_err(|e| AppError::Internal(format!("PDS uploadBlob failed: {e}")))?; - if resp.status() == reqwest::StatusCode::UNAUTHORIZED - && let Some(nonce) = resp - .headers() - .get("dpop-nonce") - .and_then(|v| v.to_str().ok()) - { - let nonce = nonce.to_string(); - tracing::debug!("retrying uploadBlob with DPoP nonce"); + let status = StatusCode::from_u16(response.status().as_u16()).unwrap(); + let body_bytes = response.into_body(); - let dpop = generate_dpop_proof( - "POST", - &url, - &session.dpop_jwk, - &session.access_token, - Some(&nonce), - )?; - - let resp = state - .http - .post(&url) - .header("authorization", format!("DPoP {}", session.access_token)) - .header("dpop", &dpop) - .header("content-type", content_type) - .body(blob) - .send() - .await - .map_err(|e| AppError::Internal(format!("PDS uploadBlob retry failed: {e}")))?; - - return forward_pds_response(resp).await; + if status.is_success() { + Ok(( + status, + [(axum::http::header::CONTENT_TYPE, "application/json")], + Bytes::from(body_bytes), + ) + .into_response()) + } else { + let body_str = String::from_utf8_lossy(&body_bytes); + tracing::warn!(status = %status, body = %body_str, "PDS uploadBlob returned error"); + Err(AppError::PdsError(status, Bytes::from(body_bytes))) } - - forward_pds_response(resp).await } diff --git a/src/repo/session.rs b/src/repo/session.rs --- a/src/repo/session.rs +++ b/src/repo/session.rs @@ -1,45 +1,20 @@ -use serde::Deserialize; +use atrium_api::types::string::Did; use crate::AppState; +use crate::HappyViewOAuthSession; use crate::error::AppError; -#[derive(Deserialize)] -pub(crate) struct AtpSession { - pub(crate) access_token: String, - pub(crate) pds_endpoint: String, - pub(crate) dpop_jwk: DpopJwk, -} - -#[derive(Deserialize)] -pub(crate) struct DpopJwk { - pub(crate) x: String, - pub(crate) y: String, - pub(crate) d: String, -} - -/// Fetch the user's AT Protocol session (PDS credentials) from AIP. -pub(crate) async fn get_atp_session(state: &AppState, token: &str) -> Result { - let url = format!( - "{}/api/atprotocol/session", - state.config.aip_url.trim_end_matches('/') - ); - - let resp = state - .http - .get(&url) - .header("authorization", format!("Bearer {token}")) - .send() +/// Resume an OAuth session for the given DID via atrium. +/// The returned `OAuthSession` handles DPoP and token refresh internally. +pub(crate) async fn get_oauth_session( + state: &AppState, + did: &str, +) -> Result { + let did = + Did::new(did.to_string()).map_err(|_| AppError::Auth(format!("invalid DID: {did}")))?; + state + .oauth + .restore(&did) .await - .map_err(|e| AppError::Internal(format!("AIP session request failed: {e}")))?; - - if !resp.status().is_success() { - return Err(AppError::Auth(format!( - "AIP session returned {}", - resp.status() - ))); - } - - resp.json() - .await - .map_err(|e| AppError::Internal(format!("invalid AIP session response: {e}"))) + .map_err(|e| AppError::Auth(format!("no OAuth session for {}: {e}", did.as_ref()))) } diff --git a/src/repo/upload_blob.rs b/src/repo/upload_blob.rs --- a/src/repo/upload_blob.rs +++ b/src/repo/upload_blob.rs @@ -10,7 +10,7 @@ use crate::error::AppError; use crate::rate_limit::CheckResult; use super::pds::pds_post_blob; -use super::session::get_atp_session; +use super::session::get_oauth_session; pub async fn upload_blob( State(state): State, @@ -44,7 +44,7 @@ reset, }); } - let session = get_atp_session(&state, claims.token()).await?; + let session = get_oauth_session(&state, claims.did()).await?; let content_type = headers .get("content-type") diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -13,7 +13,6 @@ use tower_http::trace::TraceLayer; use crate::AppState; use crate::admin; -use crate::aip; use crate::auth::Claims; use crate::error::AppError; use crate::profile; @@ -65,6 +64,8 @@ Router::new() .route("/health", get(health)) .nest("/admin", admin::admin_routes(state.clone())) + .nest("/auth", crate::auth::routes::routes()) + .route("/oauth/client-metadata.json", get(client_metadata)) .route("/xrpc/app.bsky.actor.getProfile", get(get_profile)) .route( "/xrpc/com.atproto.repo.uploadBlob", @@ -73,7 +74,6 @@ ) // Catch-all for dynamically registered lexicons .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) .route("/config", get(config_endpoint)) - .route("/aip/{*path}", get(aip::aip_proxy).post(aip::aip_proxy)) .fallback_service(serve_dir) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) @@ -85,7 +85,12 @@ "ok" } async fn config_endpoint(State(state): State) -> Json { - Json(serde_json::json!({ "aip_url": state.config.aip_public_url })) + Json(serde_json::json!({ "public_url": state.config.public_url })) +} + +async fn client_metadata(State(state): State) -> Json { + let client_metadata = &state.oauth.client_metadata; + Json(serde_json::to_value(client_metadata).unwrap_or_default()) } fn ip_from_forwarded_for(value: Option<&str>) -> Option { diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -29,7 +29,7 @@ let collection = lexicon.target_collection.as_deref().ok_or_else(|| { AppError::BadRequest(format!("{method} has no target_collection configured")) })?; - let session = repo::get_atp_session(state, claims.token()).await?; + let session = repo::get_oauth_session(state, claims.did()).await?; match &lexicon.action { ProcedureAction::Create => { @@ -58,7 +58,7 @@ state: &AppState, claims: &Claims, input: &Value, collection: &str, - session: &repo::AtpSession, + session: &crate::HappyViewOAuthSession, ) -> Result { // Build record from input, adding $type let mut record = input.clone(); @@ -134,7 +134,7 @@ state: &AppState, claims: &Claims, input: &Value, collection: &str, - session: &repo::AtpSession, + session: &crate::HappyViewOAuthSession, ) -> Result { let uri = input .get("uri") @@ -222,7 +222,7 @@ state: &AppState, claims: &Claims, input: &Value, collection: &str, - session: &repo::AtpSession, + session: &crate::HappyViewOAuthSession, ) -> Result { let uri = input .get("uri") diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -1,3 +1,9 @@ +use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig}; +use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; +use atrium_oauth::{ + AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClientConfig, + OAuthResolverConfig, Scope, +}; use axum::Router; use happyview::config::Config; use happyview::db::{DatabaseBackend, adapt_sql, now_rfc3339}; @@ -33,8 +39,8 @@ host: "127.0.0.1".into(), port: 0, database_url: String::new(), database_backend: backend, - aip_url: mock_url.clone(), - aip_public_url: mock_url.clone(), + public_url: "http://127.0.0.1:0".into(), + session_secret: "test-secret".into(), tap_url: "http://localhost:2480".into(), tap_admin_password: None, relay_url: mock_url.clone(), @@ -66,6 +72,36 @@ let initial_collections = lexicons.get_record_collections().await; let (collections_tx, _collections_rx) = watch::channel(initial_collections); let (labeler_subscriptions_tx, _) = watch::channel(()); + let atrium_http = std::sync::Arc::new(DefaultHttpClient::default()); + let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }); + let handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { + dns_txt_resolver: happyview::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }); + let oauth_pool = db::test_pool().await; + let oauth = atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![Scope::Known(KnownScope::Atproto)]), + }, + keys: None, + state_store: happyview::auth::oauth_store::DbStateStore::new( + oauth_pool.clone(), + backend, + ), + session_store: happyview::auth::oauth_store::DbSessionStore::new(oauth_pool, backend), + resolver: OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); + let state = AppState { config, http: reqwest::Client::new(), @@ -85,6 +121,8 @@ default_proxy_cost: 1, }, vec![], ), + oauth: std::sync::Arc::new(oauth), + cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), }; let router = server::router(state.clone()); @@ -98,8 +136,8 @@ admin_token, } } - pub async fn mock_admin_userinfo(&self) { - use crate::common::auth::mock_aip_userinfo; - mock_aip_userinfo(&self.mock_server, &self.admin_did).await; + /// Build a Cookie header that authenticates as the admin user. + pub fn admin_cookie(&self) -> (axum::http::HeaderName, axum::http::HeaderValue) { + crate::common::auth::admin_cookie_header(&self.admin_did, &self.state.cookie_key) } } diff --git a/tests/common/auth.rs b/tests/common/auth.rs --- a/tests/common/auth.rs +++ b/tests/common/auth.rs @@ -1,23 +1,31 @@ use axum::http::{HeaderName, HeaderValue}; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; +use axum_extra::extract::cookie::{Cookie, Key, SignedCookieJar}; -use crate::common::fixtures; +/// Build a Cookie header containing a signed session cookie for the given DID. +/// This simulates a user who has completed OAuth and has a session cookie. +pub fn admin_cookie_header(did: &str, cookie_key: &Key) -> (HeaderName, HeaderValue) { + let jar = SignedCookieJar::new(cookie_key.clone()); + let mut cookie = Cookie::new(happyview::auth::COOKIE_NAME, did.to_string()); + cookie.set_path("/"); + let jar = jar.add(cookie); -/// Build an Authorization header for admin endpoints. + // Extract the Set-Cookie value and convert to a Cookie request header + let cookie_header = jar + .iter() + .map(|c| format!("{}={}", c.name(), c.value())) + .collect::>() + .join("; "); + + ( + HeaderName::from_static("cookie"), + HeaderValue::from_str(&cookie_header).unwrap(), + ) +} + +/// Build an Authorization header for API key endpoints. pub fn admin_auth_header(token: &str) -> (HeaderName, HeaderValue) { ( HeaderName::from_static("authorization"), HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), ) } - -/// Mount a mock on the given server that responds to AIP userinfo requests -/// with a successful response containing the given DID. -pub async fn mock_aip_userinfo(mock_server: &MockServer, did: &str) { - Mock::given(method("GET")) - .and(path("/oauth/userinfo")) - .respond_with(ResponseTemplate::new(200).set_body_json(fixtures::userinfo_response(did))) - .mount(mock_server) - .await; -} diff --git a/tests/common/fixtures.rs b/tests/common/fixtures.rs --- a/tests/common/fixtures.rs +++ b/tests/common/fixtures.rs @@ -104,8 +104,3 @@ "description": "A test user" } }) } - -/// A fake AIP userinfo response. -pub fn userinfo_response(did: &str) -> Value { - json!({ "sub": did }) -} diff --git a/tests/e2e_admin.rs b/tests/e2e_admin.rs --- a/tests/e2e_admin.rs +++ b/tests/e2e_admin.rs @@ -9,7 +9,6 @@ use serial_test::serial; use tower::ServiceExt; use common::app::TestApp; -use common::auth::admin_auth_header; use common::fixtures; // --------------------------------------------------------------------------- @@ -21,32 +20,39 @@ let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap() } -fn admin_get(uri: &str, token: &str) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { Request::builder() .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } -fn admin_post(uri: &str, token: &str, body: &Value) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_post( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { Request::builder() .method("POST") .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() } -fn admin_delete(uri: &str, token: &str) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_delete( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { Request::builder() .method("DELETE") .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } @@ -63,6 +69,7 @@ let app = TestApp::new().await; let resp = app .router + .clone() .oneshot( Request::builder() .uri("/admin/lexicons") @@ -81,10 +88,17 @@ #[ignore] async fn admin_wrong_token_returns_401() { let app = TestApp::new().await; - // No AIP mock mounted — the userinfo request will fail. + // No valid session cookie — the request will be rejected. let resp = app .router - .oneshot(admin_get("/admin/lexicons", "wrong-token")) + .clone() + .oneshot( + Request::builder() + .uri("/admin/lexicons") + .header("cookie", "session=invalid-cookie-value") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); @@ -96,11 +110,11 @@ #[serial] #[ignore] async fn admin_valid_token_returns_200() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router - .oneshot(admin_get("/admin/lexicons", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/lexicons", app.admin_cookie())) .await .unwrap(); @@ -113,12 +127,14 @@ #[ignore] async fn admin_non_admin_did_returns_403() { let app = TestApp::new().await; - // Mock AIP returning a DID that is NOT in the admins table. - common::auth::mock_aip_userinfo(&app.mock_server, "did:plc:notadmin").await; - + // Use a DID that is NOT in the admins table. let resp = app .router - .oneshot(admin_get("/admin/lexicons", "some-token")) + .clone() + .oneshot(admin_get( + "/admin/lexicons", + common::auth::admin_cookie_header("did:plc:notadmin", &app.state.cookie_key), + )) .await .unwrap(); @@ -138,13 +154,16 @@ .execute(&app.state.db) .await .unwrap(); - // Mock AIP returning a new DID. + // Use a new DID via cookie auth. let bootstrap_did = "did:plc:bootstrap"; - common::auth::mock_aip_userinfo(&app.mock_server, bootstrap_did).await; let resp = app .router - .oneshot(admin_get("/admin/lexicons", "bootstrap-token")) + .clone() + .oneshot(admin_get( + "/admin/lexicons", + common::auth::admin_cookie_header(bootstrap_did, &app.state.cookie_key), + )) .await .unwrap(); @@ -170,7 +189,6 @@ #[serial] #[ignore] async fn lexicon_create_returns_201() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true @@ -178,7 +196,8 @@ }); let resp = app .router - .oneshot(admin_post("/admin/lexicons", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); @@ -193,7 +212,6 @@ #[serial] #[ignore] async fn lexicon_upsert_returns_200_with_incremented_revision() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true @@ -203,7 +221,8 @@ // First create let resp = app .router .clone() - .oneshot(admin_post("/admin/lexicons", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); @@ -211,7 +230,8 @@ // Upsert let resp = app .router - .oneshot(admin_post("/admin/lexicons", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -224,14 +244,14 @@ #[serial] #[ignore] async fn lexicon_invalid_version_returns_400() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let body = json!({ "lexicon_json": { "lexicon": 99, "id": "test.bad" }, }); let resp = app .router - .oneshot(admin_post("/admin/lexicons", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); @@ -243,14 +263,14 @@ #[serial] #[ignore] async fn lexicon_missing_id_returns_400() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let body = json!({ "lexicon_json": { "lexicon": 1 }, }); let resp = app .router - .oneshot(admin_post("/admin/lexicons", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); @@ -262,14 +282,14 @@ #[serial] #[ignore] async fn lexicon_list_all() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; // Seed a lexicon app.router + .clone() .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon() }), )) .await @@ -277,7 +297,8 @@ .unwrap(); let resp = app .router - .oneshot(admin_get("/admin/lexicons", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/lexicons", app.admin_cookie())) .await .unwrap(); @@ -293,13 +314,13 @@ #[serial] #[ignore] async fn lexicon_get_by_id() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; app.router + .clone() .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon() }), )) .await @@ -307,9 +328,10 @@ .unwrap(); let resp = app .router + .clone() .oneshot(admin_get( "/admin/lexicons/games.gamesgamesgamesgames.game", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -324,13 +346,13 @@ #[serial] #[ignore] async fn lexicon_get_not_found() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router + .clone() .oneshot(admin_get( "/admin/lexicons/nonexistent.lexicon", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -343,13 +365,13 @@ #[serial] #[ignore] async fn lexicon_delete() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; app.router + .clone() .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon() }), )) .await @@ -357,9 +379,10 @@ .unwrap(); let resp = app .router + .clone() .oneshot(admin_delete( "/admin/lexicons/games.gamesgamesgamesgames.game", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -372,13 +395,13 @@ #[serial] #[ignore] async fn lexicon_delete_not_found() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router + .clone() .oneshot(admin_delete( "/admin/lexicons/nonexistent.lexicon", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -395,11 +418,11 @@ #[serial] #[ignore] async fn stats_empty_db() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router - .oneshot(admin_get("/admin/stats", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/stats", app.admin_cookie())) .await .unwrap(); @@ -414,7 +437,6 @@ #[serial] #[ignore] async fn stats_with_seeded_records() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let backend = app.state.db_backend; // Seed a lexicon so the stats query can join against it @@ -456,7 +478,8 @@ .unwrap(); let resp = app .router - .oneshot(admin_get("/admin/stats", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/stats", app.admin_cookie())) .await .unwrap(); @@ -476,7 +499,6 @@ #[serial] #[ignore] async fn backfill_create_job() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; // Register a record-type lexicon first (required by backfill validation). let lexicon_body = json!({ @@ -485,9 +507,10 @@ "backfill": true }); app.router .clone() + .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &lexicon_body, )) .await @@ -497,7 +520,8 @@ let body = json!({ "collection": "games.gamesgamesgamesgames.game" }); let resp = app .router - .oneshot(admin_post("/admin/backfill", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/backfill", app.admin_cookie(), &body)) .await .unwrap(); @@ -512,18 +536,23 @@ #[serial] #[ignore] async fn backfill_list_jobs() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; // Create a job first app.router .clone() - .oneshot(admin_post("/admin/backfill", &app.admin_token, &json!({}))) + .clone() + .oneshot(admin_post( + "/admin/backfill", + app.admin_cookie(), + &json!({}), + )) .await .unwrap(); let resp = app .router - .oneshot(admin_get("/admin/backfill/status", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/backfill/status", app.admin_cookie())) .await .unwrap(); @@ -541,12 +570,12 @@ #[serial] #[ignore] async fn admin_create_returns_did() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let body = json!({ "did": "did:plc:newadmin" }); let resp = app .router - .oneshot(admin_post("/admin/users", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/users", app.admin_cookie(), &body)) .await .unwrap(); @@ -561,7 +590,6 @@ #[serial] #[ignore] async fn admin_created_did_authenticates() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let new_did = "did:plc:newadmin2"; let body = json!({ "did": new_did }); @@ -569,16 +597,19 @@ // Create admin via the existing admin app.router .clone() - .oneshot(admin_post("/admin/users", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/users", app.admin_cookie(), &body)) .await .unwrap(); - // Now mock AIP returning the new DID for a different token - common::auth::mock_aip_userinfo(&app.mock_server, new_did).await; - + // Use cookie auth with the new DID let resp = app .router - .oneshot(admin_get("/admin/lexicons", "new-admin-token")) + .clone() + .oneshot(admin_get( + "/admin/lexicons", + common::auth::admin_cookie_header(new_did, &app.state.cookie_key), + )) .await .unwrap(); @@ -590,11 +621,12 @@ #[serial] #[ignore] async fn admin_list_returns_dids() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; + let cookie = app.admin_cookie(); let resp = app .router - .oneshot(admin_get("/admin/users", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/users", cookie)) .await .unwrap(); @@ -614,15 +646,15 @@ #[serial] #[ignore] async fn admin_delete_returns_204() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; // Create an admin to delete let resp = app .router .clone() + .clone() .oneshot(admin_post( "/admin/users", - &app.admin_token, + app.admin_cookie(), &json!({ "did": "did:plc:disposable" }), )) .await @@ -630,12 +662,11 @@ .unwrap(); let json = json_body(resp).await; let id = json["id"].as_str().unwrap(); + let cookie = app.admin_cookie(); let resp = app .router - .oneshot(admin_delete( - &format!("/admin/users/{id}"), - &app.admin_token, - )) + .clone() + .oneshot(admin_delete(&format!("/admin/users/{id}"), cookie)) .await .unwrap(); @@ -647,13 +678,14 @@ #[serial] #[ignore] async fn admin_delete_not_found() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; + let cookie = app.admin_cookie(); let resp = app .router + .clone() .oneshot(admin_delete( "/admin/users/00000000-0000-0000-0000-000000000000", - &app.admin_token, + cookie, )) .await .unwrap(); diff --git a/tests/e2e_labelers.rs b/tests/e2e_labelers.rs --- a/tests/e2e_labelers.rs +++ b/tests/e2e_labelers.rs @@ -9,7 +9,6 @@ use serial_test::serial; use tower::ServiceExt; use common::app::TestApp; -use common::auth::admin_auth_header; // --------------------------------------------------------------------------- // Helpers @@ -20,43 +19,53 @@ let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap() } -fn admin_get(uri: &str, token: &str) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { Request::builder() .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } -fn admin_post(uri: &str, token: &str, body: &Value) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_post( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { Request::builder() .method("POST") .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() } -fn admin_patch(uri: &str, token: &str, body: &Value) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_patch( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { Request::builder() .method("PATCH") .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() } -fn admin_delete(uri: &str, token: &str) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_delete( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { Request::builder() .method("DELETE") .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } @@ -70,13 +79,13 @@ #[serial] #[ignore] async fn labeler_add_returns_201() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let body = json!({ "did": "did:plc:labeler1" }); let resp = app .router - .oneshot(admin_post("/admin/labelers", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/labelers", app.admin_cookie(), &body)) .await .unwrap(); @@ -88,23 +97,24 @@ #[serial] #[ignore] async fn labeler_add_upsert_reactivates() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let body = json!({ "did": "did:plc:labeler1" }); // First add app.router .clone() - .oneshot(admin_post("/admin/labelers", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/labelers", app.admin_cookie(), &body)) .await .unwrap(); // Pause it app.router .clone() + .clone() .oneshot(admin_patch( "/admin/labelers/did:plc:labeler1", - &app.admin_token, + app.admin_cookie(), &json!({ "status": "paused" }), )) .await @@ -114,7 +124,8 @@ // Re-add (upsert should reactivate) let resp = app .router .clone() - .oneshot(admin_post("/admin/labelers", &app.admin_token, &body)) + .clone() + .oneshot(admin_post("/admin/labelers", app.admin_cookie(), &body)) .await .unwrap(); @@ -123,7 +134,8 @@ // Verify it's active again let resp = app .router - .oneshot(admin_get("/admin/labelers", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/labelers", app.admin_cookie())) .await .unwrap(); @@ -142,11 +154,11 @@ #[serial] #[ignore] async fn labeler_list_empty() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router - .oneshot(admin_get("/admin/labelers", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/labelers", app.admin_cookie())) .await .unwrap(); @@ -160,23 +172,24 @@ #[serial] #[ignore] async fn labeler_list_returns_added() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; app.router + .clone() .clone() .oneshot(admin_post( "/admin/labelers", - &app.admin_token, + app.admin_cookie(), &json!({ "did": "did:plc:lab1" }), )) .await .unwrap(); app.router + .clone() .clone() .oneshot(admin_post( "/admin/labelers", - &app.admin_token, + app.admin_cookie(), &json!({ "did": "did:plc:lab2" }), )) .await @@ -184,7 +197,8 @@ .unwrap(); let resp = app .router - .oneshot(admin_get("/admin/labelers", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/labelers", app.admin_cookie())) .await .unwrap(); @@ -206,13 +220,13 @@ #[serial] #[ignore] async fn labeler_update_status() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; app.router + .clone() .clone() .oneshot(admin_post( "/admin/labelers", - &app.admin_token, + app.admin_cookie(), &json!({ "did": "did:plc:lab1" }), )) .await @@ -220,10 +234,11 @@ .unwrap(); let resp = app .router + .clone() .clone() .oneshot(admin_patch( "/admin/labelers/did:plc:lab1", - &app.admin_token, + app.admin_cookie(), &json!({ "status": "paused" }), )) .await @@ -234,7 +249,8 @@ // Verify status changed let resp = app .router - .oneshot(admin_get("/admin/labelers", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/labelers", app.admin_cookie())) .await .unwrap(); @@ -248,13 +264,13 @@ #[serial] #[ignore] async fn labeler_update_not_found() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router + .clone() .oneshot(admin_patch( "/admin/labelers/did:plc:nonexistent", - &app.admin_token, + app.admin_cookie(), &json!({ "status": "paused" }), )) .await @@ -272,13 +288,13 @@ #[serial] #[ignore] async fn labeler_delete_returns_204() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; app.router + .clone() .clone() .oneshot(admin_post( "/admin/labelers", - &app.admin_token, + app.admin_cookie(), &json!({ "did": "did:plc:lab1" }), )) .await @@ -287,9 +303,10 @@ let resp = app .router .clone() + .clone() .oneshot(admin_delete( "/admin/labelers/did:plc:lab1", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -299,7 +316,8 @@ // Verify it's gone let resp = app .router - .oneshot(admin_get("/admin/labelers", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/labelers", app.admin_cookie())) .await .unwrap(); @@ -312,13 +330,13 @@ #[serial] #[ignore] async fn labeler_delete_not_found() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router + .clone() .oneshot(admin_delete( "/admin/labelers/did:plc:nonexistent", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -331,15 +349,15 @@ #[serial] #[ignore] async fn labeler_delete_removes_labels() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let backend = app.state.db_backend; // Add a labeler app.router + .clone() .clone() .oneshot(admin_post( "/admin/labelers", - &app.admin_token, + app.admin_cookie(), &json!({ "did": "did:plc:lab1" }), )) .await @@ -361,10 +379,11 @@ .unwrap(); // Delete the labeler app.router + .clone() .clone() .oneshot(admin_delete( "/admin/labelers/did:plc:lab1", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -392,6 +411,7 @@ let app = TestApp::new().await; let resp = app .router + .clone() .oneshot( Request::builder() .uri("/admin/labelers") diff --git a/tests/e2e_network_lexicons.rs b/tests/e2e_network_lexicons.rs --- a/tests/e2e_network_lexicons.rs +++ b/tests/e2e_network_lexicons.rs @@ -9,7 +9,6 @@ use serial_test::serial; use tower::ServiceExt; use common::app::TestApp; -use common::auth::admin_auth_header; use common::fixtures; // --------------------------------------------------------------------------- @@ -21,21 +20,25 @@ let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap() } -fn admin_get(uri: &str, token: &str) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { Request::builder() .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } -fn admin_delete(uri: &str, token: &str) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_delete( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { Request::builder() .method("DELETE") .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } @@ -72,11 +75,11 @@ #[serial] #[ignore] async fn network_lexicon_list_empty() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router - .oneshot(admin_get("/admin/network-lexicons", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/network-lexicons", app.admin_cookie())) .await .unwrap(); @@ -90,13 +93,13 @@ #[serial] #[ignore] async fn network_lexicon_list_returns_seeded() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; seed_network_lexicon(&app, "games.gamesgamesgamesgames.game", "did:plc:authority").await; let resp = app .router - .oneshot(admin_get("/admin/network-lexicons", &app.admin_token)) + .clone() + .oneshot(admin_get("/admin/network-lexicons", app.admin_cookie())) .await .unwrap(); @@ -113,7 +116,6 @@ #[serial] #[ignore] async fn network_lexicon_delete_removes_tracking_and_lexicon() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let backend = app.state.db_backend; let nsid = "games.gamesgamesgamesgames.game"; @@ -122,9 +124,10 @@ let resp = app .router .clone() + .clone() .oneshot(admin_delete( &format!("/admin/network-lexicons/{nsid}"), - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -149,13 +152,13 @@ #[serial] #[ignore] async fn network_lexicon_delete_not_found() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router + .clone() .oneshot(admin_delete( "/admin/network-lexicons/nonexistent.lexicon", - &app.admin_token, + app.admin_cookie(), )) .await .unwrap(); @@ -171,6 +174,7 @@ let app = TestApp::new().await; let resp = app .router + .clone() .oneshot( Request::builder() .uri("/admin/network-lexicons") diff --git a/tests/e2e_xrpc.rs b/tests/e2e_xrpc.rs --- a/tests/e2e_xrpc.rs +++ b/tests/e2e_xrpc.rs @@ -11,7 +11,6 @@ use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, ResponseTemplate}; use common::app::TestApp; -use common::auth::{admin_auth_header, mock_aip_userinfo}; use common::fixtures; // --------------------------------------------------------------------------- @@ -23,12 +22,15 @@ let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap() } -fn admin_post(uri: &str, token: &str, body: &Value) -> Request { - let (hname, hval) = admin_auth_header(token); +fn admin_post( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { Request::builder() .method("POST") .uri(uri) - .header(hname, hval) + .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() @@ -43,14 +45,13 @@ .unwrap() } async fn seed_lexicons(app: &TestApp) { - app.mock_admin_userinfo().await; - // Record lexicon app.router + .clone() .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": false @@ -62,9 +63,10 @@ // Query lexicon app.router .clone() + .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::list_games_query_lexicon(), "target_collection": "games.gamesgamesgamesgames.game" @@ -76,9 +78,10 @@ // Procedure lexicon app.router .clone() + .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::create_game_procedure_lexicon(), "target_collection": "games.gamesgamesgamesgames.game" @@ -120,6 +123,7 @@ let app = TestApp::new().await; let resp = app .router + .clone() .oneshot( Request::builder() .uri("/xrpc/app.bsky.actor.getProfile") @@ -139,9 +143,6 @@ async fn profile_with_mocked_services_returns_200() { let app = TestApp::new().await; let did = "did:plc:testuser"; - // Mock AIP userinfo - mock_aip_userinfo(&app.mock_server, did).await; - // Mock PLC directory Mock::given(method("GET")) .and(path(format!("/{did}"))) @@ -161,6 +162,7 @@ .await; let resp = app .router + .clone() .oneshot(authed_get("/xrpc/app.bsky.actor.getProfile", "valid-token")) .await .unwrap(); @@ -183,6 +185,7 @@ let app = TestApp::new().await; let resp = app .router + .clone() .oneshot( Request::builder() .uri("/xrpc/nonexistent.method") @@ -207,6 +210,7 @@ // game is a record, not a query let resp = app .router + .clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.game") @@ -243,6 +247,7 @@ .await; let resp = app .router + .clone() .oneshot( Request::builder() .uri(format!( @@ -269,7 +274,7 @@ let app = TestApp::new().await; seed_lexicons(&app).await; let resp = app - .router + .router.clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?uri=at%3A%2F%2Fdid%3Aplc%3Anone%2Fgames.gamesgamesgamesgames.game%2Fmissing") @@ -318,6 +323,7 @@ // Request with limit=2 let resp = app .router .clone() + .clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?limit=2") @@ -336,6 +342,7 @@ // Use cursor for next page let cursor = json["cursor"].as_str().unwrap(); let resp = app .router + .clone() .oneshot( Request::builder() .uri(format!( @@ -392,6 +399,7 @@ .await; let resp = app .router + .clone() .oneshot( Request::builder() .uri("/xrpc/games.gamesgamesgamesgames.listGames?did=did:plc:a") @@ -421,6 +429,7 @@ seed_lexicons(&app).await; let resp = app .router + .clone() .oneshot( Request::builder() .method("POST") @@ -442,16 +451,18 @@ async fn xrpc_post_non_procedure_returns_400() { let app = TestApp::new().await; seed_lexicons(&app).await; - // Mock AIP userinfo so auth passes - mock_aip_userinfo(&app.mock_server, "did:plc:test").await; + // Use cookie auth for did:plc:test so auth passes + let (cookie_name, cookie_val) = + common::auth::admin_cookie_header("did:plc:test", &app.state.cookie_key); let resp = app .router + .clone() .oneshot( Request::builder() .method("POST") .uri("/xrpc/games.gamesgamesgamesgames.listGames") - .header("authorization", "Bearer valid-token") + .header(cookie_name, cookie_val) .header("content-type", "application/json") .body(Body::from(b"{}".to_vec())) .unwrap(), @@ -468,16 +479,16 @@ #[ignore] async fn xrpc_delete_procedure_removes_record() { let app = TestApp::new().await; seed_lexicons(&app).await; - app.mock_admin_userinfo().await; let backend = app.state.db_backend; // Upload delete procedure lexicon with action: "delete" let resp = app .router .clone() + .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::delete_game_procedure_lexicon(), "target_collection": "games.gamesgamesgamesgames.game", @@ -503,8 +514,8 @@ .await .unwrap(); assert_eq!(count.0, 1); - // Mock AIP userinfo for the procedure call - mock_aip_userinfo(&app.mock_server, did).await; + // Use cookie auth for the procedure call + let (cookie_name, cookie_val) = common::auth::admin_cookie_header(did, &app.state.cookie_key); // Mock PLC directory for PDS resolution Mock::given(method("GET")) @@ -539,11 +550,12 @@ // setup in tests, but we verify the lexicon action was stored correctly. let _resp = app .router .clone() + .clone() .oneshot( Request::builder() .method("POST") .uri("/xrpc/games.gamesgamesgamesgames.deleteGame") - .header("authorization", "Bearer valid-token") + .header(cookie_name, cookie_val) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"uri": uri})).unwrap(), @@ -568,13 +580,13 @@ #[serial] #[ignore] async fn upload_lexicon_with_invalid_action_returns_400() { let app = TestApp::new().await; - app.mock_admin_userinfo().await; let resp = app .router + .clone() .oneshot(admin_post( "/admin/lexicons", - &app.admin_token, + app.admin_cookie(), &json!({ "lexicon_json": fixtures::create_game_procedure_lexicon(), "target_collection": "games.gamesgamesgamesgames.game", diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -1,5 +1,11 @@ mod common; +use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig}; +use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; +use atrium_oauth::{ + AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClientConfig, + OAuthResolverConfig, Scope, +}; use happyview::AppState; use happyview::config::Config; use happyview::db::{DatabaseBackend, adapt_sql, now_rfc3339}; @@ -15,8 +21,8 @@ host: "127.0.0.1".into(), port: 3000, database_url: String::new(), database_backend: backend, - aip_url: String::new(), - aip_public_url: String::new(), + public_url: String::new(), + session_secret: "test-secret".into(), tap_url: String::new(), tap_admin_password: None, relay_url: String::new(), @@ -26,6 +32,32 @@ event_log_retention_days: 30, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); + let atrium_http = std::sync::Arc::new(DefaultHttpClient::default()); + let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }); + let handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { + dns_txt_resolver: happyview::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }); + let oauth_pool = db::test_pool().await; + let oauth = atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![Scope::Known(KnownScope::Atproto)]), + }, + keys: None, + state_store: happyview::auth::oauth_store::DbStateStore::new(oauth_pool.clone(), backend), + session_store: happyview::auth::oauth_store::DbSessionStore::new(oauth_pool, backend), + resolver: OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); AppState { config, http: reqwest::Client::new(), @@ -45,6 +77,8 @@ default_proxy_cost: 1, }, vec![], ), + oauth: std::sync::Arc::new(oauth), + cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), } } diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -1,5 +1,11 @@ mod common; +use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig}; +use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; +use atrium_oauth::{ + AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClientConfig, + OAuthResolverConfig, Scope, +}; use happyview::AppState; use happyview::config::Config; use happyview::db::{DatabaseBackend, adapt_sql, now_rfc3339}; @@ -18,8 +24,8 @@ host: "127.0.0.1".into(), port: 3000, database_url: String::new(), database_backend: backend, - aip_url: String::new(), - aip_public_url: String::new(), + public_url: String::new(), + session_secret: "test-secret".into(), tap_url: String::new(), tap_admin_password: None, relay_url: String::new(), @@ -29,6 +35,32 @@ event_log_retention_days: 30, }; let (tx, _) = watch::channel(vec![]); let (labeler_tx, _) = watch::channel(()); + let atrium_http = std::sync::Arc::new(DefaultHttpClient::default()); + let did_resolver = CommonDidResolver::new(CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }); + let handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { + dns_txt_resolver: happyview::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }); + let oauth_pool = db::test_pool().await; + let oauth = atrium_oauth::OAuthClient::new(OAuthClientConfig { + client_metadata: AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![Scope::Known(KnownScope::Atproto)]), + }, + keys: None, + state_store: happyview::auth::oauth_store::DbStateStore::new(oauth_pool.clone(), backend), + session_store: happyview::auth::oauth_store::DbSessionStore::new(oauth_pool, backend), + resolver: OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); AppState { config, http: reqwest::Client::new(), @@ -48,6 +80,8 @@ default_proxy_cost: 1, }, vec![], ), + oauth: std::sync::Arc::new(oauth), + cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), } } diff --git a/web/next.config.ts b/web/next.config.ts --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,7 +1,6 @@ import type { NextConfig } from "next"; const apiBase = process.env.API_URL || "http://localhost:3000"; -const aipBase = process.env.AIP_PROXY_URL || "http://localhost:8080"; const nextConfig: NextConfig = { reactCompiler: true, @@ -17,10 +16,11 @@ // beforeFiles rewrites run before the trailingSlash redirect, // preventing 308s on API fetch calls. beforeFiles: [ { source: "/admin/:path*", destination: `${apiBase}/admin/:path*` }, + { source: "/auth/:path*", destination: `${apiBase}/auth/:path*` }, { source: "/xrpc/:path*", destination: `${apiBase}/xrpc/:path*` }, { source: "/health", destination: `${apiBase}/health` }, - { source: "/aip/:path*", destination: `${aipBase}/:path*` }, { source: "/config", destination: `${apiBase}/config` }, + { source: "/oauth/:path*", destination: `${apiBase}/oauth/:path*` }, ], afterFiles: [], fallback: [], diff --git a/web/src/app/dashboard/backfill/page.tsx b/web/src/app/dashboard/backfill/page.tsx --- a/web/src/app/dashboard/backfill/page.tsx +++ b/web/src/app/dashboard/backfill/page.tsx @@ -2,7 +2,6 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { createBackfillJob, @@ -50,22 +49,21 @@ TableRow, } from "@/components/ui/table"; export default function BackfillPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const [jobs, setJobs] = useState([]); const [tapStats, setTapStats] = useState(null); const [error, setError] = useState(null); const load = useCallback(() => { - getBackfillJobs(getToken) + getBackfillJobs() .then(setJobs) .catch((e) => setError(e.message)); if (hasPermission("stats:read")) { - getTapStats(getToken) + getTapStats() .then(setTapStats) .catch(() => setTapStats(null)); } - }, [getToken, hasPermission]); + }, [hasPermission]); useEffect(() => { load(); @@ -115,7 +113,7 @@

Backfill Jobs

{hasPermission("backfill:create") && ( - + )}
@@ -177,10 +175,8 @@ ); } function CreateDialog({ - getToken, onSuccess, }: { - getToken: () => Promise; onSuccess: () => void; }) { const [collection, setCollection] = useState(null); @@ -191,7 +187,7 @@ const [recordLexicons, setRecordLexicons] = useState([]); useEffect(() => { if (open) { - getLexicons(getToken) + getLexicons() .then((lexicons) => setRecordLexicons( lexicons @@ -202,12 +198,12 @@ ), ) .catch(() => {}); } - }, [open, getToken]); + }, [open]); async function handleCreate() { setError(null); try { - await createBackfillJob(getToken, { + await createBackfillJob({ collection: collection || undefined, did: did || undefined, }); diff --git a/web/src/app/dashboard/lexicons/page.tsx b/web/src/app/dashboard/lexicons/page.tsx --- a/web/src/app/dashboard/lexicons/page.tsx +++ b/web/src/app/dashboard/lexicons/page.tsx @@ -18,7 +18,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { deleteLexicon, @@ -35,17 +34,16 @@ import { Button } from "@/components/ui/button"; import { Eye, Rows3, Trash2 } from "lucide-react"; export default function LexiconsPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const router = useRouter(); const [lexicons, setLexicons] = useState([]); const [error, setError] = useState(null); const load = useCallback(() => { - getLexicons(getToken) + getLexicons() .then(setLexicons) .catch((e) => setError(e.message)); - }, [getToken]); + }, []); useEffect(() => { load(); @@ -54,9 +52,9 @@ async function handleDelete(lex: LexiconSummary) { try { if (lex.source === "network") { - await deleteNetworkLexicon(getToken, lex.id); + await deleteNetworkLexicon(lex.id); } else { - await deleteLexicon(getToken, lex.id); + await deleteLexicon(lex.id); } load(); } catch (e: unknown) { @@ -246,7 +244,7 @@ enableHiding: false, }, ], // eslint-disable-next-line react-hooks/exhaustive-deps - [getToken, hasPermission], + [hasPermission], ); const [sorting, setSorting] = useState([ diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -2,7 +2,6 @@ "use client"; import { useEffect, useState } from "react"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { getStats } from "@/lib/api"; import type { StatsResponse } from "@/types/stats"; @@ -23,7 +22,6 @@ TableRow, } from "@/components/ui/table"; export default function DashboardPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const [stats, setStats] = useState(null); const [error, setError] = useState(null); @@ -31,10 +29,10 @@ const canReadStats = hasPermission("stats:read"); useEffect(() => { if (!canReadStats) return; - getStats(getToken) + getStats() .then(setStats) .catch((e) => setError(e.message)); - }, [getToken, canReadStats]); + }, [canReadStats]); return ( <> diff --git a/web/src/app/dashboard/records/page.tsx b/web/src/app/dashboard/records/page.tsx --- a/web/src/app/dashboard/records/page.tsx +++ b/web/src/app/dashboard/records/page.tsx @@ -10,7 +10,6 @@ useReactTable, } from "@tanstack/react-table"; import { useSearchParams } from "next/navigation"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { getStats, @@ -74,7 +73,6 @@ return JSON.stringify(value); } export default function RecordsPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const searchParams = useSearchParams(); const initialCollection = searchParams.get("collection") ?? ""; @@ -100,10 +98,10 @@ const [columnVisibility, setColumnVisibility] = useState({}); const [rowSelection, setRowSelection] = useState({}); useEffect(() => { - getStats(getToken) + getStats() .then((stats) => setCollections(stats.collections)) .catch((e) => setError(e.message)); - }, [getToken]); + }, []); // Auto-select collection from URL search param on initial load. useEffect(() => { @@ -121,7 +119,7 @@ async (collection: string, cursor?: string) => { setLoading(true); setError(null); try { - const data = await getAdminRecords(getToken, collection, 20, cursor); + const data = await getAdminRecords(collection, 20, cursor); setRecords(data.records); setNextCursor(data.cursor); } catch (e: unknown) { @@ -132,14 +130,14 @@ } finally { setLoading(false); } }, - [getToken], + [], ); const handleDeleteRecord = useCallback( async (uri: string) => { setDeleting(true); try { - await deleteRecord(getToken, uri); + await deleteRecord(uri); setDeleteUri(null); setViewRecord(null); if (selectedCollection) { @@ -155,20 +153,20 @@ } finally { setDeleting(false); } }, - [getToken, selectedCollection, cursorStack, fetchRecords], + [selectedCollection, cursorStack, fetchRecords], ); const handleDeleteAll = useCallback(async () => { if (!selectedCollection) return; setDeletingAll(true); try { - await deleteCollectionRecords(getToken, selectedCollection); + await deleteCollectionRecords(selectedCollection); setBulkDeleteOpen(false); setBulkDeleteMode("selected"); setBulkDeleteConfirm(""); setRowSelection({}); // Refresh stats and records - const stats = await getStats(getToken); + const stats = await getStats(); setCollections(stats.collections); setCursorStack([]); setNextCursor(undefined); @@ -179,14 +177,14 @@ setError(e instanceof Error ? e.message : String(e)); } finally { setDeletingAll(false); } - }, [getToken, selectedCollection]); + }, [selectedCollection]); const handleBulkDelete = useCallback(async () => { setDeleting(true); try { const selectedUris = Object.keys(rowSelection); for (const uri of selectedUris) { - await deleteRecord(getToken, uri); + await deleteRecord(uri); } setRowSelection({}); setBulkDeleteOpen(false); @@ -202,7 +200,7 @@ setError(e instanceof Error ? e.message : String(e)); } finally { setDeleting(false); } - }, [getToken, rowSelection, selectedCollection, cursorStack, fetchRecords]); + }, [rowSelection, selectedCollection, cursorStack, fetchRecords]); // Build columns dynamically from the union of all record keys const columns = useMemo[]>(() => { diff --git a/web/src/app/dashboard/settings/api-keys/page.tsx b/web/src/app/dashboard/settings/api-keys/page.tsx --- a/web/src/app/dashboard/settings/api-keys/page.tsx +++ b/web/src/app/dashboard/settings/api-keys/page.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from "react"; import { Copy, Check } from "lucide-react"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { getApiKeys, @@ -54,16 +53,15 @@ const ALL_PERMISSIONS = Object.values(PERMISSION_CATEGORIES).flat(); export default function ApiKeysPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const [keys, setKeys] = useState([]); const [error, setError] = useState(null); const load = useCallback(() => { - getApiKeys(getToken) + getApiKeys() .then(setKeys) .catch((e) => setError(e.message)); - }, [getToken]); + }, []); useEffect(() => { load(); @@ -71,7 +69,7 @@ }, [load]); async function handleRevoke(id: string) { try { - await revokeApiKey(getToken, id); + await revokeApiKey(id); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -87,7 +85,7 @@

API Keys

{hasPermission("api-keys:create") && ( - + )}
@@ -187,10 +185,8 @@ ); } function CreateApiKeyDialog({ - getToken, onSuccess, }: { - getToken: () => Promise; onSuccess: () => void; }) { const [name, setName] = useState(""); @@ -237,7 +233,7 @@ async function handleCreate() { setError(null); try { - const result = await createApiKey(getToken, { + const result = await createApiKey({ name, permissions: selectedPermissions, }); diff --git a/web/src/app/dashboard/settings/env-variables/page.tsx b/web/src/app/dashboard/settings/env-variables/page.tsx --- a/web/src/app/dashboard/settings/env-variables/page.tsx +++ b/web/src/app/dashboard/settings/env-variables/page.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from "react"; import { Trash2, Pencil } from "lucide-react"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { getScriptVariables, @@ -36,16 +35,15 @@ TableRow, } from "@/components/ui/table"; export default function EnvVariablesPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const [vars, setVars] = useState([]); const [error, setError] = useState(null); const load = useCallback(() => { - getScriptVariables(getToken) + getScriptVariables() .then(setVars) .catch((e) => setError(e.message)); - }, [getToken]); + }, []); useEffect(() => { load(); @@ -53,7 +51,7 @@ }, [load]); async function handleDeleteVar(key: string) { try { - await deleteScriptVariable(getToken, key); + await deleteScriptVariable(key); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -75,7 +73,7 @@ env global table.

{hasPermission("script-variables:create") && ( - + )} @@ -114,7 +112,6 @@
@@ -143,11 +140,9 @@ ); } function UpsertVariableDialog({ - getToken, onSuccess, editKey, }: { - getToken: () => Promise; onSuccess: () => void; editKey?: string; }) { @@ -161,7 +156,7 @@ async function handleSave() { setError(null); try { - await upsertScriptVariable(getToken, { + await upsertScriptVariable({ key: isEdit ? editKey : key, value, }); diff --git a/web/src/app/dashboard/settings/labelers/page.tsx b/web/src/app/dashboard/settings/labelers/page.tsx --- a/web/src/app/dashboard/settings/labelers/page.tsx +++ b/web/src/app/dashboard/settings/labelers/page.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from "react"; import { Trash2, Pause, Play } from "lucide-react"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { getLabelers, @@ -37,7 +36,6 @@ TableRow, } from "@/components/ui/table"; export default function LabelersPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const [labelers, setLabelers] = useState([]); const [handles, setHandles] = useState>({}); @@ -46,10 +44,10 @@ const [deleteDid, setDeleteDid] = useState(null); const [deleting, setDeleting] = useState(false); const load = useCallback(() => { - getLabelers(getToken) + getLabelers() .then(setLabelers) .catch((e) => setError(e.message)); - }, [getToken]); + }, []); useEffect(() => { load(); @@ -78,7 +76,7 @@ async function handleToggleStatus(labeler: LabelerSummary) { try { const newStatus = labeler.status === "active" ? "paused" : "active"; - await updateLabeler(getToken, labeler.did, { status: newStatus }); + await updateLabeler(labeler.did, { status: newStatus }); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -88,7 +86,7 @@ async function handleDelete(did: string) { setDeleting(true); try { - await deleteLabeler(getToken, did); + await deleteLabeler(did); setDeleteDid(null); load(); } catch (e: unknown) { @@ -112,7 +110,7 @@ Manage external labeler services that provide content labels.

{hasPermission("labelers:create") && ( - + )} @@ -252,10 +250,8 @@ ); } function AddLabelerDialog({ - getToken, onSuccess, }: { - getToken: () => Promise; onSuccess: () => void; }) { const [did, setDid] = useState(""); @@ -265,7 +261,7 @@ async function handleAdd() { setError(null); try { - await addLabeler(getToken, { did }); + await addLabeler({ did }); setDid(""); setOpen(false); onSuccess(); diff --git a/web/src/app/dashboard/settings/rate-limits/page.tsx b/web/src/app/dashboard/settings/rate-limits/page.tsx --- a/web/src/app/dashboard/settings/rate-limits/page.tsx +++ b/web/src/app/dashboard/settings/rate-limits/page.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from "react"; import { Trash2 } from "lucide-react"; -import { useAuth } from "@/lib/auth-context"; import { useCurrentUser } from "@/hooks/use-current-user"; import { getRateLimits, @@ -38,7 +37,6 @@ TableRow, } from "@/components/ui/table"; export default function RateLimitsPage() { - const { getToken } = useAuth(); const { hasPermission } = useCurrentUser(); const [enabled, setEnabled] = useState(false); const [capacity, setCapacity] = useState(""); @@ -59,7 +57,7 @@ const [origProcedureCost, setOrigProcedureCost] = useState(""); const [origProxyCost, setOrigProxyCost] = useState(""); const load = useCallback(() => { - getRateLimits(getToken) + getRateLimits() .then((data) => { setEnabled(data.enabled); setCapacity(String(data.capacity)); @@ -75,7 +73,7 @@ setOrigProxyCost(String(data.default_proxy_cost)); setAllowlist(data.allowlist); }) .catch((e) => setError(e.message)); - }, [getToken]); + }, []); useEffect(() => { load(); @@ -91,7 +89,7 @@ async function handleToggleEnabled(checked: boolean) { setToggling(true); try { - await setRateLimitEnabled(getToken, { enabled: checked }); + await setRateLimitEnabled({ enabled: checked }); setEnabled(checked); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -117,7 +115,7 @@ return; } setSaving(true); try { - await upsertRateLimit(getToken, { + await upsertRateLimit({ capacity: cap, refill_rate: rate, default_query_cost: qc || 1, @@ -134,7 +132,7 @@ } async function handleRemoveAllowlistEntry(id: number) { try { - await removeAllowlistEntry(getToken, id); + await removeAllowlistEntry(id); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -249,7 +247,7 @@ IPs or CIDRs that bypass rate limiting.

{canEdit && ( - + )} @@ -306,10 +304,8 @@ ); } function AddAllowlistDialog({ - getToken, onSuccess, }: { - getToken: () => Promise; onSuccess: () => void; }) { const [cidr, setCidr] = useState(""); @@ -322,7 +318,7 @@ setError(null); try { const body: { cidr: string; note?: string } = { cidr: cidr.trim() }; if (note.trim()) body.note = note.trim(); - await addAllowlistEntry(getToken, body); + await addAllowlistEntry(body); setCidr(""); setNote(""); setOpen(false); diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -76,7 +76,7 @@ full_access: ALL_PERMISSIONS, }; export default function UsersPage() { - const { getToken, did: currentDid } = useAuth(); + const { did: currentDid } = useAuth(); const [users, setUsers] = useState([]); const [handles, setHandles] = useState>({}); const [error, setError] = useState(null); @@ -86,10 +86,10 @@ const currentUser = users.find((u) => u.did === currentDid); const isCurrentUserSuper = currentUser?.is_super ?? false; const load = useCallback(() => { - getUsers(getToken) + getUsers() .then(setUsers) .catch((e) => setError(e instanceof Error ? e.message : String(e))); - }, [getToken]); + }, [did]); useEffect(() => { load(); @@ -117,7 +117,7 @@ }, [users, handles]); async function handleDelete(id: string) { try { - await deleteUser(getToken, id); + await deleteUser(id); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -167,7 +167,7 @@ try { const body: { grant?: string[]; revoke?: string[] } = {}; if (grant.length > 0) body.grant = grant; if (revoke.length > 0) body.revoke = revoke; - await updateUserPermissions(getToken, user.id, body); + await updateUserPermissions(user.id, body); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -176,7 +176,7 @@ } async function handleTransferSuper(targetUserId: string) { try { - await transferSuper(getToken, { target_user_id: targetUserId }); + await transferSuper({ target_user_id: targetUserId }); load(); } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)); @@ -192,7 +192,7 @@

Users

{(isCurrentUserSuper || currentUser?.permissions.includes("users:create")) && ( - + )}
@@ -455,10 +455,8 @@ ); } function AddUserDialog({ - getToken, onSuccess, }: { - getToken: () => Promise; onSuccess: () => void; }) { const [did, setDid] = useState(""); @@ -471,7 +469,7 @@ setError(null); try { const body: { did: string; template?: string } = { did }; if (template) body.template = template; - await addUser(getToken, body); + await addUser(body); setDid(""); setTemplate(""); setOpen(false); diff --git a/web/src/hooks/use-current-user.ts b/web/src/hooks/use-current-user.ts --- a/web/src/hooks/use-current-user.ts +++ b/web/src/hooks/use-current-user.ts @@ -5,14 +5,14 @@ import { getUsers } from "@/lib/api"; import type { UserSummary } from "@/types/users"; export function useCurrentUser() { - const { getToken, did } = useAuth(); + const { did } = useAuth(); const [currentUser, setCurrentUser] = useState(null); const load = useCallback(() => { - getUsers(getToken) + getUsers() .then((users) => setCurrentUser(users.find((u) => u.did === did) ?? null)) .catch(() => setCurrentUser(null)); - }, [getToken, did]); + }, [did]); useEffect(() => { load(); diff --git a/web/src/hooks/use-lua-completions.ts b/web/src/hooks/use-lua-completions.ts --- a/web/src/hooks/use-lua-completions.ts +++ b/web/src/hooks/use-lua-completions.ts @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import { useAuth } from "@/lib/auth-context"; import { getLexicon, getLexicons } from "@/lib/api"; import { buildCollectionSchemas, @@ -17,13 +16,12 @@ export function useLuaCompletions(jsonText: string): { luaCompletions: LuaCompletions; collections: string[]; } { - const { getToken } = useAuth(); const [collections, setCollections] = useState([]); const [collectionSchemas, setCollectionSchemas] = useState({}); useEffect(() => { - getLexicons(getToken).then(async (lexicons) => { + getLexicons().then(async (lexicons) => { const records = lexicons.filter((l) => l.lexicon_type === "record"); setCollections(records.map((l) => l.id)); @@ -31,14 +29,14 @@ // Fetch individual details to get full lexicon_json for schema extraction const details = []; for (const rec of records) { try { - details.push(await getLexicon(getToken, rec.id)); + details.push(await getLexicon(rec.id)); } catch { // skip failed fetches } } setCollectionSchemas(buildCollectionSchemas(details)); }); - }, [getToken]); + }, []); const luaCompletions = useMemo(() => { const completions = extractLuaCompletions(jsonText); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,3 @@ -import { createDpopProof, setDpopNonce } from "./dpop" - import type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys" import type { StatsResponse } from "@/types/stats" import type { LexiconSummary, LexiconDetail } from "@/types/lexicons" @@ -27,12 +25,6 @@ export type { LabelerSummary } from "@/types/labelers" export type { RecordLabel } from "@/types/records" export type { AllowlistEntry, RateLimitsResponse } from "@/types/rate-limits" -// The DPoP proof for admin API calls must target AIP's userinfo URL, -// because the backend forwards the proof to AIP for token validation. -// Set at runtime via ConfigProvider. -let aipUrl = "" -export function setAipUrl(url: string) { aipUrl = url } - export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -43,21 +35,9 @@ } async function apiFetch( path: string, - getToken: () => Promise, options?: RequestInit, - dpopNonce?: string ): Promise { - const token = await getToken() - if (!token) throw new ApiError(401, "Not authenticated") - - // Proof targets AIP's userinfo endpoint (GET) since the backend - // forwards it there for token validation. - const dpopProof = await createDpopProof("GET", `${aipUrl}/oauth/userinfo`, token, dpopNonce) - - const headers: Record = { - Authorization: `DPoP ${token}`, - DPoP: dpopProof, - } + const headers: Record = {} if ( options?.method === "POST" || options?.method === "PUT" || @@ -69,23 +49,9 @@ const res = await fetch(path, { ...options, headers: { ...headers, ...options?.headers }, + credentials: "same-origin", }) - // If AIP requires a DPoP nonce, the backend relays it via both - // the dpop-nonce response header and the JSON body. Retry once. - if (res.status === 401 && !dpopNonce) { - const text = await res.text().catch(() => "") - let nonce = res.headers.get("dpop-nonce") - if (!nonce) { - try { nonce = JSON.parse(text).dpop_nonce } catch { /* not JSON */ } - } - if (nonce) { - setDpopNonce(nonce) - return apiFetch(path, getToken, options, nonce) - } - throw new ApiError(res.status, text) - } - if (!res.ok) { const text = await res.text().catch(() => res.statusText) throw new ApiError(res.status, text) @@ -97,24 +63,22 @@ return JSON.parse(text) } // Stats -export function getStats(getToken: () => Promise) { - return apiFetch("/admin/stats", getToken) +export function getStats() { + return apiFetch("/admin/stats") } // Lexicons -export function getLexicons(getToken: () => Promise) { - return apiFetch("/admin/lexicons", getToken) +export function getLexicons() { + return apiFetch("/admin/lexicons") } -export function getLexicon(getToken: () => Promise, id: string) { +export function getLexicon(id: string) { return apiFetch( `/admin/lexicons/${encodeURIComponent(id)}`, - getToken ) } export function uploadLexicon( - getToken: () => Promise, body: { lexicon_json: unknown backfill?: boolean @@ -125,128 +89,119 @@ index_hook?: string token_cost?: number | null } ) { - return apiFetch<{ id: string; revision: number }>("/admin/lexicons", getToken, { + return apiFetch<{ id: string; revision: number }>("/admin/lexicons", { method: "POST", body: JSON.stringify(body), }) } -export function deleteLexicon(getToken: () => Promise, id: string) { - return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, getToken, { +export function deleteLexicon(id: string) { + return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, { method: "DELETE", }) } // Network Lexicons -export function getNetworkLexicons(getToken: () => Promise) { - return apiFetch("/admin/network-lexicons", getToken) +export function getNetworkLexicons() { + return apiFetch("/admin/network-lexicons") } export function addNetworkLexicon( - getToken: () => Promise, body: { nsid: string; target_collection?: string } ) { return apiFetch<{ nsid: string; authority_did: string; revision: number }>( "/admin/network-lexicons", - getToken, { method: "POST", body: JSON.stringify(body) } ) } export function deleteNetworkLexicon( - getToken: () => Promise, nsid: string ) { return apiFetch( `/admin/network-lexicons/${encodeURIComponent(nsid)}`, - getToken, { method: "DELETE" } ) } // Tap Stats -export function getTapStats(getToken: () => Promise) { - return apiFetch("/admin/tap/stats", getToken) +export function getTapStats() { + return apiFetch("/admin/tap/stats") } // Backfill -export function getBackfillJobs(getToken: () => Promise) { - return apiFetch("/admin/backfill/status", getToken) +export function getBackfillJobs() { + return apiFetch("/admin/backfill/status") } export function createBackfillJob( - getToken: () => Promise, body: { collection?: string; did?: string } ) { - return apiFetch<{ id: string; status: string }>("/admin/backfill", getToken, { + return apiFetch<{ id: string; status: string }>("/admin/backfill", { method: "POST", body: JSON.stringify(body), }) } // Users -export function getUsers(getToken: () => Promise) { - return apiFetch("/admin/users", getToken) +export function getUsers() { + return apiFetch("/admin/users") } -export function getUser(getToken: () => Promise, id: string) { - return apiFetch(`/admin/users/${encodeURIComponent(id)}`, getToken) +export function getUser(id: string) { + return apiFetch(`/admin/users/${encodeURIComponent(id)}`) } export function addUser( - getToken: () => Promise, body: { did: string; template?: string; permissions?: string[] } ) { - return apiFetch<{ id: string; did: string }>("/admin/users", getToken, { + return apiFetch<{ id: string; did: string }>("/admin/users", { method: "POST", body: JSON.stringify(body), }) } -export function deleteUser(getToken: () => Promise, id: string) { - return apiFetch(`/admin/users/${encodeURIComponent(id)}`, getToken, { +export function deleteUser(id: string) { + return apiFetch(`/admin/users/${encodeURIComponent(id)}`, { method: "DELETE", }) } export function updateUserPermissions( - getToken: () => Promise, id: string, body: { grant?: string[]; revoke?: string[] } ) { - return apiFetch(`/admin/users/${encodeURIComponent(id)}/permissions`, getToken, { + return apiFetch(`/admin/users/${encodeURIComponent(id)}/permissions`, { method: "PATCH", body: JSON.stringify(body), }) } export function transferSuper( - getToken: () => Promise, body: { target_user_id: string } ) { - return apiFetch("/admin/users/transfer-super", getToken, { + return apiFetch("/admin/users/transfer-super", { method: "POST", body: JSON.stringify(body), }) } // API Keys -export function getApiKeys(getToken: () => Promise) { - return apiFetch("/admin/api-keys", getToken) +export function getApiKeys() { + return apiFetch("/admin/api-keys") } export function createApiKey( - getToken: () => Promise, body: { name: string; permissions: string[] } ) { - return apiFetch("/admin/api-keys", getToken, { + return apiFetch("/admin/api-keys", { method: "POST", body: JSON.stringify(body), }) } -export function revokeApiKey(getToken: () => Promise, id: string) { - return apiFetch(`/admin/api-keys/${encodeURIComponent(id)}`, getToken, { +export function revokeApiKey(id: string) { + return apiFetch(`/admin/api-keys/${encodeURIComponent(id)}`, { method: "DELETE", }) } @@ -267,7 +222,6 @@ } // Admin records browsing export function getAdminRecords( - getToken: () => Promise, collection: string, limit?: number, cursor?: string @@ -277,100 +231,88 @@ if (limit) params.set("limit", String(limit)) if (cursor) params.set("cursor", cursor) return apiFetch( `/admin/records?${params}`, - getToken ) } export function deleteRecord( - getToken: () => Promise, uri: string ) { return apiFetch( `/admin/records?${new URLSearchParams({ uri })}`, - getToken, { method: "DELETE" } ) } export function deleteCollectionRecords( - getToken: () => Promise, collection: string, ) { return apiFetch<{ deleted: number }>( `/admin/records/collection?${new URLSearchParams({ collection })}`, - getToken, { method: "DELETE" }, ) } // Script Variables -export function getScriptVariables(getToken: () => Promise) { - return apiFetch("/admin/script-variables", getToken) +export function getScriptVariables() { + return apiFetch("/admin/script-variables") } export function upsertScriptVariable( - getToken: () => Promise, body: { key: string; value: string } ) { - return apiFetch("/admin/script-variables", getToken, { + return apiFetch("/admin/script-variables", { method: "POST", body: JSON.stringify(body), }) } export function deleteScriptVariable( - getToken: () => Promise, key: string ) { return apiFetch( `/admin/script-variables/${encodeURIComponent(key)}`, - getToken, { method: "DELETE" } ) } // Labelers -export function getLabelers(getToken: () => Promise) { - return apiFetch("/admin/labelers", getToken) +export function getLabelers() { + return apiFetch("/admin/labelers") } export function addLabeler( - getToken: () => Promise, body: { did: string } ) { - return apiFetch("/admin/labelers", getToken, { + return apiFetch("/admin/labelers", { method: "POST", body: JSON.stringify(body), }) } export function updateLabeler( - getToken: () => Promise, did: string, body: { status: string } ) { - return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, getToken, { + return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, { method: "PATCH", body: JSON.stringify(body), }) } export function deleteLabeler( - getToken: () => Promise, did: string ) { - return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, getToken, { + return apiFetch(`/admin/labelers/${encodeURIComponent(did)}`, { method: "DELETE", }) } // Rate Limits -export function getRateLimits(getToken: () => Promise) { - return apiFetch("/admin/rate-limits", getToken) +export function getRateLimits() { + return apiFetch("/admin/rate-limits") } export function upsertRateLimit( - getToken: () => Promise, body: { capacity: number refill_rate: number @@ -379,44 +321,40 @@ default_procedure_cost: number default_proxy_cost: number } ) { - return apiFetch("/admin/rate-limits", getToken, { + return apiFetch("/admin/rate-limits", { method: "POST", body: JSON.stringify(body), }) } export function setRateLimitEnabled( - getToken: () => Promise, body: { enabled: boolean } ) { - return apiFetch("/admin/rate-limits/enabled", getToken, { + return apiFetch("/admin/rate-limits/enabled", { method: "PUT", body: JSON.stringify(body), }) } export function addAllowlistEntry( - getToken: () => Promise, body: { cidr: string; note?: string } ) { - return apiFetch("/admin/rate-limits/allowlist", getToken, { + return apiFetch("/admin/rate-limits/allowlist", { method: "POST", body: JSON.stringify(body), }) } export function removeAllowlistEntry( - getToken: () => Promise, id: number ) { - return apiFetch(`/admin/rate-limits/allowlist/${encodeURIComponent(id)}`, getToken, { + return apiFetch(`/admin/rate-limits/allowlist/${encodeURIComponent(id)}`, { method: "DELETE", }) } // Event Logs export function getEvents( - getToken: () => Promise, params?: { category?: string severity?: string @@ -434,6 +372,5 @@ if (params?.limit) searchParams.set("limit", String(params.limit)) const qs = searchParams.toString() return apiFetch( `/admin/events${qs ? `?${qs}` : ""}`, - getToken ) } diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -8,12 +8,8 @@ useEffect, useState, } from "react" -import { clearDpopKeypair, createDpopProof, ensureDpopKeypair, setDpopNonce } from "./dpop" -import { useConfig } from "./config-context" - interface AuthContextType { did: string | null - getToken: () => Promise login: (handle: string) => Promise logout: () => Promise loading: boolean @@ -22,68 +18,13 @@ } const AuthContext = createContext({ did: null, - getToken: async () => null, login: async () => {}, logout: async () => {}, loading: true, error: null, }) -// PKCE helpers - -function base64urlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - let binary = "" - for (const b of bytes) binary += String.fromCharCode(b) - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") -} - -function generateRandomString(byteLength: number): string { - const array = new Uint8Array(byteLength) - crypto.getRandomValues(array) - return base64urlEncode(array.buffer as ArrayBuffer) -} - -async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder() - const hash = await crypto.subtle.digest("SHA-256", encoder.encode(verifier)) - return base64urlEncode(hash) -} - -// Dynamic client registration with AIP. -// Caches the client_id in localStorage so we only register once. -async function getOrRegisterClient(aipUrl: string, redirectUri: string): Promise { - const cacheKey = `oauth_client_id:${aipUrl}:${redirectUri}` - const cached = localStorage.getItem(cacheKey) - if (cached) return cached - - const resp = await fetch("/aip/oauth/clients/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - redirect_uris: [redirectUri], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - application_type: "native", - client_name: "HappyView Admin", - }), - }) - - if (!resp.ok) { - const text = await resp.text() - throw new Error(`Client registration failed: ${text}`) - } - - const data = await resp.json() - const clientId: string = data.client_id - localStorage.setItem(cacheKey, clientId) - return clientId -} - export function AuthProvider({ children }: { children: React.ReactNode }) { - const { aip_url } = useConfig() - const [accessToken, setAccessToken] = useState(null) const [did, setDid] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -99,44 +40,17 @@ let cancelled = false async function init() { try { - const params = new URLSearchParams(window.location.search) - const code = params.get("code") - const state = params.get("state") - - if (code && state) { - console.log("[auth] OAuth callback detected, exchanging code") - await handleOAuthCallback(aip_url, code, state, cancelled, { - setAccessToken, - setDid, - }) - } else { - // Restore session from storage - const savedToken = sessionStorage.getItem("oauth_access_token") - const savedDid = sessionStorage.getItem("oauth_did") - const savedDpopKey = sessionStorage.getItem("dpop_private_jwk") - - console.log("[auth] Session restore check:", { - hasToken: !!savedToken, - hasDid: !!savedDid, - hasDpopKey: !!savedDpopKey, - }) - - if (savedToken && !savedDpopKey) { - console.log("[auth] Clearing pre-DPoP session") - sessionStorage.removeItem("oauth_access_token") - sessionStorage.removeItem("oauth_did") - sessionStorage.removeItem("oauth_client_id") - } else if (savedToken && savedDid && !cancelled) { - console.log("[auth] Restoring session from storage") - setAccessToken(savedToken) - setDid(savedDid) - } else { - console.log("[auth] No session to restore") + // Check if the user has a valid session cookie + const resp = await fetch("/auth/me", { credentials: "same-origin" }) + if (resp.ok) { + const data = await resp.json() + if (!cancelled && data.did) { + setDid(data.did) } } } catch (e) { if (!cancelled) { - console.error("OAuth init error:", e) + console.error("Auth init error:", e) setError(e instanceof Error ? e.message : String(e)) } } finally { @@ -148,69 +62,36 @@ init() return () => { cancelled = true } - }, [aip_url]) - - const getToken = useCallback(async (): Promise => { - return accessToken - }, [accessToken]) + }, []) const login = useCallback(async (handle: string) => { - if (!aip_url) { - throw new Error("AIP URL not configured") - } - setError(null) - await ensureDpopKeypair() - - const redirectUri = `${window.location.origin}/` - const clientId = await getOrRegisterClient(aip_url, redirectUri) - - const codeVerifier = generateRandomString(32) - const codeChallenge = await generateCodeChallenge(codeVerifier) - const state = generateRandomString(16) - - sessionStorage.setItem("oauth_code_verifier", codeVerifier) - sessionStorage.setItem("oauth_state", state) - sessionStorage.setItem("oauth_client_id", clientId) - - const params = new URLSearchParams({ - response_type: "code", - client_id: clientId, - redirect_uri: redirectUri, - code_challenge: codeChallenge, - code_challenge_method: "S256", - state, - scope: "atproto", - login_hint: handle, + const resp = await fetch(`/auth/login?handle=${encodeURIComponent(handle)}`, { + credentials: "same-origin", }) - window.location.href = `${aip_url}/oauth/authorize?${params.toString()}` - }, [aip_url]) + if (!resp.ok) { + const text = await resp.text() + throw new Error(`Login failed: ${text}`) + } + + const data = await resp.json() + // Redirect to the authorization URL + window.location.href = data.url + }, []) const logout = useCallback(async () => { - const clientId = sessionStorage.getItem("oauth_client_id") - if (accessToken && clientId) { - try { - await fetch("/aip/oauth/revoke", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - token: accessToken, - client_id: clientId, - }).toString(), - }) - } catch { - // Best-effort revocation - } + try { + await fetch("/auth/logout", { + method: "POST", + credentials: "same-origin", + }) + } catch { + // Best-effort revocation } - setAccessToken(null) setDid(null) - clearDpopKeypair() - sessionStorage.removeItem("oauth_access_token") - sessionStorage.removeItem("oauth_did") - sessionStorage.removeItem("oauth_client_id") - }, [accessToken]) + }, []) if (loading) return null @@ -218,7 +99,6 @@ return ( {children} ) -} - -async function handleOAuthCallback( - aipUrl: string, - code: string, - state: string, - cancelled: boolean, - setters: { - setAccessToken: (t: string) => void - setDid: (d: string) => void - } -) { - const savedState = sessionStorage.getItem("oauth_state") - if (state !== savedState) { - throw new Error("OAuth state mismatch") - } - - const codeVerifier = sessionStorage.getItem("oauth_code_verifier") - if (!codeVerifier) { - throw new Error("Missing PKCE code verifier") - } - - const clientId = sessionStorage.getItem("oauth_client_id") - if (!clientId) { - throw new Error("Missing OAuth client ID") - } - - // Verify issuer if present in callback - const params = new URLSearchParams(window.location.search) - const iss = params.get("iss") - if (iss) { - const savedIssuer = sessionStorage.getItem("oauth_issuer") - if (savedIssuer && iss !== savedIssuer) { - throw new Error("OAuth issuer mismatch") - } - } - - const redirectUri = `${window.location.origin}/` - - // Token exchange via proxied path (avoids CORS). - // AIP may require a DPoP nonce — retry once if we get one back. - const tokenUrl = `${aipUrl}/oauth/token` - const tokenBody = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: clientId, - code_verifier: codeVerifier, - }).toString() - - let tokenDpopProof = await createDpopProof("POST", tokenUrl) - let resp = await fetch("/aip/oauth/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - DPoP: tokenDpopProof, - }, - body: tokenBody, - }) - - if (!resp.ok) { - // AIP returns the nonce via header and/or JSON body - let nonce = resp.headers.get("dpop-nonce") - if (!nonce) { - const errBody = await resp.text().catch(() => "") - try { nonce = JSON.parse(errBody).dpop_nonce ?? null } catch { /* not JSON */ } - if (!nonce) throw new Error(`Token exchange failed: ${errBody}`) - } - tokenDpopProof = await createDpopProof("POST", tokenUrl, undefined, nonce) - resp = await fetch("/aip/oauth/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - DPoP: tokenDpopProof, - }, - body: tokenBody, - }) - } - - if (!resp.ok) { - const text = await resp.text() - throw new Error(`Token exchange failed: ${text}`) - } - - const tokens = await resp.json() - // Capture the DPoP nonce from the token response for use in subsequent requests - const dpopNonce = resp.headers.get("dpop-nonce") - if (dpopNonce) setDpopNonce(dpopNonce) - - // Clean URL and session storage - window.history.replaceState({}, "", window.location.pathname) - sessionStorage.removeItem("oauth_state") - sessionStorage.removeItem("oauth_code_verifier") - sessionStorage.removeItem("oauth_issuer") - - if (cancelled) return - - const accessToken: string = tokens.access_token - setters.setAccessToken(accessToken) - - // Get DID from token response or userinfo - let userDid: string | undefined = tokens.sub - if (!userDid) { - const userinfoUrl = `${aipUrl}/oauth/userinfo` - // Use the nonce from the token response if available - let currentNonce = dpopNonce - let userinfoDpopProof = await createDpopProof("GET", userinfoUrl, accessToken, currentNonce ?? undefined) - - let userinfoResp = await fetch("/aip/oauth/userinfo", { - headers: { - Authorization: `DPoP ${accessToken}`, - DPoP: userinfoDpopProof, - }, - }) - - // Retry with nonce if AIP requires one - if (!userinfoResp.ok) { - let nonce = userinfoResp.headers.get("dpop-nonce") - if (!nonce) { - const errBody = await userinfoResp.text().catch(() => "") - try { nonce = JSON.parse(errBody).dpop_nonce ?? null } catch { /* not JSON */ } - } - if (nonce) { - currentNonce = nonce - userinfoDpopProof = await createDpopProof("GET", userinfoUrl, accessToken, nonce) - userinfoResp = await fetch("/aip/oauth/userinfo", { - headers: { - Authorization: `DPoP ${accessToken}`, - DPoP: userinfoDpopProof, - }, - }) - } - } - - if (userinfoResp.ok) { - const info = await userinfoResp.json() - userDid = info.sub - } - } - - if (userDid) { - setters.setDid(userDid) - sessionStorage.setItem("oauth_did", userDid) - } - sessionStorage.setItem("oauth_access_token", accessToken) } export function useAuth() { diff --git a/web/src/lib/config-context.tsx b/web/src/lib/config-context.tsx --- a/web/src/lib/config-context.tsx +++ b/web/src/lib/config-context.tsx @@ -1,13 +1,12 @@ "use client" import { createContext, useContext, useEffect, useState } from "react" -import { setAipUrl } from "./api" interface ConfigContextType { - aip_url: string + public_url: string } -const ConfigContext = createContext({ aip_url: "" }) +const ConfigContext = createContext({ public_url: "" }) export function ConfigProvider({ children }: { children: React.ReactNode }) { const [config, setConfig] = useState(null) @@ -20,8 +19,7 @@ if (!res.ok) throw new Error(`Config fetch failed: ${res.status}`) return res.json() }) .then((data) => { - setAipUrl(data.aip_url) - setConfig({ aip_url: data.aip_url }) + setConfig({ public_url: data.public_url }) }) .catch((e) => setError(e.message)) }, []) diff --git a/web/src/lib/dpop.ts b/web/src/lib/dpop.ts deleted file mode 100644 --- a/web/src/lib/dpop.ts +++ /dev/null @@ -1,113 +0,0 @@ -interface DpopKeyPair { - privateKey: CryptoKey - publicJwk: { kty: string; crv: string; x: string; y: string } -} - -let cachedKeypair: DpopKeyPair | null = null -let cachedNonce: string | null = null - -function base64urlEncode(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - let binary = "" - for (const b of bytes) binary += String.fromCharCode(b) - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") -} - -async function importKeypair(jwk: JsonWebKey): Promise { - const privateKey = await crypto.subtle.importKey( - "jwk", - jwk, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"] - ) - return { - privateKey, - publicJwk: { kty: jwk.kty!, crv: jwk.crv!, x: jwk.x!, y: jwk.y! }, - } -} - -export async function ensureDpopKeypair(): Promise { - if (cachedKeypair) return - - const stored = sessionStorage.getItem("dpop_private_jwk") - if (stored) { - cachedKeypair = await importKeypair(JSON.parse(stored)) - return - } - - const keyPair = await crypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"] - ) - const jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey) - sessionStorage.setItem("dpop_private_jwk", JSON.stringify(jwk)) - - cachedKeypair = { - privateKey: keyPair.privateKey, - publicJwk: { kty: jwk.kty!, crv: jwk.crv!, x: jwk.x!, y: jwk.y! }, - } -} - -export function setDpopNonce(nonce: string): void { - cachedNonce = nonce -} - -export async function createDpopProof( - method: string, - url: string, - accessToken?: string, - nonce?: string -): Promise { - // Use the cached nonce if no explicit nonce is provided - const effectiveNonce = nonce ?? cachedNonce - await ensureDpopKeypair() - const keypair = cachedKeypair! - - const header = { - typ: "dpop+jwt", - alg: "ES256", - jwk: keypair.publicJwk, - } - - const claims: Record = { - jti: crypto.randomUUID(), - htm: method.toUpperCase(), - htu: url, - iat: Math.floor(Date.now() / 1000), - } - - if (accessToken) { - const hash = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(accessToken) - ) - claims.ath = base64urlEncode(hash) - } - - if (effectiveNonce) { - claims.nonce = effectiveNonce - } - - const enc = new TextEncoder() - const headerB64 = base64urlEncode( - enc.encode(JSON.stringify(header)).buffer as ArrayBuffer - ) - const claimsB64 = base64urlEncode( - enc.encode(JSON.stringify(claims)).buffer as ArrayBuffer - ) - - const signature = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - keypair.privateKey, - enc.encode(`${headerB64}.${claimsB64}`) - ) - - return `${headerB64}.${claimsB64}.${base64urlEncode(signature)}` -} - -export function clearDpopKeypair(): void { - cachedKeypair = null - sessionStorage.removeItem("dpop_private_jwk") -} -- tangled.sh