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:
[](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
@@ -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