···11+# ANProto over ATProto
22+33+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).
44+55+## Purpose
66+77+To demonstrate a secure, production-ready (conceptually) OAuth flow using `@atproto/oauth-client-node`, including:
88+- **Handle Resolution**: Converting `user.bsky.social` to a DID.
99+- **Session Management**: Persisting sessions securely using a database.
1010+- **Scopes**: Requesting appropriate permissions (Standard vs. Transition).
1111+- **Token Management**: Handling access and refresh tokens automatically.
1212+1313+## Quick Start
1414+1515+1. **Install Dependencies**:
1616+ ```bash
1717+ npm install
1818+ ```
1919+2. **Run the Server**:
2020+ ```bash
2121+ npm run dev
2222+ ```
2323+3. **Open**: Visit `http://localhost:3000`
2424+2525+## Documentation
2626+2727+- **[Architecture Overview](./docs/ARCHITECTURE.md)**: How the components (Client, DB, Storage, Express) fit together.
2828+- **[Understanding Scopes](./docs/SCOPES.md)**: Which permissions to ask for and why.
2929+- **[Handles vs. DIDs](./docs/HANDLES_AND_DIDS.md)**: How user identity works in ATProto.
3030+- **[Setup & Configuration](./docs/SETUP.md)**: Configuring the client metadata for localhost vs. production.
3131+3232+## Key Files
3333+3434+- `src/index.ts`: The web server and route handlers.
3535+- `src/client.ts`: Configuration of the OAuth client.
3636+- `src/storage.ts`: Interface between the OAuth client and the database.
3737+- `src/db.ts`: SQLite database connection.
3838+3939+## "Do's and Don'ts"
4040+4141+- **DO** use DIDs (`did:plc:...`) as the primary user key in your database, not handles. Handles are mutable.
4242+- **DO** persist the `state` and `session` data securely.
4343+- **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).
4444+- **DON'T** hardcode the PDS URL. Always resolve it from the user's DID/Handle.
+43
docs/ARCHITECTURE.md
···11+# Architecture
22+33+This reference app follows a standard server-side OAuth flow suited for a backend (Node.js/Express) application.
44+55+## Components
66+77+1. **Express Server (`src/index.ts`)**
88+ - Host web endpoints (`/`, `/login`, `/oauth/callback`).
99+ - Manages the browser session (cookie-based) using `iron-session`.
1010+ - NOTE: The browser session is *separate* from the OAuth session. The browser session just remembers "Who is logged in here?" (by storing the DID).
1111+1212+2. **OAuth Client (`src/client.ts`)**
1313+ - Instance of `NodeOAuthClient`.
1414+ - Manages the complexity of the handshake, token exchanges, and key management (DPoP).
1515+ - Uses `client-metadata` to define itself to the world (redirect URIs, etc.).
1616+1717+3. **Storage Adapters (`src/storage.ts`)**
1818+ - **State Store**: Temporarily stores the random `state` parameter generated during the login request to prevent CSRF.
1919+ - **Session Store**: Persists the actual Access and Refresh tokens (the "OAuth Session") mapped to the user's DID.
2020+2121+4. **Database (`src/db.ts`)**
2222+ - A simple SQLite database to back the Storage Adapters.
2323+ - In a real app, this would be Postgres, Redis, etc.
2424+2525+## The Flow
2626+2727+1. **Initiation**:
2828+ - User enters Handle.
2929+ - App calls `client.authorize(handle)`.
3030+ - App redirects User to the PDS (e.g., bsky.social login page).
3131+2. **Authentication**:
3232+ - User logs in at the PDS.
3333+ - User approves the app.
3434+3. **Callback**:
3535+ - PDS redirects User back to `/oauth/callback?code=...`.
3636+ - App calls `client.callback(params)`.
3737+ - `client` exchanges `code` for `tokens`.
3838+ - `client` saves tokens to `SessionStore`.
3939+ - App saves `session.did` to the browser cookie.
4040+4. **Usage**:
4141+ - On subsequent requests, App reads DID from browser cookie.
4242+ - App loads OAuth tokens from `SessionStore` using the DID.
4343+ - App creates an `Agent` to make API calls.
+38
docs/HANDLES_AND_DIDS.md
···11+# Handles vs. DIDs
22+33+In the AT Protocol, users have two identifiers: a **Handle** and a **DID** (Decentralized Identifier).
44+55+## Handle (`alice.bsky.social`)
66+- **Human-readable**: Looks like a domain name.
77+- **Mutable**: Users can change their handle at any time (e.g., `alice.bsky.social` -> `alice.com`).
88+- **Usage**: Used for login input, display names, and mentions.
99+- **NOT for Storage**: Never use the handle as the primary key in your database user table.
1010+1111+## DID (`did:plc:z72...`)
1212+- **Machine-readable**: A unique string starting with `did:`.
1313+- **Immutable**: This creates a permanent identity for the user, regardless of handle changes.
1414+- **Usage**: Database primary keys, internal logic, and resolving data from the PDS (Personal Data Server).
1515+1616+## The Resolution Flow
1717+1818+1. **User Input**: User types `alice.bsky.social`.
1919+2. **Resolution**: The OAuth client (or a resolver) queries the network to find the DID associated with that handle.
2020+3. **Authentication**: The OAuth flow proceeds using the DID.
2121+4. **Storage**: Your app stores the DID.
2222+5. **Display**: When showing the user, you resolve the DID back to their *current* handle (or cache it and update periodically).
2323+2424+## Code Example
2525+2626+When a user logs in:
2727+2828+```typescript
2929+// src/index.ts logic
3030+const handle = req.body.handle; // "alice.bsky.social"
3131+3232+// The client.authorize() method handles the resolution internally!
3333+const url = await client.authorize(handle, { ... });
3434+3535+// On callback, we get the session which contains the DID
3636+const { session } = await client.callback(params);
3737+const userDid = session.did; // "did:plc:123..."
3838+```
+39
docs/SCOPES.md
···11+# AT Protocol Scopes
22+33+OAuth scopes define the permissions your application requests from the user. In the AT Protocol, scopes are critical for security and user trust.
44+55+## Common Scopes
66+77+### `atproto`
88+- **Description**: Grants full access to the user's account (except for account deletion or migration in some contexts).
99+- **Use Case**: Full-featured clients (e.g., a Twitter-like app) that need to read notifications, post content, update profiles, and manage follows.
1010+- **Risk**: High. If your token is leaked, the attacker has nearly full control.
1111+1212+### `transition:generic`
1313+- **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.
1414+1515+### `transition:chat.bsky`
1616+- **Description**: specific to Bluesky chat capabilities.
1717+1818+## Granular Scopes (The Future)
1919+2020+The protocol is moving towards fine-grained scopes like:
2121+- `com.atproto.repo.create`
2222+- `com.atproto.repo.delete`
2323+- `app.bsky.feed.post`
2424+2525+*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.*
2626+2727+## Best Practices
2828+2929+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).
3030+2. **Transparency**: Explain to your users why you need specific permissions.
3131+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).
3232+3333+## In This Demo
3434+3535+We use:
3636+```typescript
3737+scope: 'atproto'
3838+```
3939+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.
+47
docs/SETUP.md
···11+# Setup and Configuration
22+33+## Prerequisites
44+- Node.js v18+
55+- NPM
66+77+## Local Development
88+99+1. **Clone & Install**:
1010+ ```bash
1111+ git clone <repo>
1212+ cd atproto-oauth-demo
1313+ npm install
1414+ ```
1515+1616+2. **Public URL**:
1717+ OAuth requires a publicly reachable or explicitly defined callback URL. For `localhost`, strict matching is enforced.
1818+1919+ In `src/client.ts`, the `client_id` is constructed specifically for localhost development to avoid needing a public domain:
2020+ ```typescript
2121+ client_id: 'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Foauth%2Fcallback&scope=atproto'
2222+ ```
2323+ *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`).*
2424+2525+3. **Run**:
2626+ ```bash
2727+ npm run dev
2828+ ```
2929+3030+## Production Deployment
3131+3232+1. **Domain**: You need a public domain (e.g., `https://myapp.com`).
3333+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).
3434+3. **Update `src/client.ts`**:
3535+ ```typescript
3636+ clientMetadata: {
3737+ client_name: 'My App',
3838+ client_id: 'https://myapp.com/client-metadata.json', // The URL where this JSON is served
3939+ client_uri: 'https://myapp.com',
4040+ redirect_uris: ['https://myapp.com/oauth/callback'],
4141+ // ...
4242+ }
4343+ ```
4444+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`.
4545+4646+5. **Environment Variables**:
4747+ Move secrets (like cookie passwords) to `.env` files.
+21
docs/WORK_ORDER.md
···11+# Work Order: ANProto Messages on Bluesky PDS
22+33+## Scope
44+- Store ANProto-signed messages (and blobs) in a Bluesky PDS using the existing Node OAuth backend. APDS remains frontend-only.
55+- Use custom collections `com.anproto.message.v1` (messages) and optional `com.anproto.blob` if we need standalone blob records.
66+77+## First Steps
88+- 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`.
99+- OAuth/scopes: Keep Node OAuth flow; request repo read/write + blob upload (`atproto` for now). Continue storing DID in cookies and tokens server-side.
1010+- Signing/validation: Frontend APDS signs messages; send backend `{anmsg, blob?, blobMime?}`. Backend recomputes `anhash` via ANProto, converts to base64url rkey, and rejects mismatches.
1111+- 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`.
1212+- Retrieval/indexing: Add backend endpoints to fetch by rkey/anhash/DID; optional local index (DB) for search/filter; fallback to repo reads by rkey.
1313+- Safety: Enforce size limits on content/blobs, rate-limit publish, reject tampered/duplicate/invalid signatures, log verification failures.
1414+1515+## Client UI (Frontend)
1616+- Auth status: “Connect Bluesky” button; show connected DID/handle; disable publish until connected.
1717+- Key management: Display ANProto pubkey; controls to generate/import/export (avoid leaking private key in production).
1818+- Composer: Text area for content, optional “previous” hash, blob uploader, live display of computed hash/signature.
1919+- Publish flow: “Sign & Save to Bluesky” runs APDS sign then POSTs to backend; show progress/errors.
2020+- Viewer: Fetch by hash, display content, author pubkey, timestamp, previous chain, blob previews; simple search/filter if backend index exists.
2121+- Notifications: Inline status/toasts for auth, signing, upload, and save failures.
···11+import { NodeOAuthClient } from '@atproto/oauth-client-node'
22+import { SessionStore, StateStore } from './storage'
33+44+export const createClient = async () => {
55+ return new NodeOAuthClient({
66+ // This metadata describes your OAuth client to the PDS.
77+ clientMetadata: {
88+ client_name: 'ATProto OAuth Test',
99+ // For localhost development, we use a "Loopback Client ID".
1010+ // This allows us to test without a public domain or https.
1111+ // In production, this should be the URL where your metadata is served (e.g., https://myapp.com/client-metadata.json).
1212+ 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',
1313+ client_uri: 'http://localhost:3000',
1414+ redirect_uris: ['http://127.0.0.1:3000/oauth/callback'],
1515+ scope: 'atproto repo:com.anproto.message.v1?action=create',
1616+ grant_types: ['authorization_code', 'refresh_token'],
1717+ response_types: ['code'],
1818+ application_type: 'web',
1919+ token_endpoint_auth_method: 'none',
2020+ // DPoP (Demonstrating Proof-of-Possession) binds tokens to a private key, preventing replay attacks if the token is stolen.
2121+ // This is highly recommended for security.
2222+ dpop_bound_access_tokens: true,
2323+ },
2424+ stateStore: new StateStore(),
2525+ sessionStore: new SessionStore(),
2626+ })
2727+}
+16
src/db.ts
···11+import Database from 'better-sqlite3'
22+33+// A simple SQLite database for persisting sessions and auth state.
44+// In a production environment, you might use PostgreSQL, Redis, or another durable store.
55+export const db = new Database('db.sqlite')
66+77+db.exec(`
88+ CREATE TABLE IF NOT EXISTS auth_state (
99+ key TEXT PRIMARY KEY,
1010+ state TEXT NOT NULL
1111+ );
1212+ CREATE TABLE IF NOT EXISTS auth_session (
1313+ key TEXT PRIMARY KEY,
1414+ session TEXT NOT NULL
1515+ );
1616+`)
···11+import type {
22+ NodeSavedSession,
33+ NodeSavedSessionStore,
44+ NodeSavedState,
55+ NodeSavedStateStore,
66+} from '@atproto/oauth-client-node'
77+import { db } from './db'
88+99+/**
1010+ * StateStore:
1111+ * Stores temporary "state" parameters used during the initial OAuth handshake (authorize -> callback).
1212+ * This prevents CSRF attacks by ensuring the callback comes from the same flow we started.
1313+ * These are short-lived and can be deleted after the callback is processed.
1414+ */
1515+export class StateStore implements NodeSavedStateStore {
1616+ async get(key: string): Promise<NodeSavedState | undefined> {
1717+ const result = db.prepare('SELECT state FROM auth_state WHERE key = ?').get(key) as { state: string } | undefined
1818+ if (!result) return
1919+ return JSON.parse(result.state) as NodeSavedState
2020+ }
2121+ async set(key: string, val: NodeSavedState) {
2222+ const state = JSON.stringify(val)
2323+ db.prepare(`
2424+ INSERT INTO auth_state (key, state) VALUES (?, ?)
2525+ ON CONFLICT(key) DO UPDATE SET state = excluded.state
2626+ `).run(key, state)
2727+ }
2828+ async del(key: string) {
2929+ db.prepare('DELETE FROM auth_state WHERE key = ?').run(key)
3030+ }
3131+}
3232+3333+/**
3434+ * SessionStore:
3535+ * Persists the long-term OAuth session data (Access Token, Refresh Token, DID).
3636+ * This allows the user to stay logged in even if the server restarts.
3737+ * Keys are usually mapped to the user's DID.
3838+ */
3939+export class SessionStore implements NodeSavedSessionStore {
4040+ async get(key: string): Promise<NodeSavedSession | undefined> {
4141+ const result = db.prepare('SELECT session FROM auth_session WHERE key = ?').get(key) as { session: string } | undefined
4242+ if (!result) return
4343+ return JSON.parse(result.session) as NodeSavedSession
4444+ }
4545+ async set(key: string, val: NodeSavedSession) {
4646+ const session = JSON.stringify(val)
4747+ db.prepare(`
4848+ INSERT INTO auth_session (key, session) VALUES (?, ?)
4949+ ON CONFLICT(key) DO UPDATE SET session = excluded.session
5050+ `).run(key, session)
5151+ }
5252+ async del(key: string) {
5353+ db.prepare('DELETE FROM auth_session WHERE key = ?').run(key)
5454+ }
5555+}