~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
848 lines · 23.6 KB Raw History
# 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<string>} 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<string>} 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<string>} 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<Object>} 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