this repo has no description

feat: add JWT authentication and session management

Implement full authentication system for ATProto PDS:

- JWT helpers: base64url encode/decode, HMAC-SHA256 signing
- Access tokens (2hr) and refresh tokens (90 days)
- createSession endpoint with password validation
- getSession endpoint with token verification
- Auth middleware protecting write endpoints (createRecord, deleteRecord, putRecord, applyWrites)
- AppView proxy with ES256 service auth for app.bsky.* endpoints
- Local storage for user preferences (getPreferences, putPreferences)
- resolveHandle XRPC endpoint

Refactoring:
- Consolidated 4 CBOR encoders into 2
- Added errorResponse() helper for consistent ATProto error format
- Extracted handleAuthenticatedRepoWrite() to reduce duplication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+1567 -153
docs
src
test
+848
docs/plans/2026-01-05-auth-sessions.md
··· 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 + 20 + Add to `test/pds.test.js`: 21 + 22 + ```javascript 23 + import { 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 + 32 + describe('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 + 59 + Run: `npm test -- --test-name-pattern "JWT Base64URL"` 60 + Expected: FAIL with "base64UrlEncode is not exported" 61 + 62 + **Step 3: Implement base64url functions** 63 + 64 + In `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 + */ 72 + export 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 + */ 86 + export 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 + 101 + Run: `npm test -- --test-name-pattern "JWT Base64URL"` 102 + Expected: PASS 103 + 104 + **Step 5: Commit** 105 + 106 + ```bash 107 + git add src/pds.js test/pds.test.js 108 + git 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 + 120 + Add to `test/pds.test.js`: 121 + 122 + ```javascript 123 + import { 124 + // ... existing imports ... 125 + base64UrlEncode, base64UrlDecode, 126 + createAccessJwt, createRefreshJwt 127 + } from '../src/pds.js' 128 + 129 + describe('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 + 170 + Run: `npm test -- --test-name-pattern "JWT Creation"` 171 + Expected: FAIL with "createAccessJwt is not exported" 172 + 173 + **Step 3: Implement JWT creation functions** 174 + 175 + Add 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 + */ 184 + async 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 + */ 203 + export 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 + */ 228 + export 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 + 255 + Run: `npm test -- --test-name-pattern "JWT Creation"` 256 + Expected: PASS 257 + 258 + **Step 5: Commit** 259 + 260 + ```bash 261 + git add src/pds.js test/pds.test.js 262 + git 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 + 274 + Add to `test/pds.test.js`: 275 + 276 + ```javascript 277 + import { 278 + // ... existing imports ... 279 + createAccessJwt, createRefreshJwt, 280 + verifyAccessJwt 281 + } from '../src/pds.js' 282 + 283 + describe('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 + 331 + Run: `npm test -- --test-name-pattern "JWT Verification"` 332 + Expected: FAIL with "verifyAccessJwt is not exported" 333 + 334 + **Step 3: Implement JWT verification** 335 + 336 + Add 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 + */ 346 + export 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 + 381 + Run: `npm test -- --test-name-pattern "JWT Verification"` 382 + Expected: PASS 383 + 384 + **Step 5: Commit** 385 + 386 + ```bash 387 + git add src/pds.js test/pds.test.js 388 + git 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 + 401 + In `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 + 412 + Add 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 + 490 + In `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 + 503 + Deploy and test: 504 + ```bash 505 + npx wrangler deploy 506 + curl -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 + 511 + Expected: JSON response with `accessJwt`, `refreshJwt`, `handle`, `did`, `active` 512 + 513 + **Step 5: Commit** 514 + 515 + ```bash 516 + git add src/pds.js 517 + git 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 + 529 + In `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 + 539 + Add 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 + 581 + In `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 596 + TOKEN=$(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 601 + curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.server.getSession' \ 602 + -H "Authorization: Bearer $TOKEN" 603 + ``` 604 + 605 + Expected: JSON response with `handle`, `did`, `active` 606 + 607 + **Step 5: Commit** 608 + 609 + ```bash 610 + git add src/pds.js 611 + git 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 + 623 + Add 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 + */ 632 + async 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 + 670 + In `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 + 706 + Update 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 742 + curl -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 748 + TOKEN=$(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 + 752 + curl -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 762 + git add src/pds.js 763 + git 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 777 + npx wrangler secret put PDS_PASSWORD 778 + # Enter your password when prompted 779 + 780 + # Set the JWT signing secret (generate a random string) 781 + npx 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 788 + npx wrangler deploy 789 + ``` 790 + 791 + **Step 3: Test full flow** 792 + 793 + ```bash 794 + # Login 795 + curl -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 + 806 + Go to https://bsky.app and click "Sign in" 807 + 808 + **Step 2: Enter custom PDS** 809 + 810 + Click "Hosting provider" and enter your PDS URL: `chad-pds.chad-53c.workers.dev` 811 + 812 + **Step 3: Login** 813 + 814 + Enter your handle (e.g., `chad-pds.chad-53c.workers.dev`) and password. 815 + 816 + **Step 4: Verify login works** 817 + 818 + You should see your profile. Try creating a post to verify write access works. 819 + 820 + **Step 5: Final commit** 821 + 822 + ```bash 823 + git add -A 824 + git commit -m "feat: complete authentication implementation for Bluesky app login" 825 + ``` 826 + 827 + --- 828 + 829 + ## Summary of Changes 830 + 831 + 1. **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 + 838 + 2. **New endpoints:** 839 + - `POST /xrpc/com.atproto.server.createSession` - Login 840 + - `GET /xrpc/com.atproto.server.getSession` - Verify session 841 + 842 + 3. **Modified endpoints:** 843 + - `POST /xrpc/com.atproto.repo.createRecord` - Now requires auth 844 + - `POST /xrpc/com.atproto.repo.deleteRecord` - Now requires auth 845 + 846 + 4. **Environment variables:** 847 + - `PDS_PASSWORD` - Password for login 848 + - `JWT_SECRET` - Secret for signing JWTs
+609 -152
src/pds.js
··· 23 23 // DAG-CBOR CID link tag 24 24 const CBOR_TAG_CID = 42 25 25 26 + // === ERROR HELPER === 27 + function errorResponse(error, message, status) { 28 + return Response.json({ error, message }, { status }) 29 + } 30 + 26 31 // === CID WRAPPER === 27 32 // Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection) 28 33 ··· 458 463 return compressed 459 464 } 460 465 461 - function base64UrlDecode(str) { 466 + /** 467 + * Encode bytes as base64url string (no padding) 468 + * @param {Uint8Array} bytes - Bytes to encode 469 + * @returns {string} Base64url-encoded string 470 + */ 471 + export function base64UrlEncode(bytes) { 472 + let binary = '' 473 + for (const byte of bytes) { 474 + binary += String.fromCharCode(byte) 475 + } 476 + const base64 = btoa(binary) 477 + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 478 + } 479 + 480 + /** 481 + * Decode base64url string to bytes 482 + * @param {string} str - Base64url-encoded string 483 + * @returns {Uint8Array} Decoded bytes 484 + */ 485 + export function base64UrlDecode(str) { 462 486 const base64 = str.replace(/-/g, '+').replace(/_/g, '/') 463 - const binary = atob(base64) 487 + const pad = base64.length % 4 488 + const padded = pad ? base64 + '='.repeat(4 - pad) : base64 489 + const binary = atob(padded) 464 490 const bytes = new Uint8Array(binary.length) 465 491 for (let i = 0; i < binary.length; i++) { 466 492 bytes[i] = binary.charCodeAt(i) ··· 469 495 } 470 496 471 497 /** 498 + * Create HMAC-SHA256 signature for JWT 499 + * @param {string} data - Data to sign (header.payload) 500 + * @param {string} secret - Secret key 501 + * @returns {Promise<string>} Base64url-encoded signature 502 + */ 503 + async function hmacSign(data, secret) { 504 + const key = await crypto.subtle.importKey( 505 + 'raw', 506 + new TextEncoder().encode(secret), 507 + { name: 'HMAC', hash: 'SHA-256' }, 508 + false, 509 + ['sign'] 510 + ) 511 + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)) 512 + return base64UrlEncode(new Uint8Array(sig)) 513 + } 514 + 515 + /** 516 + * Create an access JWT for ATProto 517 + * @param {string} did - User's DID (subject and audience) 518 + * @param {string} secret - JWT signing secret 519 + * @param {number} [expiresIn=7200] - Expiration in seconds (default 2 hours) 520 + * @returns {Promise<string>} Signed JWT 521 + */ 522 + export async function createAccessJwt(did, secret, expiresIn = 7200) { 523 + const header = { typ: 'at+jwt', alg: 'HS256' } 524 + const now = Math.floor(Date.now() / 1000) 525 + const payload = { 526 + scope: 'com.atproto.access', 527 + sub: did, 528 + aud: did, 529 + iat: now, 530 + exp: now + expiresIn 531 + } 532 + 533 + const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) 534 + const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) 535 + const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) 536 + 537 + return `${headerB64}.${payloadB64}.${signature}` 538 + } 539 + 540 + /** 541 + * Create a refresh JWT for ATProto 542 + * @param {string} did - User's DID (subject and audience) 543 + * @param {string} secret - JWT signing secret 544 + * @param {number} [expiresIn=7776000] - Expiration in seconds (default 90 days) 545 + * @returns {Promise<string>} Signed JWT 546 + */ 547 + export async function createRefreshJwt(did, secret, expiresIn = 7776000) { 548 + const header = { typ: 'refresh+jwt', alg: 'HS256' } 549 + const now = Math.floor(Date.now() / 1000) 550 + // Generate random jti (token ID) 551 + const jtiBytes = new Uint8Array(32) 552 + crypto.getRandomValues(jtiBytes) 553 + const jti = base64UrlEncode(jtiBytes) 554 + 555 + const payload = { 556 + scope: 'com.atproto.refresh', 557 + sub: did, 558 + aud: did, 559 + jti, 560 + iat: now, 561 + exp: now + expiresIn 562 + } 563 + 564 + const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) 565 + const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) 566 + const signature = await hmacSign(`${headerB64}.${payloadB64}`, secret) 567 + 568 + return `${headerB64}.${payloadB64}.${signature}` 569 + } 570 + 571 + /** 572 + * Verify and decode an access JWT 573 + * @param {string} jwt - JWT string to verify 574 + * @param {string} secret - JWT signing secret 575 + * @returns {Promise<Object>} Decoded payload 576 + * @throws {Error} If token is invalid, expired, or wrong type 577 + */ 578 + export async function verifyAccessJwt(jwt, secret) { 579 + const parts = jwt.split('.') 580 + if (parts.length !== 3) { 581 + throw new Error('Invalid JWT format') 582 + } 583 + 584 + const [headerB64, payloadB64, signatureB64] = parts 585 + 586 + // Verify signature 587 + const expectedSig = await hmacSign(`${headerB64}.${payloadB64}`, secret) 588 + if (signatureB64 !== expectedSig) { 589 + throw new Error('Invalid signature') 590 + } 591 + 592 + // Decode header and payload 593 + const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))) 594 + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))) 595 + 596 + // Check token type 597 + if (header.typ !== 'at+jwt') { 598 + throw new Error('Invalid token type: expected access token') 599 + } 600 + 601 + // Check expiration 602 + const now = Math.floor(Date.now() / 1000) 603 + if (payload.exp && payload.exp < now) { 604 + throw new Error('Token expired') 605 + } 606 + 607 + return payload 608 + } 609 + 610 + /** 611 + * Create a service auth JWT signed with ES256 (P-256) 612 + * Used for proxying requests to AppView 613 + * @param {Object} params - JWT parameters 614 + * @param {string} params.iss - Issuer DID (PDS DID) 615 + * @param {string} params.aud - Audience DID (AppView DID) 616 + * @param {string|null} params.lxm - Lexicon method being called 617 + * @param {CryptoKey} params.signingKey - P-256 private key from importPrivateKey 618 + * @returns {Promise<string>} Signed JWT 619 + */ 620 + export async function createServiceJwt({ iss, aud, lxm, signingKey }) { 621 + const header = { typ: 'JWT', alg: 'ES256' } 622 + const now = Math.floor(Date.now() / 1000) 623 + 624 + // Generate random jti 625 + const jtiBytes = new Uint8Array(16) 626 + crypto.getRandomValues(jtiBytes) 627 + const jti = bytesToHex(jtiBytes) 628 + 629 + const payload = { 630 + iss, 631 + aud, 632 + exp: now + 60, // 1 minute expiration 633 + iat: now, 634 + jti 635 + } 636 + if (lxm) payload.lxm = lxm 637 + 638 + const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header))) 639 + const payloadB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(payload))) 640 + const toSign = new TextEncoder().encode(`${headerB64}.${payloadB64}`) 641 + 642 + const sig = await sign(signingKey, toSign) 643 + const sigB64 = base64UrlEncode(sig) 644 + 645 + return `${headerB64}.${payloadB64}.${sigB64}` 646 + } 647 + 648 + /** 472 649 * Convert bytes to hexadecimal string 473 650 * @param {Uint8Array} bytes - Bytes to convert 474 651 * @returns {string} Hex string ··· 648 825 node.l = leftCid ? new CID(cidToBytes(leftCid)) : null 649 826 650 827 // Encode node with proper MST CBOR format 651 - const nodeBytes = cborEncodeMstNode(node) 828 + const nodeBytes = cborEncodeDagCbor(node) 652 829 const nodeCid = await createCid(nodeBytes) 653 830 const cidStr = cidToString(nodeCid) 654 831 ··· 662 839 } 663 840 } 664 841 665 - // Special CBOR encoder for MST nodes (CIDs as raw bytes with tag 42) 666 - function cborEncodeMstNode(node) { 667 - const parts = [] 668 - 669 - function encode(val) { 670 - if (val === null || val === undefined) { 671 - parts.push(CBOR_NULL) 672 - } else if (typeof val === 'number') { 673 - encodeHead(parts, 0, val) // unsigned int 674 - } else if (val instanceof CID) { 675 - // CID - encode with CBOR tag 42 + 0x00 prefix (DAG-CBOR CID link) 676 - parts.push(0xd8, CBOR_TAG_CID) 677 - encodeHead(parts, 2, val.bytes.length + 1) // +1 for 0x00 prefix 678 - parts.push(0x00) // multibase identity prefix 679 - parts.push(...val.bytes) 680 - } else if (val instanceof Uint8Array) { 681 - // Regular bytes 682 - encodeHead(parts, 2, val.length) 683 - parts.push(...val) 684 - } else if (Array.isArray(val)) { 685 - encodeHead(parts, 4, val.length) 686 - for (const item of val) encode(item) 687 - } else if (typeof val === 'object') { 688 - // Sort keys for deterministic encoding (DAG-CBOR style) 689 - // Include null values (ATProto MST requires l and t fields even when null) 690 - const keys = Object.keys(val).filter(k => val[k] !== undefined) 691 - keys.sort((a, b) => { 692 - // DAG-CBOR: sort by length first, then lexicographically 693 - if (a.length !== b.length) return a.length - b.length 694 - return a < b ? -1 : a > b ? 1 : 0 695 - }) 696 - encodeHead(parts, 5, keys.length) 697 - for (const key of keys) { 698 - // Encode key as text string 699 - const keyBytes = new TextEncoder().encode(key) 700 - encodeHead(parts, 3, keyBytes.length) 701 - parts.push(...keyBytes) 702 - // Encode value 703 - encode(val[key]) 704 - } 705 - } 706 - } 707 - 708 - encode(node) 709 - return new Uint8Array(parts) 710 - } 711 - 712 842 // === CAR FILE BUILDER === 713 843 714 844 /** ··· 762 892 return new Uint8Array(output) 763 893 } 764 894 765 - // Encode CAR header with proper DAG-CBOR CID links 766 - function cborEncodeCarHeader(obj) { 767 - const parts = [] 768 - 769 - function encodeHead(majorType, value) { 770 - if (value < 24) { 771 - parts.push((majorType << 5) | value) 772 - } else if (value < 256) { 773 - parts.push((majorType << 5) | 24, value) 774 - } else if (value < 65536) { 775 - parts.push((majorType << 5) | 25, value >> 8, value & 0xff) 776 - } 777 - } 778 - 779 - function encodeCidLink(cidBytes) { 780 - // DAG-CBOR CID link: tag(42) + byte string with 0x00 prefix 781 - parts.push(0xd8, 42) // tag 42 782 - const withPrefix = new Uint8Array(cidBytes.length + 1) 783 - withPrefix[0] = 0x00 // multibase identity prefix 784 - withPrefix.set(cidBytes, 1) 785 - encodeHead(2, withPrefix.length) 786 - parts.push(...withPrefix) 787 - } 788 - 789 - // Encode { roots: [...], version: 1 } 790 - // Sort keys: "roots" (5 chars) comes after "version" (7 chars)? No - shorter first 791 - // "roots" = 5 chars, "version" = 7 chars, so "roots" first 792 - encodeHead(5, 2) // map with 2 entries 793 - 794 - // Key "roots" 795 - const rootsKey = new TextEncoder().encode('roots') 796 - encodeHead(3, rootsKey.length) 797 - parts.push(...rootsKey) 798 - 799 - // Value: array of CID links 800 - encodeHead(4, obj.roots.length) 801 - for (const cid of obj.roots) { 802 - encodeCidLink(cid) 803 - } 804 - 805 - // Key "version" 806 - const versionKey = new TextEncoder().encode('version') 807 - encodeHead(3, versionKey.length) 808 - parts.push(...versionKey) 809 - 810 - // Value: 1 811 - parts.push(0x01) 812 - 813 - return new Uint8Array(parts) 814 - } 815 - 816 895 /** 817 896 * Build a CAR (Content Addressable aRchive) file 818 897 * @param {string} rootCid - Root CID string ··· 823 902 const parts = [] 824 903 825 904 // Header: { version: 1, roots: [rootCid] } 826 - // CIDs in header must be DAG-CBOR links (tag 42 + 0x00 prefix + CID bytes) 827 905 const rootCidBytes = cidToBytes(rootCid) 828 - const header = cborEncodeCarHeader({ version: 1, roots: [rootCidBytes] }) 906 + const header = cborEncodeDagCbor({ version: 1, roots: [new CID(rootCidBytes)] }) 829 907 parts.push(varint(header.length)) 830 908 parts.push(header) 831 909 ··· 902 980 '/xrpc/com.atproto.server.describeServer': { 903 981 handler: (pds, req, url) => pds.handleDescribeServer(req) 904 982 }, 983 + '/xrpc/com.atproto.server.createSession': { 984 + method: 'POST', 985 + handler: (pds, req, url) => pds.handleCreateSession(req) 986 + }, 987 + '/xrpc/com.atproto.server.getSession': { 988 + handler: (pds, req, url) => pds.handleGetSession(req) 989 + }, 990 + '/xrpc/app.bsky.actor.getPreferences': { 991 + handler: (pds, req, url) => pds.handleGetPreferences(req) 992 + }, 993 + '/xrpc/app.bsky.actor.putPreferences': { 994 + method: 'POST', 995 + handler: (pds, req, url) => pds.handlePutPreferences(req) 996 + }, 905 997 '/xrpc/com.atproto.sync.listRepos': { 906 998 handler: (pds, req, url) => pds.handleListRepos() 907 999 }, ··· 912 1004 '/xrpc/com.atproto.repo.deleteRecord': { 913 1005 method: 'POST', 914 1006 handler: (pds, req, url) => pds.handleDeleteRecord(req) 1007 + }, 1008 + '/xrpc/com.atproto.repo.putRecord': { 1009 + method: 'POST', 1010 + handler: (pds, req, url) => pds.handlePutRecord(req) 1011 + }, 1012 + '/xrpc/com.atproto.repo.applyWrites': { 1013 + method: 'POST', 1014 + handler: (pds, req, url) => pds.handleApplyWrites(req) 915 1015 }, 916 1016 '/xrpc/com.atproto.repo.getRecord': { 917 1017 handler: (pds, req, url) => pds.handleGetRecord(url) ··· 1334 1434 async handleInit(request) { 1335 1435 const body = await request.json() 1336 1436 if (!body.did || !body.privateKey) { 1337 - return Response.json({ error: 'missing did or privateKey' }, { status: 400 }) 1437 + return errorResponse('InvalidRequest', 'missing did or privateKey', 400) 1338 1438 } 1339 1439 await this.initIdentity(body.did, body.privateKey, body.handle || null) 1340 1440 return Response.json({ ok: true, did: body.did, handle: body.handle || null }) ··· 1387 1487 const body = await request.json() 1388 1488 const { handle, did } = body 1389 1489 if (!handle || !did) { 1390 - return Response.json({ error: 'missing handle or did' }, { status: 400 }) 1490 + return errorResponse('InvalidRequest', 'missing handle or did', 400) 1391 1491 } 1392 1492 const handleMap = await this.state.storage.get('handleMap') || {} 1393 1493 handleMap[handle] = did ··· 1398 1498 async handleResolveHandle(url) { 1399 1499 const handle = url.searchParams.get('handle') 1400 1500 if (!handle) { 1401 - return Response.json({ error: 'missing handle' }, { status: 400 }) 1501 + return errorResponse('InvalidRequest', 'missing handle', 400) 1402 1502 } 1403 1503 const handleMap = await this.state.storage.get('handleMap') || {} 1404 1504 const did = handleMap[handle] 1405 1505 if (!did) { 1406 - return Response.json({ error: 'handle not found' }, { status: 404 }) 1506 + return errorResponse('NotFound', 'handle not found', 404) 1407 1507 } 1408 1508 return Response.json({ did }) 1409 1509 } ··· 1426 1526 }) 1427 1527 } 1428 1528 1529 + async handleCreateSession(request) { 1530 + const body = await request.json() 1531 + const { identifier, password } = body 1532 + 1533 + if (!identifier || !password) { 1534 + return errorResponse('InvalidRequest', 'Missing identifier or password', 400) 1535 + } 1536 + 1537 + // Check password against env var 1538 + const expectedPassword = this.env?.PDS_PASSWORD 1539 + if (!expectedPassword || password !== expectedPassword) { 1540 + return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401) 1541 + } 1542 + 1543 + // Resolve identifier to DID 1544 + let did = identifier 1545 + if (!identifier.startsWith('did:')) { 1546 + // Try to resolve handle 1547 + const handleMap = await this.state.storage.get('handleMap') || {} 1548 + did = handleMap[identifier] 1549 + if (!did) { 1550 + return errorResponse('InvalidRequest', 'Unable to resolve handle', 400) 1551 + } 1552 + } 1553 + 1554 + // Get handle for response 1555 + const handle = await this.getHandleForDid(did) 1556 + 1557 + // Create tokens 1558 + const jwtSecret = this.env?.JWT_SECRET 1559 + if (!jwtSecret) { 1560 + return errorResponse('InternalServerError', 'Server not configured for authentication', 500) 1561 + } 1562 + 1563 + const accessJwt = await createAccessJwt(did, jwtSecret) 1564 + const refreshJwt = await createRefreshJwt(did, jwtSecret) 1565 + 1566 + return Response.json({ 1567 + accessJwt, 1568 + refreshJwt, 1569 + handle: handle || did, 1570 + did, 1571 + active: true 1572 + }) 1573 + } 1574 + 1575 + async handleGetSession(request) { 1576 + const authHeader = request.headers.get('Authorization') 1577 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 1578 + return errorResponse('AuthenticationRequired', 'Missing or invalid authorization header', 401) 1579 + } 1580 + 1581 + const token = authHeader.slice(7) // Remove 'Bearer ' 1582 + const jwtSecret = this.env?.JWT_SECRET 1583 + if (!jwtSecret) { 1584 + return errorResponse('InternalServerError', 'Server not configured for authentication', 500) 1585 + } 1586 + 1587 + try { 1588 + const payload = await verifyAccessJwt(token, jwtSecret) 1589 + const did = payload.sub 1590 + const handle = await this.getHandleForDid(did) 1591 + 1592 + return Response.json({ 1593 + handle: handle || did, 1594 + did, 1595 + active: true 1596 + }) 1597 + } catch (err) { 1598 + return errorResponse('InvalidToken', err.message, 401) 1599 + } 1600 + } 1601 + 1602 + async handleGetPreferences(request) { 1603 + // Preferences are stored per-user in their DO 1604 + const preferences = await this.state.storage.get('preferences') || [] 1605 + return Response.json({ preferences }) 1606 + } 1607 + 1608 + async handlePutPreferences(request) { 1609 + const body = await request.json() 1610 + const { preferences } = body 1611 + if (!Array.isArray(preferences)) { 1612 + return errorResponse('InvalidRequest', 'preferences must be an array', 400) 1613 + } 1614 + await this.state.storage.put('preferences', preferences) 1615 + return Response.json({}) 1616 + } 1617 + 1618 + async getHandleForDid(did) { 1619 + // Check if this DID has a handle registered 1620 + const handleMap = await this.state.storage.get('handleMap') || {} 1621 + for (const [handle, mappedDid] of Object.entries(handleMap)) { 1622 + if (mappedDid === did) return handle 1623 + } 1624 + // Check instance's own handle 1625 + const instanceDid = await this.getDid() 1626 + if (instanceDid === did) { 1627 + return await this.state.storage.get('handle') 1628 + } 1629 + return null 1630 + } 1631 + 1632 + async createServiceAuthForAppView(did, lxm) { 1633 + const signingKey = await this.getSigningKey() 1634 + return createServiceJwt({ 1635 + iss: did, 1636 + aud: 'did:web:api.bsky.app', 1637 + lxm, 1638 + signingKey 1639 + }) 1640 + } 1641 + 1642 + async handleAppViewProxy(request, userDid) { 1643 + const url = new URL(request.url) 1644 + // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 1645 + const lxm = url.pathname.replace('/xrpc/', '') 1646 + 1647 + // Create service auth JWT 1648 + const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm) 1649 + 1650 + // Build AppView URL 1651 + const appViewUrl = new URL(url.pathname + url.search, 'https://api.bsky.app') 1652 + 1653 + // Forward request with service auth 1654 + const headers = new Headers() 1655 + headers.set('Authorization', `Bearer ${serviceJwt}`) 1656 + headers.set('Content-Type', request.headers.get('Content-Type') || 'application/json') 1657 + if (request.headers.get('Accept')) { 1658 + headers.set('Accept', request.headers.get('Accept')) 1659 + } 1660 + if (request.headers.get('Accept-Language')) { 1661 + headers.set('Accept-Language', request.headers.get('Accept-Language')) 1662 + } 1663 + 1664 + const proxyReq = new Request(appViewUrl.toString(), { 1665 + method: request.method, 1666 + headers, 1667 + body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined, 1668 + }) 1669 + 1670 + try { 1671 + const response = await fetch(proxyReq) 1672 + // Return the response with CORS headers 1673 + const responseHeaders = new Headers(response.headers) 1674 + responseHeaders.set('Access-Control-Allow-Origin', '*') 1675 + return new Response(response.body, { 1676 + status: response.status, 1677 + statusText: response.statusText, 1678 + headers: responseHeaders 1679 + }) 1680 + } catch (err) { 1681 + return errorResponse('UpstreamFailure', 'Failed to reach AppView: ' + err.message, 502) 1682 + } 1683 + } 1684 + 1429 1685 async handleListRepos() { 1430 1686 const registeredDids = await this.state.storage.get('registeredDids') || [] 1431 1687 const did = await this.getDid() ··· 1437 1693 async handleCreateRecord(request) { 1438 1694 const body = await request.json() 1439 1695 if (!body.collection || !body.record) { 1440 - return Response.json({ error: 'missing collection or record' }, { status: 400 }) 1696 + return errorResponse('InvalidRequest', 'missing collection or record', 400) 1441 1697 } 1442 1698 try { 1443 1699 const result = await this.createRecord(body.collection, body.record, body.rkey) 1444 - return Response.json(result) 1700 + const head = await this.state.storage.get('head') 1701 + const rev = await this.state.storage.get('rev') 1702 + return Response.json({ 1703 + uri: result.uri, 1704 + cid: result.cid, 1705 + commit: { cid: head, rev }, 1706 + validationStatus: 'valid' 1707 + }) 1445 1708 } catch (err) { 1446 - return Response.json({ error: err.message }, { status: 500 }) 1709 + return errorResponse('InternalError', err.message, 500) 1447 1710 } 1448 1711 } 1449 1712 1450 1713 async handleDeleteRecord(request) { 1451 1714 const body = await request.json() 1452 1715 if (!body.collection || !body.rkey) { 1453 - return Response.json({ error: 'InvalidRequest', message: 'missing collection or rkey' }, { status: 400 }) 1716 + return errorResponse('InvalidRequest', 'missing collection or rkey', 400) 1454 1717 } 1455 1718 try { 1456 1719 const result = await this.deleteRecord(body.collection, body.rkey) ··· 1459 1722 } 1460 1723 return Response.json({}) 1461 1724 } catch (err) { 1462 - return Response.json({ error: err.message }, { status: 500 }) 1725 + return errorResponse('InternalError', err.message, 500) 1726 + } 1727 + } 1728 + 1729 + async handlePutRecord(request) { 1730 + const body = await request.json() 1731 + if (!body.collection || !body.rkey || !body.record) { 1732 + return errorResponse('InvalidRequest', 'missing collection, rkey, or record', 400) 1733 + } 1734 + try { 1735 + // putRecord is like createRecord but with a specific rkey (upsert) 1736 + const result = await this.createRecord(body.collection, body.record, body.rkey) 1737 + const head = await this.state.storage.get('head') 1738 + const rev = await this.state.storage.get('rev') 1739 + return Response.json({ 1740 + uri: result.uri, 1741 + cid: result.cid, 1742 + commit: { cid: head, rev }, 1743 + validationStatus: 'valid' 1744 + }) 1745 + } catch (err) { 1746 + return errorResponse('InternalError', err.message, 500) 1747 + } 1748 + } 1749 + 1750 + async handleApplyWrites(request) { 1751 + const body = await request.json() 1752 + if (!body.writes || !Array.isArray(body.writes)) { 1753 + return errorResponse('InvalidRequest', 'missing writes array', 400) 1754 + } 1755 + try { 1756 + const results = [] 1757 + for (const write of body.writes) { 1758 + const type = write['$type'] 1759 + if (type === 'com.atproto.repo.applyWrites#create') { 1760 + const result = await this.createRecord(write.collection, write.value, write.rkey) 1761 + results.push({ 1762 + $type: 'com.atproto.repo.applyWrites#createResult', 1763 + uri: result.uri, 1764 + cid: result.cid, 1765 + validationStatus: 'valid' 1766 + }) 1767 + } else if (type === 'com.atproto.repo.applyWrites#update') { 1768 + const result = await this.createRecord(write.collection, write.value, write.rkey) 1769 + results.push({ 1770 + $type: 'com.atproto.repo.applyWrites#updateResult', 1771 + uri: result.uri, 1772 + cid: result.cid, 1773 + validationStatus: 'valid' 1774 + }) 1775 + } else if (type === 'com.atproto.repo.applyWrites#delete') { 1776 + await this.deleteRecord(write.collection, write.rkey) 1777 + results.push({ 1778 + $type: 'com.atproto.repo.applyWrites#deleteResult' 1779 + }) 1780 + } else { 1781 + return errorResponse('InvalidRequest', `Unknown write operation type: ${type}`, 400) 1782 + } 1783 + } 1784 + // Return commit info 1785 + const head = await this.state.storage.get('head') 1786 + const rev = await this.state.storage.get('rev') 1787 + return Response.json({ commit: { cid: head, rev }, results }) 1788 + } catch (err) { 1789 + return errorResponse('InternalError', err.message, 500) 1463 1790 } 1464 1791 } 1465 1792 ··· 1467 1794 const collection = url.searchParams.get('collection') 1468 1795 const rkey = url.searchParams.get('rkey') 1469 1796 if (!collection || !rkey) { 1470 - return Response.json({ error: 'missing collection or rkey' }, { status: 400 }) 1797 + return errorResponse('InvalidRequest', 'missing collection or rkey', 400) 1471 1798 } 1472 1799 const did = await this.getDid() 1473 1800 const uri = `at://${did}/${collection}/${rkey}` ··· 1475 1802 `SELECT cid, value FROM records WHERE uri = ?`, uri 1476 1803 ).toArray() 1477 1804 if (rows.length === 0) { 1478 - return Response.json({ error: 'record not found' }, { status: 404 }) 1805 + return errorResponse('RecordNotFound', 'record not found', 404) 1479 1806 } 1480 1807 const row = rows[0] 1481 1808 const value = cborDecode(new Uint8Array(row.value)) ··· 1485 1812 async handleDescribeRepo() { 1486 1813 const did = await this.getDid() 1487 1814 if (!did) { 1488 - return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1815 + return errorResponse('RepoNotFound', 'repo not found', 404) 1489 1816 } 1490 1817 const handle = await this.state.storage.get('handle') 1491 1818 // Get unique collections ··· 1505 1832 async handleListRecords(url) { 1506 1833 const collection = url.searchParams.get('collection') 1507 1834 if (!collection) { 1508 - return Response.json({ error: 'InvalidRequest', message: 'missing collection' }, { status: 400 }) 1835 + return errorResponse('InvalidRequest', 'missing collection', 400) 1509 1836 } 1510 1837 const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100) 1511 1838 const reverse = url.searchParams.get('reverse') === 'true' ··· 1534 1861 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1535 1862 ).toArray() 1536 1863 if (commits.length === 0) { 1537 - return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1864 + return errorResponse('RepoNotFound', 'repo not found', 404) 1538 1865 } 1539 1866 return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) 1540 1867 } ··· 1545 1872 `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1546 1873 ).toArray() 1547 1874 if (commits.length === 0 || !did) { 1548 - return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1875 + return errorResponse('RepoNotFound', 'repo not found', 404) 1549 1876 } 1550 1877 return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) 1551 1878 } ··· 1555 1882 `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1556 1883 ).toArray() 1557 1884 if (commits.length === 0) { 1558 - return Response.json({ error: 'repo not found' }, { status: 404 }) 1885 + return errorResponse('RepoNotFound', 'repo not found', 404) 1559 1886 } 1560 1887 1561 1888 // Only include blocks reachable from the current commit ··· 1625 1952 const collection = url.searchParams.get('collection') 1626 1953 const rkey = url.searchParams.get('rkey') 1627 1954 if (!collection || !rkey) { 1628 - return Response.json({ error: 'InvalidRequest', message: 'missing collection or rkey' }, { status: 400 }) 1955 + return errorResponse('InvalidRequest', 'missing collection or rkey', 400) 1629 1956 } 1630 1957 const did = await this.getDid() 1631 1958 const uri = `at://${did}/${collection}/${rkey}` ··· 1633 1960 `SELECT cid FROM records WHERE uri = ?`, uri 1634 1961 ).toArray() 1635 1962 if (rows.length === 0) { 1636 - return Response.json({ error: 'RecordNotFound', message: 'record not found' }, { status: 404 }) 1963 + return errorResponse('RecordNotFound', 'record not found', 404) 1637 1964 } 1638 1965 const recordCid = rows[0].cid 1639 1966 ··· 1642 1969 `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1643 1970 ).toArray() 1644 1971 if (commits.length === 0) { 1645 - return Response.json({ error: 'RepoNotFound', message: 'no commits' }, { status: 404 }) 1972 + return errorResponse('RepoNotFound', 'no commits', 404) 1646 1973 } 1647 1974 const commitCid = commits[0].cid 1648 1975 ··· 1714 2041 const url = new URL(request.url) 1715 2042 const route = pdsRoutes[url.pathname] 1716 2043 1717 - if (!route) { 1718 - return Response.json({ error: 'not found' }, { status: 404 }) 2044 + // Check for local route first 2045 + if (route) { 2046 + if (route.method && request.method !== route.method) { 2047 + return errorResponse('MethodNotAllowed', 'method not allowed', 405) 2048 + } 2049 + return route.handler(this, request, url) 1719 2050 } 1720 - if (route.method && request.method !== route.method) { 1721 - return Response.json({ error: 'method not allowed' }, { status: 405 }) 2051 + 2052 + // Handle app.bsky.* proxy requests (only if no local route) 2053 + if (url.pathname.startsWith('/xrpc/app.bsky.')) { 2054 + const userDid = request.headers.get('x-authed-did') 2055 + if (!userDid) { 2056 + return errorResponse('Unauthorized', 'Missing auth context', 401) 2057 + } 2058 + return this.handleAppViewProxy(request, userDid) 1722 2059 } 1723 - return route.handler(this, request, url) 2060 + 2061 + return errorResponse('NotFound', 'not found', 404) 1724 2062 } 1725 2063 } 1726 2064 1727 2065 const corsHeaders = { 1728 2066 'Access-Control-Allow-Origin': '*', 1729 2067 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 1730 - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, atproto-accept-labelers', 2068 + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, atproto-accept-labelers, atproto-proxy, x-bsky-topics', 1731 2069 } 1732 2070 1733 2071 function addCorsHeaders(response) { ··· 1771 2109 return null 1772 2110 } 1773 2111 2112 + /** 2113 + * Verify auth and return DID from token 2114 + * @param {Request} request - HTTP request with Authorization header 2115 + * @param {Object} env - Environment with JWT_SECRET 2116 + * @returns {Promise<{did: string} | {error: Response}>} DID or error response 2117 + */ 2118 + async function requireAuth(request, env) { 2119 + const authHeader = request.headers.get('Authorization') 2120 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 2121 + return { 2122 + error: Response.json({ 2123 + error: 'AuthenticationRequired', 2124 + message: 'Authentication required' 2125 + }, { status: 401 }) 2126 + } 2127 + } 2128 + 2129 + const token = authHeader.slice(7) 2130 + const jwtSecret = env?.JWT_SECRET 2131 + if (!jwtSecret) { 2132 + return { 2133 + error: Response.json({ 2134 + error: 'InternalServerError', 2135 + message: 'Server not configured for authentication' 2136 + }, { status: 500 }) 2137 + } 2138 + } 2139 + 2140 + try { 2141 + const payload = await verifyAccessJwt(token, jwtSecret) 2142 + return { did: payload.sub } 2143 + } catch (err) { 2144 + return { 2145 + error: Response.json({ 2146 + error: 'InvalidToken', 2147 + message: err.message 2148 + }, { status: 401 }) 2149 + } 2150 + } 2151 + } 2152 + 2153 + async function handleAuthenticatedRepoWrite(request, env) { 2154 + const auth = await requireAuth(request, env) 2155 + if (auth.error) return auth.error 2156 + 2157 + const body = await request.json() 2158 + const repo = body.repo 2159 + if (!repo) { 2160 + return errorResponse('InvalidRequest', 'missing repo param', 400) 2161 + } 2162 + 2163 + if (auth.did !== repo) { 2164 + return errorResponse('Forbidden', 'Cannot modify another user\'s repo', 403) 2165 + } 2166 + 2167 + const id = env.PDS.idFromName(repo) 2168 + const pds = env.PDS.get(id) 2169 + return pds.fetch(new Request(request.url, { 2170 + method: 'POST', 2171 + headers: request.headers, 2172 + body: JSON.stringify(body) 2173 + })) 2174 + } 2175 + 1774 2176 async function handleRequest(request, env) { 1775 2177 const url = new URL(request.url) 1776 2178 const subdomain = getSubdomain(url.hostname) ··· 1803 2205 return defaultPds.fetch(newReq) 1804 2206 } 1805 2207 2208 + // createSession - handle on default DO (has handleMap for identifier resolution) 2209 + if (url.pathname === '/xrpc/com.atproto.server.createSession') { 2210 + const defaultId = env.PDS.idFromName('default') 2211 + const defaultPds = env.PDS.get(defaultId) 2212 + return defaultPds.fetch(request) 2213 + } 2214 + 2215 + // getSession - route to default DO 2216 + if (url.pathname === '/xrpc/com.atproto.server.getSession') { 2217 + const defaultId = env.PDS.idFromName('default') 2218 + const defaultPds = env.PDS.get(defaultId) 2219 + return defaultPds.fetch(request) 2220 + } 2221 + 2222 + // Proxy app.bsky.* endpoints to Bluesky AppView 2223 + if (url.pathname.startsWith('/xrpc/app.bsky.')) { 2224 + // Authenticate the user first 2225 + const auth = await requireAuth(request, env) 2226 + if (auth.error) return auth.error 2227 + 2228 + // Route to the user's DO instance to create service auth and proxy 2229 + const id = env.PDS.idFromName(auth.did) 2230 + const pds = env.PDS.get(id) 2231 + return pds.fetch(new Request(request.url, { 2232 + method: request.method, 2233 + headers: { 2234 + ...Object.fromEntries(request.headers), 2235 + 'x-authed-did': auth.did // Pass the authenticated DID 2236 + }, 2237 + body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined, 2238 + })) 2239 + } 2240 + 1806 2241 // Handle registration routes - go to default DO 1807 2242 if (url.pathname === '/register-handle' || url.pathname === '/resolve-handle') { 1808 2243 const defaultId = env.PDS.idFromName('default') 1809 2244 const defaultPds = env.PDS.get(defaultId) 1810 2245 return defaultPds.fetch(request) 2246 + } 2247 + 2248 + // resolveHandle XRPC endpoint 2249 + if (url.pathname === '/xrpc/com.atproto.identity.resolveHandle') { 2250 + const handle = url.searchParams.get('handle') 2251 + if (!handle) { 2252 + return errorResponse('InvalidRequest', 'missing handle param', 400) 2253 + } 2254 + const defaultId = env.PDS.idFromName('default') 2255 + const defaultPds = env.PDS.get(defaultId) 2256 + const resolveRes = await defaultPds.fetch( 2257 + new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(handle)}`) 2258 + ) 2259 + if (!resolveRes.ok) { 2260 + return errorResponse('InvalidRequest', 'Unable to resolve handle', 400) 2261 + } 2262 + const { did } = await resolveRes.json() 2263 + return Response.json({ did }) 1811 2264 } 1812 2265 1813 2266 // subscribeRepos WebSocket - route to default instance for firehose ··· 1843 2296 url.pathname === '/xrpc/com.atproto.repo.getRecord') { 1844 2297 const repo = url.searchParams.get('repo') 1845 2298 if (!repo) { 1846 - return Response.json({ error: 'InvalidRequest', message: 'missing repo param' }, { status: 400 }) 2299 + return errorResponse('InvalidRequest', 'missing repo param', 400) 1847 2300 } 1848 2301 const id = env.PDS.idFromName(repo) 1849 2302 const pds = env.PDS.get(id) 1850 2303 return pds.fetch(request) 1851 2304 } 1852 2305 1853 - // POST repo endpoints have repo in body 1854 - if (url.pathname === '/xrpc/com.atproto.repo.deleteRecord' || 1855 - url.pathname === '/xrpc/com.atproto.repo.createRecord') { 1856 - const body = await request.json() 1857 - const repo = body.repo 1858 - if (!repo) { 1859 - return Response.json({ error: 'InvalidRequest', message: 'missing repo param' }, { status: 400 }) 1860 - } 1861 - const id = env.PDS.idFromName(repo) 1862 - const pds = env.PDS.get(id) 1863 - // Re-create request with body since we consumed it 1864 - return pds.fetch(new Request(request.url, { 1865 - method: 'POST', 1866 - headers: request.headers, 1867 - body: JSON.stringify(body) 1868 - })) 2306 + // Authenticated repo write endpoints 2307 + const repoWriteEndpoints = [ 2308 + '/xrpc/com.atproto.repo.createRecord', 2309 + '/xrpc/com.atproto.repo.deleteRecord', 2310 + '/xrpc/com.atproto.repo.putRecord', 2311 + '/xrpc/com.atproto.repo.applyWrites' 2312 + ] 2313 + if (repoWriteEndpoints.includes(url.pathname)) { 2314 + return handleAuthenticatedRepoWrite(request, env) 1869 2315 } 1870 2316 1871 - const did = url.searchParams.get('did') 1872 - if (!did) { 1873 - return new Response('missing did param', { status: 400 }) 2317 + // Root path - ASCII art 2318 + if (url.pathname === '/') { 2319 + const ascii = ` 2320 + ██████╗ ██████╗ ███████╗ ██╗ ███████╗ 2321 + ██╔══██╗ ██╔══██╗ ██╔════╝ ██║ ██╔════╝ 2322 + ██████╔╝ ██║ ██║ ███████╗ ██║ ███████╗ 2323 + ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║ 2324 + ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║ 2325 + ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 2326 + ` 2327 + return new Response(ascii, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) 1874 2328 } 1875 2329 1876 - // On init, also register this DID with the default instance 2330 + // On init, register this DID with the default instance (requires ?did= param, no auth yet) 1877 2331 if (url.pathname === '/init' && request.method === 'POST') { 2332 + const did = url.searchParams.get('did') 2333 + if (!did) { 2334 + return errorResponse('InvalidRequest', 'missing did param', 400) 2335 + } 1878 2336 const body = await request.json() 1879 2337 1880 2338 // Register with default instance for discovery ··· 1895 2353 })) 1896 2354 } 1897 2355 1898 - const id = env.PDS.idFromName(did) 1899 - const pds = env.PDS.get(id) 1900 - return pds.fetch(request) 2356 + // Unknown endpoint 2357 + return errorResponse('NotFound', 'Endpoint not found', 404) 1901 2358 }
+110 -1
test/pds.test.js
··· 3 3 import { 4 4 cborEncode, cborDecode, createCid, cidToString, cidToBytes, base32Encode, createTid, 5 5 generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes, 6 - getKeyDepth, varint, base32Decode, buildCarFile 6 + getKeyDepth, varint, base32Decode, buildCarFile, 7 + base64UrlEncode, base64UrlDecode, 8 + createAccessJwt, createRefreshJwt, verifyAccessJwt 7 9 } from '../src/pds.js' 8 10 9 11 describe('CBOR Encoding', () => { ··· 365 367 assert.ok(car[0] > 0) 366 368 }) 367 369 }) 370 + 371 + describe('JWT Base64URL', () => { 372 + test('base64UrlEncode encodes bytes correctly', () => { 373 + const input = new TextEncoder().encode('hello world') 374 + const encoded = base64UrlEncode(input) 375 + assert.strictEqual(encoded, 'aGVsbG8gd29ybGQ') 376 + assert.ok(!encoded.includes('+')) 377 + assert.ok(!encoded.includes('/')) 378 + assert.ok(!encoded.includes('=')) 379 + }) 380 + 381 + test('base64UrlDecode decodes string correctly', () => { 382 + const decoded = base64UrlDecode('aGVsbG8gd29ybGQ') 383 + const str = new TextDecoder().decode(decoded) 384 + assert.strictEqual(str, 'hello world') 385 + }) 386 + 387 + test('base64url roundtrip', () => { 388 + const original = new Uint8Array([0, 1, 2, 255, 254, 253]) 389 + const encoded = base64UrlEncode(original) 390 + const decoded = base64UrlDecode(encoded) 391 + assert.deepStrictEqual(decoded, original) 392 + }) 393 + }) 394 + 395 + describe('JWT Creation', () => { 396 + test('createAccessJwt creates valid JWT structure', async () => { 397 + const did = 'did:web:test.example' 398 + const secret = 'test-secret-key' 399 + const jwt = await createAccessJwt(did, secret) 400 + 401 + const parts = jwt.split('.') 402 + assert.strictEqual(parts.length, 3) 403 + 404 + // Decode header 405 + const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) 406 + assert.strictEqual(header.typ, 'at+jwt') 407 + assert.strictEqual(header.alg, 'HS256') 408 + 409 + // Decode payload 410 + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) 411 + assert.strictEqual(payload.scope, 'com.atproto.access') 412 + assert.strictEqual(payload.sub, did) 413 + assert.strictEqual(payload.aud, did) 414 + assert.ok(payload.iat > 0) 415 + assert.ok(payload.exp > payload.iat) 416 + }) 417 + 418 + test('createRefreshJwt creates valid JWT with jti', async () => { 419 + const did = 'did:web:test.example' 420 + const secret = 'test-secret-key' 421 + const jwt = await createRefreshJwt(did, secret) 422 + 423 + const parts = jwt.split('.') 424 + const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) 425 + assert.strictEqual(header.typ, 'refresh+jwt') 426 + 427 + const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) 428 + assert.strictEqual(payload.scope, 'com.atproto.refresh') 429 + assert.ok(payload.jti) // has unique token ID 430 + }) 431 + }) 432 + 433 + describe('JWT Verification', () => { 434 + test('verifyAccessJwt returns payload for valid token', async () => { 435 + const did = 'did:web:test.example' 436 + const secret = 'test-secret-key' 437 + const jwt = await createAccessJwt(did, secret) 438 + 439 + const payload = await verifyAccessJwt(jwt, secret) 440 + assert.strictEqual(payload.sub, did) 441 + assert.strictEqual(payload.scope, 'com.atproto.access') 442 + }) 443 + 444 + test('verifyAccessJwt throws for wrong secret', async () => { 445 + const did = 'did:web:test.example' 446 + const jwt = await createAccessJwt(did, 'correct-secret') 447 + 448 + await assert.rejects( 449 + () => verifyAccessJwt(jwt, 'wrong-secret'), 450 + /invalid signature/i 451 + ) 452 + }) 453 + 454 + test('verifyAccessJwt throws for expired token', async () => { 455 + const did = 'did:web:test.example' 456 + const secret = 'test-secret-key' 457 + // Create token that expired 1 second ago 458 + const jwt = await createAccessJwt(did, secret, -1) 459 + 460 + await assert.rejects( 461 + () => verifyAccessJwt(jwt, secret), 462 + /expired/i 463 + ) 464 + }) 465 + 466 + test('verifyAccessJwt throws for refresh token', async () => { 467 + const did = 'did:web:test.example' 468 + const secret = 'test-secret-key' 469 + const jwt = await createRefreshJwt(did, secret) 470 + 471 + await assert.rejects( 472 + () => verifyAccessJwt(jwt, secret), 473 + /invalid token type/i 474 + ) 475 + }) 476 + })