diff --git a/README.md b/README.md index 0cb04b2..35fa4dc 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,16 @@ With DMs enabled you also get an alert when a single account creates a burst of `ACTIVITY_ALERT_CREATES` and `ACTIVITY_ALERT_WINDOW_MINUTES`, disable with `ACTIVITY_ALERT_CREATES=0`. At most one alert per account per window. +### Admins + +The dashboard supports multiple named admins. Add someone by handle in the Admins panel, +then a "new admin" button appears on the sign-in page. They verify their atproto account +via OAuth (identity only, no permissions granted) and create a passkey once. From then on +they sign in with the passkey, and the audit log records actions under their handle. +Enrollment is one shot per admin. To reset someone (lost device), remove and re-add them, +which deletes their passkeys and reopens enrollment. `npm run enroll` remains the +bootstrap and break-glass path, those passkeys sign in as the generic "operator". + ## Dev ``` diff --git a/server/package-lock.json b/server/package-lock.json index 7b098fb..2e66bce 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,9 @@ "name": "pds-operator-server", "version": "0.1.0", "dependencies": { + "@atcute/identity-resolver": "^2.0.1", + "@atcute/identity-resolver-node": "^2.0.1", + "@atcute/oauth-node-client": "^2.0.1", "@fastify/cookie": "^11.0.2", "@fastify/helmet": "^13.1.0", "@fastify/session": "^11.1.0", @@ -31,6 +34,161 @@ "typescript": "^5.7.3" } }, + "node_modules/@atcute/client": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-5.1.1.tgz", + "integrity": "sha512-cn5/Zi/qo37WtQG6gzIC7JPs0RDzX9Z4eaceX45SpKgLZoc3fCFDJcE7C8xsbxBNfjry2T6PmUxWA8obebZsEQ==", + "license": "0BSD", + "dependencies": { + "@atcute/identity": "^2.0.1", + "@atcute/lexicons": "^2.0.2" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/identity": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.2.tgz", + "integrity": "sha512-amr/EQceqVtBVmjBK4uUF7nKKYuRttadigpvOcAn4dnO6SNSwSjQi8KDH9LnukEotPsSP4UDsQvNfHWEoUcslw==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.3", + "valibot": "^1.4.2" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/identity-resolver": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-2.0.1.tgz", + "integrity": "sha512-0enA9w7XnbbqsZ5Rcl6jXLf7ZZuwFQ9dBmxFq3qOxPHLaCETsqsrQflXDPqiM27TnZwYq8sqCV5D1mFOksggDQ==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.2", + "@atcute/util-fetch": "^2.0.1", + "valibot": "^1.4.1" + }, + "peerDependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/identity-resolver-node": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver-node/-/identity-resolver-node-2.0.1.tgz", + "integrity": "sha512-UtcmhY4Oxk9tk1elTHmilcIXA1EFtGQae7mp3l8Xqs6EZsZyH3cUDf/2jS1CoRT1GE/i3WfnLaxYmdMIVau1kg==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.2" + }, + "peerDependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/identity-resolver": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lexicons": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.0.3.tgz", + "integrity": "sha512-rs3WqZIQ7lhSYXEvRU/x9rBMY2/fzvrsTgTf2/bgt+MgIQqOKAMySK3TWqfrvesGHDLxFlunHE3uMXeNdEC90Q==", + "license": "0BSD", + "dependencies": { + "@atcute/uint8array": "^1.1.5", + "@atcute/util-text": "^1.3.4", + "@standard-schema/spec": "^1.1.0", + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/multibase": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@atcute/multibase/-/multibase-1.2.5.tgz", + "integrity": "sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw==", + "license": "0BSD", + "dependencies": { + "@atcute/uint8array": "^1.1.5" + } + }, + "node_modules/@atcute/oauth-crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@atcute/oauth-crypto/-/oauth-crypto-1.0.1.tgz", + "integrity": "sha512-ghC8ceFx0r6pi5X3dNoET1K6PNg8afeAOZLD4PKLhrpVYdRtRrgY4uqAe3ojtSIjoS6GXmVm063CoJ2xtg/MoQ==", + "license": "0BSD", + "dependencies": { + "@atcute/multibase": "^1.2.4", + "@atcute/uint8array": "^1.1.3", + "nanoid": "^5.1.16", + "valibot": "^1.4.1" + } + }, + "node_modules/@atcute/oauth-keyset": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@atcute/oauth-keyset/-/oauth-keyset-0.1.3.tgz", + "integrity": "sha512-VSNUb2TLUTyntgzfn58kwNL4kp4TD+M868tyyrFWlCaDVy0pDnVFfUXa1z1W+DBWLL9ICy2qTdPitRokAZKivw==", + "license": "0BSD", + "dependencies": { + "@atcute/oauth-crypto": "^1.0.1" + } + }, + "node_modules/@atcute/oauth-node-client": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@atcute/oauth-node-client/-/oauth-node-client-2.0.1.tgz", + "integrity": "sha512-MgqN9Rnt5nuUH5zriETtbQrwcx/tWaELKaFVQtqggMtRI4k4IP5+ZbnG40tiJjbZfSclapA2kwOnT14kO0nAxg==", + "license": "0BSD", + "dependencies": { + "@atcute/client": "^5.1.1", + "@atcute/identity": "^2.0.1", + "@atcute/identity-resolver": "^2.0.1", + "@atcute/lexicons": "^2.0.2", + "@atcute/oauth-crypto": "^1.0.1", + "@atcute/oauth-keyset": "^0.1.2", + "@atcute/oauth-types": "^1.0.1", + "@atcute/util-fetch": "^2.0.1", + "nanoid": "^5.1.16", + "valibot": "^1.4.1" + }, + "peerDependencies": { + "@atcute/identity-resolver": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/oauth-types": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@atcute/oauth-types/-/oauth-types-1.0.1.tgz", + "integrity": "sha512-9uFP/nK0PgPDsaNbLArzrCHvTZO1k04cSEX5jZ75W2Jh7Jxbh/CSTenSrad3CZXuX6y6hDquEtl6H/IqLtTmHw==", + "license": "0BSD", + "dependencies": { + "@atcute/identity": "^2.0.1", + "@atcute/lexicons": "^2.0.2", + "@atcute/oauth-keyset": "^0.1.2", + "valibot": "^1.4.1" + } + }, + "node_modules/@atcute/uint8array": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.5.tgz", + "integrity": "sha512-1SFCXOtjE3ismP92CqzbOfYSwJmPPPFbj798wLXSpLv2hSd5OIaUrTh6EqRVR1VZ3ZhR+vaBmd3kxAN0wkw+gQ==", + "license": "0BSD" + }, + "node_modules/@atcute/util-fetch": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-2.0.2.tgz", + "integrity": "sha512-I0oenHlJjwRpWPyAJojox5H0z9NthhdMFaEjtkxyjo85VPPxk1vMJk/1ZjMJsrs1SgHhB3spazEWzV5DW8w70A==", + "license": "0BSD", + "dependencies": { + "valibot": "^1.4.2" + } + }, + "node_modules/@atcute/util-text": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@atcute/util-text/-/util-text-1.3.4.tgz", + "integrity": "sha512-u2UAM7iSM09sQaSG9jtxWFSPgB8boVj50/BoyMvYnhVgGBu+nXIuAcdDUQCsZA44YZgTPPhN/b86JX+jH7SPzQ==", + "license": "0BSD", + "dependencies": { + "unicode-segmenter": "^0.17.0" + } + }, "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", @@ -1040,6 +1198,12 @@ "node": ">=20.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -1470,6 +1634,12 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1942,6 +2112,24 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -2561,7 +2749,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2578,12 +2766,32 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-segmenter": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.17.3.tgz", + "integrity": "sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/server/package.json b/server/package.json index 438c312..16fc33d 100644 --- a/server/package.json +++ b/server/package.json @@ -13,6 +13,9 @@ "enroll": "tsx src/cli.ts enroll" }, "dependencies": { + "@atcute/identity-resolver": "^2.0.1", + "@atcute/identity-resolver-node": "^2.0.1", + "@atcute/oauth-node-client": "^2.0.1", "@fastify/cookie": "^11.0.2", "@fastify/helmet": "^13.1.0", "@fastify/session": "^11.1.0", diff --git a/server/src/auth.ts b/server/src/auth.ts index b23300c..fb8747b 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import bcrypt from "bcryptjs"; +import type { Db } from "./db.js"; declare module "@fastify/session" { interface FastifySessionObject { @@ -15,6 +16,7 @@ export function registerAuthRoutes( passwordHash: string | null, appviewUrl: string, pdsHostname: string, + db: Db, ) { const attempts = new Map(); @@ -47,11 +49,18 @@ export function registerAuthRoutes( }); app.get("/api/session", async (req) => { + // an admin invited via OAuth but without a passkey yet: the login page shows + // the "new admin" entry point only while such an invite is open + const pendingAdmins = Boolean( + db.prepare("SELECT 1 FROM operators WHERE enrolled_at IS NULL LIMIT 1").get(), + ); return { authenticated: Boolean(req.session.operator), + operator: req.session.operator ?? null, appviewUrl, pdsHostname, passwordLogin: Boolean(passwordHash), + pendingAdmins, }; }); } diff --git a/server/src/db.ts b/server/src/db.ts index b32f545..fc72f17 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -86,25 +86,41 @@ export function openDb(file?: string) { created_at INTEGER NOT NULL ); + -- named admins, keyed by their atproto DID. enrolled_at is set once they have + -- proven the DID via OAuth and created their passkey; null means invited only + CREATE TABLE IF NOT EXISTS operators ( + did TEXT PRIMARY KEY, + handle TEXT NOT NULL, + added_at INTEGER NOT NULL, + enrolled_at INTEGER + ); + CREATE TABLE IF NOT EXISTS enroll_tokens ( token_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL ); `); - // storage columns: rev tracks the repo head and storage_at the last measurement, - // so the sweep only re-measures changed repos, at most so often - const addAccountColumn = (name: string, ddl: string) => { + const addColumn = (table: string, name: string, ddl: string) => { const has = db - .prepare("SELECT COUNT(*) AS n FROM pragma_table_info('accounts') WHERE name = ?") + .prepare(`SELECT COUNT(*) AS n FROM pragma_table_info('${table}') WHERE name = ?`) .get(name) as { n: number }; - if (has.n === 0) db.exec(`ALTER TABLE accounts ADD COLUMN ${ddl}`); + if (has.n === 0) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); }; - addAccountColumn("rev", "rev TEXT NOT NULL DEFAULT ''"); - addAccountColumn("repo_bytes", "repo_bytes INTEGER"); - addAccountColumn("blob_bytes", "blob_bytes INTEGER"); - addAccountColumn("storage_rev", "storage_rev TEXT"); - addAccountColumn("storage_at", "storage_at INTEGER"); + // storage columns: rev tracks the repo head and storage_at the last measurement, + // so the sweep only re-measures changed repos, at most so often + addColumn("accounts", "rev", "rev TEXT NOT NULL DEFAULT ''"); + addColumn("accounts", "repo_bytes", "repo_bytes INTEGER"); + addColumn("accounts", "blob_bytes", "blob_bytes INTEGER"); + addColumn("accounts", "storage_rev", "storage_rev TEXT"); + addColumn("accounts", "storage_at", "storage_at INTEGER"); + // passkeys enrolled through the admin OAuth flow belong to a named operator; + // legacy CLI-enrolled passkeys keep a null owner and sign in as "operator" + addColumn("passkeys", "operator_did", "operator_did TEXT"); + // enroll tokens minted by the OAuth callback carry the verified DID, so the + // enrollment can hop from the 127.0.0.1 callback origin back to the dashboard + // origin without relying on a session cookie + addColumn("enroll_tokens", "operator_did", "operator_did TEXT"); // activity was briefly bucketed by whole day; re-home those rows at UTC midnight const legacyDayColumn = db @@ -150,27 +166,34 @@ function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } -export function createEnrollToken(db: Db): { token: string; expiresAt: number } { +export function createEnrollToken( + db: Db, + operatorDid: string | null = null, +): { token: string; expiresAt: number } { db.prepare("DELETE FROM enroll_tokens WHERE expires_at <= ?").run(Date.now()); const token = randomBytes(32).toString("base64url"); const expiresAt = Date.now() + ENROLL_TOKEN_TTL_MS; - db.prepare("INSERT INTO enroll_tokens (token_hash, expires_at) VALUES (?, ?)").run( - hashToken(token), - expiresAt, - ); + db.prepare( + "INSERT INTO enroll_tokens (token_hash, expires_at, operator_did) VALUES (?, ?, ?)", + ).run(hashToken(token), expiresAt, operatorDid); return { token, expiresAt }; } -export function checkEnrollToken(db: Db, token: string): boolean { +/** Valid-token lookup without consuming it. */ +export function peekEnrollToken(db: Db, token: string): { operatorDid: string | null } | null { const row = db - .prepare("SELECT 1 FROM enroll_tokens WHERE token_hash = ? AND expires_at > ?") - .get(hashToken(token), Date.now()); - return Boolean(row); + .prepare("SELECT operator_did FROM enroll_tokens WHERE token_hash = ? AND expires_at > ?") + .get(hashToken(token), Date.now()) as { operator_did: string | null } | undefined; + return row ? { operatorDid: row.operator_did } : null; +} + +export function checkEnrollToken(db: Db, token: string): boolean { + return peekEnrollToken(db, token) != null; } -export function consumeEnrollToken(db: Db, token: string): boolean { - const res = db - .prepare("DELETE FROM enroll_tokens WHERE token_hash = ? AND expires_at > ?") - .run(hashToken(token), Date.now()); - return res.changes > 0; +export function consumeEnrollToken(db: Db, token: string): { operatorDid: string | null } | null { + const found = peekEnrollToken(db, token); + if (!found) return null; + db.prepare("DELETE FROM enroll_tokens WHERE token_hash = ?").run(hashToken(token)); + return found; } diff --git a/server/src/index.ts b/server/src/index.ts index 27970f4..71405a5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,6 +20,8 @@ import { registerPasskeyRoutes } from "./routes/passkeys.js"; import { registerAuditRoutes } from "./routes/audit.js"; import { registerInviteRoutes } from "./routes/invites.js"; import { registerStatsRoutes } from "./routes/stats.js"; +import { registerOperatorRoutes } from "./routes/operators.js"; +import { createOAuthClient } from "./oauth.js"; import { BskyDmNotifier } from "./notifier.js"; const { @@ -161,8 +163,17 @@ if (!OPERATOR_PASSWORD_HASH) { ); } -registerAuthRoutes(app, OPERATOR_PASSWORD_HASH ?? null, appviewUrl, PDS_HOSTNAME!); +// admin enrollment proves DIDs via atproto OAuth; the dashboard URL doubles as the +// OAuth client identity (loopback client in dev, hosted metadata in prod) +const dashboardUrl = (DASHBOARD_URL ?? `http://localhost:${Number(PORT ?? 8787)}`).replace( + /\/$/, + "", +); +const oauth = createOAuthClient(dashboardUrl); + +registerAuthRoutes(app, OPERATOR_PASSWORD_HASH ?? null, appviewUrl, PDS_HOSTNAME!, db); registerAccountRoutes(app, pds, db, labelers); +registerOperatorRoutes(app, db, PDS_HOSTNAME!, oauth, dashboardUrl); registerStatusRoutes(app, pds, relay, PDS_HOSTNAME!, db); registerPasskeyRoutes(app, db, PDS_HOSTNAME!); registerAuditRoutes(app); diff --git a/server/src/notifier.ts b/server/src/notifier.ts index 930a93c..edcef18 100644 --- a/server/src/notifier.ts +++ b/server/src/notifier.ts @@ -20,7 +20,7 @@ interface Facet { features: Record[]; } -async function resolveHandleToDid(pdsHostname: string, handle: string): Promise { +export async function resolveHandleToDid(pdsHostname: string, handle: string): Promise { const res = await fetch( `https://${pdsHostname}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, ); diff --git a/server/src/oauth.ts b/server/src/oauth.ts new file mode 100644 index 0000000..0917a80 --- /dev/null +++ b/server/src/oauth.ts @@ -0,0 +1,56 @@ +import { MemoryStore, OAuthClient, type StoredState } from "@atcute/oauth-node-client"; +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + LocalActorResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver, +} from "@atcute/identity-resolver"; +import { NodeDnsHandleResolver } from "@atcute/identity-resolver-node"; + +/** + * atproto OAuth is used purely to prove control of a DID during admin enrollment, + * so the scope is just "atproto" (identity only, no resource grants) and tokens are + * revoked as soon as the callback has read the DID. + * + * Prod (https dashboard) runs as a discoverable public client whose metadata is + * served by this server. Dev (http dashboard) runs as a loopback client, where the + * client_id is derived from the redirect URI and no hosted metadata is needed. + * Loopback redirect URIs must use 127.0.0.1, not localhost. + */ +export function createOAuthClient(dashboardUrl: string) { + const isLoopback = dashboardUrl.startsWith("http://"); + const redirectUri = isLoopback + ? `${dashboardUrl.replace("//localhost", "//127.0.0.1")}/api/oauth/callback` + : `${dashboardUrl}/api/oauth/callback`; + + return new OAuthClient({ + metadata: { + ...(isLoopback ? {} : { client_id: `${dashboardUrl}/oauth/client-metadata.json` }), + redirect_uris: [redirectUri], + scope: "atproto", + }, + // no keyset: public client, which is plenty for one-shot authentication + stores: { + sessions: new MemoryStore(), + states: new MemoryStore({ maxSize: 100, ttl: 10 * 60_000 }), + }, + actorResolver: new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + methods: { + dns: new NodeDnsHandleResolver(), + http: new WellKnownHandleResolver(), + }, + }), + didDocumentResolver: new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, + }), + }), + }); +} + +export type DashboardOAuthClient = ReturnType; diff --git a/server/src/routes/operators.ts b/server/src/routes/operators.ts new file mode 100644 index 0000000..e064c88 --- /dev/null +++ b/server/src/routes/operators.ts @@ -0,0 +1,131 @@ +import type { FastifyInstance } from "fastify"; +import { isHandle, type Did } from "@atcute/lexicons/syntax"; +import { createEnrollToken, type Db } from "../db.js"; +import type { DashboardOAuthClient } from "../oauth.js"; +import { resolveHandleToDid } from "../notifier.js"; +import { requireAuth } from "../auth.js"; +import { recordAction } from "../auditLog.js"; + +interface OperatorRow { + did: string; + handle: string; + added_at: number; + enrolled_at: number | null; +} + +export function registerOperatorRoutes( + app: FastifyInstance, + db: Db, + pdsHostname: string, + oauth: DashboardOAuthClient, + dashboardUrl: string, +) { + const pendingExists = () => + Boolean(db.prepare("SELECT 1 FROM operators WHERE enrolled_at IS NULL LIMIT 1").get()); + + // the PDS (and any authorization server) fetches this to learn about the client + app.get("/oauth/client-metadata.json", async () => oauth.metadata); + + // ---- enrollment flow: OAuth proves the DID, then a passkey is created ---- + + app.get("/api/oauth/start", async (req, reply) => { + const raw = (req.query as { handle?: string }).handle ?? ""; + const handle = raw.trim().replace(/^@/, "").toLowerCase(); + if (!isHandle(handle)) return reply.code(400).send({ error: "invalid handle" }); + // don't bounce anyone to their PDS unless there is actually an open invite; + // the callback re-checks the specific DID either way + if (!pendingExists()) return reply.code(403).send({ error: "no pending admins" }); + try { + const { url } = await oauth.authorize({ + target: { type: "account", identifier: handle }, + }); + return reply.redirect(url.toString()); + } catch (err) { + req.log.warn({ err, handle }, "oauth authorize failed"); + return reply.redirect(errorUrl("could not reach that account's server")); + } + }); + + // redirects out of the callback are absolute: in dev the callback runs on the + // 127.0.0.1 loopback origin (required by the OAuth spec) but WebAuthn and the + // operator's session live on the dashboard origin, so send them back there + const errorUrl = (msg: string) => `${dashboardUrl}/?adminError=${encodeURIComponent(msg)}`; + + app.get("/api/oauth/callback", async (req, reply) => { + const params = new URLSearchParams(req.query as Record); + let did: Did; + try { + const { session } = await oauth.callback(params); + did = session.did; + // authentication only: the tokens have done their job the moment we know the DID + void oauth.revoke(did).catch(() => {}); + } catch (err) { + req.log.warn({ err }, "oauth callback failed"); + return reply.redirect(errorUrl("sign-in failed, try again")); + } + const row = db.prepare("SELECT * FROM operators WHERE did = ?").get(did) as + | OperatorRow + | undefined; + if (!row) { + return reply.redirect(errorUrl("this account is not an invited admin")); + } + if (row.enrolled_at != null) { + return reply.redirect(errorUrl("this admin is already enrolled, sign in with your passkey")); + } + // hand the proven DID to the enrollment flow as a one-time token, the same + // mechanism CLI enroll links use + const { token } = createEnrollToken(db, did); + return reply.redirect(`${dashboardUrl}/?enroll=${token}`); + }); + + // ---- admin management ---------------------------------------------------- + + app.get("/api/operators", { preHandler: requireAuth }, async () => { + const rows = db.prepare("SELECT * FROM operators ORDER BY added_at").all() as OperatorRow[]; + const count = db.prepare("SELECT COUNT(*) AS n FROM passkeys WHERE operator_did = ?"); + return { + operators: rows.map((r) => ({ + did: r.did, + handle: r.handle, + addedAt: r.added_at, + enrolledAt: r.enrolled_at, + passkeys: (count.get(r.did) as { n: number }).n, + })), + }; + }); + + app.post("/api/operators", { preHandler: requireAuth }, async (req, reply) => { + const raw = (req.body as { handle?: string })?.handle ?? ""; + const handle = raw.trim().replace(/^@/, "").toLowerCase(); + if (!handle) return reply.code(400).send({ error: "handle required" }); + let did: string; + try { + did = await resolveHandleToDid(pdsHostname, handle); + } catch { + return reply.code(400).send({ error: `could not resolve ${handle}` }); + } + const exists = db.prepare("SELECT 1 FROM operators WHERE did = ?").get(did); + if (exists) return reply.code(409).send({ error: "already an admin" }); + db.prepare("INSERT INTO operators (did, handle, added_at) VALUES (?, ?, ?)").run( + did, + handle, + Date.now(), + ); + await recordAction({ operator: req.session.operator!, action: "admin-added", target: did }); + return { ok: true, did }; + }); + + // removing and re-adding an admin is also the recovery path: it deletes their + // passkeys and reopens the one-shot OAuth enrollment + app.delete("/api/operators/:did", { preHandler: requireAuth }, async (req, reply) => { + const { did } = req.params as { did: string }; + const row = db.prepare("SELECT 1 FROM operators WHERE did = ?").get(did); + if (!row) return reply.code(404).send({ error: "not an admin" }); + db.transaction(() => { + db.prepare("DELETE FROM passkeys WHERE operator_did = ?").run(did); + db.prepare("DELETE FROM operators WHERE did = ?").run(did); + })(); + await recordAction({ operator: req.session.operator!, action: "admin-removed", target: did }); + return { ok: true }; + }); +} diff --git a/server/src/routes/passkeys.ts b/server/src/routes/passkeys.ts index c6b5447..fb1d820 100644 --- a/server/src/routes/passkeys.ts +++ b/server/src/routes/passkeys.ts @@ -6,7 +6,7 @@ import { verifyAuthenticationResponse, type AuthenticatorTransportFuture, } from "@simplewebauthn/server"; -import { checkEnrollToken, consumeEnrollToken, type Db } from "../db.js"; +import { checkEnrollToken, consumeEnrollToken, peekEnrollToken, type Db } from "../db.js"; import { requireAuth } from "../auth.js"; import { recordAction } from "../auditLog.js"; @@ -23,6 +23,7 @@ interface PasskeyRow { transports: string | null; name: string | null; created_at: number; + operator_did: string | null; } // The dashboard runs on localhost in dev and its real domain in prod; derive the @@ -36,9 +37,22 @@ function rpFromRequest(req: FastifyRequest): { rpID: string; origin: string } { export function registerPasskeyRoutes(app: FastifyInstance, db: Db, pdsHostname: string) { const allPasskeys = () => db.prepare("SELECT * FROM passkeys").all() as PasskeyRow[]; - // Enrollment is authorized by an operator session OR a one-time token minted by - // the CLI (`npm run enroll`) — the bootstrap path for passkey-only deployments. - // Guessing tokens is rate-limited per IP on top of the 15-minute expiry. + /** The pending operator a valid OAuth-minted enroll token belongs to, if any. */ + const tokenOperator = (req: FastifyRequest): { did: string; handle: string } | null => { + const token = (req.body as { enrollToken?: string } | null)?.enrollToken; + if (!token) return null; + const info = peekEnrollToken(db, token); + if (!info?.operatorDid) return null; + const row = db + .prepare("SELECT handle FROM operators WHERE did = ? AND enrolled_at IS NULL") + .get(info.operatorDid) as { handle: string } | undefined; + return row ? { did: info.operatorDid, handle: row.handle } : null; + }; + + // Enrollment is authorized by an operator session OR a one-time token — minted by + // the CLI (`npm run enroll`, the bootstrap path) or by the OAuth callback for an + // invited admin. Guessing tokens is rate-limited per IP on top of the 15-minute + // expiry. const enrollAttempts = new Map(); function enrollAuthorized(req: FastifyRequest): boolean { if (req.session.operator) return true; @@ -63,7 +77,7 @@ export function registerPasskeyRoutes(app: FastifyInstance, db: Db, pdsHostname: const options = await generateRegistrationOptions({ rpName: `${pdsHostname} admin`, rpID, - userName: "operator", + userName: tokenOperator(req)?.handle ?? req.session.operator ?? "operator", attestationType: "none", excludeCredentials: allPasskeys().map((p) => ({ id: p.id, @@ -100,17 +114,42 @@ export function registerPasskeyRoutes(app: FastifyInstance, db: Db, pdsHostname: return reply.code(400).send({ error: "passkey verification failed" }); } - // consume the one-time token only after a verified registration + // bind the passkey to its owner. tokens are consumed only after the verified + // registration: a CLI token signs in as the generic operator, an OAuth-minted + // token carries the invited admin's DID and completes their one-shot enrollment + let operatorDid: string | null = null; if (!req.session.operator) { - if (!enrollToken || !consumeEnrollToken(db, enrollToken)) { + const consumed = enrollToken ? consumeEnrollToken(db, enrollToken) : null; + if (!consumed) { return reply.code(401).send({ error: "enrollment link expired" }); } - req.session.operator = "operator"; // enrolling the passkey signs you in + if (consumed.operatorDid) { + const op = db + .prepare("SELECT handle FROM operators WHERE did = ? AND enrolled_at IS NULL") + .get(consumed.operatorDid) as { handle: string } | undefined; + if (!op) { + return reply.code(401).send({ error: "this admin enrollment is no longer open" }); + } + operatorDid = consumed.operatorDid; + db.prepare("UPDATE operators SET enrolled_at = ? WHERE did = ?").run( + Date.now(), + consumed.operatorDid, + ); + req.session.operator = op.handle; + } else { + req.session.operator = "operator"; + } + } else { + // an already signed-in named admin adding another device keeps ownership + const owner = db + .prepare("SELECT did FROM operators WHERE handle = ?") + .get(req.session.operator) as { did: string } | undefined; + operatorDid = owner?.did ?? null; } const { credential } = verification.registrationInfo; db.prepare( - "INSERT INTO passkeys (id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?)", + "INSERT INTO passkeys (id, public_key, counter, transports, name, created_at, operator_did) VALUES (?, ?, ?, ?, ?, ?, ?)", ).run( credential.id, Buffer.from(credential.publicKey), @@ -118,6 +157,7 @@ export function registerPasskeyRoutes(app: FastifyInstance, db: Db, pdsHostname: JSON.stringify(credential.transports ?? []), name?.trim() || null, Date.now(), + operatorDid, ); await recordAction({ operator: req.session.operator!, @@ -128,11 +168,15 @@ export function registerPasskeyRoutes(app: FastifyInstance, db: Db, pdsHostname: }); app.get("/api/passkeys", { preHandler: requireAuth }, async () => { + const handleFor = db.prepare("SELECT handle FROM operators WHERE did = ?"); return { passkeys: allPasskeys().map((p) => ({ id: p.id, name: p.name ?? "unnamed", createdAt: p.created_at, + operator: p.operator_did + ? ((handleFor.get(p.operator_did) as { handle: string } | undefined)?.handle ?? null) + : null, })), }; }); @@ -200,7 +244,12 @@ export function registerPasskeyRoutes(app: FastifyInstance, db: Db, pdsHostname: verification.authenticationInfo.newCounter, row.id, ); - req.session.operator = "operator"; + const owner = row.operator_did + ? (db.prepare("SELECT handle FROM operators WHERE did = ?").get(row.operator_did) as + | { handle: string } + | undefined) + : undefined; + req.session.operator = owner?.handle ?? "operator"; return { ok: true }; }); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 9e59eab..0c82120 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { RequestCrawl } from "./RequestCrawl.js"; import { SyncFooter } from "./SyncFooter.js"; import { InvitesPanel } from "./InvitesPanel.js"; import { StatsPanel } from "./StatsPanel.js"; +import { OperatorsPanel } from "./OperatorsPanel.js"; import { Enroll } from "./Enroll.js"; export function App() { @@ -16,6 +17,7 @@ export function App() { const [appviewUrl, setAppviewUrl] = useState("https://bsky.app"); const [pdsHostname, setPdsHostname] = useState(""); const [passwordLogin, setPasswordLogin] = useState(true); + const [pendingAdmins, setPendingAdmins] = useState(false); const [lastFullSync, setLastFullSync] = useState(null); // object wrapper so clicking the same handle twice still retriggers the search const [accountSearch, setAccountSearch] = useState<{ q: string } | null>(null); @@ -31,6 +33,7 @@ export function App() { if (s.appviewUrl) setAppviewUrl(s.appviewUrl); if (s.pdsHostname) setPdsHostname(s.pdsHostname); setPasswordLogin(s.passwordLogin ?? true); + setPendingAdmins(s.pendingAdmins ?? false); }) .catch(() => setAuthed(false)); }, []); @@ -50,6 +53,7 @@ export function App() { setAuthed(true)} /> ); @@ -74,6 +78,7 @@ export function App() { setAccountSearch({ q: handle })} /> + diff --git a/web/src/Enroll.tsx b/web/src/Enroll.tsx index f617183..b254060 100644 --- a/web/src/Enroll.tsx +++ b/web/src/Enroll.tsx @@ -7,6 +7,7 @@ export function Enroll({ pdsHostname, onEnrolled, }: { + /** one-time token, minted by the CLI or by the admin OAuth callback */ token: string; pdsHostname: string; onEnrolled: () => void; diff --git a/web/src/Login.tsx b/web/src/Login.tsx index ad826e8..8fc5e6c 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -5,15 +5,24 @@ import { api } from "./api.js"; export function Login({ pdsHostname, passwordLogin, + pendingAdmins, onLoggedIn, }: { pdsHostname: string; passwordLogin: boolean; + pendingAdmins: boolean; onLoggedIn: () => void; }) { const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(null); + // invited admins prove their DID via atproto OAuth before creating a passkey + const [showNewAdmin, setShowNewAdmin] = useState(false); + const [adminHandle, setAdminHandle] = useState(""); + const [error, setError] = useState(() => { + const err = new URLSearchParams(window.location.search).get("adminError"); + if (err) window.history.replaceState(null, "", "/"); + return err; + }); const [busy, setBusy] = useState(false); const submit = async (e: React.FormEvent) => { @@ -73,6 +82,32 @@ export function Login({ use password ))} + {pendingAdmins && + (showNewAdmin ? ( + <> + setAdminHandle(e.target.value)} + autoFocus + /> + + + ) : ( + + ))} {error && {error}} diff --git a/web/src/OperatorsPanel.tsx b/web/src/OperatorsPanel.tsx new file mode 100644 index 0000000..29be52d --- /dev/null +++ b/web/src/OperatorsPanel.tsx @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "./api.js"; + +interface Operator { + did: string; + handle: string; + addedAt: number; + enrolledAt: number | null; + passkeys: number; +} + +export function OperatorsPanel() { + const [operators, setOperators] = useState([]); + const [handle, setHandle] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmDid, setConfirmDid] = useState(null); + + const refresh = useCallback(() => { + api + .operators() + .then((r) => setOperators(r.operators)) + .catch((e) => setError(e.message)); + }, []); + + useEffect(refresh, [refresh]); + + const add = async () => { + setBusy(true); + setError(null); + try { + await api.operatorAdd(handle.trim()); + setHandle(""); + refresh(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + const remove = async (did: string) => { + setBusy(true); + setError(null); + setConfirmDid(null); + try { + await api.operatorRemove(did); + refresh(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+

Admins

+ {error &&
{error}
} + {operators.length === 0 && ( +

+ no admins yet. add one by handle and have them sign in using the new admin button to + create a passkey. +

+ )} + {operators.map((o) => ( +
+ + @{o.handle} + {o.enrolledAt == null && invited} + + + {o.enrolledAt != null + ? `enrolled ${new Date(o.enrolledAt).toLocaleDateString()}` + : `added ${new Date(o.addedAt).toLocaleDateString()}`} + + {confirmDid === o.did ? ( + + + + + ) : ( + + )} +
+ ))} +
+ setHandle(e.target.value)} + /> + +
+

+ removing and re-adding an admin resets their enrollment (deletes their passkeys). +

+
+ ); +} diff --git a/web/src/PasskeysPanel.tsx b/web/src/PasskeysPanel.tsx index c2c0739..b2ce761 100644 --- a/web/src/PasskeysPanel.tsx +++ b/web/src/PasskeysPanel.tsx @@ -6,6 +6,7 @@ interface Passkey { id: string; name: string; createdAt: number; + operator: string | null; } export function PasskeysPanel() { @@ -62,7 +63,10 @@ export function PasskeysPanel() { )} {passkeys.map((p) => (
- {p.name} + + {p.name} + {p.operator && @{p.operator}} + {new Date(p.createdAt).toLocaleDateString()}