···1+# ANProto over ATProto
2+3+This project demonstrates ANProto messaging on top of the AT Protocol (Bluesky/ATProto) with OAuth auth. It is designed to be clear, concise, and "LLM-friendly" (easy for AI assistants to parse and understand context).
4+5+## Purpose
6+7+To demonstrate a secure, production-ready (conceptually) OAuth flow using `@atproto/oauth-client-node`, including:
8+- **Handle Resolution**: Converting `user.bsky.social` to a DID.
9+- **Session Management**: Persisting sessions securely using a database.
10+- **Scopes**: Requesting appropriate permissions (Standard vs. Transition).
11+- **Token Management**: Handling access and refresh tokens automatically.
12+13+## Quick Start
14+15+1. **Install Dependencies**:
16+ ```bash
17+ npm install
18+ ```
19+2. **Run the Server**:
20+ ```bash
21+ npm run dev
22+ ```
23+3. **Open**: Visit `http://localhost:3000`
24+25+## Documentation
26+27+- **[Architecture Overview](./docs/ARCHITECTURE.md)**: How the components (Client, DB, Storage, Express) fit together.
28+- **[Understanding Scopes](./docs/SCOPES.md)**: Which permissions to ask for and why.
29+- **[Handles vs. DIDs](./docs/HANDLES_AND_DIDS.md)**: How user identity works in ATProto.
30+- **[Setup & Configuration](./docs/SETUP.md)**: Configuring the client metadata for localhost vs. production.
31+32+## Key Files
33+34+- `src/index.ts`: The web server and route handlers.
35+- `src/client.ts`: Configuration of the OAuth client.
36+- `src/storage.ts`: Interface between the OAuth client and the database.
37+- `src/db.ts`: SQLite database connection.
38+39+## "Do's and Don'ts"
40+41+- **DO** use DIDs (`did:plc:...`) as the primary user key in your database, not handles. Handles are mutable.
42+- **DO** persist the `state` and `session` data securely.
43+- **DON'T** request `atproto` (full access) scope unless you absolutely need it. Prefer granular scopes if available (though currently `atproto` or `transition:generic` are common).
44+- **DON'T** hardcode the PDS URL. Always resolve it from the user's DID/Handle.
+43
docs/ARCHITECTURE.md
···0000000000000000000000000000000000000000000
···1+# Architecture
2+3+This reference app follows a standard server-side OAuth flow suited for a backend (Node.js/Express) application.
4+5+## Components
6+7+1. **Express Server (`src/index.ts`)**
8+ - Host web endpoints (`/`, `/login`, `/oauth/callback`).
9+ - Manages the browser session (cookie-based) using `iron-session`.
10+ - NOTE: The browser session is *separate* from the OAuth session. The browser session just remembers "Who is logged in here?" (by storing the DID).
11+12+2. **OAuth Client (`src/client.ts`)**
13+ - Instance of `NodeOAuthClient`.
14+ - Manages the complexity of the handshake, token exchanges, and key management (DPoP).
15+ - Uses `client-metadata` to define itself to the world (redirect URIs, etc.).
16+17+3. **Storage Adapters (`src/storage.ts`)**
18+ - **State Store**: Temporarily stores the random `state` parameter generated during the login request to prevent CSRF.
19+ - **Session Store**: Persists the actual Access and Refresh tokens (the "OAuth Session") mapped to the user's DID.
20+21+4. **Database (`src/db.ts`)**
22+ - A simple SQLite database to back the Storage Adapters.
23+ - In a real app, this would be Postgres, Redis, etc.
24+25+## The Flow
26+27+1. **Initiation**:
28+ - User enters Handle.
29+ - App calls `client.authorize(handle)`.
30+ - App redirects User to the PDS (e.g., bsky.social login page).
31+2. **Authentication**:
32+ - User logs in at the PDS.
33+ - User approves the app.
34+3. **Callback**:
35+ - PDS redirects User back to `/oauth/callback?code=...`.
36+ - App calls `client.callback(params)`.
37+ - `client` exchanges `code` for `tokens`.
38+ - `client` saves tokens to `SessionStore`.
39+ - App saves `session.did` to the browser cookie.
40+4. **Usage**:
41+ - On subsequent requests, App reads DID from browser cookie.
42+ - App loads OAuth tokens from `SessionStore` using the DID.
43+ - App creates an `Agent` to make API calls.
+38
docs/HANDLES_AND_DIDS.md
···00000000000000000000000000000000000000
···1+# Handles vs. DIDs
2+3+In the AT Protocol, users have two identifiers: a **Handle** and a **DID** (Decentralized Identifier).
4+5+## Handle (`alice.bsky.social`)
6+- **Human-readable**: Looks like a domain name.
7+- **Mutable**: Users can change their handle at any time (e.g., `alice.bsky.social` -> `alice.com`).
8+- **Usage**: Used for login input, display names, and mentions.
9+- **NOT for Storage**: Never use the handle as the primary key in your database user table.
10+11+## DID (`did:plc:z72...`)
12+- **Machine-readable**: A unique string starting with `did:`.
13+- **Immutable**: This creates a permanent identity for the user, regardless of handle changes.
14+- **Usage**: Database primary keys, internal logic, and resolving data from the PDS (Personal Data Server).
15+16+## The Resolution Flow
17+18+1. **User Input**: User types `alice.bsky.social`.
19+2. **Resolution**: The OAuth client (or a resolver) queries the network to find the DID associated with that handle.
20+3. **Authentication**: The OAuth flow proceeds using the DID.
21+4. **Storage**: Your app stores the DID.
22+5. **Display**: When showing the user, you resolve the DID back to their *current* handle (or cache it and update periodically).
23+24+## Code Example
25+26+When a user logs in:
27+28+```typescript
29+// src/index.ts logic
30+const handle = req.body.handle; // "alice.bsky.social"
31+32+// The client.authorize() method handles the resolution internally!
33+const url = await client.authorize(handle, { ... });
34+35+// On callback, we get the session which contains the DID
36+const { session } = await client.callback(params);
37+const userDid = session.did; // "did:plc:123..."
38+```
+39
docs/SCOPES.md
···000000000000000000000000000000000000000
···1+# AT Protocol Scopes
2+3+OAuth scopes define the permissions your application requests from the user. In the AT Protocol, scopes are critical for security and user trust.
4+5+## Common Scopes
6+7+### `atproto`
8+- **Description**: Grants full access to the user's account (except for account deletion or migration in some contexts).
9+- **Use Case**: Full-featured clients (e.g., a Twitter-like app) that need to read notifications, post content, update profiles, and manage follows.
10+- **Risk**: High. If your token is leaked, the attacker has nearly full control.
11+12+### `transition:generic`
13+- **Description**: A transitional scope often used while the ecosystem moves towards more granular scopes. It provides broad access similar to `atproto` but is intended to be phased out for specific capabilities.
14+15+### `transition:chat.bsky`
16+- **Description**: specific to Bluesky chat capabilities.
17+18+## Granular Scopes (The Future)
19+20+The protocol is moving towards fine-grained scopes like:
21+- `com.atproto.repo.create`
22+- `com.atproto.repo.delete`
23+- `app.bsky.feed.post`
24+25+*Note: As of late 2024/early 2025, `atproto` is still the most commonly used scope for general apps, but you should always check the latest ATProto specs.*
26+27+## Best Practices
28+29+1. **Least Privilege**: Only request what you need. If you only need to verify identity, you might only need a hypothetical "signin" scope (or just check the DID returned without requesting API access, although typically some scope is required to get the token).
30+2. **Transparency**: Explain to your users why you need specific permissions.
31+3. **Offline Access**: If you need to perform actions when the user is not actively using the app (background jobs), ensure you request `offline_access` (often implicit or managed via refresh tokens in this library).
32+33+## In This Demo
34+35+We use:
36+```typescript
37+scope: 'atproto'
38+```
39+This is because we demonstrate fetching the user's profile and potentially other account data. For a simple "Log in with Bluesky" (identity only), you might strictly restrict usage to reading the profile and nothing else, even if the token technically allows more.
···1+# Setup and Configuration
2+3+## Prerequisites
4+- Node.js v18+
5+- NPM
6+7+## Local Development
8+9+1. **Clone & Install**:
10+ ```bash
11+ git clone <repo>
12+ cd atproto-oauth-demo
13+ npm install
14+ ```
15+16+2. **Public URL**:
17+ OAuth requires a publicly reachable or explicitly defined callback URL. For `localhost`, strict matching is enforced.
18+19+ In `src/client.ts`, the `client_id` is constructed specifically for localhost development to avoid needing a public domain:
20+ ```typescript
21+ client_id: 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Foauth%2Fcallback&scope=atproto'
22+ ```
23+ *Note: This is a "Loopback Client" technique. In production, your Client ID will be your website's URL (e.g., `https://myapp.com/client-metadata.json`).*
24+25+3. **Run**:
26+ ```bash
27+ npm run dev
28+ ```
29+30+## Production Deployment
31+32+1. **Domain**: You need a public domain (e.g., `https://myapp.com`).
33+2. **Metadata Endpoint**: You must serve the client metadata at a known URL (usually `https://myapp.com/.well-known/oauth-client-metadata` or similar, or just referenced by the ID).
34+3. **Update `src/client.ts`**:
35+ ```typescript
36+ clientMetadata: {
37+ client_name: 'My App',
38+ client_id: 'https://myapp.com/client-metadata.json', // The URL where this JSON is served
39+ client_uri: 'https://myapp.com',
40+ redirect_uris: ['https://myapp.com/oauth/callback'],
41+ // ...
42+ }
43+ ```
44+4. **Serve Metadata**: Ensure your app actually serves this JSON at the `client_id` URL (if using URL-based IDs). The demo app serves it at `/oauth-client-metadata.json`.
45+46+5. **Environment Variables**:
47+ Move secrets (like cookie passwords) to `.env` files.
+21
docs/WORK_ORDER.md
···000000000000000000000
···1+# Work Order: ANProto Messages on Bluesky PDS
2+3+## Scope
4+- Store ANProto-signed messages (and blobs) in a Bluesky PDS using the existing Node OAuth backend. APDS remains frontend-only.
5+- Use custom collections `com.anproto.message.v1` (messages) and optional `com.anproto.blob` if we need standalone blob records.
6+7+## First Steps
8+- Lexicons: `com.anproto.message.v1` has `anmsg` (full ANProto message), optional `anblob`, `anhash` (base64 ANProto hash), and `blobhash` (hash of the attached blob). PDS rkey uses a base64url form of `anhash` (replace `+`→`-`, `/`→`_`, strip `=`) to satisfy record-key rules. IDs must match the collection names exactly. `com.anproto.blob` is optional if blobs stay in `anblob`.
9+- OAuth/scopes: Keep Node OAuth flow; request repo read/write + blob upload (`atproto` for now). Continue storing DID in cookies and tokens server-side.
10+- Signing/validation: Frontend APDS signs messages; send backend `{anmsg, blob?, blobMime?}`. Backend recomputes `anhash` via ANProto, converts to base64url rkey, and rejects mismatches.
11+- Persistence: Upload blobs via `com.atproto.repo.uploadBlob`; create `com.anproto.message.v1` records with rkey = base64url(anhash), storing `anmsg`, `anhash`, optional `anblob`, and optional `blobhash`.
12+- Retrieval/indexing: Add backend endpoints to fetch by rkey/anhash/DID; optional local index (DB) for search/filter; fallback to repo reads by rkey.
13+- Safety: Enforce size limits on content/blobs, rate-limit publish, reject tampered/duplicate/invalid signatures, log verification failures.
14+15+## Client UI (Frontend)
16+- Auth status: “Connect Bluesky” button; show connected DID/handle; disable publish until connected.
17+- Key management: Display ANProto pubkey; controls to generate/import/export (avoid leaking private key in production).
18+- Composer: Text area for content, optional “previous” hash, blob uploader, live display of computed hash/signature.
19+- Publish flow: “Sign & Save to Bluesky” runs APDS sign then POSTs to backend; show progress/errors.
20+- Viewer: Fetch by hash, display content, author pubkey, timestamp, previous chain, blob previews; simple search/filter if backend index exists.
21+- Notifications: Inline status/toasts for auth, signing, upload, and save failures.
···1+import { NodeOAuthClient } from '@atproto/oauth-client-node'
2+import { SessionStore, StateStore } from './storage'
3+4+export const createClient = async () => {
5+ return new NodeOAuthClient({
6+ // This metadata describes your OAuth client to the PDS.
7+ clientMetadata: {
8+ client_name: 'ATProto OAuth Test',
9+ // For localhost development, we use a "Loopback Client ID".
10+ // This allows us to test without a public domain or https.
11+ // In production, this should be the URL where your metadata is served (e.g., https://myapp.com/client-metadata.json).
12+ client_id: 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Foauth%2Fcallback&scope=atproto%20repo%3Acom.anproto.message.v1%3Faction%3Dcreate',
13+ client_uri: 'http://localhost:3000',
14+ redirect_uris: ['http://127.0.0.1:3000/oauth/callback'],
15+ scope: 'atproto repo:com.anproto.message.v1?action=create',
16+ grant_types: ['authorization_code', 'refresh_token'],
17+ response_types: ['code'],
18+ application_type: 'web',
19+ token_endpoint_auth_method: 'none',
20+ // DPoP (Demonstrating Proof-of-Possession) binds tokens to a private key, preventing replay attacks if the token is stolen.
21+ // This is highly recommended for security.
22+ dpop_bound_access_tokens: true,
23+ },
24+ stateStore: new StateStore(),
25+ sessionStore: new SessionStore(),
26+ })
27+}
+16
src/db.ts
···0000000000000000
···1+import Database from 'better-sqlite3'
2+3+// A simple SQLite database for persisting sessions and auth state.
4+// In a production environment, you might use PostgreSQL, Redis, or another durable store.
5+export const db = new Database('db.sqlite')
6+7+db.exec(`
8+ CREATE TABLE IF NOT EXISTS auth_state (
9+ key TEXT PRIMARY KEY,
10+ state TEXT NOT NULL
11+ );
12+ CREATE TABLE IF NOT EXISTS auth_session (
13+ key TEXT PRIMARY KEY,
14+ session TEXT NOT NULL
15+ );
16+`)
···1+import type {
2+ NodeSavedSession,
3+ NodeSavedSessionStore,
4+ NodeSavedState,
5+ NodeSavedStateStore,
6+} from '@atproto/oauth-client-node'
7+import { db } from './db'
8+9+/**
10+ * StateStore:
11+ * Stores temporary "state" parameters used during the initial OAuth handshake (authorize -> callback).
12+ * This prevents CSRF attacks by ensuring the callback comes from the same flow we started.
13+ * These are short-lived and can be deleted after the callback is processed.
14+ */
15+export class StateStore implements NodeSavedStateStore {
16+ async get(key: string): Promise<NodeSavedState | undefined> {
17+ const result = db.prepare('SELECT state FROM auth_state WHERE key = ?').get(key) as { state: string } | undefined
18+ if (!result) return
19+ return JSON.parse(result.state) as NodeSavedState
20+ }
21+ async set(key: string, val: NodeSavedState) {
22+ const state = JSON.stringify(val)
23+ db.prepare(`
24+ INSERT INTO auth_state (key, state) VALUES (?, ?)
25+ ON CONFLICT(key) DO UPDATE SET state = excluded.state
26+ `).run(key, state)
27+ }
28+ async del(key: string) {
29+ db.prepare('DELETE FROM auth_state WHERE key = ?').run(key)
30+ }
31+}
32+33+/**
34+ * SessionStore:
35+ * Persists the long-term OAuth session data (Access Token, Refresh Token, DID).
36+ * This allows the user to stay logged in even if the server restarts.
37+ * Keys are usually mapped to the user's DID.
38+ */
39+export class SessionStore implements NodeSavedSessionStore {
40+ async get(key: string): Promise<NodeSavedSession | undefined> {
41+ const result = db.prepare('SELECT session FROM auth_session WHERE key = ?').get(key) as { session: string } | undefined
42+ if (!result) return
43+ return JSON.parse(result.session) as NodeSavedSession
44+ }
45+ async set(key: string, val: NodeSavedSession) {
46+ const session = JSON.stringify(val)
47+ db.prepare(`
48+ INSERT INTO auth_session (key, session) VALUES (?, ?)
49+ ON CONFLICT(key) DO UPDATE SET session = excluded.session
50+ `).run(key, session)
51+ }
52+ async del(key: string) {
53+ db.prepare('DELETE FROM auth_session WHERE key = ?').run(key)
54+ }
55+}