this repo has no description
1# Authentication & Sessions Implementation Plan
2
3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
5**Goal:** Add authentication to the PDS so users can login from bsky.app and create records.
6
7**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.
8
9**Tech Stack:** Web Crypto API for HMAC signing, manual JWT encoding/decoding (no external deps)
10
11---
12
13## Task 1: Add JWT Helper Functions
14
15**Files:**
16- Modify: `src/pds.js:461-469` (after existing `base64UrlDecode`)
17
18**Step 1: Write failing test for base64url encode/decode**
19
20Add to `test/pds.test.js`:
21
22```javascript
23import {
24 cborEncode, cborDecode, createCid, cidToString, cidToBytes, base32Encode, createTid,
25 generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes,
26 getKeyDepth, varint, base32Decode, buildCarFile,
27 base64UrlEncode, base64UrlDecode
28} from '../src/pds.js'
29
30// Add new test block after existing tests:
31
32describe('JWT Base64URL', () => {
33 test('base64UrlEncode encodes bytes correctly', () => {
34 const input = new TextEncoder().encode('hello world')
35 const encoded = base64UrlEncode(input)
36 assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ')
37 assert.ok(!encoded.includes('+'))
38 assert.ok(!encoded.includes('/'))
39 assert.ok(!encoded.includes('='))
40 })
41
42 test('base64UrlDecode decodes string correctly', () => {
43 const decoded = base64UrlDecode('aGVsbG8gd29ybGQ')
44 const str = new TextDecoder().decode(decoded)
45 assert.strictEqual(str, 'hello world')
46 })
47
48 test('base64url roundtrip', () => {
49 const original = new Uint8Array([0, 1, 2, 255, 254, 253])
50 const encoded = base64UrlEncode(original)
51 const decoded = base64UrlDecode(encoded)
52 assert.deepStrictEqual(decoded, original)
53 })
54})
55```
56
57**Step 2: Run test to verify it fails**
58
59Run: `npm test -- --test-name-pattern "JWT Base64URL"`
60Expected: FAIL with "base64UrlEncode is not exported"
61
62**Step 3: Implement base64url functions**
63
64In `src/pds.js`, replace the existing `base64UrlDecode` function (around line 461) and add `base64UrlEncode`:
65
66```javascript
67/**
68 * Encode bytes as base64url string (no padding)
69 * @param {Uint8Array} bytes - Bytes to encode
70 * @returns {string} Base64url-encoded string
71 */
72export function base64UrlEncode(bytes) {
73 let binary = ''
74 for (const byte of bytes) {
75 binary += String.fromCharCode(byte)
76 }
77 const base64 = btoa(binary)
78 return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
79}
80
81/**
82 * Decode base64url string to bytes
83 * @param {string} str - Base64url-encoded string
84 * @returns {Uint8Array} Decoded bytes
85 */
86export function base64UrlDecode(str) {
87 const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
88 const pad = base64.length % 4
89 const padded = pad ? base64 + '='.repeat(4 - pad) : base64
90 const binary = atob(padded)
91 const bytes = new Uint8Array(binary.length)
92 for (let i = 0; i < binary.length; i++) {
93 bytes[i] = binary.charCodeAt(i)
94 }
95 return bytes
96}
97```
98
99**Step 4: Run test to verify it passes**
100
101Run: `npm test -- --test-name-pattern "JWT Base64URL"`
102Expected: PASS
103
104**Step 5: Commit**
105
106```bash
107git add src/pds.js test/pds.test.js
108git commit -m "feat: add base64url encode/decode helpers for JWT"
109```
110
111---
112
113## Task 2: Add JWT Creation Functions
114
115**Files:**
116- Modify: `src/pds.js` (add after base64url functions, around line 490)
117
118**Step 1: Write failing test for JWT creation**
119
120Add to `test/pds.test.js`:
121
122```javascript
123import {
124 // ... existing imports ...
125 base64UrlEncode, base64UrlDecode,
126 createAccessJwt, createRefreshJwt
127} from '../src/pds.js'
128
129describe('JWT Creation', () => {
130 test('createAccessJwt creates valid JWT structure', async () => {
131 const did = 'did:web:test.example'
132 const secret = 'test-secret-key'
133 const jwt = await createAccessJwt(did, secret)
134
135 const parts = jwt.split('.')
136 assert.strictEqual(parts.length, 3)
137
138 // Decode header
139 const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])))
140 assert.strictEqual(header.typ, 'at+jwt')
141 assert.strictEqual(header.alg, 'HS256')
142
143 // Decode payload
144 const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])))
145 assert.strictEqual(payload.scope, 'com.atproto.access')
146 assert.strictEqual(payload.sub, did)
147 assert.strictEqual(payload.aud, did)
148 assert.ok(payload.iat > 0)
149 assert.ok(payload.exp > payload.iat)
150 })
151
152 test('createRefreshJwt creates valid JWT with jti', async () => {
153 const did = 'did:web:test.example'
154 const secret = 'test-secret-key'
155 const jwt = await createRefreshJwt(did, secret)
156
157 const parts = jwt.split('.')
158 const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])))
159 assert.strictEqual(header.typ, 'refresh+jwt')
160
161 const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])))
162 assert.strictEqual(payload.scope, 'com.atproto.refresh')
163 assert.ok(payload.jti) // has unique token ID
164 })
165})
166```
167
168**Step 2: Run test to verify it fails**
169
170Run: `npm test -- --test-name-pattern "JWT Creation"`
171Expected: FAIL with "createAccessJwt is not exported"
172
173**Step 3: Implement JWT creation functions**
174
175Add to `src/pds.js` after base64url functions:
176
177```javascript
178/**
179 * Create HMAC-SHA256 signature for JWT
180 * @param {string} data - Data to sign (header.payload)
181 * @param {string} secret - Secret key
182 * @returns {Promise<string>} Base64url-encoded signature
183 */
184async function hmacSign(data, secret) {
185 const key = await crypto.subtle.importKey(
186 'raw',
187 new TextEncoder().encode(secret),
188 { name: 'HMAC', hash: 'SHA-256' },
189 false,
190 ['sign']
191 )
192 const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data))
193 return base64UrlEncode(new Uint8Array(sig))
194}
195
196/**
197 * Create an access JWT for ATProto
198 * @param {string} did - User's DID (subject and audience)
199 * @param {string} secret - JWT signing secret
200 * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours)
201 * @returns {Promise<string>} Signed JWT
202 */
203export async function createAccessJwt(did, secret, expiresIn = 7200) {
204 const header = { typ: 'at+jwt', alg: 'HS256' }
205 const now = Math.floor(Date.now() / 1000)
206 const payload = {
207 scope: 'com.atproto.access',
208 sub: did,
209 aud: did,
210 iat: now,
211 exp: now + expiresIn
212 }
213
214 const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)))
215 const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload)))
216 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret)
217
218 return `${headerB64}.${payloadB64}.${signature}`
219}
220
221/**
222 * Create a refresh JWT for ATProto
223 * @param {string} did - User's DID (subject and audience)
224 * @param {string} secret - JWT signing secret
225 * @param {number} [expiresIn=7776000] - Expiration in seconds (default 90 days)
226 * @returns {Promise<string>} Signed JWT
227 */
228export async function createRefreshJwt(did, secret, expiresIn = 7776000) {
229 const header = { typ: 'refresh+jwt', alg: 'HS256' }
230 const now = Math.floor(Date.now() / 1000)
231 // Generate random jti (token ID)
232 const jtiBytes = new Uint8Array(32)
233 crypto.getRandomValues(jtiBytes)
234 const jti = base64UrlEncode(jtiBytes)
235
236 const payload = {
237 scope: 'com.atproto.refresh',
238 sub: did,
239 aud: did,
240 jti,
241 iat: now,
242 exp: now + expiresIn
243 }
244
245 const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)))
246 const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload)))
247 const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret)
248
249 return `${headerB64}.${payloadB64}.${signature}`
250}
251```
252
253**Step 4: Run test to verify it passes**
254
255Run: `npm test -- --test-name-pattern "JWT Creation"`
256Expected: PASS
257
258**Step 5: Commit**
259
260```bash
261git add src/pds.js test/pds.test.js
262git commit -m "feat: add JWT creation functions for access and refresh tokens"
263```
264
265---
266
267## Task 3: Add JWT Verification Function
268
269**Files:**
270- Modify: `src/pds.js` (add after JWT creation functions)
271
272**Step 1: Write failing test for JWT verification**
273
274Add to `test/pds.test.js`:
275
276```javascript
277import {
278 // ... existing imports ...
279 createAccessJwt, createRefreshJwt,
280 verifyAccessJwt
281} from '../src/pds.js'
282
283describe('JWT Verification', () => {
284 test('verifyAccessJwt returns payload for valid token', async () => {
285 const did = 'did:web:test.example'
286 const secret = 'test-secret-key'
287 const jwt = await createAccessJwt(did, secret)
288
289 const payload = await verifyAccessJwt(jwt, secret)
290 assert.strictEqual(payload.sub, did)
291 assert.strictEqual(payload.scope, 'com.atproto.access')
292 })
293
294 test('verifyAccessJwt throws for wrong secret', async () => {
295 const did = 'did:web:test.example'
296 const jwt = await createAccessJwt(did, 'correct-secret')
297
298 await assert.rejects(
299 () => verifyAccessJwt(jwt, 'wrong-secret'),
300 /invalid signature/i
301 )
302 })
303
304 test('verifyAccessJwt throws for expired token', async () => {
305 const did = 'did:web:test.example'
306 const secret = 'test-secret-key'
307 // Create token that expired 1 second ago
308 const jwt = await createAccessJwt(did, secret, -1)
309
310 await assert.rejects(
311 () => verifyAccessJwt(jwt, secret),
312 /expired/i
313 )
314 })
315
316 test('verifyAccessJwt throws for refresh token', async () => {
317 const did = 'did:web:test.example'
318 const secret = 'test-secret-key'
319 const jwt = await createRefreshJwt(did, secret)
320
321 await assert.rejects(
322 () => verifyAccessJwt(jwt, secret),
323 /invalid token type/i
324 )
325 })
326})
327```
328
329**Step 2: Run test to verify it fails**
330
331Run: `npm test -- --test-name-pattern "JWT Verification"`
332Expected: FAIL with "verifyAccessJwt is not exported"
333
334**Step 3: Implement JWT verification**
335
336Add to `src/pds.js` after JWT creation functions:
337
338```javascript
339/**
340 * Verify and decode an access JWT
341 * @param {string} jwt - JWT string to verify
342 * @param {string} secret - JWT signing secret
343 * @returns {Promise<Object>} Decoded payload
344 * @throws {Error} If token is invalid, expired, or wrong type
345 */
346export async function verifyAccessJwt(jwt, secret) {
347 const parts = jwt.split('.')
348 if (parts.length !== 3) {
349 throw new Error('Invalid JWT format')
350 }
351
352 const [headerB64, payloadB64, signatureB64] = parts
353
354 // Verify signature
355 const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret)
356 if (signatureB64 !== expectedSig) {
357 throw new Error('Invalid signature')
358 }
359
360 // Decode header and payload
361 const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)))
362 const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)))
363
364 // Check token type
365 if (header.typ !== 'at+jwt') {
366 throw new Error('Invalid token type: expected access token')
367 }
368
369 // Check expiration
370 const now = Math.floor(Date.now() / 1000)
371 if (payload.exp && payload.exp < now) {
372 throw new Error('Token expired')
373 }
374
375 return payload
376}
377```
378
379**Step 4: Run test to verify it passes**
380
381Run: `npm test -- --test-name-pattern "JWT Verification"`
382Expected: PASS
383
384**Step 5: Commit**
385
386```bash
387git add src/pds.js test/pds.test.js
388git commit -m "feat: add JWT verification function"
389```
390
391---
392
393## Task 4: Add createSession Endpoint
394
395**Files:**
396- Modify: `src/pds.js:869-940` (add to pdsRoutes)
397- Modify: `src/pds.js` (add handler method to PersonalDataServer class)
398
399**Step 1: Add route to pdsRoutes**
400
401In `src/pds.js`, add to the `pdsRoutes` object (around line 902, after describeServer):
402
403```javascript
404 '/xrpc/com.atproto.server.createSession': {
405 method: 'POST',
406 handler: (pds, req, url) => pds.handleCreateSession(req)
407 },
408```
409
410**Step 2: Add handler method**
411
412Add to `PersonalDataServer` class (after `handleDescribeServer`, around line 1427):
413
414```javascript
415 async handleCreateSession(request) {
416 const body = await request.json()
417 const { identifier, password } = body
418
419 if (!identifier || !password) {
420 return Response.json({
421 error: 'InvalidRequest',
422 message: 'Missing identifier or password'
423 }, { status: 400 })
424 }
425
426 // Check password against env var
427 const expectedPassword = this.env?.PDS_PASSWORD
428 if (!expectedPassword || password !== expectedPassword) {
429 return Response.json({
430 error: 'AuthenticationRequired',
431 message: 'Invalid identifier or password'
432 }, { status: 401 })
433 }
434
435 // Resolve identifier to DID
436 let did = identifier
437 if (!identifier.startsWith('did:')) {
438 // Try to resolve handle
439 const handleMap = await this.state.storage.get('handleMap') || {}
440 did = handleMap[identifier]
441 if (!did) {
442 return Response.json({
443 error: 'InvalidRequest',
444 message: 'Unable to resolve handle'
445 }, { status: 400 })
446 }
447 }
448
449 // Get handle for response
450 const handle = await this.getHandleForDid(did)
451
452 // Create tokens
453 const jwtSecret = this.env?.JWT_SECRET
454 if (!jwtSecret) {
455 return Response.json({
456 error: 'InternalServerError',
457 message: 'Server not configured for authentication'
458 }, { status: 500 })
459 }
460
461 const accessJwt = await createAccessJwt(did, jwtSecret)
462 const refreshJwt = await createRefreshJwt(did, jwtSecret)
463
464 return Response.json({
465 accessJwt,
466 refreshJwt,
467 handle: handle || did,
468 did,
469 active: true
470 })
471 }
472
473 async getHandleForDid(did) {
474 // Check if this DID has a handle registered
475 const handleMap = await this.state.storage.get('handleMap') || {}
476 for (const [handle, mappedDid] of Object.entries(handleMap)) {
477 if (mappedDid === did) return handle
478 }
479 // Check instance's own handle
480 const instanceDid = await this.getDid()
481 if (instanceDid === did) {
482 return await this.state.storage.get('handle')
483 }
484 return null
485 }
486```
487
488**Step 3: Add route in main handleRequest**
489
490In `src/pds.js`, in the `handleRequest` function (around line 1796), add handling for createSession right after describeServer:
491
492```javascript
493 // createSession - handle on default DO (has handleMap for identifier resolution)
494 if (url.pathname === '/xrpc/com.atproto.server.createSession') {
495 const defaultId = env.PDS.idFromName('default')
496 const defaultPds = env.PDS.get(defaultId)
497 return defaultPds.fetch(request)
498 }
499```
500
501**Step 4: Test manually**
502
503Deploy and test:
504```bash
505npx wrangler deploy
506curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \
507 -H 'Content-Type: application/json' \
508 -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}'
509```
510
511Expected: JSON response with `accessJwt`, `refreshJwt`, `handle`, `did`, `active`
512
513**Step 5: Commit**
514
515```bash
516git add src/pds.js
517git commit -m "feat: add com.atproto.server.createSession endpoint"
518```
519
520---
521
522## Task 5: Add getSession Endpoint
523
524**Files:**
525- Modify: `src/pds.js` (add route and handler)
526
527**Step 1: Add route to pdsRoutes**
528
529In `src/pds.js`, add to the `pdsRoutes` object (after createSession):
530
531```javascript
532 '/xrpc/com.atproto.server.getSession': {
533 handler: (pds, req, url) => pds.handleGetSession(req)
534 },
535```
536
537**Step 2: Add handler method**
538
539Add to `PersonalDataServer` class (after `handleCreateSession`):
540
541```javascript
542 async handleGetSession(request) {
543 const authHeader = request.headers.get('Authorization')
544 if (!authHeader || !authHeader.startsWith('Bearer ')) {
545 return Response.json({
546 error: 'AuthenticationRequired',
547 message: 'Missing or invalid authorization header'
548 }, { status: 401 })
549 }
550
551 const token = authHeader.slice(7) // Remove 'Bearer '
552 const jwtSecret = this.env?.JWT_SECRET
553 if (!jwtSecret) {
554 return Response.json({
555 error: 'InternalServerError',
556 message: 'Server not configured for authentication'
557 }, { status: 500 })
558 }
559
560 try {
561 const payload = await verifyAccessJwt(token, jwtSecret)
562 const did = payload.sub
563 const handle = await this.getHandleForDid(did)
564
565 return Response.json({
566 handle: handle || did,
567 did,
568 active: true
569 })
570 } catch (err) {
571 return Response.json({
572 error: 'InvalidToken',
573 message: err.message
574 }, { status: 401 })
575 }
576 }
577```
578
579**Step 3: Add route in main handleRequest**
580
581In `src/pds.js`, in the `handleRequest` function, add handling for getSession (after createSession):
582
583```javascript
584 // getSession - route to default DO
585 if (url.pathname === '/xrpc/com.atproto.server.getSession') {
586 const defaultId = env.PDS.idFromName('default')
587 const defaultPds = env.PDS.get(defaultId)
588 return defaultPds.fetch(request)
589 }
590```
591
592**Step 4: Test manually**
593
594```bash
595# First get a token
596TOKEN=$(curl -s -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \
597 -H 'Content-Type: application/json' \
598 -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}' | jq -r '.accessJwt')
599
600# Then test getSession
601curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.getSession' \
602 -H "Authorization: Bearer $TOKEN"
603```
604
605Expected: JSON response with `handle`, `did`, `active`
606
607**Step 5: Commit**
608
609```bash
610git add src/pds.js
611git commit -m "feat: add com.atproto.server.getSession endpoint"
612```
613
614---
615
616## Task 6: Add Auth Middleware and Protect Write Endpoints
617
618**Files:**
619- Modify: `src/pds.js` (add requireAuth helper, modify createRecord/deleteRecord handlers)
620
621**Step 1: Add requireAuth helper function**
622
623Add to `src/pds.js` (before the `handleRequest` function, around line 1774):
624
625```javascript
626/**
627 * Verify auth and return DID from token
628 * @param {Request} request - HTTP request with Authorization header
629 * @param {Object} env - Environment with JWT_SECRET
630 * @returns {Promise<{did: string} | {error: Response}>} DID or error response
631 */
632async function requireAuth(request, env) {
633 const authHeader = request.headers.get('Authorization')
634 if (!authHeader || !authHeader.startsWith('Bearer ')) {
635 return {
636 error: Response.json({
637 error: 'AuthenticationRequired',
638 message: 'Authentication required'
639 }, { status: 401 })
640 }
641 }
642
643 const token = authHeader.slice(7)
644 const jwtSecret = env?.JWT_SECRET
645 if (!jwtSecret) {
646 return {
647 error: Response.json({
648 error: 'InternalServerError',
649 message: 'Server not configured for authentication'
650 }, { status: 500 })
651 }
652 }
653
654 try {
655 const payload = await verifyAccessJwt(token, jwtSecret)
656 return { did: payload.sub }
657 } catch (err) {
658 return {
659 error: Response.json({
660 error: 'InvalidToken',
661 message: err.message
662 }, { status: 401 })
663 }
664 }
665}
666```
667
668**Step 2: Modify createRecord in handleRequest**
669
670In `src/pds.js`, find the createRecord handling in `handleRequest` (around line 1854) and update it:
671
672```javascript
673 // POST repo endpoints have repo in body - REQUIRE AUTH
674 if (url.pathname === '/xrpc/com.atproto.repo.createRecord') {
675 // Check auth first
676 const auth = await requireAuth(request, env)
677 if (auth.error) return auth.error
678
679 // Clone request to read body
680 const body = await request.json()
681 const repo = body.repo
682 if (!repo) {
683 return Response.json({ error: 'InvalidRequest', message: 'missing repo param' }, { status: 400 })
684 }
685
686 // Verify authenticated user matches repo
687 if (auth.did !== repo) {
688 return Response.json({
689 error: 'Forbidden',
690 message: 'Cannot write to another user\'s repo'
691 }, { status: 403 })
692 }
693
694 const id = env.PDS.idFromName(repo)
695 const pds = env.PDS.get(id)
696 return pds.fetch(new Request(request.url, {
697 method: 'POST',
698 headers: request.headers,
699 body: JSON.stringify(body)
700 }))
701 }
702```
703
704**Step 3: Modify deleteRecord in handleRequest**
705
706Update the deleteRecord handling similarly:
707
708```javascript
709 if (url.pathname === '/xrpc/com.atproto.repo.deleteRecord') {
710 // Check auth first
711 const auth = await requireAuth(request, env)
712 if (auth.error) return auth.error
713
714 const body = await request.json()
715 const repo = body.repo
716 if (!repo) {
717 return Response.json({ error: 'InvalidRequest', message: 'missing repo param' }, { status: 400 })
718 }
719
720 // Verify authenticated user matches repo
721 if (auth.did !== repo) {
722 return Response.json({
723 error: 'Forbidden',
724 message: 'Cannot modify another user\'s repo'
725 }, { status: 403 })
726 }
727
728 const id = env.PDS.idFromName(repo)
729 const pds = env.PDS.get(id)
730 return pds.fetch(new Request(request.url, {
731 method: 'POST',
732 headers: request.headers,
733 body: JSON.stringify(body)
734 }))
735 }
736```
737
738**Step 4: Test auth protection**
739
740```bash
741# Without auth - should fail
742curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.createRecord' \
743 -H 'Content-Type: application/json' \
744 -d '{"repo":"did:web:chad-pds.chad-53c.workers.dev","collection":"app.bsky.feed.post","record":{"text":"test","createdAt":"2024-01-01T00:00:00Z"}}'
745# Expected: 401 AuthenticationRequired
746
747# With auth - should work
748TOKEN=$(curl -s -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \
749 -H 'Content-Type: application/json' \
750 -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}' | jq -r '.accessJwt')
751
752curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.createRecord' \
753 -H 'Content-Type: application/json' \
754 -H "Authorization: Bearer $TOKEN" \
755 -d '{"repo":"did:web:chad-pds.chad-53c.workers.dev","collection":"app.bsky.feed.post","record":{"text":"test","createdAt":"2024-01-01T00:00:00Z"}}'
756# Expected: 200 with uri, cid, commit
757```
758
759**Step 5: Commit**
760
761```bash
762git add src/pds.js
763git commit -m "feat: protect createRecord and deleteRecord with JWT auth"
764```
765
766---
767
768## Task 7: Configure Environment Variables
769
770**Files:**
771- Modify: `wrangler.toml` (optional - can use wrangler secret instead)
772
773**Step 1: Set secrets using wrangler**
774
775```bash
776# Set the password for login
777npx wrangler secret put PDS_PASSWORD
778# Enter your password when prompted
779
780# Set the JWT signing secret (generate a random string)
781npx wrangler secret put JWT_SECRET
782# Enter a long random string (e.g., openssl rand -base64 32)
783```
784
785**Step 2: Deploy and verify**
786
787```bash
788npx wrangler deploy
789```
790
791**Step 3: Test full flow**
792
793```bash
794# Login
795curl -X POST 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.createSession' \
796 -H 'Content-Type: application/json' \
797 -d '{"identifier":"chad-pds.chad-53c.workers.dev","password":"YOUR_PASSWORD"}'
798```
799
800---
801
802## Task 8: Test with Bluesky App
803
804**Step 1: Open bsky.app**
805
806Go to https://bsky.app and click "Sign in"
807
808**Step 2: Enter custom PDS**
809
810Click "Hosting provider" and enter your PDS URL: `chad-pds.chad-53c.workers.dev`
811
812**Step 3: Login**
813
814Enter your handle (e.g., `chad-pds.chad-53c.workers.dev`) and password.
815
816**Step 4: Verify login works**
817
818You should see your profile. Try creating a post to verify write access works.
819
820**Step 5: Final commit**
821
822```bash
823git add -A
824git commit -m "feat: complete authentication implementation for Bluesky app login"
825```
826
827---
828
829## Summary of Changes
830
8311. **New exports in `src/pds.js`:**
832 - `base64UrlEncode(bytes)` - Encode bytes to base64url
833 - `base64UrlDecode(str)` - Decode base64url to bytes
834 - `createAccessJwt(did, secret)` - Create access token
835 - `createRefreshJwt(did, secret)` - Create refresh token
836 - `verifyAccessJwt(jwt, secret)` - Verify access token
837
8382. **New endpoints:**
839 - `POST /xrpc/com.atproto.server.createSession` - Login
840 - `GET /xrpc/com.atproto.server.getSession` - Verify session
841
8423. **Modified endpoints:**
843 - `POST /xrpc/com.atproto.repo.createRecord` - Now requires auth
844 - `POST /xrpc/com.atproto.repo.deleteRecord` - Now requires auth
845
8464. **Environment variables:**
847 - `PDS_PASSWORD` - Password for login
848 - `JWT_SECRET` - Secret for signing JWTs