From edf126edbdc67f20d7f30e51814629d09e67febc Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 29 Mar 2026 16:01:35 +0100 Subject: [PATCH 1/4] feat(pds): add app password support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement AT Protocol app passwords for client authentication. - Add app_passwords storage table with bcrypt-hashed passwords - Implement com.atproto.server.createAppPassword endpoint - Implement com.atproto.server.listAppPasswords endpoint - Implement com.atproto.server.revokeAppPassword endpoint - Support app password login via createSession - Add CLI commands: app-password create/list/revoke - Generate passwords in xxxx-xxxx-xxxx-xxxx format - Non-privileged only (single-user PDS) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/app-passwords.md | 5 + packages/pds/src/account-do.ts | 35 ++++ .../src/cli/commands/app-password/create.ts | 143 +++++++++++++++ .../src/cli/commands/app-password/index.ts | 19 ++ .../pds/src/cli/commands/app-password/list.ts | 130 ++++++++++++++ .../src/cli/commands/app-password/revoke.ts | 167 +++++++++++++++++ packages/pds/src/cli/index.ts | 2 + packages/pds/src/cli/utils/pds-client.ts | 120 +++++++++++++ packages/pds/src/index.ts | 17 ++ packages/pds/src/storage.ts | 69 +++++++ packages/pds/src/xrpc/server.ts | 168 ++++++++++++++++-- 11 files changed, 865 insertions(+), 10 deletions(-) create mode 100644 .changeset/app-passwords.md create mode 100644 packages/pds/src/cli/commands/app-password/create.ts create mode 100644 packages/pds/src/cli/commands/app-password/index.ts create mode 100644 packages/pds/src/cli/commands/app-password/list.ts create mode 100644 packages/pds/src/cli/commands/app-password/revoke.ts diff --git a/.changeset/app-passwords.md b/.changeset/app-passwords.md new file mode 100644 index 00000000..73bfee69 --- /dev/null +++ b/.changeset/app-passwords.md @@ -0,0 +1,5 @@ +--- +"@getcirrus/pds": minor +--- + +Add app password support for AT Protocol client authentication. Implements `com.atproto.server.createAppPassword`, `listAppPasswords`, `revokeAppPassword`, and login via app passwords. Includes CLI commands for creating, listing, and revoking app passwords. diff --git a/packages/pds/src/account-do.ts b/packages/pds/src/account-do.ts index f4bad16b..d072839d 100644 --- a/packages/pds/src/account-do.ts +++ b/packages/pds/src/account-do.ts @@ -1574,6 +1574,41 @@ export class AccountDurableObject extends DurableObject { return oauthStorage.consumeWebAuthnChallenge(challenge); } + // ============================================ + // App Password RPC Methods + // ============================================ + + /** Save an app password (bcrypt hash) */ + async rpcSaveAppPassword( + name: string, + passwordHash: string, + ): Promise { + const storage = await this.getStorage(); + storage.saveAppPassword(name, passwordHash); + } + + /** List all app passwords (names and dates only) */ + async rpcListAppPasswords(): Promise< + Array<{ name: string; createdAt: string }> + > { + const storage = await this.getStorage(); + return storage.listAppPasswords(); + } + + /** Delete an app password by name */ + async rpcDeleteAppPassword(name: string): Promise { + const storage = await this.getStorage(); + return storage.deleteAppPassword(name); + } + + /** Get all app password hashes for login verification */ + async rpcGetAppPasswordHashes(): Promise< + Array<{ name: string; passwordHash: string }> + > { + const storage = await this.getStorage(); + return storage.getAppPasswordHashes(); + } + /** * HTTP fetch handler for WebSocket upgrades and streaming responses. * Used instead of RPC when the response can't be serialized (WebSocket) diff --git a/packages/pds/src/cli/commands/app-password/create.ts b/packages/pds/src/cli/commands/app-password/create.ts new file mode 100644 index 00000000..bdef87ba --- /dev/null +++ b/packages/pds/src/cli/commands/app-password/create.ts @@ -0,0 +1,143 @@ +/** + * Create app password command + */ +import { defineCommand } from "citty"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { getVars } from "../../utils/wrangler.js"; +import { readDevVars } from "../../utils/dotenv.js"; +import { PDSClient } from "../../utils/pds-client.js"; +import { + getTargetUrl, + getDomain, + promptText, + copyToClipboard, +} from "../../utils/cli-helpers.js"; + +export const createCommand = defineCommand({ + meta: { + name: "create", + description: "Create a new app password", + }, + args: { + dev: { + type: "boolean", + description: "Target local development server instead of production", + default: false, + }, + name: { + type: "string", + alias: "n", + description: + "Name for this app password (e.g., 'Graysky', 'Croissant')", + }, + }, + async run({ args }) { + const isDev = args.dev; + + p.intro("🔑 Create App Password"); + + // Get target URL + const vars = getVars(); + let targetUrl: string; + try { + targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME); + } catch (err) { + p.log.error( + err instanceof Error ? err.message : "Configuration error", + ); + p.log.info("Run 'pds init' first to configure your PDS."); + process.exit(1); + } + + const targetDomain = getDomain(targetUrl); + + // Load config + const wranglerVars = getVars(); + const devVars = readDevVars(); + const config = { ...devVars, ...wranglerVars }; + + const authToken = config.AUTH_TOKEN; + + if (!authToken) { + p.log.error("No AUTH_TOKEN found. Run 'pds init' first."); + p.outro("Cancelled."); + process.exit(1); + } + + // Get name + let passwordName: string | undefined = args.name; + if (!passwordName) { + const nameInput = await promptText({ + message: "Name for this app password:", + placeholder: "Graysky, Croissant, etc.", + }); + if (!nameInput) { + p.cancel("Name is required."); + process.exit(1); + } + passwordName = nameInput; + } + + // Create client + const client = new PDSClient(targetUrl, authToken); + + // Check if PDS is reachable + const spinner = p.spinner(); + spinner.start(`Checking PDS at ${targetDomain}...`); + + const isHealthy = await client.healthCheck(); + if (!isHealthy) { + spinner.stop(`PDS not responding at ${targetDomain}`); + p.log.error(`Your PDS isn't responding at ${targetUrl}`); + p.outro("Cancelled."); + process.exit(1); + } + + spinner.stop(`Connected to ${targetDomain}`); + + // Create app password + spinner.start("Creating app password..."); + let result: { name: string; password: string; createdAt: string }; + try { + result = await client.createAppPassword(passwordName); + spinner.stop("App password created"); + } catch (err: unknown) { + spinner.stop("Failed to create app password"); + let errorMessage = "Could not create app password"; + if (err instanceof Error) { + errorMessage = err.message; + } + const errObj = err as { data?: { message?: string; error?: string } }; + if (errObj?.data?.message) { + errorMessage = errObj.data.message; + } else if (errObj?.data?.error) { + errorMessage = errObj.data.error; + } + p.log.error(errorMessage); + p.outro("Cancelled."); + process.exit(1); + } + + // Display the password + p.log.info(""); + p.log.success( + `App password ${pc.bold(`"${result.name}"`)} created.`, + ); + p.log.info(""); + p.note(result.password, "App Password"); + p.log.info(""); + + // Try to copy to clipboard + const copied = await copyToClipboard(result.password); + if (copied) { + p.log.info(pc.dim("Copied to clipboard.")); + } + + p.log.warn( + "This password will not be shown again. Save it now.", + ); + + p.outro("Done!"); + }, +}); diff --git a/packages/pds/src/cli/commands/app-password/index.ts b/packages/pds/src/cli/commands/app-password/index.ts new file mode 100644 index 00000000..6bd5d11d --- /dev/null +++ b/packages/pds/src/cli/commands/app-password/index.ts @@ -0,0 +1,19 @@ +/** + * App password management commands + */ +import { defineCommand } from "citty"; +import { createCommand } from "./create.js"; +import { listCommand } from "./list.js"; +import { revokeCommand } from "./revoke.js"; + +export const appPasswordCommand = defineCommand({ + meta: { + name: "app-password", + description: "Manage app passwords for third-party client access", + }, + subCommands: { + create: createCommand, + list: listCommand, + revoke: revokeCommand, + }, +}); diff --git a/packages/pds/src/cli/commands/app-password/list.ts b/packages/pds/src/cli/commands/app-password/list.ts new file mode 100644 index 00000000..a19719ba --- /dev/null +++ b/packages/pds/src/cli/commands/app-password/list.ts @@ -0,0 +1,130 @@ +/** + * List app passwords command + */ +import { defineCommand } from "citty"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { getVars } from "../../utils/wrangler.js"; +import { readDevVars } from "../../utils/dotenv.js"; +import { PDSClient } from "../../utils/pds-client.js"; +import { getTargetUrl, getDomain } from "../../utils/cli-helpers.js"; + +/** + * Format a date as yyyy-mm-dd hh:mm + */ +function formatDateTime(isoString: string): string { + const d = new Date(isoString); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hours = String(d.getHours()).padStart(2, "0"); + const minutes = String(d.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +export const listCommand = defineCommand({ + meta: { + name: "list", + description: "List all app passwords", + }, + args: { + dev: { + type: "boolean", + description: "Target local development server instead of production", + default: false, + }, + }, + async run({ args }) { + const isDev = args.dev; + + p.intro("🔑 App Passwords"); + + // Get target URL + const vars = getVars(); + let targetUrl: string; + try { + targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME); + } catch (err) { + p.log.error( + err instanceof Error ? err.message : "Configuration error", + ); + p.log.info("Run 'pds init' first to configure your PDS."); + process.exit(1); + } + + const targetDomain = getDomain(targetUrl); + + // Load config + const wranglerVars = getVars(); + const devVars = readDevVars(); + const config = { ...devVars, ...wranglerVars }; + + const authToken = config.AUTH_TOKEN; + + if (!authToken) { + p.log.error("No AUTH_TOKEN found. Run 'pds init' first."); + p.outro("Cancelled."); + process.exit(1); + } + + // Create client + const client = new PDSClient(targetUrl, authToken); + + // Check if PDS is reachable + const spinner = p.spinner(); + spinner.start(`Checking PDS at ${targetDomain}...`); + + const isHealthy = await client.healthCheck(); + if (!isHealthy) { + spinner.stop(`PDS not responding at ${targetDomain}`); + p.log.error(`Your PDS isn't responding at ${targetUrl}`); + p.outro("Cancelled."); + process.exit(1); + } + + spinner.stop(`Connected to ${targetDomain}`); + + // List app passwords + spinner.start("Fetching app passwords..."); + let result: { + passwords: Array<{ name: string; createdAt: string }>; + }; + try { + result = await client.listAppPasswords(); + spinner.stop("App passwords retrieved"); + } catch (err) { + spinner.stop("Failed to fetch app passwords"); + p.log.error( + err instanceof Error + ? err.message + : "Could not fetch app passwords", + ); + p.outro("Failed."); + process.exit(1); + } + + if (result.passwords.length === 0) { + p.log.info("No app passwords."); + p.log.info( + `Run ${pc.cyan("pds app-password create")} to create one.`, + ); + } else { + p.log.info(""); + p.log.info(`${pc.bold("App passwords:")}`); + p.log.info(""); + + for (const pw of result.passwords) { + const created = formatDateTime(pw.createdAt); + console.log(` ${pc.green("●")} ${pc.bold(pw.name)}`); + console.log(` ${pc.dim("Created:")} ${created}`); + console.log(""); + } + + p.log.info( + pc.dim(`Total: ${result.passwords.length} app password(s)`), + ); + } + + p.outro("Done!"); + }, +}); diff --git a/packages/pds/src/cli/commands/app-password/revoke.ts b/packages/pds/src/cli/commands/app-password/revoke.ts new file mode 100644 index 00000000..c2eeec70 --- /dev/null +++ b/packages/pds/src/cli/commands/app-password/revoke.ts @@ -0,0 +1,167 @@ +/** + * Revoke app password command + */ +import { defineCommand } from "citty"; +import * as p from "@clack/prompts"; +import { getVars } from "../../utils/wrangler.js"; +import { readDevVars } from "../../utils/dotenv.js"; +import { PDSClient } from "../../utils/pds-client.js"; +import { getTargetUrl, getDomain } from "../../utils/cli-helpers.js"; + +export const revokeCommand = defineCommand({ + meta: { + name: "revoke", + description: "Revoke an app password", + }, + args: { + dev: { + type: "boolean", + description: "Target local development server instead of production", + default: false, + }, + name: { + type: "string", + alias: "n", + description: "Name of the app password to revoke", + }, + yes: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, + }, + async run({ args }) { + const isDev = args.dev; + const skipConfirm = args.yes; + + p.intro("🔑 Revoke App Password"); + + // Get target URL + const vars = getVars(); + let targetUrl: string; + try { + targetUrl = getTargetUrl(isDev, vars.PDS_HOSTNAME); + } catch (err) { + p.log.error( + err instanceof Error ? err.message : "Configuration error", + ); + p.log.info("Run 'pds init' first to configure your PDS."); + process.exit(1); + } + + const targetDomain = getDomain(targetUrl); + + // Load config + const wranglerVars = getVars(); + const devVars = readDevVars(); + const config = { ...devVars, ...wranglerVars }; + + const authToken = config.AUTH_TOKEN; + + if (!authToken) { + p.log.error("No AUTH_TOKEN found. Run 'pds init' first."); + p.outro("Cancelled."); + process.exit(1); + } + + // Create client + const client = new PDSClient(targetUrl, authToken); + + // Check if PDS is reachable + const spinner = p.spinner(); + spinner.start(`Checking PDS at ${targetDomain}...`); + + const isHealthy = await client.healthCheck(); + if (!isHealthy) { + spinner.stop(`PDS not responding at ${targetDomain}`); + p.log.error(`Your PDS isn't responding at ${targetUrl}`); + p.outro("Cancelled."); + process.exit(1); + } + + spinner.stop(`Connected to ${targetDomain}`); + + // If no name provided, list and let user choose + let passwordName = args.name; + + if (!passwordName) { + spinner.start("Fetching app passwords..."); + let result: { + passwords: Array<{ name: string; createdAt: string }>; + }; + try { + result = await client.listAppPasswords(); + spinner.stop("App passwords retrieved"); + } catch (err) { + spinner.stop("Failed to fetch app passwords"); + p.log.error( + err instanceof Error + ? err.message + : "Could not fetch app passwords", + ); + p.outro("Failed."); + process.exit(1); + } + + if (result.passwords.length === 0) { + p.log.info("No app passwords to revoke."); + p.outro("Done!"); + return; + } + + // Build options for selection + const options = result.passwords.map((pw) => ({ + value: pw.name, + label: pw.name, + hint: `Created ${new Date(pw.createdAt).toLocaleDateString()}`, + })); + + const selected = await p.select({ + message: "Select app password to revoke:", + options, + }); + + if (p.isCancel(selected)) { + p.cancel("Cancelled."); + process.exit(0); + } + + passwordName = selected; + } + + // Confirm deletion + if (!skipConfirm) { + const confirm = await p.confirm({ + message: `Revoke app password "${passwordName}"? Any client using it will lose access.`, + initialValue: false, + }); + + if (p.isCancel(confirm) || !confirm) { + p.cancel("Cancelled."); + process.exit(0); + } + } + + // Revoke the app password + spinner.start("Revoking app password..."); + try { + await client.revokeAppPassword(passwordName); + spinner.stop("App password revoked"); + p.log.success( + `App password "${passwordName}" has been revoked.`, + ); + } catch (err) { + spinner.stop("Failed to revoke app password"); + p.log.error( + err instanceof Error + ? err.message + : "Could not revoke app password", + ); + p.outro("Failed."); + process.exit(1); + } + + p.outro("Done!"); + }, +}); diff --git a/packages/pds/src/cli/index.ts b/packages/pds/src/cli/index.ts index 06fb30fd..ff7bd80b 100644 --- a/packages/pds/src/cli/index.ts +++ b/packages/pds/src/cli/index.ts @@ -5,6 +5,7 @@ import { defineCommand, runMain } from "citty"; import { secretCommand } from "./commands/secret/index.js"; import { passkeyCommand } from "./commands/passkey/index.js"; +import { appPasswordCommand } from "./commands/app-password/index.js"; import { initCommand } from "./commands/init.js"; import { migrateCommand } from "./commands/migrate.js"; import { migrateTokenCommand } from "./commands/migrate-token.js"; @@ -25,6 +26,7 @@ const main = defineCommand({ init: initCommand, secret: secretCommand, passkey: passkeyCommand, + "app-password": appPasswordCommand, migrate: migrateCommand, "migrate-token": migrateTokenCommand, identity: identityCommand, diff --git a/packages/pds/src/cli/utils/pds-client.ts b/packages/pds/src/cli/utils/pds-client.ts index 6e5b41a2..cd3c0a61 100644 --- a/packages/pds/src/cli/utils/pds-client.ts +++ b/packages/pds/src/cli/utils/pds-client.ts @@ -722,6 +722,126 @@ export class PDSClient { // Relay Operations // ============================================ + // ============================================ + // App Password Operations + // ============================================ + + /** + * Create a new app password + */ + async createAppPassword(name: string): Promise<{ + name: string; + password: string; + createdAt: string; + }> { + const url = new URL( + "/xrpc/com.atproto.server.createAppPassword", + this.baseUrl, + ); + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.authToken) { + headers["Authorization"] = `Bearer ${this.authToken}`; + } + const res = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ name }), + }); + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as { + error?: string; + message?: string; + }; + throw new ClientResponseError({ + status: res.status, + headers: res.headers, + data: { + error: errorBody.error ?? "Unknown", + message: errorBody.message, + }, + }); + } + return res.json() as Promise<{ + name: string; + password: string; + createdAt: string; + }>; + } + + /** + * List all app passwords + */ + async listAppPasswords(): Promise<{ + passwords: Array<{ name: string; createdAt: string }>; + }> { + const url = new URL( + "/xrpc/com.atproto.server.listAppPasswords", + this.baseUrl, + ); + const headers: Record = {}; + if (this.authToken) { + headers["Authorization"] = `Bearer ${this.authToken}`; + } + const res = await fetch(url.toString(), { + method: "GET", + headers, + }); + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as { + error?: string; + message?: string; + }; + throw new ClientResponseError({ + status: res.status, + headers: res.headers, + data: { + error: errorBody.error ?? "Unknown", + message: errorBody.message, + }, + }); + } + return res.json() as Promise<{ + passwords: Array<{ name: string; createdAt: string }>; + }>; + } + + /** + * Revoke an app password by name + */ + async revokeAppPassword(name: string): Promise { + const url = new URL( + "/xrpc/com.atproto.server.revokeAppPassword", + this.baseUrl, + ); + const headers: Record = { + "Content-Type": "application/json", + }; + if (this.authToken) { + headers["Authorization"] = `Bearer ${this.authToken}`; + } + const res = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ name }), + }); + if (!res.ok) { + const errorBody = (await res.json().catch(() => ({}))) as { + error?: string; + message?: string; + }; + throw new ClientResponseError({ + status: res.status, + headers: res.headers, + data: { + error: errorBody.error ?? "Unknown", + message: errorBody.message, + }, + }); + } + } + // ============================================ // Passkey Operations // ============================================ diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index d1f1249a..04625ed2 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -337,6 +337,23 @@ app.get("/xrpc/com.atproto.server.getSession", (c) => ); app.post("/xrpc/com.atproto.server.deleteSession", server.deleteSession); +// App passwords +app.post( + "/xrpc/com.atproto.server.createAppPassword", + requireAuth, + (c) => server.createAppPassword(c, getAccountDO(c.env)), +); +app.get( + "/xrpc/com.atproto.server.listAppPasswords", + requireAuth, + (c) => server.listAppPasswords(c, getAccountDO(c.env)), +); +app.post( + "/xrpc/com.atproto.server.revokeAppPassword", + requireAuth, + (c) => server.revokeAppPassword(c, getAccountDO(c.env)), +); + // Account lifecycle app.get("/xrpc/com.atproto.server.checkAccountStatus", requireAuth, (c) => server.checkAccountStatus(c, getAccountDO(c.env)), diff --git a/packages/pds/src/storage.ts b/packages/pds/src/storage.ts index 99fcc679..5e934ae7 100644 --- a/packages/pds/src/storage.ts +++ b/packages/pds/src/storage.ts @@ -103,6 +103,13 @@ export class SqliteRepoStorage expires_at INTEGER NOT NULL, name TEXT ); + + -- App passwords (AT Protocol com.atproto.server.createAppPassword) + CREATE TABLE IF NOT EXISTS app_passwords ( + name TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); // Migration: add email column for existing databases @@ -673,4 +680,66 @@ export class SqliteRepoStorage Date.now(), ); } + + // ============================================ + // App Password Methods + // ============================================ + + /** + * Save an app password (store bcrypt hash, not plaintext). + */ + saveAppPassword(name: string, passwordHash: string): void { + this.sql.exec( + `INSERT INTO app_passwords (name, password_hash) VALUES (?, ?)`, + name, + passwordHash, + ); + } + + /** + * List all app passwords (names and creation dates only — never return hashes). + */ + listAppPasswords(): Array<{ + name: string; + createdAt: string; + }> { + const rows = this.sql + .exec( + `SELECT name, created_at FROM app_passwords ORDER BY created_at DESC`, + ) + .toArray(); + + return rows.map((row) => ({ + name: row.name as string, + createdAt: row.created_at as string, + })); + } + + /** + * Delete an app password by name. + */ + deleteAppPassword(name: string): boolean { + const before = this.sql + .exec("SELECT COUNT(*) as c FROM app_passwords") + .one(); + this.sql.exec("DELETE FROM app_passwords WHERE name = ?", name); + const after = this.sql + .exec("SELECT COUNT(*) as c FROM app_passwords") + .one(); + return (before.c as number) > (after.c as number); + } + + /** + * Get all app password hashes for verification during login. + */ + getAppPasswordHashes(): Array<{ name: string; passwordHash: string }> { + const rows = this.sql + .exec(`SELECT name, password_hash FROM app_passwords`) + .toArray(); + + return rows.map((row) => ({ + name: row.name as string, + passwordHash: row.password_hash as string, + })); + } } diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index 2e303020..aa41d94b 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -1,4 +1,5 @@ import type { Context } from "hono"; +import { hash as bcryptHash } from "bcryptjs"; import type { AccountDurableObject } from "../account-do"; import { createServiceJwt, getSigningKeypair } from "../service-auth"; import { @@ -11,6 +12,30 @@ import { } from "../session"; import type { AppEnv, AuthedAppEnv } from "../types"; +/** + * Generate an AT Protocol app password in the format xxxx-xxxx-xxxx-xxxx. + * Uses crypto.getRandomValues for secure randomness. + */ +function generateAppPassword(): string { + const chars = "abcdefghijklmnopqrstuvwxyz"; + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + const groups: string[] = []; + for (let g = 0; g < 4; g++) { + let segment = ""; + for (let i = 0; i < 4; i++) { + segment += chars[bytes[g * 4 + i]! % chars.length]; + } + groups.push(segment); + } + return groups.join("-"); +} + +/** Check if a string looks like an app password (xxxx-xxxx-xxxx-xxxx). */ +export function isAppPassword(password: string): boolean { + return /^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/.test(password); +} + export async function describeServer(c: Context): Promise { return c.json({ did: c.env.DID, @@ -20,7 +45,8 @@ export async function describeServer(c: Context): Promise { } /** - * Create a new session (login) + * Create a new session (login). + * Accepts either the account password or an app password. */ export async function createSession( c: Context, @@ -54,16 +80,41 @@ export async function createSession( ); } - // Verify password - const passwordValid = await verifyPassword(password, c.env.PASSWORD_HASH); - if (!passwordValid) { - return c.json( - { - error: "AuthenticationRequired", - message: "Invalid identifier or password", - }, - 401, + // Try app password first if it matches the format + if (isAppPassword(password)) { + const appPasswords = await accountDO.rpcGetAppPasswordHashes(); + let matched = false; + for (const ap of appPasswords) { + const valid = await verifyPassword(password, ap.passwordHash); + if (valid) { + matched = true; + break; + } + } + if (!matched) { + return c.json( + { + error: "AuthenticationRequired", + message: "Invalid identifier or password", + }, + 401, + ); + } + } else { + // Verify account password + const passwordValid = await verifyPassword( + password, + c.env.PASSWORD_HASH, ); + if (!passwordValid) { + return c.json( + { + error: "AuthenticationRequired", + message: "Invalid identifier or password", + }, + 401, + ); + } } // Create tokens @@ -477,3 +528,100 @@ export async function resetMigration( ); } } + +/** + * Create an app password. + * com.atproto.server.createAppPassword + */ +export async function createAppPassword( + c: Context, + accountDO: DurableObjectStub, +): Promise { + const body = await c.req.json<{ name: string }>(); + + if (!body.name || body.name.trim().length === 0) { + return c.json( + { + error: "InvalidRequest", + message: "Missing required field: name", + }, + 400, + ); + } + + const name = body.name.trim(); + + // Check for duplicate names + const existing = await accountDO.rpcListAppPasswords(); + if (existing.some((p) => p.name === name)) { + return c.json( + { + error: "DuplicateName", + message: `App password with name "${name}" already exists`, + }, + 400, + ); + } + + const password = generateAppPassword(); + const passwordHash = await bcryptHash(password, 10); + + await accountDO.rpcSaveAppPassword(name, passwordHash); + + return c.json({ + name, + password, + createdAt: new Date().toISOString(), + }); +} + +/** + * List app passwords (names and dates, never the passwords themselves). + * com.atproto.server.listAppPasswords + */ +export async function listAppPasswords( + c: Context, + accountDO: DurableObjectStub, +): Promise { + const passwords = await accountDO.rpcListAppPasswords(); + return c.json({ + passwords: passwords.map((p) => ({ + name: p.name, + createdAt: p.createdAt, + })), + }); +} + +/** + * Revoke an app password by name. + * com.atproto.server.revokeAppPassword + */ +export async function revokeAppPassword( + c: Context, + accountDO: DurableObjectStub, +): Promise { + const body = await c.req.json<{ name: string }>(); + + if (!body.name) { + return c.json( + { + error: "InvalidRequest", + message: "Missing required field: name", + }, + 400, + ); + } + + const deleted = await accountDO.rpcDeleteAppPassword(body.name); + if (!deleted) { + return c.json( + { + error: "InvalidRequest", + message: `App password "${body.name}" not found`, + }, + 400, + ); + } + + return c.json({}); +} From 01178e47446ea7f3e191c5db8002665b2b2f286b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 29 Mar 2026 16:09:35 +0100 Subject: [PATCH 2/4] Add app password tests and README documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 tests covering CRUD endpoints, authentication flow, revocation, and the full create→list→auth→revoke→reject lifecycle. README updated with CLI reference and API endpoint table entries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/pds/README.md | 31 ++ packages/pds/test/app-passwords.test.ts | 550 ++++++++++++++++++++++++ 2 files changed, 581 insertions(+) create mode 100644 packages/pds/test/app-passwords.test.ts diff --git a/packages/pds/README.md b/packages/pds/README.md index b1b0577f..12710b0d 100644 --- a/packages/pds/README.md +++ b/packages/pds/README.md @@ -221,6 +221,34 @@ Lists all registered passkeys with their names, IDs, and last used timestamps. Interactively select and remove a passkey from the account. +### `pds app-password` + +Manage app passwords for third-party client access. + +```bash +pds app-password create # Create a new app password +pds app-password list # List app passwords +pds app-password revoke # Revoke an app password +``` + +All app-password commands support: + +- `--dev` – Target the local development server instead of production + +#### `pds app-password create` + +Creates a new app password. Prompts for a name (for example, "Graysky" or "Skeet client"), generates a secure password in `xxxx-xxxx-xxxx-xxxx` format, and displays it once. The password cannot be retrieved after creation. + +App passwords grant the same access as the account password but are designed for use in third-party clients. They can be individually revoked without changing the account password. + +#### `pds app-password list` + +Lists all app passwords with their names and creation dates. Passwords themselves are never shown — only the names. + +#### `pds app-password revoke` + +Interactively select and revoke an app password. Use `-y` to skip the confirmation prompt. Sessions created with a revoked app password continue to work until the access token expires, but no new sessions can be created. + ### `pds secret` Manage individual secrets. @@ -424,6 +452,9 @@ See [Cloudflare's data location documentation](https://developers.cloudflare.com | `POST /xrpc/com.atproto.server.createSession` | No | Login with password, get JWT | | `POST /xrpc/com.atproto.server.refreshSession` | Yes | Refresh JWT tokens | | `GET /xrpc/com.atproto.server.getSession` | Yes | Get current session info | +| `POST /xrpc/com.atproto.server.createAppPassword` | Yes | Create an app password | +| `GET /xrpc/com.atproto.server.listAppPasswords` | Yes | List app passwords | +| `POST /xrpc/com.atproto.server.revokeAppPassword` | Yes | Revoke an app password | | `POST /xrpc/com.atproto.server.deleteSession` | Yes | Logout | | `GET /xrpc/com.atproto.server.getServiceAuth` | Yes | Get JWT for external services | | `GET /xrpc/com.atproto.server.getAccountStatus` | Yes | Account status (active/deactivated) | diff --git a/packages/pds/test/app-passwords.test.ts b/packages/pds/test/app-passwords.test.ts new file mode 100644 index 00000000..8f86b6ff --- /dev/null +++ b/packages/pds/test/app-passwords.test.ts @@ -0,0 +1,550 @@ +import { describe, it, expect } from "vitest"; +import { env, worker } from "./helpers"; + +/** Helper to get an access token for authenticated requests. */ +async function getAccessToken(): Promise { + const res = await worker.fetch( + new Request("http://pds.test/xrpc/com.atproto.server.createSession", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password: "test-password", + }), + }), + env, + ); + const body = (await res.json()) as { accessJwt: string }; + return body.accessJwt; +} + +/** Helper to create an app password and return the response body. */ +async function createAppPassword( + token: string, + name: string, +): Promise<{ name: string; password: string; createdAt: string }> { + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name }), + }, + ), + env, + ); + expect(res.status).toBe(200); + return res.json() as Promise<{ + name: string; + password: string; + createdAt: string; + }>; +} + +describe("App Passwords", () => { + describe("createAppPassword", () => { + it("creates an app password and returns it in xxxx-xxxx-xxxx-xxxx format", async () => { + const token = await getAccessToken(); + const body = await createAppPassword(token, "test-client"); + + expect(body.name).toBe("test-client"); + expect(body.password).toMatch( + /^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/, + ); + expect(body.createdAt).toBeDefined(); + // createdAt should be a valid ISO timestamp + expect(new Date(body.createdAt).toISOString()).toBe(body.createdAt); + }); + + it("requires authentication", async () => { + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createAppPassword", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "no-auth" }), + }, + ), + env, + ); + expect(res.status).toBe(401); + }); + + it("rejects missing name", async () => { + const token = await getAccessToken(); + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({}), + }, + ), + env, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe("InvalidRequest"); + }); + + it("rejects empty name", async () => { + const token = await getAccessToken(); + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name: " " }), + }, + ), + env, + ); + expect(res.status).toBe(400); + }); + + it("rejects duplicate names", async () => { + const token = await getAccessToken(); + await createAppPassword(token, "duplicate-test"); + + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name: "duplicate-test" }), + }, + ), + env, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe("DuplicateName"); + }); + }); + + describe("listAppPasswords", () => { + it("returns created app passwords", async () => { + const token = await getAccessToken(); + const created = await createAppPassword(token, "list-test"); + + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.listAppPasswords", + { + headers: { Authorization: `Bearer ${token}` }, + }, + ), + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + passwords: Array<{ name: string; createdAt: string }>; + }; + expect(body.passwords).toBeDefined(); + expect(Array.isArray(body.passwords)).toBe(true); + + const found = body.passwords.find((p) => p.name === "list-test"); + expect(found).toBeDefined(); + expect(found!.createdAt).toBeDefined(); + }); + + it("never exposes password hashes", async () => { + const token = await getAccessToken(); + await createAppPassword(token, "hash-check"); + + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.listAppPasswords", + { + headers: { Authorization: `Bearer ${token}` }, + }, + ), + env, + ); + const body = (await res.json()) as { + passwords: Array>; + }; + for (const p of body.passwords) { + expect(p).not.toHaveProperty("password"); + expect(p).not.toHaveProperty("passwordHash"); + expect(p).not.toHaveProperty("password_hash"); + } + }); + + it("requires authentication", async () => { + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.listAppPasswords", + ), + env, + ); + expect(res.status).toBe(401); + }); + }); + + describe("revokeAppPassword", () => { + it("revokes an existing app password", async () => { + const token = await getAccessToken(); + await createAppPassword(token, "revoke-test"); + + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.revokeAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name: "revoke-test" }), + }, + ), + env, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({}); + + // Verify it no longer appears in the list + const listRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.listAppPasswords", + { + headers: { Authorization: `Bearer ${token}` }, + }, + ), + env, + ); + const listBody = (await listRes.json()) as { + passwords: Array<{ name: string }>; + }; + expect(listBody.passwords.find((p) => p.name === "revoke-test")).toBeUndefined(); + }); + + it("returns 400 for non-existent app password", async () => { + const token = await getAccessToken(); + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.revokeAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name: "does-not-exist" }), + }, + ), + env, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as Record; + expect(body.error).toBe("InvalidRequest"); + }); + + it("rejects missing name", async () => { + const token = await getAccessToken(); + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.revokeAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({}), + }, + ), + env, + ); + expect(res.status).toBe(400); + }); + + it("requires authentication", async () => { + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.revokeAppPassword", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "no-auth" }), + }, + ), + env, + ); + expect(res.status).toBe(401); + }); + }); + + describe("authentication with app passwords", () => { + it("can create a session using an app password", async () => { + const token = await getAccessToken(); + const { password } = await createAppPassword(token, "auth-test"); + + // Login with the app password + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password, + }), + }, + ), + env, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.accessJwt).toBeDefined(); + expect(body.refreshJwt).toBeDefined(); + expect(body.did).toBe("did:web:pds.test"); + expect(body.handle).toBe("alice.test"); + }); + + it("can use app password session token for write operations", async () => { + const token = await getAccessToken(); + const { password } = await createAppPassword(token, "write-test"); + + // Login with app password + const loginRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password, + }), + }, + ), + env, + ); + const { accessJwt } = (await loginRes.json()) as { + accessJwt: string; + }; + + // Use that token to create a record + const createRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.repo.createRecord", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessJwt}`, + }, + body: JSON.stringify({ + repo: "did:web:pds.test", + collection: "app.bsky.feed.post", + record: { + $type: "app.bsky.feed.post", + text: "Posted with app password", + createdAt: new Date().toISOString(), + }, + }), + }, + ), + env, + ); + expect(createRes.status).toBe(200); + const record = (await createRes.json()) as Record; + expect(record.uri).toMatch(/^at:\/\//); + }); + + it("rejects invalid app password", async () => { + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password: "abcd-efgh-ijkl-mnop", + }), + }, + ), + env, + ); + expect(res.status).toBe(401); + }); + + it("rejects a revoked app password", async () => { + const token = await getAccessToken(); + const { password } = await createAppPassword(token, "revoke-auth"); + + // Verify it works first + const loginRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password, + }), + }, + ), + env, + ); + expect(loginRes.status).toBe(200); + + // Revoke it + await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.revokeAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name: "revoke-auth" }), + }, + ), + env, + ); + + // Should no longer work + const rejectedRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password, + }), + }, + ), + env, + ); + expect(rejectedRes.status).toBe(401); + }); + + it("account password still works after creating app passwords", async () => { + const token = await getAccessToken(); + await createAppPassword(token, "no-interference"); + + // Account password should still work + const res = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password: "test-password", + }), + }, + ), + env, + ); + expect(res.status).toBe(200); + }); + }); + + describe("full lifecycle", () => { + it("create, list, authenticate, revoke, reject", async () => { + const token = await getAccessToken(); + + // 1. Create + const { password, name } = await createAppPassword( + token, + "lifecycle", + ); + expect(name).toBe("lifecycle"); + + // 2. List — should contain it + const listRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.listAppPasswords", + { + headers: { Authorization: `Bearer ${token}` }, + }, + ), + env, + ); + const listBody = (await listRes.json()) as { + passwords: Array<{ name: string }>; + }; + expect(listBody.passwords.find((p) => p.name === "lifecycle")).toBeDefined(); + + // 3. Authenticate with it + const authRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password, + }), + }, + ), + env, + ); + expect(authRes.status).toBe(200); + + // 4. Revoke + const revokeRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.revokeAppPassword", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ name: "lifecycle" }), + }, + ), + env, + ); + expect(revokeRes.status).toBe(200); + + // 5. Authentication should now fail + const rejectedRes = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.server.createSession", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: "alice.test", + password, + }), + }, + ), + env, + ); + expect(rejectedRes.status).toBe(401); + }); + }); +}); From a40e46010508bfcdf9cc64f791d46ca3da50aafe Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 29 Mar 2026 16:26:27 +0100 Subject: [PATCH 3/4] fix: remove unused export on isAppPassword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knip flagged isAppPassword as an unused export — it's only used internally in the same file. Removed the export keyword. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/pds/src/xrpc/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index aa41d94b..45b3e9ff 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -32,7 +32,7 @@ function generateAppPassword(): string { } /** Check if a string looks like an app password (xxxx-xxxx-xxxx-xxxx). */ -export function isAppPassword(password: string): boolean { +function isAppPassword(password: string): boolean { return /^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/.test(password); } From 204de198f8a8a4f2773a7c1e1a23deeb1824d76d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 29 Mar 2026 16:34:54 +0100 Subject: [PATCH 4/4] feat: hide app password from terminal after confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After displaying the generated password, prompts the user to confirm they've saved it, then overwrites the display with a masked version so the password doesn't linger in terminal scrollback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/cli/commands/app-password/create.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/cli/commands/app-password/create.ts b/packages/pds/src/cli/commands/app-password/create.ts index bdef87ba..59504767 100644 --- a/packages/pds/src/cli/commands/app-password/create.ts +++ b/packages/pds/src/cli/commands/app-password/create.ts @@ -125,11 +125,13 @@ export const createCommand = defineCommand({ `App password ${pc.bold(`"${result.name}"`)} created.`, ); p.log.info(""); - p.note(result.password, "App Password"); - p.log.info(""); // Try to copy to clipboard const copied = await copyToClipboard(result.password); + + // Show the password + p.note(result.password, "App Password"); + if (copied) { p.log.info(pc.dim("Copied to clipboard.")); } @@ -138,6 +140,24 @@ export const createCommand = defineCommand({ "This password will not be shown again. Save it now.", ); + // Wait for confirmation, then hide the password from terminal + const confirmed = await p.confirm({ + message: "Have you saved the password?", + }); + + if (p.isCancel(confirmed)) { + p.outro("Done! (password still visible above)"); + return; + } + + // Overwrite the password display with a masked version + // Lines to clear: note(6) + clipboard log(0|2) + warn(2) + confirm(2) + const linesToClear = 6 + (copied ? 2 : 0) + 2 + 2; + process.stdout.write(`\x1b[${linesToClear}A\x1b[0J`); + + p.note("xxxx-xxxx-xxxx-xxxx", "App Password (hidden)"); + p.log.info(pc.dim("Password hidden from terminal.")); + p.outro("Done!"); }, });