A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

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:

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:

/**
 * 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

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:

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:

/**
 * 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

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:

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:

/**
 * 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

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):

  '/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):

  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:

  // 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:

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

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):

  '/xrpc/com.atproto.server.getSession': {
    handler: (pds, req, url) => pds.handleGetSession(req)
  },

Step 2: Add handler method

Add to PersonalDataServer class (after handleCreateSession):

  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):

  // 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

# 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

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):

/**
 * 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:

  // 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:

  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

# 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

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

# 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

npx wrangler deploy

Step 3: Test full flow

# 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

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