# Authentication & Sessions Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add authentication to the PDS so users can login from bsky.app and create records. **Architecture:** JWT-based authentication using HMAC-SHA256. Password checked against `PDS_PASSWORD` env var. Access tokens expire in 2 hours, refresh tokens in 90 days. Write endpoints (`createRecord`, `deleteRecord`) require valid access token. **Tech Stack:** Web Crypto API for HMAC signing, manual JWT encoding/decoding (no external deps) --- ## Task 1: Add JWT Helper Functions **Files:** - Modify: `src/pds.js:461-469` (after existing `base64UrlDecode`) **Step 1: Write failing test for base64url encode/decode** Add to `test/pds.test.js`: ```javascript import { cborEncode, cborDecode, createCid, cidToString, cidToBytes, base32Encode, createTid, generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes, getKeyDepth, varint, base32Decode, buildCarFile, base64UrlEncode, base64UrlDecode } from '../src/pds.js' // Add new test block after existing tests: describe('JWT Base64URL', () => { test('base64UrlEncode encodes bytes correctly', () => { const input = new TextEncoder().encode('hello world') const encoded = base64UrlEncode(input) assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ') assert.ok(!encoded.includes('+')) assert.ok(!encoded.includes('/')) assert.ok(!encoded.includes('=')) }) test('base64UrlDecode decodes string correctly', () => { const decoded = base64UrlDecode('aGVsbG8gd29ybGQ') const str = new TextDecoder().decode(decoded) assert.strictEqual(str, 'hello world') }) test('base64url roundtrip', () => { const original = new Uint8Array([0, 1, 2, 255, 254, 253]) const encoded = base64UrlEncode(original) const decoded = base64UrlDecode(encoded) assert.deepStrictEqual(decoded, original) }) }) ``` **Step 2: Run test to verify it fails** Run: `npm test -- --test-name-pattern "JWT Base64URL"` Expected: FAIL with "base64UrlEncode is not exported" **Step 3: Implement base64url functions** In `src/pds.js`, replace the existing `base64UrlDecode` function (around line 461) and add `base64UrlEncode`: ```javascript /** * Encode bytes as base64url string (no padding) * @param {Uint8Array} bytes - Bytes to encode * @returns {string} Base64url-encoded string */ export function base64UrlEncode(bytes) { let binary = '' for (const byte of bytes) { binary += String.fromCharCode(byte) } const base64 = btoa(binary) return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } /** * Decode base64url string to bytes * @param {string} str - Base64url-encoded string * @returns {Uint8Array} Decoded bytes */ export function base64UrlDecode(str) { const base64 = str.replace(/-/g, '+').replace(/_/g, '/') const pad = base64.length % 4 const padded = pad ? base64 + '='.repeat(4 - pad) : base64 const binary = atob(padded) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes } ``` **Step 4: Run test to verify it passes** Run: `npm test -- --test-name-pattern "JWT Base64URL"` Expected: PASS **Step 5: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "feat: add base64url encode/decode helpers for JWT" ``` --- ## Task 2: Add JWT Creation Functions **Files:** - Modify: `src/pds.js` (add after base64url functions, around line 490) **Step 1: Write failing test for JWT creation** Add to `test/pds.test.js`: ```javascript import { // ... existing imports ... base64UrlEncode, base64UrlDecode, createAccessJwt, createRefreshJwt } from '../src/pds.js' describe('JWT Creation', () => { test('createAccessJwt creates valid JWT structure', async () => { const did = 'did:web:test.example' const secret = 'test-secret-key' const jwt = await createAccessJwt(did, secret) const parts = jwt.split('.') assert.strictEqual(parts.length, 3) // Decode header const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) assert.strictEqual(header.typ, 'at+jwt') assert.strictEqual(header.alg, 'HS256') // Decode payload const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) assert.strictEqual(payload.scope, 'com.atproto.access') assert.strictEqual(payload.sub, did) assert.strictEqual(payload.aud, did) assert.ok(payload.iat > 0) assert.ok(payload.exp > payload.iat) }) test('createRefreshJwt creates valid JWT with jti', async () => { const did = 'did:web:test.example' const secret = 'test-secret-key' const jwt = await createRefreshJwt(did, secret) const parts = jwt.split('.') const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) assert.strictEqual(header.typ, 'refresh+jwt') const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) assert.strictEqual(payload.scope, 'com.atproto.refresh') assert.ok(payload.jti) // has unique token ID }) }) ``` **Step 2: Run test to verify it fails** Run: `npm test -- --test-name-pattern "JWT Creation"` Expected: FAIL with "createAccessJwt is not exported" **Step 3: Implement JWT creation functions** Add to `src/pds.js` after base64url functions: ```javascript /** * Create HMAC-SHA256 signature for JWT * @param {string} data - Data to sign (header.payload) * @param {string} secret - Secret key * @returns {Promise} Base64url-encoded signature */ async function hmacSign(data, secret) { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)) return base64UrlEncode(new Uint8Array(sig)) } /** * Create an access JWT for ATProto * @param {string} did - User's DID (subject and audience) * @param {string} secret - JWT signing secret * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours) * @returns {Promise} Signed JWT */ export async function createAccessJwt(did, secret, expiresIn = 7200) { const header = { typ: 'at+jwt', alg: 'HS256' } const now = Math.floor(Date.now() / 1000) const payload = { scope: 'com.atproto.access', sub: did, aud: did, iat: now, exp: now + expiresIn } const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) return `${headerB64}.${payloadB64}.${signature}` } /** * Create a refresh JWT for ATProto * @param {string} did - User's DID (subject and audience) * @param {string} secret - JWT signing secret * @param {number} [expiresIn=7776000] - Expiration in seconds (default 90 days) * @returns {Promise} Signed JWT */ export async function createRefreshJwt(did, secret, expiresIn = 7776000) { const header = { typ: 'refresh+jwt', alg: 'HS256' } const now = Math.floor(Date.now() / 1000) // Generate random jti (token ID) const jtiBytes = new Uint8Array(32) crypto.getRandomValues(jtiBytes) const jti = base64UrlEncode(jtiBytes) const payload = { scope: 'com.atproto.refresh', sub: did, aud: did, jti, iat: now, exp: now + expiresIn } const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) return `${headerB64}.${payloadB64}.${signature}` } ``` **Step 4: Run test to verify it passes** Run: `npm test -- --test-name-pattern "JWT Creation"` Expected: PASS **Step 5: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "feat: add JWT creation functions for access and refresh tokens" ``` --- ## Task 3: Add JWT Verification Function **Files:** - Modify: `src/pds.js` (add after JWT creation functions) **Step 1: Write failing test for JWT verification** Add to `test/pds.test.js`: ```javascript import { // ... existing imports ... createAccessJwt, createRefreshJwt, verifyAccessJwt } from '../src/pds.js' describe('JWT Verification', () => { test('verifyAccessJwt returns payload for valid token', async () => { const did = 'did:web:test.example' const secret = 'test-secret-key' const jwt = await createAccessJwt(did, secret) const payload = await verifyAccessJwt(jwt, secret) assert.strictEqual(payload.sub, did) assert.strictEqual(payload.scope, 'com.atproto.access') }) test('verifyAccessJwt throws for wrong secret', async () => { const did = 'did:web:test.example' const jwt = await createAccessJwt(did, 'correct-secret') await assert.rejects( () => verifyAccessJwt(jwt, 'wrong-secret'), /invalid signature/i ) }) test('verifyAccessJwt throws for expired token', async () => { const did = 'did:web:test.example' const secret = 'test-secret-key' // Create token that expired 1 second ago const jwt = await createAccessJwt(did, secret, -1) await assert.rejects( () => verifyAccessJwt(jwt, secret), /expired/i ) }) test('verifyAccessJwt throws for refresh token', async () => { const did = 'did:web:test.example' const secret = 'test-secret-key' const jwt = await createRefreshJwt(did, secret) await assert.rejects( () => verifyAccessJwt(jwt, secret), /invalid token type/i ) }) }) ``` **Step 2: Run test to verify it fails** Run: `npm test -- --test-name-pattern "JWT Verification"` Expected: FAIL with "verifyAccessJwt is not exported" **Step 3: Implement JWT verification** Add to `src/pds.js` after JWT creation functions: ```javascript /** * Verify and decode an access JWT * @param {string} jwt - JWT string to verify * @param {string} secret - JWT signing secret * @returns {Promise} Decoded payload * @throws {Error} If token is invalid, expired, or wrong type */ export async function verifyAccessJwt(jwt, secret) { const parts = jwt.split('.') if (parts.length !== 3) { throw new Error('Invalid JWT format') } const [headerB64, payloadB64, signatureB64] = parts // Verify signature const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret) if (signatureB64 !== expectedSig) { throw new Error('Invalid signature') } // Decode header and payload const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))) const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))) // Check token type if (header.typ !== 'at+jwt') { throw new Error('Invalid token type: expected access token') } // Check expiration const now = Math.floor(Date.now() / 1000) if (payload.exp && payload.exp < now) { throw new Error('Token expired') } return payload } ``` **Step 4: Run test to verify it passes** Run: `npm test -- --test-name-pattern "JWT Verification"` Expected: PASS **Step 5: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "feat: add JWT verification function" ``` --- ## Task 4: Add createSession Endpoint **Files:** - Modify: `src/pds.js:869-940` (add to pdsRoutes) - Modify: `src/pds.js` (add handler method to PersonalDataServer class) **Step 1: Add route to pdsRoutes** In `src/pds.js`, add to the `pdsRoutes` object (around line 902, after describeServer): ```javascript '/xrpc/com.atproto.server.createSession': { method: 'POST', handler: (pds, req, url) => pds.handleCreateSession(req) }, ``` **Step 2: Add handler method** Add to `PersonalDataServer` class (after `handleDescribeServer`, around line 1427): ```javascript async handleCreateSession(request) { const body = await request.json() const { identifier, password } = body if (!identifier || !password) { return Response.json({ error: 'InvalidRequest', message: 'Missing identifier or password' }, { status: 400 }) } // Check password against env var const expectedPassword = this.env?.PDS_PASSWORD if (!expectedPassword || password !== expectedPassword) { return Response.json({ error: 'AuthenticationRequired', message: 'Invalid identifier or password' }, { status: 401 }) } // Resolve identifier to DID let did = identifier if (!identifier.startsWith('did:')) { // Try to resolve handle const handleMap = await this.state.storage.get('handleMap') || {} did = handleMap[identifier] if (!did) { return Response.json({ error: 'InvalidRequest', message: 'Unable to resolve handle' }, { status: 400 }) } } // Get handle for response const handle = await this.getHandleForDid(did) // Create tokens const jwtSecret = this.env?.JWT_SECRET if (!jwtSecret) { return Response.json({ error: 'InternalServerError', message: 'Server not configured for authentication' }, { status: 500 }) } const accessJwt = await createAccessJwt(did, jwtSecret) const refreshJwt = await createRefreshJwt(did, jwtSecret) return Response.json({ accessJwt, refreshJwt, handle: handle || did, did, active: true }) } async getHandleForDid(did) { // Check if this DID has a handle registered const handleMap = await this.state.storage.get('handleMap') || {} for (const [handle, mappedDid] of Object.entries(handleMap)) { if (mappedDid === did) return handle } // Check instance's own handle const instanceDid = await this.getDid() if (instanceDid === did) { return await this.state.storage.get('handle') } return null } ``` **Step 3: Add route in main handleRequest** In `src/pds.js`, in the `handleRequest` function (around line 1796), add handling for createSession right after describeServer: ```javascript // createSession - handle on default DO (has handleMap for identifier resolution) if (url.pathname === '/xrpc/com.atproto.server.createSession') { const defaultId = env.PDS.idFromName('default') const defaultPds = env.PDS.get(defaultId) return defaultPds.fetch(request) } ``` **Step 4: Test manually** Deploy and test: ```bash npx wrangler deploy curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \ -H 'Content-Type: application/json' \ -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}' ``` Expected: JSON response with `accessJwt`, `refreshJwt`, `handle`, `did`, `active` **Step 5: Commit** ```bash git add src/pds.js git commit -m "feat: add com.atproto.server.createSession endpoint" ``` --- ## Task 5: Add getSession Endpoint **Files:** - Modify: `src/pds.js` (add route and handler) **Step 1: Add route to pdsRoutes** In `src/pds.js`, add to the `pdsRoutes` object (after createSession): ```javascript '/xrpc/com.atproto.server.getSession': { handler: (pds, req, url) => pds.handleGetSession(req) }, ``` **Step 2: Add handler method** Add to `PersonalDataServer` class (after `handleCreateSession`): ```javascript async handleGetSession(request) { const authHeader = request.headers.get('Authorization') if (!authHeader || !authHeader.startsWith('Bearer ')) { return Response.json({ error: 'AuthenticationRequired', message: 'Missing or invalid authorization header' }, { status: 401 }) } const token = authHeader.slice(7) // Remove 'Bearer ' const jwtSecret = this.env?.JWT_SECRET if (!jwtSecret) { return Response.json({ error: 'InternalServerError', message: 'Server not configured for authentication' }, { status: 500 }) } try { const payload = await verifyAccessJwt(token, jwtSecret) const did = payload.sub const handle = await this.getHandleForDid(did) return Response.json({ handle: handle || did, did, active: true }) } catch (err) { return Response.json({ error: 'InvalidToken', message: err.message }, { status: 401 }) } } ``` **Step 3: Add route in main handleRequest** In `src/pds.js`, in the `handleRequest` function, add handling for getSession (after createSession): ```javascript // getSession - route to default DO if (url.pathname === '/xrpc/com.atproto.server.getSession') { const defaultId = env.PDS.idFromName('default') const defaultPds = env.PDS.get(defaultId) return defaultPds.fetch(request) } ``` **Step 4: Test manually** ```bash # First get a token TOKEN=$(curl -s -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \ -H 'Content-Type: application/json' \ -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}' | jq -r '.accessJwt') # Then test getSession curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.getSession' \ -H "Authorization: Bearer $TOKEN" ``` Expected: JSON response with `handle`, `did`, `active` **Step 5: Commit** ```bash git add src/pds.js git commit -m "feat: add com.atproto.server.getSession endpoint" ``` --- ## Task 6: Add Auth Middleware and Protect Write Endpoints **Files:** - Modify: `src/pds.js` (add requireAuth helper, modify createRecord/deleteRecord handlers) **Step 1: Add requireAuth helper function** Add to `src/pds.js` (before the `handleRequest` function, around line 1774): ```javascript /** * Verify auth and return DID from token * @param {Request} request - HTTP request with Authorization header * @param {Object} env - Environment with JWT_SECRET * @returns {Promise<{did: string} | {error: Response}>} DID or error response */ async function requireAuth(request, env) { const authHeader = request.headers.get('Authorization') if (!authHeader || !authHeader.startsWith('Bearer ')) { return { error: Response.json({ error: 'AuthenticationRequired', message: 'Authentication required' }, { status: 401 }) } } const token = authHeader.slice(7) const jwtSecret = env?.JWT_SECRET if (!jwtSecret) { return { error: Response.json({ error: 'InternalServerError', message: 'Server not configured for authentication' }, { status: 500 }) } } try { const payload = await verifyAccessJwt(token, jwtSecret) return { did: payload.sub } } catch (err) { return { error: Response.json({ error: 'InvalidToken', message: err.message }, { status: 401 }) } } } ``` **Step 2: Modify createRecord in handleRequest** In `src/pds.js`, find the createRecord handling in `handleRequest` (around line 1854) and update it: ```javascript // POST repo endpoints have repo in body - REQUIRE AUTH if (url.pathname === '/xrpc/com.atproto.repo.createRecord') { // Check auth first const auth = await requireAuth(request, env) if (auth.error) return auth.error // Clone request to read body const body = await request.json() const repo = body.repo if (!repo) { return Response.json({ error: 'InvalidRequest', message: 'missing repo param' }, { status: 400 }) } // Verify authenticated user matches repo if (auth.did !== repo) { return Response.json({ error: 'Forbidden', message: 'Cannot write to another user\'s repo' }, { status: 403 }) } const id = env.PDS.idFromName(repo) const pds = env.PDS.get(id) return pds.fetch(new Request(request.url, { method: 'POST', headers: request.headers, body: JSON.stringify(body) })) } ``` **Step 3: Modify deleteRecord in handleRequest** Update the deleteRecord handling similarly: ```javascript if (url.pathname === '/xrpc/com.atproto.repo.deleteRecord') { // Check auth first const auth = await requireAuth(request, env) if (auth.error) return auth.error const body = await request.json() const repo = body.repo if (!repo) { return Response.json({ error: 'InvalidRequest', message: 'missing repo param' }, { status: 400 }) } // Verify authenticated user matches repo if (auth.did !== repo) { return Response.json({ error: 'Forbidden', message: 'Cannot modify another user\'s repo' }, { status: 403 }) } const id = env.PDS.idFromName(repo) const pds = env.PDS.get(id) return pds.fetch(new Request(request.url, { method: 'POST', headers: request.headers, body: JSON.stringify(body) })) } ``` **Step 4: Test auth protection** ```bash # Without auth - should fail curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.createRecord' \ -H 'Content-Type: application/json' \ -d '{"repo":"did:web:chad-pds.chad-53c.workers.dev","collection":"app.bsky.feed.post","record":{"text":"test","createdAt":"2024-01-01T00:00:00Z"}}' # Expected: 401 AuthenticationRequired # With auth - should work TOKEN=$(curl -s -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \ -H 'Content-Type: application/json' \ -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}' | jq -r '.accessJwt') curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.createRecord' \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"repo":"did:web:chad-pds.chad-53c.workers.dev","collection":"app.bsky.feed.post","record":{"text":"test","createdAt":"2024-01-01T00:00:00Z"}}' # Expected: 200 with uri, cid, commit ``` **Step 5: Commit** ```bash git add src/pds.js git commit -m "feat: protect createRecord and deleteRecord with JWT auth" ``` --- ## Task 7: Configure Environment Variables **Files:** - Modify: `wrangler.toml` (optional - can use wrangler secret instead) **Step 1: Set secrets using wrangler** ```bash # Set the password for login npx wrangler secret put PDS_PASSWORD # Enter your password when prompted # Set the JWT signing secret (generate a random string) npx wrangler secret put JWT_SECRET # Enter a long random string (e.g., openssl rand -base64 32) ``` **Step 2: Deploy and verify** ```bash npx wrangler deploy ``` **Step 3: Test full flow** ```bash # Login curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \ -H 'Content-Type: application/json' \ -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}' ``` --- ## Task 8: Test with Bluesky App **Step 1: Open bsky.app** Go to https://bsky.app and click "Sign in" **Step 2: Enter custom PDS** Click "Hosting provider" and enter your PDS URL: `chad-pds.chad-53c.workers.dev` **Step 3: Login** Enter your handle (e.g., `chad-pds.chad-53c.workers.dev`) and password. **Step 4: Verify login works** You should see your profile. Try creating a post to verify write access works. **Step 5: Final commit** ```bash git add -A git commit -m "feat: complete authentication implementation for Bluesky app login" ``` --- ## Summary of Changes 1. **New exports in `src/pds.js`:** - `base64UrlEncode(bytes)` - Encode bytes to base64url - `base64UrlDecode(str)` - Decode base64url to bytes - `createAccessJwt(did, secret)` - Create access token - `createRefreshJwt(did, secret)` - Create refresh token - `verifyAccessJwt(jwt, secret)` - Verify access token 2. **New endpoints:** - `POST /xrpc/com.atproto.server.createSession` - Login - `GET /xrpc/com.atproto.server.getSession` - Verify session 3. **Modified endpoints:** - `POST /xrpc/com.atproto.repo.createRecord` - Now requires auth - `POST /xrpc/com.atproto.repo.deleteRecord` - Now requires auth 4. **Environment variables:** - `PDS_PASSWORD` - Password for login - `JWT_SECRET` - Secret for signing JWTs