Skip to content

Commit e93d5be

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
feat(admin): three-mode auth — BUZZ_ADMIN_AUTH enum + NIP-98 pubkey allowlist
Deletes BUZZ_ADMIN_INSECURE_NO_AUTH. Replaces it with BUZZ_ADMIN_AUTH, a mode enum with three exact values: - unset / "token" — bearer token via BUZZ_ADMIN_TOKEN (unchanged default) - "disabled" — no auth; boot WARN retained (replaces INSECURE_NO_AUTH) - "nip98" — NIP-98 HTTP Auth via BUZZ_ADMIN_PUBKEYS allowlist All existing token/disabled invariants carry 1:1 under the new name. Junk values fail closed at startup with a ConfigError. NIP-98 mode (admin-moderation scope): - Parses BUZZ_ADMIN_PUBKEYS as comma-separated 64-hex pubkeys (deduped). - BUZZ_ADMIN_PUBKEYS required non-empty in nip98 mode; warn-and-ignore in token/disabled modes. BUZZ_ADMIN_TOKEN + nip98 = ConfigError. - authorize_nip98(): single Authorization: Nostr <base64 event> header, verify_nip98_event, deployment-scoped replay guard (admin-moderation), allowlist membership check. Uniform 401 on all failures; no oracle. - WWW-Authenticate: Nostr on 401 (Bearer stays in token mode) — the SPA uses this header to discover the auth mode. - Canonical URL: https://<BUZZ_ADMIN_HOST>/api/admin/v1<stripped-path>; http:// for loopback hosts (local dev). Axum strips the prefix before handlers; ADMIN_API_PREFIX constant re-adds it for NIP-98 verification. SPA (admin-web): - probeAuthMode() reads WWW-Authenticate: Bearer/Nostr/absent to return "token" | "nip98" | "disabled". - nip98 mode: signNip98() helper builds kind-27235 events via window.nostr (NIP-07); send() attaches Authorization: Nostr per request. - Nip07Screen: shown when nip98 mode is detected but window.nostr is absent; instructs operator to install nos2x or Alby. - 401 in nip98 mode re-signs once then surfaces the error (no infinite loop). Docs/config: - docs/admin/README.md rewritten to three-mode shape; migration note from BUZZ_ADMIN_INSECURE_NO_AUTH. - Both .env.example files updated (root and deploy/compose/). - CHANGELOG.md Unreleased sections updated with three-mode description. Tests: - Config matrix: all mode values, junk, every mutual exclusion, missing/invalid BUZZ_ADMIN_PUBKEYS. - NIP-98 route tests: wrong pubkey → 401, replay → 401, wrong u/method → 401, duplicate Authorization → 401, valid → 200, valid + wrong host → 403. - Regression pins: token-mode and disabled-mode behavior unchanged. - Playwright: nip98 mode without NIP-07 → instruction screen; mocked window.nostr happy path signs requests and renders dashboard. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 75277f3 commit e93d5be

11 files changed

Lines changed: 1141 additions & 195 deletions

File tree

.env.example

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,25 @@ RELAY_URL=ws://localhost:3000
6767
# Setting it requires one of the two authentication variables below.
6868
# BUZZ_ADMIN_HOST=admin.localhost:3000
6969
#
70-
# Option A — Bearer token (recommended for public-facing / OSS deployments):
70+
# Authentication mode. Accepted values: token (default), disabled, nip98.
71+
# Any other value is a startup error.
72+
# BUZZ_ADMIN_AUTH=token
73+
#
74+
# Option A — BUZZ_ADMIN_AUTH=token (default when unset):
7175
# Exactly 64 hex characters (32 bytes); generate with `openssl rand -hex 32`.
7276
# `just admin` generates a throwaway one per run and prints it.
7377
# BUZZ_ADMIN_TOKEN=<64 hex characters>
7478
#
75-
# Option B — Network-layer auth (for deployments behind a VPN or firewall):
76-
# Set only when the admin API is already protected at the network layer.
77-
# The relay logs a WARN on every startup in this mode.
78-
# Only the exact value "true" is accepted; any other non-empty value fails.
79-
# BUZZ_ADMIN_INSECURE_NO_AUTH=true
79+
# Option B — BUZZ_ADMIN_AUTH=disabled (network-layer auth only):
80+
# Set only when the admin API is already protected at the network layer
81+
# (VPN, private ingress). The relay logs a WARN on every startup.
82+
# BUZZ_ADMIN_TOKEN must NOT also be set.
83+
#
84+
# Option C — BUZZ_ADMIN_AUTH=nip98 (Nostr pubkey allowlist):
85+
# NIP-98 HTTP Auth. Each request must carry an Authorization: Nostr header
86+
# with a signed kind-27235 event. The signer's pubkey must be in
87+
# BUZZ_ADMIN_PUBKEYS. The dashboard requires a NIP-07 browser extension.
88+
# BUZZ_ADMIN_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
8089
#
8190
# Directory holding the built dashboard assets (`pnpm -C admin-web build`).
8291
# BUZZ_ADMIN_WEB_DIR=./admin-web/dist

CHANGELOG.md

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,27 @@
33
## Unreleased
44

55
- **Breaking:** the relay admin moderation API (`/api/admin/v1`) now requires
6-
explicit authentication when `BUZZ_ADMIN_HOST` is set. Choose one mode:
7-
- **Token mode:** set `BUZZ_ADMIN_TOKEN` to exactly 64 hex characters
8-
(`openssl rand -hex 32`). Every API request requires `Authorization: Bearer`.
9-
The dashboard prompts for the token on first load.
10-
- **Network-layer mode:** set `BUZZ_ADMIN_INSECURE_NO_AUTH=true` (exact value
11-
only). Use only when the admin API is already protected by a VPN or private
12-
ingress. The relay logs a `WARN` on every startup. The dashboard skips the
13-
token prompt and renders directly.
14-
- Both set at the same time, or neither, is a startup error.
15-
`Host`/`Origin` matching is retained in both modes as defense-in-depth.
6+
explicit authentication configuration when `BUZZ_ADMIN_HOST` is set. Choose
7+
one mode via `BUZZ_ADMIN_AUTH` (unset defaults to `token`):
8+
- **`BUZZ_ADMIN_AUTH=token` (default):** set `BUZZ_ADMIN_TOKEN` to exactly
9+
64 hex characters (`openssl rand -hex 32`). Every request requires
10+
`Authorization: Bearer`. The dashboard prompts for the token on first load.
11+
- **`BUZZ_ADMIN_AUTH=disabled`:** admin API is unauthenticated. Use only when
12+
the admin API is already protected by a VPN or private ingress. The relay
13+
logs a `WARN` on every startup. The dashboard skips the token prompt.
14+
- **`BUZZ_ADMIN_AUTH=nip98`:** NIP-98 HTTP Auth. Every request must carry an
15+
`Authorization: Nostr <base64 event>` header containing a signed kind-27235
16+
event. The signer's pubkey must be listed in `BUZZ_ADMIN_PUBKEYS`
17+
(comma-separated 64-char hex pubkeys). The dashboard requires a NIP-07
18+
browser extension (nos2x or Alby); without one it shows an installation
19+
screen. Individual operator access is revocable without rotating a shared
20+
secret.
21+
- Any unrecognised value for `BUZZ_ADMIN_AUTH` is a startup error
22+
(typo-proofing). `BUZZ_ADMIN_TOKEN` set alongside `disabled` or `nip98` is
23+
also a startup error.
24+
- `Host`/`Origin` matching is retained in all modes as defense-in-depth.
25+
- **Migration from the previous `BUZZ_ADMIN_INSECURE_NO_AUTH=true`:** replace
26+
with `BUZZ_ADMIN_AUTH=disabled`. The behavior is identical.
1627

1728

1829
## v0.5.4

admin-web/src/App.tsx

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import {
77
useState,
88
} from "react";
99
import {
10+
type AuthMode,
1011
ApiFailure,
11-
probeAuthRequired,
12+
probeAuthMode,
1213
request,
1314
requestObjectUrl,
1415
} from "./api";
@@ -91,9 +92,9 @@ function StateView<T>({
9192
return resource.data ? children(resource.data) : null;
9293
}
9394

94-
function Reports() {
95+
function Reports({ authMode }: { authMode: AuthMode }) {
9596
const resource = useResource(
96-
() => request<Report[]>("/reports?status=open&limit=100"),
97+
() => request<Report[]>("/reports?status=open&limit=100", authMode),
9798
"reports",
9899
);
99100
return (
@@ -141,9 +142,9 @@ function Reports() {
141142
);
142143
}
143144

144-
function ReportDetail({ id }: { id: string }) {
145+
function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) {
145146
const resource = useResource(
146-
() => request<ReportDetailData>(`/reports/${id}`),
147+
() => request<ReportDetailData>(`/reports/${id}`, authMode),
147148
id,
148149
);
149150
return (
@@ -216,9 +217,9 @@ function ReportDetail({ id }: { id: string }) {
216217
);
217218
}
218219

219-
function FeedbackList() {
220+
function FeedbackList({ authMode }: { authMode: AuthMode }) {
220221
const resource = useResource(
221-
() => request<FeedbackSummary[]>("/feedback"),
222+
() => request<FeedbackSummary[]>("/feedback", authMode),
222223
"feedback",
223224
);
224225
const [query, setQuery] = useState("");
@@ -425,9 +426,15 @@ function FeedbackResults({
425426
return children(results);
426427
}
427428

428-
function FeedbackDetailView({ id }: { id: string }) {
429+
function FeedbackDetailView({
430+
id,
431+
authMode,
432+
}: {
433+
id: string;
434+
authMode: AuthMode;
435+
}) {
429436
const resource = useResource(
430-
() => request<FeedbackDetail>(`/feedback/${id}`),
437+
() => request<FeedbackDetail>(`/feedback/${id}`, authMode),
431438
id,
432439
);
433440
return (
@@ -468,6 +475,7 @@ function FeedbackDetailView({ id }: { id: string }) {
468475
<Attachment
469476
key={`${attachment.hash}-${attachment.path}`}
470477
attachment={attachment}
478+
authMode={authMode}
471479
/>
472480
))}
473481
</dd>
@@ -601,7 +609,13 @@ function stripAttachmentMarkdown(
601609
.trim();
602610
}
603611

604-
function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
612+
function Attachment({
613+
attachment,
614+
authMode,
615+
}: {
616+
attachment: FeedbackAttachment;
617+
authMode: AuthMode;
618+
}) {
605619
const [objectUrl, setObjectUrl] = useState<string>();
606620
const [failed, setFailed] = useState(false);
607621
const path = attachment.path;
@@ -622,7 +636,7 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
622636
let active = true;
623637
setObjectUrl(undefined);
624638
setFailed(false);
625-
requestObjectUrl(path)
639+
requestObjectUrl(path, authMode)
626640
.then((created) => {
627641
if (!active) {
628642
URL.revokeObjectURL(created);
@@ -638,7 +652,7 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) {
638652
active = false;
639653
if (url) URL.revokeObjectURL(url);
640654
};
641-
}, [path]);
655+
}, [path, authMode]);
642656

643657
const detail = failed ? "Could not load attachment" : metadata;
644658

@@ -885,6 +899,38 @@ function TokenPrompt({ rejected }: { rejected: boolean }) {
885899
);
886900
}
887901

902+
/// Shown in nip98 mode when no NIP-07 extension is available. Instructs the
903+
/// operator to install nos2x or Alby before continuing.
904+
function Nip07Screen() {
905+
return (
906+
<div className="app">
907+
<div className="state token-prompt">
908+
<h2>Nostr extension required</h2>
909+
<p>
910+
This relay uses NIP-98 HTTP Auth. Install a NIP-07 browser extension
911+
such as{" "}
912+
<a
913+
href="https://github.com/fiatjaf/nos2x"
914+
target="_blank"
915+
rel="noreferrer"
916+
>
917+
nos2x
918+
</a>{" "}
919+
or{" "}
920+
<a href="https://getalby.com" target="_blank" rel="noreferrer">
921+
Alby
922+
</a>
923+
, then reload this page. Your Nostr key will be used to sign each
924+
request.
925+
</p>
926+
<button type="button" onClick={() => location.reload()}>
927+
Reload
928+
</button>
929+
</div>
930+
</div>
931+
);
932+
}
933+
888934
export function App() {
889935
const { path } = usePath();
890936
const token = useToken();
@@ -894,21 +940,20 @@ export function App() {
894940
if (token !== null) setEverHadToken(true);
895941
}, [token]);
896942

897-
// When no token is stored, probe the relay to find out whether it requires
898-
// one. In insecure_no_auth mode the probe returns 200 and the dashboard
899-
// renders without a credential; in token mode it returns 401 and the prompt
900-
// is shown. `null` means the probe is still in flight.
901-
const [authRequired, setAuthRequired] = useState<boolean | null>(
902-
token !== null ? false : null,
943+
// Probe the relay once to discover the auth mode. `null` means the probe is
944+
// still in flight. Once resolved, the mode is stable for the session.
945+
const [authMode, setAuthMode] = useState<AuthMode | null>(
946+
// If we already have a token we know we're in token mode; skip the probe.
947+
token !== null ? "token" : null,
903948
);
904949
useEffect(() => {
905950
if (token !== null) {
906-
setAuthRequired(false);
951+
setAuthMode("token");
907952
return;
908953
}
909954
let active = true;
910-
probeAuthRequired().then((required) => {
911-
if (active) setAuthRequired(required);
955+
probeAuthMode().then((mode) => {
956+
if (active) setAuthMode(mode);
912957
});
913958
return () => {
914959
active = false;
@@ -919,18 +964,24 @@ export function App() {
919964
const feedback = path.match(/^\/feedback\/([^/]+)$/);
920965

921966
// Probe still in flight — render nothing to avoid a visible flash.
922-
if (authRequired === null) return null;
967+
if (authMode === null) return null;
968+
969+
// Token mode: show the token prompt until a valid token is stored.
970+
if (authMode === "token" && !token)
971+
return <TokenPrompt rejected={everHadToken} />;
923972

924-
if (authRequired && !token) return <TokenPrompt rejected={everHadToken} />;
973+
// NIP-98 mode: require a NIP-07 extension.
974+
if (authMode === "nip98" && !(window as Window & { nostr?: unknown }).nostr)
975+
return <Nip07Screen />;
925976

926977
const content = report ? (
927-
<ReportDetail id={report[1]} />
978+
<ReportDetail id={report[1]} authMode={authMode} />
928979
) : feedback ? (
929-
<FeedbackDetailView id={feedback[1]} />
980+
<FeedbackDetailView id={feedback[1]} authMode={authMode} />
930981
) : path === "/feedback" ? (
931-
<FeedbackList />
982+
<FeedbackList authMode={authMode} />
932983
) : (
933-
<Reports />
984+
<Reports authMode={authMode} />
934985
);
935986
return (
936987
<div className="app">

0 commit comments

Comments
 (0)