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 existingbase64UrlDecode)
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#
-
New exports in
src/pds.js:base64UrlEncode(bytes)- Encode bytes to base64urlbase64UrlDecode(str)- Decode base64url to bytescreateAccessJwt(did, secret)- Create access tokencreateRefreshJwt(did, secret)- Create refresh tokenverifyAccessJwt(jwt, secret)- Verify access token
-
New endpoints:
POST /xrpc/com.atproto.server.createSession- LoginGET /xrpc/com.atproto.server.getSession- Verify session
-
Modified endpoints:
POST /xrpc/com.atproto.repo.createRecord- Now requires authPOST /xrpc/com.atproto.repo.deleteRecord- Now requires auth
-
Environment variables:
PDS_PASSWORD- Password for loginJWT_SECRET- Secret for signing JWTs