Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/app-passwords.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions packages/pds/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) |
Expand Down
35 changes: 35 additions & 0 deletions packages/pds/src/account-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1574,6 +1574,41 @@ export class AccountDurableObject extends DurableObject<PDSEnv> {
return oauthStorage.consumeWebAuthnChallenge(challenge);
}

// ============================================
// App Password RPC Methods
// ============================================

/** Save an app password (bcrypt hash) */
async rpcSaveAppPassword(
name: string,
passwordHash: string,
): Promise<void> {
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<boolean> {
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)
Expand Down
163 changes: 163 additions & 0 deletions packages/pds/src/cli/commands/app-password/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* 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("");

// 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."));
}

p.log.warn(
"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!");
},
});
19 changes: 19 additions & 0 deletions packages/pds/src/cli/commands/app-password/index.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
Loading
Loading