From 0b8dd3a97e4092a85aea9134bbc529bedd18c334 Mon Sep 17 00:00:00 2001 From: Brooke Date: Fri, 10 Jul 2026 20:48:44 -0700 Subject: [PATCH] bearer admin auth mode for PDSes without an admin password --- .env.example | 4 ++++ README.md | 10 ++++++++++ server/src/cli.ts | 11 +++++++++-- server/src/index.ts | 3 ++- server/src/pdsClient.ts | 36 +++++++++++++++++++++++++++++++++--- 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 7bb6a3a..c5f93a0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # PDS this dashboard manages PDS_HOSTNAME=pds.example.com PDS_ADMIN_PASSWORD=changeme +# For a PDS without an admin password (e.g. tranquil-pds): set this to the handle or DID +# of an account with admin rights. PDS_ADMIN_PASSWORD is then that account's password +# (an app password works). Leave empty for the reference PDS. +PDS_ADMIN_IDENTIFIER= # Relay used for sync status / requestCrawl RELAY_HOSTNAME=bsky.network diff --git a/README.md b/README.md index b3ef32e..ebda291 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ Run it again any time to enroll another device, or as recovery if all passkeys a `server/.env` and generate the hash with `node -e "console.log(require('bcryptjs').hashSync('', 10))"`. +### Other PDS implementations + +The reference PDS authenticates admin calls with `PDS_ADMIN_PASSWORD` over basic auth. +For implementations without an admin password, like +[tranquil-pds](https://tangled.org/tranquil.farm/tranquil-pds), set +`PDS_ADMIN_IDENTIFIER` to the handle of an account with admin rights and put that +account's password in `PDS_ADMIN_PASSWORD`. The dashboard then signs in as that account +and sends bearer tokens instead. Sign-in is headless, so use an app password or keep 2FA +off that account. + ### Labelers `server/labelers.json` (gitignored, copy `server/labelers.json.example`): diff --git a/server/src/cli.ts b/server/src/cli.ts index 4f9de83..a166a96 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -164,7 +164,13 @@ async function setup() { } const pdsHostname = await ask("pds hostname (e.g. pds.example.com)"); - const pdsAdminPassword = await askHidden("pds admin password"); + console.log("\nreference pds: leave the handle empty, sign in with the admin password."); + console.log("pds without an admin password (e.g. tranquil-pds): give an admin account's"); + console.log("handle, then its password (an app password works and sidesteps 2fa)."); + const pdsAdminIdentifier = await ask("admin account handle (empty for admin password auth)", ""); + const pdsAdminPassword = await askHidden( + pdsAdminIdentifier ? "admin account password" : "pds admin password", + ); const relayHostname = await ask("relay hostname", "bsky.network"); const appviewUrl = await ask("appview url for profile links", "https://bsky.app"); const dashboardUrl = await ask("dashboard url (used in DM deep links)", "http://localhost:5173"); @@ -186,6 +192,7 @@ async function setup() { const lines = [ `PDS_HOSTNAME=${pdsHostname}`, `PDS_ADMIN_PASSWORD=${pdsAdminPassword}`, + `PDS_ADMIN_IDENTIFIER=${pdsAdminIdentifier}`, `RELAY_HOSTNAME=${relayHostname}`, operatorPassword ? `OPERATOR_PASSWORD_HASH=${bcrypt.hashSync(operatorPassword, 10)}` @@ -225,7 +232,7 @@ primary_region = "${region}" HOST = "0.0.0.0" PORT = "8787" PDS_HOSTNAME = "${pdsHostname}" - RELAY_HOSTNAME = "${relayHostname}" +${pdsAdminIdentifier ? ` PDS_ADMIN_IDENTIFIER = "${pdsAdminIdentifier}"\n` : ""} RELAY_HOSTNAME = "${relayHostname}" APPVIEW_URL = "${appviewUrl}" DASHBOARD_URL = "${flyUrl.replace(/\/$/, "")}" DB_PATH = "/data/data.sqlite" diff --git a/server/src/index.ts b/server/src/index.ts index 02fa8ff..254620d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,6 +24,7 @@ import { BskyDmNotifier } from "./notifier.js"; const { PDS_HOSTNAME, PDS_ADMIN_PASSWORD, + PDS_ADMIN_IDENTIFIER, RELAY_HOSTNAME, OPERATOR_PASSWORD_HASH, SESSION_SECRET, @@ -75,7 +76,7 @@ await app.register(fastifySession, { }, }); -const pds = new PdsClient(PDS_HOSTNAME!, PDS_ADMIN_PASSWORD!); +const pds = new PdsClient(PDS_HOSTNAME!, PDS_ADMIN_PASSWORD!, PDS_ADMIN_IDENTIFIER || undefined); const relay = new RelayClient(RELAY_HOSTNAME!); // labelers.json: [{ "name"?, "did", "labels": [...] }] — labels empty/omitted means all labels flag. // Falls back to LABELER_DID / FLAG_LABELS env vars if the file doesn't exist. diff --git a/server/src/pdsClient.ts b/server/src/pdsClient.ts index cb61cb2..409e677 100644 --- a/server/src/pdsClient.ts +++ b/server/src/pdsClient.ts @@ -36,25 +36,55 @@ export interface AdminAccount { } export class PdsClient { + private accessJwt: string | null = null; + constructor( public readonly hostname: string, private adminPassword: string, + /** + * When set, admin calls sign in as this account (which must have admin rights on the + * PDS) and send bearer tokens instead of `admin:` basic auth. Needed for + * PDS implementations without an admin password, e.g. tranquil-pds. adminPassword is + * then this account's password — an app password works and sidesteps 2FA. + */ + private adminIdentifier?: string, ) {} - private authHeader() { + private async ensureSession(): Promise { + if (this.accessJwt) return this.accessJwt; + const res = await fetch(`https://${this.hostname}/xrpc/com.atproto.server.createSession`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: this.adminIdentifier, password: this.adminPassword }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`PDS admin sign-in as ${this.adminIdentifier} failed: ${res.status} ${body}`); + } + const { accessJwt } = (await res.json()) as { accessJwt: string }; + this.accessJwt = accessJwt; + return accessJwt; + } + + private async authHeader() { + if (this.adminIdentifier) return `Bearer ${await this.ensureSession()}`; const token = Buffer.from(`admin:${this.adminPassword}`).toString("base64"); return `Basic ${token}`; } - private async xrpc(path: string, opts: RequestInit = {}) { + private async xrpc(path: string, opts: RequestInit = {}, retry = true): Promise { const res = await fetch(`https://${this.hostname}/xrpc/${path}`, { ...opts, headers: { ...opts.headers, - Authorization: this.authHeader(), + Authorization: await this.authHeader(), "Content-Type": "application/json", }, }); + if (res.status === 401 && this.adminIdentifier && retry) { + this.accessJwt = null; // token expired — re-auth once + return this.xrpc(path, opts, false); + } if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`PDS ${path} failed: ${res.status} ${body}`); -- 2.51.2